Frontend integration
The frontend SDK connects a browser or a Node backend to a running deployment: spawn and command agents, materialize an agent's timeline into UI collections, and subscribe to the catalog as live-updating queries. Everything goes through the gateway — the SDK never touches internal services directly.
@teaspill/frontend-sdk and the rest are consumed as workspace packages inside the monorepo. Today you build your app in the repo and depend on them with "@teaspill/frontend-sdk": "workspace:*", the way the quick start sets up its agents package.Three clients, one per route family
The SDK is three small clients, each mapping to one gateway surface:
createActionsClient— writes: spawn, send, control, through/api/*. Requires an API key.createAgentTimeline— reads one entity's history and live tail, through/streams/*.createAgentCatalog— reads the entity registry as live queries, through/shapes/*.
Reads can use a browser read token; writes always require a full credential, because writes never bypass your backend.
Under the actions and timeline clients is a pure reducer — no I/O, no clock — that folds the raw event stream into typed collections. It runs unchanged in the browser, on a server, and in tests.
The actions client
Commands go through the gateway's /api/* routes. Every command answers with the entity's canonical url and the URL of its timeline stream — feed that straight into the timeline reader.
import { createActionsClient } from "@teaspill/frontend-sdk";
const actions = createActionsClient({
baseUrl: GATEWAY_URL,
auth: { apiKey: process.env.TEASPILL_API_KEY! }, // server-side credential
});
// Spawn returns the new entity + its stream URL.
const { url, streamUrl } = await actions.spawn({
type: "researcher",
args: { topic: "the history of tea" },
});
// Send a follow-up message (the payload is your agent's inbox shape).
await actions.send(url, { note: "focus on green vs black" });
// Control verbs: interrupt | pause | resume | archive.
await actions.interrupt(url, "wrong topic");
A target can be the canonical url, a short form "/a/researcher/<id>", or { type, id }. Pass an idempotencyKey in the per-request options to make a retried command safe. Because writes require a full credential, run the actions client from your backend (or a trusted proxy), not from untrusted browser code.
interrupt, pause, resume, and archive — the same four covered in Lifecycle & control. There is no "steer" from the browser; steering is an agent-to-agent delivery mode only.Reading a timeline
createAgentTimeline reads an entity's timeline stream, folds every event through the reducer, and exposes a subscribable snapshot of typed collections — messages, toolCalls, runs, children, and more.
import { createAgentTimeline } from "@teaspill/frontend-sdk";
const timeline = createAgentTimeline(`${GATEWAY_URL}${streamUrl}`, {
auth: { token: () => refreshReadToken() }, // browser read token, refreshed per request
deltas: true, // also follow the live token stream
onDrift: (d) => console.warn("timeline drift", d),
});
timeline.subscribe((s) => {
render(s.timeline.messages, s.timeline.liveDeltas);
});
The snapshot's timeline holds the materialized collections; liveDeltas holds the in-flight token deltas — the streaming fragments of a message the model is still producing. Render liveDeltas for the typing-in-progress effect; each one is replaced by its finalized message the moment that event lands. Set deltas: true to open the sibling live stream; leave it off for a read-only history view. Call timeline.close() when the view unmounts.
The reducer's collections are deliberately lossy — they keep what a UI renders, not the exact event sequence. If you need the raw events themselves (the conformance kit's gapless/exactly-once checks are the motivating case), pass an onEvents(events, state) callback: it fires with each applied batch of parsed events, in stream order, before the reducer folds them. Per-seq dedup is the caller's to apply at this layer.
Auth in the browser
Writes need a full API key, so they stay on your backend. For reads, the gateway can verify a short-lived read token, letting a browser read /streams/* and /shapes/* directly without proxying every byte through your server.
You mint tokens server-side with mintReadToken and hand them to the browser:
import { mintReadToken } from "@teaspill/agents-sdk";
const token = await mintReadToken({
// One prefix covers this entity's timeline AND deltas. Include the trailing slash.
pfx: "/streams/t/default/agents/researcher/<id>/",
ttlSeconds: 300, // keep it short — reconnecting is cheap
secret: process.env.GATEWAY_JWT_SECRET!, // must match the gateway's secret
});
The token authorizes exactly one path prefix, and only for reads — it can never spawn, send, or control. Pass a token() function (not a string) to the timeline and catalog clients: the SDK re-evaluates it per request, so a long-lived subscription picks up fresh tokens automatically. On a 401 the streams are resumable, so reconnecting with a new token is cheap and loses nothing.
The trailing slash on pfx matters: it gives a clean entity boundary so the token can't leak to a neighbor whose id shares the prefix. The full flow — enabling the JWT secret on the gateway, CORS, TTLs — is in Auth & API keys.
Fast-join: joining a long timeline cheaply
A UI that opens an hour-old conversation shouldn't replay every event from zero. A snapshot carries the entity's complete state at a point in the timeline, so you can join at the latest snapshot and fold forward from there. That is fast-join.
The catalog row for an entity carries the two values you need:
snapshotOffset— theseqof the latest snapshot (its position in the numbered history).snapshotStreamOffset— an opaque byte offset that lets the reader seek straight to that snapshot record instead of scanning from the beginning.
Pass both as fromSnapshot:
const timeline = createAgentTimeline(streamUrl, {
fromSnapshot: { seq: row.snapshotOffset, offset: row.snapshotStreamOffset },
});
The helper fromSnapshotForRow(row) builds this object from a catalog row for you, handling every case:
import { fromSnapshotForRow } from "@teaspill/frontend-sdk";
const timeline = createAgentTimeline(streamUrl, {
fromSnapshot: fromSnapshotForRow(row), // undefined if the entity never snapshotted
});
The offset is a token, never a number — pass it through verbatim, never do arithmetic on it. When it's present the reader seeks straight to the snapshot; when it's absent (an entity that hasn't snapshotted yet) the reader scans from the start and the reducer skips everything below seq — correct either way, just not as cheap. Collections then cover the post-join window; the pre-snapshot history is a separate full read if you ever need it.
Ordering guarantees you get for free
The reducer makes a mid-stream join behave exactly like a full replay. Three rules do the work, and you don't implement any of them:
- Duplicates are dropped. If an event arrives with a
seqalready applied, the reducer discards it and counts it. Duplicate events are normal, not an error — you never handle them yourself. - Snapshot-join equals replay. Joining at a snapshot and folding forward yields the same collections, entity state, and post-join window as reading from event 0.
- The finalized event always wins. Live token deltas merge as they stream, but the instant the finalized message, reasoning, or tool call lands, it supersedes the buffer and every later fragment for it. The committed history is always the truth on screen.
The streams server persists its writer-dedup state on a debounce. If it crashes inside that window, an append it already acknowledged can be re-admitted after restart, so the same event can appear twice in a read — byte-identical, with the same seq. Because every event embeds its seq, dropping the second copy is always safe, and the reducer does it for you. A gap in seq, by contrast, is never normal — the reducer surfaces it as drift.
Drift and history holes
The timeline is numbered 0, 1, 2, … with no gaps, so a forward gap means something went wrong. The reducer surfaces it through the onDrift callback and a drift field on the snapshot, then keeps applying events so the UI still shows the live tail. The right response is to offer the user a reload rather than trusting a partial view.
One discontinuity is sanctioned. After a catastrophic loss of stream history, recovery writes a snapshot marked as a history hole — a marked gap that says "history is missing here" instead of pretending otherwise. The reducer jumps across it without raising drift and sets historyHole on the snapshot. Render a visible marker where s.timeline.historyHole is true so the gap is honest to the reader.
Catalog subscriptions
The catalog is the live registry of entities. createAgentCatalog subscribes to it as a filtered, live-updating query — the source for lists and dashboards (conversation detail comes from timelines).
import { createAgentCatalog } from "@teaspill/frontend-sdk";
const catalog = createAgentCatalog({
baseUrl: GATEWAY_URL,
filter: { type: "researcher", status: "active" },
auth: { token: () => refreshReadToken() },
});
catalog.subscribe((s) => renderList(s.rows));
filter combines scalar equalities on type, parent, status, and tenant. Each row carries the entity's url, status, parent, headSeq, and the two fast-join offsets above — so a list view already holds everything needed to open any row into a fast-joined timeline. For anything richer than scalar equality, the where escape hatch takes a positional-parameter clause.
React bindings
React bindings live behind a separate entry point, so the framework-agnostic core never imports React. They are lifecycle glue around the same stores.
import { useAgentTimeline, useAgentCatalog } from "@teaspill/frontend-sdk/react";
function AgentView({ streamUrl }: { streamUrl: string }) {
const { timeline } = useAgentTimeline(streamUrl, {
auth: { token: refreshReadToken },
deltas: true,
});
const { rows } = useAgentCatalog({ filter: { type: "researcher" } });
return (
<Conversation
messages={timeline.messages}
streaming={timeline.liveDeltas}
drift={timeline.drift}
/>
);
}
Each hook subscribes on mount and tears down on unmount. The subscription restarts when the stream URL or the structural options (fromSnapshot, deltas, live) change; callbacks and auth are read fresh each render without restarting. Pass null or undefined for the URL or options to render without subscribing — useful while the stream URL is still loading.
Server-side rendering
The reducer is pure — no I/O, no clock — so it runs on a server as readily as in a browser. Fold a batch of events into a TimelineState to render an initial HTML view, then hydrate the client with a live createAgentTimeline that continues from the snapshot's offset. Keep read tokens on the server for the SSR pass and mint a fresh, short-lived one for the browser to take over.
Next steps
You can now drive agents and stream them into a UI. To stand up the deployment behind the gateway, see Self-hosting; for the read-token flow in full, see Auth & API keys.