Skip to content
kitn AI/UI

Wire adapter

The kit parses the stream. You own the transport. @kitn.ai/ui/wire takes a Response (or any byte source) and folds it onto an assistant message: text, reasoning, tool calls with their arguments streaming in, citations. There is no client, no key handling and no provider SDK anywhere in it, so auth, proxies, aborts and retries stay yours.

import { Chat, useKaiChat } from '@kitn.ai/ui/react';
import { readOpenAIStream, toOpenAIMessages } from '@kitn.ai/ui/wire';
export function App() {
const chat = useKaiChat({
onSubmit: async ({ value }) => {
const user = {
id: crypto.randomUUID(),
role: 'user' as const,
parts: [{ type: 'text' as const, text: value }],
};
chat.append(user);
// streamAssistant appends the in-flight assistant message and hands back a
// sink. readOpenAIStream drives it, so every delta lands on the right part.
const stream = chat.streamAssistant();
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// chat.messages is this render's snapshot, so it does not contain the
// turn appended a few lines up. Append it here too.
body: JSON.stringify({ messages: toOpenAIMessages([...chat.messages, user]) }),
});
await readOpenAIStream(res, stream);
} finally {
stream.done();
}
},
});
return <Chat {...chat.bind} style={{ display: 'block', height: '100dvh' }} />;
}

Four lines are the adapter. The rest is fetch and React.

Your /api/chat route forwards the provider’s SSE bytes untouched. That is what every integration template already does.

Outside React, build the sink yourself with createAssistantStream from @kitn.ai/ui/state and pass it to the same reader.

import { createAssistantStream } from '@kitn.ai/ui/state';
import { readOpenAIStream } from '@kitn.ai/ui/wire';
const stream = createAssistantStream((update) => {
chat.messages = update(chat.messages);
});
try {
await readOpenAIStream(res, stream);
} finally {
stream.done();
}

readAnthropicStream reads Anthropic Messages SSE and produces the same parts:

import { readAnthropicStream } from '@kitn.ai/ui/wire';
const turn = await readAnthropicStream(res, stream);

Both are thin wrappers over readModelStream, which takes any WireFormat. A format maps one decoded frame onto zero or more neutral chunks, so adding a third is a value, not a pull request:

import { readModelStream, type ModelStreamChunk, type WireFormat } from '@kitn.ai/ui/wire';
// push receives `unknown`: a provider can put anything on the wire, and a format
// that throws on an unrecognised frame takes the whole turn down. Return [].
function isTextFrame(frame: unknown): frame is { text: string } {
return (
typeof frame === 'object' &&
frame !== null &&
typeof (frame as { text?: unknown }).text === 'string'
);
}
const acmeEvents: WireFormat = {
id: 'acme.events',
// open() runs once per stream. Hold per-stream state in this closure; two
// calls must share nothing.
open() {
return {
push(frame: unknown): ModelStreamChunk[] {
return isTextFrame(frame) ? [{ text: frame.text }] : [];
},
};
},
};
await readModelStream(res, stream, { format: acmeEvents });

The kit never calls your function. readOpenAIStream returns the turn, you run the tools, applyToolOutput completes the panel and applyToolFailure fails it. Every round folds onto the same assistant message, so one stream spans the whole loop.

import { Chat, useKaiChat } from '@kitn.ai/ui/react';
import type { ChatMessage } from '@kitn.ai/ui/react';
import { createAssistantStream, type SetMessages } from '@kitn.ai/ui/state';
import { applyToolFailure, applyToolOutput, readOpenAIStream, toOpenAIMessages } from '@kitn.ai/ui/wire';
// The tools the model may call. The request body carries this array; without it
// the model never emits a tool call and <kai-tool> stays empty.
const tools = [
{
type: 'function' as const,
function: {
name: 'search',
description: 'Search the web for up-to-date information.',
parameters: {
type: 'object',
properties: { query: { type: 'string', description: 'What to search for.' } },
required: ['query'],
},
},
},
];
// YOUR tool runs here. The kit never calls it: the model asks, you execute, and
// applyToolOutput reports the result back into the panel and the next round.
async function runTool(name: string, input: Record<string, unknown>) {
if (name === 'search') return { results: [`Searched for: ${String(input.query ?? '')}`] };
return { error: `Unknown tool: ${name}` };
}
export function App() {
const chat = useKaiChat({
onSubmit: async ({ value }) => {
const userMsg: ChatMessage = {
id: crypto.randomUUID(),
role: 'user',
parts: [{ type: 'text', text: value }],
};
// `thread` is this turn's source of truth. `set` mutates it and projects
// the result through chat.setMessages, so every round below re-encodes the
// live thread instead of the stale `chat.messages` this closure captured
// at submit time.
let thread: ChatMessage[] = [...chat.messages, userMsg];
const set: SetMessages = (fn) => {
thread = fn(thread);
chat.setMessages(thread);
};
chat.setMessages(thread);
const stream = createAssistantStream(set);
try {
// Cap the rounds: a runaway model is a runaway bill.
for (let round = 0; round < 4; round++) {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// Re-encoded every round, so the results applyToolOutput just wrote
// travel back with the next request. That is what lets the model
// answer with them.
body: JSON.stringify({ messages: toOpenAIMessages(thread), tools }),
});
const turn = await readOpenAIStream(res, stream);
const pending = turn.toolCalls.filter((call) => !call.error && !call.providerExecuted);
if (pending.length === 0) break;
for (const call of pending) {
try {
applyToolOutput(stream, call.id, await runTool(call.name, call.input ?? {}));
} catch (err) {
applyToolFailure(stream, call.id, err instanceof Error ? err.message : 'Tool failed');
}
}
}
} finally {
stream.done();
}
},
});
return <Chat {...chat.bind} style={{ display: 'block', height: '100dvh' }} />;
}

The loop runs inside the try. stream.done() settles the message and every sink call after it is dropped, so an applyToolOutput placed after a try/finally that already called done() leaves the panel stuck on input-available forever, with no error to point at.

call.providerExecuted marks a tool the provider already ran, with the result in the stream. Running it again bills you twice for the same answer. call.error marks arguments that never parsed, usually because the model was cut off at max_tokens.

While the arguments stream, ToolPart.rawInput carries the partial JSON, so <kai-tool> renders it filling in character by character. You render nothing yourself.

toOpenAIMessages splits an assistant turn at each tool boundary: the announcement, then each role: 'tool' result, then any text the model produced afterwards. Flattening instead would put the model’s answer before the result it was based on, which no endpoint rejects and every later round pays for. A turn that encodes to nothing is skipped rather than sent as { content: null }.

On the Anthropic wire a reasoning part carries the provider’s own block on part.raw, and toAnthropicMessages echoes that payload verbatim.

This is not a nicety. Anthropic returns 400 if a thinking block in the most recent assistant message is modified, reordered, filtered or reconstructed, so an encoder cannot rebuild one from text plus signature. A reasoning part with no raw throws a WireEncodeError at encode time, naming the message and the part index, rather than sending a request that fails at the provider.

The practical consequence is in your persistence layer: strip raw when you save a message and the next turn will not encode. Keep it.

That wire does have a choice, so it is an option rather than a rule:

import { toOpenAIMessages } from '@kitn.ai/ui/wire';
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: toOpenAIMessages(thread, { reasoning: 'include' }),
tools: myTools,
}),
});

The default is 'omit', which is what every single-argument call already does, and the default is a measurement rather than caution: omitting is accepted by every configuration tested, while including reasoning cost about 25% more prompt tokens per round (665 to 834 on a two-round loop). Raising every consumer’s bill is not something a library gets to do as a side effect.

Reach for 'include' in a multi-round tool loop with a reasoning model. That is the case it pays for: the model’s own prior reasoning travels back with the tool results, so it continues from where it left off instead of re-deriving.

What goes on the wire, per part:

The blockWhat is sent
SignedReassembled from part.text plus part.signature
EncryptedSent by reference, whole
NeitherSkipped

Signed blocks are reassembled, not echoed. Of 85 reasoning frames in a streamed turn only the final one carries the signature, and that frame has no text, so part.raw after a stream is a textless fragment. Echoing it the way the Anthropic encoder does would send a block with no reasoning in it.

The encoder emits a block only when the provider can verify it, and verifiability is a property of the model configuration rather than of individual blocks: one model signs every block, another signs none. So the skip never drops one block out of a run of comparable ones, and against a model that does not sign its reasoning the option is inert. That is a limitation to know about, not a bug to file.

Entries land as reasoning_details on the assistant message that owns them, in part order, including the split at a tool boundary: a block that follows a tool call rides on the message after the result, exactly where toAnthropicMessages puts one. OpenAIEncodeOptions and OpenAIReasoningDetail are exported if you need the types.

Two different failures, two different channels.

FailureSurfaces asRead
Non-ok HTTP responseWireError thrown before any chunk is readstatus, statusText, body, bodyText
Error frame inside a 200 streamModelTurn.error on the resolved turncode, message
import { WireError, readOpenAIStream } from '@kitn.ai/ui/wire';
try {
const turn = await readOpenAIStream(res, stream);
if (turn.error) {
// The connection was fine. The model or the gateway failed mid-stream, and
// whatever streamed before it is already on the message.
console.error(turn.error.code, turn.error.message);
}
} catch (err) {
if (err instanceof WireError) {
console.error(err.status, err.statusText, err.body);
} else {
throw err;
}
} finally {
stream.done();
}

WireError.body is the provider’s own error JSON when the body parsed, and undefined when it did not (an HTML error page from a proxy is the usual case). bodyText is always the raw body.

  • No retries, reconnect, Last-Event-ID or backoff. Abort the fetch you own.
  • No tool executor and no loop driver. The loop above is yours.
  • No key handling and no hosted client. There is no createChatClient({ apiKey }).
  • No server-side route helpers.
  • No non-SSE transports.
  • No tolerant partial-JSON closer. Truncated tool arguments are reported as call.error, not guessed at.

The format tests run against captured provider streams in packages/ui/src/wire/fixtures/. packages/ui/scripts/capture-wire-fixture.mjs re-records them from a live provider:

Terminal window
OPENAI_API_KEY=... node scripts/capture-wire-fixture.mjs openai/text-only
node scripts/capture-wire-fixture.mjs --list

CI never runs it. It needs a key and the network, and its output is checked in on purpose so the suite stays offline. Run it by hand when a provider changes its event surface, then read the diff before committing.