Beidou observes agents by draining each SDK agent's async message iterator
(async for msg in query(...)). The drain loop lives in beidou/sdk_agent.py
and emits Beidou events to the JSONL sink; the orchestrator emits lifecycle
and contract events.
- JSONL:
~/.beidou/events/{task_id}.jsonl(append-only, authoritative). - SQLite:
~/.beidou/stats.db(WAL mode, aggregated rollup cache only).
Consumers derive state by reducing the JSONL stream in order.
Dedup key for turn.usage messages: (caller_id, message_id, ts).
Dedup key for tool pairs: tool_use_id (shared by tool_called and tool_result).
Lifecycle. Emitted before the first SDK message for a spawn.
| Field | Source |
|---|---|
ts |
Wall clock at spawn. |
task_id |
Context. |
team_id |
Context. Nullable. The root agent emits team_id: null because it starts teamless (no synthetic root team exists). Any subsequent team_created event (if any) is the first one a real agent emits when it explicitly calls create_team. Non-root agents always have a non-null team_id. |
agent_id |
Assigned by orchestrator. Stable join key across all events. |
role |
From create_team roles entry. null for the root agent (it is not spawned via create_team). |
name |
Human-readable display name, e.g. frontend-engineer-a3b2. agent_id is the stable join key; name is a convenience label for UIs and logs. |
template |
Skill name (e.g. junior_engineer). |
model_requested |
The model= passed to ClaudeAgentOptions (HINT, see agent-runtime.md section 6). |
Lifecycle. Emitted after the SDK iterator returns and the final ack has been recorded (or after the contract-violation escalation ends the agent).
| Field | Source |
|---|---|
ts |
Wall clock at loop exit. |
agent_id |
Context (emitted as caller_id in the JSONL). |
terminated |
Boolean. true when the agent consumed a terminate sentinel. |
stop_reason |
One of: end_turn (normal), no_result, cancelled (CancelledError), crashed (subprocess crash before re-raise), crash_escalated (crash strikes exhausted). |
exit_reason |
(Legacy) One of: terminate_ack (normal), contract_violation_escalated, orchestrator_shutdown. Present on orchestrator-emitted agent_exited events. |
Emitted when an unhandled exception escapes the SDK iterator. An
agent_completed event with stop_reason="crashed" always follows
(observability invariant: every agent_started has a paired
agent_completed).
| Field | Source |
|---|---|
ts |
Wall clock at exception. |
caller_id |
Agent context. |
exception |
Exception class name (string). |
msg |
Exception message string (synonym: error). |
stderr |
Captured subprocess stderr from the ring buffer (via ClaudeAgentOptions.stderr callback). May be empty string if no stderr was captured. |
stop_reason |
Stop reason if available at the point of failure, otherwise absent. |
Emitted by the orchestrator (_run_agent_with_policy) when a subprocess
crash is caught. One per crash attempt. Followed by either a resume/restart
or an escalation.
| Field | Source |
|---|---|
ts |
Wall clock at crash catch. |
caller_id |
Agent that crashed. |
exception |
Exception class name (string). |
msg |
Exception message string. |
strike_count |
Current crash strike (1-indexed, pre-reset). |
stderr |
Captured subprocess stderr from AgentRecord.last_crash_stderr. |
ts |
Wall clock at crash catch. |
Emitted when the root agent exhausts CRASH_STRIKES and cannot recover.
Analogous to root_contract_escalation.
| Field | Source |
|---|---|
ts |
Wall clock at escalation. |
agent_id |
The root agent. |
crash_strikes |
Total crash strikes exhausted. |
stderr |
Last captured stderr. |
Non-fatal misconfiguration detected during skill loading (e.g. an agent
spawned with an empty allowed_tools list). Does not prevent the spawn but
almost always signals a mis-built spec.
| Field | Source |
|---|---|
ts |
Wall clock at detection. |
agent_id |
Context. |
skill |
Skill name that triggered the warning. |
warning |
Human-readable description string (e.g. empty_allowed_tools). |
Per-turn token accounting. Emitted once per unique
AssistantMessage.message_id seen in the stream.
Deduplication rule (verified during the original SDK-behaviour probe;
the throwaway prototype that produced the finding has since been removed):
the SDK may yield multiple AssistantMessage fragments that share a
message_id (one per block). Count usage once per id. A simple
seen: set[str] in the drain loop suffices.
| Field | Source |
|---|---|
ts |
Wall clock at emission. |
caller_id |
Context. |
message_id |
AssistantMessage.message_id. |
model |
AssistantMessage.model. Authoritative; may differ from model_requested (see agent-runtime.md section 6). |
stop_reason |
AssistantMessage.stop_reason. |
input_tokens |
AssistantMessage.usage.input_tokens. |
output_tokens |
AssistantMessage.usage.output_tokens. |
cache_creation_input_tokens |
AssistantMessage.usage.cache_creation_input_tokens (if present). |
cache_read_input_tokens |
AssistantMessage.usage.cache_read_input_tokens (if present). |
Per-turn USD is NOT available. The SDK does not expose a per-assistant
cost (verified during the original SDK-behaviour probe). USD lands on
run.cost only. Do not synthesize per-turn dollars; it would misrepresent
the cost accounting the SDK actually performs.
Start of a tool span. Emitted by the drain loop when it observes a
ToolUseBlock in the message stream. The drain loop is the sole owner of
this emission — the MCP wrapper does NOT emit tool_called.
| Field | Source |
|---|---|
ts |
Wall clock at ToolUseBlock arrival. |
caller_id |
Context. |
message_id |
Parent AssistantMessage.message_id. |
tool_use_id |
ToolUseBlock.id. Join key for the matching tool_result. |
name |
ToolUseBlock.name (includes mcp__beidou__ prefix for Beidou primitives). |
input |
ToolUseBlock.input (may be redacted by a future layer — not redacted today). |
End of a tool span. Emitted by the drain loop when it observes the
ToolResultBlock matching a previously-seen tool_called.
| Field | Source |
|---|---|
ts |
Wall clock at ToolResultBlock arrival. |
caller_id |
Context. |
tool_use_id |
ToolResultBlock.tool_use_id. Join key — links back to the corresponding tool_called. |
duration_ms |
Orchestrator-measured: monotonic time from ToolUseBlock arrival to ToolResultBlock arrival. |
is_error |
ToolResultBlock.is_error; false when the field is absent. |
error_reason |
Text from ToolResultBlock.content when is_error=true. For Beidou primitives this is the JSON {error, message, details} string produced by beidou/primitives/mcp.py. For SDK builtin tools it is the raw error text. Truncated to 2000 chars with a …(truncated) suffix. null when is_error=false or when the block carried no extractable text. |
Pairing rule: tool_use_id is the join key between tool_called and
tool_result. Every tool_called will be followed by exactly one
tool_result with the same tool_use_id. The drain loop tracks in-flight
tool uses in a pending_tool_uses: dict[str, float] map (keyed by
tool_use_id, value = monotonic arrival time) to compute duration_ms.
Caveat — parallel tool calls: When multiple ToolUseBlocks are dispatched
in a single AssistantMessage, their corresponding ToolResultBlocks arrive
at roughly the same time (the drain loop processes them sequentially but the
results are produced in parallel by the model). All duration_ms values will
be approximately equal, reflecting the wall-clock time until the slowest tool
completes. Individual per-tool timing is not measurable from the drain loop.
Error capture: error_reason is populated by the drain loop in beidou/sdk_agent.py only when is_error=true. The drain loop concatenates text fragments from ToolResultBlock.content (which may be a string or a list of {type,text} blocks); non-text fragments (image blocks, etc.) are skipped. The field is the same on disk in JSONL and in the SQLite event row — no separate persistence path. Survives page refresh because the JSONL is replayed in full on every WebSocket connect (see beidou/web/tail.py).
Terminal, emitted once per agent spawn when the SDK yields a
ResultMessage. Carries the authoritative cost and duration figures.
| Field | Source |
|---|---|
ts |
Wall clock on ResultMessage. |
agent_id |
Context. |
total_cost_usd |
ResultMessage.total_cost_usd. |
duration_ms |
ResultMessage.duration_ms. |
duration_api_ms |
ResultMessage.duration_api_ms. |
num_turns |
ResultMessage.num_turns. |
usage |
ResultMessage.usage (aggregate rollup). |
model_usage |
ResultMessage.model_usage - per-model breakdown. Use to get authoritative per-model dollar cost. |
Reconciliation: the sum of turn.usage token counts should roughly
match run.cost.usage totals. If they diverge, trust run.cost (SDK
ground truth) and investigate the dedup logic.
Emitted by on_ask_user_question PreToolUse hook
(beidou/agent/hooks.py) when a model emits the SDK-builtin
AskUserQuestion tool. The hook denies the call (with a redirect reason
pointing the agent at mcp__beidou__ask_user) and emits this event for
observability. No tool_called/tool_result pair is synthesized — earlier
versions did, which made the redirect indistinguishable from a real
gateway call in the event stream.
| Field | Source |
|---|---|
ts |
Wall clock at emission. |
caller_id |
The agent that emitted the raw AskUserQuestion. |
questions_count |
Length of the questions array (or 0 if missing/malformed). |
Emitted by on_disallowed_alias PreToolUse hook
(beidou/agent/hooks.py) when a forbidden SDK shadow tool reaches the
hook. In normal operation the SDK's disallowed_tools filter drops these
tools before any hook runs, so this event is a backstop signal: if it
fires, the SDK layer leaked. Useful as a regression alarm. Currently
covers SendMessage and TodoWrite (see
docs/tool-surface.md#forbidden-tools).
| Field | Source |
|---|---|
ts |
Wall clock at emission. |
caller_id |
The agent that attempted the forbidden tool. |
tool_name |
"SendMessage" or "TodoWrite". |
Emitted by QuestionBroker when an agent calls ask_user. The prompt
field is truncated to 200 characters; the full record (full questions
array, context, chain) is only available via GET /api/questions/pending.
Treat this event as a ping and re-poll the API for rich data.
| Field | Source |
|---|---|
ts |
Wall clock at emission. |
qid |
Unique question id (e.g. q_abc12345). |
asker |
agent_id of the agent that called ask_user. |
holder |
agent_id of the team-leader currently holding the question, or null if surfaced to the user. |
prompt |
Derived flat-text summary of the question, truncated to 200 chars. |
questions |
The raw questions array (Claude Code wire shape) as passed to the broker. Included alongside prompt for consumers that prefer the structured form. |
Emitted by QuestionBroker after the question future resolves.
| Field | Source |
|---|---|
ts |
Wall clock at answer receipt. |
qid |
Matches the corresponding question_asked event. |
asker |
agent_id of the original asker. |
chain_len |
Length of the audit chain (kept for back-compat). |
answers |
Structured answer payload: `[{selected_labels: list[str], text: str |
answer_text |
Single human-readable rendering used by the web UI to render an answer chat bubble in the asker's stream. |
Delivery-side event. Emitted by the input_stream generator in
beidou/sdk_agent.py immediately before each user-role turn is yielded
to the SDK session. This is the unified "agent received a message" record —
every user-role text an agent ingests passes through one of the two yield
sites in input_stream, so this event covers all four input classes:
- The initial user task (synthetic,
source="initial"). - Peer messages from
send_message(source="queue"). - Web-injected user messages via REST (
source="queue",from="user"). - System notifications via direct
inbox_put(source="queue",from="beidou").
terminate sentinels do not produce an agent_input event — the
input_stream generator returns before yielding them.
Ordering with send_message: send_message (action-side) fires when the
sender calls the primitive. agent_input (delivery-side) fires when the
recipient consumes the queue. These events are distinct in time; the frontend
renders them as "A sent X" then "B received X" with potentially other events
between. This is intentionally truthful.
Behavior note: A send_message queued to a recipient that is terminated
before consumption will produce a send_message event on the sender side but
no agent_input event on the recipient side. The new behavior is more
accurate (the agent did not actually see the message) but differs from the
prior behavior where the recipient bubble appeared regardless.
| Field | Type | Source |
|---|---|---|
ts |
float | Delivery time. For queue yields: msg_in.ts (origin time, not consume time). For initial task: time.time(). |
caller_id |
string | The receiving agent. |
from |
string | Message.from_id: "user" for initial task and web-injected; "beidou" for system notifications; else the sender agent_id. |
message_kind |
string | Message.kind: "user" | "system". Plus synthetic "initial" for the first yield (no underlying Message). |
source |
string | "initial" = first yield from spec.task; "queue" = pulled from the per-agent inbox. Lets the frontend identify the boot bubble without inspecting from/message_kind. |
content |
string | Raw msg_in.content (or spec.task for initial). NOT the [from X] ... wrapper applied at yield time — that is SDK prompt presentation, not wire content. |
message_id |
string | Carried from Message.message_id when present; deterministic synthetic for initial: f"{caller_id}:initial". |
Dedup key for replay/reconnect: (caller_id, message_id).
Action-side event. Emitted by the send_message primitive whenever an agent
(or the user via the web composer) puts a message into another agent's
inbox. User messages have caller_id == "user". No kind field.
The delivery-side counterpart — when the recipient consumes the message
from the queue — is agent_input (see above).
| Field | Source |
|---|---|
ts |
Wall clock at delivery. |
caller_id |
Sender — an agent_id or the literal string "user". |
to |
Recipient agent_id. |
content |
Full message text. |
message_id |
UUID-based id shared with the inbox Message record. |
Emitted every time the SDK query() returns for an agent that has not been
terminated by the runtime. See agent-runtime.md section 5.
| Field | Source |
|---|---|
ts |
Wall clock. |
agent_id |
Context. |
strike_count |
Current consecutive-violation count (1-indexed). |
action |
One of: resumed, escalated_to_leader, escalated_to_user (root). |
Emitted once per AssistantMessage that contains TextBlock content (the
agent's natural-language reasoning for a turn). JSONL-only — no SQLite
rollup.
| Field | Source |
|---|---|
ts |
Wall-clock seconds at emission. |
caller_id |
Context. |
message_id |
AssistantMessage.message_id — ties to the turn.usage event with the same message_id. |
text |
Concatenation of every TextBlock in this AssistantMessage. |
stop_reason |
SDK-reported stop reason for this turn; null if absent. |
Useful for showing per-agent live output in observability tools (e.g. the web UI right pane).
Emitted on every signal_review call.
| Field | Source |
|---|---|
ts |
Wall clock. |
agent_id |
Context. |
state |
One of: working, idle, blocked, done. |
detail |
Free-text from the call. |
Data-only event. Emitted by the orchestrator when an agent calls
signal_review(detail=...). JSONL-only -- no SQLite rollup. Leader agents
observe member status via list_peers.
| Field | Source |
|---|---|
ts |
Wall clock. |
agent_id |
The agent reporting done. |
team_id |
The agent's team. null for the root agent (it is teamless). |
all_done |
true when every member of the team is also in done state; false otherwise. null for the root agent (no team). |
Emitted by the on_signal_review PostToolUse hook after the completion
review has been routed. JSONL-only.
| Field | Source |
|---|---|
agent_id |
The agent that reported done. |
leader_id |
The reviewer. Either the agent's leader id, or USER_SENTINEL for the root. |
via |
Routing path: "hook" (delivered to leader inbox) or "user_gateway" (root review surfaced through the human gateway). |
decision |
Present only when via == "user_gateway". One of "approve" (root will be terminated) or "rework" (a rework message was delivered back to the root's own inbox). |
For non-root agents the leader's resolution (terminate-child vs.
send-message) still emits the existing completion.approved / completion.rework
events (see agent-runtime.md). For the root, those are replaced by the
gateway decision recorded above.
Emitted when the root-agent hook's gateway round-trip fails. JSONL-only.
| Field | Source |
|---|---|
agent_id |
The agent that reported done. |
leader_id |
The reviewer (USER_SENTINEL for root). |
reason |
"gateway_failure: <ExcType>" when the root's gateway round-trip raised an exception. |
This event is only emitted for root agents (where the gateway is the reviewer). For non-root agents, envelope validation is enforced at the primitive level — calls without a valid envelope are rejected before the hook runs.
The legacy reason="root_no_leader" and reason="no summary in signal_review turn" values are retired.
Audited override of the completion-review gate or runtime backstop
cancellation. Always paired with a subsequent agent_completed (or
equivalent) event when the SDK session ends.
Emitted in two situations:
- A leader called
terminate_child(force=true), bypassing the completion-review gate for a member that had not yet acknowledged its terminate sentinel. - The runtime watchdog cancelled an agent's
run_taskbecause the agent did not consume its terminate sentinel withinTERMINATE_GRACE_S(seeagent-runtime.md§3.1).
| Field | Source |
|---|---|
ts |
Unix timestamp at forced termination. |
caller_id |
The actor that triggered the forced terminate. "watchdog" when emitted by the runtime watchdog; the leader's agent_id when emitted by terminate_child(force=true). |
agent_id |
Target agent being forcibly terminated. |
team_id |
The team containing the target. |
reason |
One of: "leader_force" (leader passed force=true to bypass the completion-review gate) or "watchdog_grace" (target did not consume its terminate sentinel within TERMINATE_GRACE_S; runtime cancelled run_task). |
Emitted by the orchestrator when an agent calls declare_plan successfully.
team_id is null because declare_plan is a pure-data primitive — no team
is created at this point.
| Field | Source |
|---|---|
ts |
Wall clock at declaration. |
plan_id |
Stable plan identifier. |
owner_agent_id |
The agent that declared the plan. |
team_id |
Always null — no team exists yet. |
tasks |
List of task descriptors: [{id, role, skill, depends_on, status}, ...]. |
Emitted by the orchestrator when an agent calls remove_plan successfully.
The plan file is renamed to {plan_id}.removed.json on disk for audit.
| Field | Source |
|---|---|
ts |
Wall clock at removal. |
plan_id |
The plan that was removed. |
owner_agent_id |
The agent that removed the plan. |
Emitted when a task transitions from blocked to ready. This happens after
a dependency's terminate_child approval drives that dependency in_flight → done
and all of the newly-ready task's other dependencies are also done.
| Field | Source |
|---|---|
ts |
Wall clock at transition. |
plan_id |
The plan containing the task. |
task_id |
The task that became ready. |
Emitted when spawn_agent transitions a task ready → in_flight and launches
the agent. If this is the first spawn_agent for the plan's leader, team_created
is emitted before this event (producer-side ordering invariant: team_created
always precedes the first agent_spawned for a given team).
| Field | Source |
|---|---|
ts |
Wall clock at spawn. |
plan_id |
The plan containing the task. |
task_id |
The task being executed. |
team_id |
The team the agent was added to (materialized on first spawn). |
agent_id |
The newly-spawned agent. |
Emitted when terminate_child (approve) drives a task in_flight → done.
After emission, the orchestrator recomputes the readiness set and emits
task_ready for any newly-unblocked dependents.
| Field | Source |
|---|---|
ts |
Wall clock at approval. |
plan_id |
The plan containing the task. |
task_id |
The task that completed. |
Emitted when terminate_child(force=true) drives a task in_flight → failed.
Dependents that list the failed task become permanently blocked; recovery requires
remove_plan + declare_plan.
| Field | Source |
|---|---|
ts |
Wall clock at forced termination. |
plan_id |
The plan containing the task. |
task_id |
The task that failed. |
reason |
Reason string from the forced termination (e.g. "leader_force"). |
Note on team_created timing: Prior to the plan-as-data model, team_created
was emitted at create_team time (atomically with member spawns). With the new
spawn_agent flow, team_created is emitted on the first spawn_agent call
(lazy team creation), not at declare_plan. The producer-side ordering invariant
is preserved: team_created fires before the first agent_spawned for that team.
Web frontend reducers that normalise event ordering do not need to change.
- JSONL
~/.beidou/events/{task_id}.jsonl: one JSON object per line, append-only. Authoritative event log. All consumers (web UI,beidou events --follow) read event history by tailing this file. The stream is replay-friendly; state is derived by reducing it. - SQLite
~/.beidou/stats.db: aggregated cache only. Holds three rollup tables —tasks,teams,agents— written by the orchestrator at state transitions (team created, agent started/ended, task ended) and updated incrementally fromturn.usageandrun.cost. There is no per-event table. Do not treat SQLite as a source of truth for raw event data.
The original plan assumed per-agent rollup was acceptable. The prototype
showed per-turn token counts are also available (deduplicated by
message_id). We therefore record both: per-turn tokens (fine-grained, no
USD) plus terminal rollup (coarse, with USD). This is a strict upgrade; no
regression recorded.