Skip to content

4.2 AgentEvent Reference ​

What you'll learn

  • When to use AgentEvent (UI / debug / replay) vs. HostEvent (stable host contract)
  • The complete catalog of event types, triggers, and data payloads
  • How to safely serialize events for SSE / WebSocket transport

The agent pushes structured events through transport.emit(event). This section is the complete event catalog — triggers, data payloads, typical use.

Building a production audit pipeline? Use HostEvent instead.

The events on this page are the internal transport events — they drive the CLI, replay, and debug tooling, and their fields/enum values may change between releases. They're the right pick for a streaming UI that needs rich detail (THINKING bubbles, in-flight tool views). For the assistant's text alone, agent.astream() is the stable path (4.7).

For production audit / observability / SIEM pipelines, use the stable host contract in 4.7 Embedded Harness Contract instead. Quick comparison:

SurfaceWhereStabilityWhen to use
agentao.transport.AgentEvent (this page)Transport.emit() push callbackInternal — may change per releaseCLI / streaming UI that needs rich detail
agentao.host.HostEvent (4.7)agent.events() async pull iteratorStable, schema-snapshotted, CI-enforcedProduction audit, billing, multi-tenant compliance
agentao.host.TextDelta / TurnOutcome (4.7)agent.astream() async generatorStable (not HostEvent members)Streaming the assistant's text to a UI

The two surfaces are complementary, not alternatives — most production deployments use both: Transport for UI, events() for audit. They share zero code paths.

AgentEvent data structure ​

python
@dataclass
class AgentEvent:
    type: EventType              # enum
    data: Dict[str, Any] = ...   # must be JSON-serializable

The JSON-serializable constraint means every data payload can ship over SSE / WebSocket / JSON-RPC with no extra marshaling.

Event groups ​

TURN_BEGIN -> (user message arrives — turn begins; carries the user text)
└── TURN_START -> (LLM call starts; resets streaming UI)
    ├── LLM_CALL_STARTED        (metadata before the provider call)
    ├── LLM_RETRY *             (optional: a failed call is about to be retried)
    ├── THINKING *              (optional: reasoning pieces, as they stream)
    ├── LLM_TEXT *              (visible streaming chunks)
    ├── LLM_CALL_DELTA          (new messages since previous call)
    ├── LLM_CALL_COMPLETED      (usage + finish reason)
    ├── TOOL_START              (tool begins)
    │   ├── TOOL_CONFIRMATION   (optional, mirrors confirm prompt)
    │   ├── TOOL_OUTPUT *       (streaming chunks)
    │   ├── TOOL_COMPLETE       (status + duration)
    │   └── TOOL_RESULT         (final content/hash/disk metadata)
    ├── AGENT_START / AGENT_END (sub-agent lifecycle)
    ├── ERROR                   (optional, on errors)
    └── replay-only observability events
TURN_END   -> (turn ends; carries final assistant text + status/error)

TURN_BEGIN / TURN_END fire once per user-driven turn; TURN_START fires once per LLM iteration inside that turn. The replay recorder is wired by splicing a ReplayAdapter over agent.transport that translates the outer pair into recorder turn writes (ReplayAdapter._mirror) — not through a subscription. Transport.subscribe() (see 4.1) is the path for other side-channel observers, and the adapter forwards it to the inner transport so subscribing keeps working while replay is on.

Most UIs only need LLM_TEXT, THINKING, TOOL_START, TOOL_OUTPUT, TOOL_COMPLETE, TOOL_CONFIRMATION, AGENT_START, AGENT_END, and ERROR. The rest are primarily for session replay, audit, metrics, and debugging.

Per-event details ​

TURN_BEGIN ​

FieldDescription
TriggerOnce at the start of each user-driven turn, before any LLM iteration
data{"user_message": "..."}
Typical useOpen a new turn frame in an audit / observer stream via Transport.subscribe(). (The replay recorder is wired separately, through the ReplayAdapter splice — not this subscription.)

Distinct from TURN_START (which fires per LLM iteration). TURN_BEGIN carries the user input and pairs 1-to-1 with TURN_END.

TURN_END ​

FieldDescription
TriggerOnce at the end of each user-driven turn, after the final assistant reply (or on error / cancellation)
data{"final_text": "...", "status": "ok"|"error"|"cancelled", "error": None, "tool_count": 3, "incomplete_reason": None, "finish_reason_missing": False}
Typical useClose the turn frame; flush per-turn metrics — tool_count is the number of tool calls the LLM made across all iterations of the turn, so a host can size a turn without replaying every TOOL_START

incomplete_reason is None on an ordinary turn. It is set when the turn ended without a complete, model-authored answer, and says why:

ValueMeaning
no_outputThe model emitted nothing.
reasoning_onlyThe model emitted reasoning but no answer text.
length_truncatedOutput was cut off at the token limit — either a tool call whose arguments were truncated (the harness then halts the turn) or a final answer cut off mid-sentence.
doom_loopThe model repeated the same call until the detector halted the turn.
llm_errorThe LLM call itself failed (provider 5xx / rate limit / auth) after retries; final_text holds the harness's [LLM API error: …] notice, not a model answer.

This is a single closed vocabulary: every turn-ending path commits exactly one of these, or None for a real answer — there is no unclassified ending. The first two mean the turn ended normally with no answer; the middle two mean the harness stopped a turn that was not converging; the last means the provider call never yielded a turn. (max_iterations is a sixth way a turn ends without a complete answer, but it is a deliberately separate axis — a sticky transport flag with its own exit code 4 and its own on_max_iterations interaction, not a value here.)

Prefer it over string-matching final_text — the harness substitutes a placeholder or a canned notice there (including the [LLM API error: …] notice, now classified llm_error), and a Stop hook may decorate it, so the text is not a reliable signal. A cancelled turn is never reported via incomplete_reason (it carries status: "cancelled"). agentao run maps a non-None value to a non-zero exit; other hosts are free to retry, prompt, or ignore.

finish_reason_missing is a separate axis, not a member of that vocabulary. It is True when at least one LLM call in the turn ended without the provider reporting why generation stopped — a gateway closing the SSE body after its own upstream timeout, or a partially-compatible server that never emits the field — so agentao's "stop" fallback, rather than the provider, is what says the answer is complete. It does not affect incomplete_reason, is_answer, or the agentao run exit code: the servers that omit the field omit it on every call, so gating on it would fail every turn against them. A host that wants the strict reading checks the key itself. Suppressed on a cancelled turn (the cancellation already explains the absence), reported on an errored one.

Replay recorders pair this with TURN_BEGIN to delimit a turn. Drives the runtime → replay handoff that used to be a direct call into the replay adapter.

TURN_START ​

FieldDescription
TriggerBefore each LLM iteration inside a turn (a single turn can fire many) — and once after a rejected confirmation, to reset the display
data{} empty; {"display_reset": true} for the post-rejection reset, which starts no LLM iteration — don't count it as one
Typical useReset UI display, set spinner to "Thinking…"
python
if event.type == EventType.TURN_START:
    ui.spinner.text = "Thinking..."
    ui.reset_streaming_buffer()

THINKING ​

FieldDescription
TriggerLLM emits reasoning/thought content (o1, Claude thinking, etc.), on a tool-call turn and on a final answer alike. Reasoning streams: each event is one piece, already sanitized, in the order the provider sent it, so a call's reasoning arrives before the text it precedes. (A host llm_client= that never calls on_reasoning_chunk gets the whole reasoning once, after the call, so after its text.) Append the pieces; within one successful attempt they join to the reasoning stored in history (one rare exception: with interleaved thinking, a flag emoji split by a text block loses its tag characters in the pieces). Pieces are never retracted: after a retry (LLM_RETRY, which a UI may treat as a reset; a parameter fix-up or the non-streaming fallback sends one too, with delay_s 0, once reasoning has been shown) the next attempt's reasoning follows the failed one's. Text is never sent as THINKING: a tool-call turn's text goes out only as LLM_TEXT
data{"text": "Let me think..."}
Typical useRender into a collapsible "thinking" panel
python
if event.type == EventType.THINKING:
    ui.thinking_panel.append(event.data["text"])

LLM_TEXT ​

FieldDescription
TriggerLLM streams a chunk of the visible reply
data{"chunk": "Sure, I can help"}
Typical useAppend each chunk to the visible reply area
python
if event.type == EventType.LLM_TEXT:
    ui.response_area.append(event.data["chunk"])

⚠️ A chunk can be a few letters, half a word, or an entire paragraph — only ordering is guaranteed, not granularity.

For the same text through the stable contract, use agent.astream()'s TextDelta items (4.7).

TOOL_START ​

FieldDescription
TriggerAbout to execute a tool
data{"tool": "run_shell_command", "args": {...}, "call_id": "uuid"}
Typical useInsert a "Running X..." card; remember call_id to correlate

call_id is the unique key for this invocation. Later TOOL_OUTPUT, TOOL_COMPLETE, and TOOL_RESULT carry the same id, so you can route streamed output to the right card.

TOOL_CONFIRMATION ​

FieldDescription
TriggerJust before confirm_tool() is called
data{"tool": "run_shell_command", "args": {...}, "call_id": "uuid"} — the same call_id the call's TOOL_START / TOOL_COMPLETE carry
Typical useOptional mirror event — lets read-only observers see "a prompt is coming"

You usually don't handle this — real confirmation flows through confirm_tool(). TOOL_CONFIRMATION is mostly for audit/log stream completeness — and for correlation: the ACP transport opens the call's tool_call from it, so the permission request names a call the client has already seen.

TOOL_OUTPUT ​

FieldDescription
TriggerTool emits streaming output mid-execution
data{"tool": "...", "chunk": "...", "call_id": "uuid"}
Typical useAppend chunk to the matching tool card

Streaming tools include run_shell_command (stdout/stderr live), long web_fetch, custom "paginated fetch" tools.

TOOL_COMPLETE ​

FieldDescription
TriggerTool finishes (success/error/cancelled). A call that Ctrl+C interrupts while the tool runs still gets one: it ends cancelled before the interrupt reaches the turn
data{"tool": "...", "call_id": "uuid", "status": "ok"|"error"|"cancelled", "duration_ms": 123, "error": None}
Typical useClose spinner, color by status, record timing
python
if event.type == EventType.TOOL_COMPLETE:
    d = event.data
    ui.close_tool_card(d["call_id"],
                       status=d["status"],
                       duration=d["duration_ms"])

TOOL_RESULT ​

FieldDescription
TriggerAfter a tool result is available
data{"tool": "...", "call_id": "uuid", "content": "...", "content_hash": "sha256:...", "original_chars": 123, "saved_to_disk": false, "disk_path": null, "status": "ok"|"error"|"cancelled", "duration_ms": 123, "error": None}
Typical usePersist or inspect final tool output without relying on streamed chunks

For normal UI spinners, prefer TOOL_COMPLETE. Use TOOL_RESULT for replay, audit, result hashing, and large-output workflows.

LLM_CALL_STARTED / LLM_CALL_COMPLETED ​

FieldDescription
TriggerAround each provider call
dataProvider-call metadata before the call; usage / finish metadata after the call. LLM_CALL_COMPLETED carries duration_ms, model_latency_ms (a stable intent-named alias of duration_ms), first_token_ms (time-to-first-token in ms, or null when the call streamed no text — e.g. a tool-only response or a failure before the first delta), first_reasoning_ms (the same wait for the first streamed reasoning piece, or null when no reasoning streamed, including a host llm_client= that hands it over only whole, after the call. On the non-streaming paths (the Gemini bypass, an endpoint that does not stream) the whole reasoning reaches the host when the call ends, so it is close to duration_ms. Like first_token_ms, it is measured from the start of the call and not reset on a retry, so after a stream that dropped mid-reasoning it times the failed attempt's first piece. A thinking model sends its reasoning first, so there first_token_ms is the wait for the answer and first_reasoning_ms the wait for the first output), prompt_tokens, completion_tokens, finish_reason, plus status / error_class / error_message / streamed on the error path (streamed says whether answer text reached the host; reasoning pieces may have streamed even when it is false). Since 0.5.1 also cache_read_tokens / cache_creation_tokens: the parts of prompt_tokens the provider bills at its cache rates (cache_read_input_tokens / cache_creation_input_tokens on the Anthropic wire; prompt_tokens_details.cached_tokens / .cache_write_tokens on Chat Completions, and the same two under input_tokens_details on openai-responses — the write count only where the SDK states it, openai 3.x on). Since 0.5.15 also reasoning_tokens: the part of completion_tokens spent thinking (completion_tokens_details.reasoning_tokens on Chat Completions, output_tokens_details.reasoning_tokens on openai-responses, always 0 on anthropic-messages, whose output_tokens includes thinking). None when the response carried no usage, 0 when the provider did not state one
Typical useMetrics, cost tracking, debugging model behavior — first_token_ms (or, on a thinking model, first_reasoning_ms) vs model_latency_ms separates queueing/TTFT from total generation time

LLM_RETRY ​

FieldDescription
TriggerA provider call failed with a retryable error and the client is about to wait before trying again — emitted before the backoff sleep. Only while no text has been shown: a call that already streamed text is never retried, so this never interleaves with LLM_TEXT. Reasoning may already have streamed; since #548 a parameter fix-up or the non-streaming fallback that re-sends a request after reasoning was shown emits one too, with delay_s 0 and reason parameter_repair / streaming_unsupported
data{"retry": 1, "max_retries": 4, "delay_s": 1.62, "reason": "status=503"} — retry counts from 1 (the first request is not a retry); reason is status=<code> or the exception class name (RemoteProtocolError, ReadTimeout, StreamEndedEarlyError, …), never the provider's message
Typical useTell the user why the screen went still — the CLI prints ⟳ Reconnecting… 1/4. Since 0.5.4

Retries are bounded by count (5 attempts, so 4 retries), not by a wall-clock budget. Without Retry-After the waits double from 7.5 s — 7.5, 15, 30, 60 s, each plus up to 30% jitter, never past 60 s; a Retry-After is honoured up to the same 60 s ceiling. What is retried: 408/409/425/429/5xx/529 (a 429 that means out of quota is not), a connection or timeout failure before the response, and — since 0.5.4 — a connection dropped while the body is read, or, on anthropic-messages / openai-responses, a stream that ends before its terminal event.

LLM_CALL_DELTA ​

FieldDescription
TriggerAfter an LLM call adds messages to history
dataMessages newly added since the previous call
Typical useSession replay with compact per-call history

LLM_CALL_IO ​

FieldDescription
TriggerOnly when deep capture is enabled
dataFull prompt/tool payloads for the LLM call
Typical useOffline debugging; treat as sensitive content

ERROR ​

FieldDescription
TriggerRuntime caught an exception (LLM, network, MCP disconnect…)
data{"message": "...", "detail": "..."}
Typical useShow toast, log — does not end the session; the agent decides
python
if event.type == EventType.ERROR:
    logger.error(event.data["message"], extra=event.data)
    ui.toast(event.data["message"])

AGENT_START ​

FieldDescription
TriggerAgent spawns a sub-agent (e.g. codebase-investigator, Explore)
data{"agent": "codebase-investigator", "task": "...", "max_turns": 15}
Typical useOpen a "sub-task" collapsible in the UI

AGENT_END ​

FieldDescription
TriggerSub-agent finishes
data{"agent": "...", "state": "completed"|"...", "turns": 3, "tool_calls": 5, "tokens": 1200, "duration_ms": 8000, "error": None}. A counter that is not known is left out: a sub-agent that raised before it ran sends none of turns / tool_calls / tokens / duration_ms, so read them with .get()
Typical useCollapse sub-task, show summary (3 turns / 5 tool calls / 8s)

Replay observability events ​

These events are emitted for session replay and operational audit. Most interactive UIs can ignore them.

EventTypical payload / use
ASK_USER_REQUESTED / ASK_USER_ANSWEREDRecords ask_user() prompts and answers
BACKGROUND_NOTIFICATION_INJECTEDBackground notification was injected into the turn
CONTEXT_COMPRESSEDContext compression occurred
SESSION_SUMMARY_WRITTENSession summary persisted
SKILL_ACTIVATED / SKILL_DEACTIVATEDSkill lifecycle
MEMORY_WRITE / MEMORY_DELETE / MEMORY_CLEAREDMemory mutations
MODEL_CHANGEDRuntime model switched
PERMISSION_MODE_CHANGED / READONLY_MODE_CHANGEDRuntime safety mode changed. The first carries previous / current (mode strings) and cause — "cli", "cli-allow-all", "cli-plan-implement", "cli-resume", "acp", "run" or "host", naming the entry path that made the switch — and fires only when the preset actually moved. The second carries previous / current as booleans: it reports ToolRunner.readonly_mode, one of read-only's two switches, not the effective posture. Both are emitted by agent.set_permission_mode(...); a bare permission_engine.set_mode(...) emits neither, and neither does the starting mode set by Agentao(permission_mode=...) / build_from_environment(permission_mode=...) — a starting state is not a switch. READONLY_MODE_CHANGED also fires on its own from a direct ToolRunner.set_readonly_mode(...) — which is how a sub-agent inherits the flag.
PLUGIN_HOOK_FIREDA plugin hook ran. data["hook_name"] is one of UserPromptSubmit / PreToolUse / PostToolUse* / Stop / PreCompact / SessionStart. Two of those read oddly and both are deliberate: PostToolUse* is one name for two events, because a batch mixes PostToolUse and PostToolUseFailure results and the notices are aggregated across it, so branching on either name alone never matches; and SessionStart appears here only after a full compaction (source="compact") — a SessionStart / SessionEnd at a real session boundary emits nothing on this event and returns its result to the surface that dispatched it instead. The hook-name-specific fields differ — for example Stop carries turn_end_reason ∈ {"final_response", "max_iterations", "doom_loop", "hook_stop"}, at_max_iter, added_context_count, and suppress_output; PreCompact carries compaction_type ∈ {"microcompact", "full", "minimal_history"} and trigger="auto". user_notices is the field a host renders: a list of strings a hook addressed to the user rather than the model — systemMessage, exit-2 stderr, the one-time profile field diagnostics — and for every event listed here it is the only route they have. Every emit also carries outcome and a rule count, in two different fields: matched_rule_count (PreToolUse / Stop / PreCompact — rules selected for dispatch, and those three emit nothing when it is zero) or rule_count (UserPromptSubmit / PostToolUse* / the compaction SessionStart — rules configured, since none of the three has a matched count in hand where it emits). For Stop, outcome ∈ {"allow", "block", "continue", "continue_at_max_iter", "reentry_capped"} reflects the chat-loop verdict at that exit site (continue vs continue_at_max_iter disambiguates which site honored a force_continue decision; reentry_capped means the loop refused a further re-entry). For PreCompact, outcome is always "allow" (observe-only). For the rule-author guide, see §5.7 Plugin Hooks.

Enum as string ​

EventType subclasses str, so:

python
>>> EventType.LLM_TEXT
<EventType.LLM_TEXT: 'llm_text'>
>>> str(EventType.LLM_TEXT)
'llm_text'
>>> EventType.LLM_TEXT == "llm_text"
True

Drop event.type into an SSE/WebSocket field directly:

python
json.dumps({"type": event.type, "data": event.data})
# → {"type": "llm_text", "data": {"chunk": "..."}}

Event-filter boilerplate ​

In embedded contexts you usually care only about a handful of event types:

python
TEXT_EVENTS = {EventType.LLM_TEXT, EventType.TOOL_OUTPUT}
CONTROL_EVENTS = {EventType.TOOL_START, EventType.TOOL_COMPLETE,
                  EventType.AGENT_START, EventType.AGENT_END}

def on_event(event):
    if event.type in TEXT_EVENTS:
        stream_to_ui(event.data.get("chunk", ""))
    elif event.type in CONTROL_EVENTS:
        update_structural_ui(event)
    elif event.type == EventType.ERROR:
        log_and_toast(event)
    # ignore the rest

Event → JSON helper ​

For SSE / WebSocket / message queues:

python
def event_to_json(event: AgentEvent) -> str:
    return json.dumps({
        "type": event.type.value,   # "llm_text" / "tool_start" / ...
        "data": event.data,
        "ts": time.time(),
    })

Reverse (rebuild from JSON, useful for tests):

python
from agentao.transport import AgentEvent, EventType

def event_from_json(j: str) -> AgentEvent:
    obj = json.loads(j)
    return AgentEvent(type=EventType(obj["type"]), data=obj["data"])

TL;DR ​

  • AgentEvent is internal — fields and EventType values may change between releases. For a stable host surface (audit / observability), use HostEvent — see 4.7 Embedded Harness Contract.
  • The most common types you'll handle: LLM_TEXT (streaming chunks), TOOL_START / TOOL_COMPLETE, THINKING, ERROR.
  • Treat unknown event types defensively — new ones are added across releases. Always have a default branch.
  • Serialize via event.type.value + event.data (already JSON-safe) — don't pickle.

→ Next: 4.3 SdkTransport Bridging