Avenor Event Stream
Avenor writes newline-delimited JSON to --on-event. Each line is one event. Consumers should switch on the event field and read domain-specific fields directly from the same object — there is no nested wrapper.
The event structure is flat: all fields at the top level, including a required event string that names the event type.
{"event":"tool.call","session_id":"ses_123","toolCallId":"call_1","status":"pending"}Why events matter
Without events, you're polling HTTP endpoints or reading a finished log file. With events, you know the moment something happens: when the agent transitions between thinking and working, when a permission gate is hit, when a critical tool runs. That timing is load-bearing for orchestration, reporting, and permission handling.
Avenor classifies each event as MILESTONE, FINDING, or ACTIVITY. LLMs that consume this stream use those tags to decide whether to wake up, log, or escalate.
Event structure
Events use a flat envelope. In addition to the event-specific fields below, Avenor adds these fields when the corresponding identity is available:
event— event type name.ts— Unix time in milliseconds.seq— monotonically increasing, runtime-local sequence number.session_id— backend session identifier.run_idandrun_label— Avenor run identity.runtime_id— containing launch identity. Underavenor stablethis is the child runtime ID. On a CLI run it equalsrun_id.
Use seq, not arrival time, to reconcile replayed and live events from the same runtime. Sequence numbers are not globally comparable across runtimes.
Event types
Protocol events (passed from backend)
These come from the Claude API backend. Avenor parses them, validates required fields, and passes them downstream.
agent.message_chunk — Streamed response text visible to the agent. Contains content (object with text field), messageId, and optional session_update metadata.
agent.thought_chunk — Intermediate reasoning or progress text exposed by the model (when enabled in the backend configuration). Same structure as agent.message_chunk.
tool.call — A tool invocation began. Fields: toolCallId (unique identifier), title (human-readable name), kind (tool category like bash, read, write), status ("pending" at emission), rawInput (the serialized argument JSON), and optional session_update.
tool.call_update — Tool status changed. Emitted when a tool completes or transitions states. Fields: toolCallId, kind, status ("completed" or "failed"), and optional session_update.
user.message_chunk — User text reflected by the backend. Rarely emitted; documented for protocol completeness.
session.plan — Backend plan update (experimental). Rarely emitted; documented for protocol completeness.
Backend normalization and compatibility events
ACP backends already speak the canonical chunk and tool vocabulary. Other backends are translated on a best-effort basis:
- Pi message and tool notifications produce canonical
agent.message_chunk,agent.thought_chunk,tool.call, andtool.call_updateevents. Existingavenor.message.*andavenor.tool.*compatibility events remain available; consumers should deduplicate canonical/compatibility pairs byseqand semantic content. Streamedavenor.message.updateevents retain only the incremental delta and tool identity: cumulative message snapshots, cumulative partial tool arguments, and opaque provider reasoning signatures are intentionally omitted from Avenor's event log. Empty Pi text deltas are suppressed. - Codex app-server item notifications are mapped to canonical chunks and tool lifecycle events when their payload contains usable text or tool metadata. The original
avenor.item.*lifecycle events remain available. - Claude transcript records produce canonical assistant, user, thought, and tool events when those records expose the content. Avenor does not fabricate reasoning or tool output that the transcript does not contain.
- Agy cumulative planner responses produce canonical incremental
agent.message_chunkevents. Validated tool metadata produces canonicaltool.callandtool.call_updateevents, while permissions and user questions producepermission.request. Unsupported or malformed private state produces a boundedavenor.agy.protocoldiagnostic instead of exposing private wire identities.
TypeScript consumers can use normalizeRunEvent, RunReducer, and observeRun from @dougbots/avenor-core rather than maintaining backend-specific parsing.
Permission events
permission.request — Backend is asking the client to choose a permission option. For example: "Allow bash command?" with options ["allow", "reject"].
Avenor populates request_id (unique for this session), tool (best-effort), question (best-effort), and options (array of choice objects) when available. Every request also includes resolver, which is one of automatic, reserved, file, or none:
automatic: Avenor will resolve the request automatically. This value is informational.reserved: A connected control client can answer the request.file: The configured file handler can answer the request.none: No resolver is configured, so the backend remains blocked until another path answers it.
For the pi backend, tool is reliably populated by correlating the request with the in-flight tool call (the tool.call title, e.g. write, edit, bash) whenever one is pending; if no tool call is in flight, tool is omitted. kind is the dialog kind (command, confirm, or input), not the tool kind, and question is the prompt text. The in-flight call's raw arguments are attached as a bounded input string (capped at 4 KB) so policy resolvers can inspect them.
The latter three values indicate a real block requiring attention when the event is emitted. --auto-approve does not make requests with requires_user_input: true automatic. Consumers should continue using pending_permission as the lifecycle summary; resolver is an event-time hint, not a replacement for status. If you're using --permission-handler file:<path>, Avenor emits this event once and writes the .req file for the file resolver without waiting for the response.
Classifies as MILESTONE because permission gates are decision points.
Example:
{"event":"permission.request","session_id":"ses_xyz","request_id":"17","resolver":"reserved","tool":"bash","question":"Run this command?","options":[{"optionId":"allow","kind":"allow"},{"optionId":"reject","kind":"reject"}]}permission.response — Synthesized by Avenor after resolving a permission decision. Emitted for all resolution paths: --auto-approve, control socket, or file handler. Fields: request_id (string), option_id (the chosen optionId from the request's option list), kind ("allow" or "reject"), source ("avenor" for auto-approve, "control" for control socket, "file" for file handler), ts (Unix milliseconds).
Also includes run_id and run_label when present.
Classifies as ACTIVITY because the permission is already decided by the time this event fires.
Example:
{"event":"permission.response","session_id":"ses_xyz","request_id":"17","option_id":"allow","kind":"allow","source":"file","ts":1234567890}Phase and iteration events (Avenor synthesized)
When running with --config, Avenor executes a loop: optional pre-phases followed by a configurable number of iterations through phases. Each phase is a separate Claude prompt invocation.
avenor.loop.start — Loop is starting. Emitted before the first pre-phase. Fields: max_iterations (from config), pre_phase_count, loop_phase_count, ts.
Classifies as MILESTONE.
Example:
{"event":"avenor.loop.start","run_id":"run_1","max_iterations":5,"pre_phase_count":1,"loop_phase_count":2,"ts":1234567890}avenor.phase.start — One phase is starting. Emitted before invoking the Claude backend for this phase. Fields: phase (phase name from config), iteration (0 for pre-phases, 1+ for loop iterations), kind (the section: "pre", "loop", "team", or "post"), run_kind (the containing runner: "loop" or "team"), ts.
Classifies as ACTIVITY.
Example:
{"event":"avenor.phase.start","run_id":"run_1","phase":"code_review","iteration":1,"kind":"loop","run_kind":"loop","ts":1234567890}avenor.phase.end — Phase completed (or failed). Emitted after the Claude invocation returns. Fields: phase, iteration, run_kind, stop_reason (why the phase ended: "end_turn" for normal completion, "max_tokens", "tool_use", etc.), ts.
avenor.phase.end does not carry the section kind, and neither phase event carries a launch attempt.
If the phase exit text contained a workflow directive marker (for example, <|workflow: abort | reason|> or <|workflow: exit | tests green|>), additional fields capture it: abort_marker (boolean) with optional abort_marker_label, or exit_marker (boolean) with optional exit_marker_label.
Classifies as ACTIVITY.
Example:
{"event":"avenor.phase.end","run_id":"run_1","phase":"code_review","iteration":1,"run_kind":"loop","stop_reason":"end_turn","ts":1234567890}avenor.loop.end — Loop finished (or stopped early). Emitted after the final phase completes or when a phase directive halts the loop. Fields: exit_reason (one of "end_turn", "abort", "exit", "max_iterations", "phase_failure", "timeout", "cancelled"), iterations_completed (count of fully-executed iterations), ts.
Also includes exit_label when a directive marker had a label.
Classifies as MILESTONE because loop completion is a significant boundary.
Example:
{"event":"avenor.loop.end","run_id":"run_1","exit_reason":"end_turn","iterations_completed":3,"ts":1234567890}claude backend events
The claude backend has no broker or sidecar, so it produces no channel-side events.
Events emitted:
session.start— carriesbackend="claude",dir, anddangerously_load=false(the flag is never set).session.end—stop_reasonis one ofend_turn,cancelled, orcancelled_forced. No token usage field (transcript does not expose it).agent.prompt_submitted—deliveryis"pty"or"tmux"depending on the active launcher.ready_signalsays what decided the input box was taking input:"osc7501"when the prompt waited for Claude's startupidlereport,"pane"when the visible input box alone decided (tmux,AVENOR_CLAUDE_OSC7501=off, or no report in time).startup_wait_msis present only on the first prompt of a--claude-mcp-waitrun: the extra milliseconds the first prompt waited beyond readiness (0when readiness already took longer).agent.status—phase=workingsourced from"transcript"when new JSONL records appear;phase=waitingsourced from"pty"or"tmux"on each pane scrape that finds Claude at its input box, until the first OSC 7501 report arrives (see below).permission.request— emitted from pane-scrape when a permission dialog is detected.
On the PTY launcher Claude Code reports its own state with OSC 7501 (Program Status Protocol) once Avenor answers its detection query. After the first report the pane heartbeat and the transcript working events stop, and agent.status events come from report transitions, one per transition, tagged signal="osc7501" with source="pty":
phase=workingwhen Claude starts working on the prompt.phase=waitingwithreason="question"orreason="auth"when Claude is blocked on a question or on authentication.labelcarries Claude's message, truncated to 200 characters; it is display text only. Nopermission.requestis emitted for these. A blocked report with no recognised kind givesphase=waitingwith noreason.phase=waitingwithreason="idle"when Claude returns to its input box after it started working on the prompt (a cancelled or denied turn).
A report that Claude is blocked on a permission emits no agent.status; Avenor brokers the dialog as a permission.request as soon as it is on screen. Reports from subagents never affect the turn. session.end stays transcript-driven: an end_turn record ends the turn, except that while Claude still reports the turn working (a background subagent ends an inner turn first) Avenor holds it until Claude reports done, idle, clear or error, the terminal resets, or reports are lost, and once reports are flowing it ends the turn only after about 0.75s passes without a later end_turn, so the final reply is emitted first. Claude reporting done without a transcript end_turn never ends the session.
The tmux launcher does not answer the query and keeps the pane-scrape behaviour above. AVENOR_CLAUDE_OSC7501=off (or 0, false) turns the reply off for the claude backend.
The broker-channel events Avenor used to synthesize — agent.channel_ready, agent.prompt_queued, agent.report, agent.reply, agent.finish — are retired with the claude-channel backend and no shipped backend emits them. The session tracker still decodes them, so an external publisher that sends one is not ignored.
The source field on agent.status is limited to "transcript", "pty", and "tmux" — never "channel" or "sidecar". signal and reason appear only on report-derived events.
For the claude backend, session.end is the authoritative lifecycle signal. There is no agent.finish event; use session.end to detect completion.
pi backend events
The pi backend drives a pi child over an RPC command stream, so it has no terminal to scrape. It emits the backend events listed in Protocol events plus one synthesized lifecycle event:
agent.prompt_submitted — The pi child accepted a prompt over the RPC command stream. Fields: prompt_length (the prompt's length in bytes). The event omits prompt text for privacy.
A matching success: true response emits this event before any later backend notifications, including session.end. A rejected prompt (success: false) emits no submission event and returns the refusal to the caller. Submission does not mean completion; session.end remains the authoritative completion signal.
Retry and error events
avenor.retry — A phase is being retried (exit code 1, non-fatal failure). Emitted before each retry attempt after the first. Fields: attempt (which retry this is: 1 for the first retry, 2 for the second, etc.), max_retries, ts.
Classifies as MILESTONE.
Example:
{"event":"avenor.retry","run_id":"run_1","attempt":1,"max_retries":3,"ts":1234567890}avenor.error — Avenor encountered a runtime error (malformed request, permission handler timeout, backend unavailable, etc.). Fields: source (subsystem: "permission", "cancel", "backend", "loop", etc.), message (human-readable error text), ts.
Also includes run_id and run_label when present. Loop-level degenerate reasoning aborts include additional fields: kind ("degenerate_reasoning_stream"), stop_reason (the session-end stop reason that follows), model (the provider profile that produced the bad stream), and diagnostics (an object with abort_signal, observed_total, hex_total, hex_in_window, window_size, consecutive_hex, and progress_in_window).
Classifies as MILESTONE because errors usually require operator intervention.
Example (permission timeout):
{"event":"avenor.error","session_id":"ses_xyz","run_id":"run_1","source":"permission","message":"handler timed out after 10m","ts":1234567890}Example (degenerate reasoning stream):
{"event":"avenor.error","session_id":"ses_xyz","source":"loop","kind":"degenerate_reasoning_stream","stop_reason":"degenerate_reasoning_stream","model":"gpt-oss-120b","message":"degenerate reasoning stream: provider control token leakage (<|channel>)","diagnostics":{"abort_signal":"control_token","observed_total":1,"hex_total":0,"hex_in_window":0,"window_size":2048,"consecutive_hex":0,"progress_in_window":0}}Status and session events
Every event in this section is written to the durable --on-event NDJSON except session.renamed, which is published to the control stream only.
agent.status — Synthesized by Avenor to signal agent phase transitions. Emitted before the protocol event that triggered it.
Fields: phase (one of thinking, working, waiting, done), source ("avenor" for synthesized transitions, "agent" for explicit markers in output text, "transcript" when the claude backend derives it from JSONL records, or the terminal kind "tmux"/"pty" for pane-scrape-derived waiting/permission states), optional label (human-readable activity description), optional signal ("osc7501" when the program's own status report produced the event; claude backend only), optional reason (why a waiting phase is waiting: question, auth, or idle), ts (Unix milliseconds).
Also includes run_id and run_label when present.
The phase values mean:
thinking: Agent is reasoning or planning (triggered byagent.thought_chunk).working: Agent is actively executing tools (triggered bytool.call).waiting: Agent is blocked on a permission decision (triggered bypermission.request).done: Session ended (triggered bysession.end).
Classifies as MILESTONE when phase is done or waiting; ACTIVITY otherwise.
Example:
{"event":"agent.status","session_id":"ses_xyz","phase":"working","label":"reading files","source":"avenor","ts":1234567890}session.renamed — A run's display name changed. Fields: run_id, runtime_id (so the event is indexed into that runtime's history), label (the new name), previous_label, actor ("owner" or "self"), ts (Unix milliseconds). It is published after the rename persists, to the control stream only; the renamed run's on_event log is not written.
Classifies as MILESTONE.
session.end — Terminal record for the session. Fields: stop_reason, exit_code (always the code the stop reason maps to: 0 for end_turn, 2 for tool_denied/refusal, 3 for max_tokens, 4 for max_turn_requests, 5 for blocked, 6 for quota_exhausted, 7 for rate_limited, 124 for timeout/progress_timeout, 130 for cancelled, 1 otherwise — a backend-supplied number is never used), optional end_kind ("run" for the run-level end described below), optional usage (object with input_tokens, output_tokens, total_tokens, cached_read_tokens in snake_case), optional retry_after (whole seconds, provider limit failures only), optional classified_from (the backend's own stop reason when Avenor replaced it), and optional complete final_output provided by the backend or synthesized by Avenor from the complete agent.message_chunk stream. Durable NDJSON retains that complete value. Status, inspection, control history, and subscriptions show bounded previews and set final_output_truncated: true when they omit text; the result tool retrieves the complete reply.
A run that ends on its context while no turn is in flight — parked, waiting for a runtime slot or tree token, or in retry backoff — publishes a run-level terminal session.end tagged end_kind: "run", with runtime_id and the mapped exit_code, so the end reaches the durable event stream and broker recording like a turn's own end. It fires only for a run context end (a cancel or a run deadline): a run that stops normally between phases publishes nothing, and neither does a runtime reaped by --parked-timeout. A session publishes at most one such end, and a child that never established a session publishes none — an empty session_id identifies no session to end. A turn's own session.end is the session's terminal record, and its end_turn is not the session's end while the run continues. The run-level end is the session's final event: it always carries end_kind: "run", so a run-level end that follows a turn's end is told apart from it. A run-level end records no last-agent-message entry: only a turn's own session.end updates the broker's last-message store, so a run that only ended at the run level still answers has_record: false to session_last.
Classifies as MILESTONE.
The stop_reason indicates why the session ended:
"end_turn"— Normal completion. Backend finished responding."max_tokens"— Hit the token limit during generation."stop_sequence"— Backend stopped on a configured stop sequence."tool_use"— Session ended while waiting for tool results (unexpected in normal flows)."timeout"— Avenor's client-side timeout fired. Usage is best-effort from the buffer at timeout."cancelled"— Avenor or the operator cancelled the session. Usage may be incomplete."quota_exhausted"— The provider refused the turn because the account's usage quota is exhausted. Exit code6, and not retried. See Provider limit failures for where the provider's message lands."rate_limited"— The provider refused the turn because of a rate limit. Exit code7, and retryable by--max-retries. When the provider states how long to wait,session.endalso carriesretry_after(whole seconds)."degenerate_reasoning_stream"— The reasoning/thought stream became clearly non-productive (provider leakage of internal control tokens, or a long run of single-character hex deltas). Avenor aborted the session early to prevent the run from consuming the entire output budget. The matchingavenor.errorevent contains the abort signal and diagnostics; themodelandsession_idfields on the error are enough to identify the provider profile that produced the bad stream.
Example (normal completion):
{"event":"session.end","session_id":"ses_123","stop_reason":"end_turn","usage":{"input_tokens":1000,"output_tokens":500,"total_tokens":1500,"cached_read_tokens":100}}Example (client-side timeout):
{"event":"session.end","session_id":"ses_123","stop_reason":"timeout","usage":{"input_tokens":1000,"output_tokens":200,"total_tokens":1200}}Example (cancelled):
{"event":"session.end","session_id":"ses_123","stop_reason":"cancelled"}Example (provider rate limit with a retry hint):
{"event":"session.end","session_id":"ses_123","stop_reason":"rate_limited","error_message":"429: rate limit reached; try again in 20s","retry_after":20}Provider limit failures
Avenor classifies a provider's usage-limit or rate-limit failure into quota_exhausted or rate_limited from the terminal failure text, so the run is not reported as a generic error. An explicit stop reason from the backend always wins; only failures that would otherwise map to exit code 1 are reclassified. The classifier covers the shapes each backend forwards:
| Backend | Failure text source |
|---|---|
pi | The provider message in the settled agent_end (error_message), or the captured child stderr when the event carries none |
codex-app-server | The turn/completed failure message |
| ACP backends | The prompt error text |
opencode-http | The provider error name, reported as the stop reason |
claude | None: the finish record's summary is model-authored prose, so it never classifies a run |
The provider's retry-after hint is parsed from the same text (try again in 20s, retry-after: 30, resets in 2 hours) and reported as retry_after in whole seconds. A value beyond a day is treated as absent.
A quota failure with a reset hint stays quota_exhausted: only an explicit throttle signal (a 429, or rate-limit wording) makes the retryable class, so a hard-capped account is not retried against the same refusal.
A retry wait honors the hint: the CLI, the supervisor's child retry loop, and the loop/team phase retry loops sleep at least retry_after (or the configured backoff, whichever is longer), clamped to the run deadline and to a five minute ceiling. A hint above the ceiling, or one that cannot fit before the run deadline, stops the retry loop instead of sleeping: the run ends rate_limited with its RETRY_AFTER intact.
Classification runs once, after the turn settles, from the terminal event's own failure field plus the prompt error text, so the result does not depend on which arrived first. Where the provider's message lands depends on the path:
- The terminal event carried it. The event keeps the field it arrived on (
error_message,error, ormessage) and Avenor rewritesstop_reason. When the backend's own stop reason was replaced, the original value stays visible inclassified_from, and the event is written once the turn has settled, so it stays the last event. - Only the prompt error carried it. No terminal event arrived, so Avenor writes a
session.endwithstop_reason(andretry_afterwhen present). The cause is on theavenor.errorevent whosesourceis"prompt".
Only provider-owned fields classify a run. A model-authored value — notably a finish summary — cannot choose the stop reason or the retry-after hint.
Launch identity
Avenor starts each harness process with an environment that describes the launch. A local MCP server or another harness child can read that environment and attribute its tool calls to the run that contained it.
The values are informational. A child can change them, and nothing authorizes on them. Use them to join to the event stream. Never use them as credentials.
| Variable | Value | Matching event field |
|---|---|---|
AVENOR_RUNTIME_ID | Runtime identity of the launch: the CLI run ID, or the supervisor child ID. | runtime_id |
AVENOR_EXECUTION_ID | Containing execution: the CLI run ID, or the supervisor child ID. | runtime_id, which equals run_id on a CLI run |
AVENOR_KIND | Containing runner: run, team, or loop. | run_kind |
AVENOR_PHASE | Member or phase name. Absent for a direct run. | phase |
AVENOR_ITERATION | Loop iteration, including 0 for pre and post phases. Absent for team and direct runs. | iteration |
AVENOR_LABEL | Run label, when one was supplied. | run_label |
AVENOR_ATTEMPT | 1-based launch-attempt ordinal. | No event field |
Avenor sets AVENOR_RUNTIME_ID and AVENOR_EXECUTION_ID to the same value. Join on AVENOR_EXECUTION_ID, because it names the containing execution.
Omission and scrubbing
- Avenor exports only fields with a value. An unset field is absent, not empty.
- Avenor removes inherited values for these seven names and for the superseded
AVENOR_PARENT_RUN_IDbefore it adds the current launch values. Matching is on the exact name, so duplicates are removed too. - Near-miss names keep their values. So do
AVENOR_TREE_BUDGET, backend selectors such asAVENOR_AGY_TRANSPORT, and broker values. Thepibackend replaces the broker values it inherits; see Backends → Launch identity. - When broker credentials are present, the
pibackend also receivesAVENOR_RUN_IDandAVENOR_BROKER_TOKEN. That pair lets the child poll its inbound asks and reply as its own broker run.AVENOR_RUN_IDis broker identity, not the containing execution.
CLI and supervisor ID mapping
- A CLI run sets both IDs to the CLI run ID. That ID is
run_idandruntime_idon every event of the run. - A supervisor child sets both IDs to the child runtime ID (
rt_N). That ID isruntime_idon the child's events; those events keeprun_idset to the supervisor run ID. - A nested loop or team inside one CLI run shares the CLI run ID.
AVENOR_KINDnames the inner runner, so a team inside a loop reportsteam.
Attempt numbering
- A direct run uses the retry counter of its owning CLI or supervisor run. The first launch is
1; each retry increments it. - A loop or team phase uses one counter per phase invocation. The first launch is
1. Retries and requirement nudges increment it. A new phase invocation or loop iteration starts again at1. - Phase events do not carry the attempt. One
avenor.phase.start/avenor.phase.endpair encloses every attempt of a phase invocation. A consumer that needs per-attempt attribution keeps its own record of repeated tool calls.
Launch, not prompt
The environment describes a process launch.
- A follow-up prompt to a live process does not change its environment. The process keeps the identity of its launch.
- A fresh start or resume that starts a process receives the identity of that request.
- A transport relaunch inside one attempt, such as the
agyprint continuation, keeps that attempt's identity.
Join a finding to its completion
Assume an observation reported AVENOR_EXECUTION_ID=a3f9c1, AVENOR_KIND=team, AVENOR_PHASE=security-reviewer, AVENOR_ATTEMPT=1, and no AVENOR_ITERATION.
- Match
AVENOR_EXECUTION_IDto the eventruntime_id. - When
AVENOR_PHASEis set, select the phase events whoserun_kindisAVENOR_KIND, whosephaseisAVENOR_PHASE, and whoseiterationisAVENOR_ITERATION, or0when the variable is absent. - When
AVENOR_PHASEis set and the run is not nested, expect exactly oneavenor.phase.startand oneavenor.phase.end. Readstop_reasonfrom the end event. To read the member's final message, match the end event'ssession_idto thesession_idon that session'ssession.endand read itsfinal_output. - When
AVENOR_KINDisteamorloop, matchavenor.team.endoravenor.loop.endon the sameruntime_id. Readexit_reasonfrom that event. - A direct run sets
AVENOR_KIND=runand noAVENOR_PHASE, so it emits no phase events. Join it onsession.endinstead: matchruntime_id, then readstop_reasonfrom the lastsession.end. Each retried attempt emits its ownsession.endand none carries an attempt field, so a direct run supports no per-attempt join.
Example for a CLI team run:
{"event":"avenor.phase.end","run_id":"a3f9c1","runtime_id":"a3f9c1","run_label":"verify-provenance","run_kind":"team","phase":"security-reviewer","iteration":0,"stop_reason":"end_turn","ts":1715000000200}
{"event":"avenor.team.end","run_id":"a3f9c1","runtime_id":"a3f9c1","run_label":"verify-provenance","exit_reason":"end_turn","members_completed":3,"members_aborted":0,"ts":1715000000300}Under a supervisor, the same join uses runtime_id: "rt_4" while run_id stays the supervisor run ID.
Coverage limits
opencode-httpattaches to an external server and starts no backend process.ponyruns in-process. Neither delivers an environment to a harness child.- Repeated nested phase names with the same iteration produce identical tuples. The join is ambiguous; no scope ID is generated.
- An opaque launch wrapper can drop or rewrite the environment. Avenor does not verify wrapper behavior.
See Backends → Launch identity for per-backend delivery and the wrapper boundary.
Classification system
Avenor classifies events into three buckets: MILESTONE, FINDING, ACTIVITY. This classification drives alerting and reporting.
MILESTONE — Structural decisions or boundaries: loop start/end, phase end, permission gates, session end, retries, errors, agent.status transitions to waiting or done.
FINDING — Content that explicitly signals a problem: confidence scores ≥60% in message text, explicit [finding] markers, phrases like "reviewer flagged", "correction needed", "failed test".
ACTIVITY — Everything else: routine tool calls, message chunks, phase transitions to thinking or working.
When using avenor watch --classify, MILESTONE and FINDING events are tagged so they stand out; ACTIVITY events are not.
Emitting your own events
You can emit custom events for Avenor to pass downstream. Avenor treats any unknown event type as ACTIVITY. If you want a custom event classified as MILESTONE or FINDING, post-process it with avenor digest --classify before shipping downstream.
Custom events must have an event field (string) and may include any other fields. Avenor does not validate or transform them.
Consumer pattern
Machine consumers should treat the NDJSON event log as the durable source of truth and derive their own run view from it. Control-socket history and subscriber queues retain only a recent bounded window and surface lag explicitly. For Go orchestration code, use internal/events.SessionTracker for the coarse lifecycle view. TypeScript integrations should use the shared @dougbots/avenor-core normalizer, reducer, and observer for transcript-oriented snapshots.
Minimal pattern:
tracker := events.NewSessionTracker(sessionID)
for _, ev := range stream {
if tracker.Observe(ev) {
snapshot := tracker.Snapshot()
// snapshot.ChannelReady, snapshot.LastReportState,
// snapshot.WaitingPermission, snapshot.Ended, etc.
}
}For claude, session.end is the only terminal lifecycle event. There is no agent.finish. Use session.end to detect completion and agent.status phase=working source=transcript to track progress.
Design notes
Flattened structure. No nested
fieldsobject. All domain fields live at the top level alongsideeventandsession_id. This simplifies both human reading and LLM parsing.Timestamps. Most events include
ts(Unix milliseconds) so consumers can correlate timing without relying on file-write order.Session and run tracking.
session_ididentifies a single Claude conversation.run_ididentifies a loop execution (only present in config-driven runs);run_labelis an optional human label for the run.Markers. Loop directives (
[abort],[exit]) are extracted from phase exit text and encoded inavenor.phase.endfields so LLMs can react without parsing free text.Permission flow.
permission.requestfires when the backend asks the client to decide.permission.responsefires after Avenor resolves the decision, marking the moment the backend is answered.