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 streaming UI (LLM_TEXT chunks, THINKING bubbles, in-flight tool views).

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

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)
    ├── THINKING *              (optional, 0 or more)
    ├── 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. Replay recorders subscribe to the outer pair via Transport.subscribe() (see 4.1) instead of being reached through agent state.

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 the replay log / audit stream; subscribe via Transport.subscribe()

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

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)
data{} empty
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.)
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.

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": {...}}
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.

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)
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), prompt_tokens, completion_tokens, finish_reason, plus status / error_class / error_message / streamed on the error path
Typical useMetrics, cost tracking, debugging model behavior — first_token_ms vs model_latency_ms separates queueing/TTFT from total generation time

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}
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
PLUGIN_HOOK_FIREDA plugin hook ran. data["hook_name"] is one of UserPromptSubmit / SessionStart / SessionEnd / PreToolUse / PostToolUse / PostToolUseFailure / Stop / PreCompact. The hook-name-specific fields differ — for example Stop carries turn_end_reason ∈ {"final_response", "max_iterations", "doom_loop"}, at_max_iter, added_context_count, and suppress_output; PreCompact carries compaction_type ∈ {"microcompact", "full", "minimal_history"} and trigger="auto". Every emit also carries outcome and matched_rule_count (the count of rules selected for dispatch — when zero, no event is emitted). 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