The Event Model
Every agent run in go-code communicates progress through a stream of Server-Sent Events (SSE). When you start a run with POST /v1/runs, the server assigns it an ID, queues it, and starts executing it asynchronously. You then connect to GET /v1/runs/{id}/events to receive a real-time feed of everything that happens: when the LLM was called, which tools ran, how many tokens were used, and finally whether the run succeeded or failed.
This page explains the wire format of that stream, how to know when it is finished, how to reconnect if your connection drops, and what categories of events to expect. For the complete list of every event type and its payload fields, see the Event Catalog reference.
The SSE wire format
The harness streams events as plain-text SSE over HTTP. Each event is a block of four lines followed by a blank line:
id: <runID>:<seq>
retry: 3000
event: <event-type>
data: <JSON Event object>
id— A globally unique identifier in the form{runID}:{seq}, whereseqis a zero-based counter incremented for each event in the run. Browsers and clients use this value for reconnection (see Reconnection and keepalive).retry— Tells the client to wait 3,000 ms before reconnecting after a dropped connection.event— The event type string, e.g.run.startedortool.call.completed.data— A single line containing the fullEventJSON object.
The Event JSON struct
The data field is always a JSON-serialized Event:
type Event struct {
ID string `json:"id"` // "<runID>:<seq>"
RunID string `json:"run_id"`
Type EventType `json:"type"` // e.g. "run.started"
Timestamp time.Time `json:"timestamp"` // RFC3339 UTC
Payload map[string]any `json:"payload,omitempty"`
}
A full frame on the wire looks like this:
id: run_abc123:4
retry: 3000
event: tool.call.completed
data: {"id":"run_abc123:4","run_id":"run_abc123","type":"tool.call.completed","timestamp":"2026-06-28T12:34:56Z","payload":{"call_id":"call_1","tool":"bash","output":"hello world\n","duration_ms":142,"schema_version":"1","conversation_id":"conv_xyz","step":2}}
Live event viewer
The block below is fully editable — change the event fields and watch the formatted output update in real time. No server needed; this runs entirely in your browser.
function EventViewer() { const event = { id: "run_abc123:3", run_id: "run_abc123", type: "run.step.completed", timestamp: "2026-06-28T12:34:56Z", payload: { step: 3, tool_calls: ["bash", "read_file"], duration_ms: 842, schema_version: "1", conversation_id: "conv_xyz", }, }; const labelStyle = { fontSize: "0.7rem", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em", color: "#888", marginBottom: "2px", }; const valueStyle = { fontFamily: "monospace", fontSize: "0.85rem", }; const rowStyle = { display: "flex", flexDirection: "column", gap: "2px", padding: "8px 12px", borderRadius: "6px", background: "rgba(0,144,255,0.06)", border: "1px solid rgba(0,144,255,0.2)", }; const gridStyle = { display: "grid", gridTemplateColumns: "1fr 1fr", gap: "8px", }; return ( <div style={{ padding: "16px", fontFamily: "sans-serif" }}> <div style={{ marginBottom: "12px", display: "flex", alignItems: "center", gap: "8px" }}> <span style={{ background: "rgba(40,169,72,0.15)", color: "#28a948", borderRadius: "4px", padding: "2px 8px", fontSize: "0.75rem", fontWeight: 700, fontFamily: "monospace", }}> {event.type} </span> <span style={{ fontSize: "0.8rem", color: "#888" }}>{event.timestamp}</span> </div> <div style={gridStyle}> <div style={rowStyle}> <span style={labelStyle}>run_id</span> <span style={valueStyle}>{event.run_id}</span> </div> <div style={rowStyle}> <span style={labelStyle}>id (seq)</span> <span style={valueStyle}>{event.id}</span> </div> <div style={rowStyle}> <span style={labelStyle}>step</span> <span style={valueStyle}>{event.payload.step}</span> </div> <div style={rowStyle}> <span style={labelStyle}>duration_ms</span> <span style={valueStyle}>{event.payload.duration_ms} ms</span> </div> <div style={{ ...rowStyle, gridColumn: "1 / -1" }}> <span style={labelStyle}>tool_calls</span> <span style={valueStyle}>{event.payload.tool_calls.join(", ")}</span> </div> </div> </div> ); }
Auto-injected payload fields
Every payload automatically receives three fields injected by the event journal before the event is written to the stream:
| Field | Value |
|---|---|
schema_version | Always "1" (EventSchemaVersion) |
conversation_id | The run's conversation ID |
step | Current step number (if not already set by the emitter) |
You can rely on these fields being present in every payload without checking per-event documentation.
Terminal events
Three event types signal that the run is finished and the stream will close. Clients must stop reading after receiving any of these:
| Event type | Meaning |
|---|---|
run.completed | Run finished successfully; payload.output contains the final response |
run.failed | Run encountered an unrecoverable error; payload.error explains why |
run.cancelled | Run was cancelled via POST /v1/runs/{id}/cancel |
The Go function IsTerminalEvent(et EventType) bool returns true for exactly these three. If you are writing your own consumer, use this same set.
run.cancelled is terminal
Some older internal documentation states that only two events signal stream termination. That document is stale. The code (IsTerminalEvent in internal/harness/events.go:466-468) includes run.cancelled as a third terminal event. Always treat all three as terminal.
What terminal payloads look like
run.completed:
{
"output": "The task is done. Here is what I found...",
"usage_totals": { "prompt_tokens_total": 1200, "completion_tokens_total": 340, "total_tokens": 1540, "last_turn_tokens": 0 },
"cost_totals": { "cost_usd_total": 0.0042, "last_turn_cost_usd": 0.0008, "cost_status": "available" },
"schema_version": "1",
"conversation_id": "conv_xyz",
"step": 5
}
run.failed (max steps reached):
{
"error": "max steps (8) reached",
"reason": "max_steps_reached",
"max_steps": 8,
"usage_totals": {},
"cost_totals": {},
"schema_version": "1",
"conversation_id": "conv_xyz",
"step": 8
}
run.cancelled:
{
"usage_totals": {},
"cost_totals": {},
"schema_version": "1",
"conversation_id": "conv_xyz"
}
Reconnection and keepalive
Reconnecting with Last-Event-ID
If your connection drops mid-run, include the Last-Event-ID header on reconnect. The server replays all events after the sequence number you provide:
curl -N \
-H "Accept: text/event-stream" \
-H "Last-Event-ID: run_abc123:11" \
http://localhost:8080/v1/runs/run_abc123/events
The server parses the {runID}:{seq} format and skips every event whose sequence number is at or below the one you provided. This means you receive exactly the events you missed — nothing more, nothing less.
EventSource and query-string auth
Browser EventSource cannot set custom headers. To authenticate an SSE connection from the browser, pass the token as a query parameter: GET /v1/runs/{id}/events?token=your-api-key. The server accepts both Authorization: Bearer and ?token= for all SSE endpoints.
Keepalive pings
The server sends an SSE comment line every 15 seconds to keep the connection alive through proxies and load balancers:
: ping
SSE comments (lines beginning with :) carry no event type and no data. Clients should ignore them. You can tune the interval with the HARNESS_SSE_KEEPALIVE_SECONDS environment variable.
Observing a live stream
The examples below show how to connect to the event stream from the command line and from TypeScript.
Start the server in key-free mode, submit a run, then tail the stream:
# Terminal 1: start harnessd with the fake provider (no API key needed)
HARNESS_PROVIDER=fake \
HARNESS_AUTH_DISABLED=true \
go run ./cmd/harnessd
# Terminal 2: start a run and capture the run ID
RUN_ID=$(curl -s -X POST http://localhost:8080/v1/runs \
-H "Content-Type: application/json" \
-d '{"prompt":"hello"}' | jq -r .run_id)
# Terminal 2: stream the events until the connection closes
curl -N http://localhost:8080/v1/runs/$RUN_ID/events
You will see frames like:
id: run_abc123:0
retry: 3000
event: run.started
data: {"id":"run_abc123:0","run_id":"run_abc123","type":"run.started","timestamp":"...","payload":{"prompt":"hello","schema_version":"1","conversation_id":"conv_xyz","step":0}}
id: run_abc123:1
retry: 3000
event: llm.turn.requested
data: {"id":"run_abc123:1",...}
...
id: run_abc123:9
retry: 3000
event: run.completed
data: {"id":"run_abc123:9",...,"payload":{"output":"Hello! How can I help?","usage_totals":{...},...}}
Event categories at a glance
The harness emits 77 event types across several categories. Here is a high-level map so you know where to look in the full catalog.
Run lifecycle
Ten events track the overall run: run.started, run.queued, run.step.started, run.step.completed, run.waiting_for_user, run.resumed, run.cost_limit_reached, run.completed, run.failed, run.cancelled. The last three are terminal.
LLM turns
Five events bracket each call to the language model: llm.turn.requested, llm.turn.completed, assistant.message.delta (streaming text), assistant.thinking.delta (streaming reasoning), and assistant.message (full text, no-tool-call turns). The llm.turn.completed payload includes total_duration_ms, ttft_ms, and provider.
Tool execution
Eight events cover tool calls: tool.call.started, tool.call.completed, tool.call.delta (streaming arguments), tool.output.delta (streaming output), tool.call.blocked (blocked by skill constraint), and the approval gate trio tool.approval_required, tool.approval_granted, tool.approval_denied.
Accounting
usage.delta fires after every LLM turn with per-step and cumulative token counts and cost in USD. The usage_status field is either provider_reported or provider_unreported. A separate cost_status field is one of available, unpriced_model, provider_unreported, or pending. The opt-in cost.anomaly event fires when a step's cost exceeds a configurable multiplier of the rolling average.
Workspace
When a run provisions a per-run workspace (workspace_type set in the run request), you receive workspace.provisioned at the start and workspace.destroyed at the end. If provisioning fails, workspace.provision_failed fires and is immediately followed by run.failed.
Memory
Four events track the memory subsystem: memory.observe.started, memory.observe.completed, memory.observe.failed, and memory.reflection.completed.
Hooks and callbacks
Message-level hooks emit hook.started, hook.failed, hook.completed. Tool-level hooks use the tool_hook.* prefix (note the underscore). Deferred callbacks emit callback.scheduled, callback.fired, and callback.canceled — these may fire after the originating run has already ended.
Forensics (opt-in)
Several diagnostic categories are off by default and must be enabled in RunnerConfig: context window snapshots (context.window.snapshot, context.window.warning), LLM request envelopes (llm.request.snapshot, llm.response.meta), error chain (error.context), audit trail (audit.action), tool decision tracing (tool.decision, tool.antipattern, tool.hook.mutation), and causal graph (causal.graph.snapshot).
Reserved event types
Several event type constants — including skill.fork.started, skill.fork.completed, skill.fork.failed, spawn_agent.started, spawn_agent.completed, task.completed, and tool.activated — are defined in internal/harness/events.go and appear in AllEventTypes(), but no production emit site for these specific constants was found in the codebase at research time. Treat them as reserved; do not build logic that depends on receiving them until confirmed in the changelog.
Next steps
- Full catalog: Every event type, its payload schema, and which
RunnerConfigflags control opt-in events are documented in the Event Catalog reference. - Starting a run: The fields you can set when calling
POST /v1/runs(includingmax_steps,max_cost_usd, and workspace options) are covered in the HTTP API reference. - Connecting from the CLI:
harnessclistreams events automatically — see the CLI reference for flags that control output verbosity.