The UI timeline
The runtime's events translated into what a frontend actually draws — reasoning summaries, tool groups, decisions, artifacts and the final answer, as plain JSON.
agent.stream() is the runtime's own vocabulary: every text delta, every
retry, every partial tool argument. That is the right feed for a renderer and
the wrong one for a UI, which draws four things — a summary of what the agent
set out to do, groups of tool calls, the decisions between them, and the
answer.
timeline() translates one into the other.
from shipit_agent.narrate import stream_timeline
for step in stream_timeline(agent, "Process the latest RSVP"):
await websocket.send_json(step){"type": "run_started", "goal": "Process the latest RSVP"}
{"type": "tool_group_started", "group_id": "g1", "title": "Reading 2 files"}
{"type": "tool_call_started", "tool_call_id": "1", "tool_name": "read_file", "input": {"path": "in.eml"}}
{"type": "tool_call_completed", "tool_call_id": "1", "status": "completed", "duration_ms": 420, "output": {"from": "jordan@acme.com"}}
{"type": "tool_group_completed", "group_id": "g1", "tool_calls": 1}
{"type": "agent_decision", "content": "No existing record. Creating one.", "next_action": "call_tool"}
{"type": "final_response", "content": "RSVP recorded.", "tool_calls": 2, "usage": {"total_tokens": 1180}}For a finished run, timeline(result.events) returns the same list.
What your frontend receives
{"type": "run_started", "goal": "Where did revenue land?"}
{"type": "tool_group_started", "group_id": "g1", "title": "Reading bookings.csv"}
{"type": "tool_call_started", "tool_name": "read_file", "input": {"path": "bookings.csv"}}
{"type": "tool_call_completed", "status": "completed", "duration_ms": 3.3}
{"type": "tool_group_completed", "group_id": "g1", "tool_calls": 1}
{"type": "agent_decision", "content": "Totalling by region.", "next_action": "call_tool"}
{"type": "artifact_created", "title": "Revenue by region", "kind": "Page"}
{"type": "final_response", "content": "AMER leads at $494K.", "tool_calls": 3}
Every step is a plain dict of primitives — straight onto a socket, with no serialisation step of your own.
The step vocabulary
| Type | Fields |
|---|---|
run_started | goal |
reasoning_summary | content, status |
tool_group_started | group_id, title |
tool_call_started | tool_call_id, group_id, tool_name, input |
tool_call_completed | tool_call_id, status, duration_ms, output | error |
tool_group_completed | group_id, tool_calls |
agent_decision | content, next_action, iteration |
agent_observation | content, iteration |
approval_required | action_id, tool_name, title, tag, auto_approved |
connection_required | connection_id, title, reason, auth |
artifact_created | path, title, kind, tool |
sub_agent_tool_call | agent, task, tool_name, input |
notice | kind, content |
final_response | content, tool_calls, usage |
status on a completed call is completed, failed or denied.
next_action is call_tool, ask_user or finish.
Three properties it is built to hold
Causal. Every step is emitted from information that already exists. A
group's settled title arrives in tool_group_completed, never patched back
into a started event a client has already drawn — so a row never has to be
undrawn.
JSON, not objects. Every step is a plain dict of primitives. It goes onto
a socket, into a log, or through
shipit_agent.streaming unchanged. Tool output that
happens to be JSON is parsed, so a UI can render a table rather than a blob;
anything else stays text.
No hidden reasoning. agent_decision carries prose the model actually
emitted, or a summary generated from observable actions and results — never
reasoning_content, never the system prompt.
Wiring a frontend
@app.websocket("/run")
async def run(ws):
await ws.accept()
prompt = await ws.receive_text()
for step in stream_timeline(agent, prompt):
await ws.send_json(step)switch (step.type) {
case "tool_group_started": openGroup(step.group_id, step.title); break
case "tool_call_started": addCall(step.group_id, step); break
case "tool_call_completed": settleCall(step.tool_call_id, step); break
case "agent_decision": addDecision(step.content); break
case "artifact_created": addCard(step); break
case "final_response": finish(step.content, step.usage); break
}A crash mid-run still closes open groups: stream_timeline finishes the
builder in a finally, so a client is never left with a group that never
ends.
The same run as a report
render_markdown() prints the timeline as a document — for a PR comment, a
run log, or an audit trail.
from shipit_agent.narrate import render_markdown
print(render_markdown(result.events))## Agent Run
**Goal:** Process the latest RSVP
### 1. Tool calls
#### Reading in.eml
##### `read_file`
**Input**
```json
{"path": "in.eml"}Status: Completed Duration: 420 ms
---
## Raw events instead
If you want everything — token deltas, retries, partial tool arguments —
`agent.stream()` is still there, and every event a timeline step derives from
is in it. See [Agent — Streaming](/docs/agent/streaming).
---
## See also
- [The live UI panel](/docs/guides/live-ui)
- [Progress narration](/docs/guides/progress-narration) — where `agent_decision` and
`agent_observation` come from
- [Apps](/docs/guides/apps) — what produces `artifact_created`