Skip to main content

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}, where seq is 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.started or tool.call.completed.
  • data — A single line containing the full Event JSON 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.

Live Editor
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>
  );
}
Result
Loading...

Auto-injected payload fields

Every payload automatically receives three fields injected by the event journal before the event is written to the stream:

FieldValue
schema_versionAlways "1" (EventSchemaVersion)
conversation_idThe run's conversation ID
stepCurrent 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 typeMeaning
run.completedRun finished successfully; payload.output contains the final response
run.failedRun encountered an unrecoverable error; payload.error explains why
run.cancelledRun 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 RunnerConfig flags control opt-in events are documented in the Event Catalog reference.
  • Starting a run: The fields you can set when calling POST /v1/runs (including max_steps, max_cost_usd, and workspace options) are covered in the HTTP API reference.
  • Connecting from the CLI: harnesscli streams events automatically — see the CLI reference for flags that control output verbosity.