Canonical spec of every MCP tool an agent sees. All tools are exposed under
the beidou server, so the agent-visible name is mcp__beidou__<name>.
Every agent sees the same tool list. There is no worker/leader tool-set
split. "Leader" is a relational fact Beidou tracks, not a permission tier.
Permission-style checks (e.g. terminate_child leadership check) are
enforced at call time by the primitive implementation.
All primitives receive an orchestrator-bound caller_id via the per-spawn
MCP closure. caller_id is NEVER read from the model's tool input.
| Tool (mcp__beidou__...) | Kind | Blocking? |
|---|---|---|
send_message |
A2A | No |
list_peers |
Agent-Beidou | No |
ask_user |
Agent-Beidou | Yes (chain-bounded) |
answer_question |
Agent-Beidou | No |
escalate_question |
Agent-Beidou | No |
create_team (deprecated) |
Agent-Beidou | No |
declare_plan |
Agent-Beidou | No |
remove_plan |
Agent-Beidou | No |
spawn_agent |
Agent-Beidou | No |
list_ready |
Agent-Beidou | No |
terminate_child |
Agent-Beidou | No |
list_pending_reviews |
Agent-Beidou | No |
Kind: A2A (agent to agent). The only true A2A primitive.
Input schema
| Field | Type | Required | Notes |
|---|---|---|---|
to |
string (agent_id) | yes | Recipient's agent id (must exist in the same task). |
content |
string | yes | Message body. No length cap in schema (but inbox cap applies per recipient, see limits.md). |
Output schema
{ "delivered": true, "message_id": "<uuid>" }
Error cases
unknown_recipient:todoes not resolve to a live agent in this task.recipient_terminated: recipient hasterminate_consumed=True(the agent record persists for accounting but its inbox is unreachable). The sender's workflow had a bug — typically a leader calledterminate_childon the child before its work was finalized upstream. Escalate to your own leader rather than spawning a replacement.inbox_full: recipient's inbox has reached the cap inlimits.md. Returned as a structured tool error; sender decides how to react.task_mismatch:toresolves but belongs to a different task.
Event semantics — action-side vs delivery-side:
send_message emits a send_message event recording the send action on
the sender's side (when the message enters the recipient's inbox). It does NOT
emit the recipient's delivery event. The delivery-side event — recording that
the recipient's input_stream consumed the message — is agent_input
(see docs/observability.md). The two events share the same message_id,
allowing them to be joined. A send_message queued to a recipient that is
terminated before consumption will produce a send_message event but no
corresponding agent_input event.
Kind: Agent-Beidou. Reads the team graph.
Input schema
| Field | Type | Required | Notes |
|---|---|---|---|
scope |
string enum | no | One of: team (default; direct teammates), children (direct reports of teams the caller leads), all (entire task graph). |
Output schema
{ "peers": [ {"agent_id": "...", "role": "...", "team_id": "...", "status": "...", "is_leader_of": ["<team_id>", ...], "name": "<string | null>"}, ... ] }
name is the human-readable display label for the peer (e.g. frontend-engineer-a3b2). agent_id remains the stable join key; name is for display only.
Error cases: none under normal operation.
Kind: Agent-Beidou. Agent-originated ask_user is routed through the
leader chain: the question first lands in the caller's team leader's
inbox (as a [INBOX QUESTION] system message), where the leader can
either resolve it directly via mcp__beidou__answer_question or push it
one hop further with mcp__beidou__escalate_question. The question only
reaches the human gateway when the chain reaches an agent whose leader is
the user sentinel (or via explicit escalation). System-originated paths
(watchdog, root completion review) bypass the chain and surface to the
gateway directly.
Input schema
| Field | Type | Required | Notes |
|---|---|---|---|
questions |
array of objects | yes | 1..4 sub-questions. Each item described below. |
context |
string | no | Optional shared background, surfaced verbatim if present. |
Each questions[i]
| Field | Type | Required | Notes |
|---|---|---|---|
question |
string | yes | The question text. |
header |
string | yes | <=12 chars. Used as a chip/label in the UI. May be empty. |
multiSelect |
bool | yes | camelCase. False=radio (single-select), True=checkbox (multi-select). |
options |
array of objects | yes | Length 0 (free-text only) or 2..4 (choice). Each option: {label: string, description: string, value?: string, requires_text?: boolean}. value is an optional machine discriminator (defaults to label); the broker mirrors the matched option's value into selected_values on the answer. requires_text=true instructs the frontend to reveal a free-text textarea and gate submission on a non-empty value once that option is selected (the typed text is returned in text). |
Output schema
{
"answers": [
{"selected_labels": ["..."], "selected_values": ["..."], "text": null}, // single-select
{"selected_labels": ["a", "b"], "selected_values": ["a", "b"], "text": null}, // multi-select
{"selected_labels": [], "selected_values": [], "text": "free-text reply"}, // free-text or "Other" path
...
],
"answer_text": "<human-readable rendering joined by newlines>"
}
selected_values is server-derived per-answer from the matched option's
value (label fallback) so callers can match on a stable machine
discriminator instead of user-facing copy.
answer_text rendering rules:
- Free-text only: the typed text.
- Single-select: the chosen
label. If "Other" was chosen, the typed text. - Multi-select: comma-joined chosen labels. If "Other" was selected (only on single-select), append the typed text.
- Grouped: one rendered line per sub-question, prefixed with
<header>:if header is non-empty, joined by newlines.
Error cases
gateway_unavailable: no human gateway registered. Returns structured error; agent decides whether to fall back or block.user_declined: user explicitly refused. Returns the refusal as a structured error.invalid_input: schema violation. The error message names the offending question index and field. Examples: questions list length not in 1..4, header > 12 chars, options length not in {0, 2, 3, 4}, malformed option dict.
ask_user blocks indefinitely until the question is resolved — by the
caller's leader via answer_question, by an upstream leader after one or
more escalate_question hops, or by the user gateway terminal of the
chain. There is no timeout. Internal escalation paths (watchdog,
contract-violation review) wrap their plain-text prompts as a single
free-text question (options: []).
Raw AskUserQuestion is rejected. The SDK-builtin AskUserQuestion
tool is intercepted by the on_ask_user_question PreToolUse hook
(beidou/agent/hooks.py). The hook returns
permissionDecision="deny" with a permissionDecisionReason that points
the agent at mcp__beidou__ask_user. The hook does NOT call the gateway
and does NOT synthesize tool result events — every user-question must
traverse mcp__beidou__ask_user so the leader chain can answer or
escalate locally before the question reaches the user. The hook emits a
single ask_user_question.redirected observability event.
Kind: Agent-Beidou. Used by a leader (the current holder of an
ask_user question routed via the chain) to resolve the question
directly. The asker's ask_user future returns with the supplied
answers; the user gateway is never pinged.
Input schema
| Field | Type | Required | Notes |
|---|---|---|---|
qid |
string | yes | The question id from the [INBOX QUESTION] system message in your inbox. |
answers |
array of objects | yes | One entry per sub-question, in order. Each entry is {selected_labels: list[string], text: string | null}. For free-text or "Other" answers, leave selected_labels empty and put the answer in text. |
reason |
string | yes | Short note explaining why you can answer this directly instead of escalating (audit trail). |
Output schema
{ "ok": true, "qid": "<qid>" }
Error cases
invalid_input:qidempty,answersnot a non-empty list, malformedanswers[i], orreasonempty or not a string.unknown_qid: no pending question with that id.not_holder: caller is not the current holder of the question. Only the agent the question was last routed to may answer.answer_count_mismatch:len(answers) != len(question.questions).
Kind: Agent-Beidou. Used by a leader (the current holder of an
ask_user question) to push the question one hop further up the chain.
The next holder is the caller's own team leader; if the caller is the
root or the next hop is the user sentinel, the question surfaces to the
human gateway.
Fire-and-forget (bubble model). This call returns immediately after
dispatching the question to the next hop. The escalator does NOT await the
answer — the answer is delivered only to the original asker's ask_user
future. The escalator's tool call ends as soon as the forward is sent.
Input schema
| Field | Type | Required | Notes |
|---|---|---|---|
qid |
string | yes | The question id from the [INBOX QUESTION] system message. |
reason |
string | yes | Short note explaining why you can't answer it yourself (audit trail). |
Output schema
{ "ok": true, "qid": "<qid>", "new_holder": "<agent_id or null>" }
new_holder is null when the question reached the user gateway.
Error cases
invalid_input:qidempty orreasonempty.unknown_qid: no pending question with that id.not_holder: caller is not the current holder (chain[-1] != caller).stale: the question is no longer pending (already answered or escalated past in a race).
Kind: Agent-Beidou. Signals the agent's leader that deliverables are ready for review. Triggers the review gate and watchdog escalation on the leader. Can be called multiple times per lifecycle (reentrant).
Input schema
| Field | Type | Required | Notes |
|---|---|---|---|
detail |
string | yes | Must contain [REVIEW REQUIRED] or [ITERATION READY] envelope (case-insensitive substring check). Free-form summary including deliverables, open questions, and leader action options. |
Output schema
{ "recorded": true, "review_pending": true }
Error cases
envelope_missing:detaildoes not contain[REVIEW REQUIRED]or[ITERATION READY](case-insensitive). The call is rejected.reason: "detail_empty"—detailis null or whitespace-only.reason: "envelope_missing"—detailis non-empty but lacks the required marker.
Side effects
- Sets
review_pending=Trueandreview_pending_tson the agent record. If alreadyreview_pending=True, updatesdetailand resets timestamp (the previous pending review is superseded). - Emits a
statusevent withstate="review_pending". - For non-root agents: the
detailis delivered to the leader's inbox as areview_reportmessage. This triggers the leader's review gate (blocks non-review tools until the leader responds). - For the root agent: the
detailis routed through the human gateway. - Triggers watchdog Pass A: if the leader does not respond within the escalation threshold, the watchdog pings and eventually escalates.
- Leader clearing: when the leader sends a
send_messageto the reviewing agent,review_pendingis automatically cleared and areview.acknowledgedevent is emitted. When the leader callsterminate_child, the agent is terminated (review implicitly approved).
Lifecycle semantics
- Does NOT check plan completion (unlike the deprecated
signal_review(detail=...)). An agent may signal review mid-plan to report iteration progress. - Does NOT imply lifecycle end. The agent remains alive after signaling and can continue working after the leader acknowledges.
- Typical patterns:
- One-shot worker:
signal_review(...)→ leader terminates → agent dies. - Iterative leader:
signal_review(...)→ leader ACKs → agent continues next iteration →signal_review(...)again → ... →request_termination().
- One-shot worker:
Kind: Agent-Beidou. Declares that the agent's entire lifecycle work is complete and requests the leader to terminate it. This is the "I'm truly done" signal.
Input schema
| Field | Type | Required | Notes |
|---|---|---|---|
detail |
string | no | Optional summary of completed work. |
Output schema
{ "recorded": true, "termination_requested": true }
Error cases
plan_incomplete: The agent has an active plan with unfinished tasks. All plan tasks must bedoneorfailedbefore requesting termination.
Side effects
- Sets
termination_requested=Trueon the agent record. - Emits a
statusevent withstate="termination_requested". - Triggers watchdog: the leader is notified that a termination request is pending (same escalation ladder as review_pending).
- Does NOT set
review_pending— if the agent wants both a review and termination, callsignal_review(...)first, thenrequest_termination(). - Leader response:
terminate_child(execute) orsend_message(reject; agent continues working, flag cleared).
Lifecycle semantics
- This is typically the last primitive an agent calls.
- Unlike
signal_review, this IS lifecycle-terminal in intent. - The leader may reject the request (via
send_message), in which casetermination_requestedis cleared and the agent should continue working. - An agent that has called
request_termination()should end its turn and wait for the leader's decision. It should NOT call further tools.
Deprecated. Use
declare_plan+spawn_agentfor ordered work; this primitive will be removed once all callers migrate. Self-contained semantics are preserved during the transition.
Kind: Agent-Beidou. Spawns a sub-team. The caller becomes its leader by construction.
Input schema
| Field | Type | Required | Notes |
|---|---|---|---|
name |
string | yes | Human-readable team name. |
task |
string | yes | Recorded on the new TeamRecord for orchestrator-internal coordination. Not delivered as the agent's first user message — that role is reserved for the originating user task (propagated from run_root). Use this field for a team-level coordination charter when useful; otherwise mirror the user task. |
roles |
list[object] | yes | One per member. Each has role (string), skill (skill name, e.g. junior_engineer), model (optional string), description (string). The description becomes the role-specific scope: it is substituted into the member's system prompt as {role_description} and should describe what THIS member must produce. The originating user task always reaches every member as a separate user message; do not paste it into description. |
rules |
list[string] | no | Coordination rules visible to each member. |
consensus |
bool | no | Default false. When true, bypasses the duplicate-description guard (see below). Use only when deliberately spawning N agents to attempt the same thing in parallel for voting or ensemble purposes. |
Output schema
{ "team_id": "<id>", "members": [{"agent_id": "...", "role": "..."}, ...] }
Error cases
depth_exceeded: caller's team depth is already at the max recursion depth.leader_override_attempted: the call included aleader_idfield in the input. (Beidou's validator rejects before spawn.)unknown_skill: aroles[i].skilldoes not resolve to a loadable SKILL.md.concurrent_create_team: caller already has an in-flightcreate_team(serialization lock).duplicate_member_descriptions: raised whenlen(roles) > 1AND every member shares the same(skill, description)pair ANDconsensusisfalse. This is a footgun guard: if all N members have identical descriptions, they will redundantly implement the whole task in parallel. Either write distinct descriptions for each role, or passconsensus=trueif parallel attempts are genuinely intentional.
Validation rules
- Self-lead invariant: Beidou sets
leader_id = caller_idfor the new team. Anyleader_idin the tool input is rejected withleader_override_attempted. - Recursion depth cap: see
limits.md(#2). No concurrent-member fan-out cap (limits.md #1 removed). - One in-flight
create_teamper caller at a time (limits.md).
Kind: Agent-Beidou. Pure data primitive — validates a task DAG, computes
initial readiness, persists the plan to disk, and returns rich introspection.
No team, workspace, or agent is created — pure data primitive. The only
side effect on registry state is setting the caller's active_plan_id to the
newly persisted plan.
Input schema
| Field | Type | Required | Notes |
|---|---|---|---|
tasks |
list[TaskSpec] | yes | One entry per node in the DAG. See below. |
Each tasks[i] (TaskSpec)
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string | yes | Caller-supplied task id, unique within this plan. Join key for spawn_agent and dependency refs. |
role |
string | yes | Display name for the agent that will run this task. |
skill |
string | yes | Skill name. Existence check is performed at declare_plan time (so a typo is caught early), but the skill is not loaded here — load happens on spawn_agent. |
task |
string | yes | First user message the spawned agent will receive. Must be self-contained; the spawned agent will not see the originating user request. |
description |
string | no | Substituted into {role_description}. Defaults to "". |
model |
string | no | Per-task model override. |
depends_on |
list[string] | no | Task ids in this same plan that must reach done before this task is spawnable. Defaults to []. |
Output schema
{
"plan_id": "<id>",
"plan_path": "<absolute path to the persisted plan file>",
"tasks": [
{"id": "t1", "role": "...", "skill": "...", "depends_on": [], "status": "ready"},
{"id": "t2", "role": "...", "skill": "...", "depends_on": ["t1"], "status": "blocked"},
...
]
}
Readiness is derivable from tasks[*].status and depends_on. Use list_ready
to query the current ready set after approvals shift it.
Error cases
Validation runs before the plan is persisted. On error, nothing is written and
active_plan_id is unchanged.
cycle_detected— returns the offending id chain.unknown_dep— adepends_onentry does not match any declared id.duplicate_task_id— two tasks share anid.self_dep— a task lists itself independs_on.empty_plan—tasksis empty.unknown_skill—skilldoes not resolve to a loadable SKILL.md (existence check; not full load).plan_already_declared— caller already has an active plan. Useremove_planfirst if a replan is intended.
Validation rules
- Existence check on each
skillfield is performed at declare time; full load is deferred tospawn_agent. - The plan takes a separate per-agent
plan_lock(not the spawn lock), sodeclare_planandremove_plannever blockspawn_agentcalls. - Atomicity: the plan file is written first, then
active_plan_idis updated in memory. If the file write fails, no in-memory update occurs.
Kind: Agent-Beidou. Removes the caller's active plan so a fresh one can be declared. Used when the user changes their mind mid-flight. Does not touch the team or workspace — the team is a runtime container; plans come and go on top of it.
Input schema
No input fields.
Output schema
{ "removed_plan_id": "<id>" }
Error cases
no_active_plan— caller has no active plan.plan_in_use— at least one task in the active plan isin_flight. Returns the list ofin_flighttask ids and theiragent_ids. The leader must approve or force-terminate those agents before replanning. Done/failed tasks do not block removal.
Validation rules
remove_planis allowed when the plan has noin_flighttasks (any combination ofready/blocked/done/failedis fine).- The plan file is renamed to
{plan_id}.removed.jsonon disk; an audit event preserves the history. Agents already approved under the old plan stay in the team's history but are no longer tracked in any plan.
Kind: Agent-Beidou. Execution primitive. Spawns a single agent for the
given task_id from the caller's active plan. On the first call by this
leader (when the leader has no team yet), this primitive lazily creates the
team: allocates a team_id, provisions the workspace, and registers the
TeamRecord with the caller as leader (self-lead invariant). Subsequent calls
append members to the same team.
Input schema
| Field | Type | Required | Notes |
|---|---|---|---|
task_id |
string | yes | Id of a task in the caller's active plan. Must be ready (not blocked, in_flight, done, or failed). |
Output schema
{
"agent_id": "...",
"team_id": "...",
"task_id": "...",
"remaining_ready": ["<task_id>", ...],
"team_created": true | false
}
team_created is true only on the first spawn_agent call by this leader
(when the team was materialized). remaining_ready lists task ids that are
currently ready to spawn (excluding the just-spawned task).
Error cases
no_active_plan— caller has no active plan.unknown_task—task_idis not in the active plan.task_not_ready— task isblocked; returnsunmet_deps: [...].task_not_pending— task isin_flight,done, orfailed. Returns current status.depth_exceeded— checked here (not atdeclare_plantime; declare is a pure-data primitive that does not know about the runtime team graph). If the caller's team-depth-to-be exceeds the cap, the firstspawn_agenterrors out; the plan stays declared but unspawnable. The leader canremove_planand rethink at a shallower level.unknown_skill— full skill load failed (the existence check atdeclare_planwas satisfied but loading the SKILL.md content errored).concurrent_spawn— caller already holds the per-agent spawn lock (one in-flightspawn_agentper agent at a time).
Validation rules
- Self-lead invariant:
leader_id = caller_idis injected by Beidou; it cannot be supplied or overridden in the tool input. - There is no concurrent-member cap on a team (limits.md #1 removed). Leaders may spawn any number of members in sequence; recursion depth (#2) and the per-agent spawn lock (#5) still bound the spawn graph.
team_createdandagent_spawnedevents are emitted in order:team_createdfires synchronously before the firstagent_spawnedfor that team.
Task assignment injection. When plan_task_id is set on the spawned agent,
the runtime prepends a [TASK ASSIGNMENT]...[/TASK ASSIGNMENT] block to the
agent's first user message (before the task text from declare_plan). The
block contains plan_task_id and artifacts_path
({project_workspace_path}/artifacts/{plan_task_id}). This is injected in the
user message — not the system prompt — to preserve KV cache on the skill body.
Kind: Agent-Beidou. Read-only query. Returns the set of task ids in the
caller's active plan that are currently in ready state (dependencies all
done). Useful after a few terminate_child approvals shift the readiness set.
Input schema
No input fields.
Output schema
{ "ready": ["<task_id>", ...] }
Error cases
no_active_plan— caller has no active plan.
Validation rules
- Read-only: no state is mutated. Safe to call as many times as needed.
Kind: Agent-Beidou. Posts a terminate sentinel to the target agent's inbox.
Input schema
| Field | Type | Required | Notes |
|---|---|---|---|
agent_id |
string | yes | Target agent. Must be a member of a team the caller leads. |
force |
bool | no | Default false. When true, bypass the completion-review gate. Audited via a terminate.forced event with reason="leader_force". |
Output schema
{ "sentinel_posted": true }
Error cases
not_leader: caller does not lead the team that containsagent_id.unknown_agent:agent_idnot resolvable.already_terminating: a terminate sentinel is already in the target's inbox (idempotent; structured error indicating no-op).child_not_pending_review:target.review_pending == falseANDtarget.terminate_consumed == falseANDforce != true. The leader must wait for the child to callsignal_review(detail=...), send a rework message viasend_message, or passforce=trueto override.
Validation rules
terminate_childis the leader's APPROVE verdict on a child'ssignal_review(detail=...)request. The plain (force=false) call requires the child to be in completion-review state.force=trueis the explicit override. It emits aterminate.forcedaudit event withreason="leader_force". Use sparingly.terminate_childis only valid if the caller leads the parent team of the target. Crossing team boundaries is not allowed, even for ancestor leaders — termination is always leader -> direct-child-team-member.- Beidou does NOT itself call this tool. Beidou's ONE termination
privilege applies only to the root agent, via an internal path
(see
orchestration.md).
Kind: Agent-Beidou. Read-only registry walk; no mutations.
Input schema
No input fields.
Output schema
[
{
"agent_id": "<string>",
"role": "<skill_name string>",
"review_pending_ts": <float | null>,
"age_s": <float | null>,
"summary": "<last_status_detail string>",
"name": "<string | null>"
},
...
]
Returns the list of the caller's direct child agents (members of any team the
caller leads) that currently have review_pending=True — meaning they have
called signal_review(detail=...) and the caller has not yet responded with
terminate_child or a rework send_message.
age_s is now - review_pending_ts (positive float) or null if the
timestamp is absent. Results are sorted ascending by review_pending_ts
(oldest pending review first); entries with no timestamp sort last.
Error cases: none. Returns [] when there are no pending reviews.
Validation rules
- Read-only: no state is mutated. Safe to call as many times as needed.
- "Direct children" means members of teams the caller directly leads (one hop). Grandchildren and deeper descendants are not included.
- The caller itself is excluded from results even if it somehow appears as a member of one of its own teams.
Two SDK shadow tools are blocked at the harness layer because they compete with Beidou primitives:
| Forbidden tool | Use this primitive instead | Why blocked |
|---|---|---|
SendMessage |
mcp__beidou__send_message |
SDK team-mode messaging shadow that bypasses Beidou's inbox / reply-gate. |
TodoWrite |
mcp__beidou__declare_plan + mcp__beidou__update_plan_task for tracking; mcp__beidou__signal_review(detail=...) for completion handoff |
TodoWrite competes with both task tracking and the completion signal. Using it has caused agents to claim "done" via TodoWrite while skipping the signal_review review (tsk_658f44b6). |
Raw AskUserQuestion is also blocked — see ask_user for
the leader-chain routing rationale.
Two layers of enforcement (defense in depth):
- SDK
disallowed_tools(beidou/agent/loop.py—disallowed_tools=["SendMessage", "TodoWrite"]). The SDK normally filters these tools out of the model's tool list, so the model never sees them and they never reach a hook. This is the primary barrier. - PreToolUse
on_disallowed_aliashook (beidou/agent/hooks.py). If layer 1 ever leaks (SDK regression, env config drift, future SDK shadow tool that escapes the disallowed list), this hook converts the would-be silent drop into a model-visiblepermissionDecision="deny"whosepermissionDecisionReasonnames the canonical primitive and the spec section. Each deny emits adisallowed_alias.deniedevent for observability.
Forbidding more shadow tools: append the alias to both
disallowed_tools in loop.py AND _DISALLOWED_ALIAS_REASONS in
hooks.py (with a redirect reason), and add a row to the table above.