Skip to Content
ConceptsChild agents
Open in Claude Open in ChatGPT

Child agents

A parent agent can mint a child identity for a subagent it’s about to spawn, via POST /v1/agents/children, issue_child in the Python SDK, or the issue_child_agent MCP tool. The child gets its own agent id, its own bearer token, and its own row in the audit log; it is never handed the parent’s key.

The mint flow

The parent calls the endpoint with the scopes it wants the child to have:

POST /v1/agents/children { "name": "research-worker", "ttl_seconds": 600, "grants": [ { "platform": "github", "scopes": ["github:repo.read"] } ] }

The proxy attenuates that request against the parent’s live grants (not what the parent was granted historically, what it currently holds) and writes the intersection as the child’s own permissions rows at mint time. A grant the parent doesn’t currently hold is dropped from the child silently; if a whole platform attenuates to nothing, the mint fails with no_scopes_after_attenuation rather than issuing an empty grant. The response carries the child’s bearer token and its expiry:

{ "child_agent_id": "agt_...", "bearer_token": "...", "expires_at": "2026-08-21T13:15:00Z", "granted": [ { "platform": "github", "scopes": ["github:repo.read"], "require_approval": false } ] }

Put only bearer_token into the subagent’s hands. It builds its own client from that one string, using AgentValet.from_bearer() or the equivalent constructor.

Attenuation, not just a filter at mint time

The intersection with the parent’s grants isn’t a one-time check. services/child-liveness.ts re-derives it on every call the child makes, and again (against the connection pinned at queue time) when a queued approval for a child’s action replays. Concretely, each governed call checks:

  • The child hasn’t expired.
  • The parent’s agents.status is still active.
  • The requested scope is still present in the parent’s current permission row for that platform.

That last check is what makes child ≤ parent an invariant of the read path rather than a property that only held at mint time. Narrow the parent’s grant after the child was minted, and the child loses that scope on its very next call, no re-mint required, no stale credential to chase down.

TTL

ttl_seconds is bounded to 60–3600 seconds (900 by default). A child’s grant expiry is also clamped to the parent’s own grant expiry, whichever is sooner: a child grant can never outlive the grant it was sliced from.

An expired child is over for good. Its calls are refused with the reason agent_expired, whatever its status, and its halt envelope says to ask the parent for a fresh child: nobody can reactivate it. A held call from a child that expires before approval is dropped with agent_expired too. Within five minutes a sweep marks the child revoked and writes an agent.child_expired audit row.

An agent expiry exists only for children, and only the proxy sets it, when it issues the child. To time-box a top-level agent, put the expiry on its grants instead: each permission has its own expires_at, and calls under an expired grant are refused with grant_expired.

Depth is capped at 1

A child cannot itself call POST /v1/agents/children. If the parent has a parent_agent_id set, the mint request is rejected with delegation_depth_exceeded. If a worker needs to delegate further, route back through the parent agent, which is the one holding a real key.

Revoke the parent, contain every child

Suspending or revoking the parent doesn’t require walking the tree. Because liveness is re-derived from the parent’s row on every call, revoking or suspending the parent denies every one of its children on their very next call, with no cascading sweep needed. (A DB-level status cascade and a pg_cron sweep also exist for dashboard hygiene and garbage collection, but they aren’t what makes revocation effective; the call-time check is. The cascade writes an agent.suspended_by_parent audit row on each child it suspends, so the child’s own history says why it stopped.)

Audit attribution

Every call a child makes is attributed to the child’s own agent_id in the audit log: “which subagent read that file” is a column, not a reconstruction from timestamps. The mint itself writes an agent.child_issued audit row against the parent, carrying the child id and the granted slice, so the parent-child pair survives even after the child row is eventually garbage-collected.

Honest caveats

This mechanism is only as good as the harness’s ability to hold a per-child credential. Two constraints ride every claim about it:

  • Claude Code’s stdio Task-tool subagents share the parent’s MCP server. There’s no per-subagent hook the parent process can use to hand a distinct bearer to a Task-tool subprocess today, so those subagents are attributed to the parent connection. The Phase-1 session_id marker is the attribution story there, not a child identity. Child identities are the real mechanism for harnesses whose orchestrator code holds the child bearer directly, including the Agent SDK, LangGraph, and CrewAI.
  • Approval replay re-checks the parent, not the whole policy. When a queued child action is approved, the replay checks the child’s expiry, the parent’s active status, and the requested scope against the parent’s current grant on the connection the call resolved to at queue time. Narrow the parent between queue and approval and the replay is dropped; narrow the child’s own grant and it is dropped as scope_revoked, the same as for any agent. The replay does not re-run the full permission check, though: policy, guardrail and endpoint decisions stay as they were when the action was queued, since a human is approving that exact action.

What the attestation says for Claude Code stdio subagents

When an MCP server you connected has attestation turned on, every governed call to it carries a signed statement of the calling agent’s standing. Task-tool subagents run inside the parent’s MCP server process, so AgentValet sees one identity. The attestation on their calls names the parent agent, carries delegation: false and depth: 0, and includes the process-level session_id. It proves the call came through a governed AgentValet agent in a specific session. It does not prove which subagent made the call, and it does not carry an attenuated scope slice, because none exists on that path. A verifier that requires delegation: true will correctly reject these calls. If you need per-subagent attestations, mint child identities from orchestrator code that holds the child bearer (Agent SDK query() fan-out, LangGraph, CrewAI), as above.

Next

Last updated on