id: subagents title: A subagent is a context, so it gets an account status: open crates: [didbot-pds, didbot-agentd, didbot-operator] dependsOn: [ownership, node] exitCriterion: > Two subagents of one session write records signed by two different accounts, each account's registration names the account it was created beneath, and a deployment sets in configuration rather than in code whether that is the session or the host, and whether a session's subagents share its account. #
subagents #
One session is one context. A subagent's context is its own and ends with its task, so it is a different entity from its parent and from its siblings, not a role its parent plays. It gets an account.
What an account buys, and what it does not #
- Revocability. A context's binding is dropped in the daemon without ending the session that spawned it.
- Attribution that survives. The writer is the signer, so nothing has to
be tagged onto each record and nothing has to be believed. Where the account
sits is stated once, at creation, in
bot.did.registration'slineage, and the creator's ownbot.did.operatorrecord names it back. - Not narrower authority. An agent's credential is all-or-nothing over its own repository; what a context may write is decided by policy. A subagent account restricts what a subagent is handed, never what it could reach by acting as the session instead.
The last point holds whatever harness is underneath: a subagent runs inside its parent's process and filesystem, so the boundary is reported rather than enforced. A session id is the same kind of claim from the same bookkeeping as a subagent id. What the server checked is the creator's credential and the operator's allowance for it: the host's key, or the session's own token when the session creates. Read a subagent's account as "an account the operator's allowance covers created this", never as "this agent is who it says it is".
One account reused across every subagent of a type would be cheaper and would denote nothing: two invocations share no context, so they are not one entity.
Which account a subagent is created beneath #
The daemon creates every context with the host's key, so a subagent's lineage ends at the host and its session's account says nothing about it. The other tree creates a subagent as its session, beneath the session. Issue #133 asks which, and how a deployment and a harness choose.
The hook tells the daemon which context is acting. didbot-claude sends the
harness's agent_id as the report's context, and leaves it out for the
main conversation. The daemon keys a context by {session, context}
(crates/didbot-agentd/src/context.rs), so a subagent's session is the
context with the same session and no context. Below, {S, A} is
subagent A of session S, and {S} is the session. The hook names the
acting agent and not the one that spawned it, so a subagent started by
another subagent has {S} as its parent too.
Options #
- Do nothing. Every context is created beneath the host, as today.
- Costs nothing, breaks nothing, stores nothing new.
- A lock on a session reaches none of its subagents, and its next
subagent is still created.
crates/didbot-pds/src/estop.rsnames the gap: "a halted agent that can still provision takes a fresh account under the same host". - Deleting a session leaves its subagents standing, and nothing at the server names the session they came from: an account's name is a digest of both halves of its context.
- Every context on the machine spends the host's
creates_per_hour, 100 by default, so one session's fan-out can leave another session's next context refusedCreatesTooFast.
- A key,
hostby default. A deployment, or a harness for one context, choosesagent.- Costs the daemon change below. Breaks nothing.
- Stores nothing new: one environment variable and one optional field on the wire.
- The same key,
agentby default.- Costs what option 2 costs, and more. A laptop host is admitted with
--creates-beneath agentbeside--creates agent; the pool in docs/ownership.md already carries it. The scenario suite'sDaemon::agentreports a subagent of a fresh session, so its laptop's allowance and its lineage assertions change.didbot operate --checkrefuses a chain still hosted here after three edges (MAX_EDGES), and a subagent beneath a session beneath a host beneath a pool is such a chain. - Breaks every host whose record lacks the
descendantsallowance: each subagent is refusedNotAllowedToCreateuntil the record changes or the deployment setshost. No command changes a standing account'screates: a seconddidbot operatefor it is refusedAccountAlreadyExists, and the record that stood is put back. - Stores nothing new.
- Costs what option 2 costs, and more. A laptop host is admitted with
Recommendation #
Option 3, agent by default.
- The tree records who spawned what. Lineage tracking is in
the mandate. Only
agentputs the session in a subagent's lineage, and a record naming the subagent in the session's repository. - The operator's controls reach what a session spawned. A lock on a session hangs on each subagent beneath it and refuses the next one. A delete of the session's subtree takes its subagents. A policy binding that includes the session's descendants covers them.
- A Revoke holds. Its release ends every agent token, so a session from
before it gets no new subagents. Under
hostthe host's key gives them fresh accounts. - A fan-out is charged to its session. The session's own
creates_per_hourbounds it, and the host's counts sessions only. The deployment's[capacity] max_accountscounts the same accounts either way.
The case for host:
- It needs nothing from the operator.
agentneeds adescendantsallowance, and that lets every account beneath the host create, each against its own[tree]bounds. A fan-out from one token is then bounded bymax_depthand[capacity] max_accounts, not by the host's hourly rate. Underhostan agent's token creates nothing. - A subagent depends on the host's account alone. Under
agentit also needs the session's account provisioned, active and unlocked, and a session token that still verifies: a year by default, ended by a Revoke's release, and gone if the daemon loses the session's binding. - The tree is one edge shallower.
didbot operate --checkholds at three hosted edges, and a session can deactivate itself or be erased without its subagents going first.
On a laptop the session's token sits in contexts.json beside the host's
key, so a process that reads one reads the other. The wider allowance matters
for a token that leaves the machine without the key.
What agent does #
For a report from {S, A}, which has no account yet:
- The daemon takes
{S, A}'s turn, as today. - If
{S}has no account, the daemon creates one first, under{S}'s own turn and with the host's JWT, as a report from{S}would.{S}is told its identity on its own next report. - It calls
bot.did.createAccountwith{name, kind: "agent"}, and{S}'s account token asAuthorization: Bearer. The name is built as today, from both halves of{S, A}. - The server checks what it checks for every hosted creator
(docs/ownership.md):
- the operator's record for the root above
{S}still stands; {S}and every account above it are active and unlocked;- the
[tree]bounds hold, counted against{S}; - an allowance covers the chain
[session, host, …]:descendants, with no kinds or withagent, on the host's record or one above it.
- the operator's record for the root above
- It writes:
bot.did.operator/<subagent>into{S}'s repository, as{S}:subjectandcreatedAt, one commit on the firehose;bot.did.registration/selfinto the subagent's repository:operatorthe human,lineage[host, session](or[pool, host, session]beneath a pool),kindagent;- what every account gets:
app.bsky.actor.profile/self, a DID document naming the human asoperator, and a ledger opened byProvisioned, whoseparentis{S}; - nothing into the host's repository.
- The daemon keeps the DID and the token in
{S, A}'s binding, follows its sign-ins and answers with its identity, as today.
The daemon already holds {S}'s token: createAccount answered it once as
accountToken, and contexts.json keeps it. tree::proof::prove reads a
token this server issued as Proof::Session, and the account it names is
the creator (crates/didbot-serve/src/tree/proof.rs). No second sign-in is
needed, and none would work. bot.did.createSession takes a key-signed JWT
or an ID token, and {S}'s account holds neither. A getServiceAuth JWT is
signed with the #atproto key, which prove_by_key does not read. No
credential type is added.
The server half is built and tested:
a_descendants_allowance_lets_the_children_create_and_not_the_root
(crates/didbot-serve/tests/tree.rs) creates an agent beneath an agent and
checks its lineage, and a_session_creates_beneath_the_account_it_names
(crates/didbot-serve/src/tests/provisioning.rs) creates with a session's
token.
The protocol does not decide this. The reference PDS's
com.atproto.server.createAccount takes no creator or parent
(bluesky-social/atproto at ff99ec0a, vendored in vendor/atproto-lexicons),
so the tree is bot.did.*'s own. The spec governs one side effect: a lock
that removes reads changes an account's hosting status, and each account the
cascade reaches gets its own #account event
(account spec, Hosting Status).
When the allowance does not reach descendants #
The refusal is the subagent's trouble, and nothing is created beneath the host instead.
- The server re-reads the operator's records, waiting up to eight seconds
(
ALLOWANCE_REREAD_WAIT), and answersNotAllowedToCreatenaming{S}. - The daemon answers the report with that name and sentence as
trouble, and logs a warning naming both ways out:--creates-beneath agenton the host's record, orDIDBOT_SUBAGENT_OPERATOR=host. - didbot-claude writes trouble to its hook error log and puts nothing in front of the model. The subagent runs with no account.
- The subagent stays owed a name, and its next call asks again: the retry a host with no allowance gets today.
A fallback on this one refusal would escape no lock and no bound, because the server checks those before the allowance. It is refused anyway:
- where a subagent lands would depend on when the operator's poll last ran, and the key would stop saying where accounts are;
- the same fallback on
AccountLocked,CreatorNotActive,AncestorNotStanding,TreeTooDeep,TooManyChildren,CreatesTooFast,InvalidTokenorExpiredTokentakes a subagent out from under what refused it, and one match arm would be all that separates the two.
The key #
DIDBOT_SUBAGENT_OPERATOR: the issue's subagent.operator, spelled the way
the daemon's settings are, since "the environment is the whole
configuration" (crates/didbot-agentd/src/bin/didbot-agentd.rs). It is read
once, at start.
agent, the default: a subagent is created as its session.host: a subagent is created with the host's key, beneath the host.- Anything else refuses the start, naming the key and both values, as
DIDBOT_CONTEXT_TTL_DAYSrefuses a number outside its range.
It does not touch a session's own account, which the host always creates.
The per-context override #
A report may carry operator, "agent" or "host".
- It is read on the report that creates a subagent's account, and wins over the key.
- It is ignored on a session's report, and on every report once the account exists: an account is never moved.
- Any other value is trouble naming both, and nothing is created.
The wire needs the field and a new version. Report drops fields it does
not know, so a version-2 daemon would create beneath the host and say
nothing. Version 3 adds operator. A daemon reads a version-2 report as
before, so an adapter that never sends the field need not move. One that
sends it writes version 3 on that report, and a version-2 daemon refuses it
by name (serve::too_new). ask-report-full.json in
crates/didbot-agentd/fixtures/protocol/ gains the field.
Both values are honoured, whatever the key says. host on a daemon set to
agent takes a subagent out from under its session. That is no wider than
the hook already is: it names the session, and a report naming a new session
already gets an account beneath the host. node's rule that nothing
from a hook may widen anything is why the owner is asked below.
didbot-claude need not send it: nothing in Claude Code's payload changes the answer.
What a session's freeze, lock, deletion or e-stop does to its subagents #
| What happens to the session | host |
agent |
|---|---|---|
A lock: the operator's (didbot account lock), its own (bot.did.freezeAccount), or a policy's quarantine |
Its subagents keep writing, and new ones are created beneath the host. | Each live subagent carries the same lock from parent until the session's comes off (Provisioner::cascade). A new subagent is refused AccountLocked. |
It deactivates itself (bot.did.deactivateAccount) |
Allowed. | Refused AccountHasLiveChildren while a subagent beneath it is live. |
The operator erases it (didbot account erase) |
Allowed; its subagents are untouched. | Refused AccountHasLiveChildren while a subagent beneath it is live. |
The operator deletes it (didbot account delete) |
It goes alone. Its subagents stay beneath the host, and nothing names their session. | Refused LiveChildren without --recursive. With it, each subagent goes first, then the session. |
| A lock on the host, or the operator's record for the root lapsing | Reaches every account on the host. | The same, through the session. |
| An e-stop Pause, or a Revoke while it stands | Every create is refused Halted. |
The same. |
| A Revoke released | Every agent token ends. The host's key still creates the session's new subagents. | The session's token has ended, so its new subagents are refused InvalidToken. A session started after the release is unaffected. |
Its token expires ([credential] agent_token_ttl_days, a year by default) |
Nothing changes for its subagents. | Its new subagents are refused ExpiredToken. |
| What the ledger says | A subagent's Provisioned.parent is the host. |
A subagent's Provisioned.parent is the session, and a cascaded lock is a Locked entry from parent in its ledger. |
State #
The recommended option adds no stored fact, field or lifecycle. It changes which account some existing facts name.
Settings, which nothing stores:
| Setting | Type | Where |
|---|---|---|
DIDBOT_SUBAGENT_OPERATOR |
agent or host; agent when unset |
the daemon's environment: ~/.config/didbot/agentd.env under the unit |
Report::operator |
agent or host, optional |
one report, protocol version 3 |
Stored facts:
| Fact | Type | Where it lives | A subagent under agent |
Under host |
|---|---|---|---|---|
| The subagent's binding | session, context, kind, did, token, told, done, last_reported |
contexts.json (context::Binding) |
as today | as today |
The session's token |
a secret | the session's binding in contexts.json |
presented to create the subagent | not read |
parent |
DID | the subagent's account row (HostedAccount::parent) |
the session | the host |
bot.did.operator/<subagent> |
subject, createdAt |
the parent's repository | the session's | the host's |
lineage |
DIDs, root first | bot.did.registration/self in the subagent's repository |
[host, session] |
[host] |
Provisioned.parent |
DID | the subagent's ledger | the session | the host |
| Live children; creates in the last hour | counts | derived from the account store; the provisioner's memory | the session's | the host's |
The host's creates |
allowances | bot.did.operator/<host> in the operator's repository |
subject and descendants, each [agent] |
subject [agent] |
A subagent context in the daemon. The choice is the report's operator when
it has one, and the key when it does not.
| From | Trigger | When | Then | To |
|---|---|---|---|---|
| none | any report from {S, A} |
a binding with no did |
unnamed | |
| unnamed | began or acted |
operator is neither value |
trouble naming both; no create | unnamed |
| unnamed | began or acted |
the choice is host |
createAccount with the host's JWT |
named, or unnamed with the refusal |
| unnamed | began or acted |
the choice is agent; {S} is named and holds a token |
createAccount with {S}'s token |
named beneath {S}, or unnamed with the refusal |
| unnamed | began or acted |
the choice is agent; {S} has no account |
{S} is created first under its own turn, with the host's JWT; then the row above |
named beneath {S}, or unnamed with the refusal |
| unnamed | began or acted |
the choice is agent; {S} is named and holds no token |
trouble naming {S}'s DID; no create |
unnamed |
| named | any report | told once per asker, as today; operator ignored |
named | |
| any | ended |
as today | done | |
| any | quiet past DIDBOT_CONTEXT_TTL_DAYS |
as today | dropped |
A session context moves as it does today, and ignores operator. A
subagent's account gains no transitions: the table above says what its
session's do to it.
Build #
Tests #
The daemon, against the Counting registrar in
crates/didbot-agentd/src/serve.rs, which keeps the credential it was shown:
The wire and the binary:
End to end, with the real binaries in crates/didbot/tests/scenarios/:
Questions for the owner #
The identifier is not reliable, and disk is #
The harness's subagent id has been observed arriving wrong: one subagent's consecutive tool calls under two different ids, and a single stray id collecting calls from more than one real subagent. A stray id would create an account for a context that never existed, or sign several agents' work under one name.
Granularity #
Subagent use is bursty and concentrated where the work is: two days on one
machine were 16 sessions and 167 subagent invocations, 161 of them from one
project. A fan-out of parallel subagents is the same burst
node's [tree] bounds cap, moved to the busiest moment, and
charged to whichever account creates it: the session under agent, the host
under host.