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:
@@ -207,7 +207,7 @@ ahead of everything, and it re-verifies the token itself so it covers routes tha
|
||||
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
|
||||
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
|
||||
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
|
||||
never grantable at any level). **20 entries as of 2026-08-23** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 4 app, 3 confined, 4 execution, 4 admin. It said 27 for nine days.
|
||||
never grantable at any level). **21 entries as of 2026-08-23** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 4 app, 4 confined, 4 execution, 4 admin. It said 27 for nine days.
|
||||
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
|
||||
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
|
||||
and `confined` stripped for an account with no `osUser`.
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
import { chatIdentity } from '../chat/chat';
|
||||
import { checkBudget } from './budget';
|
||||
|
||||
/*
|
||||
POST /sub-agents/spawn — an agent asks Officer to run another agent.
|
||||
|
||||
── Why this exists rather than leaning on the harness ──
|
||||
|
||||
A chat agent already has a way to spawn a worker: the harness gives it one. Nothing else does. A watcher
|
||||
loop, a plugin, a background job, a cron entry — none of them can reach that tool, which is exactly why a
|
||||
commander's loop shells out to `claude -p` by hand and then has to invent its own record-keeping for what
|
||||
it started.
|
||||
|
||||
So this is the door Officer defines: the shape, the limits and the vocabulary are the platform's, every
|
||||
caller gets the same one, and none of it changes when a CLI does.
|
||||
|
||||
── The condition that comes before the feature ──
|
||||
|
||||
`checkBudget()` runs first and refuses on a floor. The windows belong to the Anthropic account and are
|
||||
spent by every `claude` on the machine, so an endpoint that can start work without asking would burn the
|
||||
last of a window and kill the turn the owner is sitting in — a failure that lands on somebody else's
|
||||
conversation, which is the worst place for it. The reserve is a guess and the honest shape of a guess is a
|
||||
floor: never spend the last 15%.
|
||||
|
||||
── What it is NOT ──
|
||||
|
||||
Not a queue, not a supervisor, not a retry. One call starts one agent. Whether that agent's work was any
|
||||
good is the caller's problem, and a caller that wants three tries writes three calls — because a retry
|
||||
inside here would be a policy nobody chose, applied to work this endpoint cannot read.
|
||||
*/
|
||||
|
||||
export const subAgentsRouter = createRouter();
|
||||
|
||||
const MAX_PROMPT = 100_000;
|
||||
|
||||
subAgentsRouter.post('/spawn', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.get('body') as { prompt?: string; cwd?: string; model?: string; wait?: boolean };
|
||||
|
||||
const prompt = typeof body?.prompt === 'string' ? body.prompt.trim() : '';
|
||||
if (!prompt) throw errors.BAD_REQUEST('prompt is required');
|
||||
if (prompt.length > MAX_PROMPT) throw errors.BAD_REQUEST(`prompt exceeds ${MAX_PROMPT} characters`);
|
||||
|
||||
/*
|
||||
Refused BEFORE anything is spawned, and the reason travels with the refusal.
|
||||
|
||||
A caller that gets "no room" needs to know which window and how close, or its only move is to try again
|
||||
immediately — which is the behaviour this is trying to prevent. 429 rather than 403: this is a "not now",
|
||||
not a "not you".
|
||||
*/
|
||||
const budget = await checkBudget();
|
||||
if (!budget.ok) {
|
||||
return ctx.json(
|
||||
{ error: budget.reason ?? 'no budget to start another agent', limits: budget.limits, problem: budget.problem },
|
||||
429,
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
The identity is resolved from the TOKEN, never from the body.
|
||||
|
||||
`chatIdentity` is what decides the home directory and the OS user the agent runs as, and it is the same
|
||||
resolution every chat route uses. A caller naming its own email here would be naming whose filesystem to
|
||||
run in.
|
||||
*/
|
||||
const who = await chatIdentity(user);
|
||||
|
||||
// Officer's own handle for the session. The harness's transcript uuid is assigned when the agent first
|
||||
// reports one, which is after this returns — see the note in the response below.
|
||||
const sessionKey = crypto.randomUUID();
|
||||
const spawn = {
|
||||
userId: user.id,
|
||||
email: who.email,
|
||||
username: user.username ?? who.email.split('@')[0] ?? String(user.id),
|
||||
prompt,
|
||||
sessionKey,
|
||||
...(body?.cwd ? { cwd: body.cwd } : {}),
|
||||
...(body?.model ? { model: body.model } : {}),
|
||||
/*
|
||||
Stated explicitly, never omitted. The field is `required-but-may-be-undefined` on purpose — an optional
|
||||
one let a call site forget it, and forgetting it means a member's agent silently running as the owner in
|
||||
the owner's home. `undefined` here says "the owner", and that is only reached when `chatIdentity` says
|
||||
there is no Linux user to run as.
|
||||
*/
|
||||
member: who.osUser ? { osUser: who.osUser, home: who.home } : undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
/*
|
||||
Two modes, and the default is the one a loop wants.
|
||||
|
||||
`wait: false` returns as soon as the agent is running, so a watcher can start work and get on with
|
||||
watching. `wait: true` blocks for the answer, which is right for a question with a short answer and
|
||||
wrong for anything that touches a fleet — the request would hold a socket open for the length of the
|
||||
work.
|
||||
*/
|
||||
if (body?.wait === true) {
|
||||
const result = await sidecar.spawnClaude(spawn);
|
||||
return ctx.json({ sessionKey, result });
|
||||
}
|
||||
await sidecar.spawnClaudeStreaming(spawn);
|
||||
/*
|
||||
`sessionKey` and not a transcript id, because the transcript does not exist yet.
|
||||
|
||||
The agent announces its own uuid on its first turn, which is after this responds. A caller that needs
|
||||
the transcript — to open `/chat/<id>`, or to record what it started — reads it from the live session
|
||||
list a moment later. Returning a guess here, or blocking until it arrived, would both be worse than
|
||||
saying which id this is.
|
||||
*/
|
||||
return ctx.json({ sessionKey, running: true });
|
||||
} catch (err) {
|
||||
// The agent sidecar being down is a 503, not a 500: nothing about the request was wrong and retrying it
|
||||
// later is the correct response, which is a distinction a caller can act on.
|
||||
// 503 rather than 500: nothing about the request was wrong, and retrying it later is the correct
|
||||
// response — a distinction the caller can act on.
|
||||
throw errors.createError(503, err instanceof Error ? err.message : 'could not start an agent');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /sub-agents/budget — is there room to start one, without starting one.
|
||||
*
|
||||
* Separate from the spawn so a caller can decide before it commits. A watcher that would rather skip a cycle
|
||||
* than be refused mid-flight asks this first; the spawn checks again regardless, because between the two
|
||||
* calls somebody else may have spent the window.
|
||||
*/
|
||||
subAgentsRouter.get('/budget', async (ctx) => {
|
||||
const budget = await checkBudget();
|
||||
return ctx.json(budget);
|
||||
});
|
||||
@@ -28,6 +28,7 @@ import { agentHandoffRouter } from './api/agent-handoff/router';
|
||||
import { terminalRouter } from './api/terminal/sidecar-server';
|
||||
import { minesweeperRouter } from './api/minesweeper/router';
|
||||
import { sendoffRouter } from './api/sendoff/router';
|
||||
import { subAgentsRouter } from './api/sub-agents/router';
|
||||
import { caldavRouter } from './api/dav/sidecar-server';
|
||||
// import { memosRouter } from './api/memos/router';
|
||||
import { appStoreRouter } from './api/app-store/router';
|
||||
@@ -143,6 +144,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
||||
['/terminal', terminalRouter],
|
||||
['/minesweeper', minesweeperRouter],
|
||||
['/sendoff', sendoffRouter],
|
||||
['/sub-agents', subAgentsRouter],
|
||||
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
|
||||
['/app-store', appStoreRouter],
|
||||
['/plugins', pluginsRouter],
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user