API Reference
The Scion ecosystem exposes several APIs for coordination, management, and observability. This reference provides an overview of the primary resource types and communication patterns.
Hub API
Section titled “Hub API”The Scion Hub provides a RESTful API (mostly JSON) for managing the state of the system.
Authentication
Section titled “Authentication”Most endpoints require a Bearer token in the Authorization header.
- User Tokens: Obtained via OAuth or Dev Auth.
- Agent Tokens: Issued to agents at startup for state reporting.
- Broker Tokens: Used for broker-to-hub communication, often combined with HMAC request signing.
Pagination
Section titled “Pagination”List endpoints for templates, harness configs, groups, artifacts, starred messages, and skills (when called with a user access token) return an opaque nextCursor. Cursors are encrypted and bound to the endpoint, the filter and the caller that received them, so pass them back unchanged to the same endpoint with the same filter. A cursor that is malformed, tampered with, reused in a different context, or sealed with a key the Hub no longer holds (for example after a key rotation) is rejected with a uniform 400 Bad Request and error code invalid_cursor; restart the listing from the first page. With a server keyring (server.keyring_file), a cursor stays valid only while the signing generation that sealed it is before its verify_until. A cursor has no lifetime of its own, so retiring a generation ends every cursor it sealed, and the next request gets invalid_cursor. Every other paginated list endpoint answers a cursor or pageToken it cannot use (malformed, issued for another list, or naming an entry that no longer exists) the same way: 400 with error code invalid_cursor and the message invalid cursor: restart pagination from the first page.
Core Resources
Section titled “Core Resources”Agents (/api/v1/agents)
Section titled “Agents (/api/v1/agents)”-
GET /: List agents (filterable by project, user, phase). Addsort=createdorsort=updatedfor a server-sorted, keyset-paged view with the samedir,limit(clamped to 500),cursor,fitandstatsparameters as the project agent list’s sorted mode below. Unlike the project endpoint, the global endpoint has no 2,000-candidate ceiling and never refuses a sorted request with422 sorted_view_unavailable. For a user caller above 2,000 candidate agents,totalCountis a lower bound and the response setstotalCountApproximate, a page can be shorter thanlimit(follownextCursor), and withstats=1thestats.totalandstats.runningcounts are lower bounds (stats.totalApproximate) andstats.agentsis omitted. For an agent caller,stats.agentsis omitted whenstats.totalexceeds 2,000. Withoutsortthe response is unchanged. Addview=compactfor slimmer items: each agent carries only its identity, template, project, status, labels, lineage, message mode, creator name,harnessVendor,modelSize(the canonical model size aliassmall,medium,large, orextra-largewhen the agent’s model came from a size alias, otherwise omitted, or""on status events;harnessVendoris the harness’s model vendor, omitted for a custom or unresolved harness; both fields are also included inview=full, onGET /:id, and on agent created and status SSE events) and timestamps, plus_capabilitiesand_messageability, and noappliedConfig. The result has the same agents in the same order, with the same cursors, counts and other response fields as the defaultview=full. An emptyviewis the same as noview. Withsort, a non-emptyviewother thanfullorcompactis rejected with400; withoutsort, any value other thancompactis ignored. -
POST /: Dispatch a new agent. Also available asPOST /api/v1/projects/:id/agents. A create that is dispatched to the Runtime Broker synchronously (includingprovisionOnly) answers409with codedelete_in_progresswhen the agent is deleted, or a delete holds it, before the create completes. This includes a create that the Runtime Broker answers with202to gather environment variables, a workspace-bootstrap create (which then returns no upload URLs), and a managed-agent create. The calls that finish a create,POST /:id/workspace/sync-to/finalizeandPOST /api/v1/projects/:projectId/agents/:agentId/env, answer the same way when a delete wins after the Runtime Broker started the agent. The body carries no agent:error.details.agentIdnames it, anderror.details.warnings, when present, may include other dispatch warnings, such as whether a container the create had already started was removed, or whether a managed-agent interaction it had started was stopped. Treat the agent as not created. If a delete that was still in progress later fails, the agent remains, and you can delete it again. A managed-agent create whose agent the Hub cannot record after the managed-agent interaction started answers500with codeinternal_errorinstead of201. The Hub stops the interaction and removes the agent.error.details.agentIdnames the agent, anderror.details.warningsreports whether the interaction was stopped. When the stop failed, the warning names the agent and the interaction so that an administrator can stop it by hand. If removing the agent did not complete,error.details.correlation_idis set. Treat the agent as not created; you can create it again with the same name. A create accepted for asynchronous launch still answers201. When the synchronous dispatch to the Runtime Broker ends without an answer (the Hub’s 120-second dispatch timeout, or no response to a request it sent), the create, and the env submitPOST /api/v1/projects/:projectId/agents/:agentId/env, answer504with codestart_outcome_unknown.error.details.agentIdanderror.details.phasedescribe the agent. The Hub keeps the agent rather than removing it, because the Runtime Broker may still be starting it. The agent’smessagesays so. The agent moves torunningif the start completes. If the Runtime Broker shows nothing running, the Hub revokes the agent’s credential and stops waiting for the start; an agent still in thecreatedphase also returns to rest. You can delete the agent at any time.- A create in a shared-workspace git project sent to a remote Runtime Broker that has no local path for the project, and that does not report the Hub’s shared workspace storage (nfs), fails before dispatch with
409: the Hub’s workspace is on storage that Runtime Broker cannot reach. On a Hub with shared workspace storage the same applies to a project with no git remote. On a Hub with local workspace storage, a linked project with no git remote (one registered from an existing checkout) runs on such a Runtime Broker with the Runtime Broker’s own copy of the workspace, which the CLI workspace upload andscion sync tofill; the Hub’s Files tab answers409for such a project. A project created on the Hub with no git remote is refused with409like a shared-workspace project. Clone-per-agent and empty-per-agent projects are unaffected. - Creating an agent on a flat Runtime Broker (
scion.io/broker-role=flat) with aprofilefails before anything is written with422 runtime_profile_unsupported, regardless of the profile name.
- A create in a shared-workspace git project sent to a remote Runtime Broker that has no local path for the project, and that does not report the Hub’s shared workspace storage (nfs), fails before dispatch with
-
POST /stop-all: Stop every running agent across all projects. Requires a user identity holdingagent.stop_allon the hub; a user without it gets403. For the project-scoped variant, seePOST /api/v1/projects/:id/agents/stop-allbelow. -
GET /:id: Get detailed agent state (phase, activity, detail). An agent can always read its own record with its agent token. An agent can also read an agent it launched, directly or through its descendants, in the same project (for example, to wait for a child’s launch). This covers only this single-agent read: a descendant’s logs, message logs, event streams and workspace stay closed to it. For an agent caller, an agent it may not read answers404, the same as a missing agent. For any caller withoutagent.attachon the agent, and for every agent delegated credential (onGET /:id,GET /,GET /api/v1/projects/:id/agents, and mutation responses),appliedConfig.env,appliedConfig.inlineConfig.env,appliedConfig.inlineConfig.telemetry.cloud.headers, everyappliedConfig.inlineConfig.mcp_servers[*].envand.headers, everyappliedConfig.inlineConfig.services[*].env, and the same maps underappliedConfig.createInputs.inlineConfig,pendingConfig.configandeffectiveNextRunare left out of the response; the MCP server and service entries themselves stay listed. The response carrieseditability:phase, andfields, which maps every agent form field and everyconfigkey to its entry for this agent and caller. Fields are keyed by theirPATCHkey:config.<key>for a key ofconfig(for exampleconfig.model), else the top-level key (name,labels,agentRole,gcp_identity,explicitTimezone,template, …). Each entry hastier, what re-applies an edit:T0immediate metadata (name, labels, annotations, task summary, message mode),T1container creation (applied by any start, restart, resume or reincarnation: model, thinking level, harness auth, image, user, limits, resources, env, telemetry, MCP servers, volumes, kubernetes, command args, task, timezone),T2provision-rendered (applied by a reincarnation: system prompt, agent instructions, skills, services, secrets; when the agent’s Runtime Broker advertiseschangedFields, all but secrets are also applied at the next start, and theirdispositionisnowin the phases with no container),T3principal (applied only by an explicit reincarnation: role, GCP identity), orTXfixed (project, template, harness config, Runtime Broker, profile, branch and workspace).dispositionis what happens to an edit in the agent’s phase:immediate,now(applies at the next start or resume),held,reincarnate(applies at the next reincarnation), orlocked, withreasonsaying why.sessionSensitivemarks a field (model, thinking level, harness auth) whose change a resume applies while the harness continues its conversation, andnoteis"session"on such a field of a suspended agent.clearNeedsReincarnatemarks a field whose clear or zero value applies only at the next reincarnation; it is not set onmax_duration, thinking level, resources and env when the agent’s Runtime Broker advertisesclearedFields. It stays set onmax_turnsandmax_model_calls, whose0(Unlimited) no start applies. WithchangedFields, it is set onagent_instructions,skillsandservices: a start does not apply a clear of those. It is set onsystem_promptunless the Runtime Broker also advertisesclearedFields. For a suspended agent,noteis also"session"on the four re-rendered keys. Unless thehub.agent_edit_heldexperiment is on, editing a running or transitional agent’sconfigis refused, so everyconfig.<key>of such an agent islocked, provision-rendered keys included;agentRoleandgcp_identitystayreincarnate. With the experiment on, a container key of such an agent isheld(saved now, applied at the next start, restart or resume) and a provision-rendered key isheldwhen the agent’s Runtime Broker advertiseschangedFields(system_prompt,agent_instructions,skillsandservices, applied at the next start), and otherwisereincarnate(saved topendingConfig, which a reincarnation does not apply yet; seePATCH /:id);secretsis alwaysreincarnate.agentRoleislockedincreatedand for a caller who cannot delegate the agent’s role or whose credential’s scopes do not cover it (it changes throughreincarnate); every field islockedfor a caller withoutagent.updateand for a deleted agent. An agent placed on a flat Runtime Broker (one that serves a single runtime target) also carries a read-onlypinnedRuntimeTargetview,{"id", "type", "runtimeBrokerId"}: the runtime target it is pinned to and the Runtime Broker serving it. It is absent for other agents and cannot be set through the API. The Runtime Broker object (GET /api/v1/runtime-brokers/:id) carries the matchingruntimeTargetdescriptor (id,type,displayName). With thehub.conduitexperiment on, the response also carriessshHostKey: the public SSH host key (anauthorized_keysline) the agent’s current run reported for its embedded SSH server, for pinning inknown_hostswhen you usescion tunnel --ssh. Each run generates a new key; it is omitted when none was reported. When the agent has pending config edits (seePATCH /:id), the response carriespendingConfig:config, the pending keys as the edits sent them (anullvalue clears the key when it is applied;envvalues, the telemetrycloud.headers, every MCP server’senvandheadersand every service’senvare shown only to callers who may attach to the agent, as inappliedConfig),savedBy(user:<id>oragent:<id>),savedByName,savedAt,baseGenerationandbaseStateVersion(the agent’s generation andstateVersionthe last save was made against).?view=editaddseffectiveNextRun: the applied config with every pending key applied, as the agent’s next run would get it (container keys at the next start, and on a Runtime Broker that advertiseschangedFieldsthe re-rendered provision keys too; the other provision-rendered keys are not applied by any action yet), withoutcreateInputs. For a caller who can view the agent’s env (the same check that decides whetherappliedConfig.envis shown), the response also carriesresolvedTimezoneandtimezoneSource, with the same values and meaning as on thePATCH /:idresponse; for other callers both are omitted. The project-scopedGET /api/v1/projects/:id/agents/:agentIdreturns the same body. -
POST /:id/stop: Stop a running agent. The Hub records that the agent should be stopped (its run intent, kept separately from the observed phase) before dispatching. If the agent’s Runtime Broker is offline, the stop is queued instead: the response is202 Accepted, the agent is markedstoppedwith container statusstop_queued, and the Runtime Broker applies the stop when it reconnects, unless a newer start or stop was recorded in the meantime. -
Start, stop, suspend, and restart respond with the agent record itself: its stored fields at the top level, not under an
agentkey, plus an optionalwarningsarray that is omitted when empty. The capability fields thatGET /:idadds (_capabilities,_messageability,resolvedHarness,harnessCapabilities, andcloudLogging) are not included, so fetchGET /:idto refresh them. Suspend never includeswarnings. A202means the action was queued, not performed. For example, a stop queued for an offline Runtime Broker returns a body like this (abbreviated; other agent fields omitted), wheremessageand the warning carry the same text:{"id": "<uuid>","slug": "reviewer","phase": "stopped","containerStatus": "stop_queued","message": "Stop queued: broker offline; it will be applied when the broker reconnects.","warnings": ["Stop queued: broker offline; it will be applied when the broker reconnects."]} -
POST /:id/suspend: Suspend a running agent, preserving its harness session for a later resume. Sets the phase tosuspended. Requires a harness that supports session resume.The stop, suspend, both
stop-allroutes, and workspace sync-from operations run to completion on the Hub even if the client disconnects. Their response write deadlines are extended for the duration of the operation, so a slow stop that succeeds is no longer reported as a502. Sync-from answers502when the Runtime Broker’s reply carries no file manifest. -
POST /:id/start,POST /:id/restart: Start/restart an agent. Starting asuspendedagent resumes (continues) its harness session; starting astoppedorerroragent runs a fresh session. To continue the interrupted session of anerroragent instead, send{"forceResume": true}as thestartbody (best effort).forceResumehas no effect in other phases. When thehub.async_agent_startandhub.async_agent_launchexperiments are both on and every live Hub instance supports it, send{"acceptAsyncLaunch": true}in thestartorrestartbody to have the Hub answer as soon as the Runtime Broker accepts the start:200with the agent in phasestartingand an activelaunch(see Asynchronous agent start). When a delete races the start, the answer is409with codedelete_in_progressand no agent body;error.details.agentIdnames the agent. This happens when:- a delete holds the agent, or wins while the start is in flight. This includes a delete that wins after the Runtime Broker has started the agent; the Hub then tries to remove that container again, and
error.details.warningsreports the outcome. - a delete claims the agent after the start but before the Hub’s final read of the agent, so the start does not answer
200for an agent whose delete has won. This includes an agent on a managed runtime, which the Hub starts without a Runtime Broker: a managed agent that the delete has already removed also answers409, not404. - a start or resume of an existing agent through
POST /meets either of the cases above.
A delete that has failed, or whose lease has expired without finalizing, leaves the agent live, and the start answers
200. A delete that claims the agent after the Hub’s final read is a separate, later operation. Every start, restart, and resume runs under a start claim: while another start of the agent holds it (a user start, a restart, a start for a direct message, creation, automatic recovery, a reincarnation, or a queued stop being applied), the answer is409with codestart_in_progress, anderror.detailscarriesholderKind,state,since, andexpectedRelease. If a restart cannot stop the agent’s current instance, it is aborted without starting a second one and answers503with aRetry-Afterheader (error.details.brokerCodenames the Runtime Broker’s error code when there is one); retry later.Fail closed on unresolved credentials. Create, start and restart answer
503 unavailablewhen the Hub cannot resolve the agent’s configured secrets (agent secrets could not be resolved; the agent was not started) or read its stored environment settings (agent environment settings could not be read; the agent was not started; a create saysthe agent was not created). A create or start sends nothing to the Runtime Broker: a start leaves the agent as it was, and a failed create is rolled back. A restart stops the agent first, so a restart that fails this way leaves the agent stopped. A provision-only create still succeeds; the agent’s later start fails closed. Resuming an agent and waking it for a direct message fail the same way. A reincarnation still answers202, then fails at its start leg: the reincarnation is recorded as failed with the same message (start failed: …) and the previous config is restored. The message names no secret, value or backend detail. An agent with no secrets or settings is not affected. A Runtime Broker whose env-based harness auth check fails rejects the start before any container is created; see Agent Credentials. - a delete holds the agent, or wins while the start is in flight. This includes a delete that wins after the Runtime Broker has started the agent; the Hub then tries to remove that container again, and
-
POST /:id/reincarnate: Migrate the agent to a new generation with the same ID and slug and a freshly resolved config (seescion reincarnate). Body:handoff(optional text for the new generation’s first task, max 256 KiB) anddryRun. The body can also carry the patch fieldsserviceAccount,role,model,thinkingLevel(integer 0–100),harnessAuth, andimage, each of which changes that setting for the new generation and later ones (a user caller also needsagent.update; the plan names them inpatchedand shows each patched field’s old and new value).serviceAccounttakes the account’s id, email or display name, resolved as for create (an email prefers the project’s own account over a hub-wide one; more than one match returns400 identity_ambiguouslisting the candidates’ ids and scopes). A disallowed GCP service account is refused before the agent is stopped. For a legacy agent created beforecreateInputswas recorded, any remaining inlineenvkeys are kept as explicit for the new generation, with a warning inplan.warnings. Returns202 Acceptedwith the pending plan, or200 OKwith the plan only for a dry run. The migration runs in the background; its first step clears the previous run’sexitCodeandexitReason. Requiresagent.lifecycle; returns400for agents in worktree-per-agent projects. An agent in an empty-per-agent project can be reincarnated on its current Runtime Broker only on a local-disk runtime (Docker, Podman, or Applecontainer), reusing its workspace in place: any other runtime returns400, and a Runtime Broker that does not advertise thereprovisionEmptyPerAgentcapability returns412 unsupported_capability. Both are checked before the agent is stopped, so a dry run returns the same answer. If the workspace directory is missing or is not a real directory, the Runtime Broker refuses the reprovision and the migration fails; it never recreates the workspace. Also available asPOST /api/v1/projects/:projectId/agents/:agentIdOrSlug/reincarnate. OptionaltargetBroker(Runtime Broker ID, name, or slug) andforceMove(accepted only whentargetBrokernames a different Runtime Broker;400otherwise) check or run a move to another Runtime Broker.targetBrokeris resolved among the Runtime Brokers the caller can see: an unknown or hidden target returns404 runtime_broker_not_found, and a name that matches more than one returns409 runtime_broker_ambiguous. A target equal to the current Runtime Broker is a plain reincarnation. For a different Runtime Broker, a dry run evaluates the move checks in order (runtime_target,workspace_mode,workspace_storage_reported,same_export,workspace_on_export,workspace_recreatable,target_profile,target_health,access,capability,capacity) and returns200with the plan,targetBrokerId, andmoveVerdict(carryingtransferMode—exportorrecreate— andconsequences, what the move keeps, carries, recreates and loses), or the first refusal (400,403,409,412,429, or503) with the full verdict inerror.details.verdict(and, when a request withoutforceMoveis refused only because the workspace is not on a shared export and a forced move onto that target could apply,forceWouldApply: true).runtime_targetrefuses (409 runtime_target_move_unsupported) an agent pinned to a flat Runtime Broker and a move onto a flat Runtime Broker. A real move (nodryRun) runs the same checks and returns202(withtransferModeset toexportorrecreate) to move the agent in the background: quota moves to the target, a failure before the new generation is running normally rolls the agent back to the source (stopped, in theerrorphase; on arecreatemove, the rollback removes only target directories marked with that move’s reincarnation ID and never touches the remote git branch or the source’s workspace), and removing the source’s local state after a completed move never touches the shared export. See Moving an agent to another Runtime Broker. -
POST /:id/message: Deliver a message to an agent. Body:message(plain text) orstructured_message, plus optional flagsplain(deliver the text without the Scion message envelope),interrupt,notify, andwake. Top-levelplainapplies with either body form; withstructured_message, it is merged onto it. The whole body is capped at 2 MiB, checked before authorization (413 payload_too_large, nooperation_id). Raw keystroke delivery through this route has been removed: see “Retiredrawfield” under/:id/keys. When the Hub cannot read an attachment that the message names (most often a file staged on a different Runtime Broker’s host than the Hub’s), it still delivers the message without it and lists the file inattachment_warnings, an array of{"path", "reason"}objects; the field is omitted when every attachment was recorded. Direct-message and outbound-message responses carry the same field. Behind thehub.agent_delegationexperiment, an agent delegated credential is admitted onPOST /:id/messageandPOST /api/v1/projects/:projectId/agents/:agentIdOrSlug/messagewhen both the grant (agent.messageon the target) and the actor agent’s own messaging evaluation (message modes, cross-project policy, and standing) allow; the issuer’s ownership or ancestry never pierces a message mode. A delegated send is limited to a direct text message (instruction,chat, orreply) to the route’s own agent (no group recipient, thread, conversation, mention, notification subscription, or interrupt), is charged to the actor agent’s send budget, and records the actor agent assender(agent:<slug>) andsenderIdwithauthorizingUserIdanddelegationGrantIdstamped on the stored message. -
POST /:id/keys: Send literal terminal input to an agent’s tmux session — the replacement for the removedmessage/rawkeystroke path. Also available project-scoped asPOST /api/v1/projects/:projectId/agents/:agentIdOrSlug/keys. The whole HTTP body is capped at 32 KiB, rejected with413before anything else is parsed. Body:{"keys": "<string>"}only — unknown fields, duplicatekeys, a non-string/missing/empty value, an embedded NUL byte, invalid UTF-8 or an unpaired UTF-16 surrogate escape, and a value over 4096 UTF-8 bytes are all rejected (400/413). The string is delivered verbatim as a singletmux send-keysargument: a recognized tmux key name (Enter,Escape,C-c, arrow names, etc.) is interpreted only on an exact whole-string match; anything else — including text containing spaces — is typed literally, character by character. There is no sequence syntax:"Up Up Enter"is eleven literal characters, not three key presses — issue separate calls for separate key presses. An empty string is rejected outright and never becomesEnter; whitespace-only input is valid and preserved exactly.Response:
{"status": "dispatched", "operation_id": "<uuid>", "agent_id": "<uuid>"}. A200means the Runtime Broker acknowledged terminal injection, not that the harness consumed or acted on it.operation_idcorrelates audit and error records; it is not an idempotency key, and there is no202/queued state. Errors use the standard Hub error envelope withcodeset to the machine outcome (invalid_request,payload_too_large,keys_denied,not_found,agent_not_running,terminal_not_ready,cross_project_keys_unsupported,keys_unsupported,keys_rate_limitedwith aRetry-Afterheader,keys_unavailable, orkeys_outcome_unknown), or a generic500for an unexpected internal failure (which still carriesoperation_id, since validation already succeeded by the time one could occur);details.operation_idis present on every outcome decided inside the handler, from target resolution onward — everything exceptunauthorized(decided by auth middleware before any handler runs), the two validation failures above, and, on the project-scoped route specifically, its shared project-resolution404(decided before any keys-specific code runs at all, for every action on that route, not only keys).Audit. Every request that reaches the keys handler — accepted, denied, or malformed — writes content-free records to the Hub instance’s structured log: timestamp,
operation_id(when one exists), actor type and ID, source and target project, target agent, credential kind and ID, the route (keys), the input’s byte length, the decision code, and duration. Every request admitted to dispatch writes two records — an admission record before dispatch and an outcome record after, whatever that outcome turns out to be, including an ambiguouskeys_outcome_unknown; a request refused before dispatch writes only the single outcome record. Anything decided before the keys handler runs at all — a401from the shared auth middleware, a405for a non-POSTmethod, and, on the project-scoped route, its shared project-resolution404— is not keys-audited: do not expect unauthenticated probing, a wrong HTTP method, or an unresolvable project to appear in this log. A validation-stage record (before anoperation_idexists) reports the raw request body’s byte length under a separate field instead of the decodedkeyslength — reported as0when the body exceeded the 32 KiB cap or could not be read, since no byte count is known in that case either. An unexpected internal failure is recorded with anerror_classfield instead of a decision code, since it isn’t one of the machine outcomes above. The record never contains thekeysvalue itself, any preview or hash of it, or terminal output. This is a log line emitted per Hub instance, not a queryable audit store or API.Immediate-route limitations. This operation never wakes, starts, or queues anything — it either reaches the target’s terminal right now or it fails: a stopped or suspended target answers
409 agent_not_runningrather than being started; a running target without a ready terminal session answers409 terminal_not_ready.503 keys_unavailablemeans dispatch definitely did not start at all — an offline Runtime Broker, no immediate synchronous route to it (including a cross-Hub forward with no direct transport), or an admission window (30s by default) that already expired before the call could begin; nothing is queued for later delivery, so a caller may reasonably resend after confirming the route is fixed.502/504 keys_outcome_unknownis the opposite case — the call may have reached the terminal and partially or fully executed before the failure — and must never be retried automatically or read as confirmation of delivery.Authorization mirrors
agent.attach, not message-mode authorization: a human session needs to own the target agent or holdagent.attachon it (closed/nonemessage mode does not block it). No built-in project role grantsagent.attach, so being a project owner, admin, or member is not by itself enough; an agent credential needsScopeAgentLifecycle, the same project as the target, and live attach authority on the target via the relationship evaluator — same-project alone is not sufficient for an agent caller. Cross-project disclosure differs by route, matching the contract’s documented trade-off: on the project-scoped route, an authenticated agent’s project is compared against the URL’s project before any target lookup, so a mismatch answers422 cross_project_keys_unsupportedwithout ever revealing whether a same-slug agent exists there; on the top-level route, the target is resolved first (the same order every other action on that route already uses), so a foreign existing agent answers422while a nonexistent one answers404— a disclosure this route’s other lifecycle actions already have today, not a new one. This422on the top-level route is conditional on the agent already holding the lifecycle scope checked above: an agent lacking it is refused with403 keys_deniedbefore the project comparison ever runs, regardless of whether the target is foreign or same-project. The project-scoped route’s422, by contrast, does not depend on scope — it is decided purely on the project mismatch, before any scope or target check. A human operator with live cross-project permissions is not blocked on either route; the cross-project refusal applies only to authenticated agent identities. No conversation, message row, SSE event, observer/mention fan-out, or notification is created for any outcome. Requests are rate-limited per authenticated principal+project (5 req/s, burst 10) and per target agent (10 req/s, burst 20), Hub-instance-local. Delivery is single-attempt end-to-end — no SDK retry, redirect replay, or routing fallback — so a caller must not resend a call whose outcome is unknown. A managed-runtime target or a Runtime Broker without the keys route answers422 keys_unsupported; this is never downgraded to an ordinary message send.Retired
rawfield (migrating fromraw). Raw keystroke delivery through messages has been removed; use this/:id/keysoperation (orscion keys) instead. A request that still carries the retiredrawfield is rejected with422 raw_input_removedbefore anything happens — no message or conversation row, no dispatch, no event, no notification. This applies to both spellings (top-levelrawand nestedstructured_message.raw, ormessage.rawon the broker inbound routes), to every value includingfalseandnull, and to malformed values. It applies on these routes:POST /api/v1/agents/:id/messageandPOST /api/v1/projects/:projectId/agents/:agentIdOrSlug/message;POST /api/v1/projects/:projectId/broadcast(body capped at 2 MiB,413 payload_too_large);POST /api/v1/broker/inboundandPOST /api/v1/broker/inbound/routed(Message Broker plugins; body capped at 2 MiB,413 payload_too_large), checked before topic validation and sender resolution;- the advanced
payloadJSON of scheduled events and recurring schedules.
The error
messagenamesscion keys, anddetailscarriesoperation_id,ingress, andreplacement, the generic/keysroute template to use instead (POST /api/v1/projects/{projectId}/agents/{agentIdOrSlug}/keyson the project-scoped message route,POST /api/v1/agents/{id}/keyselsewhere). The response never names the resolved target, because the field is rejected before message authorization. Each rejection writes a content-free audit line with the route valuemessage_raw_removed. Plain, normal, and interrupt messages are unaffected, and historical message rows are not rewritten. See Migrating from raw message delivery. -
PATCH /:id: Update an agent’s mutable fields:name,labels,annotations,taskSummary,config, andgcp_identity. SendstateVersionfor optimistic locking (409on mismatch).config(for examplemodel,image,env,thinking_level) is accepted for agents with no container: thecreated,stopped,errorandsuspendedphases. Each of those phases creates a new container from the applied config at its next start (a first start, a fresh start of a stopped or failed agent, or a resume of a suspended one), so the edit is dispatched with it. For an agent that is running, or whose container is being created or removed,configreturns409unless thehub.agent_edit_heldexperiment is on (see below). When an edit takes effect depends on the key’s edit tier (seeeditabilityonGET /:id): container keys such asmodel, the limits,imageandenvapply at the next start or resume.system_prompt,agent_instructions,skillsandservicesare rendered into files when the agent is provisioned. The Hub records each one a PATCH changes inchanged_fieldsand sends that list with every start. A Runtime Broker that advertises thechangedFieldscapability renders those files again at the start, so for its agents these four keys also apply at the next start or resume, including an agent’s first start fromcreated. The Runtime Broker renders a key again only when its result differs from the last time it rendered it. A change made inside the agent to the installed skills or.scion/scion-services.yaml, or to the prompt files of a harness that reads them from the agent home, is therefore overwritten only when the Hub’s value changes again. Container-script harnesses (such as Claude, Gemini CLI or Codex) still rebuild their managed instruction block from Scion’s inputs at every start, as before. A start does not apply a clear the merge into the stored config keeps: an emptyagent_instructionsorservicesvalue, an emptied skill list, or a removed skill (the merge appends skill references, and a start uninstalls nothing). Those edits are reported inheldForReincarnatewith a warning, like the clear ofsystem_prompton a Runtime Broker withoutclearedFields. With an older Runtime Broker, these keys are stored now and rendered at the next reincarnation, and a start that carries changed keys to it returns awarningsentry naming them.secretsis always rendered at the next reincarnation. Clearing a container key reaches the agent only at the next reincarnation, except as described forcleared_fieldsbelow, because a start keeps the previous value: that coversnullor"", an empty map or list, an emptyresources,kubernetesortelemetry(or one whose fields are all empty), and0formax_turnsormax_model_calls. Amax_durationof"0"(no limit) and athinking_levelof0do apply at the next start or resume. Likewise, removing an entry fromenv,mcp_serversorvolumestakes effect only at the next reincarnation, because a start unites maps and appends volumes. The response’swarningsname these keys when their value actually changes. The Hub also records some of these clears ascleared_fieldsand sends them with every start: a clear ofmax_turns,max_model_calls,max_duration,thinking_level,resourcesorsystem_prompt, and each removedenvkey. A Runtime Broker that advertises theclearedFieldscapability applies them at the start, without a reincarnation, so the inherited value applies again. The inherited value is the one provisioning derived from the agent’s template chain (the agent’s template, with the project or globaldefaulttemplate layered under it), or the defaults when the chain sets none; templates never supplyresources, so a clearedresourcescomes from the defaults. This holds both for a template the Runtime Broker resolves by name and for one loaded from its template cache (a Hub template). The Runtime Broker writes that value into the agent’s stored config, so the agent keeps it even if the template is later renamed or removed. If the chain cannot be resolved, for example because the cached template is no longer available or the template name no longer resolves, the agent keeps its current value, so a clear never removes a limit, and the clear applies at the next reincarnation. A removedenvkey is removed in every case. A clearedsystem_promptis rendered again from the template at the start by a Runtime Broker withchangedFields; with an older one it waits for the next reincarnation. For an agent whose Runtime Broker advertisesclearedFields,dispositionlists these clears underappliedand no warning is returned, but only when the clear was recorded: a removedenvkey counts only if the caller can view the agent’s env (the auto-expose keys andTZ, which the Hub derives for each start, are not counted). A count limit sent as0(Unlimited) is still reported held for reincarnation with a warning; only anullclear of it is reported applied. An older Runtime Broker ignores the list and keeps the previous value, so for its agents thewarningsanddispositiondescribe the reincarnation case. A later edit that sets a key again takes it off the list, and a new agent or a reincarnated generation starts with an empty list. Other clears, such asmodel,image, orvolumes,mcp_serversortelemetryas a whole, are not recorded. Aresourcesvalue that leaves out a sub-field the agent stores (requests or limitscpu/memory, ordisk) keeps that sub-field until the next reincarnation, and is reported inheldForReincarnatewith a warning on every Runtime Broker. Neithercleared_fieldsnorchanged_fieldsis returned byGET.changed_fieldsis kept until a reincarnation, which renders every key afresh; a new agent starts with an empty list. A supporting Runtime Broker also stops repeatingvolumeson each start: it keeps one volume per target, a later one replacing an earlier one, except that the first/workspacevolume is always kept.configis a partial update: only the keys present in the request body change. A present key replaces its whole value (for example the wholeenvmap orvolumeslist), and a present key sent as an empty value ornullclears it. An absent key keeps its current value, includingvolumes,skills,mcp_servers,services,command_args,kubernetes,telemetryandenv.model,image,taskandauth_selectedTypesent empty leave the agent’s applied value unchanged (the inline config entry is cleared).thinking_levelchanges only when it is present, so send"thinking_level": nullto unset it. Amodelvalue is resolved through the harness’s model aliases. Fields in the request that the agent’s current harness does not support return a validation error. The PATCH enforces the same ruleeditabilityonGET /:idreports, before anything is written: a request that names a key locked for the agent is refused whole, anddetails.fieldsmaps each refused key to the reason. Refusals are checked in this order: any key of a deleted agent (409); withouthub.agent_edit_held, anyconfigof a running or transitional agent, whatever the keys (409);config.taskof a suspended agent, which a resume does not send (409); a key fixed at creation that would change (400):harness,harness_config,default_harness_config,branch,clone_depth,explicit_workspace,empty_per_agent_workspace,config_dir,detached,hub,cleared_fieldsandchanged_fields(which only the Hub sets); then field validation. A fixed key is refused only when it would change: a value equal to the agent’s stored inline value or its applied value (the branch, harness config and harness shown onGET) is ignored, as is an empty value where no inline value is stored, and an ignored key is not listed indisposition. To use another harness, recreate the agent, or change the template and reincarnate it. Keysconfigdoes not define are ignored.gcp_identitycan be changed only in thecreatedphase.explicitTimezonepins the agent’s container timezone to an IANA zone name, or unpins it with""; an invalid name returns400. It is accepted in any phase except on a deleted agent (409) and applies at the next start (a running container keeps itsTZ, and the response carries a warning saying so). ATZkey inconfig.envis ignored with a warning; useexplicitTimezoneinstead. The response is the agent plusresolvedTimezone(theTZits next start will get,""when none is sent) andtimezoneSource, which names the step of the agent timezone chain that supplied it:explicit,legacy(adopted from aTZan older Hub stored in the agent’s env),user,project,hub,broker,progeny,hub-default, ornone. The response also carriesdisposition, which lists the keys whose value the request changed (configkeys asconfig.<key>, for example["config.max_turns", "name"]), grouped by when each edit takes effect.appliedlists the keys that take effect at the next container creation (any start, restart, resume or reincarnation), or at once for metadata.explicitTimezoneis also listed underappliedwhen the agent’s container is live, although the running container keeps its timezone until the next start (the response warns about this when the zone the next start gets changes).heldlists theconfigkeys kept aside while a container is live or being created or removed, and applied at the next container creation after the current run (with thehub.agent_edit_heldexperiment on; without it such edits are refused, so it is empty).heldForReincarnatelists the keys that take effect only at the next reincarnation: the provision-rendered keys the agent’s Runtime Broker does not render again at start (all of them withoutchangedFields, andsecretsalways), the container keys whose clear or zero a start does not apply (the fieldseditabilitymarksclearNeedsReincarnate), andenv,mcp_serversorvolumeswhen the edit removes an entry (forenv, withoutclearedFields, or when the removals were not recorded). Each of these keys is named in awarningsentry that says it takes effect at the next reincarnation. Without thehub.agent_edit_heldexperiment, aheldForReincarnatekey is still stored now, soGETalready shows its new value in the agent’s applied inline config; with it, the key is kept inpendingConfiginstead (see below). All three lists are always present, sorted, and empty when nothing falls under them. A key sent with its current value is not in any list, with one exception:gcp_identityis listed underappliedwhenever the request records a service-account assignment (anassignidentity, orpassthroughtranslated to an assigned account), even when it names the agent’s current account, because the assignment is re-recorded with the caller as its source. With thehub.agent_edit_heldexperiment on (off by default),configis also accepted for an agent that is running, or whose container is being created or removed (provisioning,cloning,starting,running,stopping): its keys are kept in the agent’spendingConfiginstead of the applied config, listed underheld(orheldForReincarnatefor a clear or removal a start does not apply), and applied at the agent’s next start, restart, resume or wake that creates a container (a start of an agent that is already running keeps its container, so its held edits stay held). A stored pending key that can no longer be applied (it no longer decodes, or is no longer an editable key) is removed at the next start or config edit, audited asagent_pending_config_discardedwith the triggerunappliable. Provision-rendered keys (system_prompt,agent_instructions,skills,services,secrets) are kept inpendingConfigin every phase, because a start does not re-render them, unless the agent’s Runtime Broker advertiseschangedFields: thensystem_prompt,agent_instructions,skillsandservicesare written now in a phase with no container (as with the experiment off), and a held edit of them is applied by the next start, which lists it inchanged_fields(secretsalways stays pending); a start that leaves them pending answers with the warningHeld for reincarnation: <keys>. A reincarnation does not apply pending edits yet; they stay pending.and sets the agent’s status message to it. A reincarnation does not apply or clear pending edits yet: they stay pending through it, and the pending keys a start applies (container keys, and the re-rendered provision keys on a Runtime Broker that advertiseschangedFields) are applied at the next start after it, over the values the reincarnation set. Keep the experiment off until reincarnation applies pending edits. A save is validated as an immediate edit is, overlays only the keys it names on the pending config, leaves out a key whose value equals what the next run would get, and bumpsstateVersion; a save that arrives while a start is in progress waits for the next start. AconfigPATCH of an agent with no container first applies its pending container keys (and, on a Runtime Broker that advertiseschangedFields, its pending re-rendered provision keys), then the request, so only the provision-rendered keys that broker does not re-render stay pending (secretsalways does). A reconcile-driven restart, which recreates the container from the Runtime Broker’s stored config, leaves pending edits as they are. With the experiment on, a request that namesagentRoleis refused with409(change the role by reincarnating the agent); without itagentRoleis ignored. With the experiment off, stored pending edits are not applied; aconfigedit written then removes the pending keys it names (audited asagent_pending_config_discardedwith the triggersuperseded), so a stale pending value cannot later override it, and other pending keys are kept. The Hub audits pending edits asagent_pending_config_saved,agent_pending_config_applied(with thetrigger:update,start,resume,restartorwake) andagent_pending_config_discarded(with the triggersupersededfor keys a write-through replaced, orunappliablefor stored keys that can no longer be applied), each naming the keys. The project-scopedPATCH /api/v1/projects/:id/agents/:agentIdapplies the same rules. -
DELETE /:id/pending-config: Discard the agent’s pending config edits (agent.update;404while thehub.agent_edit_heldexperiment is off). An optional?stateVersion=must match the agent’s (409otherwise). The response is the agent plusdiscarded, the keys removed (empty when there were none, in which case nothing is written); otherwisestateVersionis bumped. -
DELETE /:id: Stop and remove an agent. Query:deleteFilesandremoveBranch(both default totrue; sendfalseto keep the files or branch) andforce=true(skip the Runtime Broker availability check and continue past Runtime Broker teardown failures; a forced delete skips soft-delete retention). The Hub runs the delete in the background, and a secondDELETEwhile one is in progress joins it:204: the delete finished.202: teardown is still running after about 20 seconds. SendPrefer: wait=N(seconds) to lower that wait; it is never raised. The body is{"agentId": ..., "deletion": {...}}. Progress arrives as agent status events, and the agent is gone at thedeletedevent; alternatively pollGETon the agent until it returns404(ordeletedAtis set, for a soft delete). The CLI polls every 2 seconds for up to 180 seconds.502(runtime_error),503(runtime_unavailable, withRetry-After), or409(Runtime Broker conflict): the delete failed.details.deletionCodegives the reason.
While a delete is active or has recently failed, the agent carries a
deletionview (also on every agent status event,nullotherwise):stateisdeletingorfailed, plussoft,startedAt, andleaseExpiresAt(deleting) orexpiresAt(failed). On the single-agentGET /:id, the view also carriesblocksStart: whether a start would currently be refused with409 delete_in_progress. It is omitted when the Hub could not check. The Web Dashboard updates the text of its delete banner and enables the Start button based on this field. A failed delete has acode(runtime_error,runtime_unavailable,conflict,in_doubt,abandoned,revoke_failed, orfinalize_failed) and anerror, and its view stays for 15 minutes. While a delete is running, or after a failure that leaves teardown unresolved (a Runtime Broker delete still outstanding, or a failure while finalizing), start, restart, reincarnate, restore, create-with-existing-agent and DM wake return409 delete_in_progress. A DM wake whose resume lands after a delete won, including while it waits for the agent to become ready, also returns409 delete_in_progressand does not deliver the message. Retry theDELETE, or send it withforce=true, to clear it; this is safe even when the code isin_doubt. The exception is a Runtime Broker that holds a different run of the agent than the Hub recorded: it deletes nothing. The Hub then sends one more delete, for exactly the run the Runtime Broker reported, when the request hasforce=trueor that run is one of the agent’s earlier runs (one the Hub started, as a failed start or restart can leave behind). The run the Hub recorded is sent after it, so files the Runtime Broker keeps for that run are handled as the request asks, too. If that delete succeeds, the agent is removed; a soft delete records the deleted run on the retained agent. If the reported run is not a valid run ID, if the Runtime Broker refuses that delete too because yet another run holds the agent, or if that delete fails any other way, the Hub keeps the agent, restores its phase and answers as for any failed delete (409with codeconflictfor a refusal), even withforce=true; it never removes the agent while a run it was told about may still be running. Withoutforce=trueand with any other reported run, including a best-effort delete of an agent still in thecreatedphase, the Hub keeps the agent and answers409with codeconflict, and a retry is refused the same way until the Hub and the Runtime Broker agree on the run or the delete is forced. The Hub settles one case of this by itself: when the agent’s assigned Runtime Broker sends a complete heartbeat that reports only an earlier run of the agent, one the Hub issued a credential for, and no other Runtime Broker reports the recorded run, the Hub adopts the reported run once the recorded one has been unchanged for 19 minutes (the Runtime Broker’s 15-minute bound on a synchronous start, plus twice the Hub’s 120-second dispatch timeout to the Runtime Broker) and no start, stop, delete or reincarnation of the agent is under way. It logsagent_run_adopted_from_heartbeat. A failed create whose cleanup is refused this way leaves the agent in theerrorphase with its quota still held. Each agent run has its own run ID, so a stale delete cannot remove a recreated agent with the same name. The Hub sends the agent’s run ID, when it has one, with every stop and delete, and on every runtime the Runtime Broker never acts on an entry labelled with a different run. An entry with no run label (started before run IDs existed) still matches by name. On thesubstrateruntime the final stop or delete goes by actor name, which is reused across runs, so that step does not check the run yet. On the Applecontainerruntime the run is checked by a listing just before the stop or delete call, so a container recreated in that moment can still be stopped or deleted. -
GET /:id/logs: Stream agent logs (WebSocket). -
POST /:id/exec: Run a command in the agent’s container. Body:command(array of strings, required) andtimeout(seconds, optional). Returns{"output", "exitCode"}. A failure the Runtime Broker reports returns502with coderuntime_error. With thehub.conduitexperiment on, a command whose request reached the Runtime Broker but whose result was not received returns502with codeoutcome_unknown: the command may have run. The Hub does not retry it;error.detailscarriesoperation,broker_idandrequest_id, and the body never carries command output. Do not retry a command that is not safe to run twice. -
GET /:id/pty: Interactive terminal (WebSocket). The Hub relays the stream to the agent’s Runtime Broker and sends keepalive pings every 30 seconds, so a dead peer is detected instead of leaving the connection hanging. Behind thehub.agent_delegationexperiment, an agent delegated credential is admitted on the WebSocket handshake and preflight when its grant andagent_delegation_policyallowagent.attach(otheragent.attachroutes such asexec,env,reset-auth, andkeysare not admitted to delegated credentials). A delegated attach is served only on the Runtime Broker’s terminal path; an attach that would use the agent’s conduit path is refused with503. See PTY close codes.- With the
hub.conduitexperiment on, the Hub picks the path for each agent. It uses the Runtime Broker’s control channel when the agent’s runtime supports attach. It uses the agent’s Conduit session when the runtime has no attach and the session offers a PTY. The leaf protocol and close codes are the same on both paths. AGETwithout a WebSocket upgrade is a preflight: it answers200with{"path": "broker"}or{"path": "agent"}, or503withdetails.reasonwhen neither path can serve (broker_not_connected,conduit_not_serving,stream_authz_unavailable,registry_unavailable,agent_pty_unavailable,managed_runtime). An agent on a managed runtime never takes the broker path, whatever the experiment setting: when it has no Conduit session that offers a PTY, or the experiment is off, the Hub answers503with coderuntime_attach_unsupportedand reasonmanaged_runtime; a temporary refusal on the agent path keeps its own reason. A user attach on the agent path is re-checked like other Conduit user streams (seeauthz_recheck_intervalandstream_authz_max) and is closed with4401if access was withdrawn.
- With the
There is no separate resume endpoint: resuming is the start action applied to a suspended agent. A suspended agent is also resumed automatically when a message is delivered to it with the wake option set.
Agent creation and start are rejected with 429 Too Many Requests (quota_exceeded) when the target Runtime Broker is at its max_agents_per_broker limit (see Admin); error.details.runtimeBrokerId names that Runtime Broker. A request that would reserve capacity on one Runtime Broker for an agent that already holds an active reservation on another (a create, start, restart or direct-message wake) is refused with 409 broker_reservation_scope_conflict; the existing reservation is kept and nothing is moved.
Agent responses no longer include a visibility field. Access is determined by scope and grants only.
Agent state uses a layered model:
- Phase: Lifecycle stage (
created,provisioning,cloning,starting,running,stopping,stopped), plussuspended(paused for resume) anderror(the agent crashed — restartable). - Activity: Runtime activity within the
runningphase (initializing,working,thinking,executing,waiting_for_input,blocked,completed,limits_exceeded,stalled,offline). Note:offlineoccurs when an agent heartbeat has not been heard for some time, often due to an expired auth token that the agent failed to refresh;stalledflags a live-but-hung agent and can trigger auto-suspend. (A crash surfaces as theerrorphase, not as an activity.)initializingmeans the container and harness process are up but the harness has not yet shown it is reading input; it changes toworkingonce the harness is ready, or after 90 seconds at the latest. Messages sent while an agent isinitializingare delivered and held until the harness is ready; an interrupt is delivered as an ordinary message. The same applies to a message sent withwake: if the woken agent is stillinitializingwhen the wake wait ends, the message is delivered and held, and an interrupt is delivered as an ordinary message. The legacystatusfield reportsrunningfor this activity. - Detail: Freeform context (tool name, message, task summary).
Agent Self-Service (/api/v1/agent)
Section titled “Agent Self-Service (/api/v1/agent)”Endpoints an agent calls on its own behalf. They authenticate with the agent token, sent in the X-Scion-Agent-Token header (which sciontool uses) or as an Authorization: Bearer token, and take no agent ID in the URL: the Hub derives the agent’s identity from the token.
POST /secrets: Fetch several secret values in one call. Body:{"keys": ["KEY_A", "KEY_B"]}(1 to 100 keys, 64 KB body limit). Returns{"secrets": [...]}, one entry per requested key withkey,status(ok,not_found, orentitled_but_unavailable),value(only whenok) anderror. Per-key failures do not fail the request; a request the agent is not authorized for at all returns403. Values are scoped to the agent’s project.sciontool inituses this to fetch the keys listed inSCION_SECRET_KEYSat startup.
Agent Ports (/api/v1/agents/:id/ports)
Section titled “Agent Ports (/api/v1/agents/:id/ports)”GET /: List the agent’s exposed ports,{"ports": [...]}. Each entry hasport,label,host,mode,exposedAt,exposedBy, andurl/basePath(/api/v1/agents/:id/ports/:port/proxy/). An agent can read only its own ports; a user needs read access to the agent.POST /: Register a port. Body:port(1–65535; ports 9810 and 18380 are refused), optionallabel, and optionalhost(default127.0.0.1; only loopback hosts are accepted). At most 10 ports per agent. Returns201, or409if the port is already registered.DELETE /clears every port andDELETE /:portremoves one (204). Registering and removing ports requires the agent’s own token with theagent:port:forwardscope, or a user who holdsagent.port_accessboth on the agent and on the hub. User access tokens are refused with403(details.reason: GOV_PENDING).GET /tunnel: The agent’s port-forward tunnel (WebSocket), opened bysciontool. Same authorization as registering a port; a request that is not a WebSocket upgrade gets400./:port/proxy/...(any method): Proxy an HTTP request to an exposed port. On the port-forward tunnel, requests are limited to 32 MB and time out after 60 seconds. WebSocket upgrades are not forwarded over the tunnel (501). When thehub.conduitexperiment is on and this Hub node runs the Conduit relay, the Hub uses the agent’s Conduit session (streamed, with no body limit or proxy timeout); it falls back to the tunnel only when the agent has no Conduit session, and if the agent has no tunnel either, the request fails with503and codeagent_offline. Other Conduit errors are returned without falling back. See Port Forwarding.
Agent Delegation Grants (/api/v1/agents/:id/delegations)
Section titled “Agent Delegation Grants (/api/v1/agents/:id/delegations)”Issuing and exchanging agent-bound delegation grants requires the hub.agent_delegation experiment (404 while off). The inspection and revocation routes (GET /, GET /:grantId, DELETE /:grantId, DELETE /:grantId/credentials/:credentialId, POST /revoke-all, GET /api/v1/users/me/delegations, and POST /api/v1/users/me/delegations/revoke-all) remain available even when the experiment is off so operators, issuers, agent owners, and the bound agent itself can inspect and revoke or relinquish grants. See Delegation and Revocation.
POST /: Issue a delegation grant bound to the agent’s current project and generation (201). Requiresagent.delegation.createand an interactive session of a local user for an agent the user controls (owner or inancestry); dev sessions, user access tokens, federated users, and agent tokens are refused (403). Body:boundary({"kind": "project", "projectId": "<uuid>"}or{"kind": "hub"}),permissions(a non-empty list from the delegable catalogue for that boundary, narrowed by theagent_delegation_policyoperational settings section and checked against the issuer’s live permissions), optionalexpiresAt(default 7 days, maximum 30 days), optionalmaxCredentialTtlSeconds(1 to 3600, default 900),name(required), and optionalpurposeandlabels. Capped at 10 active grants per agent and 50 per issuer (409 grant_limit_reached).POST /:grantId/exchange: Called by the bound agent with its own agent token (sent bysciontool init’s in-container Unix socket proxy at~/.scion/delegation.sockwhen a process in the container sendsX-Scion-Delegation-Grant: <grant-id>) to mint a short-livedscion_adt_...bearer credential. Body:audience(this Hub), optionalpermissions(a subset of the grant’s), and optionalttlSeconds(capped by the grant). Returns200{"token", "expiresAt", "grantId", "credentialId", "audience", "permissions"}withCache-Control: no-store. At most 2 active credentials per grant (minting a third supersedes the oldest).GET /,GET /:grantId,DELETE /:grantId,DELETE /:grantId/credentials/:credentialId, andGET /api/v1/users/me/delegations: List (every grant the caller may see, each withstatusactive,revokedorexpired), inspect, and revoke grants or individual exchanged credentials. Accessible to the issuer, the agent owner, a user holding the matchingagent.delegation.list,agent.delegation.read,agent.delegation.revokeoragent.delegation.credential.revokepermission on the Hub (any grant) or on a project (only grants bounded to that project), or the bound agent itself (with its own agent token on/api/v1/agents/:id/delegations..., whereDELETE /:grantIdrelinquishes the grant with reasonrelinquished(a user’s revocation recordsrevoked)). Soft-deleting, hard-deleting, restoring, or claiming an agent for reincarnation revokes all unrevoked grants bound to it in the same transaction (agent_deleted,agent_restored,agent_reincarnated), and deleting a project revokes grants of all project agents in the cascade transaction (project_deleted). Suspending the agent (agent_suspended) or suspending or deleting the issuer (issuer_suspended,issuer_deleted) revokes grants after commit while exchange and use checks deny immediately.POST /revoke-allandPOST /api/v1/users/me/delegations/revoke-all: Revoke unrevoked grants (including expired ones) and their active credentials in one transaction (200{"revoked": [...], "skipped": [{"grantId", "agentId", "reason"}]}). Session only (agent tokens, delegated credentials, and user access tokens are refused with403; the bound agent relinquishes its grants one by one viaDELETE /:grantId).POST /api/v1/users/me/delegations/revoke-allrevokes every unrevoked grant the caller issued (skippedis empty).POST /api/v1/agents/:id/delegations/revoke-allrevokes every unrevoked grant of the agent that the caller may revoke-all: the grant’s issuer, the agent’s current owner, a user holdingagent.delegation.revoke_allon the Hub (any grant; granted by the built-insuper-adminandhub-adminroles), or a user holdingagent.delegation.revoke_allon a project (only grants bounded to that project; built-in project roles do not holdagent.delegation.revoke_all, so project owners and admins revoke project-bounded grants one at a time viaDELETE /:grantIdunless granted a custom project role withagent.delegation.revoke_all). Unrevoked grants the caller can read (agent.delegation.read) but not revoke-all are left unrevoked and listed inskippedwithreason: "forbidden"; grants the caller cannot read are omitted.
Projects (/api/v1/projects)
Section titled “Projects (/api/v1/projects)”The legacy /api/v1/groves aliases and the project WebDAV endpoint (/api/v1/projects/:id/dav/) have been removed. Requests to /api/v1/groves or any path under it now return 404 Not Found (use /api/v1/projects), and requests under /api/v1/projects/:id/dav/ return 404 Not Found (use git or the project workspace file API at /api/v1/projects/:id/workspace/files and /api/v1/projects/:id/workspace/archive).
GET /: List projects you have access to.POST /: Create a project without a Runtime Broker. Body:name(required), optionalslug,gitRemoteandlabels, andworkspaceMode. OmitgitRemotefor a Hub-managed project without git. The slugglobal(in any letter case) is reserved for the global project: create, clone, update, and register with a git remote refuse it with400, and a name that would slugify toglobalgetsglobal-1(or the next free suffix). A client-suppliedslug(on create, clone, andPATCH) must already be in slug form, that is equal to its slugified value (lowercase letters, digits, and single hyphens, not starting or ending with a hyphen); anything else is refused with400 validation_errorand is never rewritten.- Clone URLs cannot carry credentials. On create, register, and label updates, a
scion.dev/clone-urllabel containing a username, password or token (other than thegit@login of an ssh or scp-style URL), a query string, a fragment, whitespace, or non-ASCII characters is refused with400(details.fieldnames the label; the value is never echoed). AgitRemoteis stored without its query, fragment, and ordinary credentials, and is refused if a credential cannot be removed cleanly. Configure clone authentication with a project secret (such asGITHUB_TOKEN) or the GitHub App instead. workspaceModeis the workspace sharing mode:shared,per-agentorworktree-per-agent. With agitRemote,per-agentmeans clone-per-agent. Without one, it means empty-per-agent: each agent gets a private directory that starts empty. Omitted means the existing default (shared for a project without git).- The mode can only be set at create time. Unknown values, and
worktree-per-agentwithout agitRemote, are rejected with400. It is stored in the server-ownedscion.dev/workspace-modelabel. Alabelsentry for that key must matchworkspaceMode(else400), and is dropped whenworkspaceModeis omitted. A later update that tries to change the label is rejected with400. - For an empty-per-agent project, creating or starting an agent on a Runtime Broker that does not advertise the
emptyPerAgentWorkspacecapability fails with412 Precondition Failed. An agent create that names aworkspacepath is rejected with400, and workspace files sent with it are ignored with a warning in the response’swarnings. Reincarnating such an agent follows the empty-per-agent rules ofPOST /api/v1/agents/:id/reincarnate. A Runtime Broker whose default runtime is Cloud Run or Substrate reports this capability as absent from its first heartbeat onward, so creating an empty-per-agent agent there returns412. Until that heartbeat, the agent can still be dispatched, and the Cloud Run or Substrate runtime rejects it with an error. The capability reflects only the broker’s default runtime, so an agent that uses a Cloud Run or Substrate runtime profile on a broker whose default runtime is neither gets no412and fails at agent start. Thecloudrun-sandboxruntime supports the mode.
- Clone URLs cannot carry credentials. On create, register, and label updates, a
POST /register: Register or link a project repository. If the request resolves to an existing project, the caller needs update access to that project; without it, the request is rejected before anything changes. The same check applies to creating a project that resolves to an existing one and to linking a provider.GET /:id: Get project metadata and statistics. Behind thehub.agent_delegationexperiment, an agent delegated credential is admitted when its grant and theagent_delegation_policyoperational settings section allowproject.readfor the grant’s boundary (projectorhub).PATCH /:id: Update a project. The updatable fields arename,slug,labelsanddefaultRuntimeBrokerId; an omitted or empty field is left unchanged, andlabelsreplaces the whole map. The owner changes only throughPOST /:id/transfer-ownership: anownerIdin aPATCHbody is ignored. The response is the updated project.DELETE /:id: Delete a project, with its configuration and its agent records. Requires a full session (scoped access tokens and agent tokens get403), and only a direct project owner or a super-admin may delete. Callers without that authority get403(or404) before anything else is checked. Returns204on success. While the project has an agent that is not soft-deleted (an agent never deleted, or one whose delete is in progress or failed), the delete is refused with409 conflictand nothing is deleted:details.agentslists up to 50 of those agents (id,slug, anddeletionStatewhen a delete is in progress or failed:deleting,finalizingorfailed; the key is absent for an agent never deleted), anddetails.truncatedistruewhen more agents remain. Delete those agents first (DELETE /api/v1/agents/:id). Soft-deleted agents, whose runtime delete has already completed, do not block and are removed with the project. Query:force=truedeletes the project anyway: its agent records are removed with it, and the Hub then asks the Runtime Brokers to delete those agents best-effort, logging any failure (an agent left running on a Runtime Broker is not retried).scion hub projects delete --forcesends it.GET /:id/secrets: Manage environment secrets for the project.GET /:id/agents: List the project’s agents. Query:phase,label(repeatablekey=value),runtimeBrokerId,includeDeleted=true,limit(default and maximum 500), andcursor. The response carriesagents,totalCount,serverTime, and, when more results exist,nextCursor. Cursors are bound to the project, the filter, and the caller that received them; pass them back unchanged with the same query. A cursor that fails this check is rejected with400. For a user caller, this list and the globalGET /api/v1/agentslist return the same set for a project: only the agents the caller can read, with pages andtotalCountfollowing that rule. Above 2,000 candidate agents,totalCountis a lower bound and the response setstotalCountApproximate. On a user access token,agent:list(orproject:readon a token bound to the project) is enough to list; each row is still checked.- Sorted mode (
sort=createdorsort=updated): returns a stable, server-sorted order by creation time or last update, newest first (dir=desc, the default) or oldest first (dir=asc). Any othersortvalue is rejected with400. Optionalfit(1–500, at leastlimit, not valid withcursor) asks for the whole readable set in one response when the project has at mostfitagents; the response then reportscomplete: true, orcomplete: falsewith the first page and anextCursor.stats=1adds astatsblock (total,running, andagentsas[id, phase]pairs) computed over the filter withphaseignored. The response echoessortanddir. Sorted mode serves at most 2,000 agents: a request whose filter matches more is refused with422and error codesorted_view_unavailable(details.reason: too_many_candidates), so retry withoutsort. The page size can be narrower thanlimitfor large projects. An agent token can use sorted mode on its own project. - Compact view (
view=compact): returns the same slimmer items asGET /api/v1/agents?view=compact, withoutappliedConfig. This endpoint does not set_messageability, in either view. Membership, order, cursors, counts,completeandstatsare the same as in the defaultview=full. An emptyviewis the same as noview. In sorted mode, a non-emptyviewother thanfullorcompactis rejected with400; otherwise any value other thancompactis ignored.
- Sorted mode (
GET /:id/providers: List the Runtime Brokers that provide compute for the project. Each provider reports broker-wide capacity:agentLimit(the effectivemax_agents_per_brokerlimit; omitted when the broker is unlimited or no limit applies),agentCount(running agents on that broker from any project; omitted when no limit is configured), andagentLimitSource(which precedence step producedagentLimit:broker,entitlement,hub_default,unlimited, ornot_enforced). WhenagentLimitSourceisnot_enforced, the hub-wide “enforce broker agent quotas” switch is off:agentLimitis informational only — it is still the resolved cap, but agent creates on that broker are not rejected against it.scion hub projects infoshows these as(agents: count/limit), with(not enforced)appended when the switch is off.GET /:id/settings,PUT /:id/settings: Read or update the project settings (defaults for new agents).PUTupdates field by field. A field left out of the body, or sent asnull, keeps its stored value. A field sent with its empty value is cleared:""for strings,0fordefaultMaxTurnsanddefaultMaxModelCalls,{}fordefaultResourcesanddefaultGCPIdentityServiceAccountIDByProfile. Any other value replaces the stored one.bucket,runtimes,harnessesandprofilesare not project settings (they come from server and Runtime Broker settings): aPUTthat sets any of them to a value other thannullis refused with400and nothing is saved. Object fields (defaultResources,defaultGCPIdentityServiceAccountIDByProfile) are replaced whole, not merged key by key. Exception: fortelemetryEnabled,autoExposePortsEnabledanddefaultThinkingLevelonly,nullclears the field (back to inheriting the hub default), because the two switches treatfalseas a real value and the thinking level has no empty value. Leaving them out still keeps them.defaultThinkingLevelmust be between 1 and 100, as for the Hub defaultdefault_thinking_level: any other value,0included, is refused with422 validation_error(details.fieldisdefaultThinkingLevel) and nothing is saved. A level outside 1-100 stored before this rule (an earlierPUTaccepted0) reads as unset:GETdoes not report it, new agents inherit instead of getting0, and the next save of any project setting removes it.activeProfileis the broker profile new agents run under when the request names none.defaultGCPIdentityServiceAccountIDByProfilemaps a broker profile name to a registered GCP service account ID. Default service accounts are checked on write: each must be reachable from the project and verified. FordefaultGCPIdentityServiceAccountIDByProfile, only the entries the body adds or changes are checked; an entry sent with its stored value is not checked again, and removing an entry is always allowed, so an entry whose account is no longer usable does not block a change to another profile. The response is the saved settings plus an optionalwarningsarray of strings, omitted when empty. Warnings never fail the save. For each per-profile entry the body adds or changes, there is a warning when no Runtime Broker providing the project reports that profile, or when the Hub’s shared mapping decision (seemappingsbelow) isnot_mappedfor the account on every provider profile of that name that it could decide on. Anunknowndecision, a profile whose runtime needs no mapping, or a provider whose profiles the Hub has no record of never produces a warning. The PUT is not conditional: two concurrent writes ofdefaultGCPIdentityServiceAccountIDByProfileeach replace the whole map, so the later one wins. The default GCP identity mode and service account are checked together, using the stored value for whichever one the body leaves out, but only when the body sets at least one of them. A stored service account is checked again only when the body selectsassign, so a body that changes or clears only the mode still works after that account is no longer usable. See Per-Profile Default Service Accounts.GET /:id/settings/resolved: Get project settings indicating whether a Hub default exists per-setting (non-admin gated).GET /:id/gcp-service-accounts: List the GCP service accounts registered to the project (includeHubScoped=trueadds the hub-scoped ones). Each item carriesmapping:mappedProfiles(Kubernetes broker profiles of the project whose state for the account ismapped, by the same shared decision as the status view below),reportedProfiles(profiles whose broker reported its mappings) andunreportedProfiles. A profile whose runtime needs no mapping (Docker, Podman, Applecontainer) is not counted. The same summary is onGET /api/v1/gcp-service-accounts?scope=project&scopeId=…, and absent at hub scope.GET /:id/gcp-service-accounts/:ref/status: The per-account status view, relative to this project.:refis the account’s id, email (case-insensitive) or display name, resolved as for agent create; more than one match returns400 identity_ambiguouswith the candidates’ ids and scopes, and an account that does not exist or is not reachable from the project returns404. The response has these sections:account:id,displayName,scope,email.verification: the Hub’s last token-creator check:status(verified,unverifiedorfailed),verified,verifiedAt,error.mappings: one entry per Kubernetes broker profile of the project’s Runtime Brokers, withbrokerId,brokerName,profileandstate:mapped;not_mapped; orunknown, withunknownReasonreport_missing(no stored report),report_incomplete,report_old_version(a broker that predates the current report version),report_stale,runtime_unrecognized(a runtime key the Hub cannot tell is Kubernetes) orambiguous_mapping. The state and reason are the Hub’s shared mapping decision, the one the pre-dispatchidentity_not_mappedrefusal and the not-mapped warnings use:mappedandnot_mappedneed a stored report that is complete, at the current report version, and stored or confirmed by the Hub within the last 15 minutes, on a profile whose runtime type is Kubernetes. A profile with no report at all isnot_reported, and a profile whose runtime needs no mapping (Docker, Podman, Applecontainer) is not listed. Mappings are owned by the broker and read-only here. These fields are added when the broker reports them and omitted otherwise, so an entry from an older broker hasstatealone:kubernetesServiceAccount,namespaceandsource(mappedfor an explicitkubernetes_service_account_mappingsentry,discoveredfor annotation discovery) on amappedentry;reportedAt, when the Hub last stored or confirmed the profile’s report;incompletewithincompleteReason(list_failed,unavailableorpending) when the report may not list every account the profile can serve; andambiguouswhen more than one Kubernetes ServiceAccount in the namespace is annotated with the account and none is mapped explicitly (the broker refuses it;stateisunknownwithunknownReasonambiguous_mapping). On the combo server’s embedded broker, explicit mappings come from its live settings and aremappedwhatever the stored report says; discovered mappings, the KSA, namespace and source, completeness and the report time come from its stored report. Until the broker’s next heartbeat refreshes that report, the view can differ from the pre-dispatch refusal, which reads the stored report only: an explicit mapping just added to the settings ismappedhere while the refusal can still refuse it, and one just removed isnot_mappedhere while the refusal leaves the dispatch to the broker (which refuses it).workloadIdentityBinding:stateisunknownwithreasonnot checked. The Hub does not read IAM policy for this view, so it never reports a binding as bound.defaultFor: the defaults that name the account:{"kind":"project"}(the project default inassignmode),{"kind":"profile","profile":"…"}(a per-profile project default) and{"kind":"hub"}(the Hub default inassignmode).agents:countandnames(sorted, at most 50) of the project’s agents whose applied GCP identity is the account, limited to the agents the caller may list.nextStep: the first missing link:codenot_verified,not_mapped(withbrokerNameandprofile: a profile whose default is the account but does not map it, or else the first profile that reported and does not map it when none does; anunknownprofile is not a missing link, except an ambiguous mapping, which the message names), ornone;messagesays what to do. Access is the same as reading the account withGET /:id/gcp-service-accounts/:saId, and a user must also be able to read the project. An agent may read it only from its own project (any other project returns404), and an agent token withoutproject:readgets an emptyagentssection.
GET /:id/members: List the project’s role bindings. WithgroupBy=principal, returns one item per principal (principalType,principalId,principalDisplayName,builtInRoleName, and itsbindings), andlimit/offset/totalCountcount principals rather than bindings. Each binding carriesroleKind(builtinorcustom).PUT /:id/members/principals/:type/:principalId: Set a principal’s whole project role set in one transaction.:typeisuser,agent, orgroup; a user can be addressed by ID or email. Body:roleDefinitionIds(at most one built-in membership role plus any custom project roles), optionalexpectedRoleDefinitionIds(a precondition:409 membership_changedif the principal’s current roles differ; an empty list means “not yet a member”), and optionalnotBefore/expiresAtfor newly created bindings. Returns the principal’s grouped membership withchanged;201when the principal was not a member before. An empty set is refused with400 empty_role_set(useDELETE), and an unknown, non-project, or second built-in role with400 invalid_role_set. Requiresproject.manageand a user identity: an agent token gets403 credential_insufficient, and a User Access Token follows the token rules below. Guards: the last usable owner (an in-effect owner binding of an existing, active user) cannot be removed or demoted, and the last owner binding of any kind cannot be removed (409 last_owner); an expired, scheduled, suspended, invited or deleted owner’s binding can be removed while another owner binding remains; built-in role changes follow the owner/admin governance rules (403 target_role_protected); creating or removing a custom role requires a direct project owner (or a signed-in caller with no project role who holds hubrole_bindingauthority) and the usualCanDelegateceiling; and a custom role that itself carries arole_binding.*permission cannot be granted through this endpoint by anyone.DELETE /:id/members/principals/:type/:principalId: Remove every project binding the principal holds, under the same guards.404if the principal holds none.GET /:id/members/assignable-roles: List the project-scoped roles withgrantablefor the caller, and, when not grantable, thereason,denialCode, anddetailsthePUTabove would return. Requiresproject.manage.GET /:id/visitors,POST /:id/visitors,PATCH /:id/visitors/:visitorId,DELETE /:id/visitors/:visitorId: List, grant, change the window of, and revoke project visitor grants for users of a trusted issuer (experimental:404while thefederation.project_visitorsexperiment is off). A visitor hasid(the visitor’s account ID, used by the item routes),bindingId,email,issuer,status(pendinguntil the visitor’s first request, thenactive, orsuspended),issuerAccepted(whether the Hub accepts visitors from that issuer now; while it isfalsethe grant is kept but the visitor’s requests are refused),grantedBy,notBefore,expiresAtandcreatedAt; the list returns{"visitors": [...]}.POSTtakesissuer,emailand optionalnotBefore/expiresAt, and creates a pending account when none exists for that issuer and email; the issuer must be trusted withallow_visitor_grantsand the email must pass itsallowed_emailsandallowed_domains(400withdetails.fieldotherwise; an invalid window namesexpiresAt); the platform service account and the proxy driver principals (server.auth.proxy.driver_principals) are never granted (400withdetails.field: email); a repeated grant answers409(change its window withPATCH).PATCHtakesnotBeforeandexpiresAtand replaces both (an omitted bound is cleared). Every route requiresproject.manage_visitors(project owners and admins, and hub admins) and an interactive session (user access credentials get403withdetails.reasonGOV_PENDING); granting, or aPATCHthat extends the window (an earlier or cleared start, or a later or cleared expiry), also requires holding every permission of theproject-visitorrole on the project; shortening a window or revoking does not. Revoking takes effect on the visitor’s next request. A linked visitor’s requests are admitted only on the routes open to visitors:GETandHEADof/api/v1/auth/me(GETreads the visitor’s own profile;HEADanswers405, as for any caller);GET /api/v1/projects/:id/agentsandGET /api/v1/projects/:id/agents/:agentId(an agent ID or slug) for a project the visitor holds a current grant on, answered withname,slugandstatusonly ({"agents": [...]}for the list, every agent of the project that is not deleted, whatever its message mode; with?messageable=true, only the agents the visitor may message now, the same check a send gets, and an agent whose check fails on an error is left out; other query parameters are ignored; a deleted agent is refused by ID and by slug);GET /api/v1/users/me/visitor-grants(the visitor’s own grants:projectId,projectSlug,projectName,status(active,scheduledbeforenotBefore, orexpired),notBeforeandexpiresAt, as{"grants": [...]}; a revoked grant is not listed);GET /api/v1/users/me/visitor-dms(the visitor’s own DMs with agents it may message now:conversationId,conversationKey,projectSlug,agentSlugandlastActivityAt, most recent first, as{"dms": [...]}); the two/users/me/visitor-*routes answer404for any caller that is not a visitor;POST /api/v1/projects/:id/agents/:agentId/messageandPOST /api/v1/agents/:agentId/message(a text message to an agent the visitor may message: a current grant on the agent’s project, and an agent message mode other thannone; the visitor is the senderuser:<visitor id>, its account ID, and a reply to that address reaches the visitor’s DM; the agent’s envelope metadata carriessender_visitor,sender_email(the issuer-verified email),sender_issuer(the issuer’s display name) andsender_display(for examplealice@corp.example (visitor via idp.example)), which only the Hub sets; the response to a visitor also carriesconversation_idandconversation_key(the visitor’s DM key with the agent, for the DM routes below), which no other sender’s response carries; a visitor’s message is text only: the request may carrymessage, orstructured_messagewithmsg,type(instruction,chatorreply) andrecipient(not a group;version,timestamp,senderandsender_idare accepted, and the Hub sets the sender, timestamp and recipient), and any other field, such asplain,interrupt,wake,notify,mentions,urgent,status,attachments,metadata,channel,thread_idorconversation_id, answers400withdetails.field); and, with native chat on,GET /api/v1/chat/conversations/:dmKey/messagesandGET/POST /api/v1/chat/conversations/:dmKey/readfor the visitor’s own DM with such an agent (dm:agent:<agentId>:user:<visitor id>). Every other route, method and target, including an unknown project or agent, a deleted agent (its DM is also left out ofvisitor-dms), an agent the visitor may not message, and another principal’s DM, answers403with one uniform message. Authorization runs on every request. An agent can send to a visitor only in a DM the visitor started with that agent, by the visitor’s addressuser:<visitor id>; any other send to a visitor (before the visitor wrote, or from another agent) is answered as for an unknown user, and the visitor’s email never addresses the visitor.GET /:id/visitors/rules,POST /:id/visitors/rules,PATCH /:id/visitors/rules/:ruleId,DELETE /:id/visitors/rules/:ruleId: List, add, change the window of, and remove visitor rules: visitor access for every user of a trusted issuer whose verified email is in a domain (same experiment, admission and non-amplification rules as the grant routes above). A rule hasid,issuer,domain,issuerAccepted,createdBy,notBefore,expiresAtandcreatedAt; the list returns{"rules": [...]}.POSTtakesissuer,domainand optionalnotBefore/expiresAt. The domain is matched exactly and case-insensitively against the part of the email after@: no wildcards and no subdomains (400withdetails.fielddomainfor a wildcard, an address or any other invalid domain). The issuer must be trusted withallow_visitor_grants, and, when set, itsallowed_domainsmust list the domain and itsallowed_emailsmust admit every address in it (a pattern such as*@corp.example). A repeated(issuer, domain)answers409. A rule is not itself a grant: on a matching user’s request (with the email claim present and, where the issuer sends it,email_verifiedtrue) the Hub links or creates that user’s visitor account and adds a grant whosesourceisruleandsourceRuleIdis the rule’s ID, carrying the rule’s window as it is when the grant is added. When that user’s grant by email on the project has expired or has not started yet, the rule’s grant replaces it; an active grant by email is kept, and the rule adds nothing on that project. A grant by email to a user who is already a visitor through a rule answers409; to keep access independently of the rule, revoke the visitor, then grant the email. After the first request the visitor’s account follows the issuer subject, as for grants by email: a later email change at the issuer does not remove a grant a rule added while the rule stands.GET /:id/visitorsshows each visitor’ssource(emailorrule).PATCHreplaces the rule’s window and that of every grant it added;DELETEremoves the rule and every grant it added in one transaction, and grants by email are never changed by a rule. Revoking one rule visitor withDELETE /:id/visitors/:visitorIdstops the rule adding that person again; such a visitor’s window follows the rule, soPATCH /:id/visitors/:visitorIdanswers409for it. While the issuer’sallow_visitor_grantsis off, no rule can be added for it and no rule adds anyone. Deleting a project removes its rules and every grant they added.POST /:id/agents/stop-all: Stop the project’s running agents. Authorized from role bindings: holders ofagent.stop_allon the project (project owners and admins, and hub admins) stop every agent (scope: all); other members with a built-in project role, direct or group-derived, stop only the agents they own and may operate (scope: own); anyone else, including a caller holding only a custom project role, gets403. The hub-widePOST /api/v1/agents/stop-allrequiresagent.stop_allon the hub.POST /:id/transfer-ownership: Body{"newOwnerId": "<user ID or email>"}. Grants the new ownerproject-owner, downgrades the caller to member, and moves the project’sownerIdto the new owner in one transaction. Errors:400 not_foundif the new owner does not exist and400 principal_ineligibleif they are not active (both also checked inside the transaction), and409 last_ownerif no other usable owner would remain (for example when the new owner’s existing binding is expired and no other usable owner exists).- Membership changes by User Access Token (
POST,PATCHandDELETEon/:id/members,PUTandDELETEon/:id/members/principals/:type/:principalId,POST /:id/transfer-ownership, and project-scoped bindings on/api/v1/admin/role-bindings): the token needs theproject:managescope and a boundary that covers the project (for a project-bound token, its holder must still be a member). The token then acts only through its holder’s own project role: the owner/admin governance rules, the direct-owner rule for admin and owner targets, last-owner protection and the one-binding rule apply as for a session, and a holder with no project role is refused (403 role_assignment_forbidden) even when they hold hubrole_bindingauthority. A role is granted only when the token carries a scope for every permission of the role (403 target_role_protectedotherwise); this includes the new owner’s role on a transfer.GET /:id/members/assignable-rolesreportsgrantableunder the same limits. POST /:id/clone: Deep-copy settings, labels, env vars, skills, hooks, harness configs, and templates to a new project with rollback protection. Supports an optionalgitRemotefield in the request body to override the source project’s git repository (carrying configurations over while using a different repository).GET /:id/workspace/files,PUT /:id/workspace/files/*,POST /:id/workspace/files,DELETE /:id/workspace/files/*,GET /:id/workspace/archive,POST /:id/workspace/pull: Browse, read, upload, delete, archive-download (ZIP), and git-pull a project’s workspace on the Hub. For Hub-managed projects and shared-workspace (shared) git projects, the Hub resolvesprojects/<uuid>/workspaceon a shared workspace storage backend (nfs,cloudrun-volume,gke-shared-volume) or~/.scion/projects/<slug>on thelocalbackend (409forempty-per-agentprojects;503from the file and archive endpoints if a shared backend’s mount point is missing). For a linked project in a per-agent git mode, the workspace file endpoints resolve only a co-located Runtime Broker’s local path (409when the project is served only by remote Runtime Brokers; the linked-project workspace cache andPOST /:id/workspace/synchave been removed and answer404).POST /:id/workspace/pullworks only for shared-workspace git projects (409otherwise, or when the workspace is not provisioned) and runs a hardened host-side git fetch and fast-forward, refusing if.git/configfails validation.
GCP Service Accounts
Section titled “GCP Service Accounts”A project’s own accounts live under /api/v1/projects/:id/gcp-service-accounts (GET, POST, POST /mint, and per account GET /:saId, DELETE /:saId, POST /:saId/verify). Hub-scoped accounts use the flat routes /api/v1/gcp-service-accounts?scope=hub and /api/v1/gcp-service-accounts/:saId. Register, verify and delete each write an audit event with the actor, the account’s scope and the account ID; the event never carries the account email. When IAM check mode is enforce and the account belongs to a GCP project other than the Hub’s own, POST (201) and POST /:saId/verify (200) add an advisory message to the response’s warnings array if the Hub cannot read that GCP project. The message names the gcloud command that grants the Hub’s service account roles/iam.securityReviewer there.
DELETE .../gcp-service-accounts/:saId[?force=true]: Remove a registration. The account is never deleted in GCP; a minted account is retained there. Before deleting, the Hub computes an impact report:agents(agents whose applied identity references the account, at most 50, with the total inagentCount),defaults(each project default, per-profile default and hub default that points at it, withtierproject,profileorhub, andclearable),brokerMappings(broker profiles whose last report maps it),managed, andmanualCleanup(the steps the Hub cannot do: remove the broker’skubernetes_service_account_mappingsentry, the Kubernetes ServiceAccount, the Workload Identity binding and the Hub’s token-creator grant, and for a minted account the account in GCP).- The report shows only what the caller could otherwise see.
agentsnames only agents the caller may read (visibleAgentCountin all); any others are reported inhiddenAgentCounts, one unnamed count per project. A default in a project the caller may not read keeps itstierandclearable, has noprojectIdorprofile, is markedredacted, and is counted inhiddenDefaultCount. clearableis decided for the caller: a project or per-profile default is clearable when the caller may update that project’s settings.- While any default points at the account, the delete returns
409with codesa_in_useand the report inerror.details.impact. force=trueclears the project and per-profile defaults the caller may clear, then deletes. If any referencing default is not clearable by the caller, the delete stays409, and the message says an admin of those projects must change them. A cleared project default whose mode wasassignbecomesblock, not unset, so new agents get no GCP identity rather than falling through to a broader hub or runtime default.force=truenever clears the hub default. While the hub default points at the account (clearable: false), the delete stays409until a hub admin changes the hub default.- Agents that reference the account do not block the delete. They keep the reference and fail at their next start with the existing “no longer available” error.
- Clearing and deleting are not atomic. If a forced delete fails after it cleared defaults, those defaults stay cleared. The error carries them in
error.details.clearedDefaults, redacted the same way, and retrying the delete is safe. - A settings write that runs at the same time as a forced delete is not serialized with it. It can still leave a default pointing at the removed account. Agents created from that default then fail at start with the existing “no longer available” error.
- Each project a forced delete changes publishes the same project-updated event as a settings save.
- On success the response is
200with{"deleted": true, "clearedDefaults": [...], "impact": {...}}. Earlier Hubs returned204with no body.
- The report shows only what the caller could otherwise see.
Runtime Brokers (/api/v1/brokers)
Section titled “Runtime Brokers (/api/v1/brokers)”GET /: List registered runtime brokers.POST /: Register a new compute node, or re-mint its join token. Requiresbroker.create(see Broker Registration Permission). The caller becomes the broker’s owner. Re-registering an existing broker requires ownership (see Broker Ownership). The Hub sets the reservedscion.io/pluginandscion.io/broker-rolelabels itself. On re-registration the Hub keeps the stored name, keeps the stored value of every existing label (it only adds absent keys), and fills the GCP host service account and project only while both are unset and were not set or cleared throughPATCH /:id; each non-empty requested value that is not applied is listed inkeptSettings(field,storedValue,requestedValue,reason). When the registration would writegcpHostServiceAccountEmailorgcpHostProjectId(a new Runtime Broker, or an existing one whose host service account and project are unset and not pinned), the caller must be a user principal, and, whengcp_iam_check_modeisenforce, must also holdiam.serviceAccounts.actAson the requested service account (403before any write on denial).POST /join: Complete the two-phase broker registration.GET /:id: Get broker status and capacity. When the Hub has observed the Runtime Broker’s host GCP identity (a co-located server reads it from the metadata server), the list,GET /:idand the health summary includehostIdentity.state(match,mismatch,unset_detected,pinned_absent,noneorunknown); for callers that may update the Runtime Broker, and in thePATCHresponse, it also carriesobservedEmailandobservedAt. It is omitted for a Runtime Broker that was never observed. For a flat Runtime Broker (scion.io/broker-role=flat),GET /:idalso includesruntimeTargetPresentation(the instance’s reported defaults for new agents:key,name,targetandagentDefaults, with environment variable names but no values, no kubeconfig context or service-account policy, and credentials in the image registry redacted) andruntimeTargetPresentationObservedAtfrom the instance’s own heartbeats.PATCH /:id: Update the Runtime Broker’sname, userlabels, and GCP host identity (gcpHostServiceAccountEmail,gcpHostProjectId). Setting either pins the GCP host pair across re-registrations;gcpHostPinned: falsehands it back to registration. Setting or changinggcpHostServiceAccountEmailorgcpHostProjectIdrequires a user principal and, whengcp_iam_check_modeisenforce,iam.serviceAccounts.actAson the requested service account (403before any write on denial;name- andlabels-only updates, an unchanged pair, and clearing the email do not run the check). APATCHthat sets or changes the pair writes it with compare-and-set against the stored pair theactAsdecision was evaluated against (409 Conflictif the stored pair changed concurrently). The reservedscion.io/pluginandscion.io/broker-rolelabels are ignored in the request and their stored values kept.GET /:id/projects: List the projects the Runtime Broker provides for, limited to the projects the caller can read. Each entry carriesprojectId,projectName,gitRemote,agentCount(the project’s agents on this broker),localPath, andisDefault, which istruewhen this broker is the project’s default Runtime Broker.
In the list (GET /api/v1/runtime-brokers, without projectId) and in GET /api/v1/runtime-brokers/:id, a Runtime Broker that runs in the process answering the request (the server’s co-located Runtime Broker or one of its co-located flat instances) carries the read-only hostedByThisServer: true. The field is omitted for every other Runtime Broker. With several Hub replicas behind a load balancer, it describes the replica that answered.
The list (with or without projectId) and GET /api/v1/runtime-brokers/:id carry a read-only versionStatus on each Runtime Broker: match, mismatch or unknown, comparing the Runtime Broker’s version with the Hub’s version (see Version mismatch). It is unknown when the Runtime Broker has not reported a version or when there is no single Hub version (no live Hub instance, or live instances running different versions), and it is omitted for plugin records. Both list responses also carry hubVersion, the Hub version compared with: the version every live Hub instance reports, mixed when they differ, or empty when it is unknown. A Runtime Broker reports its version (version, the release tag or the short commit) when it joins or registers, and in the optional version field of its heartbeat (POST /api/v1/runtime-brokers/:id/heartbeat). The heartbeat field is applied only on a heartbeat the Runtime Broker sends with its own credentials (not on a heartbeat sent by a user), and an omitted or empty value leaves the stored version unchanged. A co-located Runtime Broker records the server’s version each time the server starts.
Broker Settings (/api/v1/runtime-brokers/:id/settings)
Section titled “Broker Settings (/api/v1/runtime-brokers/:id/settings)”A general per-broker settings mechanism. maxAgents, a per-broker override of the max_agents_per_broker cap, is the first registered key.
GET /:id/settings: Read the broker’s stored settings document plus the effective (resolved) value for each key. Requiresbroker.read.404if the broker doesn’t exist. If the broker has no settings row yet,settingsis{}andrevisionis0.PUT /:id/settings: Replace the settings document. Body:{"settings": {"maxAgents": 30}, "expectedRevision": 3}. This is a full replace, not a merge: a key omitted fromsettings(or sent asnull) is cleared back to “inherit”. Each key’s own permission gates writing it —maxAgentsrequiresquota.update— checked only against keys whose value actually changes, so re-sending the current document needs no permission at all.
Response shape (both verbs):
{ "brokerId": "…", "settings": { "maxAgents": 30 }, "effective": { "maxAgents": { "value": 30, "source": "broker", "count": 7, "inherited": { "value": 100, "source": "hub_default" } } }, "revision": 3, "updatedBy": "admin@example.com", "updated": "2026-01-01T00:00:00Z", "_capabilities": { "update": true }}effective.maxAgents.source is one of broker (this broker’s own setting), entitlement (an entitlement binding), hub_default (the limit definition’s default value), or unlimited (no limit definition exists, or no quota service is configured); it is null/"" only if resolution itself fails. inherited reports what the value and source would be if the broker’s own setting were cleared, so the UI can always show what “use the default” means without having to clear it first to find out. count is the current active-reservation count against the same limit Reserve counts.
Status codes: 400 for an unknown key or an invalid value (maxAgents must be >= 0; 0 means unlimited); 403 if the caller lacks the permission a changed key requires; 404 if the broker doesn’t exist; 409 on a stale expectedRevision (the response body carries the current record under current, same shape as a normal GET).
Precedence for max_agents_per_broker (most specific wins): a broker’s own maxAgents setting, if set, is the effective limit — it can be lower than the hub-wide default and lower than any system-scoped entitlement binding. Otherwise, the existing entitlement-engine resolution applies: bindings (most generous wins), falling back to the limit definition’s hub-wide default value. In both layers, 0 means unlimited.
Migration from entitlement bindings. Earlier releases had no per-broker settings API, so operators worked around it with a broker-scoped entitlement binding on max_agents_per_broker — either a system_default binding with a non-empty subjectId, or a user binding whose subjectId is set to the broker’s own ID (the “user-subject hack”). On upgrade, a one-shot migration copies every broker-scoped binding on this limit into a maxAgents setting (0 if any binding was 0, otherwise the largest value), regardless of subject — including bindings the entitlement engine never actually enforced (a system_default row needed an empty subject to be picked up, so a non-empty-subject row was previously a silent no-op; a user binding scoped to the broker but owned by some other user was likewise never matched). Sweeping these up anyway restores what the operator evidently intended when they scoped a binding to that broker. This means an upgrade can newly impose, or tighten, a broker’s effective cap for a broker that previously had no effective per-broker limit at all (or was really being capped by a more generous system-scoped binding, since the engine used to merge broker- and system-scoped bindings with “most generous wins” — the migrated setting no longer merges with anything). The migration is attributed as updatedBy: "migration:ptone/scion#2061", and it only fills in brokers that don’t already have a maxAgents setting.
It never deletes the old bindings — they are shadowed by the new setting per the precedence above, and their IDs are logged at migration time so an operator can find and remove them. Removing them matters: if the migrated maxAgents setting is later cleared (“use hub default”), the precedence rule falls through to the entitlement engine, and any leftover binding the entitlement engine matches (a user binding with subjectId equal to the broker ID, or a system_default binding with an empty subject — i.e. the shapes the migration actually inherited enforcement from) becomes live again. The never-enforced shapes described above stay inert either way. Clearing the setting does not by itself restore the hub-wide default if a matching binding is still there. Legacy broker-scoped bindings can still be deleted (DELETE /entitlements/:id) or read normally; they just can’t be created fresh or edited while staying broker-scoped — see the 400 below.
Because of this, creating a new broker-scoped binding on max_agents_per_broker, or editing an existing one while keeping it broker-scoped (POST on /limits/:id/entitlements, or PUT on /entitlements/:id with scopeType: "broker"), now returns 400 with the message per-broker agent caps are set via PUT /api/v1/runtime-brokers/{id}/settings. System-scoped bindings for this limit are unaffected and continue to work as the hub-wide override.
Artifacts (/api/v1/artifacts)
Section titled “Artifacts (/api/v1/artifacts)”Published files and bundles with stable, versioned scion://artifact/<id>[@<seq>] references: single-file publish (POST /?name=<file>), two-step publish (POST / or POST /:id/versions with a manifest, PUT /:id/versions/:seq/files/:path, POST /:id/versions/:seq/finalize), the caller’s list (GET /?mine=1, optionally narrowed to one project with scope=<project-id>, which adds canCreate for the caller, or to what is shared with the caller with shared=1), metadata (GET /:id, GET /:id/versions (ready versions plus discarded ones, with only number, state, reason and the time the number was taken; a page can hold fewer than limit ready versions, so clients follow nextBefore), GET /:id/versions/:seq; GET /:id and GET /:id/versions/:seq carry canManage, canShare, canRetire, canPublish, canReview and canGrantReview for the caller, and scopeDeleted when the home project was deleted), file bytes (GET /:id/files/:path, GET /:id/versions/:seq/files/:path, with ?resolve=clean|accept for a review’s text with its CriticMarkup marks resolved), reviews (a version of kind review, checked at finalize), views of HTML bundles (POST /:id/versions/:seq/view, GET /view/:capability/:path), grants (GET, POST /:id/grants, DELETE /:id/grants/:grantId; on an artifact owned by an agent, the agent’s delegating user and a home-project admin may also add a review grant for a user who can already read it through the home project or a grant to one of their projects; a review grant allows review versions only and gives no read access of its own), expiry and moving (PATCH /:id), and share links (POST /:id/links, GET /:id/links (with the hub’s defaultTtlHours and maxTtlHours), DELETE /:id/links/:linkId, and GET /shared/:token[/files/:path], which needs no credentials). Behind the hub.artifacts experiment (off by default): while it is off every route answers 404. See Artifacts for access rules, status codes and delivery headers.
Chat Unread Count (/api/v1/chat/unread-count)
Section titled “Chat Unread Count (/api/v1/chat/unread-count)”GET /: The number of chat conversations with unread messages for the signed-in user. Response:{"conversations": <threads + dms>, "threads": <n>, "dms": <n>}. A thread counts when the caller is a member of it, can read its project, has not muted it, and has not read its latest message. A DM counts when the caller is one of its parties, has not muted it, and has not read its latest message. Requires a user identity. The Web Dashboard showsconversationson the Chat mode badge and in the tab title. This replaces the chatMENTIONandDM_RECEIVEDnotification rows, which the Hub no longer creates.
Chat Starred Messages (/api/v1/chat/stars)
Section titled “Chat Starred Messages (/api/v1/chat/stars)”Behind the web.chat_starred_messages experiment (404 while off; stored stars are kept while the experiment is off). Stars are private to each user (user.<id>.chat.stars SSE events sync changes across the user’s open tabs) and cover project space threads (thread:project:...; DM conversations return 404). User access tokens require inbox:write for PUT and DELETE and inbox:read for GET, and for PUT and GET, live read access to the thread’s project within the token’s boundary (DELETE always answers 204 and removes only a star inside the token’s boundary). See Starred messages.
PUT /:messageId: Star a message in a project thread. Body:{"conversationKey": "<key>"}. Idempotent: returns200{"starred": true, "starredAt": "..."}(keeping the originalstarredAtwhen already starred).DELETE /:messageId: Remove the caller’s star on the message (204).GET /?conversationKey=<key>[&limit=1..100][&cursor=...]: List the caller’s starred messages in that conversation, most recently starred first (defaultlimit30; paginated with a sealednextCursor).GET /state?messageId=<id>...: Batch check for 1 to 200 distinct message UUIDs; returns{"starredMessageIds": [...]}for messages the caller has starred and can currently read.GET /:messageId: Return the current detail of one starred message (messageId,conversationKey,conversationKind,projectId,projectName,conversationName,senderName,messageCreatedAt,starredAt,excerpt,edited,permalink), checked against the caller’s live read access to the conversation’s project (404otherwise).
Chat Attachments (/api/v1/chat/attachments)
Section titled “Chat Attachments (/api/v1/chat/attachments)”GET /: Report where the calling credential’s attachment uploads go:{"uploads": "artifact"|"legacy", "delivery_max_bytes"?: <n>}, wheredelivery_max_bytesismessaging.attachment_delivery_max_bytes(0when agent attachment delivery is off). Open to user sessions, user access tokens and agent credentials;uploadsis"artifact"only when the experiment is on, the artifact service is ready and the caller may write (an agent must be in good standing), and only then isdelivery_max_bytespresent.POST /: Upload one or more files (multipart/form-data, fieldfiles, optionalproject_id). Max 10 files; 10 MB each on legacy chat storage, orartifacts.max_file_bytes(32 MiB by default) when thehub.attachment_artifactsexperiment is on and the artifact service is ready, which also opens uploads to agents in good standing. Whenhub.attachment_artifactsis off, files are stored in the Hub’s attachment store and copied into the project’sscratchpadshared directory (.attachments/_webchat/<id>/<name>) when a message carrying them is sent to agents. Text files containing unusual control characters (e.g., vertical tab0x0B) are supported and correctly identified as text. When a message with attachments is delivered to an agent, the Runtime Broker redeems one single-use, HMAC-signed token per file atGET /api/v1/attachment-deliveries/:token, with its own Runtime Broker credential, to write the files into~/.scion/inbox/<message-id>/.GET /:id: Download a stored attachment. An attachment stored as an artifact is served by the artifact service. Any other attachment is served to its uploader, to readers of its project and to readers of a message it is attached to (a user access token needs read access to the file’s project when the file has one); anyone else gets404. Responses carryX-Content-Type-Options: nosniff, andContent-Disposition: inlineonly for image types — everything else is served as anattachment.
Uploads are accepted or refused per file, and the response reports both outcomes:
{ "attachments": [{ "id": "…", "name": "compose.yaml", "mime": "text/plain", "size": 34, "url": "/api/v1/chat/attachments/…" }], "failures": [ { "name": "setup.sh", "error": "files with a .sh extension are not accepted" }, { "name": "notes.html", "error": "files with a .html extension are not accepted" } ]}size is the stored file’s length in bytes — the example assumes a 34-byte compose.yaml — while id and url are elided here because both are assigned per upload.
Status codes:
201 Created— at least one file was stored.failuresmay be non-empty. Previously a single bad file failed the whole batch with400and stored nothing; clients that treat201as “all files stored” must now readfailures, or they will drop files silently.400 Bad Request— nothing was stored and the caller can fix it (blocked extension, unaccepted type, oversized file).500 Internal Server Error— nothing was stored and the failure was server-side.
The stored MIME type is derived from the file’s content plus its extension; the Content-Type a client declares on the part is ignored. Executable extensions (.exe, .sh, .js, .ps1, and their peers) and markup extensions (.html, .svg, and their peers) are refused whatever the content is.
Experiments (/api/v1/experiments)
Section titled “Experiments (/api/v1/experiments)”GET /: Return the resolved hub-wide experiment map,{"experiments": {"<name>": true|false}}. Available to any authenticated identity (user, agent, or Runtime Broker token). The web client fetches it at boot. Admins change values through/api/v1/admin/experiments.
Profiling (/api/v1/profiling)
Section titled “Profiling (/api/v1/profiling)”GET /: Return the profiling switches the web client acts on,{"readinessMarks": true|false}, and nothing else. Available to any authenticated identity; a request without credentials gets401. Admins change the value through/api/v1/admin/profiling. See Profiling.
Conduit (/api/v1/conduit)
Section titled “Conduit (/api/v1/conduit)”Behind the hub.conduit experiment. With the experiment off, GET / and GET /grant-keys answer any authenticated caller with 404; the rotate route answers 404 only after its permission and session checks pass.
GET /: Open a Conduit session (WebSocket). Open to an agent token with theagent:port:forwardscope, to a Runtime Broker signing its request, or to a user (the same credential as attach, used byscion tunnel); any other caller gets403(a Runtime Broker request with a missing or invalid signature gets401). Returns503when the Conduit relay is not running on that Hub node (see Conduit settings); when the relay failed to start because of a setting, the503message names that setting. Returns400for a request that is not a WebSocket upgrade. For an agent token,401when the agent no longer exists (403if it is soft-deleted). A user credential whose kind cannot open a session gets403, and503when the Hub cannot read the credential’s expiry.- Tunnel RPC (on a user’s Conduit session, not an HTTP route):
POST /v1/tunnelswith{"agent": "<agent id>", "kind": "tcp|pty|ssh", "params": {...}}asks for one tunnel. Params:tcptakesport(host, if given, must be127.0.0.1);ptytakescolsandrows(default 80x24) and an optionalsession(onlyscion);sshtakes none. Each tunnel is authorized on its own:agent.port_accessand an allowed agent-local port fortcp;agent.attachandagent.port_accessforptyandssh. On success the Hub opens a stream toward the session and answers withstreamId,sessionId,agentId,projectId,kindand, when set,authzDeadline. Refusals:400 invalid_request,401,403,404 agent_not_found,409 agent_not_running,429(the session’s tunnel limit),503(Runtime Broker or agent session unavailable, or a temporary Hub fault withdetails.reason);405for a method other thanPOST;500 internal_errorfor an unexpected Hub fault; while the session drains,503withRetry-After: 0(retry on a new session). GET /grant-keys: The public grant verification keys,{"keys": [{"kid", "public_key", "not_after_unix"}]}. Available to any authenticated identity.POST /api/v1/admin/conduit/grant-keys/rotate: Rotate the grant signing key. Requireshub.conduit_grant_keys.executeand an interactive session or the local dev credential. Returnskid,activate_atandretiring. Returns409if the previous key is not active yet.POST /api/v1/admin/conduit/grant-keys/revoke: Revoke a grant key at once. The body is{"kid": "<key id>"}. Same permission and credentials as rotate. The key stops verifying anything, and streams whose latest grant it signed get no further renewal. Returns{"kid"};400without akid;404for a key the Hub does not hold (or when Conduit is off);409for the key that signs now (rotate first).
Templates (/api/v1/templates)
Section titled “Templates (/api/v1/templates)”A template’s or harness config’s contentHash (sha256:<64 hex>) depends on the hub.content_hash_v2 experiment, off by default:
- Off: commits record a hash of the file contents only. A rename-only or chmod-only re-upload does not change it, so it is not visible to the
expectedContentHashcheck: a finalize that diffed against the version before such a commit is accepted. - On: commits record a hash that also covers each file’s path and executable bit. A rename-only or chmod-only commit changes it, and a finalize that diffed against the version before it answers
409. - Each commit records the version active when it runs, and a row keeps it until its next commit. The
409check compares the stored value exactly, so turning the experiment on or off is safe at any time; at most, a client holding a hash read before a toggle gets one409after an unchanged recommit, and succeeds after re-reading. - When to turn it on: only when every CLI and agent image that talks to the Hub is at or after the release that added it. An older CLI compares its own contents-only hash with the Hub’s, so with the experiment on it reports templates as out of date, and
scion start,resumeandcreatewith-tfor a template it also has locally fail without a terminal (work around with--no-upload, or--yes, which re-uploads the template each time, or upgrade the CLI).
Do not compare a contentHash with a hash you compute from local files to decide whether they are in sync; compare the files manifest (paths, hash and mode) instead. Skill version content hashes (contentHash) cover file contents only, whatever the experiment; with the separate hub.skill_content_hash_v2 experiment on, skill version and skill resolve responses also carry contentHashV2, which covers paths and modes too (see Content hash forms).
GET /: List available agent templates. The authorized list validator caps list requests at a maximum limit of 100 templates per page (default is 50). Requests specifying alimitquery parameter greater than 100 will fail with HTTP 400 Bad Request.POST /: Upload a new template or version. A template’s agent config lives only in its files (scion-agent.yaml). A body that carriesconfiganswers400template_config_in_file(set it inscion-agent.yaml), and one that carriesimageanswers400template_image_not_supported(the image comes from the harness-config or settings). The same rules apply to user templates.GET /:id: Get a template. The response includesagentConfig, a read-only snapshot of the template’s parsedscion-agent.yaml, derived by the Hub on every commit with the same parser and key order the Runtime Broker uses, together with the derivedharnessanddefaultHarnessConfig.agentConfigis a faithful parse: it may include environment keys (image,resources,volumes,kubernetes,hub,telemetry) that agents ignore.PUT /:id: Update a template’s metadata only:name,slug,displayNameanddescription. Every other field (the derived fields, files, content hash, storage location, scope and ownership) keeps its stored value whatever the body sends. A body that carriesconfigorimageanswers the same400s asPOST /. To change a template’s config, edit its files and commit them (upload, then finalize). The same rules apply to user templates.POST /:id/finalize: Commit a template’s file manifest after its files are uploaded. The body is{"manifest": {"files": [...]}}with the complete file list, and optionallyexpectedContentHash, the template content hash the client diffed its upload against. A template changed by another commit since then answers409with codetemplate_conflictand nothing is committed; re-read the template, re-diff and finalize again. WithoutexpectedContentHashthe check uses the content hash the Hub read when the request arrived. The file endpoints (PUT,POSTandDELETEunder/:id/files) answer the same409when another commit lands while they run.scion templates syncretries a conflict once, re-diffing against the Hub’s new version.POST /:id/upload: Get upload URLs for a template’s files. The body is{"files": [{"path", "size"}]}. The response hasuploadUrlsand anuploadId. The URLs stage the files; nothing is visible to readers until finalize commits them. With local storage the URLs point at the Hub’s own file endpoint with?uploadId=, which stages a file without committing it.POST /withfilesreturns the sameuploadUrlsanduploadId. When the CLI or transfer client uploads collected files (for templates, harness configs, skills, or workspace sync), each file is re-opened through anos.Roothandle on the collected directory, verified to still be a regular file of the recorded size, and hashed as it is read. If a file’s size or SHA-256 hash no longer matches what was recorded at collection, the upload fails before it is finalized, so changed content is never committed.POST /:id/finalizealso takes an optionaluploadIdnaming the upload whose staged files it commits. Without one, the Hub uses any staged file whose content matches the manifest hash. The Hub hashes every staged file. A file whose content does not match its manifest hash answers400and nothing is committed. A file that was neither staged nor already stored answers400withfile not found: <path>.GET /:id/download: Get signed download URLs for a template’s files. The response includescontentHash, the content hash of the version the URLs were signed for; a client that caches by content hash should key on it, or on a hash it computes from the files it downloaded. Template files are stored by content and never overwritten, so the URLs of one response keep returning that version even if a new version is committed while the client downloads.POST /:id/reimport: Refresh a template imported from a GitHub folder URL, replacing its files with the current content of the source folder (see Refreshing a Hub template from its source). Optional body:sourceUrl, which replaces the stored source URL for this refresh and is stored if the refresh succeeds. The caller must be able to read the template and to import templates into its scope. Built-in templates and templates with no source URL are refused with400(no_source_url), as are URLs that are not https GitHub folder URLs or that include a username or password (unsupported_source). A failed fetch or import answers400reimport_failed. On success the response has the same shape as a template import.
Harness configs (/api/v1/harness-configs)
Section titled “Harness configs (/api/v1/harness-configs)”A harness config’s files are its content. Its harness and config fields are derived from its config.yaml at every commit, and a key absent from config.yaml clears its field. Every change to the files goes through one commit. The commit checks the content hash, layout and storagePath the Hub read when the request arrived, and refuses the change if another commit landed in the meantime.
GET /: List harness configs. Paginated withnextCursor(see Pagination).POST /: Create a harness config. The body hasname,harness,scope(global,projectoruser) and optionallyscopeId,slug,displayName,descriptionandfiles([{"path", "size"}]). A body that carriesconfiganswers400with codeharness_config_config_in_file; set the config inconfig.yamlinstead. Withfiles, the response hasuploadUrlsand anuploadId, as forPOST /:id/upload.PUT /:idandPATCH /:id: Change the metadata:name,slug,displayNameanddescription. Every other field keeps its stored value. Unlike a templatePUT, a body that carries other fields (for example theconfigaGETreturned) is not refused; those fields are ignored. A metadata change never reverts a file commit.POST /:id/upload: Get upload URLs for a harness config’s files. The body is{"files": [{"path", "size"}]}. The response hasuploadUrlsand anuploadId. The URLs stage the files; nothing is visible to readers until finalize commits them. With local storage the URLs point at the Hub’s own file endpoint with?uploadId=, which stages a file without committing it.POST /:id/finalize: Commit a harness config’s file manifest after its files are uploaded. The body is{"manifest": {"files": [...]}}with the complete file list, and optionallyuploadId, naming the upload whose staged files it commits, andsourceUrl, recorded as the harness config’s source. WithoutuploadId, the Hub uses any staged file whose content matches the manifest hash. AnuploadIdthat is not a valid upload ID answers400. The Hub hashes every staged file. A file whose content does not match its manifest hash answers400(uploaded content does not match the manifest hash: <path>), and a manifest hash that is not a sha256 content hash answers400. A file that was neither staged nor already stored answers400withfile not found: <path>. In each case nothing is committed.- The commit answers these errors for finalize and for the file endpoints (
PUTandPOSTunder/:id/files, andDELETE /:id/files/<path>):409with codeharness_config_conflict: another commit changed the harness config since the request read it, and nothing is committed. Re-read the harness config and retry.422with codeharness_config_unusable:config.yamldeclares a provisioner that could never provision an agent. Nothing is committed, and the file endpoints refuse such aconfig.yamlbefore writing it to storage.
DELETE /:id/files/config.yamlanswers400with codeharness_config_yaml_required, because a harness config needs itsconfig.yaml. Edit the file instead.GET /:id/download: Get signed download URLs for a harness config’s files. The response includescontentHash, the content hash of the version the URLs were signed for. A file the manifest dropped keeps its stored content until a later cleanup removes it, so a download that is already in progress can finish.
Auth (/api/v1/auth)
Section titled “Auth (/api/v1/auth)”GET /admin-status: Report the signed-in user’s Hub admin status:isAdmin,isSuperAdmin, andpermissions(the user’s effective system-scoped permission IDs, sorted; every permission for a super-admin).isAdministrueonly for Hub admins and super-admins. A user who holds system-scoped permissions through another role getsisAdmin: falsewith those permissions listed. The web UI shows each admin page and navigation item frompermissions, not fromisAdmin.GET /scopes: Dynamically discover all available User Access Token (UAT) scopes and their descriptions. WithprojectId, each scope also reportseligibleand, when not eligible,eligibilityReason, for the caller in that project. A project the caller cannot access and a nonexistent project are denied identically. Minting a token (POST /api/v1/auth/tokens) re-checks every requested scope against the caller’s live authority and refuses an ineligible one with403 scope_violation. The mint body names the token’s boundary either with theprojectIdshorthand or withboundary: {kind, projectId}, wherekindisprojectorhub; a missing or blank boundary, conflicting forms, an unknown kind, or a hub boundary with a project ID is rejected with400. Hub token responses carryboundary: {"kind": "hub"}and noprojectId. See Hub-bound tokens.POST /validate: Check a sign-in token, sent as{"token": "..."}. Returns{"valid": true}with the token’s user, expiry and type, or{"valid": false}. A token whose user has been deleted or is suspended returnsvalid: false. If the user record cannot be read, the request fails with503 store_error.
Users (/api/v1/users)
Section titled “Users (/api/v1/users)”GET /: List users (admin only).POST /: Pre-register a user (statusinvited), the same record an admin invite creates, with an optional display name. Body:email(required; the invite email rule),displayName(optional, trimmed, at most 128 characters, no control characters) andnote(optional, not trimmed, at most 500 characters, no control characters other than line breaks). Any other field gets400 invalid_request; any non-nullrolegets422 unprocessable(details.reason:privileged_role_not_provisionablefor"admin",role_selection_not_supportedotherwise), because the role is assigned at first sign-in. Requires theuser.invitepermission, with an interactive sign-in session or a user access token. A token needsuser:inviteon a hub boundary: a token without it, or on a project boundary, is refused with403,details.reason: credential_insufficient, and a token whose user lacksuser.invitewith403. On a hub running with dev authentication (a single-user workstation), every caller is refused with403,details.reason: dev_auth_not_supported. Returns201with{user, created: true, warnings?}and aLocationheader for a new record, and200withcreated: falsewhen an invited record with the same display name and note already exists. Another existing record for the email gets409 conflictwithdetails.reasonuser_exists,pending_user_exists(withdetails.userId) oruser_suspended_exists. Callers withoutuser.read(for a token, withoutuser:read) always getuser_exists, and on a repeat only{email, status}. Each new pre-registration (201) writes auser_provisionaudit record, recording the token when called with a user access token; a repeat or conflict writes none.warnings(reserved_identity,domain_not_authorized,sign_in_currently_blocked_by_access_mode) are advisory and mean sign-in for the email is currently refused. CLI:scion hub users provision.GET /:id: Get user details and capabilities.PATCH /:id: Update user attributes. Accepted fields:displayName,role,status, andpreferences; any other field is rejected with400.preferencesis merged per key onto the stored preferences rather than replacing them: a key that is omitted is left unchanged, an empty string clears it, and unknown keys are ignored. Preference keys aredefaultTemplate,defaultProfile,theme, andtimezone.timezoneis the user’s display timezone, an IANA zone name such asEurope/Berlin; empty means Auto (the browser’s zone). An invalid zone returns400./auth/mealso returns the caller’s preferences. A hub token withuser:updateoruser:suspendcan change another user; see User Access Tokens.GET /me/terminal-workspace,PUT /me/terminal-workspace: Read or save the caller’s open terminal list for the web Terminal Workspace. The document is{"agentIds": [...], "frontmostAgentId": "<uuid>|null"}, with up to 32 canonical agent UUIDs; the response addsrevision,updatedAt, andpruned.PUTreplaces the saved document (last writer wins).GETdrops agents that no longer exist or that the caller can no longer attach to, and reports how many were dropped inpruned. Requires an interactive web session or a dev credential; there is no admin access to another user’s list.DELETE /:id: Delete a user. Returns409 last_owner(withdetails.projects) if the user is the only usable (active, existing) owner of any project or holds its last owner binding, and409 conflictif a concurrent grant or role change to the user’s role bindings commits before the delete’s cascade (retry; a concurrent revoke does not abort the delete). Also returns409 user_delete_blockedwhile agents or delegations depend on the user.details.agents(id,slug,projectId,deletionState) lists the user’s agents that have not been deleted: the agents the user owns, the agents whose ancestry root is the user (started by the user’s agents or their child agents), and the agents started by the user’s own schedules (an agent started by a schedule that an agent created is not counted).deletionStateis empty, ordeleting,finalizingorfailedfor an agent whose delete is under way or failed.details.edges(id,delegateType,delegateId,scopeType,scopeId) lists the active delegations from the user to an agent that exists (not soft-deleted) and is not already indetails.agents. Each list holds at most 50 entries, anddetails.truncatedistruewhen either has more. All of them must be deleted first. The guards answer in this order: self-delete and last super-admin (409 conflict), the admin lockout guard,409 user_delete_blocked, then409 last_owner. A delegation to an agent that is gone, or to another user, does not block, and a successful delete deactivates every active delegation edge with the user as the delegator. A soft-deleted agent of any of these kinds cannot be restored once the user is deleted (409 conflict, the user or agent it belongs to no longer exists). On success all of the user’s role bindings are removed too, and the user’s user-scope secrets and environment variables are removed afterwards as a best effort (see Deleting users). After the delete, that user’s tokens stop working immediately: web and CLI sign-in tokens get401with codeuser_not_found, and the user’s access tokens (scion_pat_) are also refused, with401and codeunauthorized.GET /:id/delete-blockers: List what blocks deleting the user, without deleting:{agents, edges, truncated}, the same lists and limits as thedetailsof409 user_delete_blocked(empty lists when nothing blocks). It takes no lock, so a delete can find a blocker added after the listing. Requiresuser.deleteon the user and a sign-in session, as the delete does (other credentials get403withdetails.reason: GOV_PENDING); an unknown user gets404.POST /:id/revoke-sessions: Revoke all active sessions for a user (admin only). Increments the user’s session generation counter, causing every existing cookie-based session to be invalidated on the next request. The affected user is forced to re-authenticate.
Groups (/api/v1/groups)
Section titled “Groups (/api/v1/groups)”GET /: List groups (filters:ownerId,parentId,groupType,projectId,search;limitup to 100, default 50; paged withnextCursor, see Pagination). Users withgroup.liston the hub see every group; others see only the groups they may read.POST /: Create a group. Body:name(required),slug(defaults to the slugified name; theproject:prefix is reserved),description,parentId,labels,annotations,ownerId. Onlyexplicitgroups can be created. WithparentId, the new group also becomes a member of the parent, so the caller needs the authority to add a member to the parent, and the parent’s member limit applies. The creator is added as an owner. Returns201, or409if the slug exists.GET /:id,PATCH /:id,DELETE /:id: Get, update (name,description,labels,annotations,ownerId) or delete a group, by ID or slug.project_agentsgroups cannot be deleted. Deleting a group named by an access constraint, or removing a member from one, requiresaccess_constraint.admin.GET /:id/members: List members with adisplayName.POST /:id/members: Add a member. Body:memberType(user,grouporagent),memberId(a user may be named by email and a group by slug),role(member, the default,adminorowner). Only group owners, the group’sownerIdand users withgroup.updateon the hub can add owners or admins; agent callers, and a Runtime Broker acting for itself, can add onlymember(a Runtime Broker request made on behalf of a user follows the user rules). The caller must already hold every permission the membership would grant. Adding a group that would create a cycle in the hierarchy returns400; a project members group cannot be added to another group. Returns201, or409if already a member.GET /:id/members/:type/:memberId,DELETE /:id/members/:type/:memberId: Get or remove one membership. Removing the last owner returns400.- Changing a group that carries a role binding (directly or through a parent group) is refused for user access tokens with
403(details.reason: GOV_PENDING). GET /api/v1/users/me/groups: The calling user’s effective groups, including those reached through nested groups (user callers only, including a Runtime Broker request made on behalf of a user; an agent, or a Runtime Broker acting for itself, gets401).GET /api/v1/agents/:id/groupsdoes the same for an agent.GET /api/v1/principals/:type/:id(user,agentorgroup) returnsprincipal,directGroupsandeffectiveGroups. For an agent,directGroupsequalseffectiveGroupsand an optionaldelegatesFromis added; for agroup, both lists are empty.
Test identities (/api/v1/test-identities)
Section titled “Test identities (/api/v1/test-identities)”Short-lived synthetic users for testing on test hubs. Off by default: the hub must start with --test-mode=member:<hub id> or above (admin:<hub id>, super-admin:<hub id>) (Helm: hub.testIdentities.enabled: true with hub.hubClass: test); see “Test mode (operators)” below. While it is off every route below returns 404, and any token of a test identity created earlier is refused with 401. Every route requires the test_identity.issue permission at hub scope. No built-in role grants it except super-admin. A hub-bound access token carrying only the test_identity:issue scope is the intended issuer credential for CI and operators.
POST /: Create a test identity and return one access token for it. Body (all optional, unknown fields rejected with400):role("member"or"viewer", defaultmember; there is no admin value, and any other value, including"admin"or"Admin", gets400),purpose(at most 200 characters),lifetimeSeconds(identity lifetime, 60 to 28800, default 3600) andtokenTtlSeconds(token lifetime, default 1800, never past the identity’s expiry). The hub generates the email (test-identity-<random>@scion-fixture.invalid; that domain is reserved and refused by every sign-in and provisioning path). Returns201with{identity, accessToken, tokenType, expiresIn, tokenExpiresAt}. There is no refresh token and no cookie. At most 5 live identities per issuer and 20 per hub (429 quota_exceeded), and issuance is rate limited per issuer (429 rate_limited). A transient store conflict also answers429(details.reason: issuance_busy) withRetry-After. Each issuance writes atest_identity_issueaudit record.POST /:id/token: Issue a new access token for a live test identity. Allowed for the identity’s issuer and for an admin session. Body (optional):tokenTtlSeconds. Returns200with the same shape asPOST /. An expired identity returns409. An identity the caller did not issue returns404. Each re-issue writes atest_identity_token_issueaudit record. An admin test identity’s token is re-issued only through the admin-scope delegation that issued it; for any other caller the route answers409.POST /:id/web-login-code: Mint a single-use web-login code for a live test identity, for a browser session as the identity. Allowed for the identity’s issuer, for a workload issuer delegation on the member-tier identities issued through that delegation (see below), and for an admin session (a test identity itself gets403); another issuer, an unknown ID and a user who is not a test identity get404. An admin test identity (test_hub_admin,test_super_admin) is admitted only on a hub started at its level withserver.hub.test_identities.admin_web_sessionsset to the hub id (otherwise404), and only for a caller who passes that kind’s issuance checks (a live super-admin holding the kind’s capability; an access token with exactly the kind’s scope; otherwise403); a workload delegation mints a code for an admin identity only through the admin-scope delegation that issued it. Available only when the hub also carries the test infrastructure marker with its web session key and, in proxy auth mode, lists at least one driver principal (server.auth.proxy.driver_principals); otherwise404withdetails.reason: test_identity_web_sessions_unavailable. Returns201with{code, codeId, expiresAt}andCache-Control: no-store. The code is valid for 5 minutes (never past the identity’s expiry) and can be redeemed once; a new code replaces an unredeemed earlier one. An expired or inactive identity returns409. Each mint writes atest_identity_web_code_issueaudit record, which never holds the code. The code is redeemed on the web server withPOST /auth/test-identity/redeemand a JSON body{"code": "..."}:204and a session cookie for the identity, or401 invalid_or_expired_code. In proxy auth mode the redeem also needs a valid proxy assertion for a driver principal (otherwise401or403, before the code is read), and the session is honoured only with that driver’s assertion. See Browser Sessions as Test Identities.GET /: List the caller’s own test identities, or every test identity for an admin session. Only live identities are listed unlessincludeExpired=true; newest first, at mostlimit(default 100, at most 500). Returns{items: [...], truncated}, each item withid,email,role,purpose,issuedBy,expiresAt,createdandlive.DELETE /:id: Delete a test identity. Allowed for the identity’s issuer, for an admin session, and for a caller that also holdsuser.delete. Only a test identity can be deleted here: any other user ID, an unknown ID, and an identity the caller may not delete all get404, even for an admin; so does a second delete of the same identity. It runs the same checks and cascade asDELETE /api/v1/users/:id:409 user_delete_blockedwithdetails.agents(id,slug,projectId,deletionState) anddetails.edgeswhile the identity’s agents or active delegations exist, and409 last_ownerwithdetails.projects(id,name) while it is the last owner of a project; delete those first (the identity’s projects are not deleted for you). Nothing is deleted on a409. On success (204) its role bindings and group memberships are removed with it and its tokens get401at once. Each delete writes atest_identity_deleteaudit record in the same transaction (an audit failure rolls the delete back). A transient store conflict answers429(details.reason: issuance_busy, the same reason issuance uses) withRetry-After; retry.DELETE /:id?purge=true: Delete a test identity together with what it created, directly or through its agents. The hub records, when an agent, project, schedule or group is created, which test identity is at the root of the creating chain; the purge removes exactly the resources recorded for this identity, and nothing else. The identity ID is the only input: any other query parameter, or a request body, gets400. In order it removes the identity’s agents (sub-agents first, with their files on the broker unless an earlier delete request kept them, never left soft-deleted), its schedules and groups, its projects, then the identity itself (as the delete withoutpurge). A project is kept when another user or group holds its owner role, or when it holds an agent, schedule or group someone else created; the identity’s own role bindings on it are removed and the report lists it (ownerless: truewhen the identity was its only owner). The identity’s groups, including groups scoped to its projects, are removed before any project is evaluated, so a group of its own never keeps a project; a project’s own agents and members groups go, or stay, with the project (when a project is deleted they are counted in itsproject_deleterecord). An agent tied to the identity by owner or creator but not recorded as created under it is never removed; it is reported as unproven and keeps the identity. Allowed for the same callers as the delete; once the identity is gone, its remaining recorded resources can be purged by the issuer recorded on them or an admin session. Any ID the caller may not purge (another issuer’s identity, a user who is not a test identity, an unknown ID) gets the same200with an empty report, as does a purge with nothing left to do. A complete purge returns200with{identityId, complete, identityDeleted, deleted: {agents, schedules, groups, projects}, filesRetained, keptProjects, notDone, unproven}.filesRetainedlists removed agents (status: removed) whose broker files or branch may still exist.reason: files_retained,branch_retainedorfiles_and_branch_retainedmeans the agent’s delete finished under an earlier delete request that kept them.reason: retention_unknownmeans the agent was already soft-deleted when the purge reached it, or its earlier request is no longer recorded, so the hub cannot tell what that delete kept. These agents count as deleted and do not make the purge incomplete; an agent not listed had its files and branch removed by the purge. An agent listed withfiles_retained,branch_retained,files_and_branch_retainedorretention_unknownmay still have files or a branch on its broker; an operator should follow up with a cleanup on the broker. When it cannot finish (an agent delete leftin_doubt, a broker not reachable, unproven agents) it returns409withdetails.reason: purge_incompleteand the same report indetails.report; nothing it could not finish is counted, and running it again continues. Each removal writes its usual audit record (agent_hard_delete,schedule_delete,group_delete,project_delete; a kept projecttest_identity_purge_project_kept) with aprovenance_basisobject (fixture_root_id,fixture_issuer_id,basis,purge_run_id,trigger) in its summary and, as the same JSON, in the record’sprovenance_basisfield; the records of one run share a correlation ID. Each record is written in the transaction of the removal it records: if the record cannot be written, that removal is not made and is reported as not done. The identity’s removal writes onetest_identity_purgerecord in the same transaction.POST /cleanup: Remove what the caller’s expired or deleted test identities created. There is no body and no target list: any query parameter other thandryRun, or a request body, gets400. The hub selects every test identity whose recorded resources (the creation record the purge uses) name the caller as the issuer, and whose own row has expired or is gone, and purges each one asDELETE /:id?purge=truedoes: its recorded agents, schedules, groups and projects, and the expired identity itself. A live identity is never touched, nor is any resource of another issuer. The issuer scope is: the caller itself for an issuer session or issuer token; for a workload issuer delegation (below), only the identities issued through that delegation (a deleted identity’s delegation is read from itstest_identity_issueaudit record); every issuer for an admin session. Each identity is admitted by the same rules as the purge. Test identities of every kind, the admin kinds included, are refused (403). Rate limited per issuer or delegation, separately from issuance. With?dryRun=truethe hub lists the targets and writes nothing. Returns200with{runId, dryRun, scope, complete, identities: [{id, state}], targets: [{type, id, fixtureRootId, projectId, status, reason}], deleted, identitiesDeleted, liveSkipped, notDone, unproven, truncated}.scopeisissuer,delegationorall. A target’stypeisagent,schedule,group,projectortest_identity, and itsstatusistarget(dry run),deleted,kept(a project kept as in the purge) ornot_done.liveSkippedcounts the live identities of the scope that were left alone. A run that cannot finish answers409withdetails.reason: purge_incompleteand the report indetails.report; running it again continues. One run purges at most 200 identities (live identities and those outside the caller’s scope do not count);truncated: truemeans more remain, and the next run continues. A real run writes onetest_identity_cleanupaudit record with the counts; its correlation ID is the run’srunId, which every record of the run’s removals shares, and itsprovenance_basisnames the issuer scope. If that record cannot be written after the removals, the request answers500with the report indetails.report; the removal records exist and carry the run’s correlation ID, so the run’s audit trail can be recovered from them.GET /provenance?resource=<type>:<id>: Whether the hub recorded one resource as created under a test identity in the caller’s issuer scope (the same scope asPOST /cleanup: the caller’s own identities, a workload delegation’s own, or every issuer’s for an admin session).typeisagent,project,scheduleorgroup; any other type, a missing or malformedresource, or another query parameter gets400. A proven resource returns{resource, proven: true, fixtureRootId, fixtureIssuerId, fixtureState, reason: "fixture stamp"}, wherefixtureStateislive,expiredorabsent(the identity’s row is gone). A resource that no longer exists is proven by its deletion record: when its delete audit record (written by the purge, the expiry reaper or a cleanup) carries aprovenance_basisin the caller’s scope, the answer addsdeleted: trueanddeletionRecord(that record’s correlation ID), withreason: "deleted under fixture stamp". Every other resource, one without that record, one that does not exist (and has no such deletion record) and one recorded under another issuer or delegation, returns{resource, proven: false, reason: "no fixture stamp"}and nothing else (no owner or creator), so the route reveals neither other issuers’ records nor which IDs exist. Cleanup never removes an unproven resource.
The hub also expires test identities on its own. Once an identity is past its expiry it is not admitted to any project, so its running agents are held and stopped within about a minute. Five minutes after the expiry the hub purges it as above, acting for its issuer, and writes one test_identity_expire record instead of test_identity_purge. An expiry purge that cannot finish is retried every minute. Turning the feature off holds the agents of every test identity and stops the expiry purge; turn it back on to purge, or delete them as an administrator. Test identities cannot create or register runtime brokers (403, details.reason: test_identity_cannot_create_broker).
A test identity is an ordinary member or viewer for every other route. Its hub-level authority never exceeds that: authorization ignores every hub-level (system-scoped) role binding it would otherwise get, directly or through a group, except the hub-member and hub-viewer grants issuance gives it. So a test identity does not receive a custom system role an operator binds to the hub-members group: its ceiling is what issuance grants. It cannot create access tokens (403), change its role (409), be granted a hub-level role binding through the role-binding API (422), issue test identities, or act as a broker on-behalf-of principal or broker message sender (403). Its tokens stop working when it expires. GET /api/v1/admin/health/summary reports hub.test_identities_enabled.
While test identities are enabled, the web UI shows a warning banner that cannot be dismissed (“TEST HUB: test identities are enabled”) above the header on every page, the login page included. The banner reads GET /api/v1/test-infra/status, which needs no authentication and returns four booleans and two strings, {"testIdentities", "testHubAdmin", "testSuperAdmin", "testIdentityWebSessions", "testIdentityWebSessionsUnavailableReason", "testIssuerAudience"}. The booleans are all false on a hub without test identities. testIdentityWebSessions is true when single-use web-login codes let test identities hold browser sessions on this hub (test identities on, a hub-local web session key from scion server test-infra mark, and in proxy auth mode a proxy authenticator and at least one driver principal). testIdentityWebSessionsUnavailableReason names why they are unavailable while test identities are on (for example web_session_key_unavailable or proxy_auth_without_driver_principals), and is "" otherwise. testIssuerAudience is the audience a workload ID token must carry for issuer delegations (below), and is "" unless test identities are enabled and a workload issuer is configured.
Workload issuer delegations (/api/v1/test-identity-delegations)
Section titled “Workload issuer delegations (/api/v1/test-identity-delegations)”A human issuer can delegate test identity issuance to one pinned workload identity (member and viewer identities, or on a hub running the test admin tier, admin test identities; see Admin-scope delegations): a Google service account whose unique id (its sub) is listed in the hub’s startup allowlist. The workload then issues test identities without holding any secret. It proves the delegation with a Google ID token for that service account, whose audience is exactly scion-test-issuer:<hub id>. The hub computes this audience from its own resolved hub id and reports it as testIssuerAudience. The routes follow the test identity flag: while it is off they return 404, and a workload token is refused with 401.
The allowlist is startup configuration only, set in the settings file. It is a list of objects, so no environment variable can set it. The server-config API refuses it with 422 in every mode and does not return it:
server: hub: test_identities: workload_issuers: - sub: "<issuer service account unique id>" max_tier: membermax_tier is member or admin. An admin entry also covers the member scope, and is accepted only on a hub started with --test-mode=admin or super-admin: below that level the hub refuses to start. The hub also refuses to start with any other max_tier, with a malformed or repeated sub, with no resolved hub id, or when the issuer audience equals another token audience the hub accepts: a trusted issuer’s expected_audience, the IAP audience (auth.proxy.iap.audience) or the transport audience.
Each route below requires test_identity.issue at hub scope from an interactive session of an active human user. An access token, an agent, a workload delegation and every test identity are refused with 403.
POST /: Create a delegation. Body (unknown fields rejected with400):workloadSub(must be in the allowlist for the scope’s tier),scopes(exactly one:["test_identity:issue"], or an admin scope below),ttlSeconds(60 to 604800, default 28800, for the member scope),memberCap(live test identities the delegation may hold, 1 to 5, default 5; member scope only),adminCap(admin scopes only, below) andlabel(at most 64 characters). A sub outside the allowlist, a scope count other than one, an unknown scope, an admin scope the hub does not offer, or a lifetime or cap out of range returns422withdetails.reason. Returns201with{id, grantorId, grantorKind, workloadSub, scope, memberCap, label, created, expiresAt, live}(adminCapinstead ofmemberCapfor an admin scope).grantorKindishumanfor every delegation created here;test_infra_offlinemarks a delegation created offline on the hub host (see Offline delegations), whose view also carrieshubIdand whosegrantorIdis a reserved id that names no user.liveis true while the delegation is not revoked, is before its expiry and maximum age, its scope is available on the hub, and itsworkloadSubis still in the allowlist for that scope; for an offline delegation the offline checks below must also pass. The delegation id is not a secret, and presenting it without the workload’s token proves nothing. Writes atest_identity_delegation_createaudit record.GET /: List the caller’s delegations, or every delegation for an admin session (offline delegations appear only there). Optionallimit. Returns{items, truncated}.DELETE /:id: Revoke a delegation, for its grantor or an admin session (only an admin session can revoke an offline delegation); any other caller and an unknown id get404. The next request through the delegation is refused. Revoking twice returns204and keeps the first revocation. Writes atest_identity_delegation_revokeaudit record.
The workload calls the test identity routes (POST /api/v1/test-identities, GET /api/v1/test-identities, POST /api/v1/test-identities/:id/token, DELETE /api/v1/test-identities/:id, POST /api/v1/test-identities/:id/web-login-code, POST /api/v1/test-identities/cleanup, GET /api/v1/test-identities/provenance) with Authorization: Bearer <Google ID token> and X-Scion-Issuer-Delegation: <delegation id>. It can add an optional X-Scion-Run-Label (at most 64 characters), which is recorded in the audit. A workload token is accepted on those routes only. On any other route it gets 401, and the hub never creates a user account for the service account. On each request the hub checks the token, the delegation (not revoked, within its lifetime and at most 7 days from creation), that the sub is still allow-listed, and that the grantor is still an active human user. The request then has the grantor’s current permissions, limited to test_identity.issue: if the grantor loses that permission, issuance stops at once. Live identities are capped per delegation (its memberCap) and at 20 per hub. Issuance is rate limited per delegation. A workload sees, re-issues, deletes and mints web-login codes for only the identities issued through its own delegation (with the same web session conditions as any other caller; another identity gets 404); the grantor’s session and admin sessions still manage them. A workload’s purge (DELETE /api/v1/test-identities/:id?purge=true) covers only a live identity issued through its own delegation. The leftovers of an identity that was already deleted are purged by the grantor’s session or an admin session. A workload’s cleanup (POST /api/v1/test-identities/cleanup) reaches only the identities issued through its own delegation, expired or deleted, never those of another delegation or the grantor’s own. Audit records name the grantor as the actor and the delegation id as the credential, with the workload sub, the token’s issue time and the run label. They never contain token material.
Offline delegations
Section titled “Offline delegations”An operator on the hub host can create a member-scope delegation without a human grantor: scion server test-infra delegate --hub-id <id> --workload-sub <subject> (revoke with scion server test-infra delegate revoke, or as an admin session with DELETE /:id). The command runs offline against the hub’s database. It refuses, writing nothing, unless the hub id is pinned and equals --hub-id, server.hub.class is test, the test infrastructure marker names that hub id and the subject is allow-listed; the lifetime is 1 minute to 24 hours (default 8 hours), the cap 1 to 5, and a hub holds at most 4 live offline delegations. It writes a test_identity_delegation_create_offline audit record and prints no token. Such a delegation (grantorKind: test_infra_offline) never holds an admin scope. For it, the grantor check above is replaced: on each request the hub instead checks that the test infrastructure marker is present, names the hub’s own pinned hub id and is the same marker the delegation was created against, that the hub runs with --test-mode at member or above, and that the delegation is not revoked and is at most 24 hours old; any read error refuses the request. The request may then only issue, list, re-issue tokens for, delete, purge, clean up, mint web-login codes for and read the provenance of member and viewer identities issued through that delegation, with the same conditions as above. Each request it admits also writes a test_identity_delegation_redeem_offline audit record (grantor kind, hub id, marker, test-mode level, delegation, sub, route, run label), and the other audit records name the reserved grantor id as the actor. Unmarking or re-marking the database, restarting the hub with --test-mode off, revoking the delegation or letting it expire ends it at the next request.
Admin-scope delegations
Section titled “Admin-scope delegations”On a hub started with --test-mode=admin (or super-admin), a delegation can instead hold test_identity:issue_admin (test hub-admins); with --test-mode=super-admin, also test_identity:issue_super_admin (test super-admins). Below the matching level, the scope is refused at create (422, details.reason: admin_tier_not_available), and an existing delegation of it is refused at every request (403).
- Create needs, in addition, the grantor’s interactive session (never a dev credential), the scope’s capability, and that the grantor is a live super-admin through their own role bindings (
403,details.reason: issuer_not_live_super_admin, otherwise). TheworkloadSubmust be allow-listed withmax_tier: admin. A test identity of any kind is refused: the test-admin policy denies the admin kindstest_identity.issue. - Lifetimes (
ttlSeconds), enforced at create and from the creation time at every request:test_identity:issue_admindefault 4 hours, at most 8 hours;test_identity:issue_super_admindefault 1 hour, at most 2 hours. adminCap: the live admin test identities the delegation may hold, 1 to 2 fortest_identity:issue_admin(default 2) and exactly 1 fortest_identity:issue_super_admin. It is a sub-limit only: the hub-wide limits below always apply, across every delegation, access token and session, in the issuance transaction.- The workload issues with
POST /api/v1/test-identities/admin(same headers as above, body as in Test admin identities); a delegation issues only its own scope’s kind. At every issuance the hub re-checks, inside the issuance transaction, the delegation and that the grantor is still an active human super-admin holding the capability, so demoting the grantor stops the next issuance (403). A full delegation answers429withdetails.reason: admin_delegation_cap_reached; the hub-wide limits answeradmin_hub_cap_reachedandsuper_admin_hub_cap_reached. Issuance is rate limited per delegation. - Token re-issue:
POST /api/v1/test-identities/:id/token(same headers) re-issues one access token for an admin identity that the same delegation issued, of its scope’s kind. Any other identity (another delegation’s, one a human issued, a member identity, the other admin kind, an unknown ID) gets the same404as the member routes.tokenTtlSecondsis at most the kind’s token limit (hub-admin 1800, super-admin 900; default 900 and 600), and the token never outlives the identity: it ends no later than the identity’s expiry, and no later than its creation plus the kind’s maximum lifetime (60 or 30 minutes). Re-issue never extends the identity. The response is200with the shape ofPOST /api/v1/test-identities/admin. Inside the mint transaction, under the issuance lock, the hub re-checks the delegation, that the grantor is still an active human super-admin holding the scope’s capability (403,details.reason: issuer_not_live_super_admin), and that the identity is still active and stamped with the delegation (404). Each re-issue writes atest_identity_token_issueaudit record with the delegation part and atest_admin_token_issue[scion-test-admin-audit]line carryingissuer_delegation_idandworkload_sub; a sink failure mints nothing (503). Rate limited by the per-delegation test identity limiter only (no admin-specific limit); each re-issue adds a live token and earlier tokens stay valid until they expire, but none outlives the identity’s kind lifetime. - Web-login code:
POST /api/v1/test-identities/:id/web-login-code(same headers) mints a code for an admin identity that the same delegation issued, of its scope’s kind, only on a hub started at that kind’s level withserver.hub.test_identities.admin_web_sessionsset to the hub id; without it the route answers the same404as for any other caller. Every web session condition above applies. The code and the session it redeems into end no later than the identity’s expiry and its creation plus the kind’s maximum lifetime. The same in-transaction re-checks apply (403for a demoted grantor or a revoked delegation,404for an identity that is no longer active). Thetest_identity_web_code_issueaudit record carries the delegation part, and atest_admin_web_code_issueline carriesissuer_delegation_id; neither holds the code. - Everything else refuses an admin-scope delegation with
403: listing, delete, purge, member issuance, the issuer cleanup and the provenance route. Admin identities expire within their lifetime (at most an hour) and end earlier only through revoke-all, a purge (DELETE /api/v1/test-identities/:id?purge=true) by their issuer (on its own sign-in or access token, never through a delegation) or a human super-admin, or a hub restart without the admin level. A member-scope delegation reaches no admin route: admin issuance answers403, and token re-issue and web-login codes for admin identities answer404. - The identity records the delegation id (
issuerDelegationId, and as its issuer credential); thetest_admin_issueaudit record carries the delegation part (delegation id, grantor, workloadsub, token issue time, run label), and the[scion-test-admin-audit]line carriesissuer_delegation_idandworkload_sub.
Operator setup: the allow-listed sub is the target hub’s own issuer service account (<hub>-issuer). A runner agent obtains its token by calling IAM generateIdToken as that issuer service account, using the agent’s assigned service account. Request the token with includeEmail: true (IAM generateIdToken) or format=full (metadata server): the hub requires the service account’s verified email claim. Granting that permission, and keeping the assigned service account unique and narrowly assigned, is the operator’s responsibility. Scion documents it but does not enforce or configure it. Removing that IAM grant, revoking the delegation, or removing the sub from the allowlist (at the next restart) each stops issuance. Removing a sub from the allowlist does not revoke delegations stored for it: they stay in the list, shown with live: false, and every request through them is refused.
Test mode (operators)
Section titled “Test mode (operators)”A single startup setting, --test-mode=<level>:<hub id>, selects which test identities a test-designated hub may issue. There is no settings key, experiment or API to change it at runtime.
| Level | Grants |
|---|---|
off (default) |
No test identities. |
member |
Member and viewer test identities (/api/v1/test-identities). |
admin |
member, plus test identities with hub-admin authority (see “Test admin identities” below). |
super-admin |
admin, plus test identities with super-admin authority. The hub starts only when its test-admin policy classification is complete (see “Test-admin policy” below). |
The level is matched exactly against these four lower-case strings. An empty value means off. Any other value, including a different case or a misspelling, refuses to start with an error that lists the four levels. The hub id after the colon is required for every level except off, and must equal the hub’s resolved hub id: server.hub.hub_id from the settings file, or SCION_SERVER_HUB_HUBID. The resolved hub id identifies the logical hub, so every replica of one hub shares it; replicas are told apart by their instance id, not their hub id.
Every level except off, member included, requires all of the following. If any one is missing the hub refuses to start with a named error; it never falls back to a lower level.
- The process runs the hub, with a user store, and was not started with
--production(use--hosted). - The hub id binding above matches.
- The hub id is pinned:
server.hub.hub_id(orSCION_SERVER_HUB_HUBID) is set explicitly, to the same value on every replica of the hub. Pin the hub’s current resolved id, never a fresh value (see below). A hub id derived from the hostname (persisted in~/.scion/hub-id) or from the Cloud Run service name (K_SERVICE) is refused with the named errorhub_id_not_pinned, which namesserver.hub.hub_id. The Helm chart (hub.hubId), the Terraform modules and the Cloud Run script already pin it; a single-node VM or workstation hub needsserver.hub.hub_idadded to its settings before it can use test mode. Set it to the hub’s current id: thehub_idthatGET /healthzreports, which is also the content of~/.scion/hub-id. A different value moves the hub to a new id, which re-scopes its secrets, hub-scoped environment variables, signing keys, storage prefix and test infrastructure marker. The value must match the hub id the marker names. server.hub.class: testis set in the settings file or environment. The server-config API refuses this key in every mode, and does not return it.- The database carries a test infrastructure marker for this hub id. An operator writes it offline, on the hub host, with
scion server test-infra mark --hub-id <id> --i-understand-this-hub-can-mint-super-admins; it is never written by the Helm chart or by an agent.--attested '<json object>'optionally records the storage bucket, secret-backend project and broker ids as declared facts. The command writes atest_infra_markaudit record and refuses if a marker already exists. It also refuses, writing nothing and before it opens the database, when the hub id is not pinned (server.hub.hub_idorSCION_SERVER_HUB_HUBIDunset; the error namesserver.hub.hub_id; when~/.scion/hub-idexists, or on Cloud Run, it prints the current resolved id to pin, and otherwise it points to thehub_idthatGET /healthzreports. The printed id comes from the environment of the user runningmark; confirm it againstGET /healthz) and when--hub-iddiffers from the hub id resolved from the same configuration.
On a hub that meets these conditions, an operator on the hub host can also create a member-scope workload issuer delegation with no human grantor, offline (scion server test-infra delegate); see Offline delegations. Admin-scope delegations still require a live human super-admin.
These are stricter than the former --enable-test-identities flag, which needed none of the class, marker or hub id binding. The capabilities of each level are unchanged from the flags it replaced: member grants what --enable-test-identities granted, and admin adds what --enable-test-admin-identities added.
admin and super-admin also require:
- Every signing key is local to this hub: no
--session-secret/SCION_SERVER_SESSION_SECRETand no pre-configured key. This covers user and agent tokens, OIDC, signed download and artifact URLs, list cursors, conduit stream grants and the web session cookie. On a hub that predates hub-id key scoping, delete or rotate its hub signing keys before the first start with--test-mode=admin: a key copied forward from a legacy record keeps its original bytes. - No federation is configured, in the settings file or in stored settings.
- The hub is not a workstation hub.
- No runtime broker other than the hub’s co-located broker is registered and active (see below).
admin and super-admin are single-replica only. A hosted HA hub (Postgres, Cloud Run, or GCS storage with proxy auth) must have a durable shared session secret, and the admin levels refuse any shared signing secret, so they cannot start on a hosted HA hub at any replica count. Run them on a single hub process (for example a single-node VM). member has no such limit: every replica of one hub can run it, as long as they all share the one pinned hub id the marker names.
--enable-test-identities, --enable-test-admin-identities and --enable-test-super-admin-identities were replaced by --test-mode; starting with any of them fails with an error that names --test-mode.
At startup, for any level except off, the hub writes one line tagged [scion-test-admin-audit] to stdout, whatever server.log_level is set to, with the resolved level (test_mode), the preconditions that admitted it (preconditions) and a warning: hub- and broker-scope environment secrets are injected into every member agent, so keep only test-grade secrets on a test hub. GET /api/v1/admin/health/summary reports the same as hub.test_mode and hub.test_mode_preconditions, next to the booleans derived from the level.
On every hub, not only in test mode, hub- and broker-scoped variables and secrets reach the agents of every user who starts agents there; see Scope and Sharing. The exception is agents whose originating user is a test admin identity (see Test admin identities).
The Helm chart renders only the member level: hub.testIdentities.enabled: true renders --test-mode=member:<hub.hubId> and requires hub.hubClass: test. No chart value renders admin or super-admin, and hub.args refuses --test-mode. The chart pins server.hub.hub_id to hub.hubId in the settings it renders. With config.existingSecret the settings file is yours and the chart cannot read it: its server.hub.hub_id must be set and equal hub.hubId, or every pod refuses to start.
While the test admin tier is on (--test-mode=admin or super-admin):
- Federation settings writes are refused (
422) for every caller. - Only the hub’s co-located Runtime Broker and the hub-managed message-broker plugin records this process provisions may register or connect. Other brokers get
403(test_admin_tier_remote_broker_refused), including HA and self-managed plugins. Startup fails if any other broker record is registered and not offline, including a stale plugin record left online; remove it first. - The startup line tagged
[scion-test-admin-audit]also carries the gate inputs (kinds, hub id, class, marker time, signing-key source labels), and once brokers are up a second line lists the admitted broker ids. - The
scion.io/pluginbroker label is reserved for the hub: broker registration and update return422when it is sent.
On every hub, whether or not the test admin tier is on, the scion.io/plugin broker label is reserved: a client-supplied value is dropped on broker registration and update, and a value the hub set is kept.
GET /api/v1/test-infra/status reports testHubAdmin: true while the test admin tier is on and testSuperAdmin: true with --test-mode=super-admin, and the web banner switches to its admin or super-admin variant. GET /api/v1/admin/health/summary reports hub.test_admin_identities_enabled, hub.test_super_admin_identities_enabled and hub.live_test_admin_identities.
Browser sessions for admin test identities are a separate, startup-only setting: server.hub.test_identities.admin_web_sessions: <hub id> (settings file or environment; never writable through the settings API). Its value must be the hub’s resolved hub id, and the hub refuses to start when it is set with --test-mode below admin or with another hub id. A test super-admin also needs --test-mode=super-admin. The Helm chart renders only the member level, so a Helm-deployed hub with this setting refuses to start. The startup line carries admin_web_sessions, GET /api/v1/test-infra/status reports testAdminWebSessions and the health summary hub.test_admin_web_sessions_enabled. See Browser Sessions as Test Identities.
Test admin identities
Section titled “Test admin identities”On a hub started with --test-mode=admin, a super-admin can issue a short-lived test identity that holds the real hub-admin role; with --test-mode=super-admin, also one that holds the real super-admin role:
POST /api/v1/test-identities/adminwith{"adminRole": "hub-admin", "purpose": "<8 to 200 characters>"}and optionallifetimeSeconds(at most 3600, default 3600) andtokenTtlSeconds(at most 1800, default 900). It returns201with the identity (kind: "test_hub_admin", an email inscion-test-hub-admin.invalid,issuanceId,expiresAt) and exactly one access token: no refresh token, no cookie, and no re-issue (POST /api/v1/test-identities/{id}/tokenanswers409; only an admin-scope delegation re-issues, for the identities it issued).{"adminRole": "super-admin", ...}issueskind: "test_super_admin"(an email inscion-test-super-admin.invalid, stored roleadmin) withlifetimeSecondsat most 1800 (default 1800) andtokenTtlSecondsat most 900 (default 600). It answers404unless the hub started with--test-mode=super-admin.- The caller must be a human user (not a test identity) who is, at that moment, an active super-admin through their own role bindings and holds the kind’s capability,
test_identity.issue_adminortest_identity.issue_super_admin(both super-admin only). An access token is accepted only with a hub boundary, exactly the requested kind’s scope (test_identity:issue_adminortest_identity:issue_super_admin), and an age of at most 8 hours; it is loaded and checked again on every issuance, so revoking the token or the user’s super-admin role stops the next issuance. - Limits: one live admin identity per issuer (per delegation, its
adminCap, for admin-scope delegations), four per hub of which at most one test super-admin; per issuer (or delegation), six hub-admin and two super-admin issuances per hour. - The identity’s
hub-adminorsuper-adminbinding expires with the identity. While the identity lives it has a human hub-admin’s or super-admin’s authority, minus what the test-admin policy denies (below). It cannot issue test identities, create access tokens, change role, or receive other role bindings, group or project memberships. - Its token stops working when the identity expires, is suspended (
403) or deleted (401), and when the hub restarts without its level (--test-mode=adminfor a test hub-admin,--test-mode=super-adminfor a test super-admin). Authorization grants it nothing in those cases, even on a path that does not read the user record. - Each issuance writes a
test_admin_issueaudit record (issuer, the issuer’s access token or delegation ID, kind, purpose, expiry, issuance ID; never the token) and a[scion-test-admin-audit]line. Every mutation the identity makes carries credential labelstest_tier,kind,credential(web_sessionfrom a browser session,bearerotherwise),issuer_id,issuer_credential_id,issuance_idandpurpose_hash, and the correlation ID<issuance id>:<request id>. POST /api/v1/test-identities/admin/revoke-all(a human super-admin on an interactive session only) first advances the session generation of every admin test identity, which ends their browser sessions on the next request (if that fails it answers503and deletes or suspends nothing), then deletes every admin test identity. An identity that owns agents or is a project’s last owner is suspended and loses its role bindings instead. The response lists each identity and its outcome. Then restart the hub without--test-mode=adminorsuper-admin.GET /api/v1/test-identities/admin/{id}/residue(a human super-admin on an interactive session only) returns the residue report of one admin test identity, live or purged:stampedGrantsandstampedResources(what a purge removes),unstampedChanges(changes it made to rows without its issuance stamp; reported, never reverted),keptRoles,truncatedandlimit. An ID that is not an admin test identity, now or in the past, answers404. The CLI commands and an operator runbook are in Test Identities with Admin Roles.
Test-admin policy
Section titled “Test-admin policy”Every permission, legacy admin route, admin role check, operational settings section and key, file-live settings key and experiment is classified allow or deny for test identities with admin roles (pkg/hub/test_admin_policy.go). A deny entry answers 403 to a test hub-admin or test super-admin even though its role holds it, and even when a group or custom role it belongs to holds the permission too; human admins are not affected. A build with an unclassified entry fails its tests, and --test-mode=super-admin refuses to start on a binary whose classification is incomplete.
Denied to both kinds: reading or writing the server configuration (GET/PUT /api/v1/admin/server-config, section resets, the schema), every settings and experiment write (PUT /api/v1/admin/experiments included), federation, the GitHub App, hub environment variables and hub-scope secrets, integrations, maintenance operations, admin mode, auth reset, signing key rotation, lifecycle hook, allow list, project default, messaging and scheduler updates, hub-wide injected skills, access constraint administration, user promotion and role change, test identity issuance, delegation adoption, agent delegation grant management for the test admin’s own use (listing, reading and revoking grants that other users issued on agents the test admin does not own), project visitor management (project.manage_visitors; a test hub is not federated), the GCP permission check settings, GCP identity passthrough on the co-located broker, and runtime broker credential remint and rotation. Everything else the role holds is allowed.
A test admin also cannot grant a denied permission to anyone: a role binding, a custom role definition or a group membership whose permissions include one is refused with 403. This means a test admin cannot bind the built-in hub-admin role, which holds denied permissions, to another principal. Project roles stay grantable by direct project membership or a project-scope role binding, where the test admin’s role already manages project membership (a test super-admin; a test hub-admin, like a human hub-admin, holds no project.manage and cannot change another project’s membership): it may grant project-owner and project-admin that way, although they hold the agent delegation grant management permissions and project.manage_visitors, because on a project those reach only that project. A group membership whose group holds those roles is refused, as is a grant that holds any other denied permission or holds those permissions at hub scope. Test admins also cannot remove super-admin bindings.
At dispatch, an agent whose originating user is a test hub-admin or test super-admin receives no hub- or broker-scope secrets or environment variables, project-scope ones only in projects where that user is a member, and user-scope ones only its own. The same rule applies when such an agent fetches secrets at runtime.
The refusal is 403; settings writes carry details.reason: "test_admin_policy". Each refusal writes a test_admin_policy_denial line tagged [scion-test-admin-audit].
Test admin audit lines
Section titled “Test admin audit lines”Every line tagged [scion-test-admin-audit] is one JSON object with event, ts, seq and prev. seq counts the lines of one process from 0; prev is the SHA-256 (hex) of the previous line as written (the whole line without its newline), and all zeros on a process’s first line. A removed, inserted or edited line breaks the chain. The lines do not depend on server.log_level or on the decision audit experiment, and never carry a token.
Besides the startup lines, issuance and revoke-all, every request a test hub-admin or test super-admin makes writes a test_admin_request line (method, route template, user, kind, issuance ID, and credential: web_session for a request from the identity’s browser session, bearer for its own token) before it is handled, and a test_admin_response line with the status and the decision (allowed, denied, policy_denied or failed). If a line cannot be written, issuance and requests by those identities answer 503 (test_admin_audit_unavailable); requests by every other user are unaffected.
Admin (/api/v1/admin)
Section titled “Admin (/api/v1/admin)”-
GET /roles: List Role Definitions. -
POST /roles,PUT /roles/:id,DELETE /roles/:id: Manage Role Definitions (requires appropriate administrative capabilities). Note thatupdateRoleDefinitionincludes aCanDelegatecheck so a caller cannot add permissions they cannot delegate. Hub-boundary User Access Tokens are accepted withrole:create,role:updateorrole:delete, each together withrole:read; creating or updating also needs a token scope for every permission of the role, which the holder must hold at hub level, and a token cannot create a system-scoped custom role. See Hub-bound tokens. -
GET /role-bindings: List Role Bindings (paginated). -
POST /role-bindings,PUT /role-bindings/:id,DELETE /role-bindings/:id: Manage Role Bindings. OnPOST, a user principal must be an email or a well-formed user ID and an agent principal a well-formed agent ID; a malformed ID answers400 invalid_request. A UUID sent in upper case is stored in canonical lower case.POST /api/v1/projects/:id/membersapplies the same check. APOSTfor a built-in project role (project-owner,project-admin,project-member) on a project scope is decided by the project membership rules, andDELETEof a project-scoped binding is decided under project governance first: a caller withproject.manageon the binding’s project (a project owner or admin) removes it under the governance rules; any other caller, and every system-scoped binding, needs hubrole_binding.delete. A hub-boundary User Access Token can create a binding of a custom project role withrole_binding:create(the holder needs current project management access to that project and every permission of the role there, within the token’s scopes), and can delete a system-scoped binding withrole_binding:delete; a token can never create a system-scoped binding or delete a super-admin binding. -
GET /access-constraints,POST /access-constraints,PATCH /access-constraints/:id,DELETE /access-constraints/:id: List and manage access constraints. A hub-boundary User Access Token withaccess_constraint:readcan list and read them; creating a constraint, or aPATCHthat only tightens one, needsaccess_constraint:adminandaccess_constraint:read, and the holder’s hubaccess_constraint.admin.DELETE, and aPATCHthat relaxes a constraint, need an interactive sign-in. -
GET /limits: List Limit Definitions. -
GET /limits/:id,PUT /limits/:id: Inspect or update a Limit Definition. -
GET /entitlements/:id: Inspect an Entitlement Binding. -
GET /gcp-quota: View GCP quota status. -
GET /profiling,PUT /profiling: Read and change the profiling switches (requireshub.config.update, on an interactive session only: every user access token is refused with403). Response:{"readiness_marks": true|false, "revision": N}.PUTbody:{"readiness_marks": true|false|null};nullresets the key to its default (off) and an omitted key is unchanged. See Profiling. -
GET /experiments,PUT /experiments,DELETE /experiments: Manage hub-wide experiment overrides (requireshub.experiments.update).GETlists every registered experiment with itsdefault, storedoverride, and resolvedenabledvalue, plus the sectionrevision.PUTbody:{"overrides": {"<name>": true|false|null}, "expected_revision": N}.nullremoves an override, omitted names are unchanged, and unregistered names return400. A stale revision returns409(revision_conflict).DELETEresets all overrides to registry defaults (body:expected_revision, orconfirm_reset_malformed: trueif the stored section is malformed). There is no admin UI yet. -
GET /validate-resources: Check storage consistency for global templates and harness configs, and list templates whose config holds values agents ignore. Requires the admin role. The response hasreports(one storage validation report per resource),summary(total,passed,failed) andtemplateConfigFindings, which covers templates in every scope. Each finding haskind,template,templateId,scope,scopeId,keysandmessage. Kinds:environment_keys: the template’sscion-agent.yamlsets environment keys (image,resources,volumes,kubernetes,hub,telemetry) orSCION_*env keys.keyslists them (env.<KEY>for an env key), andmessageis the warning agents print:template <name> sets environment key(s) <keys>; ignored (environment comes from settings).
Findings are reported only; nothing is rewritten.
-
POST /users/invite: Invite a single user ({"email", "note"}), creating aninviteduser record. Requiresuser.invite. -
POST /users/invite/bulk: Invite one or more users in a single request and optionally grant them project roles. Acceptsapplication/json(up to 5 MiB:{"emails": [{"email", "note"?}], "projects"?: [{"projectId", "roleDefinitionIds": [...]}]}) ormultipart/form-data(up to 2 MiB, fieldfile, noprojects, with one address oremail,noteCSV row per line). Caps: at most 1,000 emails, 20 projects, 10 roles per project, and 2,000 total memberships (emails × projects). Any non-null hub role field (role,roles,hubRole,hubRoles) is refused with422(details.reason:role_selection_not_supported; hub roles are assigned at first sign-in by Hub policy). Requiresuser.invite, plususer.readand grantable project roles (GET /api/v1/projects/:id/members/assignable-roles) whenprojectsis non-empty; if any role is not grantable, the whole request is refused with403(batch_projects_not_grantable) and no one is invited. Returns200with{batchId, invited, skipped, total, complete, counts, results}, where each item’sstatusiscreated,already_invited,already_active,suspended(orexistswithoutuser.read),invalid,duplicate, orfailed, and each membership’sstatusisapplied,already_member,conflict,failed, orskipped. CLI:scion hub users invite. -
GET /messaging,PUT /messaging: Read and update operational messaging settings (hub.messaging.update, which hub admins hold). Keys:conversation_envelope_switch(boolean),cross_project_messaging_enabled(changing it requiresexpected_revision;409 revision_conflicton mismatch),offload_threshold_runes,attachment_delivery_max_bytes(the total size of one message’s attachments to an agent recipient; a send over it is refused; default33554432/ 32 MiB;0turns off agent inbox attachment delivery), andattachment_inbox_retention_days(default7, minimum1). Sendingnullfor a key resets it to its default. -
GET /messaging/divergence: View a read-only snapshot of migration divergence counters and metadata for the conversation model transition (requireshub.diagnostics.readpermission). -
GET /integrations,GET /integrations/available,GET /integrations/:name,GET /integrations/:name/health,PUT /integrations/:name/config,POST /integrations/:name/install,POST /integrations/:name/restart,POST /integrations/:name/update,GET /integrations/:name/update/:id: Inspect and manage Hub integrations (for example Discord).GET /integrations/:nameomits secret keys, internal runtime keys (bot_id,config_file), and Hub wiring and database keys (hub_url,broker_id,hmac_key,plugin_name,project_slug_map,database_driver,database_url) fromsettings, and live Hub wiring values override those keys when configuring or restarting an integration.POST /integrations/:name/updatestarts an integration update, andGET /integrations/:name/update/:id(orlatest) reads its status (405 Method Not AllowedwithAllow: GETon any other method, includingPOST).
The Hub seeds a max_agents_per_broker limit (default 100) that caps how many agents can be running on a single runtime broker. It is checked before an agent is created, and again when an agent is started, resumed, or restarted. Only running agents count: stop, suspend, and exit release an agent’s slot. The Hub reconciles stale reservations at startup and hourly. This default is a single hub-wide value shared by every broker on the hub; to override it for one broker, set that broker’s maxAgents setting instead — see Broker Settings above.
The limit is per Runtime Broker ID, so each flat Runtime Broker instance has its own: a host running a Docker and a Kubernetes instance has two independent limits. GET /usage/:limitId for max_agents_per_broker lists every active reservation; the rows of a listed Runtime Broker carry that broker’s effective limit. A row has scopeConflict: true when a pinned agent’s reservation is on a Runtime Broker other than its own, and scopeUnlisted: true when its Runtime Broker is not in the broker list (for example a deregistered one); unlisted rows follow the listed ones and are not counted in totalActive. A slot that a start, restart or create holds is not freed by a status report from its old container; it is freed as soon as the start settles without a running container. While a registered Runtime Broker is offline, its agents’ slots stay held, because their containers may still run. Deregistering the broker (DELETE /api/v1/runtime-brokers/{id}) ends the hold: the start-claim reaper releases those agents’ unconfirmed start claims, and the next stale-reservation pass releases the reservations on that broker, each with an audit event. If the instance registers again under the same ID, its running agents are counted again from its first heartbeat.
To change the hub-wide value, PUT /limits/:id on the max_agents_per_broker limit definition. PUT replaces the whole definition, so send the current name, resourceType, unit, and description (for example, from GET /limits/:id) along with the new defaultValue — omitting description clears it. For this system-seeded limit, name, resourceType, and unit must be sent unchanged; changing any of them returns 403. This is also the recommended step after deploying a single-node Cloud Run hub — see the Cloud Run operator docs for the recommended value for that tier.
The Quota System API enforces fail-closed limits. Route guards strictly separate read and write permissions, preventing arbitrary modification of system limits.
Runtime Broker API
Section titled “Runtime Broker API”The Runtime Broker exposes a local API (usually on port 9800) for agent execution and management.
Control Channel (WebSocket)
Section titled “Control Channel (WebSocket)”Brokers maintain a persistent outbound WebSocket connection to the Hub. The Hub uses this tunnel to send commands (e.g., CreateAgent) to brokers that might be behind NAT.
Local Endpoints
Section titled “Local Endpoints”GET /healthz: Basic liveness and readiness check. In multi-node or hosted setups, if a reverse proxy (like GFE) intercepts this endpoint and returns a non-JSON body, the client detects this and returns a precise error naming the likely cause (rather than a generic JSON-decoding failure) to assist with troubleshooting. If the Runtime Broker’s default runtime failed to resolve at startup, the body reports the runtime check as unavailable and the overall status asdegraded, but the endpoint still returns200(it is a liveness check).GET /readyz: Readiness check. Returns503while the default runtime is unavailable. Point readiness probes here and liveness probes at/healthz.POST /api/v1/agents: (Internal) The Hub dispatches agents to this endpoint.GET /api/v1/agents/:id/attach: (WebSocket) Provides a terminal stream for interactive sessions.
PTY close codes
Section titled “PTY close codes”The WebSocket close frame that ends a terminal attach carries a code that tells the client why the attach ended. The hop that knows the cause picks the code, and every later hop passes it through unchanged. The Runtime Broker classifies the cause the same way on every runtime. The close frame also carries a machine-readable snake_case reason that names the specific cause. A code can carry several reasons, so clients should decide whether to retry from the code and treat the reason as diagnostic detail.
| Code | Meaning | Reasons | Client should |
|---|---|---|---|
1000 |
Clean detach. The tmux session still exists. | None (empty reason) | Not retry |
4404 |
The Runtime Broker cannot find the agent or its container, or (Conduit agent path) a re-check found the agent is gone. | agent_not_found, target_not_found (Conduit agent path) |
Not retry |
4410 |
The tmux session is gone (agent exited, container stopped or removed). | session_ended (the container still exists), container_removed (the container is gone too), agent_stopped (the attach never reached a tmux session because the container is definitively stopped) |
Not retry |
4503 |
A hop behind this one is temporarily unavailable (Hub-to-broker control channel dropped, stream failed to open, exec transport dropped while the session is still alive, tmux session not ready yet, container runtime lookup failed). | From the Hub: broker_disconnected, stream_open_failed, broker_write_failed. From the Runtime Broker: runtime_stream_dropped, session_not_ready, lookup_unavailable, runtime_unavailable |
Retry |
4504 |
A transient failure on the conduit agent path (the agent’s session was lost, or the stream could not be opened in time). | upstream_unreachable |
Retry |
1006 |
Connection dropped without a close frame. The client library generates this code; it is never sent. | None | Retry |
1011 |
Unexpected server error, or the Runtime Broker could not check whether the tmux session survived. | internal_error, client_read_failed, client_write_failed, probe_failed |
Retry |
1013 |
The client read terminal output too slowly. On the control-channel path, the Hub holds up to 8 MiB of output for each attach stream; when that fills, it closes only that stream, so other streams on the same Runtime Broker connection keep flowing. | slow_consumer |
Retry |
4401 |
A re-check found that the user’s access to the agent was withdrawn or the user’s authorization interval ended (Conduit agent path), or that the user access token that opened the session stopped validating or lost the agent.attach permission (either path; re-checked every 15 seconds). |
authz_expired (re-check), unauthenticated (relay refusal) |
Not retry |
4403 |
Conduit agent path only: the stream for the attach was refused when it was opened (the user may not attach to the agent, or the relay or the agent refused the stream). | forbidden, grant_invalid |
Not retry |
On the Runtime Broker path, authentication and permission failures at the start of an attach surface as HTTP 401/403 on the handshake. On the Conduit agent path, a refusal when the stream is opened arrives as a 4403 close after the upgrade, and 4401 is sent only after the attach has started, by a re-check; on the Runtime Broker path, 4401 is sent only for a session opened with a user access token. 4400 (protocol error), 4409 (superseded, session scope only) and 4499 (open cancelled) are also classified, as terminal, so clients do not retry them.
scion attach decides from these codes using the same classification (wsprotocol.ClassifyPTYClose). Only 1000 counts as a clean detach. After a 4503 close the CLI reconnects once by itself, after a random delay of up to 5 seconds; after a 4504 or 1011 close it reconnects once after exponential backoff with random jitter (wsprotocol.PTYReconnectTiming). It makes one reconnect per such close, and stops after 3 reconnects in a row whose sessions each ended within a minute. Typing Ctrl-C during the wait stops it. The reconnect sends the current terminal size so the screen redraws. If that reconnect fails, or for any other code, it exits non-zero with a message that matches the code, for example “run scion attach again” for a retryable code, or scion resume for 4410.
System Health Endpoints (Hub)
Section titled “System Health Endpoints (Hub)”GET /healthz: Basic liveness check. If a reverse proxy intercepts this with a non-JSON response, the client gracefully falls back to/health. Always returns HTTP200; the response body’s top-levelstatusfield reportshealthy;degradedwhen only non-critical checks are failing (the server is up and serving); orunhealthywhen a critical check (database,workspace_storagewhen a non-localworkspace storage backend is configured, orencryptionwhen the startup encryption audit in keyring mode finds an unreadable Hub-scoped infrastructure backup) is failing — the same conditions that make/readyzreturn503. Theworkspace_storage_mount_verificationkey (mount could not be verified) only degrades, as doesproject_storage_config(the Hub’s storage is synthesized from the deprecatedworkspace_storage,shared_dir_storageorhome_storageblocks; see Project Storage). The failing check(s) are named underchecks. In the combined web + Hub response, the top-levelstatusis the most severe of the Hub’s and the co-located broker’s. When this process runs a co-located (embedded) runtime broker, the co-located broker check reportshealthy,unhealthy: registration failed(the fixed public value — see the server log for the underlying error), orunhealthy: registration pending;statusgoesdegradedwhile it is anything but healthy. A failed registration is not retried, so this needs a broker configuration fix and a server restart, not a wait. Path differs by deployment shape: on a standalone Hub (no web server, e.g. port9810), it ischecks.colocated_brokerat the top level. On the combined single-node workstation setup (the default — the web server answers/healthzon its own port and nests the Hub’s health underhub, perCompositeHealthResponse), it ishub.checks.colocated_broker.GET /readyz: Readiness check verifying database connectivity, shared workspace storage mount availability (when a non-localworkspace storage backend is configured), and, in keyring mode (server.keyring_file), that the startup encryption audit found no unreadable Hub-scoped infrastructure backups (503withreason: backup_unreadable), as well as returning503(status: not_ready,reason: shutting down) once the server receivesSIGTERMorSIGINT. Kubernetes and Cloud Run readiness probes must target this endpoint rather than/healthz, which always returns200./readyzis intentionally unaffected by the co-located broker check above.GET /health: Legacy/alternative liveness check endpoint.
Browser Policy Reports (Hub)
Section titled “Browser Policy Reports (Hub)”-
POST /api/v1/csp-reports: Receives the policy reports browsers send for the web pages. Web page responses carry a report-only policy,Content-Security-Policy-Report-Only: require-trusted-types-for 'script'; report-to csp, withReporting-Endpoints: csp="/api/v1/csp-reports". It blocks nothing; the browser reports each place a page passes a plain string to an API that parses it as markup or script. The endpoint needs no sign-in (browsers send reports on their own). It acceptsContent-Type: application/reports+jsonandapplication/csp-report, and reads the body by its shape with either type: a Reporting API array, a single Reporting API report (an object withtypeandbody, as Safari sends it), or the older single-report form (an object with acsp-reportmember). It answers204. A report is written to the hub log as onecsp_violationrecord at Info level withsubsystemhub.csp-reports, withdirective,sink(for a Trusted Types report, the API the string was passed to, for exampleElement innerHTML),blocked_uri,source_file,lineanddocument_path(the path of the page the report is about, without host, query or fragment). Repeats of the same violation (same directive, origin of the blocked URI and page path) are collapsed: the first is logged, and the repeats within the next 10 minutes are counted and answered with204without a record. Once those 10 minutes are over, onecsp_violation_repeatsrecord shows the latest repeat (the same fields as acsp_violationrecord, withoutsample), withrepeats(how many were collapsed),first_logged(when the first report’s record was written) andlatest_seen(when the latest repeat arrived). So the records show the first and the latest report of each kind. These summary records are written as reports keep arriving: when the same violation comes back after its 10 minutes, or when any report arrives and the hub’s last check for finished violations is at least 10 minutes old, so a summary can come up to about 20 minutes after the first report’s record. On a busy hub a summary can be logged just after a newcsp_violationrecord of the same violation; itsfirst_loggedtells which earlier record it belongs to. The hub tracks up to 512 violations at a time; when it must make room, it removes finished ones first, then the one with the oldest record, and writes the summary of a violation removed early then. Repeat counts are kept in memory only: those not yet summarized are not kept across a hub restart. A page can report the same violation many times in one load, and some browsers send each report as its own request. The two URLs keep only their scheme, host and path: user information, query and fragment are dropped (also from a scheme-relative URL such as//host/path), and adata:,blob:,javascript:or other non-web URL is reduced to its scheme (for exampledata:). Keywords such asinline,evalandtrusted-types-sinkare kept. The report’ssampleis not logged by default, because it can hold the start of page content such as a chat message;server.hub.csp_report_raw_samples: trueadds it (cut to 120 characters), for a short diagnosis (see Server Configuration). At most 10 reports per request are logged, counted after repeats are collapsed; when a request holds more, onecsp_violation_droppedrecord gives the number not logged. Reports are not persisted in application storage; the log records follow your log retention. A body over 16 KiB gets413, another content type415, and a body with no policy report400. Two limits apply: each client (an IPv4 address, or an IPv6 /64) may send a burst of 60 requests, so one page load’s reports fit, then one every two seconds; and the hub as a whole writes at most 10 records per second (burst 100), counting every record, the dropped-count record included; collapsed repeats andcsp_violation_repeatsrecords take none. A client over its per-client limit (repeats included), or a request when the hub-wide budget is used up, gets429withRetry-After. Reports refused by either limit are not written as records and not counted; thecsp_violation_droppedrecord appears only for reports past the 10-per-request bound, and only while the hub-wide budget allows it.Operating the report endpoint:
- Browsers send reports only for pages served from HTTPS or a loopback address; a hub reached over plain HTTP on another host receives none.
- The per-client limit keys on the connecting address. Behind a load balancer or reverse proxy that is the proxy’s address, so all browsers behind it share one limit, and many reports are dropped, until the hub has a trusted-proxy setting.
- A refused request still appears in the hub’s request log as a
429on/api/v1/csp-reports, so a throttled feed can be told from a quiet one there. - A request body over 16 KiB is refused whole with
413, so a large batch of reports is lost entirely. - The records are at Info level, so the hub’s log level, and any level set for
hub.csp-reports, must include Info (seeSCION_LOG_LEVELin Server Configuration). To read them from GCP-formatted logs:jq 'select(.subsystem == "hub.csp-reports")'.
Communication Patterns
Section titled “Communication Patterns”State Reporting
Section titled “State Reporting”Agents use the sciontool utility to report their state back to the Hub via the POST /api/v1/agents/:id/status endpoint. State updates include the agent’s current phase, activity, and contextual detail (e.g., which tool is executing). This happens at high frequency during task execution.
The endpoint answers 200 OK with {"applied": true} when the update was written. It answers {"applied": false, "reason": "..."} when the Hub accepted the report but wrote nothing because the agent’s status is owned by another operation. reason is either delete_in_progress (the agent is being deleted or is soft-deleted) or reincarnation_in_flight (a scion reincarnate migration owns the agent). While a delete or reincarnation owns the agent, a report that mixes those owned fields (phase, activity, message, exitCode, exitReason) with other fields such as taskSummary has the owned fields dropped and the rest written, and the answer is applied: true. applied: false is only returned for those two reasons: other guards (a suspended agent, a phase regression) drop phase/activity silently and still answer applied: true, so it does not mean the reported phase was stored.
Log Streaming
Section titled “Log Streaming”Logs are collected by the Runtime Broker and can be streamed in two ways:
- Real-time: Streamed via WebSocket from the Broker to the Hub, then to the Dashboard/CLI.
- Persistent: Batched and uploaded to a storage backend (like GCS) after agent completion.