Web

Web ​

Two tags on a page. No bundler, no framework, no build step in your project at all — for a blog, a hand-written docs site, or anything generated by a tool that does not do JavaScript modules.

html
<link rel="stylesheet" href="/docpilot/docpilot.web.css">
<script type="module">
  import { mountDocPilot } from '/docpilot/docpilot.web.mjs'
  mountDocPilot({ config: window.__DOCPILOT__ })
</script>

Or without modules at all:

html
<link rel="stylesheet" href="/docpilot/docpilot.web.css">
<script src="/docpilot/docpilot.web.js" defer></script>
<script>
  window.addEventListener('DOMContentLoaded', () => {
    DocPilot.mountDocPilot({ config: window.__DOCPILOT__ })
  })
</script>

Getting the files ​

They are in the package, already built:

bash
npm i @cloflin/docpilot
cp node_modules/@cloflin/docpilot/dist/docpilot.web.* static/docpilot/

Only the first line changes on Yarn, pnpm or Bun — the five package managers are side by side under Installing. Deno is the one that also changes the second: it resolves npm: specifiers out of its own cache unless a node_modules directory is enabled, so copy the files from wherever your install put them.

Three files, and the third only matters for the module form:

filewhat
docpilot.web.jsIIFE — window.DocPilot, everything inlined
docpilot.web.mjsESM — same API, code-split so grammars load on demand
docpilot.web.cssthe panel's stylesheet, inside @layer docpilot

Copy dist/web/ alongside the .mjs if you use the module form: that is where its lazily-loaded chunks live.

Vue and Shiki are inside. Neither is a dependency you have, so "external" would mean an unresolvable import at runtime. The IIFE is about 250 KB gzipped because it cannot code-split; the ESM entry is about 150 KB and fetches a grammar only when an answer contains that language. Either way it is loaded when the page is idle, and a reader who never opens the panel pays nothing beyond the download.

The config ​

Everything the panel needs, and nothing it must not have — no key, no upstream host:

html
<script>
  window.__DOCPILOT__ = {
    enabled: true,
    product: 'Acme Editor',
    llm: { provider: 'openai', baseURL: '/ai/chat', model: 'gpt-6-luna' },
    embed: { provider: 'ollama', baseURL: '/ai/embed', model: 'bge-m3' },
    ui: { trigger: 'fab' },
  }
</script>

Produce it rather than typing it, so the index and the panel cannot disagree about which model embeds:

js
import { writeFileSync } from 'node:fs'
import { defineDocPilot } from '@cloflin/docpilot'
import { docPilot } from './docpilot.config.mjs'

const ai = defineDocPilot(docPilot, process.env)
writeFileSync('static/docpilot/config.js', `window.__DOCPILOT__=${JSON.stringify(ai.themeConfig)}`)

The index ​

bash
npx docpilot index

Point docsDir at your markdown and indexDir at whatever directory your site publishes, then serve the result at /rag/:

js
export const docPilot = {
  product: 'Acme Editor',
  docsDir: 'content',
  indexDir: 'static/rag',
  chat: { provider: 'openai', model: 'gpt-6-luna' },
  embed: { provider: 'ollama', model: 'bge-m3', baseURL: 'http://localhost:11434' },
}

If it lives somewhere else, say so once:

js
mountDocPilot({ config: window.__DOCPILOT__, ragBase: '/assets/rag' })

A page's route has to be the URL your site actually serves. content/guide/install.md becomes /guide/install; if your generator publishes it at /guide/install/ or /guide/install.html, every citation 404s. Check one link before anything else.

A plain multi-page site needs nothing: each page load mounts the panel fresh with the right route. A site that navigates in JavaScript tells the panel:

js
const panel = mountDocPilot({ config: window.__DOCPILOT__ })
window.addEventListener('popstate', () => panel.setRoute(location.pathname))

Telling it about your page ​

The panel guesses conservatively, because a wrong guess is worse than an absent feature:

js
mountDocPilot({
  config: window.__DOCPILOT__,
  selectors: {
    article: 'article',            // where selecting text offers to quote it
    search: '#search-button',      // your search, offered when the panel degrades
  },
})

Without article it uses main. Without search the panel offers no search at all — there is no selector every site has, and a button that clicks nothing is worse than no button. See host.

Colours ​

docpilot.web.css is the panel on real values, with its own prefers-color-scheme dark branch and no host mapping. On a site with its own tokens, override the --dp-* set after it:

css
:root {
  --dp-surface: var(--my-bg);
  --dp-text: var(--my-fg);
  --dp-line: var(--my-border);
}

The full table is on Appearance.

prefers-color-scheme is the only signal this file has, and a page pinned against its reader's OS is exactly the case it gets wrong: a site forced dark by its own stylesheet gets a light panel on a light-OS machine. Say so instead:

js
mountDocPilot({
  config: {
    ...window.__DOCPILOT__,
    ui: { theme: 'dark' },   // 'light' · 'auto' — the default, which reads the OS
  },
})

A pin puts a class on <html> and the stylesheet declares its own dark set there, so it outranks the media query without you overriding a token. See ui.theme.

This stylesheet is a cascade layer. Its whole contents are declared inside @layer docpilot, as every stylesheet in the package is since 2.0. An unlayered rule beats a layered one whatever its specificity, so any CSS your site already has wins over the panel's without !important and without counting selectors:

css
/* no layer, no !important — this wins */
.docpilot__send { border-radius: 4px; }

If your own stylesheets are layered too, order decides it, and the order is the one the layers are first named in. State it yourself rather than depend on which <link> the browser reached first:

css
@layer docpilot, site;   /* before anything else — your layers now win */

It points the other way too. A reset the page loads unlayered now outranks the panel. The two seen in practice: Tailwind v3's preflight sets border: 0 solid on *, which erases every hairline in the panel, and a bare a, button or ul rule repaints the answer body. Both are fixed by declaring the property back — which is the control the layer exists to hand over.

The font is already yours. --dp-font is inherit and the panel is mounted into <body>, so it is set in whatever face your page is — there is nothing to override for the common case. Name one only where the panel cannot inherit it:

js
mountDocPilot({
  config: {
    ...window.__DOCPILOT__,
    ui: { font: '--brand-font' },   // or 'Inter, system-ui, sans-serif'
  },
})

See ui.font.

Site tools ​

A site tool whose run is code registers its handler on the global the panel came from, before the panel mounts:

html
<script>
  window.addEventListener('DOMContentLoaded', () => {
    DocPilot.registerTool('plan_limits', async ({ plan }) => (await fetch(`/api/plans/${plan}`)).json())
    DocPilot.mountDocPilot({ config: window.__DOCPILOT__ })
  })
</script>

With modules, import { registerTool } from '@cloflin/docpilot/web'.

The key ​

The one thing this page cannot make simple. llm.baseURL above is a same-origin path, not a provider's URL: the browser posts to your own server, and your own server attaches the credential. A key in window.__DOCPILOT__ is a key you have published.

Production has the nginx and Caddy configurations, the cache rules for the index, and the table of what each failure looks like.