@stratusagent/control-api
v0.12.0
Published
The Stratus control API: one authenticated HTTP + WebSocket surface over a running stratusd, consumed by the web dashboard and the macOS app alike
Readme
@stratusagent/control-api
One authenticated HTTP + WebSocket surface over a running stratusd. It is
the only doorway any surface uses: the web dashboard consumes it, the macOS
app consumes it, and a hosted deployment is largely built on it.
Optional on purpose. stratusd runs without it, and installing it is how you
say you want a port open — which is also what lets it carry a real WebSocket
dependency without weighting the always-on core.
npm install -g @stratusagent/control-api
stratus serve # binds http://127.0.0.1:4123The web UI is a separate package (@stratusagent/dashboard). This one
serves /api/v1 and nothing else, because its other two consumers — the
macOS app and a headless VM — want the API and not a web page.
Authentication
Two credentials, one check. Every endpoint requires one of them, except two that carry a single-use token of their own in their address: the one-time sign-in exchange below, and a credential link.
Bearer token — generated into ~/.stratus/gateway-token (0600) the first
time the API binds. Programmatic clients read the file and send it:
curl -H "Authorization: Bearer $(cat ~/.stratus/gateway-token)" \
http://127.0.0.1:4123/api/v1/agentsThe file is claimed by linking an already-written file into place, so two daemons starting together on one home agree on a token instead of the loser authenticating against a value no client can read, and a process killed mid-write leaves no half-created file. A token file that is empty is refused with a message naming it rather than repaired: repair means replacing a file another daemon may have just claimed, and there is no conditional replace to do it safely. Delete it and start again.
Session cookie — a browser can do neither half of that: page JavaScript
cannot read the token file, and a WebSocket upgrade cannot carry an
Authorization header. So stratus dashboard mints a one-time token and
opens the browser at it:
POST /api/v1/auth/ott → { ott, url } (bearer only)
GET /api/v1/auth/session?ott= → 302 /, Set-Cookie (single use, 60s)POST /auth/ott answers with { ott, url, path }. path is the exchange
link with no origin on it, and a client holding the address it just reached
should prefer joining that to its own base: url is built from the request's
Host (and x-forwarded-proto, read for nothing else), which is right for a
LAN address or a tunnel but is still the daemon's best guess rather than the
caller's own knowledge.
The cookie is HttpOnly, SameSite=Strict, and rides along on the WebSocket
upgrade, so /api/v1/events authenticates the same way. It carries no
Secure flag: the gateway serves plain HTTP on loopback, and a Secure
cookie would simply never be sent back — the flag would read as hardening
while breaking every request. TLS is a tunnel's job.
Sessions live in memory and are never written to disk. An announced restart
(stratus restart, POST /restart) hands them from the stopping process to
the one replacing it, so the browser stays signed in; a crash or a plain
stop-and-start signs it out, and so does rotating the token (a handed session
is adopted only under the token it was minted with). Run stratus dashboard
again for a new link.
Origin binding. SameSite matching ignores ports, so a page served from
another port on the same host counts as the same site and its requests carry
the cookie automatically — and WebSockets get no CORS protection at all. So
every WS upgrade and every state-changing request made with a cookie is
checked against this gateway's exact origin, port included. Bearer requests
are exempt: nothing attaches that header on a page's behalf, so there is no
ambient authority to forge.
"Exact origin" means the address the browser actually reached the daemon on,
which is routinely not the one it bound to — a wildcard bind is reached over
a LAN or Tailscale address, and a tunnel terminates TLS in front of a
loopback one. So an origin equal to the request's own Host is accepted,
under http or https. That is a same-origin check rather than a
concession: a browser sets Host from the address it connected to, never
from the page making the request, and cannot be made to send another one —
Host is a forbidden header name for fetch, XMLHttpRequest, forms, and
WebSockets alike. A page on another port, or another host, still fails.
Localhost binding is the posture. Remote access is the operator's tunnel decision — Tailscale is the pattern we recommend for reaching a machine at home. There are no user accounts; that belongs to a hosted deployment.
Endpoints
Everything is under /api/v1. A path prefix rather than a header, because
the macOS app pins against it and a version you can see in a curl, a proxy
log, and an address bar is one that gets noticed when it changes.
| Method | Path | What |
| --- | --- | --- |
| GET | /health | Uptime, roster, session counts, pending approvals, intake ({ paused, message?, since? }), resolved runtimes |
| GET | /agents | The roster as data — soul metadata, avatar palette, resolved provider/model, memory counts, activity |
| POST | /agents | Create an agent: writes a soul file and reloads the roster |
| GET | /agents/:id | One agent in full: complete instructions, the raw soul markdown, its pins, and its language when the soul sets one |
| PUT | /agents/:id | Edit a soul, by field (name, instructions, tools, skills, credentials, provider, model, listens — one of mentions, thread, judge, an empty string clearing it; anything else answers 400 invalid_listens — language — a language tag, an empty string clearing it back to the config's or the default; anything else answers 400 invalid_language; a field edit that omits it keeps the soul's) or as raw markdown |
| GET | /agents/:id/grants | What this agent may do unattended beyond the built-in safe list — its command scopes, origins, and standing tool grants, from ~/.stratus/agents/<id>/whitelist.json. A tool grant the engine would not honour carries stale saying why (the tool is now contributed by another package, or nothing loads it). The id is validated but not looked up: a grant can outlive its agent, and this is how it is found. 501 grants_unavailable from a daemon started without a grant store |
| POST | /agents/:id/grants/revoke | Take one back: exactly one of { tool }, { scope } (the listed description, such as git push), or { origin } → { revoked: true }. Through the daemon's own store, so the next call is judged without it — no restart. 404 grant_not_found when nothing matched; 400 invalid_grant for none or several; 409 grants_unreadable when the agent's whitelist file exists but will not parse, since nothing is written over a grant list nobody can read |
| POST | /roster/reload | Re-read the agents directory and the configured default soul |
| POST | /skills/reload | Re-read ~/.stratus/skills and serve it, no restart — the skills half of /catalog/tools as the response. A skill that will not load answers 422 skills_reload_refused naming the file, and the previous set keeps serving |
| GET | /intake | Whether the daemon is taking new work: { paused: false }, or { paused: true, message?, since } |
| PUT | /intake | Pause or resume: { paused: true, message? } or { paused: false }. While paused, turns already running or queued finish; a new message from any channel is answered with message (or a default sentence) and runs no turn; POST /sessions/:id/messages answers 503 intake_paused; due recurring schedules are skipped to their next slot and one-time ones wait. Channels stay connected and the daemon stays up. The pause is written to ~/.stratus/intake.json and survives a restart until resumed. message is at most 500 characters. 400 invalid_body otherwise |
| POST | /promote | End a held start (stratus serve --held): connect the remaining channels, start the scheduler, run the recovery sweeps → 200 { held: false }. A no-op on a daemon that is not held. 409 not_promotable when a required channel fails to start, and the daemon stays held. /health says "held": true until then |
| POST | /restart | Announce a restart: { reason?, drainTimeoutMs? } → 202 { restarting, reason?, drainTimeoutMs, inflight }. New turns are refused from here, in-flight ones get the window, and the daemon comes back. 501 restart_unsupported from a host that cannot bring it back; 409 not_restartable while the daemon is still starting or already stopping, or when its host refuses before anything is announced — stratus serve does while the trusted config will not load, since the replacement would refuse to start on it |
| GET | /sessions?agent=&limit= | Durable sessions, newest first. limit bounds the result — the table grows for the life of an install |
| GET | /sessions/:id | One session, provider replay state stripped — including usage, the token records of every provider call it has made |
| POST | /sessions/:id/messages | Dispatch a message; returns 202 { sessionId, turnId }. A schedule:-prefixed id answers 400 session_id_reserved — those belong to scheduled firings. An optional metadata object is attached to a new session as given, except the keys the daemon writes for itself (pendingApproval, fallbackActive, delegatedBy, rootSessionId, delegationDepth, scheduled, scheduleId, sessionTrust, sessionTaintedBy, rolledOverFrom, rolledOverTo, executor, contextFloor), which answer 400 metadata_reserved. senderTrust may be set to unknown to say the message is from someone the operator has not vouched for; it is read for the turn and never stored. Omitted, the sender is the operator; present with any value but a trust label, the sender reads unknown — a misspelled authorization is not one. An existing session whose agent has since left the roster answers 404 agent_not_found |
| POST | /sessions/:id/rollover | Start the conversation over under the same id: the transcript so far is saved as a new session (<id>:rolledover:<time>-<suffix>) and the live row is emptied, keeping only the routing metadata a channel needs → 200 { sessionId, archivedAs }. The remedy for a session from before trust labels existed, which reads unknown for as long as it lasts. 409 session_busy while a turn is running or parked, 409 session_archived for an archive, 404 for an unknown id |
| GET | /approvals | Calls parked on a human right now |
| POST | /approvals | Resolve one: { requestId, answer, actor? }, where answer is once, always, or deny — see below |
| GET | /schedules | Every schedule the fleet has set — cadence, prompt, pre-authorized destination, next firing. The audit list: each row with a destination is a standing permission to speak |
| DELETE | /schedules/:id | Cancel a schedule. Also revokes the destination grant riding on the row — a still-running firing's next send is gated normally. 404 when no such schedule exists |
| GET | /catalog/models | Models the stored sign-ins can actually reach, listed live |
| GET | /catalog/tools | Every registered tool with the risk a call will face, every skill a soul's skills: can name, the plugins that contributed them (with the providers, channels, memory stores, and executors each registered), and providers — every name a soul's provider: can select on this daemon, built-ins and plugin-registered alike |
| GET | /credentials | Which sign-ins exist — presence and endpoint, never a value — channels: which agents have transport secrets stored on each channel kind, ids only, and named: { shared: [name…], agents: { id: [name…] } }, names only |
| POST | /credentials/verify | Live-check a key before storing it: { provider, key, type?, baseUrl? } |
| PUT | /credentials/:provider | Store an api_key, or an oauth_token for Anthropic (a Claude setup token) or Codex (a marker that the machine's codex login sign-in serves runs — the value is never read). A codex key refuses a baseUrl: the harness owns its endpoints, so a bound key could never be honored there |
| PUT | /credentials/channels/:channel | Store one agent's transport secrets for a channel kind, under channels.<kind>.<agentId>. slack takes { agentId, appToken, botToken }; any other kind — one a channel plugin declares — takes { agentId, secrets: { name: value, … } }, the names its README documents. A kind that is not a contribution name answers 400 unknown_channel; an agent not on the roster 404 agent_not_found. Saving one kind never disturbs another's |
| POST | /credentials/named | Add a named credential — the search.apiKey kind an agent resolves through its soul's credentials: list: { name, value, agentId? }, the fleet's shared entry without agentId, that agent's own with it. Add-only: a name already stored answers 409 credential_exists, and so does an agent's own over a shared one of that name, since the agent's entry is read first, and so does a name the daemon's environment already supplies, since a stored one would replace it. A name outside the credential-name rule answers 400 invalid_credential_name; an agentId not on the roster 404 agent_not_found |
| GET | /credential-links/:token | The form behind a credential link, as an HTML page. No bearer or cookie: the token is the credential. Showing it spends nothing. 404 once the link is spent or expired |
| POST | /credential-links/:token | Answer it: an application/x-www-form-urlencoded body with value, answered as an HTML page. 200 added, 400 a refusal a corrected value can clear (the form again, reason beside the field), 409 a final one (the link is spent), 404 spent or expired |
| GET/PUT | /config | Settings, whitelisted to keys this API owns |
Named credentials can be added here, never replaced or removed. This API
is reachable from away from the machine, and replacing a shared key would
move every agent that uses it onto whatever account the new value belongs
to. Replacing and removing stay with stratus credential set and
stratus credential remove, which the 409 names. Storing one grants no
agent anything: a soul still has to list the name under credentials:.
POST /credentials/verify reports ok, rejected, or unreachable, and
only an explicit 401/403 is rejected — a compatible endpoint with no models
route says nothing about the key. Pass type with it: a oauth_token cannot
call a models endpoint at all — a Claude subscription token, or a codex
ChatGPT sign-in that has no key to check — so it answers unreachable
rather than condemning a credential that works perfectly well once saved. A
codex api_key is an OpenAI platform key and is checked against the
platform's models endpoint. Provider names on these routes are anthropic,
openai, and codex.
PUT /config writes the principals block along with the other settings —
who counts as the operator on each channel is a trusted-config setting, and
the only file this endpoint writes is a trusted one — so the GET-modify-PUT
round trip keeps it. The same goes for vision, the boolean that tells a
text-only OpenAI-compatible model to take images as a note: GET returns
it, so PUT takes it back, and for language, the fleet's writing
language, which the loader refuses with a 400 unless it is a language tag. PUT /config does not write the plugins block, nor the executor and
memoryStore selections. GET returns them, and a PUT carrying them back
is accepted (the round trip has to work) but the values are ignored and the
file's existing ones are preserved rather than deleted by the replace.
Enabling a plugin runs somebody else's code inside the daemon — that is the
boundary the whole trust model rests on — and choosing which of that code
an agent's commands run in, or where its memories are written, is the same
boundary one step on; all three stay a deliberate edit to a file rather
than a settings save.
Reload and restart
Three things change under a running daemon without a restart, and the API
is how each takes effect: a soul (re-read before every turn; a file added
or deleted needs POST /roster/reload), a skill (POST /skills/reload),
and an MCP server's tools (discovered on reconnect). A skills reload is a
whole-catalog swap: it rebuilds what a restart would produce — operator
skills first, then each loaded plugin's, in load order, so an operator's
bare id still outranks a plugin's alias and a contested bare id stays
contested — and a file that will not load refuses the whole reload with
422 rather than serving half a catalog. Loaded is not enabled: a reloaded
skill reaches only the agents whose souls list it.
A plugin is code, and code gets a restart — POST /restart, which answers
202 at once and then refuses new turns (a POST /sessions/:id/messages
during the drain fails with "stratusd is restarting and will accept new work
once it is back up"), lets in-flight turns finish for drainTimeoutMs
(default 30 000), aborts what is still running at the end of it (the session
is saved as failed with Run aborted: stratusd is restarting), stops, and
comes back — this API's connections and event streams included, so a client
reconnects, and a dashboard session still authenticates: the stopping daemon
hands its live sessions to the replacement in memory, over the channel
between the two processes, and never writes them down (a crash or a plain
stop still signs the browser out). ~/.stratus/gateway.json is rewritten by
the new process. What
needs a restart and what does not is tabled in
Always on.
PUT /config replaces the file rather than merging into it — GET hands
you the whole document and PUT takes the whole document back, so a partial
body silently drops the keys it omits. Every accepted key is type-checked
first: the config loader ignores values of the wrong shape, so writing one
would leave the file, the response, and the running daemon disagreeing about
what was just saved.
It edits the config the operator chose — the file named by --config or
STRATUS_CONFIG, or the global ~/.stratus/config.json. Never an
auto-discovered project-local stratus.config.json: that file ships in a
repository, and writing settings into somebody's checkout because the daemon
started there would surprise everyone (its api and approvals blocks are
ignored for the same reason).
| WS | /events | The live event stream |
GET /catalog/tools answers three questions, not one
{
"tools": [
{ "name": "demo.echo", "risk": "safe" },
{ "name": "skill.read", "risk": "safe" },
{ "name": "fs.read", "risk": "safe", "package": "@stratusagent/tool-fs", "trusted": true },
{ "name": "notes.read", "risk": "gated", "package": "stratus-plugin-notes", "trusted": false }
],
"skills": [
{ "id": "stratus", "name": "stratus", "builtin": true,
"description": "Use whenever someone asks how Stratus itself works or how to set something up in it, …",
"path": "…/@stratusagent/agents/skills/stratus/SKILL.md" },
{ "id": "code-review", "name": "code-review",
"description": "Use when reviewing a diff or a pull request.",
"path": "/home/me/.stratus/skills/code-review/SKILL.md" },
{ "id": "stratus-plugin-github:pr-review", "name": "pr-review", "alias": "pr-review",
"description": "Use for GitHub pull requests.",
"package": "stratus-plugin-github", "path": "…/skills/pr-review/SKILL.md" }
],
"plugins": [
{ "package": "@stratusagent/tool-fs", "name": "@stratusagent/tool-fs", "trusted": true,
"tools": [{ "name": "fs.read", "risk": "safe", "package": "@stratusagent/tool-fs", "trusted": true }],
"skills": [] },
{ "package": "stratus-plugin-typo", "error": "Cannot find package 'stratus-plugin-typo'" }
]
}All three halves, because any alone misleads. The tools say what an agent
can be granted and at what risk; the skills say which procedures a soul's
skills: can name (and which one, marked builtin, every agent has
without naming it); the plugins say what this daemon was asked to load —
including one that failed, which is invisible in the other two and is usually
why somebody opened the screen.
A tool with no package is the kernel's, which is the honest answer for it
rather than an omission. risk is read from the live registry, so it is the
risk a call will actually face: a third-party package cannot declare its own
tool safe, and this reports the floored value rather than the manifest's
claim. A tool bridged from an MCP server shows the same way — package:
"@stratusagent/plugin-mcp", and the mcp.<server>. prefix names whose
server it is — including one discovered after startup, on a reconnect: the
list is derived per read from the plugins' live records, never a load-time
snapshot.
A skill's id is the canonical form an allowlist names — bare for one the
operator installed under ~/.stratus/skills/, qualified
<package>:<skill> for a plugin's. alias is the bare id a plugin's skill
also answers to, present only while no other package or operator skill claims
it. Skills are descriptors only — id, name, description, provenance;
bodies never leave the daemon except through skill.read, on the agent's own
turn and under the soul's allowlist.
Nothing here says which agent may call what — that is the soul's allowlist,
and it is per identity. GET /agents/:id carries it.
runsOn is absent when the daemon cannot say
Each agent in GET /agents and GET /health carries runsOn — the provider
and model a turn as that agent would actually resolve to, normalized through
the same soul-pin rules dispatch applies.
It is omitted rather than defaulted when the agent's soul file cannot be
read. The gateway keeps dispatching from a cached soul when its file is
deleted or momentarily unparseable, and that soul may pin a provider, so the
daemon is still billing somewhere it can no longer name. A default of demo
there would be a false statement about money, made exactly when someone is
looking to find out what is running.
The listing summarises; the single read does not
GET /agents carries persona: the agent's first instruction line, trimmed
to fit a table row. GET /agents/:id carries agent.instructions in full,
plus soul — the file's own bytes, which is what PUT /agents/:id accepts
back as its soul field.
The distinction matters to anything that edits. An editor seeded from the
roster's persona and saved would write that snippet back as the whole
persona, permanently truncating the agent to a fragment of its first sentence
the first time someone changed its name. Read the agent before editing it.
Activity, for a roster that shows who is busy
Each entry in GET /agents carries activeSessions (turns running or parked
on a human right now) and, once the agent has done anything, lastActiveAt.
Both, because neither is sufficient. A timestamp alone reads a turn parked on an approval as idle — the save that recorded the park is the last thing that touched the row, and a turn waiting twenty minutes on a person is exactly when you want the agent lit. A count alone loses an agent that finished a moment ago.
What counts as "recently active" is the client's decision, so this reports a timestamp and a count and never a verdict. A daemon that baked a window in would need upgrading to change it.
The client mints the session id
There is no POST /sessions. A conversation begins the first time
POST /sessions/:id/messages is called with an id the store does not have,
and that call must carry agentId — there is no stored agent to recover one
from, so it answers 400 agent_required without it. Every later message to
the same id resumes that conversation; passing an agentId that disagrees
with the stored one answers 409 session_agent_mismatch, because sessions
never cross agent identities. The stored agent is held to the roster on
every message too: a session whose agent's soul has since been removed
answers 404 agent_not_found, naming the agent, rather than accepting a
turn that can only fail on the event stream.
Mint the id the way the dashboard does — a UUID the client generates — and keep it for the life of the conversation. The daemon does not hand one out, so a client that waits for the server to name a session waits forever.
The consequence to design around: an id the daemon has never seen is a new
conversation, not a 404. A mistyped id gets 202 and a durable conversation
under that name, because there is nothing to distinguish it from a client
opening its second chat. Unlike agentId, which is checked against the roster
and answers 404 agent_not_found on a typo, a session id names something that
does not exist yet.
What is checked, on a new id only, is that it could be an address at all:
400 invalid_session_id for an empty id, one that is not its own trimmed
self, a leading dot, a path separator or control character, and the strings
JavaScript prints when an id was never computed — undefined, null, NaN,
[object Object]. The dashboard shipped the first of those, posting to
/sessions/undefined/messages and creating a durable conversation literally
named undefined.
Length is bounded too, at 200 characters on top of the agent id the session
id contains. Budgeted that way rather than flat because every convention
above embeds the agent id and agent ids have no length bound of their own — a
flat cap would cap them through the back door, leaving a long-id agent on the
roster and unable to hold a conversation. Two things keep the allowance from
becoming a loophole: it is measured against an agentId already checked
against the roster, and it applies only to an id that actually contains that
agent id. A bare UUID, or any id that does not embed it, is held to the flat
200 whichever agent the request names.
Shape beyond that is deliberately not enforced: the ids in circulation are
colon-joined addresses (web:<agentId>:<uuid>,
<channel>:<agentId>:<team>:<conversation>:<thread>, a bare UUID), and no
pattern admitting all of them would have excluded undefined anyway. An id
already in the store is never re-judged — it addresses a real conversation
whatever shape it is, and a rule written afterwards does not get to lock its
owner out of their own history.
Usage is a set of records, never a total
GET /sessions/:id carries usage: one record per provider call, in the
order the calls completed.
{
"usage": [
{ "turnId": "s-42:turn:1", "provider": "anthropic", "model": "claude-opus-5-5",
"inputTokens": 40, "outputTokens": 210, "cacheReadTokens": 9100, "cacheWriteTokens": 300 },
{ "turnId": "s-42:turn:2", "provider": "openai", "model": "gpt-5.5",
"inputTokens": 12, "outputTokens": 88 }
]
}Tokens, not money. Turning these into a cost is a projection against a price table that is per-model, per-vendor, and per-contract — one living here would be wrong here, silently, and stale within a quarter. What this surface owes a projection is the attribution to do the conversion correctly, which is why nothing is summed away: a thousand tokens of one model is not a thousand of another, the four buckets are priced differently from each other, and a session that fell back mid-run crosses providers. Sum the records if you want a total; the total is a view, and this is the stored form.
The four counts are disjoint: inputTokens is prompt input billed at the
full rate, with cache reads and cache writes counted in their own fields and
never again in it. Adapters whose vendor reports an all-inclusive prompt count
normalize to this shape, so records from two providers are comparable.
A turn that failed still keeps the tokens it spent. A record is written
for every provider call that reported a count, whether or not the turn went
on to complete: a paid call that produced nothing usable, a primary attempt
that failed over to the fallback, and a stream cut before it finished — by
the idle watchdog, a cancelled turn, or a dropped connection. That last kind
always carries the input side (inputTokens and the cache buckets, which the
API announces when the stream opens); it carries outputTokens only when the
stream reached the final message_delta frame that reports it, and omits
the field otherwise — a stream cut earlier generated output nothing counted.
An absent count means the provider reported none — not zero. A local
OpenAI-compatible server that omits usage produces a session with no
records at all, and a turn that cost real money would look free if a consumer
read that absence as a measurement. usage itself is absent until something
reports.
turnId is the Stratus turn the tokens belong to — one pass through the
runner, which is one provider call for the API providers and several for a
harness provider running its own inner loop. It is on the record rather than
reconstructed from ordering, because ordering cannot recover the boundaries
once one turn contributes several records. It is not the turnId on an
event envelope, which is assigned per dispatch by this API.
Records are durable session state, so they survive a restart and a resumed session adds to what it already had. A failed run keeps the tokens it spent before it failed.
What each provider can tell you differs, and the records say so rather than
smoothing it over. The two API providers report one record per turn, with
the model the API says served it. The two harness providers own their own
inner loops, so one turn produces several records: claude-code reports one
per model the loop touched — a sub-agent on a cheaper model shows up as its
own row — and codex reports one per completed codex turn against the
configured model, because codex publishes no per-model breakdown. A codex
turn that fails reports nothing at all: its failure event carries no counts,
which is a limit of that harness rather than a claim that the turn was free.
Subscription-billed providers report tokens too, even though the operator is not billed per token for them. That is deliberate: it is how you compare what a run would cost across providers, and a session that fell back mid-run makes that a live question rather than a hypothetical.
always means one thing, and the request says which
POST /approvals takes answer as exactly once, always, or deny;
anything else is 400 invalid_answer. Who decided is recorded on the
resolution event and, for a standing grant, as grantedBy in the grants
listing. A Slack click records the clicker's user id; a decision through this
endpoint records how the caller authenticated — api for a bearer token,
dashboard for a browser session — with the optional actor from the body
appended after a colon (api:ops-bot). The body never sets the recorded
actor bare, so a request cannot spell a Slack approver's id and read as that
approver's decision. A request that has already been decided, has expired,
or whose turn was cancelled answers 409 approval_not_pending rather than
silently doing nothing twice.
once and deny mean what they say, for this call. always is a grant to
the agent, and it lasts until an operator revokes it — for every tool but
one shape, described last. What it grants depends on how the tool is judged,
and the request says so in advance: tool.approval-requested and the
GET /approvals rows carry always with one of four values, or omit it
when always would remember nothing.
scope— a tool whose call carries a command (shell.run) remembers a command scope, durable and per agent. Approvinggit push origin mainpersistsgit pushminus its destructive forms, sogit push --forcestill asks.origin— a tool judged by an origin (browser.act) remembers that origin, durable and per agent, in the same file. Approving a click onhttps://app.example.com/reportspersistshttps://app.example.com, sohttps://admin.example.comstill asks. The origin rides asoriginon the event and the rows, and a client offering Always allow must render it: the call's arguments are a CSS selector and say nothing about which site is being widened.tool— every other gated tool that names no scope remembers the tool itself: a standing grant, durable and per agent, recorded with the package that contributed the tool and with who answered. It is consulted on every later run,headlessincluded — the only path such a tool has to running unattended — and it follows the tool as it was when granted: the same name contributed by a different package after a plugin update asks again.GET /agents/:id/grantslists it andPOST /agents/:id/grants/revokeremoves it, and a revoke is the next call's answer, with no restart. Never for a tool withcommandForororiginFor— a standing yes toshell.runwould be a yes to every command — and never for adangeroustool.session— the one shape that is not durable: a tool judged by a destination (message.send) outside a schedule, remembered against the tool name for this session and this process only. A durable grant there would be a standing yes to every destination, and no per-destination grant exists yet; the schedule carve-out is how a send runs unattended.
For a one-shot request always is absent and oneShot: true is
present: the call runs once and the next one asks again. Four cases reach
this — a dangerous tool, which means a human every time whatever is
answered; a call judged by an origin whose conversation has no page to grant;
a command this daemon's parser cannot reduce to a scope, such as a pipe or
a subshell; and any call from a conversation that has read external content,
for an agent whose approvals.externalContent is gate (see
Approvals). A client must not offer an unconditional "always" on such a
request — it does exactly what once does, under a label promising a grant
nobody gets. The Slack channel drops the button and says why. POST
/approvals still accepts always on such a request — the endpoint takes
all three answers whatever was rendered — and the engine treats it as once,
which is also how the resolved message describes it.
So a client can word the button and the outcome from the request alone:
always: 'tool' is "always allow for Blair, until revoked", always: 'session'
is "for the rest of this session", and the two scoped values name what they
widen. One thing the request cannot promise is the disk write: the daemon
does not write over a whitelist it could not read, and a policy can be built
without one, in which case the grant holds for the process and the daemon's
log says it was not saved. The tool.approval-resolved event carries the
answer that was submitted plus a reason of decided, timeout,
cancelled, or undeliverable — why the request stopped being pending, not
how long the grant lasts; the request already said that.
Two invariants worth stating
- Channel tokens have their own door. Slack app and bot tokens are
gateway infrastructure secrets, not agent capabilities. Only
PUT /credentials/channels/:channelwrites them; the provider-credential and config endpoints cannot reach that namespace. - No endpoint returns a secret. Credential reads report presence, type, and bound endpoint; named credentials, their names. Session reads strip the Anthropic raw-turn cache, which exists for replay and carries raw model turns.
Health does not probe
GET /health reports what resolution already knows: uptime, the roster, each
agent's resolved provider and model, session counts by status, pending
approvals, and the distinct runtimes the daemon would serve with how each got
its credentials. It makes no network call — a monitoring view polls this,
and a live call per poll would spend the operator's rate limit to say
something resolution can answer for free.
Both the roster and the runtimes come from the roster the gateway is
serving, with each soul re-read the way refreshAgent re-reads it before
every dispatch — so a pin edited on disk is reported by the same poll that the
next turn will bill it on. It is the roster, not the agents directory.
The two ways a re-read can fail are not the same answer. An unreadable
file keeps its cached pins, because the gateway goes on dispatching from the
soul it loaded. A file that now declares a different agent id contributes
no runtime at all: refreshAgent refuses every dispatch for the old id until
the roster reloads, so there is nothing being served under it to report. The two diverge in ordinary use: a
soul dropped on disk is not dispatchable until a reload, and a soul deleted or
left momentarily unparseable is still dispatched from the copy the gateway
loaded. Reading the directory would name a provider and model for turns the
daemon refuses to run, and omit the pins it is really billing — in the one
endpoint whose job is to say what the daemon is doing right now. POST
/roster/reload is what closes the gap.
Credential links
credential.request falls back to a link where the agent's conversation
cannot show a form, and gives one whenever the agent asks with
via: "link". The link needs the agent's soul to list credential.request under
tools: (or have no tools: key); the daemon grants the form to every
agent, but not the link. This API issues it (the adapter's requestCredentialLink):
a 256-bit token, kept in memory, minted for one pending request, and built
on api.publicUrl, or on the bound address when that is unset.
- The token is the whole credential. The operator chose a link that
works by itself over one that also asks for a dashboard session, so the
person asked can open it on whatever device they have. It answers one
request, once, through the gateway's
provideCredential: add-only, granted only to the agent that asked, the value going to the credential store and nowhere else. The submission's actor is recorded aslink. - Spent on a final answer, never on a look. A
GETrenders the form and changes nothing, so a chat app unfurling the link does not use it up. A submission that adds the key, or is refused for good (retired: the name was stored since, the agent's soul moved), spends it. An empty value does not. - Dies on its own. 30 minutes after it was minted, or at a restart.
- The page keeps its address to itself. No script, nothing loaded from
elsewhere,
Referrer-Policy: no-referrer,Cache-Control: no-store, and a CSP ofdefault-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'.
The full flow is in Remote access.
The event stream
WS /api/v1/events, filterable at connect (?session=, ?agent=) or with a
frame:
{ "type": "subscribe", "sessionId": "…", "agentId": "…" }Frames are envelopes:
{ "sessionId": "…", "turnId": "…", "event": { "type": "provider.delta", … } }The event is the existing StratusEvent union, unchanged — no new
vocabulary. (session.tainted joined that union with provenance: it carries
the session's new trust label and the name of what lowered it, never the
content. session.observed joined it with overhearing: a message entered
the session with no turn run on it — its own event rather than a
session.updated, because a client that takes running as "a reply is
coming" would wait on a turn that never speaks. Session and agent ids only.
session.context-trimmed joined it with context management: the
conversation outgrew the model's window, so its oldest messages are no
longer sent — droppedMessages is what this trim gave up and floor is
the total now held back. Counts only, and worth surfacing rather than
swallowing: nothing else tells a client that the agent it is showing has
stopped being able to see the start of the thread. It can arrive more than
once in a turn, since the window is narrowed by halving until the request
fits, and it is not replaced by a model switch — a configured fallback
rethrows an overflow so the conversation is trimmed rather than moved to
another model for good. provider.delta gained a progress delta beside
thinking: content-free, saying only that the provider is working — a
retry, a compaction, a message boundary — so a client draws nothing for it.)
The turn id lives on the envelope because StratusEvent
carries none and should not grow one: a session processes several messages in
sequence, and without this a client that queued one has no way to tell its own
deltas from the next caller's. The id is assigned at dispatch and returned by
POST /sessions/:id/messages.
session.completed carries the session's usage records in the same shape
GET /sessions/:id returns — the whole set, not just this run's, because that
is the stored form and a resumed session's earlier turns are just as real. It
is absent when nothing reported, and a client must not read that as zero. A
run that fails emits session.failed without them; its records are still
saved on the session, so read them back rather than treating a failure as
free.
Deltas are dropped for a client whose socket has backed up past 1 MB, and only
deltas: losing a token from a reply is a cosmetic gap, while losing a
completion, a failure, or an approval leaves a UI stuck on a turn that already
ended. A { "type": "dropped", "deltas": n } frame says when it happened.
The same 1 MB bounds what a client may send: a request body past it is
413 body_too_large, and a WebSocket frame past it closes the socket with
code 1009. A subscribe frame is a few hundred bytes; nothing a client has a
reason to send comes near either.
Configuration
{
"api": {
"enabled": true, // false, or `stratus serve --no-api`, to turn it off
"host": "127.0.0.1",
"port": 4123,
"publicUrl": "https://mac-mini.example.ts.net" // optional: what credential links are built on
}
}The bearer token is ~/.stratus/gateway-token (0600), generated the first
time the daemon starts. A deployment that wants to choose it sets
STRATUS_GATEWAY_TOKEN_FILE to a file holding the token, and the daemon
uses that instead, copying it into ~/.stratus/gateway-token for local
clients. A missing or malformed file refuses the start. (That variable is
the daemon's; STRATUS_GATEWAY_TOKEN is a client's way to name the token
of a gateway it talks to, and the daemon never reads it.)
While it is serving, ~/.stratus/gateway.json (0600) says where:
{ "url": "http://127.0.0.1:4123", "host": "127.0.0.1", "port": 4123, "pid": 4242, "version": "0.7.0" }Clients read it instead of guessing at a default the operator may have
changed. It is removed on a clean stop, and only by the process that wrote it.
A second daemon on the same home is refused by the home's lock
(~/.stratus/stratusd.lock, held by the gateway's claimHome), not by this
file — it exists only while the API is bound, which is inside the window that
matters; stratus serve reads it to name the holder in its refusal.
The API is a required channel. A port it cannot bind fails the gateway's start — after the channels already up are stopped, and before the scheduler starts or any parked approval is re-asked — rather than being logged past while the daemon serves on unreachable.
