diff --git a/blogs/AGENTS.md b/blogs/AGENTS.md
index 74ce3c5da..a10f6e6e2 100644
--- a/blogs/AGENTS.md
+++ b/blogs/AGENTS.md
@@ -14,11 +14,13 @@ title: "Tuning Reth for payments: how we hit 21,200 TPS"
excerpt: "One or two sentences shown on the index and as the post's lede."
date: 2026-06-02
category: technical # network-upgrades | events | technical | case-studies
+# Use an inline list when a post belongs to multiple categories:
+# category: [technical, case-studies]
featured: true # optional — pins the post to the hero card on /blog
---
```
-`category` must be one of the four slugs above (the build fails loudly otherwise). At most one post should be `featured`; if none is, the newest post takes the hero card.
+`category` must be one of the four slugs above or an inline list of those slugs (the build fails loudly otherwise). At most one post should be `featured`; if none is, the newest post takes the hero card.
## Body
diff --git a/blogs/human-authorization-in-agentic-workflows.md b/blogs/human-authorization-in-agentic-workflows.md
index 83d4f8451..f3d992366 100644
--- a/blogs/human-authorization-in-agentic-workflows.md
+++ b/blogs/human-authorization-in-agentic-workflows.md
@@ -2,7 +2,7 @@
title: "Human Authorization in Agentic Workflows"
excerpt: "How Tempo enables AI-accelerated development securely"
date: 2026-08-17
-category: case-studies
+category: [technical, case-studies]
---
At Tempo, we are building more workflows where software can move quickly on a person's behalf. That is useful, but it can also be risky.
diff --git a/src/marketing/app/blog/[slug]/page.tsx b/src/marketing/app/blog/[slug]/page.tsx
index 3c5ed6a31..0af87806b 100644
--- a/src/marketing/app/blog/[slug]/page.tsx
+++ b/src/marketing/app/blog/[slug]/page.tsx
@@ -48,9 +48,14 @@ export default function BlogPostPage({ params }: { params: { slug: string } }) {
-
- {categoryBySlug(post.category).badge}
-
+ {post.categories.map((category) => (
+
+ {categoryBySlug(category).badge}
+
+ ))}
{formatDate(post.date)}
diff --git a/src/marketing/app/blog/_components/PostExplorer.tsx b/src/marketing/app/blog/_components/PostExplorer.tsx
index 160794d42..84b1d204f 100644
--- a/src/marketing/app/blog/_components/PostExplorer.tsx
+++ b/src/marketing/app/blog/_components/PostExplorer.tsx
@@ -12,7 +12,7 @@ type Filter = (typeof filters)[number]['slug']
export default function PostExplorer({ posts }: { posts: PostMeta[] }) {
const [active, setActive] = useState('all')
- const visible = active === 'all' ? posts : posts.filter((p) => p.category === active)
+ const visible = active === 'all' ? posts : posts.filter((p) => p.categories.includes(active))
return (
@@ -48,9 +48,14 @@ export default function PostExplorer({ posts }: { posts: PostMeta[] }) {
className="group flex flex-col gap-2.5 border-line border-b px-5 py-6 transition-colors hover:bg-surface-block lg:px-8"
>
-
- {categoryBySlug(post.category).badge}
-
+ {post.categories.map((category) => (
+
+ {categoryBySlug(category).badge}
+
+ ))}
{isNew(post.date) && (
New
diff --git a/src/marketing/app/blog/_lib/categories.ts b/src/marketing/app/blog/_lib/categories.ts
index 618f7972a..e4cbd2d2f 100644
--- a/src/marketing/app/blog/_lib/categories.ts
+++ b/src/marketing/app/blog/_lib/categories.ts
@@ -17,6 +17,7 @@ export type PostMeta = {
excerpt: string
date: string
category: CategorySlug
+ categories: CategorySlug[]
authors: string
featured: boolean
}
diff --git a/src/marketing/app/blog/_lib/posts.ts b/src/marketing/app/blog/_lib/posts.ts
index e85edb741..23a3239c5 100644
--- a/src/marketing/app/blog/_lib/posts.ts
+++ b/src/marketing/app/blog/_lib/posts.ts
@@ -10,6 +10,7 @@ export type Post = PostMeta & { html: string }
const posts: Post[] = renderedPosts.map((post) => ({
...post,
category: post.category as CategorySlug,
+ categories: post.categories as CategorySlug[],
}))
export function getAllPosts(): Post[] {
diff --git a/src/marketing/app/blog/page.tsx b/src/marketing/app/blog/page.tsx
index 62bdc19c9..bbfde132a 100644
--- a/src/marketing/app/blog/page.tsx
+++ b/src/marketing/app/blog/page.tsx
@@ -17,6 +17,7 @@ export default function BlogPage() {
excerpt: post.excerpt,
date: post.date,
category: post.category,
+ categories: post.categories,
authors: post.authors,
featured: post.featured,
}))
diff --git a/src/marketing/blogPlugin.ts b/src/marketing/blogPlugin.ts
index f66229093..b6598e39f 100644
--- a/src/marketing/blogPlugin.ts
+++ b/src/marketing/blogPlugin.ts
@@ -10,7 +10,8 @@ import { unified } from 'unified'
import type { Plugin } from 'vite'
// Blog content lives as dev-managed markdown files in /blogs at the repo root.
-// Frontmatter schema: title, excerpt, date (YYYY-MM-DD), category, optional
+// Frontmatter schema: title, excerpt, date (YYYY-MM-DD), category (a slug or
+// inline list of slugs), optional
// authors, and an optional `featured: true` to pin a post to the hero card.
//
// Markdown is rendered to HTML here, in Node, at build/dev time, so the heavy
@@ -66,6 +67,7 @@ export type RenderedPost = {
excerpt: string
date: string
category: string
+ categories: string[]
authors: string
featured: boolean
html: string
@@ -97,8 +99,9 @@ const processor = unified()
.use(rehypeStringify)
// Minimal frontmatter parser for our simple schema (quoted strings, an
-// unquoted YYYY-MM-DD date, a category slug, and an optional boolean). Avoids
-// pulling in gray-matter + js-yaml for a handful of known keys.
+// unquoted YYYY-MM-DD date, a category slug or inline list of slugs, and an
+// optional boolean). Avoids pulling in gray-matter + js-yaml for a handful of
+// known keys.
function parseFrontmatter(raw: string): { data: Record; content: string } {
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(raw)
if (!match) return { data: {}, content: raw }
@@ -123,14 +126,24 @@ function parseFrontmatter(raw: string): { data: Record; content:
return { data, content: match[2] }
}
+function parseCategories(value: string): string[] {
+ const list = value.startsWith('[') && value.endsWith(']') ? value.slice(1, -1) : value
+ return list
+ .split(',')
+ .map((category) => category.trim().replace(/^(['"])(.*)\1$/, '$2'))
+ .filter(Boolean)
+}
+
async function renderPost(filename: string): Promise {
const slug = filename.replace(/\.md$/, '')
const raw = fs.readFileSync(path.join(BLOGS_DIR, filename), 'utf8')
const { data, content } = parseFrontmatter(raw)
- if (!CATEGORY_SLUGS.includes(data.category)) {
+ const categories = [...new Set(parseCategories(data.category ?? ''))]
+ const unknownCategories = categories.filter((category) => !CATEGORY_SLUGS.includes(category))
+ if (categories.length === 0 || unknownCategories.length > 0) {
throw new Error(
- `blogs/${filename}: unknown category "${data.category}". ` +
+ `blogs/${filename}: unknown category "${unknownCategories[0] ?? data.category}". ` +
`Expected one of: ${CATEGORY_SLUGS.join(', ')}`,
)
}
@@ -142,7 +155,8 @@ async function renderPost(filename: string): Promise {
title: data.title,
excerpt: data.excerpt,
date: data.date,
- category: data.category,
+ category: categories[0],
+ categories,
authors: data.authors ?? '',
featured: data.featured === 'true',
html,
diff --git a/src/marketing/next.d.ts b/src/marketing/next.d.ts
index dff63aada..be7f48b3c 100644
--- a/src/marketing/next.d.ts
+++ b/src/marketing/next.d.ts
@@ -9,6 +9,7 @@ declare module 'virtual:blog-posts' {
excerpt: string
date: string
category: string
+ categories: string[]
authors: string
featured: boolean
html: string