GoClaw lets a parent agent delegate a scoped task to a child agent ("subagent")
that runs asynchronously in its own isolated session. Every run is persisted to an
embedded SQLite jobs table, so delegation survives process restarts, is auditable, and
never leaks goroutines.
This document explains what a subagent is, how you trigger one, the guardrails that bound it, and the runtime machinery (job queue, EventBus, workers) that executes it.
Three terms, used the same way in the Web UI, the tools and this document:
agents/<name>.agent.md, made on the Agents page or with the
subagents tool. It runs inside its agent, under that agent's permissions.Requester โ Target (Type), for example
Support โ code-reviewer (Subagent) or Support โ Finance (Agent).A run answers a single, well-scoped task. The child runs in a fresh session
(agent:<parent>:subagent:<id>), streams its answer, and reports back to the parent โ all
without blocking the parent's own turn.
Use a subagent when the task is a distinct sub-problem that benefits from a different agent's persona, model, or toolset (e.g. a general assistant delegating a code change to a coding agent), or when you want fan-out/parallel work. Do not use one for a trivial single-tool call the parent can do itself โ a subagent spins up a full agent turn (LLM + tools) and has real cost.
Key properties:
task string it is handed.wait (the default) the tool waits up to 60 seconds and returns the
answer; a run that takes longer, or one started with wait: false, returns a run_id. The
parent polls, yields, or reads the result the next time it is invoked (see
ยง6). The run goes on after the turn that
started it ends, until it finishes, is stopped, or reaches its one-hour limit (see
ยง10).GoClawRole, UnifiedUserID, Channel) is
forwarded into the child, so memory scoping and role-gated writes behave correctly inside
the subagent. A subagent is not a privilege-escalation path.There are two surfaces, both backed by the same SpawnSubagent entry point. The sessions_spawn and sessions_send tools start a run and send it a follow-up through the same functions (SpawnSubagent, SendSubagentMessage).
subagents tool (LLM-facing)A registered agent tool named subagents (internal/agent/tool_subagents.go) keeps
registration (which subagents an agent has) apart from execution (running tasks).
Registering never runs anything, and running never changes a registration.
| Action | Parameters | Effect |
|---|---|---|
create |
name, description, instructions, tools, model, agent_id, draft |
Register a subagent. Admins only. Refuses a name that already exists; draft: true shows it without saving. |
update |
name, any of the above |
Change a subagent. Admins only. |
delete |
name, agent_id |
Remove a subagent. Admins only. |
list |
agent_id |
List an agent's subagents. |
inspect |
name, agent_id |
Show one subagent. Its instructions are shown to admins only. |
run |
agent_id, task, target_type, wait |
Start a run (spawn still works). agent_id may be omitted โ auto-routing to an agent (below). |
status |
run_id |
Report one run's state, timing, and error. |
list_runs |
โ | List this agent's active and recent runs. |
follow_up |
run_id, message |
Send a further message to a run's session. |
yield |
run_id |
Return the run's result if finished; otherwise report "still running". |
stop |
run_id |
Cancel a queued/running run. |
For registration, name is the subagent and agent_id the agent it belongs to (default:
the agent of this conversation). Another agent's subagents can be listed, inspected or
changed by an administrator only. For run, agent_id is the target: one of the agent's
own subagents, or another agent's ID. If a subagent and a configured agent have the same
name, target_type ("subagent" or "agent") must say which.
The execution actions only reach runs the calling agent started, and of those only the runs the caller may touch: the person who started the run, a human admin, or the system acting for no one (a cron or SOP run). A run that recorded no person belongs to the session that started it. Any other run answers "not found", so its task and result stay with it. A follow-up acts with the run's delegation: a caller acting for another person, or whose own grant does not cover the run's, is refused.
Example (as the model would call it):
{ "action": "run", "agent_id": "code-reviewer", "task": "Review merge request !42 for bugs" }
With wait (the default) the tool waits up to 60 seconds and returns the answer; a run that
takes longer keeps going while the turn that started it runs (see
ยง10). action is validated at runtime (not by the
JSON schema), so an unknown action returns an error listing the valid ones.
If agent_id is omitted on run, GoClaw attempts embedding-based auto-routing
(RouteToAgent) to pick the best-matching agent for the task; an explicit agent_id always
wins. If no confident match is found, the tool asks the caller to pick one via agents_list
or suggest_agent.
/subagents slash command (human/channel-facing)The same operations are available as a chat command, parsed by HandleSubagentCommand
(internal/agent/subagent_commands.go) and wired into every message entry point โ chat
channels, the WebSocket UI, the REST API, and the OpenAI-compatible endpoint:
/subagents spawn <agent_id> <task>
/subagents list
/subagents status <run_id>
/subagents stop <run_id>
Before any run starts, validateSubagentPolicy enforces per-parent limits declared in the
parent agent's config, plus a system-wide safety cap. This is what prevents runaway
"explosive chaining" loops.
| Guardrail | Config key (per agent) | Default | Meaning |
|---|---|---|---|
| Allow-list | subagents.allow_agents |
(empty = allow all) | Which other configured agents this parent may spawn. "*" = any. If set and the target is not listed, the spawn is denied. It does not apply to the agent's own subagents (agents/<name>.agent.md). |
| Max concurrency | subagents.max_active |
5 |
Max simultaneously queued + running children for this parent, its own subagents included. |
| Max depth | subagents.max_depth |
1 |
How deep the spawn chain may nest. Depth is counted from the number of :subagent: segments in the parent session ID, so a child cannot recurse past the limit. |
| System-wide cap | (constant) | 10 |
maxConcurrentSubagentsV2 โ a global semaphore bounding all concurrently executing subagents across every parent (prevents goroutine leaks). |
Two details worth knowing: max_active is counted per parent agent (all of that agent's
sessions share the one budget), and the whole policy block is only applied when the parent's
config is found โ if config.GetAgentConfig(parent) returns nil, all checks are skipped.
A separate switch governs agent-to-agent messaging (the "send a follow-up to a run"
path, follow_up and SendSubagentMessage) when the run's target is another agent:
tools.agent_to_agent.enabled must be true (it defaults to off), and
tools.agent_to_agent.allow (if non-empty) must list the participating agents. A follow-up to
one of the agent's own subagents does not need it. A follow-up runs the subagent with the same
instructions, tools and model as its first run, and fails if the subagent has been deleted
since. This gate is about messaging an existing run, not starting one.
Note: these limits are what stands between a prompt-injected message and a spawn storm. Registering, changing and deleting subagents needs an administrator, but starting a run does not, so
allow_agentsis the real boundary for runs on other agents โ keep it tight for agents exposed to untrusted channels. The agent's own subagents are bounded bymax_activeand by their narrowed tools.
Runs are SubagentManagerV2's (subagent_v2.go): each run is a row in the SQLite jobs
table, so it survives a restart and can be audited, its end is published on the EventBus and
announced to the parent, and it has its own trace.
SpawnSubagent() โ the function both the tool and the slash command call โ delegates to
SpawnSubagentV2 and converts the resulting Job back into a SubagentRun for callers;
GetSubagentRun, ListSubagentRuns and StopSubagentRun read and stop jobs the same way.
SubagentManager (subagent.go) is what remains of the first, in-memory manager: it holds the
agent registry that a follow-up (Send, behind follow_up and sessions_send) runs on, and
finds the run to follow up in the jobs table.
A subagent run moves through these states (SubagentStatus):
stateDiagram-v2
direction LR
[*] --> queued : spawn
queued --> running : acquire slot
queued --> failed : stop / cancel
running --> done : finish
running --> failed : finish (error) / stop / cancel
A stopped or cancelled run ends failed, usually with the error killed (a stop records
killed by user first; the run's own exit then overwrites it), or cancelled waiting for execution slot when it had not started. A run that reaches its one-hour limit (queued time
included) ends the same way. The turn that started a run ending does not stop it. There is no
separate killed state.
Under the hood, SpawnSubagentV2 performs these steps:
Job row (job_type="subagent", state="queued") with the parent/child
context serialized into context_json.10); if the context is cancelled while waiting, the job is failed with
"cancelled waiting for execution slot".running, forwards the parent's RBAC identity, and calls the
child agent's ProcessStream, draining it to a final result (DrainToResult).done (or failed on error or cancel), publishes an
EventBus event, and, when it is done, announces to the parent (see next section).The
jobsrow for a subagent is a ledger (state, result, audit, recovery) โ not a dispatch queue. Execution is driven directly by the spawning goroutine, not by a background poller. (Contrast this with SOP steps and memory jobs, ยง7.)
When a run finishes, the parent finds out through either of two mechanisms:
done has its answer appended to the parent's session as
"[Support โ code-reviewer (Subagent) done (Run: <id>)] โฆ", so the parent sees it in
history on its next turn. It waits for the parent's turn in progress to end (at most 15
minutes) so neither write drops the other. A failed run is not announced; nothing is posted
to a channel.EventBus (internal/agent/eventbus.go)
on topic job.done or job.failed.The yield action (and YieldSubagentV2) uses the EventBus to wait efficiently: it
subscribes to job.done/job.failed, and โ because publishing is non-blocking and drops
events to a full subscriber channel โ also polls the DB every 3 seconds as a safety net,
plus an overall timeout. This makes waiting correct even under bursty, high-concurrency
fan-out.
The jobs table is shared by several kinds of async work, but they are executed by three
distinct mechanisms. Keeping them straight is important โ they have different concurrency
limits.
| Executor | Handles | Concurrency | Dispatch |
|---|---|---|---|
Subagent manager (subagent_v2.go) |
subagent jobs |
Semaphore, cap 10 | In-process goroutine per spawn (no poller) |
MemoryWorker (memory_worker.go) |
fact_extract, memory_consolidate, graph_extract, graph_sync, belief_revision (an auto_learn_error row left by an older version is drained as a no-op) |
Sequential (one at a time) | Polls queued jobs every 5 s |
Tool WorkerPool (worker_pool.go) |
Tool calls (not jobs at all) | tools.worker_pool_size (8), queue tools.worker_queue_size (64) |
Buffered channel with back-pressure |
Two clarifications that the older version of this doc got wrong:
8-worker / 64-queue pool is the tool execution pool, not the subagent pool.
Subagent concurrency is the semaphore of 10 plus the per-parent max_active limit.MemoryWorker (one job at a
time) to respect LLM rate limits and to avoid SQLite write contention on the memories
table.SOP steps also live in the jobs table (job_type="sop_step") and use the extra
suspended state for human-in-the-loop approval, resuming via the EventBus and the
parent_job_id link. SOPs are covered in Standard Operating Procedures.
jobs table| Column | Purpose |
|---|---|
id |
Unique job ID (job-<unixnano>-<seq>). |
parent_job_id |
Links a child job to its parent (used by SOP resume). |
job_type |
subagent | sop_step | fact_extract | memory_consolidate | graph_extract | graph_sync | belief_revision; auto_learn_error only in rows left by an older version. |
agent_id |
The agent that executes the job. |
task |
The work description handed to the agent. |
state |
queued | running | suspended | done | failed. |
context_json |
Serialized context for resume (parent/child IDs, etc.). |
result_json |
Output captured on completion. |
error |
Failure reason, if any. |
created_at / updated_at |
Timestamps. |
Because state lives in SQLite, a restart does not lose or double-run work:
InitSubagentV2 calls RecoverPendingJobs โ FailStaleJobs, which marks any
job left in running (interrupted mid-execution by the crash) as failed with a clear
reason. Jobs still queued are left intact.failed job rather than
crashing the process.Enable subagents on a parent agent and allow it to delegate to two workers:
agents:
default: main
list:
- id: main
subagents:
allow_agents: [coding, research] # who "main" may spawn ("*" = any)
max_active: 5 # concurrent children (default 5)
max_depth: 1 # nesting depth (default 1)
- id: coding
- id: research
tools:
# Required to send a follow-up to a run on another agent (not to the agent's own subagents).
agent_to_agent:
enabled: true
allow: [main, coding, research]
# Tool-execution pool (separate from subagent concurrency).
worker_pool_size: 8
worker_queue_size: 64
max_depth, default 1): by default another agent running a task
cannot itself spawn a run. Raise it deliberately โ deep chains multiply cost. An agent's own
subagents (.agent.md) never nest: they are never offered subagents, sessions_spawn or
sessions_send, whatever their profile lists. The policy is checked only for agents listed
in agents.list.max_active, no more than 10 subagents
run at once process-wide.run waits up to 60 seconds (or not at all with
wait: false) and then returns the run_id. Use status/yield or wait for the
announcement โ do not assume the result is ready synchronously.failed (killed).allow_agents is a security boundary, not just organization: it is the guard against a
prompt-injected channel message spawning arbitrary agents. Keep it tight on exposed agents.running jobs, but a subagent
left queued at crash time is never re-dispatched (there is no queue poller for subagents).
In practice the queued window is momentary, so this is rare.