1. The prompt
Create a prompt (sayassistant) with read-write memory — it sees the conversation
so far and appends its replies to it (see
Memory & Threads). Give it your system instructions and
an input variable for the user’s message, then publish it to production.
2. The backend
Two endpoints, both thin. One mints the stream credential, one triggers runs. Your API key stays here; the browser never sees it.randomUUID()), keep it in your session or database, and pass the same one to both
endpoints. The thread doesn’t need to exist before the first run; minting a stream token
for it first is exactly the right order.
3. The frontend
Connect before sending the first message — a fresh subscription starts at the live tip, so open the stream first and you see every token from the beginning.send triggers a run, the run streams into runs[runId].text,
and status flips to done when it finishes. Reconnects, retries, and workflow
segments are handled inside the hook.
When a terminal state carries
gapped: true, the streamed text may be incomplete —
fetch the run through your backend (pj.getPromptRun(runId)) and swap in the
authoritative output. With connect-before-send this is rare, but handling it is what
makes the UI trustworthy.Showing the user’s side
The stream carries the model’s output; your user’s messages you already have locally. Render them optimistically from your own state, keyed however you like — the runs map and your local messages interleave naturally by time. On a full page reload, rebuild the transcript from your backend (the thread’s runs via the API, or your own message store) and let the stream take over for new messages.Structured data alongside the prose
Sometimes the answer isn’t only text. An assistant that pulls up records wants to stream its reply and hand the frontend the ids to render as cards. Add an emit tool to the prompt — a tool whose schema-validated arguments are the result — and its payloads arrive on the same stream asrun.data, beside the text you already render.
Give the tool a payload schema (say { records: [{ id, title }] }) and tell the model to
emit when it has matches. Then render the data next to the prose:
run.data is the ordered list of { tool, payload } the model emitted this run,
maintained for you exactly like text — cards appear the moment the model produces them,
while the prose keeps streaming. The same payloads are on the run’s emitted field if you
need the authoritative copy after a gapped terminal state.
Showing what the model is doing
If the prompt has tools, the user watches a pause with nothing to explain it — a knowledge search or an HTTP call can take seconds.run.transcript is the same run as an
ordered sequence — prose, tools, emit payloads in position — so you can render the
activity instead of a blank gap:
status: 'pending', and
flips to ok or error when it comes back. transcript replaces text and data in
your render — it contains both — and it is the same shape pj.getPromptRun(runId)
returns, so the same component renders a live run and a reloaded one. See
Transcript for the full shape.
Workflows instead of a single prompt
SwaprunPrompt for runWorkflow and nothing else changes — every prompt node streams
into the same connection. If the workflow has internal lanes (research, tool use,
synthesis), put the user-facing node on a dedicated
channel and pass channels: ['support'] to the
hook so only that lane renders.