React
One component. No Vue in your dependencies, no loader in your bundler config.
npm i @cloflin/docpilotOn Yarn, pnpm, Bun or Deno, take that line from Installing instead.
Mounting
import { DocPilotPanel } from '@cloflin/docpilot/react'
import '@cloflin/docpilot/web.css'
export function Layout({ children, pathname }) {
return (
<>
{children}
<DocPilotPanel config={window.__DOCPILOT__} route={pathname} />
</>
)
}The component renders one empty div. Everything visible — the panel, the icon sprite, the selection popover — teleports to <body>, so where you place it in the tree does not decide where it appears. Place it once.
Why it imports a prebuilt bundle
@cloflin/docpilot/react imports @cloflin/docpilot/web, not the package's source. A React project's bundler is webpack, Turbopack or esbuild; none of them compiles a .vue file, and babel-loader excludes node_modules besides. The web bundle is the same code already compiled, with Vue inside — so your app needs no Vue, no loader and no configuration.
It is about 150 KB gzipped and is loaded lazily. A reader who never opens the panel downloads none of it.
'use client' is the module's first line, so it drops into a Next App Router tree unchanged.
Keeping it in step
Pass route and lang as props and change them on navigation:
import { usePathname } from 'next/navigation'
const pathname = usePathname()
return <DocPilotPanel config={config} route={pathname} lang="en" />The panel does not remount when they change. They are pushed into the running instance instead — a remount would throw away the reader's conversation, which is the one thing the panel keeps across route changes on every other host.
route is base-less: /guide/install, not /docs/guide/install. If your site is served from a subdirectory, say so once with base:
<DocPilotPanel config={config} route={pathname} base="/docs/" />Opening it from your own button
import { DocPilotPanel, useDocPilot } from '@cloflin/docpilot/react'
function AskButton() {
const docpilot = useDocPilot()
return <button onClick={() => docpilot.open()}>Ask AI</button>
}Render the panel with trigger="none" and place your own control anywhere. ask(question) puts a question in the composer without submitting it — the reader reads what somebody else wrote before it is asked on their behalf.
The rest of the options
<DocPilotPanel
config={config}
route={pathname}
trigger="fab" // 'fab' | 'nav' | 'screen' | 'none', or an array
base="/docs/"
ragBase="https://cdn.example.com/rag"
selectors={{ article: 'article', search: '.DocSearch-Button' }}
router={{ go: (href) => navigate(href) }}
highlighter={myHighlighter}
/>router is what a citation click uses. Without it the panel follows a link with a full page load, which is correct — a host with no router IS a site where following a link means loading a page — but you almost certainly have one.
selectors.article is what bounds the offer to quote a passage; the neutral default is main. See host.
Site tools
registerTool comes from the same entry. It returns the function that removes the handler, which is what an effect's cleanup wants:
import { registerTool } from '@cloflin/docpilot/react'
useEffect(() => registerTool('plan_limits', async ({ plan }) => (await fetch(`/api/plans/${plan}`)).json()), [])See Tools and actions.
Syntax highlighting
The web bundle installs Shiki itself, so code in an answer is coloured with no setup. To use something else:
import { createHljsHighlighter } from '@cloflin/docpilot/hljs'
<DocPilotPanel config={config} highlighter={createHljsHighlighter()} />highlighter={false} removes it and renders every block as a plain escaped <pre>. See Syntax highlighting.