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.

4 min read
9 sections
Edit this page

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.

python
from shipit_agent.narrate import stream_timeline

for step in stream_timeline(agent, "Process the latest RSVP"):
    await websocket.send_json(step)
json
{"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

stream_timeline(agent, prompt) Live
{"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}
plain JSON · one dict per step

Every step is a plain dict of primitives — straight onto a socket, with no serialisation step of your own.


The step vocabulary

TypeFields
run_startedgoal
reasoning_summarycontent, status
tool_group_startedgroup_id, title
tool_call_startedtool_call_id, group_id, tool_name, input
tool_call_completedtool_call_id, status, duration_ms, output | error
tool_group_completedgroup_id, tool_calls
agent_decisioncontent, next_action, iteration
agent_observationcontent, iteration
approval_requiredaction_id, tool_name, title, tag, auto_approved
connection_requiredconnection_id, title, reason, auth
artifact_createdpath, title, kind, tool
sub_agent_tool_callagent, task, tool_name, input
noticekind, content
final_responsecontent, 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

python
@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)
ts
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.

python
from shipit_agent.narrate import render_markdown
print(render_markdown(result.events))
markdown
## 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

bash
---

## 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`