an endpoint for spawning an agent, so a caller that is not a chat can

A chat agent already has a way to start a worker — the harness gives it one.
Nothing else does. A watcher loop, a plugin, a background job, a cron entry: none
can reach that tool, which is exactly why a commander's loop shells out to
`claude -p` by hand and then invents its own record-keeping for what it started.

So this is the door Officer defines. `POST /sub-agents/spawn` takes a prompt and an
optional cwd and model, and returns either the session key immediately or the
result if asked to wait. Verified both ways against the live server: the
synchronous call came back with the four words it was told to say, and the async
call returned in milliseconds and the agent had written its file within five
seconds.

The budget check comes BEFORE the spawn, and refuses with a 429 naming the window.
The windows belong to the Anthropic account and every `claude` on the machine
spends them, so an endpoint that could start work without asking would burn the
last of one and cut off the turn the owner is sitting in — a failure that lands on
somebody else's conversation, which is the worst place for it.

`confined`, and confined in FACT on the day it was added rather than ahead of
itself — the registry test's comment records that `chat` sat on that list for a day
before it was true. The identity comes from `chatIdentity` on the caller's token
and `member: { osUser, home }` goes to the sidecar, which is the mechanism chat
already runs under. There is no path in the endpoint that chooses a directory, so
there is nothing for a caller to point elsewhere. `member` is passed explicitly
because it is required-but-may-be-undefined on purpose: optional let a call site
forget it, and forgetting means a member's agent running as the owner.

Not a queue, not a supervisor, not a retry. One call starts one agent; a caller
that wants three tries writes three calls, because a retry in here would be a
policy nobody chose applied to work this endpoint cannot read.

Closes todo/sub-agents/an-api-endpoint-for-spawning-an-agent-with-a.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 05:02:29 +00:00
co-authored by Claude Opus 5
parent ad8ed88519
commit 66234cd3e0
5 changed files with 166 additions and 2 deletions
+12 -1
View File
@@ -241,8 +241,19 @@ describe('kinds', () => {
// both refusing non-owners because a turn still spawned the agent as the owner. Both refusals were removed
// on 2026-08-12, together, once the turn ran under `runAs` with the member's own home, credential,
// transcripts and session ownership. `chat` is now confined in fact and not only in the registry.
//
// `sub-agents` joined on 2026-08-23, and it is confined IN FACT on the day it was added rather than ahead of
// itself. `POST /sub-agents/spawn` resolves the identity through `chatIdentity` from the caller's token and
// passes `member: { osUser, home }` to the sidecar — the same mechanism `chat` runs under, reached by the
// same resolver. It inherits the containment rather than claiming it: there is no path in that endpoint that
// chooses a directory, so there is nothing for a caller to point somewhere else.
test('confined is a short, deliberate list', () => {
expect(PERMISSIONS.filter((c) => c.kind === 'confined').map((c) => c.key)).toEqual(['terminal', 'chat', 'files']);
expect(PERMISSIONS.filter((c) => c.kind === 'confined').map((c) => c.key)).toEqual([
'terminal',
'chat',
'files',
'sub-agents',
]);
});
test('admin permissions are never grantable', () => {
+18
View File
@@ -360,6 +360,24 @@ const CORE_REGISTRY: Permission[] = [
person who should decide whether somebody may put a file in your home is you, not whoever administers
roles. This grant only says the feature is reachable at all.
*/
/*
`confined`, and for the same reason chat is.
Spawning an agent runs a process in a home directory — the caller's own, resolved by `chatIdentity`
from their token. So it needs exactly what chat needs: a Linux account for a member, and nothing for
the owner, with the kernel doing the containing. `authorize.ts` drops a confined grant for an account
whose `osUser` is null, which is what stops "granted but unconfined" resolving to the owner's home.
Not `execution`: that kind exists for surfaces that can ONLY ever run as the owner. This one runs as
whoever called it, which is the whole distinction.
*/
{
key: 'sub-agents',
label: 'Sub-agents',
description: 'Start another agent from a prompt, subject to the account budget',
kind: 'confined',
api: ['/sub-agents'],
},
{
key: 'sendoff',
label: 'SendOff',