product.id === productId)
+ : undefined;
+ const explicitTab = explicitProduct && tabId
+ ? explicitProduct.tabs.find((tab) => tab.id === tabId)
+ : undefined;
+ const current = explicitProduct && explicitTab
+ ? { product: explicitProduct, tab: explicitTab }
+ : pathCurrent;
+ const isGlobalDocsPage =
+ pathname.startsWith("/docs/") && Boolean(sidebarData?.length);
+ const isDocsPage =
+ Boolean(current) ||
+ pathname.startsWith("/integrations") ||
+ isGlobalDocsPage;
// On mobile the product bar collapses into this sheet. Product entries link
// to their overviews; the section dropdown lists the remaining tabs.
@@ -707,7 +727,9 @@ function DocsMobileNavigation({
label: tab.title,
href: tab.href,
}))
- : [{ id: "integrations", label: "Integrations", href: "/integrations" }];
+ : isGlobalDocsPage
+ ? [{ id: "docs", label: "Documentation", href: "/docs/" }]
+ : [{ id: "integrations", label: "Integrations", href: "/integrations" }];
const mainLinks = [
{ href: "/docs", label: "Documentation" },
diff --git a/src/content/docs/docs/mcp.mdx b/src/content/docs/docs/mcp.mdx
new file mode 100644
index 00000000..62bd60ec
--- /dev/null
+++ b/src/content/docs/docs/mcp.mdx
@@ -0,0 +1,127 @@
+---
+title: Rivet MCP
+description: Connect your AI client to Rivet to inspect actors, call actions, and use the Actor Inspector.
+seoTitle: "Rivet MCP: connect your AI client to Rivet"
+seoDescription: Connect Claude Code, Codex, Cursor, and other AI clients to Rivet to inspect actors, call actions, and use the Actor Inspector.
+---
+
+import { faClaude, faCursor, faGemini, faLaptopCode, faOpenai, faVscode } from '@rivet-gg/icons';
+
+## Connect your client
+
+The hosted endpoint is `https://mcp.rivet.dev/mcp`. When a hosted client connects, Rivet opens a sign-in flow so you can choose and approve its access.
+
+
+
+
+
+Run this command, then approve the Rivet sign-in when it opens.
+
+
+
+
+
+
+
+Run this command, then approve the Rivet sign-in when it opens.
+
+
+
+
+
+
+
+Add this configuration to Cursor, then approve the Rivet sign-in when it opens.
+
+
+
+
+
+
+
+Run this command, then approve the Rivet sign-in when it opens.
+
+
+
+
+
+
+
+Run this command, then approve the Rivet sign-in when it opens.
+
+
+
+
+
+
+
+Add this configuration to your client to connect to Rivet already running on your machine. Local connections need neither a URL nor sign-in.
+
+
+
+
+
+
+
+## Control access
+
+### Scope hosted connections
+
+You can constrain the hosted endpoint with organization, project, and namespace query parameters.
+
+| Endpoint | Access |
+| --- | --- |
+| `https://mcp.rivet.dev/mcp` | Your client asks which scope to use and remembers it. |
+| `https://mcp.rivet.dev/mcp?organization=ORG` | One organization. |
+| `https://mcp.rivet.dev/mcp?organization=ORG&project=PROJECT` | One project. |
+| `https://mcp.rivet.dev/mcp?organization=ORG&project=PROJECT&namespace=NS` | One namespace. |
+
+Replace the placeholders with the same slugs used in your [dashboard](https://dashboard.rivet.dev) URL: `/orgs/ORG/projects/PROJECT/ns/NS`. If you omit the values, your client asks you to choose and stays on that selection for the rest of the session.
+
+### Security model
+
+- Generated code runs with no network, environment variables, files, or way to start a process.
+- Every call goes through Rivet. Rivet checks it against the access you approved and attaches your credentials.
+- A hosted connection carries only the access you granted, and its grant expires after 15 minutes.
+- Your credentials stay on Rivet and never reach the model.
+- An inline Inspector is limited to the actor you requested and disappears with the conversation. Clients that cannot render it receive the same details as text.
+
+## What you can do
+
+These examples are illustrative and make no live backend calls.
+
+### Find running actors
+
+
+
+Rivet MCP uses `search` to find `rivet.actors.list`, lists the matching `chat-room` actors, and returns one concise result: **3 rooms awake**.
+
+### Call an actor action
+
+
+
+Rivet MCP uses `search` to find `rivet.actor.action`, calls the `getHistory` action on the `chat-room · acme` actor, and returns the action result: **128 messages from Acme**.
+
+### Inspect an actor inline
+
+
+
+The same actor view available in the dashboard opens inside the conversation. It includes the **State**, **Connections**, **Queue**, **Workflow**, and **Database** tabs. The inline view is limited to the actor you requested and disappears with the conversation. Clients that cannot render the Inspector receive the same details as text.
+
+## One request, two tools
+
+Your client does not have to choose from a long list of narrow tools. Rivet MCP exposes two tools and composes the operations needed for a request.
+
+### Find the right operations
+
+The `search` tool looks at the namespace you connected and returns only the operations that apply to the request.
+
+### Run one composed request
+
+The `execute` tool writes ordinary JavaScript, combines the relevant operations, and returns one answer instead of making the client coordinate every call. You do not write this generated code or see it unless you ask.
+
+For the request **“Which chat rooms are still awake, and what has Acme been saying?”**, Rivet MCP finds and runs `rivet.actors.list` and `rivet.actor.action`:
+
+
+
+The result is one answer: **3 rooms awake, 128 messages from Acme**.
diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro
index 993fb844..d7b91dfa 100644
--- a/src/layouts/BaseLayout.astro
+++ b/src/layouts/BaseLayout.astro
@@ -31,6 +31,7 @@ interface Props {
themeColor?: string;
pageFamily?: 'default' | 'site';
preconnectAssets?: boolean;
+ exactTitle?: boolean;
}
const {
@@ -50,6 +51,7 @@ const {
themeColor = "#EFEFEF",
pageFamily = 'default',
preconnectAssets = false,
+ exactTitle = false,
} = Astro.props;
const seoTitle = stripSeoMarkup(title);
@@ -60,7 +62,7 @@ const seoDescription = stripSeoMarkup(description);
// with a "Rivet … - " brand prefix (homepage, product pages).
const formattedTitle = !seoTitle
? 'Rivet'
- : /^Rivet( [A-Za-z]+)? - | - Rivet( Actors| Templates)?$/.test(seoTitle)
+ : exactTitle || /^Rivet( [A-Za-z]+)? - | - Rivet( Actors| Templates)?$/.test(seoTitle)
? seoTitle
: `${seoTitle} - Rivet`;
diff --git a/src/layouts/DocsLayout.astro b/src/layouts/DocsLayout.astro
index f6216b7d..f9f8aa6f 100644
--- a/src/layouts/DocsLayout.astro
+++ b/src/layouts/DocsLayout.astro
@@ -8,6 +8,7 @@ interface Props {
description?: string;
canonicalUrl?: string;
robots?: string;
+ exactTitle?: boolean;
sidebar?: SidebarItem[];
/** Product vertical this page belongs to; omitted outside the verticals. */
productId?: string;
@@ -16,7 +17,7 @@ interface Props {
sectionLabel?: string;
}
-const { title, description, canonicalUrl, robots, sidebar, productId, tabId, sectionLabel } = Astro.props;
+const { title, description, canonicalUrl, robots, exactTitle, sidebar, productId, tabId, sectionLabel } = Astro.props;
const pathname = Astro.url.pathname;
// Some tabs are a single page rather than a section (Use Cases). Without a
// sidebar the two-column docs grid would leave an empty gutter, so the content
@@ -24,7 +25,7 @@ const pathname = Astro.url.pathname;
const hasSidebar = (sidebar?.length ?? 0) > 0;
---
-
+
]*?)\/>/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/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 b6dcffcc..bdf2ba4b 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";
@@ -127,20 +128,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 2785409c..78c2e540 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:
*
@@ -30,7 +30,7 @@ export interface DocsSource {
localBundle?: string;
}
-export const DOCS_SOURCES: Record = Object.fromEntries(
+const PRODUCT_DOCS_SOURCES: Record = Object.fromEntries(
PRODUCTS.map((product) => [
product.id,
{
@@ -40,14 +40,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) => ({