Skip to content

Streaming to a UI

The integration itself is three lines. Everything else on this page is about what you do once you have it.

typescript
import { Agent, Transcript } from "@fifthrevision/axle";

const transcript = new Transcript();
agent.on((event) => transcript.apply(event));

await agent.send(userInput).final;
// transcript.turns is now ready to render

Transcript handles all the render bookkeeping — opening parts, applying deltas, settling statuses — so your UI only ever has to draw turns.

Driving a React view

typescript
const [turns, setTurns] = useState<readonly Turn[]>([]);

useEffect(() => {
  const transcript = new Transcript();
  return agent.on((event) => {
    transcript.apply(event);
    setTurns([...transcript.turns]);
  });
}, [agent]);

agent.on() returns an unsubscribe function, which means returning it from useEffect is your entire cleanup.

Do copy the array before setting state. turns is a stable readonly reference, so React won't re-render if you hand it the same one — a subtle bug that looks like "streaming doesn't work."

If the stream is busy, throttle: apply every event, but batch the state update to an animation frame.

Rendering a turn

tsx
function TurnView({ turn }: { turn: Turn }) {
  return (
    <div data-owner={turn.owner} data-status={turn.status}>
      {turn.parts.map((part) => {
        switch (part.type) {
          case "text":
            return <Markdown key={part.id} text={part.text} citations={part.citations} />;
          case "thinking":
            return <Thinking key={part.id} text={part.text} summary={part.summary} />;
          case "file":
            return <FileChip key={part.id} file={part.file} />;
          case "citation":
            return <SourceList key={part.id} citations={part.citations} />;
          case "compaction":
            return <Divider key={part.id} label={part.summary} />;
          case "action":
            return <ActionView key={part.id} part={part} />;
        }
      })}
    </div>
  );
}

Part ids stay stable for the life of the turn, which is exactly what makes them correct React keys.

Rendering an action

Actions move through several states, and each one wants slightly different UI:

tsx
function ActionView({ part }: { part: ActionPart }) {
  if (part.kind === "agent") {
    // Recursive — a subagent's turns render with the same component
    return (
      <details>
        <summary>{part.detail.name} ({part.status})</summary>
        {part.detail.children.map((t) => <TurnView key={t.id} turn={t} />)}
      </details>
    );
  }

  const args =
    part.kind === "tool" && part.status === "pending"
      ? part.detail.pendingArgs // still-incomplete JSON
      : JSON.stringify(part.kind === "tool" ? part.detail.parameters : part.detail.input);

  return (
    <div>
      <Spinner active={part.status === "running"} />
      <code>{part.detail.name}({args})</code>
      {part.detail.result?.type === "error" && <Error msg={part.detail.result.error.message} />}
    </div>
  );
}

pendingArgs exists precisely for that window where arguments are still streaming in and can't be parsed yet — so you have something to show instead of an empty box.

Keeping the UI out of provider SDKs

Your render code probably shouldn't drag the Anthropic, OpenAI, and Google SDKs into a browser bundle. Import from the /ui subpath instead:

typescript
import { Transcript } from "@fifthrevision/axle/ui";
import type { Turn, TurnPart, ActionPart, TurnEvent } from "@fifthrevision/axle/ui";

Streaming across a network boundary

TurnEvent is plain JSON. Serialize it on the server, apply it on the client:

typescript
// server
agent.on((event) => socket.send(JSON.stringify(event)));

// client
const transcript = new Transcript();
socket.onmessage = (msg) => {
  transcript.apply(JSON.parse(msg.data));
  render(transcript.turns);
};

If your socket also carries application events, apply() tells you what it did not recognize:

typescript
const outcome = transcript.apply(incoming);
if (!outcome.handled) myReducer(outcome.event);

Persisting and restoring

Two things to save, not one — this is the step people miss:

typescript
await db.save(sessionId, {
  session: await agent.snapshot(), // for the model
  turns: transcript.turns, // for the reader
});

// later
const saved = await db.load(sessionId);
const agent = new Agent(config, saved.session);
const transcript = new Transcript(saved.turns);
agent.on((event) => transcript.apply(event));

Save only the session and you get an agent that remembers the conversation perfectly, next to a UI with nothing to show.

Annotations

Want to attach UI state — a sandbox booting, an eval result — to a turn without it becoming model state? Emit annotation events into the same transcript:

typescript
transcript.apply({
  type: "annotation:start",
  target: { type: "turn", turnId },
  annotation: { id, kind: "sandbox", label: "Starting sandbox…", status: "running" },
});

They live on turn.annotations and part.annotations, and never reach a provider — which is the whole point of having them.

Annotations are the main extension point in a transcript, and there's more to them than one event: lifecycles, typed unions, and a generic renderer that handles kinds you haven't written yet. See Annotations & evals.

See also