# @kitn.ai/ui — Full Reference # @kitn.ai/ui > Framework-agnostic, Shadow-DOM web components for building AI chat interfaces — works in React, Vue, Angular, Svelte, or plain HTML. 96 custom elements, every one prefixed `kai-` (e.g. ``, ``): streaming responses, markdown + code rendering, reasoning/tool panels, attachments, conversation sidebar, voice input. Zero framework dependency for consumers; the SolidJS runtime it is authored in is bundled in, so the host needs nothing. ## Install ```bash npm install @kitn.ai/ui # SolidJS consumers also need the peer dep: npm install solid-js ``` ## #1 rule: array/object data goes on JS PROPERTIES, not HTML attributes This is the single most common mistake. Arrays and objects (`messages`, `models`, `context`, `suggestions`, `triggers`, …) MUST be assigned as JavaScript properties on the element. They CANNOT be passed as HTML attributes — an HTML attribute is always a string and will be ignored or mis-parsed. ```js const chat = document.querySelector('kai-chat'); chat.messages = [{ id: '1', role: 'assistant', parts: [{ type: 'text', text: 'Hi!' }] }]; // ✅ property ``` ```html ``` Only scalar values (string/number/boolean) work as attributes (e.g. `placeholder`, `loading`, `theme`). ## Two layers **Layer 1 — batteries-included web components** (`import '@kitn.ai/ui/elements'`): Drop an element into any framework (React, Vue, plain HTML). Data in via JS properties; interactions out via non-bubbling CustomEvents. - `` — full chat UI (message list + prompt input). The primary starting point. - `` — sidebar conversation browser with group support. - `` — standalone composer with send button. **Layer 2 — composable primitives** (`import { … } from '@kitn.ai/ui'`): All 96 elements are also exported individually. Use them for custom layouts or features `` does not expose (ChainOfThought, FeedbackBar, ThinkingBar, VoiceInput, …). Your bundler tree-shakes the rest. ## Key rules for the web components 1. **Array/object data = JS properties** (see above). Scalars may be attributes. 2. **Events are non-bubbling `CustomEvent`s** — listen directly on the element: `chat.addEventListener('kai-submit', (e) => console.log(e.detail.value))` 3. **`theme` attribute** (`'light' | 'dark' | 'auto'`) works on every element. Default `auto` follows `prefers-color-scheme`. 4. **Theming via CSS custom properties** — override `--kai-color-*` tokens on `:root`; they pierce Shadow DOM. ## ChatMessage schema (required for ``) A message's content is an **ordered `parts` array**. There is no `content` string: it was removed in 0.20.0. Text, reasoning, tool calls, generative-UI cards, citations and file attachments all live in `parts`, in the order the model produced them, so a post-tool answer renders below its tool panel instead of being glued onto the pre-tool text. ```ts interface ChatMessage { id: string; role: 'user' | 'assistant'; /** The ONLY content channel. Ordered. */ parts: MessagePart[]; /** Action buttons under the message. Chrome, not content. */ actions?: ('copy' | 'like' | 'dislike' | 'regenerate' | 'edit' | 'speak')[]; avatar?: { src?: string; fallback?: string; alt?: string }; feedback?: 'like' | 'dislike'; } /** Six variants, one per kind of content. Every variant may also carry `raw` * (`{ source, payload }`), the untranslated provider block the part was * normalized from, for echoing a turn back to the model verbatim. */ type MessagePart = | { type: 'text'; text: string; raw?: RawOrigin } | { type: 'reasoning'; text: string; label?: string; index?: number; signature?: string; raw?: RawOrigin } | { type: 'tool'; tool: ToolPart; raw?: RawOrigin } | { type: 'card'; envelope: CardEnvelope; raw?: RawOrigin } | { type: 'source'; source: MessageSource; raw?: RawOrigin } | { type: 'file'; attachment: AttachmentData; raw?: RawOrigin }; interface ToolPart { type: string; /** Rendering classification. Derived from `type` when you omit it; an explicit * value you set is preserved across later patches. */ kind?: 'command' | 'file-change' | 'search' | 'fetch' | 'mcp' | 'image' | 'generic'; state: 'input-streaming' | 'input-available' | 'output-available' | 'output-error'; input?: Record; /** Raw accumulated argument fragments, for character-level streaming. */ rawInput?: string; output?: Record; toolCallId?: string; errorText?: string; raw?: RawOrigin; } /** A citation. Exported as `MessageSource` (the bare `Source` name belongs to * the citation-chip component). */ interface MessageSource { id?: string; url?: string; title?: string; snippet?: string; index?: number } ``` The simplest possible message, and one with reasoning + a tool call before its answer: ```js { id: '1', role: 'assistant', parts: [{ type: 'text', text: 'Hi!' }] } { id: '2', role: 'assistant', parts: [ { type: 'reasoning', text: 'I should search for current pricing.' }, { type: 'tool', tool: { type: 'search', state: 'output-available', toolCallId: 'tc_1', input: { query: 'current pricing' }, output: { results: ['A', 'B'] } } }, { type: 'text', text: 'Here is what I found.' }, ], } ``` Types are importable: `import type { ChatMessage, MessagePart, MessageSource } from '@kitn.ai/ui'` (also from `'@kitn.ai/ui/react'` and `'@kitn.ai/ui/state'`). ## Framework wiring **Plain HTML / CDN** ```html ``` **React** — typed wrappers auto-set properties and expose `on` props: ```tsx import { Chat } from '@kitn.ai/ui/react'; send(e.detail.value)} /> ``` **Vue** — use the element directly; pass arrays via `.prop`: ```vue ``` ## Theming ```css :root { --kai-color-background: #0f0f0f; --kai-color-primary: #7c3aed; --kai-color-muted: #1e1e1e; } ``` For plain HTML/CDN: ``. For Tailwind builds: `@import "@kitn.ai/ui/theme.css"` in your CSS. ## Docs - Element reference (all 96 elements, every prop/event/method/slot/part): the "Element reference" section of ./llms-full.txt — https://kitn.dev/llms-full.txt - Programmatic layer (`@kitn.ai/ui/state` + `@kitn.ai/ui/wire`: streaming folds, the mock responder, the SSE readers/encoders): the "Programmatic layer" section of llms-full.txt - How to build a chat app in 5 steps (install → pick a layer → handle submit + stream → wire features → theme, with working code): the "How to build a chat app in 5 steps" section of llms-full.txt - Streaming recipe (the two rules that bite: reassign new references per chunk, fold deltas onto the trailing text part): the "Streaming recipe" section of llms-full.txt - Icon roster (every name `kai-icon` and the elements' `icon` props resolve; anything else fails loud): the "Icon roster" section of llms-full.txt - Machine-readable Custom Elements Manifest: https://unpkg.com/@kitn.ai/ui/dist/custom-elements.json - Working examples: https://github.com/kitn-ai/ui/tree/main/examples - Storybook: https://storybook.kitn.dev - Repository: https://github.com/kitn-ai/ui --- ## How to build a chat app in 5 steps ### 1 — Install ```bash npm install @kitn.ai/ui ``` ### 2 — Pick your layer Drop-in: use `` for a full chat UI in one tag (`import '@kitn.ai/ui/elements'`). Composable: combine ``, ``, ``, … in your own markup. ### 3 — Handle `submit` and stream ```js import '@kitn.ai/ui/elements'; // The streaming fold. It is 5 lines if you would rather inline it: see the // Streaming recipe below. import { appendTextPart } from '@kitn.ai/ui/state'; const chat = document.querySelector('kai-chat'); chat.messages = []; chat.addEventListener('kai-submit', async (e) => { const userText = e.detail.value; // Append the user message (new array, see the streaming note) const history = [...chat.messages, { id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text: userText }] }]; chat.messages = history; chat.loading = true; // Add an empty assistant placeholder to stream into const aid = crypto.randomUUID(); chat.messages = [...history, { id: aid, role: 'assistant', parts: [] }]; for await (const token of streamFromYourAPI(history)) { // Fold each delta onto the message's TRAILING text part. Do NOT replace // `parts` wholesale: that drops reasoning/tool/card parts already on it. chat.messages = chat.messages.map((m) => m.id === aid ? { ...m, parts: appendTextPart(m.parts, token) } : m); } chat.loading = false; }); ``` ### 4 — Wire optional features - Reasoning: push `{ type: 'reasoning', text: '…' }` onto the message's `parts`. - Tool calls: push `{ type: 'tool', tool: { type: 'search', state: 'output-available', input: {…}, output: {…} } }`. - Model switcher: `chat.models = [{ id: 'gpt-4o', name: 'GPT-4o' }]; chat.currentModel = 'gpt-4o';` — listen for `modelchange`. - Token meter: `chat.context = { usedTokens: 1200, maxTokens: 128000 };`. - History sidebar: add ``; listen for `select` and `newchat`. ### 5 — Theme Override `--kai-color-*` tokens on `:root` (they pierce Shadow DOM). --- ## Streaming recipe (critical) Two rules, and both bite: 1. **Reassign a NEW array containing a NEW message object on every chunk.** Mutating an existing message object in place will NOT trigger a re-render. 2. **Fold the delta onto the message's TRAILING text part.** Replacing `parts` with a fresh single-text array re-renders fine but silently deletes any reasoning / tool / card parts the turn already produced. ```js // The fold. `@kitn.ai/ui/state` exports exactly this as `appendTextPart`. const appendText = (parts, delta) => { const last = parts[parts.length - 1]; return last?.type === 'text' ? [...parts.slice(0, -1), { ...last, text: last.text + delta }] : [...parts, { type: 'text', text: delta }]; }; // ✅ re-renders, and keeps every part already on the message chat.messages = chat.messages.map((m) => m.id === id ? { ...m, parts: appendText(m.parts, delta) } : m); // ❌ does NOT re-render (same array, same object) chat.messages[i].parts = appendText(chat.messages[i].parts, delta); // ❌ re-renders, but drops the message's reasoning/tool/card parts chat.messages = chat.messages.map((m) => m.id === id ? { ...m, parts: [{ type: 'text', text: answer }] } : m); ``` Opening a new text part when the last part is not text is what stops a post-tool answer being glued onto the pre-tool text. The same reassign rule applies to every array/object property (`models`, `context`, `suggestions`, …): replace, don't mutate. --- ## Programmatic layer — `@kitn.ai/ui/state` + `@kitn.ai/ui/wire` The API you write a HOST against — everything below is what a hand-composed surface (no ``) wires together. Signatures and docs below are the shipped declaration files themselves, so they cannot drift from what your editor shows. The streaming loop, end to end: ```ts import { createAssistantStream, createMockResponder } from '@kitn.ai/ui/state'; import { readOpenAIStream } from '@kitn.ai/ui/wire'; // 1. A setter with the ONE universal contract: functional updater, new array out. const stream = createAssistantStream((update) => { el.messages = update(el.messages ?? []); }); // 2. You fetch (or preview with the mock — real SSE frames, no provider, no key): const mock = createMockResponder(); // or: fetch("/api/chat", …).then(r => r.body) const result = await readOpenAIStream(mock(prompt), stream); // 3. THE HOST RESOLVES TOOL CALLS. A provider (and the mock) only ANNOUNCES a tool // call — the part sits at state "input-available" forever unless your code answers // it. Executing the tool is the app's decision, and this is the call that answers. // The ONE exception: a call with `providerExecuted: true` (see ModelToolCall below) // was already run by the provider, in-stream — the host must NOT execute those. for (const call of result.toolCalls) { if (call.providerExecuted) continue; stream.upsertTool(call.id, { state: 'output-available', output: await runTool(call) }); } stream.done(); // seal the turn; late sink calls are dropped ``` Scripting a mock tool call (so the tool panel renders with zero backend): ```ts const mock = createMockResponder({ replies: ['Plain text turn', { text: 'Let me check.', toolCalls: [{ name: 'search_docs', arguments: { query: 'threads' } }] }], }); ``` ### `@kitn.ai/ui/state` I/O-free pure folds over `ChatMessage[]`. No client, no fetch — you own the transport; these functions own the array-identity discipline the elements re-render on. Every export (57, derived from `dist/state/index.d.ts`): | Export | Kind | Module | |---|---|---| | `appendMessage` | value | `messages` | | `upsertMessage` | value | `messages` | | `updateMessage` | value | `messages` | | `removeMessage` | value | `messages` | | `appendText` | value | `messages` | | `textMessage` | value | `messages` | | `partsToText` | value | `messages` | | `addSuggestion` | value | `suggestions` | | `removeSuggestion` | value | `suggestions` | | `createAssistantStream` | value | `stream` | | `onStreamSettled` | value | `stream` | | `SetMessages` | type | `stream` | | `AssistantStream` | type | `stream` | | `appendTextPart` | value | `parts` | | `appendReasoningPart` | value | `parts` | | `upsertToolPart` | value | `parts` | | `upsertCardPart` | value | `parts` | | `fingerprint` | value | `parts` | | `ReasoningOpts` | type | `parts` | | `updateThreadMessages` | value | `threads` | | `bindThreadMessages` | value | `threads` | | `createThreadSessions` | value | `threads` | | `ThreadLike` | type | `threads` | | `SetThreads` | type | `threads` | | `BindThreadOptions` | type | `threads` | | `ThreadSessions` | type | `threads` | | `parseStoredThread` | value | `persistence` | | `createSaveScheduler` | value | `persistence` | | `ParsedThread` | type | `persistence` | | `DroppedStored` | type | `persistence` | | `SaveScheduler` | type | `persistence` | | `SaveSchedulerOptions` | type | `persistence` | | `createMockResponder` | value | `mock` | | `DEFAULT_MOCK_REPLIES` | value | `mock` | | `MOCK_BANNER` | value | `mock` | | `MOCK_MARKER` | value | `mock` | | `MOCK_MARKER_KEY` | value | `mock` | | `MOCK_MODEL_ID` | value | `mock` | | `MockReply` | type | `mock` | | `MockResponder` | type | `mock` | | `MockResponderOptions` | type | `mock` | | `MockSource` | type | `mock` | | `MockToolCall` | type | `mock` | | `MockTurn` | type | `mock` | | `ChatMessage` | type | `../elements/chat-types` | | `ChatMessageAction` | type | `../elements/chat-types` | | `CustomAction` | type | `../elements/chat-types` | | `AvatarData` | type | `../elements/chat-types` | | `FeedbackVote` | type | `../elements/chat-types` | | `MessagePart` | type | `../elements/chat-types` | | `MessageSource` | type | `../elements/chat-types` | | `RawOrigin` | type | `../elements/chat-types` | | `ToolPart` | type | `../components/tool-types` | | `ToolKind` | type | `../components/tool-classify` | | `classifyTool` | value | `../components/tool-classify` | | `CardEnvelope` | type | `../primitives/card-contract` | | `AttachmentData` | type | `../components/attachment-types` | #### `@kitn.ai/ui/state` · `stream` — the shipped declarations ```ts /** The one universal contract: a functional-updater setter (React setState shape). */ export type SetMessages = (updater: (prev: ChatMessage[]) => ChatMessage[]) => void; /** Every OBJECT payload a `MessagePart` variant carries. `type`/`raw` are the * variant's own bookkeeping, not payload; the primitive payloads (`text`, * `label`, `index`, ...) drop out at `Extract<..., object>`. */ type PartPayload = Extract] : never) : never, object>; /** Every bag one of these mutators takes: the part payloads plus the options * bags that are not payloads themselves. */ type MutatorBag = PartPayload | ReasoningOpts; /** `keyof` over a union member-by-member. The bare `keyof (A | B)` is the * INTERSECTION of their keys, which is the opposite of what this needs. */ type KeysOf = T extends unknown ? keyof T : never; /** `Shape`, but any key that belongs exclusively to a SIBLING bag is a compile * error. Keys `Shape` never heard of are untouched, so a consumer's own * superset of a citation still passes — only the mix-ups fail. That is a * denylist, not an exact type, and deliberately so: boundary point 1. */ type Unmixed = Shape & { [K in Exclude, keyof Shape>]?: never; }; /** A fluent builder for one in-flight assistant message. Owns no state. */ export interface AssistantStream { readonly id: string; appendText(delta: string): AssistantStream; appendReasoning(delta: string, opts?: Unmixed): AssistantStream; upsertTool(toolCallId: string, patch: Unmixed>): AssistantStream; /** Adds a card, or REPLACES the existing one with the same `envelope.id`. A * model that revises a card mid-turn re-sends the whole envelope, so a second * call with a known id revises that card in place rather than rendering a * second copy of it. See `upsertCardPart`. */ addCard(envelope: CardEnvelope): AssistantStream; addSource(source: Unmixed): AssistantStream; addFile(attachment: AttachmentData): AssistantStream; done(): void; /** Settles the turn as FAILED and puts `reason` where the reader can find it. * * WHERE THE REASON LANDS, in order: * 1. every tool part that has NOT produced a result flips to `output-error` * with `errorText: reason`, so no panel spins forever. A tool ALREADY in * `output-error` with its own non-empty `errorText` is left exactly as it * is: "search index offline" is the answer to what went wrong and * "Connection lost." is the generic outer symptom, so overwriting the * specific with the generic loses the only actionable thing on the * message. It still counts as carrying the failure. A tool in * `output-error` with NO text does get filled in — an error panel with * nothing in it says no more than a blank bubble does. * 2. if no part was able to carry it, the reason is APPENDED as its own text * part. * * Never both — a turn does not report the same failure twice. * * "Where the reader can FIND it" is deliberate, and rule 1 and rule 2 are not * equally loud. Rule 2 is text in the thread: read without any interaction. * Rule 1 is a tool panel, which renders COLLAPSED — the header shows an error * icon and a badge, so the failure is unmissable, but the `errorText` itself * is one click away. That is the right default for a failed tool inside an * otherwise readable answer, and it is why rule 2 exists rather than always * stamping the panel and calling it reported. * * Rule 2 is the whole point and it used to be missing. `abort` only ever did * rule 1, so a TEXT-ONLY turn — every turn of a text-only support widget — * had nothing to stamp: the string was discarded and a failed request * rendered an EMPTY assistant bubble, while the consumer that passed a * perfectly good sentence believed it had reported the failure. That is the * repo's "decide loudly" rule broken on the one path where being quiet is * worst, and it shipped because the discard is invisible from the call site. * * It is a NEW part, never a merge onto the trailing text: gluing "Connection * lost." onto the model's half-finished sentence reads as the model saying * it. Text that already streamed stays exactly where it is. * * The reason is TRIMMED, and `abort()` with no reason — or one that is empty * or all whitespace — appends nothing: there is no reason to discard, and the * kit will not invent copy the consumer did not write. Rule 1 still runs, * with `errorText: undefined`. Whitespace is not a pedantic case here: the * scaffold hands over `err.message`, and an `Error` is free to carry `''`, * which would otherwise render an INVISIBLE text part — a blank bubble that * also claims to have said something. * * Settled is settled: after `done()` or a first `abort()`, this is a no-op * like every other mutator, so the reason cannot be appended twice. * * The reason is CONSUMER-facing text and is rendered as markdown like any * other text part. Pass a sentence a visitor can read, not a stack trace. */ abort(reason?: string): void; } /** Start an assistant message and drive it through `set`. New refs on every mutation. */ export declare function createAssistantStream(set: SetMessages, init?: Partial): AssistantStream; /** Wrap a stream so `onSettle` fires on done/abort (used to toggle a `loading` flag). * Preserves the fluent chain by returning the wrapper from every mutator. */ export declare function onStreamSettled(inner: AssistantStream, onSettle: () => void): AssistantStream; ``` #### `@kitn.ai/ui/state` · `parts` — the shipped declarations ```ts /** Stable structural fingerprint. Key order independent, so an identical snapshot * arriving twice compares equal and can be skipped. */ export declare function fingerprint(value: unknown): string; /** Appends to the trailing text part, or OPENS A NEW ONE if the last part is not * text. This is what stops a post-tool answer being glued onto the pre-tool text. */ export declare function appendTextPart(parts: MessagePart[], delta: string): MessagePart[]; export interface ReasoningOpts { index?: number; /** Namespaces `index` to one provider response stream. Producers that read more * than one stream into the SAME message must set it. See `appendReasoningPart`. */ streamId?: string; label?: string; signature?: string; raw?: RawOrigin; } /** Keyed by `(streamId, index)` so parallel reasoning blocks stay distinct. * * WHY THE KEY IS A PAIR. A block index alone is NOT unique inside one `parts` * array. Anthropic numbers content blocks per MESSAGE and restarts at 0 on the * next one, while a tool loop folds every round into a single assistant turn. * Keyed on index alone, round 2's thinking block (index 0) merges into round 1's * part: the text concatenates and `raw: opts.raw ?? cur.raw` OVERWRITES round 1's * verbatim provider payload with round 2's. `toAnthropicMessages` then emits one * thinking block where two belong, carrying round 2's signature in round 1's * position -- a modified-and-filtered thinking block, which is exactly the 400 * the verbatim `raw` channel exists to prevent. * * `streamId` is the namespace: one value per provider response stream, attached * by `consumeModelStream`. Two rounds are two streams, so their index 0s are two * parts. Producers that drive a sink from a single stream can omit it; `undefined` * is its own namespace and behaves exactly as before. * * Returns the SAME array reference when the merge produces an identical part, * for the same reason `upsertToolPart` does: a new `parts` array is the * re-render signal, so handing one back for a delta that changed nothing is a * spurious render. * * An EMPTY delta is not a no-op and must still reach here: it is how a redacted * reasoning block, a `signature_delta` and an assembled `content_block_stop` * block arrive, and how a format opens a block at the right position so block * ORDER survives into `parts`. Those carry a new `raw`/`signature`/index and so * compare unequal and DO rebuild. What the check absorbs is the other empty * frame: one carrying nothing new, which a provider is free to send repeatedly. * * `signature` and `raw` resolve with `??`, so an explicit `undefined` from a * later delta never blanks a value an earlier one established. Pass a DEFINED * value to replace either; there is no way to clear them. */ export declare function appendReasoningPart(parts: MessagePart[], delta: string, opts?: ReasoningOpts): MessagePart[]; /** Creates or REPLACES a card part, keyed on `envelope.id`. Returns the SAME array * reference when the incoming envelope is structurally identical to the current * one, for the same reason `upsertToolPart` does: a new `parts` array is the * re-render signal, so handing one back for a revision that changed nothing is a * spurious render. * * WHY THIS REPLACES WHERE `upsertToolPart` MERGES. A tool part is patched * fragment-by-fragment as its arguments stream in, which is why that function * needs carry-forward rules for `raw` and `kind` — a later patch that omits a * field is not asserting the field is gone. A card envelope is the opposite: it * arrives WHOLE, as one complete tool result, so an omitted field IS an * assertion. Last-write-wins is both simpler and the only semantics under which * a host can CLEAR `resolution` to re-open a dismissed card — a field-by-field * merge can only ever set that field, never unset it, so `CardPolicy.onReopen` * (see `primitives/card-contract.ts`) would have no way to express its result. * * The PART-level `raw` is preserved across a revision. It is a different field * from anything inside the envelope: the untranslated provider payload the part * was built from, attached once by the producer, which a fresh envelope carries * no opinion about. * * Position is preserved: a revised card stays where it first appeared in the * thread rather than jumping past the text that followed it. */ export declare function upsertCardPart(parts: MessagePart[], envelope: CardEnvelope): MessagePart[]; /** Creates or merges a tool part. Returns the SAME array reference when the merge * produces an identical tool, so repeated snapshots do not trigger a re-render. * * Two fields do NOT follow plain spread semantics, because a streaming provider * hands them over on one fragment and then keeps patching the rest: * - `kind`: a value the consumer set is preserved across later patches instead * of being reverted to `classifyTool(type)` (see `resolveKind`). * - `raw`: an explicit `raw: undefined` never blanks a `raw` an earlier patch * established. Pass a DEFINED `raw` to replace it; there is no way to clear it. */ export declare function upsertToolPart(parts: MessagePart[], toolCallId: string, patch: Partial): MessagePart[]; ``` #### `@kitn.ai/ui/state` · `mock` — the shipped declarations ```ts /** The `model` every mock frame reports. Not a model any provider serves — see * tell 3 in the header. */ export declare const MOCK_MODEL_ID = "kai-mock"; /** The marker field carried by every mock frame. Tell 2. */ export declare const MOCK_MARKER_KEY = "_kai_mock"; /** The value of that marker: a whole sentence, because it is read by a human * staring at a logged frame and wondering where the reply came from. */ export declare const MOCK_MARKER = "no provider was contacted \u2014 this reply was generated locally by createMockResponder() from @kitn.ai/ui/state"; /** The SSE comment that opens every mock stream. Tell 1. */ export declare const MOCK_BANNER = ": kai-mock \u2014 NO PROVIDER WAS CONTACTED. no provider was contacted \u2014 this reply was generated locally by createMockResponder() from @kitn.ai/ui/state."; /** The default canned replies, cycled per turn so a multi-turn preview stays * coherent instead of repeating one line forever. */ export declare const DEFAULT_MOCK_REPLIES: readonly string[]; /** One scripted tool call for a mock turn. Framed exactly the way the OpenAI * chat-completions wire frames a real one — an announce fragment carrying * `id`/`function.name`, then the argument JSON streamed in fragments — so the * kit's own reader (`readOpenAIStream`) reassembles it through the same path a * real provider's call takes. */ export interface MockToolCall { /** The tool name, e.g. `'get_weather'` or a card tool like `'kai_confirm'`. */ name: string; /** The call's arguments. Serialized with `JSON.stringify` and streamed as * fragments, like a real provider. Defaults to `{}`. */ arguments?: unknown; /** Explicit tool-call id. Defaults to `call_kai-mock--`, which keeps * the mock's naming tell (see tell 3): no provider issues ids in that shape. */ id?: string; } /** One scripted citation for a mock turn. Framed as an OpenAI-wire * `url_citation` annotation — the shape `readOpenAIStream` already parses into * a `source` MessagePart — so a scripted citation takes the exact path a real * provider's takes and renders through the same guarded sinks. */ export interface MockSource { /** The cited URL. Scheme policy is enforced at the render sink * (`isSafeUrl`), same as for a real model's citation. */ url: string; /** Human title for the citation chip. */ title?: string; /** Quoted snippet; rides the wire as the annotation's `content` field. */ snippet?: string; } /** A scripted mock turn: optional reasoning, then text, then citations, then * tool calls. A turn with tool calls finishes `finish_reason: 'tool_calls'`, * exactly as a real tool-calling turn does; a turn without them finishes * `'stop'`. */ export interface MockTurn { /** Reasoning streamed (token by token) BEFORE the text, as `delta.reasoning` * — the OpenRouter-normalized sibling `readOpenAIStream` folds into a * `reasoning` part. Models think before they answer; the mock does too. */ reasoning?: string; /** Text streamed (token by token) before the tool calls. */ text?: string; /** Citations announced after the text, one `url_citation` annotation frame * each, so consecutive `source` parts land the way a real cited answer's * do (and collapse into the citations strip). */ sources?: readonly MockSource[]; /** Tool calls announced this turn, in order. */ toolCalls?: readonly MockToolCall[]; } /** A canned reply: plain text, or a scripted turn. A string is exactly * `{ text }` — the pre-tool-call API unchanged. */ export type MockReply = string | MockTurn; export interface MockResponderOptions { /** Canned replies, cycled one per turn. Plain strings stream as text; a * `MockTurn` can also script reasoning, citations and tool calls * (`{ reasoning, text, sources, toolCalls }`), which is what lets the * zero-config mock exercise the kit's reasoning/source/tool/card paths * without hand-rolled SSE framing. Defaults to `DEFAULT_MOCK_REPLIES`. */ replies?: readonly MockReply[]; /** Delay between chunks, in ms. Defaults to 24 — fast enough to feel alive, * slow enough that the streaming is visible. `0` streams as fast as the * event loop allows, which is what tests want. */ delayMs?: number; /** How many whitespace-delimited tokens ride in each frame. Defaults to 1 * (token by token). Larger values coarsen the cadence. */ chunkSize?: number; /** Log a one-time notice on the first turn. Defaults to `true`: the point of * this module is that a mock reply is hard to mistake for a real one, and a * console line is the fastest way for a human to notice. Pass `false` in * tests, or wherever the banner and the frame markers are tell enough. */ announce?: boolean; } /** Produces one turn's worth of SSE frames. Structurally a `StreamSource`, so it * goes straight into `readOpenAIStream(responder(text), stream)`. */ export type MockResponder = (prompt?: string) => AsyncIterable; /** * Build a mock responder. * * ```ts * import { createAssistantStream, createMockResponder } from '@kitn.ai/ui/state'; * import { readOpenAIStream } from '@kitn.ai/ui/wire'; * * const mockResponse = createMockResponder(); * const stream = createAssistantStream(setMessages); * await readOpenAIStream(mockResponse(value), stream); // <- swap for fetch() * stream.done(); * ``` */ export declare function createMockResponder(options?: MockResponderOptions): MockResponder; ``` ### `@kitn.ai/ui/wire` The model-stream adapter. The kit PARSES, the consumer FETCHES: you make the request (your endpoint, your key), hand the response body to a reader, and it folds provider SSE onto message parts. The encoders turn the thread back into provider messages. Every export (66, derived from `dist/wire/index.d.ts`): | Export | Kind | Module | |---|---|---| | `readModelStream` | value | `read` | | `readOpenAIStream` | value | `read` | | `readAnthropicStream` | value | `read` | | `WireError` | value | `read` | | `StreamSource` | type | `read` | | `ReadOptions` | type | `read` | | `consumeModelStream` | value | `consume` | | `createToolCallAccumulator` | value | `consume` | | `applyToolOutput` | value | `sink-helpers` | | `applyToolFailure` | value | `sink-helpers` | | `bufferText` | value | `sink-helpers` | | `toOpenAIMessages` | value | `encode` | | `toAnthropicMessages` | value | `encode` | | `WireEncodeError` | value | `encode` | | `AnthropicContentBlock` | type | `encode` | | `AnthropicEncodeOptions` | type | `encode` | | `AnthropicWireMessage` | type | `encode` | | `FileEncodeOptions` | type | `encode` | | `OpenAIContentPart` | type | `encode` | | `OpenAIEncodeOptions` | type | `encode` | | `OpenAIReasoningDetail` | type | `encode` | | `OpenAIToolCall` | type | `encode` | | `OpenAIWireMessage` | type | `encode` | | `UnencodableFilePolicy` | type | `encode` | | `encodableMediaTypes` | value | `media-types` | | `resolveMediaPolicy` | value | `media-types` | | `EncodableKind` | type | `media-types` | | `MediaDecision` | type | `media-types` | | `MediaPolicy` | type | `media-types` | | `MediaPolicyOptions` | type | `media-types` | | `MediaTypeFilter` | type | `media-types` | | `openaiChatFormat` | value | `formats/openai` | | `anthropicMessagesFormat` | value | `formats/anthropic` | | `sseDataFrames` | value | `sse` | | `sseJson` | value | `sse` | | `readableToAsyncIterable` | value | `sse` | | `ByteSource` | type | `sse` | | `subscribeWireDiagnostics` | value | `diagnostics` | | `AppRequestEvent` | type | `diagnostics` | | `EncodeAttachmentReport` | type | `diagnostics` | | `EncodeDroppedEvent` | type | `diagnostics` | | `EncodeRequestEvent` | type | `diagnostics` | | `WireCloseEvent` | type | `diagnostics` | | `WireDiagnosticBase` | type | `diagnostics` | | `WireDiagnosticEvent` | type | `diagnostics` | | `WireFailedEvent` | type | `diagnostics` | | `WireFrameEvent` | type | `diagnostics` | | `WireInterruptedEvent` | type | `diagnostics` | | `WireOpenEvent` | type | `diagnostics` | | `WirePartEvent` | type | `diagnostics` | | `normalizeStopReason` | value | `chunk` | | `AssistantStreamSink` | type | `chunk` | | `ConsumeOptions` | type | `chunk` | | `ModelStreamChunk` | type | `chunk` | | `ModelToolCall` | type | `chunk` | | `ModelToolCallDelta` | type | `chunk` | | `ModelTurn` | type | `chunk` | | `ModelUsage` | type | `chunk` | | `StopReason` | type | `chunk` | | `WireFormat` | type | `chunk` | | `WireFormatReader` | type | `chunk` | | `ChatMessage` | type | `../elements/chat-types` | | `MessagePart` | type | `../elements/chat-types` | | `MessageSource` | type | `../elements/chat-types` | | `RawOrigin` | type | `../elements/chat-types` | | `ToolPart` | type | `../components/tool-types` | #### `@kitn.ai/ui/wire` · `read` — the shipped declarations ```ts export type StreamSource = Response | ReadableStream | AsyncIterable; export interface ReadOptions extends ConsumeOptions { format: WireFormat; } /** A non-ok HTTP response from the model endpoint, with the provider's own error * body attached when there is one. Thrown before a single chunk is read, so a * caller can distinguish "the request failed" from "the stream carried an * error", which is `ModelTurn.error`. */ export declare class WireError extends Error { readonly status: number; readonly statusText: string; /** The response body parsed as JSON, or undefined when it was not JSON (an * HTML error page from a proxy, most often). */ readonly body: unknown; /** The raw response body, always. */ readonly bodyText: string; constructor(status: number, statusText: string, bodyText: string, body: unknown); } /** Read one turn off the wire in `opts.format` and drive `sink` with it. */ export declare function readModelStream(source: StreamSource, sink: AssistantStreamSink, opts: ReadOptions): Promise; /** OpenAI chat-completions SSE. Also what all nine catalog integrations except * `mock` re-frame to server-side, so this is the common path. */ export declare function readOpenAIStream(source: StreamSource, sink: AssistantStreamSink, opts?: ConsumeOptions): Promise; /** Anthropic Messages SSE. */ export declare function readAnthropicStream(source: StreamSource, sink: AssistantStreamSink, opts?: ConsumeOptions): Promise; ``` #### `@kitn.ai/ui/wire` · `encode` — the shipped declarations ```ts export interface OpenAIToolCall { id: string; type: 'function'; function: { name: string; arguments: string; }; } /** One `reasoning_details` entry. Provider-owned open shape, kept as a record * for the same reason `AnthropicContentBlock` is: an opaque entry has to pass * through UNTOUCHED, and a closed type would be a list of the fields we happen * to have seen. */ export type OpenAIReasoningDetail = Record; /** A multimodal user message's content entries. `image_url` takes an https URL * or a `data:` URI in the same field; `file` takes `file_data`, which is a DATA * URI on this wire (`data:application/pdf;base64,...`) and not bare base64. */ export type OpenAIContentPart = { type: 'text'; text: string; } | { type: 'image_url'; image_url: { url: string; }; } | { type: 'file'; file: { filename?: string; file_data: string; }; }; export interface OpenAIWireMessage { role: 'system' | 'user' | 'assistant' | 'tool'; /** An ARRAY only when the turn carries an encodable `file` part. A text-only * turn stays a plain string, so adding attachment support changed nothing * about what an existing thread puts on the wire. */ content: string | OpenAIContentPart[] | null; tool_calls?: OpenAIToolCall[]; tool_call_id?: string; name?: string; /** Only ever present when `toOpenAIMessages` was asked for it. See * `OpenAIEncodeOptions.reasoning`. */ reasoning_details?: OpenAIReasoningDetail[]; } /** EXTENDS rather than restates the file options: `onUnencodableFile` and * `accept` mean the same thing on both wires, and a second declaration of them * here is a second place to forget to update. */ export interface OpenAIEncodeOptions extends FileEncodeOptions { /** * Whether to send the assistant's own reasoning back with the thread. * * DEFAULT `'omit'`, and that default is a measurement, not caution. Omitting * reasoning is accepted by every configuration tested -- five live omission * trials plus 28 recorded live requests per configuration across the spike's * conformance sweep, zero 400s -- so the path that ships today demonstrably * works, while including reasoning cost about 25% more prompt tokens per round * when measured (665 -> 834 on a two-round loop). A library does not get to * raise every consumer's bill and add a new provider-validation surface as a * side effect of a bug fix. * * `'include'` is for a multi-round TOOL loop, which is where OpenRouter says it * pays: "when you post tool results, including the original reasoning ensures * the model can continue its reasoning from where it left off". Measured * accepted (HTTP 200) for a signed Anthropic block and for an OpenAI encrypted * block, over the OpenAI-compatible wire. * * The Anthropic wire has no such knob because it has no such choice: a filtered * or rebuilt thinking block there is a hard 400. */ reasoning?: 'omit' | 'include'; } /** * What to do with a `file` part this wire cannot carry. * * DEFAULT `'throw'`, and the default is the whole point. Skipping is how * attachments came to render perfectly in the thread and reach the model as * nothing: the developer wires up upload, watches it work, and ships a model * that cannot see the file. A throw here names the message, the part and the * reason, which is strictly more than a 400 at request time would tell you. * * `'skip'` restores the lenient behaviour for a host that would rather send a * degraded turn than fail one. It is silent, but it is silence the developer * asked for by name, which is the difference that matters. */ export type UnencodableFilePolicy = 'throw' | 'skip'; export interface FileEncodeOptions { onUnencodableFile?: UnencodableFilePolicy; /** * Narrow which attachment media types reach the wire, as HTML `accept` syntax * (`'image/*,application/pdf'`) or an array of the same. * * THE SAME STRING the composer takes as ``, resolved by * the same function against the same declaration -- so a developer writes the * set once as a constant and hands it to both ends. Omitted means the kit's * full capability set, which is `encodableMediaTypes()`. * * It can only NARROW. Naming a type the encoders cannot represent does not * enable it; that would just move the failure to a provider 400. */ accept?: MediaTypeFilter; /** * The app's own id for the logical turn this encode belongs to, carried onto * every diagnostic event the encode emits. Purely diagnostic: nothing here * branches on it and it never reaches a provider. * * THE SAME FIELD, THE SAME MEANING, as `ConsumeOptions.traceId` -- and that * symmetry is the whole payoff. Encoding happens BEFORE a read opens, so * there is no stream to attach an encode to and the kit will not invent one. * Pass the same id to both halves: * * const body = toOpenAIMessages(messages, { traceId: 'turn-42' }); * readOpenAIStream(res, sink, { traceId: 'turn-42' }); * * and the request and the response it produced sit together, with a tool loop * or a sub-agent fan-out grouping into one trace. Without it you still see * both halves; they are simply unlinked, which is the honest rendering -- * pinning an encode to "the next stream that opens" would be a guess, and an * encode may be followed by no stream at all. */ traceId?: string; /** The app's name for this call inside its trace (`'planner'`, `'retry-2'`). * Same field and same meaning as `ConsumeOptions.label`. Absent when not * supplied. */ label?: string; } export type AnthropicEncodeOptions = FileEncodeOptions; /** Anthropic content blocks are an open, provider-owned union. Keeping them as * records is what lets a verbatim `thinking` payload pass through UNTOUCHED, * which is the entire point of this encoder. */ export type AnthropicContentBlock = Record; export interface AnthropicWireMessage { role: 'user' | 'assistant'; content: AnthropicContentBlock[]; } /** A message cannot be encoded without losing something the provider will reject. * Thrown at encode time, on purpose: a throw here beats a 400 at request time, * because here you still know which message and which part caused it. */ export declare class WireEncodeError extends Error { readonly messageId: string; readonly partIndex: number; constructor(message: string, messageId: string, partIndex: number); } /** * ChatMessage[] to an OpenAI chat-completions `messages` array. * * ONE ChatMessage CAN BECOME SEVERAL WIRE MESSAGES. The kit streams a whole * assistant turn into a single message, so text, a tool call and the model's * answer to that call all live in one `parts` array. The OpenAI wire has no such * shape: a `role:'tool'` result must sit between the assistant message that * announced the call and whatever the model said afterwards. So the turn is * SPLIT at each tool boundary, into * * assistant(pre-tool text + tool_calls) -> tool(result)... -> assistant(answer) * * Flattening instead would put the model's answer BEFORE the result it was based * on. No endpoint rejects that, which is exactly why it is worth spelling out: * it quietly degrades every later round of a tool loop. * * Consecutive tool parts stay in ONE assistant message, because parallel calls * are announced together and their results follow together. * * A turn that encodes to nothing is SKIPPED, never sent as `{ content: null }` * with no `tool_calls`: OpenAI treats `content` as required unless `tool_calls` * is present, and strict-compatible endpoints reject it. * * REASONING IS OPT-IN, and off by default. OpenRouter's OpenAI-compatible * endpoint does have a channel on the way back in -- `reasoning_details` on the * assistant message -- and `{ reasoning: 'include' }` uses it, one entry per * reasoning part, in part order, reassembled by `reasoningDetailOf` rather than * echoed out of `part.raw`. Read that function for which blocks make it and why. * The default omits, because omitting is measured-accepted everywhere and costs * about 25% fewer prompt tokens per round; see `OpenAIEncodeOptions.reasoning`. * * Reasoning alone still encodes to NOTHING. A block is content the model already * produced, not a reason to send a turn, so a message carrying reasoning and no * text and no settled tool is skipped exactly as before, rather than becoming * `{ content: null }` with no `tool_calls`. * * `card` and `source` parts are never encoded; they are kit-side. * * `file` parts ARE encoded, on a USER turn, and a turn carrying nothing but an * attachment is now a real message rather than nothing. Images become * `image_url` (https URL or `data:` URI alike); a base64 PDF becomes a `file` * part whose `file_data` is the data URI. Two cases have no form here and THROW * by default: a remote PDF, because this wire's `file` part has no URL variant, * and anything that is neither -- see `UnencodableFilePolicy` for why the * default is a throw and not a skip. * * A `file` part on an ASSISTANT turn is still dropped. Neither API accepts image * or document content in an assistant message, so there is nothing to encode it * to; attachments belong to the user turn that sent them. */ export declare function toOpenAIMessages(messages: ChatMessage[], options?: OpenAIEncodeOptions): OpenAIWireMessage[]; /** * ChatMessage[] to an Anthropic Messages `messages` array. THE ROUND-TRIP * ENCODER. * * A reasoning block is emitted as `part.raw.payload` verbatim and is NEVER * rebuilt from `text` plus `signature`: Anthropic returns 400 if a thinking * block in the most recent assistant message is modified, reordered, filtered or * reconstructed. A reasoning part with no `raw`, or with a `raw` captured from * some other format, therefore THROWS rather than silently producing a request * that will fail. * * Block order follows part order, which follows stream order, with no filtering, * because the API validates order too. An empty-text reasoning part (an omitted * or redacted block) is still emitted: the docs require sending back every block * "including any blocks with empty thinking fields". * * ONE ChatMessage CAN BECOME SEVERAL WIRE MESSAGES, for the same reason as * `toOpenAIMessages`: the kit streams a whole assistant turn into one message, so * the tool call and the model's answer to it share a `parts` array, but Anthropic * carries the result in a SEPARATE user message that has to sit between them. So * the turn is SPLIT at each tool boundary, into * * assistant(pre-tool blocks + tool_use) -> user(tool_result)... -> assistant(answer) * * Flattening instead puts the model's answer BEFORE the result it was based on, * and strands every later round's thinking block in the first assistant message. * Consecutive tool parts stay in ONE assistant message, because parallel calls are * announced together and their results come back together. * * Adjacent user messages are MERGED. The API combines consecutive same-role turns * itself rather than rejecting them, so this is not what stands between you and a * 400; it is emitted anyway because the tool-result turn and a following user turn * are one turn, several OpenAI-compatible Anthropic proxies do enforce strict * alternation, and the merged form is what the models are trained on. Ordering is * safe by construction: `results` is only non-empty when `blocks` is, so a * tool_result message always follows its assistant message and can never be * appended after a plain user turn. * * `file` parts on a USER turn become `image` and `document` blocks, in part * order. Both take `source: {type:'base64'}` and `source: {type:'url'}`, so this * wire can carry a remote PDF that `toOpenAIMessages` has to refuse. Anything * neither API accepts as message content THROWS by default; see * `UnencodableFilePolicy`. A `file` part on an ASSISTANT turn is dropped, because * an assistant message here carries only text, thinking and tool_use. * * Asymmetry worth knowing: `tool_use.input` is a parsed OBJECT on this wire, not * a string, so it uses `input` and not `rawInput`. Only thinking blocks carry a * verbatim requirement. */ export declare function toAnthropicMessages(messages: ChatMessage[], options?: AnthropicEncodeOptions): AnthropicWireMessage[]; ``` #### `@kitn.ai/ui/wire` · `chunk` — the shipped declarations ```ts /** One fragment of a tool call. */ export interface ModelToolCallDelta { /** * The ONLY thing correlating fragments, and its NAMESPACE IS FORMAT-DEFINED. * `openaiChatFormat` uses the position in `delta.tool_calls`; * `anthropicMessagesFormat` uses the content-block index. Both are correct and * both are stable within one stream, but they are not the same number, so a * third-party format must pick one and stay consistent with itself. */ index: number; id?: string; /** Usually whole on the first fragment; a few providers split it. */ name?: string; /** A FRAGMENT of the JSON arguments string, not valid JSON on its own. */ arguments?: string; /** A result the PROVIDER executed (Anthropic web_search_tool_result, an OpenAI * built-in). Completes the panel with no host work. */ output?: Record; /** A provider-executed tool that failed. */ outputError?: string; } /** Field names are deliberately provider-neutral. OpenAI says prompt/completion, * Anthropic says input/output; input/output is the one that reads correctly for * both. */ export interface ModelUsage { inputTokens?: number; outputTokens?: number; totalTokens?: number; /** Non-zero proves the model reasoned even when no reasoning text streamed. */ reasoningTokens?: number; cachedInputTokens?: number; costUsd?: number; } export interface ModelStreamChunk { text?: string; /** * The model id the RESPONSE stated, verbatim; REPORT, NEVER INFER. * * Read from the response rather than the request, which is what makes it work * at all when the app builds its own fetch and the kit never sees what was * asked for. Providers commonly resolve an alias (ask for `gpt-4o`, get * `gpt-4o-2024-08-06`); through a gateway the value is the gateway's own id. * Both are reasons to pass the string through untouched. * * It is NOT guaranteed. A proxy can strip or rewrite it and a custom endpoint * may omit it, so a consumer renders it as ABSENT when it is absent. Filling * the gap with the requested id would lie in exactly the requested-vs-served * mismatch this field exists to catch. */ model?: string; /** * Reasoning delta. `''` is MEANINGFUL, not a no-op: a redacted block has no * readable text but still carries a payload that must round-trip, and a format * uses an empty delta to OPEN a reasoning part at the right position in the * stream so block order survives into `parts`. */ reasoning?: string; /** The provider's BLOCK index. Keeps parallel reasoning blocks distinct. * Omitted means block 0, the single-block case every provider degrades to. */ reasoningIndex?: number; /** * The UNTRANSLATED provider payload for this reasoning block. Valid on a chunk * with NO reasoning text at all, which is the whole point: Anthropic returns * 400 if a `thinking` block is modified, reordered or RECONSTRUCTED, so an * encoder has to echo the original block rather than rebuild one from `text` * plus `signature`. */ reasoningRaw?: RawOrigin; /** Informational. `reasoningRaw` is the round-trip channel, not this. */ reasoningSignature?: string; toolCalls?: ModelToolCallDelta[]; /** Citations the model produced. A run of consecutive `source` parts renders * as one citation row (`part="citations"`), outside the message bubble. */ sources?: MessageSource[]; /** Provider VERBATIM: 'stop' | 'tool_calls' | 'end_turn' | 'max_tokens' | ... * Normalizing in place would destroy information consumers branch on. */ finishReason?: string | null; usage?: ModelUsage; /** An in-band provider error (the HTTP response was already 200). */ error?: { code?: string | number; message: string; }; } /** One vocabulary across formats, for code that has to BRANCH. `finishReason` * stays beside it, verbatim, for code that has to REPORT. */ export type StopReason = 'stop' | 'length' | 'tool-calls' | 'content-filter' | 'error' | 'other'; /** Unknown reasons degrade to 'other' rather than throwing: providers add stop * reasons without warning and a new one must not take a turn down. */ export declare function normalizeStopReason(finishReason: string | null | undefined): StopReason | undefined; /** * The subset of the kit's `AssistantStream` the adapter drives. Declared * STRUCTURALLY so the adapter has no runtime dependency on a stream * implementation and can be tested against a recorder. The kit's real * `AssistantStream` satisfies it as-is: same method names, same arities, and its * `AssistantStream` returns are assignable to `unknown`. * * `addSource` is optional so a hand-rolled three-method sink still compiles. */ export interface AssistantStreamSink { appendText(delta: string): unknown; appendReasoning(delta: string, opts?: ReasoningOpts): unknown; /** Create-or-merge. There is no separate "announce" call: handing a patch for * an unknown `toolCallId` creates the ToolPart, and every later patch merges. */ upsertTool(toolCallId: string, patch: Partial): unknown; addSource?(source: MessageSource): unknown; } /** One tool call reassembled out of the stream's fragments. */ export interface ModelToolCall { /** The delta index that correlated this call's fragments. */ index: number; /** Provider call id (synthesised as `call_` if the provider omits it). */ id: string; name: string; /** The RAW accumulated argument fragments. Echo THIS back on the next turn, * not a re-stringified parse. */ argumentsText: string; /** Parsed arguments: present only when `argumentsText` was a valid JSON object. */ input?: Record; /** Present only for a call the PROVIDER executed. */ output?: Record; /** True when the provider ran the tool and returned its result in-stream. The * host must NOT execute these. */ providerExecuted?: boolean; /** Why this call is unusable (malformed or truncated args, missing name). */ error?: string; } /** Everything one assistant turn produced. */ export interface ModelTurn { /** The turn as ORDERED MESSAGE PARTS, built with the kit's own part builders, * so it is exactly what the sink was driven with. Covers this turn only. */ parts: MessagePart[]; /** Flat concatenation of the text deltas. The provider wire format is a flat * string, so this is kept for encoders. Not the content model. */ text: string; /** Flat concatenation of the reasoning deltas, for the same reason. */ reasoning: string; toolCalls: ModelToolCall[]; sources: MessageSource[]; /** The provider's own word for why it stopped. Never normalized. */ finishReason: string | null; /** The same fact in one vocabulary. Branch on this. */ stopReason?: StopReason; error?: { code?: string | number; message: string; }; usage?: ModelUsage; /** How many chunks carried a NON-EMPTY reasoning delta. Zero with a non-zero * `usage.reasoningTokens` means the provider hid the thinking text. */ reasoningChunks: number; chunks: number; } export interface ConsumeOptions { /** Label for the reasoning disclosure. Defaults to 'Thinking'. */ reasoningLabel?: string; /** * Correlates diagnostics and namespaces reasoning parts for this consume call; * assigned automatically when absent. * * Supply one only to tie a read to an id you already hold. Two reads into the * SAME sink must not share a value: the id is what keeps a second round's * block 0 from merging into the first round's reasoning part. */ streamId?: string; /** * The app's own grouping of several reads into ONE logical turn, carried onto * every diagnostic event this read emits. * * THE KIT REPORTS WHAT THE APP DECLARES AND GROUPS NOTHING ON ITS OWN. A chat * app running a tool loop, or fanning out to sub-agents, makes several model * calls that belong to one turn; the kit sees one Response at a time and has * no way to know which ones those are. So it does not guess: * * readOpenAIStream(res, sink, { traceId: 'turn-42', label: 'planner' }) * * Absent when not supplied -- the key is not present on the events at all, * rather than present and undefined. Purely diagnostic: nothing in the parse * branches on it and it never reaches a provider. */ traceId?: string; /** The app's name for THIS read inside its trace (`'planner'`, * `'executor'`, `'retry-2'`). Carried onto every diagnostic event, and * absent when not supplied. Never derived from the format or the model. * * Not to be confused with `reasoningLabel`, which is UI copy for the * reasoning disclosure; this one is never rendered to an end user. */ label?: string; /** Fires once per tool call the moment its arguments parse cleanly. This is * the hook a host's tool loop waits on. There is deliberately no * per-fragment callback: `ToolPart.rawInput` is written on every fragment, * so the streaming text is already on the part. */ onToolCallReady?: (call: ModelToolCall) => void; } /** Per-stream state for one format. */ export interface WireFormatReader { /** * Map one decoded frame onto zero or more neutral chunks. Returns an ARRAY * because the mapping is not one-to-one: an Anthropic `message_start` yields * usage, a `content_block_start` for `tool_use` yields an id-plus-name delta, * a `ping` yields nothing. * * MUST NOT throw on an unrecognized frame. Return `[]` instead: providers add * event types without warning. */ push(frame: unknown): ModelStreamChunk[]; } /** A pluggable wire format. Values, not a flag, so a third party can add one * without a PR to this repo. */ export interface WireFormat { readonly id: string; /** Called once per stream so a format can hold per-stream state. Two calls * must share NOTHING. */ open(): WireFormatReader; } ``` ### The `ChatRequestBody` preamble (what your route receives) Every backend route the `kai` MCP scaffolds narrows `await request.json()` ONCE at the edge, through this type — `request.json()` is `Promise` under a Node/undici tsconfig, so destructuring it raw fails a stock `npm run build` even though it ran fine in dev. The front end sends `toOpenAIMessages(thread)`; this is what that produces, so the two halves stay pinned to one type. Do not hand-roll a second narrowing. ```ts /** * What the front end POSTs. `request.json()` is `unknown` (it is whatever the * client sent), so the body is narrowed once here instead of at every use — * without it this route does not compile under a server tsconfig. Widen it as * you add fields of your own. */ type ChatRequestBody = { messages: OpenAIWireMessage[]; model?: string; tools?: unknown[]; }; ``` The scaffolded routes pair it with a `readChatRequest(request)` guard that turns a bare GET or malformed JSON into a status response instead of an unhandled throw — re-scaffold any integration with the `kai` MCP `scaffold` tool to get the full preamble. --- ## Icon roster (77 names, derived from NAMED_ICONS in src/ui/icon.tsx) Every name `kai-icon` (and every `icon` prop/attribute across the elements) resolves — derived from the `NAMED_ICONS` map in `src/ui/icon.tsx` (also exported at runtime as `ICON_NAMES`). An icon-shaped name outside this roster renders a fallback glyph and logs a console error, in dev and prod alike; URLs render an ``, and emoji/arbitrary text passes through as text. `archive` · `arrow-down` · `arrow-left` · `arrow-right` · `arrow-up` · `audio-lines` · `bell` · `book-open` · `bookmark` · `box` · `briefcase` · `check` · `chevron-down` · `chevron-left` · `chevron-right` · `chevron-up` · `circle` · `circle-alert` · `circle-check` · `circle-x` · `clock` · `code` · `copy` · `desktop` · `download` · `ellipsis` · `external-link` · `eye` · `eye-off` · `file-text` · `flag` · `folder` · `git-branch` · `git-pull-request` · `github` · `globe` · `home` · `image` · `info` · `laptop` · `link` · `list-filter` · `lock` · `maximize-2` · `message-circle` · `message-square` · `mic` · `minimize-2` · `minus` · `mobile` · `monitor` · `moon` · `more-horizontal` · `panel-left` · `panel-right` · `paperclip` · `pencil` · `play` · `plus` · `rotate-ccw` · `rotate-cw` · `search` · `settings` · `share` · `sliders-horizontal` · `smartphone` · `smile` · `sparkles` · `square` · `square-pen` · `sun` · `tablet` · `trash` · `triangle-alert` · `upload` · `workflow` · `x` --- ## Element reference (96 elements, generated from custom-elements.json) Every element also accepts the `theme` attribute. Array/object properties are marked with a `—` attribute: they must be set as JS properties. ### `kai-agent-card` / `AgentCard` **Properties** (every element also accepts `theme="light|dark|auto"`; only scalar props work as HTML attributes): | Property | Attribute | Type | Description | |---|---|---|---| | `theme` | `theme` | `"light" \| "dark" \| "auto"` | Color mode (`auto` follows prefers-color-scheme). | | `name` | `name` | `undefined \| string` | The agent's name, the primary label. Attribute: `name`. | | `active` | `active` | `undefined \| false \| true` | Selected / focused state: highlighted border + surface. Attribute: `active`. | | `needsAttention` | `needs-attention` | `undefined \| false \| true` | Raise a prominent "Needs you" pill plus a glowing amber edge. This is the attention-routing signal that pulls focus to this agent. Attribute: `needs-attention`. | | `status` | — | `undefined \| { tone: "working" \| "idle" \| "done" \| "error" \| "blocked"; label?: undefined \| string; pulse?: undefined \| false \| true }` | Run status. A JS PROPERTY (object), not an attribute. Shape: `{ tone, label?, pulse? }`, where `tone` is one of `working` \| `idle` \| `done` \| `error` \| `blocked` (maps to the kit's tool hues), `label` is an optional short string beside the dot, and `pulse` animates the dot. Set it with `el.status = { tone: 'working', label: 'Working', pulse: true }`. | **Events** (non-bubbling `CustomEvent`s — listen directly on the element): | Event | `detail` type | Description | |---|---|---| | `kai-activate` | `CustomEvent` | The card was activated by a click, or by Enter / Space while focused. Promote this agent back to focus. | | `kai-menu` | `CustomEvent` | The trailing "..." kebab was clicked. The consumer opens its own menu; the card only surfaces the affordance (the click does not also activate the card). | **Styleable parts** (restyle from outside via `kai-agent-card::part(name)`): | Part | Description | |---|---| | `::part(status)` | The leading tone-colored status dot. — `kai-agent-card::part(status) { width: 0.625rem; height: 0.625rem }` | | `::part(menu)` | The trailing overflow ("...") menu button. — `kai-agent-card::part(menu) { opacity: 1 }` | --- ### `kai-artifact` / `Artifact` **Properties** (every element also accepts `theme="light|dark|auto"`; only scalar props work as HTML attributes): | Property | Attribute | Type | Description | |---|---|---|---| | `theme` | `theme` | `"light" \| "dark" \| "auto"` | Color mode (`auto` follows prefers-color-scheme). | | `src` | `src` | `undefined \| string` | URL the preview iframe frames. Consumer-controlled. | | `files` | — | `undefined \| { path: string; url?: undefined \| string; code?: undefined \| string; language?: undefined \| string; type?: undefined \| "html" \| "pdf" \| "image" \| "other"; additions?: undefined \| number; deletions?: undefined \| number; status?: undefined \| "added" \| "modified" \| "deleted" \| "renamed" \| "untracked" }[]` | Files for the Code tab tree + each file's preview `url`. Omit for a preview-only artifact (the Code tab then has nothing to show; pair it with `no-tabs` to hide the toggle). Set as a JS property (array). | | `tab` | `tab` | `undefined \| "preview" \| "code"` | Controlled active tab: `preview` or `code`. When set, the artifact follows it (re-asserted on change). Leave unset for an uncontrolled tab (see `defaultTab`). | | `defaultTab` | `default-tab` | `undefined \| "preview" \| "code"` | Uncontrolled INITIAL tab (used only when `tab` is unset). Default `preview`. Seeds the starting tab; the user can then switch freely without the consumer re-asserting a controlled `tab`. | | `activeFile` | `active-file` | `undefined \| string` | Selected file path. Syncs the tree highlight, Code source, and preview. | | `sandbox` | `sandbox` | `undefined \| string` | iframe `sandbox` override. Secure default `allow-scripts allow-forms` (NOT `allow-same-origin`). | | `iframeTitle` | `iframe-title` | `undefined \| string` | Accessible title for the preview iframe. | | `maximized` | `maximized` | `undefined \| false \| true` | Reflects the artifact's own maximized view-state (usually driven by the protocol). | | `expandable` | `expandable` | `undefined \| false \| true` | Show the expand-to-fill button (OPT-IN). | | `openInTab` | `open-in-tab` | `undefined \| false \| true` | Show the open-in-new-tab button (OPT-IN). | | `noNav` | `no-nav` | `undefined \| false \| true` | Hide back/forward. | | `noReload` | `no-reload` | `undefined \| false \| true` | Hide reload. | | `noHome` | `no-home` | `undefined \| false \| true` | Hide home. | | `noPathField` | `no-path-field` | `undefined \| false \| true` | Hide the address field. | | `noTabs` | `no-tabs` | `undefined \| false \| true` | Hide the Preview\|Code toggle. | | `standalone` | `standalone` | `undefined \| false \| true` | Standalone chrome: rounded corners + border (else square, borderless in-panel). | | `readonlyPath` | `readonly-path` | `undefined \| false \| true` | Show the address but make it read-only (visible, nav-tracking, non-editable). | | `displayUrl` | `display-url` | `undefined \| string` | Friendly address shown in the path field instead of the real current url (read-only, non-navigable). Use when the framed url is not consumer-facing (e.g. a `data:` blob) so a clean address shows instead of leaking it. Scalar string: set as the `display-url` attribute or the `displayUrl` property. | **Events** (non-bubbling `CustomEvent`s — listen directly on the element): | Event | `detail` type | Description | |---|---|---| | `kai-file-select` | `CustomEvent<{ path: string }>` | Fired when a file is selected. `detail.path`. | | `kai-maximize-change` | `CustomEvent<{ maximized: false \| true }>` | Artifact's own maximize button toggled (consumer-observable; non-bubbling). | | `kai-maximize-intent` | `CustomEvent<{ requested: false \| true }>` | The maximize PROTOCOL intent, raised as a raw bubbling + composed CustomEvent (not through `dispatch`) so an enclosing `` can catch it and maximize the containing panel. Declared here so it is typed and reaches the generated API. Listen for it to drive maximize from your own chrome, or re-emit it to trigger one. | | `kai-navigate` | `CustomEvent<{ url: string }>` | Fired when the preview navigates. `detail.url` = the new location. | | `kai-tab-change` | `CustomEvent<{ tab: "preview" \| "code" }>` | Fired when the Preview\|Code tab changes. `detail.tab`. | **Methods** (call on the element instance: `document.querySelector('kai-artifact').back()`): | Method | Signature | Description | |---|---|---| | `back` | `(): void` | Go back in the artifact's own history stack (no-op when there's no prior entry). | | `forward` | `(): void` | Go forward in the history stack (no-op when there's no forward entry). | | `reload` | `(): void` | Force-reload the current preview url (also re-renders an inline PDF). | | `home` | `(): void` | Navigate to the `src` home url (no-op when there's no `src`). | | `navigate` | `(url: string): void` | Push + load a url in the preview, the path-field submit path (fires kai-navigate). | | `selectFile` | `(path: string): void` | Select a file by path: highlights the tree, shows its source, navigates the preview (fires kai-file-select + kai-navigate). Named selectFile to avoid the `activeFile` prop. | | `openExternal` | `(): void` | Open the current url in a new browser tab (no-op when there's no concrete url). Named openExternal, NOT openInTab, which is a prop (toolbar button visibility). | | `maximize` | `(): void` | Enter the maximized view-state (fires kai-maximize-change{maximized:true}). Named maximize, NOT maximized, which is a prop. | | `restore` | `(): void` | Exit the maximized view-state (fires kai-maximize-change{maximized:false}). | --- ### `kai-attachments` / `Attachments` **Properties** (every element also accepts `theme="light|dark|auto"`; only scalar props work as HTML attributes): | Property | Attribute | Type | Description | |---|---|---|---| | `theme` | `theme` | `"light" \| "dark" \| "auto"` | Color mode (`auto` follows prefers-color-scheme). | | `items` | — | `undefined \| { id: string; type: "file" \| "source-document"; filename?: undefined \| string; mediaType?: undefined \| string; url?: undefined \| string; title?: undefined \| string }[]` | The attachments to render. Omit (or pass an empty array) for the empty state, which shows `emptyText` if set and nothing otherwise. Set as a JS property (array). Each item's `url` must be a `data:` URI or an https URL, never `URL.createObjectURL`: a `blob:` URL previews here but the wire encoders (`toOpenAIMessages`/`toAnthropicMessages`) refuse it. | | `variant` | `variant` | `undefined \| "grid" \| "inline" \| "list"` | Layout: `grid` = visual tiles, `inline` = icon + label chips, `list` = rows. | | `hoverCard` | `hover-card` | `undefined \| false \| true` | Wrap each item in a hover card that previews its details. | | `removable` | `removable` | `undefined \| false \| true` | Show a remove button per item; clicking it fires a `kai-remove` event. | | `showMediaType` | `show-media-type` | `undefined \| false \| true` | Also show the media type beneath the filename (non-grid variants). | | `emptyText` | `empty-text` | `undefined \| string` | Text shown when `items` is empty. | **Events** (non-bubbling `CustomEvent`s — listen directly on the element): | Event | `detail` type | Description | |---|---|---| | `kai-remove` | `CustomEvent<{ id: string }>` | A remove button was clicked. | **Styleable parts** (restyle from outside via `kai-attachments::part(name)`): | Part | Description | |---|---| | `::part(preview)` | The image shown in an attachment’s hover-card preview. Bounded by default (max ~320×256, aspect preserved) so a large image never blows up the card. Raise or lower the cap from outside. — `kai-attachments::part(preview) { max-width: 32rem; max-height: 24rem }` | | `::part(attachment)` | One attachment item: the chip, row or tile, whichever variant is rendering. Restyle its background, radius or border from outside without caring which layout it is. — `kai-chat::part(attachment) { border-radius: 0.25rem }` | | `::part(attachment-name)` | The attachment’s filename label. Present in every variant that shows one (a grid tile omits it for an image, which is its own label). Retune its type or hide it entirely. — `kai-chat::part(attachment-name) { font-size: 0.75rem }` | --- ### `kai-audio-visualizer` / `AudioVisualizer` **Properties** (every element also accepts `theme="light|dark|auto"`; only scalar props work as HTML attributes): | Property | Attribute | Type | Description | |---|---|---|---| | `theme` | `theme` | `"light" \| "dark" \| "auto"` | Color mode (`auto` follows prefers-color-scheme). | | `variant` | `variant` | `undefined \| string` | Look to render: `bar` (default), `grid`, `radial`, `wave`, `aurora`, `custom`. `aura` is accepted as a LiveKit-markup alias for `aurora`. Attribute: `variant`. | | `state` | `state` | `undefined \| string` | `idle` (default), `connecting`, `listening`, `thinking`, `speaking`, `disconnected` (connection down: the dead, flat look). LiveKit's room-lifecycle state names are accepted as aliases. Attribute: `state`. | | `size` | `size` | `undefined \| string` | `icon` \| `sm` \| `md` (default) \| `lg` \| `xl`. Attribute: `size`. | | `barCount` | `bar-count` | `undefined \| number` | Bars to draw. Bar and radial only. Attribute: `bar-count`. | | `count` | `count` | `undefined \| number` | Grid only: rows and columns of the (always square) grid. Attribute: `count`. | | `radius` | `radius` | `undefined \| number` | Radial only: ring distance from center, in px. Attribute: `radius`. | | `spread` | `spread` | `undefined \| number` | Grid only: ring distance for the connecting animation, in cells. Attribute: `spread`. | | `interval` | `interval` | `undefined \| number` | Grid only: ms between scripted frames. Attribute: `interval`. | | `color` | `color` | `undefined \| string` | CSS color for the geometry, overriding the inherited `currentColor`. Attribute: `color`. | | `complexity` | `complexity` | `undefined \| number` | Shader variants only: pattern density, 0..1. Attribute: `complexity`. | | `label` | `label` | `undefined \| string` | Setting this makes the element an announced image (`role="img"`) instead of decorative (`aria-hidden`). Attribute: `label`. | | `stream` | — | `undefined \| MediaStream` | Live microphone or WebRTC audio to analyze. JS property only. NOTE: amplitude renders only while state is "speaking" unless listening-amplitude is set; every other state plays its scripted animation and ignores the audio. | | `audioElement` | — | `undefined \| HTMLMediaElement` | An `