Configuration

aide(config) and createChatHandler(config) take the same AideConfig (exported as a type from @chat-aide/sveltekit/server). It is validated with zod when the handler is created; an invalid config throws AideConfigError with the path that failed, e.g. limits.pageChars: 1200000 is above the ceiling 50000; raise it deliberately with limitCeilings: { pageChars: 1200000 }. Unknown keys are refused.

Options

optiondefaultmeaning
providerrequired{ name, model, apiKey?, baseURL?, headers?, options?, … }, see Providers
originnonethe site's public origin (https://example.com, no path). Never taken from the request. See Deployment
endpoint'/api/chat'the chat path; pass the same to the widget
pages.include[]extra crawl seeds
pages.exclude[]globs never read, mapped or served: * one segment, ** any depth (/blog/drafts/**)
pages.sourcenoneasync () => Page[] in place of the crawl, see below
llms.readtrueread the site's own /llms.txt and /llms-full.txt
llms.preferMarkdowntrueask for <route>.md before the HTML of each page
llms.servetruethe hook answers /llms.txt, /llms-full.txt, <route>.md (when the app does not)
llms.serveSourcefalsealso serve pages that came from pages.source
cache.ttlSeconds3600page and site-map lifetime; stale pages are served while a refresh runs
waitUntildetected(promise) => void, for a platform whose own is not detected (Deployment)
systemPromptdefaultSystemPrompta string, or (ctx: PromptContext) => string
personanone{ name?, tone?, language?, audience?, refusals? }, rendered under the system prompt
factsnonestring | string[]: facts the pages do not carry (pricing, support address)
pageContext'auto'where the current page's text comes from: 'server', 'dom', or 'auto' (server render, the widget's DOM text when the render is thinner than minPageChars)
suggestionsnonestring[] or { [route]: string[], '*': string[] }, sent in the warm-up
limitssee belowany subset
limitCeilingssee belowraises a ceiling deliberately
rateLimit{ requests: 20, windowSeconds: 600, warmups: 60 }per visitor, fixed windows; false turns it off
budget{ tokensPerDay: 200_000, siteTokensPerDay: 5_000_000 }each field may be false; false turns both off
storein-memoryshared state, see Deployment; requires salt
saltrandom per processHMAC salt for visitor keys; required with store, refused without it
allowedOrigins[]extra origins whose Origin header the endpoint accepts
authorizenone(event) => boolean | Response | Promise<…>; false → 403 unauthorized, a Response is returned as is
clientKeyclient address(event) => string, the visitor's rate-limit identity (read this first)
cachingtrueprovider prompt-cache markers
onMessage, onResponse, onRefused, onErrornonecallbacks, see below

Limits

Every number is a default. Pass any subset in limits; a value above its ceiling is refused as a likely typo, and accepted once you raise the ceiling with limitCeilings.

limitdefaultceilingmeaning
pageChars12 00050 000text of one page (current page, read_page, DOM text)
minPageChars2005 000below this a page is "thin" (and 'auto' asks for the DOM)
outlineChars6 00040 000the site outline in the prompt
historyTurns1250turns of history the server keeps
historyChars16 000100 000characters of history the server keeps
maxOutputTokens1 0248 192per model step
maxSteps38model steps per turn (tool calls included)
messageChars4 00020 000one visitor message
messages60200messages in one request body
maxPages2005 000pages mapped, and pages (and misses) cached per instance
crawlConcurrency416reads in flight during the crawl
crawlBudgetMs1 50010 000how long a turn or an llms request waits for the crawl
aide({
	provider,
	limits: { pageChars: 30_000, maxPages: 8_000 },
	limitCeilings: { maxPages: 8_000 }, // above the 5 000 ceiling, deliberately
	rateLimit: { requests: 40 },
	budget: { siteTokensPerDay: false }
});

The server windows the history (oldest dropped first) whatever the client sends, and the widget trims its request to messages and messageChars from the warm-up, so a long tab never gets stuck on 400.

Budgets and their overshoot

Budgets count inputTokens + outputTokens. The usage of every finished model step is added up and charged when the turn ends, however it ends (a later step failing, the visitor disconnecting), and the budget is checked before the next turn starts. So:

  • a single turn can overshoot a budget by at most its own cost (bounded by the input limits and maxOutputTokens × maxSteps);
  • concurrent requests are each checked before any of them is charged, so each can overshoot by one turn.

budget.siteTokensPerDay is the number that bounds your bill: a per-visitor key is an address, and addresses are cheap to rotate. Day counters are per UTC day.

The prompt, and when it caches

The prompt is assembled in a fixed order: system prompt, persona, facts, site outline (the static prefix), then the current page, the windowed history and the visitor's message. With caching: true, Anthropic gets cacheControl on the prefix and Bedrock a cachePoint; OpenAI, Gemini, DeepSeek, Groq and the OpenAI-compatible endpoints cache on their side.

The prefix is byte-identical between requests only while its inputs are. Each of these starts a new prefix, so the next request pays full price for it:

  • a systemPrompt function whose result varies per request (it receives the route);
  • a facts value that changes;
  • the "outline is partial" note, present while the crawl is still running, and the outline itself changing as the map fills or refreshes.

Providers cache only a prefix above their minimum length (Anthropic's is model-specific and larger for its small models), so a small site's prefix may not cache at all. The library does not pad it.

pages.source

For a site whose content lives in a CMS, is too large to crawl, or cannot be rendered by the app:

import type { Page } from '@chat-aide/sveltekit/server';

aide({
	provider,
	pages: {
		source: async (): Promise<Page[]> =>
			(await cms.published()).map((p) => ({
				route: `/docs/${p.slug}`,
				title: p.title,
				description: p.summary,
				headings: [],
				text: p.plainText
			}))
	}
});

When set, the crawl does not run and read_page reads from it; the current page still comes from a render or the DOM. Routes go through the same route rule and excludes, at most maxPages are kept, text is cut to pageChars.

Return only text any visitor may read. What the function returns reaches the model, and through the model any visitor who asks. It is not served on the llms paths unless you set llms.serveSource: true, but that does not keep it from the chat.

Callbacks

None delays the response; each runs after its event under waitUntil, and a throw is handed to onError (one from onError itself is dropped), never to the visitor.

onMessage({ sessionId, route, text, at })
onResponse({ sessionId, route, text, usage, model, provider, durationMs, finishReason, toolCalls, at })
onRefused({ sessionId, code, at })   // invalid_request, origin_refused, rate_limited, budget_exhausted, unauthorized, method_not_allowed, provider_error
onError({ sessionId, error, at })    // the provider's real error; the visitor sees a generic sentence

usage is AI SDK 7's LanguageModelUsage summed over the steps (inputTokens, outputTokens, totalTokens, inputTokenDetails.{noCacheTokens, cacheReadTokens, cacheWriteTokens}, outputTokenDetails.{textTokens, reasoningTokens}), each undefined where the provider does not report it. sessionId is minted by the widget and is the only identifier the library has.

Edit this page on GitHub