--- 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`'s `lineage`, and the creator's own `bot.did.operator` record 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](policy.md). 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](https://tangled.org/did:plc:swttlkbjvcoln67iievud7b3/issues/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 1. **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.rs` names 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 refused `CreatesTooFast`. 2. **A key, `host` by default.** A deployment, or a harness for one context, chooses `agent`. - Costs the daemon change below. Breaks nothing. - Stores nothing new: one environment variable and one optional field on the wire. 3. **The same key, `agent` by default.** - Costs what option 2 costs, and more. A laptop host is admitted with `--creates-beneath agent` beside `--creates agent`; the pool in [docs/ownership.md](../docs/ownership.md) already carries it. The scenario suite's `Daemon::agent` reports a subagent of a fresh session, so its laptop's allowance and its lineage assertions change. `didbot operate --check` refuses 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 `descendants` allowance: each subagent is refused `NotAllowedToCreate` until the record changes or the deployment sets `host`. No command changes a standing account's `creates`: a second `didbot operate` for it is refused `AccountAlreadyExists`, and the record that stood is put back. - Stores nothing new. ### Recommendation Option 3, `agent` by default. - **The tree records who spawned what.** Lineage tracking is in [the mandate](README.md#the-capabilities). Only `agent` puts 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 `host` the host's key gives them fresh accounts. - **A fan-out is charged to its session.** The session's own `creates_per_hour` bounds it, and the host's counts sessions only. The deployment's `[capacity] max_accounts` counts the same accounts either way. The case for `host`: - **It needs nothing from the operator.** `agent` needs a `descendants` allowance, and that lets every account beneath the host create, each against its own `[tree]` bounds. A fan-out from one token is then bounded by `max_depth` and `[capacity] max_accounts`, not by the host's hourly rate. Under `host` an agent's token creates nothing. - **A subagent depends on the host's account alone.** Under `agent` it 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 --check` holds 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: 1. The daemon takes `{S, A}`'s turn, as today. 2. 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. 3. It calls `bot.did.createAccount` with `{name, kind: "agent"}`, and `{S}`'s account token as `Authorization: Bearer`. The name is built as today, from both halves of `{S, A}`. 4. The server checks what it checks for every hosted creator ([docs/ownership.md](../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 with `agent`, on the host's record or one above it. 5. It writes: - `bot.did.operator/` into `{S}`'s repository, as `{S}`: `subject` and `createdAt`, one commit on the firehose; - `bot.did.registration/self` into the subagent's repository: `operator` the human, `lineage` `[host, session]` (or `[pool, host, session]` beneath a pool), `kind` `agent`; - what every account gets: `app.bsky.actor.profile/self`, a DID document naming the human as `operator`, and a ledger opened by `Provisioned`, whose `parent` is `{S}`; - nothing into the host's repository. 6. 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](https://atproto.com/specs/account#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 answers `NotAllowedToCreate` naming `{S}`. - The daemon answers the report with that name and sentence as `trouble`, and logs a warning naming both ways out: `--creates-beneath agent` on the host's record, or `DIDBOT_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`, `InvalidToken` or `ExpiredToken` takes 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_DAYS` refuses 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](node.md)'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/` | `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/` 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 - [ ] **The key.** `DIDBOT_SUBAGENT_OPERATOR` read and checked at start in `crates/didbot-agentd/src/bin/didbot-agentd.rs`, and held by `Daemon`. - [ ] **The create.** `provision_context` presents `{S}`'s token for a subagent under `agent`, creates `{S}` first when it has no account, and logs both ways out on `NotAllowedToCreate`. - [ ] **The override.** `Report::operator` and protocol version 3, with the fixtures blessed. - [ ] **The walk.** `MAX_EDGES` in `crates/didbot-operator/src/operate/walk.rs` goes from three to four. - [ ] **The words.** A host is admitted with `--creates agent --creates-beneath agent` in `docs/agentd.md`, the laptop record and scenario table in `docs/ownership.md`, `docs/first-hour.md` and `docs/cli.md`. `docs/agentd.md`'s provisioning paragraph says which credential creates a subagent, and it, `docs/cli.md` and `didbot-agentd --help` list `DIDBOT_SUBAGENT_OPERATOR`. [node](node.md)'s exit criterion says a session's account is created beneath the host. - [ ] **The scenarios.** The laptop in `crates/didbot/tests/scenarios/` is admitted with `--creates-beneath agent`, and each lineage it asserts follows the tree. - [ ] **The order.** Everything above lands with `host` as the default. `agent` becomes the default in its own breaking change, once the owner has said how a standing host gains its `descendants` allowance. ### Tests The daemon, against the `Counting` registrar in `crates/didbot-agentd/src/serve.rs`, which keeps the credential it was shown: - [ ] `a_subagent_is_created_with_its_sessions_token`: under `agent`, the subagent's create presents `{S}`'s token and `{S}`'s presents a host JWT. - [ ] `a_subagent_before_its_session_creates_the_session_first`: one report from `{S, A}` makes two creates, `{S}` first. `{S}`'s next report is told its identity, and both accounts are followed for sign-ins. - [ ] `two_subagents_of_a_session_with_no_account_create_it_once`: two reports at once make three creates. - [ ] `a_refusal_as_the_session_is_the_subagents_trouble`: for `NotAllowedToCreate` and for `AccountLocked`, one create and no host JWT; the answer carries the server's words; the next report asks again as `{S}`. - [ ] `under_host_a_subagent_is_created_with_the_hosts_key`. - [ ] `a_report_saying_where_wins_over_the_key`: `host` under `agent`, and `agent` under `host`. - [ ] `a_sessions_report_saying_agent_is_created_beneath_the_host`. - [ ] `a_report_naming_neither_value_is_refused_by_name`. The wire and the binary: - [ ] `operator` round-trips in a version-3 report, and `ask-report-full.json` carries it (`crates/didbot-agentd/tests/fixtures.rs`). - [ ] `didbot-agentd` refuses to start on `DIDBOT_SUBAGENT_OPERATOR=pool`, naming the key and both values, and `--help` lists it (`crates/didbot-agentd/tests/cli.rs`). End to end, with the real binaries in `crates/didbot/tests/scenarios/`: - [ ] `a_laptops_subagent_is_created_beneath_its_session`: its lineage is `[host, session]`; the session's repository holds `bot.did.operator/` and the host's does not; `didbot operate --check` walks subagent, session, host, human. - [ ] `a_host_without_descendants_refuses_subagents_by_name`: admitted with `--creates agent` alone, the subagent is refused `NotAllowedToCreate`, the daemon's log names both ways out, and nothing is created beneath the host. - [ ] `locking_a_session_stops_its_subagents`: after `didbot account lock` on the session, its subagent carries `Frozen` from `parent` and a new subagent is refused `AccountLocked`; `unlock` lifts both. - [ ] `deleting_a_session_takes_its_subagents`: `didbot account delete` on the session is refused `LiveChildren`; with `--recursive` both go, the subagent first. - [ ] `a_released_revoke_ends_what_a_session_spawns`: the session's next subagent is refused `InvalidToken`, and a new session's subagent is created. - [ ] `a_pools_subagent_checks_in_four_edges`: subagent, session, host, pool, human, which `--check` refuses as `TooDeep` until `MAX_EDGES` is four. - [ ] `a_fan_out_is_charged_to_its_session`: under `creates_per_hour = 2`, a session, its two subagents and a second session are all created in one hour, where `host` refuses the second subagent `CreatesTooFast`. ### Questions for the owner - [ ] **The default.** `agent`, as recommended, or `host`. - [ ] **A standing host's allowance.** `didbot operate` rewrites `creates` on a record whose account stands; or operators rewrite it by hand, and set `host` until they do. - [ ] **`host` from a hook when the key says `agent`.** Honour it, as recommended, since a hook already names the session; or refuse it, under [node](node.md)'s rule that nothing from a hook widens anything. - [ ] **The names.** The issue's `operator`, which is the record's name; or `parent`, which `createAccount`'s answer, the account row and the ledger use, and which never names the human. - [ ] **What bounds a fan-out beneath `descendants`.** `[tree] max_depth` and `[capacity] max_accounts`, as today; or a scope that reaches one level, beside `subject` and `descendants`, which is a lexicon change. ## 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. - [ ] **Confirm a context against the harness's own transcript before creating for it.** The harness writes each subagent's calls to that subagent's own file, so which agent made a given call is recoverable from disk with certainty, including for two byte-identical concurrent commands. Measured: the hook's id agreed with the transcript on every call observed, and the row landed within a tenth of a second of the hook firing. - [ ] **Decide what happens when they disagree.** Disk is authoritative, so the write is either attributed to the agent disk names or refused. What it must not be is attributed to the agent the hook named. - [ ] **Record disagreements before enforcing on them.** The rate is unknown outside a single report, and a first deployment that blocks on it measures nothing. ## 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](node.md)'s `[tree]` bounds cap, moved to the busiest moment, and charged to whichever account creates it: the session under `agent`, the host under `host`. - [ ] **Granularity is configuration, with per-context the default.** [account-types](account-types.md) already has the kind supply defaults and the account carry values, so a deployment that wants one account per session sets it rather than patching this decision out. The instability above is a second reason to want that switch, and a reason its default should be revisited once there are numbers. ## Done - [x] **The decision this epic existed to make.** A subagent gets its own DID and its own account. What tipped it was not cost, since a wildcard DNS record removed the per-account DNS write this epic priced, but that the alternative denotes something untrue: a parent's account signing a subagent's work records the wrong author. - [x] **What a subagent account is a credential for.** Revocation and attribution, not scope. Narrowing is [policy](policy.md)'s, at the write. - [x] **Whether a harness lets a subagent's identity be enforced.** No harness examined does: a subagent shares its parent's process and filesystem, and what arrives is the harness's report. Recorded here rather than left as a question, because every design above is built on top of it. - [x] **A subagent is created separately.** A context is keyed by session and subagent id, and a session and its two subagents get three accounts; [node](node.md) has the tests.