Skip to main content

Event Catalog

Every agent run produces a real-time stream of events — typed, JSON-structured messages delivered over Server-Sent Events (SSE) from GET /v1/runs/{id}/events. Each event tells you exactly what the harness is doing: when the LLM sends a token, when a tool starts, how much a step cost, why the run failed.

This catalog is the exhaustive reference for all 77 event types. Use it to build dashboards, billing meters, audit pipelines, debuggers, or any other SSE consumer that needs to know what payload to expect for a given event name.


The SSE envelope

Every line delivered over GET /v1/runs/{id}/events follows the SSE protocol:

id: <runID>:<seq>
retry: 3000
event: <event-type>
data: <JSON Event object>

The data: value is a fully marshaled Event struct (source: internal/harness/types.go):

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"`
}

The id field encodes both the run and its sequence number as {runID}:{seq}, where seq is a 0-based counter that increments with every event. You can pass Last-Event-ID: <runID>:<seq> when reconnecting — the server replays all events after that sequence number.

The server sends an SSE comment : ping on a keepalive interval (default 15 seconds, configurable via HARNESS_SSE_KEEPALIVE_SECONDS). Keepalive comments carry no payload and are not typed events.

Auto-injected payload fields

Three fields are automatically injected into every event payload by the journal layer (internal/harness/runner_event_journal.go):

FieldValueNotes
schema_version"1"Always the string "1" (EventSchemaVersion)
conversation_idstringThe run's conversation ID
stepintegerCurrent step number (if not already set by the emitter)

Terminal events

Three events signal the end of the stream. Clients MUST stop reading after receiving any of them — no further events will be emitted for that run.

EventStringMeaning
EventRunCompletedrun.completedRun finished successfully
EventRunFailedrun.failedRun ended with an error
EventRunCancelledrun.cancelledRun cancelled via POST /v1/runs/{id}/cancel

IsTerminalEvent(et EventType) bool returns true for exactly these three types (source: internal/harness/events.go:466–468).

In headless mode (harnesscli -prompt ... or streaming harnesscli continue) the terminal event also determines the process exit code — see Exit Codes for the mapping.


Run lifecycle

Source: internal/harness/events.go:18–41

run.cost_limit_reached is not itself a terminal event type, but it is immediately followed by run.step.completed and then the terminal run.completed — the run does not continue once the cost ceiling is hit, and it always ends with run.completed (never run.failed) on this path.

EventWhen emitted
run.startedRun transitions to running
run.queuedRun accepted but the worker pool is at capacity (bounded pool mode only)
run.step.startedEach step loop iteration begins
run.step.completedStep loop iteration finishes
run.waiting_for_userask_user_question tool invoked; run paused
run.resumedUser answered; run continuing
run.cost_limit_reachedCumulative cost hit max_cost_usd; immediately followed by run.step.completed then run.completed (always ends with run.completed, never run.failed)
run.completedRun finished successfully (terminal)
run.failedRun failed (terminal)
run.cancelledRun cancelled (terminal)

Payloads


LLM turn

Source: internal/harness/events.go:44–53

These events bracket each call to the LLM provider and expose streaming tokens as they arrive.

EventWhen emitted
llm.turn.requestedBefore each LLM call
llm.turn.completedAfter LLM response received
assistant.message.deltaStreaming text chunk from the assistant
assistant.thinking.deltaStreaming reasoning/thinking chunk
reasoning.completeFull reasoning text (emitted when CaptureReasoning is enabled and the provider returned reasoning content)
assistant.messageFull assistant message on a turn with no tool calls

Payloads

llm.turn.requested: {"step": N}

llm.turn.completed:

{
"step": 1,
"tool_calls": 2,
"total_duration_ms": 1200,
"ttft_ms": 340,
"provider": "openai"
}

assistant.message.delta:

{
"step": 1,
"content": "Hello"
}

assistant.thinking.delta: same shape as assistant.message.delta.

reasoning.complete:

{
"text": "string",
"tokens": 128,
"step": 1
}

assistant.message: {"content": "string"}


Tool execution

Source: internal/harness/events.go:55–81

tool.call.completed is emitted whether the tool succeeded or failed. Check for the presence of "error" in the payload to distinguish the two cases.

EventWhen emitted
tool.call.startedBefore a tool handler runs
tool.call.completedAfter the handler returns (success or error)
tool.call.deltaStreaming argument chunk while the LLM is constructing the call
tool.output.deltaIncremental output chunk from a running tool
tool.approval_requiredTool requires operator approval; run status moves to waiting_for_approval
tool.approval_grantedOperator approved the pending tool call
tool.approval_deniedOperator denied; run continues with a permission_denied result
tool.call.blockedA skill constraint blocked the tool call before execution

tool.activated is defined in the event catalog (constant EventToolActivated, string "tool.activated") but no production emission site was found in the codebase. It appears to be reserved for future use when a deferred tool is activated via find_tool. Do not rely on it being emitted today.

Payloads

tool.call.started:

{
"call_id": "call_abc",
"tool": "bash",
"arguments": "{\"command\":\"ls\"}"
}

tool.call.completed (success):

{
"call_id": "call_abc",
"tool": "bash",
"output": "file1.txt\nfile2.txt",
"duration_ms": 45
}

tool.call.completed (error):

{
"call_id": "call_abc",
"tool": "bash",
"error": "exit status 1",
"output": "",
"duration_ms": 12
}

tool.output.delta (typed ToolOutputDeltaPayload):

{
"call_id": "call_abc",
"tool": "bash",
"stream_index": 0,
"content": "chunk text"
}

tool.approval_required:

{
"call_id": "call_abc",
"tool": "file_write",
"arguments": "...",
"deadline_at": "2026-01-01T00:00:00Z"
}

tool.approval_granted / tool.approval_denied: {"call_id": "call_abc", "tool": "file_write"}

tool.approval_denied (timeout case): also includes "reason": "string".


Accounting (token usage and cost)

Source: internal/harness/events.go:113–125

usage.delta

Emitted after every LLM turn. This is the primary event for billing meters and cost dashboards.

{
"step": 2,
"usage_status": "provider_reported",
"cost_status": "available",
"turn_usage": {},
"turn_cost_usd": 0.001,
"cumulative_usage": {},
"cumulative_cost_usd": 0.003,
"pricing_version": "string"
}

usage_status is one of "provider_reported" or "provider_unreported". Typed as UsageDeltaPayload in internal/harness/events.go:529–558.

cost.anomaly

Emitted when RunnerConfig.CostAnomalyDetectionEnabled is true and a step's cost exceeds CostAnomalyStepMultiplier × the rolling average of prior steps (default multiplier: 2.0).

{
"step": 3,
"anomaly_type": "step_multiplier",
"step_cost_usd": 0.05,
"avg_cost_usd": 0.005,
"threshold_multiplier": 2.0
}

Workspace lifecycle

Source: internal/harness/events.go:319–340

These events fire only when the run specifies a workspace_type in the RunRequest.

EventWhen emitted
workspace.provisionedPer-run workspace is ready
workspace.destroyedWorkspace torn down after run ends
workspace.provision_failedProvisioning failed; run.failed follows

workspace.provisioned: {"workspace_type": "string", "workspace_path": "string"}

workspace.destroyed: same fields; adds "error" when the destroy itself failed.

workspace.provision_failed: {"workspace_type": "string", "error": "string"}


Memory events

Source: internal/harness/events.go:105–110

Emitted when the harness's observational memory subsystem runs its observe/reflect cycle after a completed step.

EventWhen emitted
memory.observe.startedObservation cycle begins
memory.observe.completedObservation cycle finished
memory.observe.failedObservation cycle failed (non-fatal to the run)
memory.reflection.completedReflection compression finished

memory.observe.completed payload includes step, observed (bool), reflected (bool), and observation (count).


Steering

Source: internal/harness/events.go:161–165

steering.received

Emitted when a user steering message is injected into an active run via POST /v1/runs/{id}/steer.

{
"message": "string"
}

Conversation

Source: internal/harness/events.go:89–91

conversation.continued

Emitted when prior conversation history is loaded for the run (i.e., a run started with a conversation_id pointing to an existing conversation). Payload includes conversation_id and prior_message_count.


Prompt and provider resolution

Source: internal/harness/events.go:94–102

EventWhen emitted
prompt.resolvedSystem prompt resolved via the prompt engine
prompt.warningWarning encountered during prompt resolution
provider.resolvedProvider and model selected for this run

Context management

Source: internal/harness/events.go:168–193

EventWhen emitted
auto_compact.startedAutomatic context compaction triggered
auto_compact.completedAuto-compaction finished
compact_history.completedcompact_history tool call completed
context.resetAgent called reset_context

context.reset (typed ContextResetPayload):

{
"reset_index": 0,
"at_step": 3,
"persist": null
}

Hooks, callbacks, and skill constraints

Message-level hooks

Source: internal/harness/events.go:128–132

Hooks run before and after LLM turns. hook.started, hook.failed, hook.completed.

Tool-level hooks

Source: internal/harness/events.go:135–139

Note the underscore in tool_hook.* — these are tool-level hooks, not message-level. The prefix uses an underscore, not a dot.

tool_hook.started, tool_hook.failed, tool_hook.completed.

Callbacks (deferred)

Source: internal/harness/events.go:142–146

EventWhen emitted
callback.scheduledset_delayed_callback tool ran
callback.firedTimer fires
callback.canceledCallback was cancelled

callback.fired and callback.canceled may be emitted after the originating run has ended. They are emitted on the most-recent live run for the conversation, or are a no-op if none exists.

callback.* payload:

{
"callback_id": "string",
"conversation_id": "string",
"state": "string",
"delay": "duration",
"prompt": "string",
"fires_at": "RFC3339",
"created_at": "RFC3339"
}

Skill constraints

Source: internal/harness/events.go:149–153

EventWhen emitted
skill.constraint.activatedA skill constraint (tool filter) is now active
skill.constraint.deactivatedSkill constraint removed
tool.call.blockedA tool call was blocked by an active skill constraint

Agent spawning and budget events

Source: internal/harness/events.go:355–380

EventEmission statusNotes
step_budget.pressureActively emittedStep budget running low; payload {"step": N, "steps_remaining": N, "depth": N}
max_turns.exhaustedActively emittedAgent exhausted MaxTurns; run.failed follows with reason: max_turns_exhausted
spawn_agent.startedDefined, not confirmedSee warning below
spawn_agent.completedDefined, not confirmedSee warning below
task.completedDefined, not confirmedSee warning below

max_turns.exhausted payload:

{
"run_id": "string",
"step": 5,
"turn_count": 10,
"max_turns": 10
}

spawn_agent.started, spawn_agent.completed, and task.completed are defined in the event catalog and returned by AllEventTypes(), but no production emission site was found in the codebase for these three constants. They appear to be reserved for a future multi-agent phase. Do not build consumers that depend on them being emitted today.


Skill fork events

Source: internal/harness/events.go:176–180

skill.fork.started, skill.fork.completed, and skill.fork.failed are defined in the event catalog and included in AllEventTypes() but no production emission site was found in the codebase. They are defined-but-unconfirmed. Do not rely on them being emitted.


Profile efficiency suggestion

Source: internal/harness/events.go:343–352

profile.efficiency_suggestion

Emitted after a subagent run using a named profile when the run's efficiency score falls below 0.6. The efficiency formula is 1.0 / (1.0 + steps × 0.1 + costUSD × 10.0).

{
"profile_name": "researcher",
"run_id": "run_abc",
"efficiency_score": 0.42,
"steps": 18,
"cost_usd": 0.08
}

The comment in events.go for this event mentions unused_tools and remove_tools fields. The actual emission site (runner.go:2767–2773) uses steps and cost_usd instead. The comment describes an earlier planned payload shape; the payload above reflects the actual emitted fields.


Diagnostic and forensics events (opt-in)

All events in this section require a flag set on RunnerConfig. They are never emitted unless explicitly enabled.

Context window forensics

Enable: RunnerConfig.ContextWindowSnapshotEnabled = true

EventWhen
context.window.snapshotAfter each LLM turn
context.window.warningWhen usage exceeds ContextWindowWarningThreshold

context.window.snapshot payload (typed ContextWindowSnapshotPayload):

{
"step": 2,
"provider_reported_tokens": 4096,
"provider_reported": true,
"estimated_total_tokens": 4200,
"max_context_tokens": 128000,
"usage_ratio": 0.033,
"headroom_tokens": 123904,
"breakdown": {
"system_prompt_tokens": 512,
"conversation_tokens": 3500,
"tool_result_tokens": 84,
"estimated": true
}
}

context.window.warning payload:

{
"step": 2,
"usage_ratio": 0.88,
"threshold": 0.80,
"provider_reported": false,
"tokens_used": 112640,
"max_context_tokens": 128000
}

LLM request envelope forensics

Enable: RunnerConfig.CaptureRequestEnvelope = true

EventWhen
llm.request.snapshotBefore each provider call
llm.response.metaAfter each provider call

llm.request.snapshot:

{
"step": 1,
"prompt_hash": "sha256hexstring",
"tool_names": ["bash", "read"],
"memory_snippet": "string"
}

llm.response.meta:

{
"step": 1,
"latency_ms": 980,
"model_version": "string"
}

Tool decision tracing

FlagEvent
RunnerConfig.TraceToolDecisions = truetool.decision — per-step tool selection trace
RunnerConfig.DetectAntiPatterns = truetool.antipattern — same (tool, args) pair used 3+ times
RunnerConfig.TraceHookMutations = truetool.hook.mutation — pre-tool hook modified or blocked the call

tool.antipattern:

{
"type": "string",
"tool": "bash",
"call_count": 3,
"step": 5
}

Audit trail

Enable: RunnerConfig.AuditTrailEnabled = true (also requires RolloutDir to be set)

audit.action — emitted per state-modifying tool call (source: runner_step_engine.go:732):

{
"tool": "file_write",
"call_id": "call_abc",
"arguments": "...",
"step": 3
}

Causal graph

Enable: RunnerConfig.CausalGraphEnabled = true

causal.graph.snapshot — emitted at run end with the full causal dependency graph (runner_step_engine.go:113).

Error chain

Enable: RunnerConfig.ErrorChainEnabled = true

error.context — emitted immediately before run.failed. Contains the error class, message, optional cause chain, and a rolling snapshot of recent tool calls and messages (default depth: 10).


Other system events

Empty-response retry

Source: internal/harness/events.go:288–295

llm.empty_response.retry — emitted when the LLM returned no text and no tool calls, triggering a retry:

{
"step": 2,
"retry": 1,
"max_retries": 3
}

Dynamic rule injection

Source: internal/harness/events.go:298–304

rule.injected — emitted when a DynamicRule fires and is injected into the system prompt:

{
"rule_id": "string",
"step": 2,
"trigger_tool": "bash"
}

Meta message injection

Source: internal/harness/events.go:156–158

meta.message.injected — emitted when a meta-message is injected into the conversation (runner_step_engine.go:1216).

Recorder drop detection

Source: internal/harness/events.go:307–316

recorder.drop_detected — emitted when the recorder channel is full and a non-terminal event is dropped. If you see this, the recorder is under pressure and your event log has a gap.

{
"dropped_event_id": "string",
"dropped_event_type": "string",
"dropped_seq": 42
}

Reserved and unconfirmed events

The following constants are defined and returned by AllEventTypes() but have no confirmed production emission site in the current codebase:

Event stringConstantStatus
skill.fork.startedEventSkillForkStartedDefined, no emission site found
skill.fork.completedEventSkillForkCompletedDefined, no emission site found
skill.fork.failedEventSkillForkFailedDefined, no emission site found
spawn_agent.startedEventSpawnAgentStartedDefined, no emission site found
spawn_agent.completedEventSpawnAgentCompletedDefined, no emission site found
task.completedEventTaskCompletedDefined, no emission site found
tool.activatedEventToolActivatedDefined, no emission site found

These events appear reserved for multi-agent and skill-fork features not yet plumbed into the production runner. They are included in AllEventTypes() for completeness but should not be used in consumer logic that must handle real traffic. Verify before using in production integrations.


Quick reference: event count by category

CategoryCountOpt-in?
Run lifecycle10No
LLM turn6No
Tool execution8No (+ 1 unconfirmed)
Accounting / cost2cost.anomaly only
Workspace3When workspace_type is set
Memory4When memory is enabled
Hooks (message + tool)6No
Callbacks3No
Skill constraints3No
Steering / conversation / prompt / provider5No
Context management4No
Agent spawning / budget5No (2 active + 3 unconfirmed)
Profile efficiency1No
Context window forensics2ContextWindowSnapshotEnabled
LLM request envelope2CaptureRequestEnvelope
Tool decision tracing3Per flag
Audit trail1AuditTrailEnabled
Causal graph1CausalGraphEnabled
Error chain1ErrorChainEnabled
Other system events4No
Reserved / unconfirmed7
Total77

Note: The Count column sums to more than 77 because a few events (tool.call.blocked, spawn_agent.started, spawn_agent.completed, task.completed) are cross-listed in multiple categories. The true distinct total is 77, as returned by AllEventTypes().


Next steps