grepticon/
Guides

Fumadocs

Put an Ask AI dialog on your Fumadocs site whose agent answers by grepping your docs through Grepticon.

This guide adds an Ask AI dialog to a Fumadocs site in about ten minutes. Behind it sits an agent that answers by reading your documentation through Grepticon's four read tools (ls, find, cat, and grep) rather than a vector index, which is the same architecture Mintlify built in-house for its docs assistant.

Most of the work is already done for you. Fumadocs ships the chat UI through its CLI, so you only write two things: a script that syncs your pages into a Grepticon workspace, and a chat route that hands the agent the read tools.

Before you begin

  • A Fumadocs site on fumadocs-ui 16+, React 19.2+, and Tailwind v4.
  • A Grepticon account with a workspace and an API key. See Quickstart if you do not have one yet.
  • An OpenRouter API key. Any tool-capable model works, and cheap open models handle this loop well, so there is no need for a premium one.

Your pages also need to be available as clean Markdown, which is one setting on the defineDocs call your starter already has:

source.config.ts
export const docs = defineDocs({
  dir: 'content/docs',
  docs: {
    postprocess: { includeProcessedMarkdown: true },
  },
});

1. Scaffold the chat UI

Fumadocs' own CLI installs the dialog, its markdown renderer, a starter /api/chat route, and the AI SDK packages:

npx @fumadocs/cli add ai/openrouter
npm install @grepticon/sdk

Then mount the dialog in your docs layout:

app/docs/layout.tsx
import { AISearch, AISearchPanel, AISearchTrigger } from '@/components/ai/search';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import { MessageCircleIcon } from 'lucide-react';

// Wrap whatever your layout already renders inside <DocsLayout>:
<AISearch>
  {children}
  <AISearchPanel />
  <AISearchTrigger
    position="float"
    className={buttonVariants({ color: 'secondary', className: 'rounded-2xl gap-2' })}
  >
    <MessageCircleIcon className="size-4.5" />
    Ask AI
  </AISearchTrigger>
</AISearch>

That is the whole UI. One upstream fix is worth applying while you are here: in components/ai/markdown.tsx, the rehypeWrapWords plugin wraps whitespace-only text nodes, which puts a <span> directly under <table> and throws a React hydration error the first time an answer contains a table. Add if (node.value.trim().length === 0) return; just below the existing if (node.type !== 'text' || !parent || index === undefined) return;.

2. Sync your docs into a workspace

Each page becomes one Markdown file whose title line is followed by the page's absolute URL. That URL line is what lets the agent cite real links without any path-to-URL mapping.

scripts/sync-docs.ts
import { GrepticonClient } from '@grepticon/sdk';
import { register } from 'fumadocs-mdx/node';

const WORKSPACE = 'docs-corpus';
const SITE = 'https://your-docs-site.com';

// `lib/source` pulls in `.mdx?collection` modules, which resolve only once the
// fumadocs-mdx loader is registered, hence the dynamic import.
register();
const { source } = await import('@/lib/source');

const client = new GrepticonClient({ apiKey: process.env.GREPTICON_API_KEY! });

const synced: string[] = [];
for (const page of source.getPages()) {
  const path = `docs${page.url === '/' ? '/index' : page.url}.md`;
  const body = await page.data.getText('processed');

  await client.files.upload(
    WORKSPACE,
    path,
    `# ${page.data.title}\n\n${SITE}${page.url}\n\n${body}`,
    { contentType: 'text/markdown' },
  );
  synced.push(path);
}

// Drop pages that no longer exist, so the agent never cites a dead URL.
let cursor: string | undefined;
do {
  const listing = await client.files.list(WORKSPACE, { cursor });
  for (const file of listing.files) {
    if (!synced.includes(file.path)) await client.files.delete(WORKSPACE, file.path);
  }
  cursor = listing.nextCursor ?? undefined;
} while (cursor);

// Ingestion is asynchronous; wait so the first question sees every page.
await client.files.waitForReady(WORKSPACE, { paths: synced });
console.log(`synced ${synced.length} pages`);

Run it with your key in the environment:

GREPTICON_API_KEY=grp_sk_... npx tsx scripts/sync-docs.ts

3. Point the route at Grepticon

The CLI generated a route that searches a local index. Replace it with this, which hands the model the Grepticon read tools instead:

app/api/chat/route.ts
import { GrepticonClient } from '@grepticon/sdk';
import { createVfsTools } from '@grepticon/sdk/ai-sdk';
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import {
  convertToModelMessages,
  createUIMessageStreamResponse,
  stepCountIs,
  streamText,
  toUIMessageStream,
  type UIMessage,
} from 'ai';

const openrouter = createOpenRouter({ apiKey: process.env.OPENROUTER_API_KEY });
const session = new GrepticonClient({
  apiKey: process.env.GREPTICON_TOKEN!,
}).session('docs-corpus');
const tools = createVfsTools(session);

const instructions = [
  'You answer questions about this product using its documentation corpus.',
  'The corpus is a workspace you read with the ls, find, cat, and grep tools.',
  'docs/ holds one Markdown file per page. Each file starts with a "# Title" heading, then the page\'s absolute URL on its own line.',
  'Explore with grep and find, then cat the relevant files, before answering.',
  'Cite a source only by copying the URL printed at the top of the file you read.',
  'Never build a link out of a corpus file path, and never append a line or section anchor to a URL: a link you did not read verbatim will be broken.',
  'If the corpus does not contain the answer, say you do not know instead of inventing one.',
].join('\n');

export async function POST(req: Request): Promise<Response> {
  const { messages } = (await req.json()) as { messages?: UIMessage[] };

  const result = streamText({
    model: openrouter.chat('deepseek/deepseek-v4-flash'),
    instructions,
    messages: await convertToModelMessages(messages ?? [], {
      // The dialog attaches the visitor's current page as a data part.
      convertDataPart: (part) => ({
        type: 'text',
        text: `[Client Context: ${JSON.stringify(part.data)}]`,
      }),
    }),
    tools,
    stopWhen: stepCountIs(10),
  });

  return createUIMessageStreamResponse({
    stream: toUIMessageStream({ stream: result.stream }),
  });
}

Two details worth knowing. stopWhen is required: without it the run stops after the first step, before the agent has read anything, and you get an empty answer. And instructions is where the system prompt goes, since the AI SDK rejects a system-role entry inside messages.

Keep the two citation lines as written. Left to itself the model will build a link by gluing your site origin onto a corpus path, or bolt a #L120-L140 anchor onto a real URL, and both render as 404s inside an otherwise correct answer.

Set the two secrets and start your site:

GREPTICON_TOKEN=grp_sk_...
OPENROUTER_API_KEY=sk-or-...

GREPTICON_TOKEN is the credential a public agent reads with, so isolate it: give the corpus its own account, or a workspace that holds nothing else. A management key can reach every file in its account, which is fine when that account holds only published pages and is not fine otherwise. See Authentication for the difference between a grp_sk_ key and a scoped grp_at_ token, and mint the token instead if your deploy can refresh it. Rate limiting and abuse protection on this endpoint are your call.

4. Try it

Open your docs, press the Ask AI button, and ask something that spans more than one page, such as "how do I authenticate, and what happens when I hit a rate limit". The agent should grep its way to the answer and cite the pages it used.

Optional: show the agent working

The stock dialog renders nothing while the agent greps, so a multi-second answer can look frozen. Watching the agent search is also the most convincing part of the experience. Message already collects tool calls into a searchCalls array and renders them below the answer; it just keeps only a tool named search. Widen the filter to the four read tools and show what each one looked for.

Where Message collects the calls:

components/ai/search.tsx
const VFS_TOOLS = ['ls', 'find', 'cat', 'grep'];

// Replace the `toolName !== 'search'` check:
if (!VFS_TOOLS.includes(toolName) || !p.toolCallId) continue;

And where it renders them, in place of the "Searching…" text:

components/ai/search.tsx
const input = call.input as { pattern?: string; path?: string } | undefined;
const failed = call.state === 'output-error' || call.state === 'output-denied';

<span className="font-medium">{call.type.slice('tool-'.length)}</span>
<p className="truncate">
  {failed ? (call.errorText ?? 'Tool call failed') : (input?.pattern ?? input?.path ?? '')}
</p>
{!failed && call.state !== 'output-available' && (
  <Loader2 className="size-3.5 animate-spin ms-auto" />
)}

Optional: sync from CI

Run the sync script on every deploy so the corpus tracks your main branch:

.github/workflows/sync-docs.yml
on:
  push:
    branches: [main]
    paths: ['content/docs/**', 'scripts/sync-docs.ts']

jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22 }
      - run: npm ci
      - run: npx tsx scripts/sync-docs.ts
        env:
          GREPTICON_API_KEY: ${{ secrets.GREPTICON_API_KEY }}

Beyond the docs tree

Nothing about this is limited to your documentation. The same workspace can hold your SDK source, changelogs, examples, or a second repository, and the agent will grep across all of it with no extra wiring. That is the part a docs-only search index cannot do.

Two rules apply once the corpus holds more than published pages.

Anything you upload can be quoted. The agent pastes what it reads into its answer, and the dialog is open to every visitor. Uploading a private repository puts its source one question away from anyone on the page. Add a source only if you would publish it; if the material has to stay internal, put the dialog behind your login.

Every file needs a citable URL. The sync script writes each page's absolute URL directly under its title, and the prompt tells the agent to copy that line. A file with no URL leaves it nothing to copy, so the agent invents one by gluing your site origin onto the corpus path and emits dead links such as https://your-docs-site.com/sdk/src/client.ts. Give every extra source a URL line of its own (a GitHub blob link works well for source files), or leave it out.

Grepticon's own Ask AI syncs published docs pages and nothing else, for exactly these two reasons.

On this page