]*?)\/>/g;
-function inlineCodeSnippets(body: string, options: ConvertOptions) {
+export function inlineCodeSnippets(body: string, options: ConvertOptions) {
return body.replace(CODE_SNIPPET, (_match, rawAttrs: string) => {
const file = attributeValue(rawAttrs, "file");
if (!file) return "";
diff --git a/src/pages/[product]/[tab]/[...slug].astro b/src/pages/[product]/[tab]/[...slug].astro
index 0c13e8eb..75f0c45c 100644
--- a/src/pages/[product]/[tab]/[...slug].astro
+++ b/src/pages/[product]/[tab]/[...slug].astro
@@ -4,10 +4,12 @@ import { getCollection } from 'astro:content';
import DocsArticlePage from '@/components/docs/DocsArticlePage.astro';
import { getContentParamSlug } from '@/lib/content-path';
import { products } from '@/sitemap/products';
+import { AI_DOCS_ITEMS } from '@/sitemap/ai';
import { getRouteSeoPolicy, robotsDirective } from '@/lib/routeSeoPolicy';
export async function getStaticPaths() {
const entries = await getCollection('docs');
+ const entriesById = new Map(entries.map((entry) => [entry.id, entry]));
const paths = [];
for (const product of products) {
@@ -39,13 +41,56 @@ export async function getStaticPaths() {
},
});
}
+
+ // Shared AI documentation stays inside the active product's full docs
+ // shell. Actors already owns the two canonical routes, so only the other
+ // products need contextual aliases.
+ if (tab.id === 'docs' && product.id !== 'actors') {
+ for (const item of AI_DOCS_ITEMS) {
+ const entry = entriesById.get(item.contentId);
+ if (!entry) {
+ throw new Error(`Missing AI documentation entry: ${item.contentId}`);
+ }
+ const routeSlugOverride = `ai/${item.id}`;
+ paths.push({
+ params: {
+ product: product.id,
+ tab: tab.id,
+ slug: routeSlugOverride,
+ },
+ props: {
+ entry,
+ productId: product.id,
+ tabId: tab.id,
+ productName: product.name,
+ tabTitle: tab.title,
+ routeSlugOverride,
+ canonicalUrl: `https://rivet.dev${item.href}`,
+ markdownPathOverride: item.markdownPath,
+ editUrlOverride: item.editUrl,
+ exactTitle: item.id === 'mcp',
+ },
+ });
+ }
+ }
}
}
return paths;
}
-const { entry, productId, tabId, productName, tabTitle } = Astro.props;
+const {
+ entry,
+ productId,
+ tabId,
+ productName,
+ tabTitle,
+ routeSlugOverride,
+ canonicalUrl,
+ markdownPathOverride,
+ editUrlOverride,
+ exactTitle,
+} = Astro.props;
const robots = robotsDirective(getRouteSeoPolicy(Astro.url));
---
@@ -57,5 +102,10 @@ const robots = robotsDirective(getRouteSeoPolicy(Astro.url));
productId={productId}
tabId={tabId}
seoContextLabel={`${productName} ${tabTitle}`}
+ routeSlugOverride={routeSlugOverride}
+ canonicalUrl={canonicalUrl}
+ markdownPathOverride={markdownPathOverride}
+ editUrlOverride={editUrlOverride}
+ exactTitle={exactTitle}
robots={robots}
/>
diff --git a/src/pages/docs/[...slug].astro b/src/pages/docs/[...slug].astro
new file mode 100644
index 00000000..0e0b2135
--- /dev/null
+++ b/src/pages/docs/[...slug].astro
@@ -0,0 +1,43 @@
+---
+// Website-owned documentation: /docs/{...slug}. Product documentation keeps
+// its own /{product}/{tab}/{...slug} route.
+import { getCollection } from 'astro:content';
+import DocsArticlePage from '@/components/docs/DocsArticlePage.astro';
+import { getContentParamSlug } from '@/lib/content-path';
+import { getRouteSeoPolicy, robotsDirective } from '@/lib/routeSeoPolicy';
+import { SITE_DOCS_NAMESPACE } from '@/sitemap/docs-sources';
+
+export async function getStaticPaths() {
+ const entries = await getCollection('docs');
+ const entryPrefix = `${SITE_DOCS_NAMESPACE}/`;
+
+ return entries
+ .filter((entry) => entry.id.startsWith(entryPrefix))
+ .map((entry) => ({
+ params: {
+ slug: getContentParamSlug(entry.id.slice(entryPrefix.length)),
+ },
+ props: { entry },
+ }));
+}
+
+const { entry } = Astro.props;
+const entryPrefix = `${SITE_DOCS_NAMESPACE}/`;
+const robots = robotsDirective(getRouteSeoPolicy(Astro.url));
+const isMcp = entry.id === 'docs/mcp';
+---
+
+
diff --git a/src/pages/docs/index.astro b/src/pages/docs/index.astro
index 7e23d8bb..e06f5c2b 100644
--- a/src/pages/docs/index.astro
+++ b/src/pages/docs/index.astro
@@ -8,14 +8,15 @@ import { DocsLanding } from '@/components/docs/DocsLanding';
import { visibleProducts as products } from '@/sitemap/products';
import { productLogos } from '@/sitemap/productLogos';
import { productAccent } from '@/lib/product-accent';
-import { faSquareInfo } from '@rivet-gg/icons';
+import { AI_DOCS_ITEMS } from '@/sitemap/ai';
+import { faPlug, faSparkles, faSquareInfo } from '@rivet-gg/icons';
const pathname = Astro.url.pathname;
const landing = {
title: 'Documentation',
subtitle:
- 'Four products on one runtime. Pick the one you are building with.',
+ 'Choose a product, or connect your AI client to Rivet.',
// Two columns: four products in a three-up grid leaves a ragged 3 + 1.
columns: 2 as const,
sections: [
@@ -34,6 +35,13 @@ const landing = {
};
}),
},
+ {
+ title: 'AI Tools',
+ items: AI_DOCS_ITEMS.map((item) => ({
+ ...item,
+ icon: item.id === 'mcp' ? faPlug : faSparkles,
+ })),
+ },
],
};
---
diff --git a/src/pages/mcp.astro b/src/pages/mcp.astro
deleted file mode 100644
index 463e981d..00000000
--- a/src/pages/mcp.astro
+++ /dev/null
@@ -1,431 +0,0 @@
----
-// MCP landing page. Served on its own host (mcp.rivet.dev) via a redirect, so
-// it carries no product bar.
-import * as shiki from 'shiki';
-import codeTheme from '@/lib/textmate-code-theme';
-import MarketingLayout from '@/layouts/MarketingLayout.astro';
-import { ClosingCtaPanel } from '@/components/marketing/ClosingCtaPanel';
-import { SectionRule } from '@/components/marketing/SectionRule';
-import { InkPanel, InkChip } from '@/components/marketing/editorial/InkPanel';
-import { Icon, faClaude, faOpenai, faCursor, faGemini, faLaptopCode, faVscode } from '@rivet-gg/icons';
-import {
- BODY_CLASS,
- CARD_TITLE_CLASS,
- INK_PANEL_GHOST_BUTTON_CLASS,
- INK_PANEL_LIGHT_BUTTON_CLASS,
- PRODUCT_HERO_CTA_ROW_CLASS,
- PRODUCT_HERO_H1_CLASS,
- PRODUCT_HERO_INNER_CLASS,
- PRODUCT_HERO_PRIMARY_BUTTON_CLASS,
- PRODUCT_HERO_SECONDARY_BUTTON_CLASS,
- PRODUCT_HERO_SECTION_CLASS,
- PRODUCT_HERO_SUBTITLE_CLASS,
- SECTION_H2_CLASS,
- SECTION_LEDE_CLASS,
-} from '@/components/marketing/typography';
-import {
- SITE_CARD_CLASS,
- SITE_SECTION_CLASS,
-} from '@/components/marketing/layout';
-
-const ENDPOINT = 'https://mcp.rivet.dev/mcp';
-
-const clients = [
- {
- name: 'Claude Code',
- icon: faClaude,
- command: `claude mcp add --transport http rivet ${ENDPOINT}`,
- },
- { name: 'Codex', icon: faOpenai, command: `codex mcp add rivet --url ${ENDPOINT}` },
- { name: 'Cursor', icon: faCursor, config: 'remote' },
- {
- name: 'Gemini CLI',
- icon: faGemini,
- command: `gemini mcp add --transport http rivet ${ENDPOINT}`,
- },
- {
- name: 'VS Code',
- icon: faVscode,
- command: `code --add-mcp '{"name":"rivet","type":"http","url":"${ENDPOINT}"}'`,
- },
- {
- name: 'Local',
- icon: faLaptopCode,
- config: 'local',
- note: 'Talk to the Rivet already running on your machine. No URL and no sign-in.',
- },
-];
-
-const scopes = [
- { url: ENDPOINT, label: 'Your agent asks which one, and remembers it' },
- { url: `${ENDPOINT}?organization=ORG`, label: 'Held inside one organization' },
- {
- url: `${ENDPOINT}?organization=ORG&project=PROJECT`,
- label: 'Held inside one project',
- },
- {
- url: `${ENDPOINT}?organization=ORG&project=PROJECT&namespace=NS`,
- label: 'Held inside one namespace',
- },
-];
-
-const tools = [
- {
- name: 'search',
- title: 'It looks up what it can do',
- body: 'Your agent asks what is available in the namespace it is connected to, and gets back the handful of operations that actually apply.',
- },
- {
- name: 'execute',
- title: 'Then it writes the code',
- body: 'Ordinary JavaScript, so listing your actors, picking the right one, and calling it happen in a single step instead of six round trips.',
- },
-];
-
-const limits = [
- {
- title: 'It cannot reach anything else',
- body: 'Code runs with no network, no environment variables, no files, and no way to start a process.',
- },
- {
- title: 'Every call goes through Rivet',
- body: 'Requests only leave through Rivet, which checks them against what you approved and attaches your credentials for you.',
- },
- {
- title: 'Only the access you grant',
- body: 'A hosted connection carries what you gave it and nothing more, and it expires after 15 minutes. Your credentials stay on Rivet and never reach the model.',
- },
-];
-
-const CODE_MODE_EXAMPLE = `// "Which chat rooms are still awake, and what has Acme been saying?"
-const { actors } = await rivet.actors.list({ name: "chat-room" });
-const awake = actors.filter((room) => room.status === "running");
-
-const history = await rivet.actor.action({
- actor: { name: "chat-room", key: ["acme"] },
- name: "getHistory",
-});
-
-return { awake: awake.length, history };`;
-
-const REMOTE_CONFIG = `{
- "mcpServers": {
- "rivet": {
- "url": "${ENDPOINT}"
- }
- }
-}`;
-
-const LOCAL_CONFIG = `{
- "mcpServers": {
- "rivet": {
- "command": "npx",
- "args": ["-y", "@rivet-dev/mcp", "--target", "local"]
- }
- }
-}`;
-
-// Highlighted at build time with the same theme the documentation uses, so
-// code reads identically on both surfaces.
-const highlighter = await shiki.getSingletonHighlighter({
- langs: ['typescript', 'json'],
- themes: [codeTheme],
-});
-const highlight = (code: string, lang: string) =>
- highlighter.codeToHtml(code, { lang, theme: codeTheme.name });
-
-const codeModeHtml = highlight(CODE_MODE_EXAMPLE, 'typescript');
-const remoteConfigHtml = highlight(REMOTE_CONFIG, 'json');
-const localConfigHtml = highlight(LOCAL_CONFIG, 'json');
-
-// The documentation code plate, minus its filename bar and copy button.
-const CODE_CARD_CLASS =
- 'not-prose relative overflow-hidden rounded-xl border border-ink/10';
-const CODE_SCROLL_CLASS = 'overflow-x-auto bg-white text-sm';
-const CODE_INNER_CLASS = 'w-fit min-w-full p-4';
-const CODE_SHIKI_CLASS = 'not-prose code [&_pre]:!m-0 [&_pre]:!bg-transparent [&_pre]:!p-0';
----
-
-
-
-
-
-
-
- Ask your agent what your backend is doing
-
-
- Connect Claude Code, Codex, Cursor, or any MCP client. It reads your
- actors, calls them for you, and stays in a sandbox scoped to the access
- you grant it.
-
-
-
-
-
-
-
-
-
-
-
-
Two tools instead of forty
-
- Most MCP servers hand your agent a wall of tools and hope it picks the right
- one. Rivet gives it two: one to find out what is there, one to go do it.
-
-
-
-
- {tools.map((tool) => (
-
-
-
- {tool.title}
- {tool.name}
-
- - {tool.body}
-
- ))}
-
-
-
-
- -
-
-
You ask
-
- “Which chat rooms are still awake, and what has Acme been saying?”
-
-
-
- -
-
-
- Your agent looks up what it can do
- search
-
-
- {['rivet.actors.list', 'rivet.actor.action'].map((op) => (
-
- {op}
-
- ))}
-
-
-
- -
-
-
- It writes one script and runs it
- execute
-
- You never write this, and never see it unless you ask.
-
-
-
- -
-
-
One answer comes back
-
- 3 rooms awake, 128 messages from Acme
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Model-written code, held at arm's length
-
-
-
- {limits.map((limit) => (
-
-
- {limit.title}
- - {limit.body}
-
- ))}
-
-
-
-
-
-
-
-
-
-
The Inspector, inline
-
- Ask your agent about an actor and the Rivet Inspector opens inside the
- conversation. Its state, who is connected, what is queued: the same view
- you would open in the dashboard, without leaving the chat.
-
-
- You see the one actor you asked about and nothing else, and it goes away
- when the conversation does. Clients that cannot draw it get the same
- details written out.
-
-
-
-
-
-
-
chat-room · acme
-
- {['State', 'Connections', 'Queue', 'Workflow', 'Database'].map((tab) => (
-
- {tab}
- available
-
- ))}
-
-
-
-
-
-
-
-
-
-
- Add it to your client
- Talk to an engineer
-
-
-
-
-
diff --git a/src/pages/metadata/docs.json.ts b/src/pages/metadata/docs.json.ts
index 36581ea2..b81ac40e 100644
--- a/src/pages/metadata/docs.json.ts
+++ b/src/pages/metadata/docs.json.ts
@@ -10,6 +10,8 @@ import {
PROJECT_ROOT,
SITE_BASE_URL,
} from "../../metadata/shared";
+import { inlineCodeSnippets } from "../../metadata/mdx-to-markdown";
+import { snippetRootForContentPath } from "../../sitemap/docs-sources.node";
const CURATED_LIMIT = 50;
@@ -46,10 +48,24 @@ async function buildMetadata(): Promise
{
const canonicalUrl = `${SITE_BASE_URL}${canonicalPath}`;
const tags = slug.split("/").filter(Boolean);
const productArea = tags[0] ?? null;
- const body = entry.body ?? "";
- const headings = ensureHeadings(extractHeadings(body), entry.data.title);
+ const sourceBody = entry.body ?? "";
+ const expandSnippets = (content: string) =>
+ inlineCodeSnippets(content, {
+ snippetRoot: snippetRootForContentPath(entry.filePath),
+ sourceLabel: slug,
+ });
+ const body = expandSnippets(sourceBody);
+ const headings = ensureHeadings(extractHeadings(sourceBody), entry.data.title);
const updatedAt = await getUpdatedAt(entry.filePath);
- const sectionRecords = splitSections(body, headings, resourceUri, canonicalUrl, updatedAt, canonicalPath);
+ const sectionRecords = splitSections(
+ sourceBody,
+ headings,
+ resourceUri,
+ canonicalUrl,
+ updatedAt,
+ canonicalPath,
+ expandSnippets,
+ );
const plaintext = toPlainText(body);
pages.push({
@@ -162,6 +178,7 @@ function splitSections(
canonicalUrl: string,
updatedAt: string,
canonicalPath: string,
+ expandSnippets: (content: string) => string,
) {
const lines = body.split(/\r?\n/);
const records: SectionRecord[] = [];
@@ -169,7 +186,7 @@ function splitSections(
for (const heading of headings) {
const start = Math.max(heading.startLine - 1, 0);
const end = typeof heading.endLine === "number" ? heading.endLine : lines.length;
- const content = lines.slice(start, end).join("\n").trim();
+ const content = expandSnippets(lines.slice(start, end).join("\n")).trim();
const snippet = toSnippet(content);
const uri = `${resourceUri}#section=${heading.anchor}`;
diff --git a/src/sitemap/ai.ts b/src/sitemap/ai.ts
new file mode 100644
index 00000000..836362d9
--- /dev/null
+++ b/src/sitemap/ai.ts
@@ -0,0 +1,79 @@
+import type { SidebarItem } from "@/lib/sitemap";
+
+export const AI_DOCS_ITEMS = [
+ {
+ id: "skills",
+ title: "Skills",
+ href: "/actors/docs/general/skill/",
+ contentId: "actors/docs/general/skill",
+ markdownPath: "actors/docs/general/skill",
+ editUrl:
+ "https://github.com/rivet-dev/rivet/edit/main/docs/content/docs/general/skill.mdx",
+ description:
+ "Install Rivet guidance and implementation patterns in supported AI coding assistants.",
+ },
+ {
+ id: "mcp",
+ title: "MCP",
+ href: "/docs/mcp/",
+ contentId: "docs/mcp",
+ markdownPath: "docs/mcp",
+ editUrl:
+ "https://github.com/rivet-dev/website/edit/main/src/content/docs/docs/mcp.mdx",
+ description:
+ "Connect Claude Code, Codex, Cursor, and other AI clients to Rivet.",
+ },
+] as const;
+
+export type AiDocsItem = (typeof AI_DOCS_ITEMS)[number];
+
+/**
+ * Keep shared AI documentation inside the current product's docs shell. Actors
+ * owns the canonical Skills page and is the default shell for the canonical MCP
+ * page; every other product gets a noncanonical contextual alias.
+ */
+export function aiDocsHref(item: AiDocsItem, productId?: string): string {
+ if (!productId || productId === "actors") return item.href;
+ return `/${productId}/docs/ai/${item.id}/`;
+}
+
+export function aiSidebarSection(productId?: string): SidebarItem {
+ return {
+ title: "AI Tools",
+ pages: AI_DOCS_ITEMS.map((item) => ({
+ title: item.title,
+ href: aiDocsHref(item, productId),
+ })),
+ };
+}
+
+/**
+ * Add the site-wide AI links to a rendered Documentation sidebar without
+ * making those links part of every product's route-owning sitemap.
+ */
+export function withAiSidebarSection(
+ sidebar: readonly SidebarItem[],
+ productId?: string,
+): SidebarItem[] {
+ const normalized = sidebar
+ .filter((item) => item.title !== "AI" && item.title !== "AI Tools")
+ .map((item) => {
+ if (!("pages" in item)) return item;
+ return {
+ ...item,
+ pages: item.pages.filter(
+ (page) => !("title" in page) || page.title !== "AI Integration",
+ ),
+ };
+ });
+ const referenceIndex = normalized.findIndex(
+ (item) => item.title === "Reference",
+ );
+ const insertAt = referenceIndex === -1 ? normalized.length : referenceIndex;
+
+ return [
+ ...normalized.slice(0, insertAt),
+ aiSidebarSection(productId),
+ ...normalized.slice(insertAt),
+ ];
+}
diff --git a/src/sitemap/docs-sources.node.ts b/src/sitemap/docs-sources.node.ts
index 97b48cd4..c2d2a831 100644
--- a/src/sitemap/docs-sources.node.ts
+++ b/src/sitemap/docs-sources.node.ts
@@ -22,6 +22,7 @@ import { fileURLToPath } from "node:url";
import {
DOCS_SOURCES,
SHARED_CONTENT_PRODUCT,
+ SITE_DOCS_NAMESPACE,
productFromPath,
} from "./docs-sources";
@@ -104,20 +105,29 @@ export function snippetRootForContentPath(
contentPath: string | undefined,
): string | undefined {
if (contentPath) {
+ // Website-owned global docs use the website repo as their snippet root.
+ // Resolve their explicit collection namespace before matching real repo
+ // roots: the website root contains unrelated content that must keep falling
+ // back to the Actors examples.
+ const namespacedSource = productFromPath(contentPath);
+ if (namespacedSource === SITE_DOCS_NAMESPACE) {
+ return snippetRoot(SITE_DOCS_NAMESPACE);
+ }
+
// Product docs are symlinked in, and Vite reports the resolved realpath
// (`/home/me/agentos/docs/content/...`), so the `src/content/docs/`
// shape is usually gone by the time we see it. Match on the repo root
// first and fall back to the path shape.
const normalized = path.resolve(contentPath);
for (const productId of Object.keys(DOCS_SOURCES)) {
+ if (productId === SITE_DOCS_NAMESPACE) continue;
const root = docsRoot(productId);
if (root && normalized.startsWith(`${path.resolve(root)}${path.sep}`)) {
return snippetRoot(productId) ?? root;
}
}
- const product = productFromPath(contentPath);
- if (product) return snippetRoot(product);
+ if (namespacedSource) return snippetRoot(namespacedSource);
}
return docsRoot(SHARED_CONTENT_PRODUCT);
diff --git a/src/sitemap/docs-sources.ts b/src/sitemap/docs-sources.ts
index 9656a969..f36bc35f 100644
--- a/src/sitemap/docs-sources.ts
+++ b/src/sitemap/docs-sources.ts
@@ -1,5 +1,5 @@
/**
- * Which repository owns each product's docs.
+ * Which repository owns each docs namespace.
*
* Product docs are not authored in this repo. Each product repo ships a bundle:
*
@@ -32,7 +32,7 @@ export interface DocsSource {
snippetFrom?: string;
}
-export const DOCS_SOURCES: Record = Object.fromEntries(
+const PRODUCT_DOCS_SOURCES: Record = Object.fromEntries(
PRODUCTS.map((product) => [
product.id,
{
@@ -43,14 +43,25 @@ export const DOCS_SOURCES: Record = Object.fromEntries(
]),
);
+/** Website-owned documentation that sits beside, rather than inside, a product vertical. */
+export const SITE_DOCS_NAMESPACE = "docs";
+
+export const DOCS_SOURCES: Record = {
+ ...PRODUCT_DOCS_SOURCES,
+ [SITE_DOCS_NAMESPACE]: {
+ repo: "rivet-website",
+ localBundle: ".",
+ },
+};
+
export const DOCS_PRODUCT_IDS = Object.keys(DOCS_SOURCES);
/**
- * The product that owns a path, derived from where its content sits.
+ * The docs namespace that owns a path, derived from where its content sits.
*
* Accepts either a site path (`/agentos/docs/fs`) or a content-file path
* (`.../src/content/docs/agentos/fs.mdx`). Returns undefined for anything
- * outside the product verticals, such as the shared self-host guides.
+ * outside the docs collection, such as the shared self-host guides.
*/
export function productFromPath(pathname: string): string | undefined {
const normalized = pathname.replace(/\\/g, "/");
diff --git a/src/sitemap/mod.ts b/src/sitemap/mod.ts
index 790b6ba1..9d05d5c4 100644
--- a/src/sitemap/mod.ts
+++ b/src/sitemap/mod.ts
@@ -8,8 +8,6 @@ export * from "./products";
// a handful of tabs each — so the flat sitemap is derived rather than authored, and the
// product registry in `./products.ts` is the single source of truth.
//
-// Every docs section is owned by a product now, so the flat sitemap is exactly
-// the product tabs.
export const sitemap = [
...products.flatMap((product) =>
product.tabs.map((tab) => ({