Skip to main content
Web search provider configuration, instance credentials, and agent overrides are documented in Web capabilities (repository). Everything the dashboard can do, a program can do. The dashboard is a client of /api; this document is the contract that client and every other one rely on, and the runbook for the two callers that motivated it: a migration moving an agent in from another harness, and a control plane provisioning managed agents. For task-oriented setup, start with Create an agent. For feature behavior and limits, use these guides before calling their APIs:

Authentication

Two credentials open the same doors. A session cookie is what the dashboard holds after POST /api/login. An API token is what a program holds:
Tokens are created on the Settings page, or by an admin-scoped token at POST /api/settings/api-tokens. The secret is returned exactly once, in the create response, and never again; Formation stores only its SHA-256. A token acts as the operator who created it. Every attributed write (who enabled a tool, who saved a persona revision, who created a memory) records that person, exactly as a personal access token does on GitHub. Deleting the user deletes their tokens. Scopes. admin permits writes within its creator’s current role and agent grants; it does not promote an agent operator to owner. read may only GET and HEAD; a write under a read token is a 403 insufficient_scope. The bootstrapped management scope permits instance model settings, external-user provisioning, agent operations, model-provider catalogs, per-agent automatic model selection, and the management contract (capabilities, redacted readiness, execution-backend configuration and selection, Slack app provisioning). It cannot mint tokens, change authentication, read history, traces or exports, or access unrelated install settings. Its writes are attributed to a service identity rather than a human operator. CSRF. Mutating requests with a cookie must carry a same-host Origin or none. Bearer requests are exempt: a header the caller set is not a credential a browser attached behind their back. Tell them apart. GET /api/me returns {"email","via":"session"|"token", "token_name","scope"}, so a script can confirm which credential it is holding before it does anything.

Managing tokens

Revocation is immediate: the next request with that secret is a 401.

Conventions

  • JSON in, JSON out. Unknown fields in a request body are a 400 invalid_json, so a typo cannot silently become a no-op.
  • Errors are {"error": {"code", "message"}}. Codes are stable identifiers (slug_taken, agent_not_found, insufficient_scope); messages are for humans and may change.
  • Identifiers are UUIDs; agents are addressed by slug in paths.
  • Management and inspection routes require authentication. Setup status, first administrator creation, login, invitation inspection/acceptance, external-auth entry/callback, MCP client metadata, and Vault drop links have explicit public entry points. Invitation and Vault drop links use the link as the credential. Provider event routes use their own signature/secret verification.
  • There is no /api/v1 yet. The surface is stable in the sense that the dashboard depends on it; a versioned prefix arrives with the read-contract redesign the roadmap tracks as G2.

Temporal execution settings

Instance owners configure, test, activate, and repair Temporal connections and set instance/per-agent execution routing through the revisioned API described in Temporal configuration and execution (repository). The dashboard uses those same endpoints. Configuration remains available during a service outage; execution activation is gated separately from saving credentials.

Schedules

Persistent reminders and read-only briefings are created through the agent’s create_schedule tool in the delivery conversation. The operator surface manages existing schedules; it cannot invent a requester or delivery audience. Agent access is required for each route, and writes record the operator’s user ID. Actions are pause, resume, cancel, run_now, and update. Supply the schedule’s current version; stale or unavailable records return 409. run_now requires a stable UUID request_id reused on retries. update requires request: {instruction, cadence}. A cadence has kind and an IANA timezone, plus time (HH:MM) for daily/weekly, weekdays (0–6) for weekly, every_minutes for interval, or at (RFC3339) for once. Reads are no-store. Invalid requests return 400; unavailable scheduling returns 503. Pause/cancel remain available while the run worker is off. See schedule operations and timing semantics (repository) for bounds, DST handling, missed firings, rollout requirements, and the watchdog script.

Provisioning an agent, end to end

This example creates an agent and configures a manual connection. The dashboard provides the guided flow, including memory setup and automatic Slack creation; see Create an agent. Writes do not all have the same idempotency contract: inspect current state after an uncertain response. Persona saves create revisions, and provider-side effects need their own reconciliation.
Model billing keys may use instance defaults. Acting accounts for connectors and tools belong to the agent; see tool identity and permissions. Configure workspace execution before expecting a checkout tool to run.

Profile and picture generation

These routes require access to the selected agent: owners and operators granted that agent may use them. Reads accept read-scoped API tokens; writes require write permission. Provider credentials resolve through the agent’s configured credential source, including its explicit instance-model inheritance choice. Another agent’s credential is never a fallback. Paths below are prefixed with /api/agents/{slug}: Profile-draft purpose is nonempty and limited to 2,000 UTF-8 bytes. Avatar profile is the editable text the operator chose to submit, limited to the persona’s 16,384-byte ceiling. The image prompt contains that profile and this agent’s name; it does not fetch other persona revisions, memories, conversations, connected accounts, or secrets. Legacy avatar requests may supply a short purpose instead of profile, retaining the 2,000-byte limit. recommended_model always names a nonempty current catalog member, or is empty when none exist. The provider adapter supplies its preference, with a current catalog member as fallback. Omitting model at generation time uses the live recommendation; an explicitly selected model must still appear in that catalog or the request fails with 400 invalid_image_model. A recommendation does not guarantee later provider availability or credits. source is none, default, generated, uploaded, or unknown. none means no picture is stored. Provisioning fallbacks are default; pictures that predate provenance tracking are unknown. auto_generate is true only for none, default, and generated. It describes whether automatic replacement is safe, not a stored user preference. revision is a monotonically increasing integer encoded as a string, with "0" representing no stored picture. Read /avatar/state and supply its current value as avatar_revision when generating a replacement. Automatic requests require both automatic: true and this revision; they preserve uploaded and unknown pictures with 409 avatar_preserved. Explicit generation may replace those pictures. For compatibility, explicit requests may omit avatar_revision, in which case the server captures the current revision before contacting the model. Every generated picture uses a conditional database write against that revision. If a newer upload or completed generation changes it, the older generation returns 409 avatar_changed, preserving the newer picture even if its provider request ignored cancellation. A provisioning default also cannot overwrite an existing picture. On either conflict, read the latest state before deciding whether to try again. Generation may incur provider charges and is not idempotent: after an uncertain response, inspect the current avatar state before retrying. Profile or picture generation failures leave manual editing and uploads available. See automatic Slack setup for picture synchronization and the guided flow.

Bulk memory import

POST /api/agents/{slug}/memory/import lands many memories in one transaction and records where each came from.
  • audience is required and names who may see these memories at runtime. Since the audience model landed, a memory with no conversation source starts with unknown evidence and is not eligible for any turn until an operator releases it; an import that stored hundreds of memories the agent could never recall would look like success and be a silent failure. Every imported revision is released to this audience in the same transaction, by the importing user, exactly as a console release is. {"kind":"public"} means every conversation this agent joins; {"kind":"workspace","namespace":"…"} and {"kind":"members",…} narrow it. There is no default, on purpose.
  • kind and body are validated exactly as the dashboard’s create and the agent’s own remember are (lowercase kind, non-empty bounded body).
  • visibility may be omitted or perspective. conversation is refused: it restricts a memory to the conversation it was learned in, and an import has no conversation.
  • source is optional. system is required inside it, lowercased, up to 64 characters; ref (up to 512) is the source’s own identifier; created_at is when the source first recorded the memory. memory_entries.created_at stays the moment Formation learned it.
  • At most 500 entries per call (the body limit is sized for 500 full-size bodies); split larger migrations.
  • All or nothing, and serialized per agent. A bad entry refuses the whole batch with 400 invalid_entry naming its index. A storage failure rolls back with 500. Two overlapping imports for one agent run one after the other, so a retry cannot double what the first landed.
The response:
Duplicates. With skip_duplicates on (the default), an entry is skipped when its (system, ref) was ever imported for this agent (skipped_source_seen) — including into a memory an operator has since deleted, because that deletion was a decision and a re-run must not reverse it — or when a live memory with the same kind and body exists (skipped_duplicate). Provenance wins over body: a source row re-sent with an edited body is still the same source memory. Re-running an import is therefore a no-op. Set skip_duplicates: false to import everything anyway. Dry run. Send the same body with "dry_run": true and the call reports what it would do without doing it: identical validation, the same duplicate check against a consistent snapshot, the same per-entry verdicts with would_import in place of imported, no ids, no batch_id, and "dry_run": true at the top level so a plan cannot be mistaken for a result. Nothing is written and no lock is taken. A migration driven by an agent shows the operator the dry run, then sends the identical body without the flag. Imported memory carries source_type: "import" and its provenance is visible in the export below and in the memory list. batch_id groups everything one call created.

Whole-agent export

GET /api/agents/{slug}/export returns one document:
The document is read in one snapshot, so a save that lands while the export is being assembled is either wholly in it or wholly absent, never half of each. An export is refused with 413 memory_too_large when the agent holds more than 10,000 live memories; page them through GET /api/agents/{slug}/memory instead. The same ceiling applies to an import’s duplicate check (skip_duplicates: false bypasses it). No real agent is near it; it exists so one request cannot materialize an unbounded table. No credential appears in an export, redacted or otherwise: not the model key, not a connector token, not a tool secret. An export is a document that gets copied around; credentials live in the encrypted secrets table and are reached only through the endpoints that own them. Re-provisioning from an export always ends with capturing credentials again, on purpose. format changes when a field changes meaning, not when one is added.

Migrating an agent from another harness

The shape is the same whatever the source: read the source’s files, map them onto the calls above, keep the source’s identifiers as source.ref so the migration can be re-run. Two things do not move automatically. External acting credentials must belong to the agent; provision its own accounts rather than copying an operator’s login. Model billing keys may use instance defaults. Conversation history: Formation’s graph is what its own connectors observed; there is no import for another system’s transcripts, by design. Verify with GET /api/agents/{slug}/export and compare counts against the source. The migration is judgement work, not a converter. Every source instance grew a different set of files, so Formation ships no conversion tool. It ships a skill instead: skills/migrate-agent-to-mesh/SKILL.md (repository) tells an agent holding an API token how to survey an OpenClaw or Hermes workspace, decide the mapping out loud, dry-run each batch, land it, and verify against the export. Hand the agent the token, the skill, and the answer to “who may see these memories”, and let it do the reading. Use the import’s deduplication and dry-run options, and inspect current state before repeating writes. Persona saves append revisions; connector operations can include provider effects. For agent creation, an existing slug returns 409 slug_taken; read /api/agents/{slug} first and create only on 404, or send an Idempotency-Key header: with one, a create whose slug already exists answers 200 with the existing agent as it is now (header Idempotent-Replayed: true) and changes nothing. The slug is the identity — Formation does not store the key — so compare the returned display_name if you need to know whether you created the agent or adopted one.

Roles

Two, on the user (0050_operator_roles.sql): The role is read from the user’s row on every request, never from the cookie or the token, so a change an owner makes lands on the operator’s next call. An API token acts as its creator with its creator’s live role. How refusals read:
  • 403 owner_required on an install-level route from an agent_operator. They are signed in; the route is not theirs. Clients must not treat this as a sign-out.
  • 404 agent_not_found on /api/agents/{slug}/* for an agent the caller holds no grant for — the same answer an unknown slug gets, on purpose, so the set of agents an operator was not given is not enumerable from status codes. GET /api/agents lists only granted agents for an agent_operator.
  • API tokens: an agent_operator lists and revokes only their own; a token they did not create is 404 token_not_found to them. Owners see all.
The first administrator is an owner; so is every user that existed when 0050_operator_roles.sql ran, because every one of them was one. In external auth mode the control plane’s launch assertion carries the role (mesh_role, see external authentication) and Formation applies it on every launch, demotions included: the control plane, not this roster, is the authority for an external operator’s role. A role an owner sets here on an external operator lasts until that operator’s next launch. Agent grants are not part of the assertion; an owner grants agents here, and those grants survive launches until the operator is promoted to owner.

Sessions and sign-in hygiene

A cookie session (0006, 0050) ends at the first of two clocks: seven days after sign-in, or 24 hours without a request. Each request advances the idle clock (stamped at most once a minute). External auth mode keeps its 15-minute sessions. API tokens have neither clock; revoke them.
Throttling. Failed sign-ins and invalid invite links are counted in two fixed 15-minute windows, in-process per replica: 10 failures against one email (whether or not it exists — the throttle is not an enumeration oracle) locks that account for the rest of the window, 30 from one source address locks the source. Past either, POST /api/login, GET /api/invites/{token}, POST /api/invites/{token}/accept, the three /api/vault-drops/{token} routes, and the two /api/sketch-links/{token} routes answer 429 too_many_attempts with a Retry-After header, before any bcrypt work. A successful sign-in clears the account’s window. The lock is the window and nothing more: no permanent lock, no unlock endpoint, nothing that survives a restart. The source is the connection’s address, or — when the connection comes from a proxy named in MESH_TRUSTED_PROXIES — the nearest untrusted hop of X-Forwarded-For. Behind a proxy that is not named the source window is shared by everyone (thirty failures from anyone lock sign-in for the window), which is the reason to name it; the account window holds either way.

The off switch

Two levels, both rows every replica reads (0058; internal/admission), so a stop pulled anywhere holds everywhere and survives a restart. The agent’s own switch is POST /api/agents/{slug}/disable (below): nothing is admitted for it, and anything it is doing is cancelled within seconds — the governor polls the gate beside every in-flight turn and cancels the blocked call. The install’s switch is here. A cancelled turn ends with ❌ and no message; the run row (cancelled) and the activity trail say why. Deliveries that arrive while stopped are still captured into the graph and their work is cancelled at execution (install_stopped), so nothing is lost from history but the reply.
In-channel, a whole-message stop from any participant cancels the run it is addressed to (see docs/runtime/live-steering.md); switching an agent off for good stays an operator action.

About this install and Security

Two read-only owner endpoints back the Settings panes of the same names: the facts a support conversation starts with, and the sign-in policy the install runs under. Nothing secret appears in either — the master key by fingerprint only, the database by its migration level, never its URL.

Activity

The audit trail (0054): one row per state-changing request through this API — anything but GET/HEAD, whether from the dashboard or a token, with one exception: POST /api/client-errors, the browser’s own error reports, which are telemetry rather than an action — written by the session gate after the handler answers, so every route is covered by construction. A request the gate itself refuses after resolving who is asking (a read-only token’s write, an agent operator at an owner route) is a row too, as a refusal. Plus every sign-in attempt that reached a password check (auth.sign_in, auth.setup_admin, auth.accept_invite), by the auth handlers themselves. Reads are not rows. Throttled attempts (429) are not rows either: they are refused before any bcrypt or database work, and a write per attempt would hand a guessing burst the cost the throttle denies. Recording never changes an answer — a failed insert is logged and the request’s real status stands.
actor.via is session or token (with token_name) for route rows, and the sign-in method — password, external, invite — for auth rows. An anonymous attempt (wrong email, bad invite link) has an actor with a null id and the attempted email in details. Nothing in a row is a secret: request bodies are not stored, and nothing is decrypted to write one. There is no delete or edit route; retention is the operator’s call for now.

Operators

The people who sign in. Each has a role (above); an agent_operator also has a list of agents. The surface is the way in, the way out, and what each person is. Local auth mode only: in external mode the control plane owns the roster and provisions each operator on first launch, and the invite, disable and enable writes answer 409 auth_mode. PATCH there changes agent grants only: the control plane asserts each person’s role at every launch, so a body carrying role answers 409 auth_mode.
Guards: role is required for a new operator (400 role_required) and, for an existing one, may only restate their current role (409 operator_exists otherwise — change it with PATCH); grants are for agent_operators (400 agents_for_owner); every slug must exist (400 unknown_agent); you cannot change your own role (400 cannot_change_own_role); you cannot disable yourself (400 cannot_disable_self); the last enabled owner cannot be demoted or disabled (409 last_owner, checked under a roster-wide advisory lock so two owners demoting or disabling each other at once cannot both succeed); a disabled operator cannot be sent a link until enabled (409 operator_disabled); an operator provisioned by the control plane cannot be sent one at all (409 operator_external), since accepting a link would give a control-plane account a local password. Last sign-in is a column on the user stamped when a session starts, not derived from session rows, so a re-enabled operator is active, not invited. A disabled operator who signs in with the right password is told 403 account_disabled; with the wrong one they get the same 401 as anyone, so the account’s state is not an enumeration oracle. The link’s holder has no session, so the two routes they use are public: GET /api/invites/{token} answers {"email","expires_at"} or 404 invite_invalid (unknown, expired, used, and disabled-user links are indistinguishable, on purpose), and POST /api/invites/{token}/accept with {"password"} sets the password, ends every older session for that user, consumes the link, and starts a session. Links work once and expire after seven days.

The rest of the surface

The main dashboard surfaces are listed below; the registered route handlers remain the authority for authentication and payload details. Most routes under /api/agents/{slug} require agent access (owners and granted operators). Actor-identity writes and model-routing profile writes additionally require owner; everything under /api/settings except API tokens, plus POST /api/agents, the commitments health read and the install model catalog, is owner-only; the rest takes any signed-in operator. Agents. GET /api/agents · POST /api/agents · GET|PATCH /api/agents/{slug} · POST /api/agents/{slug}/enable|disable · GET /api/agents/{slug}/export Run limits. PATCH /api/agents/{slug}/run-limits uses the same authenticated agent-access boundary. budget_max_iterations accepts integers 2–120; budget_max_wall_seconds accepts integers 60–14,400. Omitted fields stay unchanged; null clears that override. An empty patch, unknown fields and out-of-range or non-integer values return 400 without writing. The response is the updated agent detail with both nullable fields, and serving agents reload the settings for new runs. Defaults are 60 work/4 conversation calls and an independent 1,800-second wall-time backstop. This endpoint never renews a commitment’s lifetime grant or changes a run that was already admitted. Persona. GET|POST /api/agents/{slug}/persona · POST /api/agents/{slug}/persona/activate Profile and picture generation. GET /api/agents/{slug}/identity/models · POST /api/agents/{slug}/identity/generate · GET|PUT /api/agents/{slug}/avatar · GET /api/agents/{slug}/avatar/state; see the identity contract for edited-profile input, live recommendations, provenance, and conditional replacement. Model. POST /api/agents/{slug}/model-provider · POST /api/agents/{slug}/model-provider/activate · GET /api/agents/{slug}/model-providers/{provider}/models · GET /api/model-providers/{provider}/models Connectors. GET /api/connector-catalog (what each connector does and how to set it up) · GET /api/agents/{slug}/connectors/slack/manifest · POST /api/agents/{slug}/connectors/slack|telegram · GET /api/agents/{slug}/connectors/{connectorID} · GET …/connectors/{connectorID}/messages/metrics Phone numbers. POST /api/agents/{slug}/connectors/phone/numbers (verify own-account credentials, list numbers) · POST /api/agents/{slug}/connectors/phone (connect with the agent’s own account) · GET /api/connectors/phone/shared-account · GET /api/agents/{slug}/connectors/phone/shared/numbers|available · POST /api/agents/{slug}/connectors/phone/shared (connect or buy on the instance account) · PUT /api/agents/{slug}/connectors/{connectorID}/phone-settings (who may text it) · owner: GET|PUT|DELETE /api/settings/phone (the instance phone account) Memory. GET|POST /api/agents/{slug}/memory · POST /api/agents/{slug}/memory/import · PATCH|DELETE /api/agents/{slug}/memory/{memoryID} · GET …/memory/{memoryID}/revisions · GET|POST …/memory/{memoryID}/grants · DELETE …/memory/{memoryID}/grants/{grantID} Dreaming. All routes below require the same authenticated operator session or API token as memory management. Read-only tokens may inspect state; writes require write permission. See Dreaming for privacy and cadence semantics. Scheduled dreaming starts disabled. Paths are prefixed with /api/agents/{slug}. Default configuration:
Intervals are validated: learning 1 hour–7 days, quiet 5 minutes–1 day, lifecycle 1 hour–30 days, grace 1–90 days. Rolling daily limits are 65,536–10,000,000 tokens and 1–500 changes. A stale source, memory revision, conversation or sharing grant yields 409; refresh and generate new evidence. Undo never overwrites a subsequent edit or revives an old sharing grant. Tools. GET /api/agents/{slug}/tools · POST /api/agents/{slug}/tools/{tool}/enable|disable · GET|POST /api/agents/{slug}/tool-secrets/{kind} · PUT /api/agents/{slug}/tool-config/{package} Vault. GET /api/agents/{slug}/vault · PUT|DELETE /api/agents/{slug}/vault/{name} · GET /api/agents/{slug}/vault/drops · DELETE /api/agents/{slug}/vault/drops/{id} · public: GET|POST /api/vault-drops/{token} · POST /api/vault-drops/{token}/renew — see Agent vault. Sketch requests. GET /api/agents/{slug}/sketches · DELETE /api/agents/{slug}/sketches/{id} · GET /api/agents/{slug}/conversations/{conversationID}/sketches · GET /api/agents/{slug}/conversations/{conversationID}/sketches/{submissionID}/image · public: GET|POST /api/sketch-links/{token} — see Sketch requests. Integration catalog (read). GET /api/agents/{slug}/integrations returns agent_id, integrations, connections, and operations for the caller’s permitted agent. It projects enabled native tools whose required credentials are present, approved MCP bindings, and available web capabilities. Disabled or credential-blocked capabilities are omitted. Responses are no-store and contain setup field descriptors, never captured values or decrypted credentials. Operations carry a stable catalog id, their unchanged model/ledger alias, integration_id, capability_id, connection_id, adapter_id, execution kind, effect class, replay policy, resource needs, and definition revision. Native and web entries include their existing input schemas. MCP entries preserve reviewed fingerprints but set discovery_required: true and omit the schema: reading this endpoint performs no network discovery or OAuth refresh. A catalog entry is metadata, not an execution grant; existing call-time validation still applies. Native operation revisions also include the manifest’s package_version, when available, so declared package upgrades invalidate the revision even if the tool schema is unchanged. Unversioned definitions only fingerprint their metadata. Legacy connection references include the agent identity. Their version is the source connection’s configuration version. Web connections also include binding_version for the agent’s own settings: with inheritance, instance key or configuration changes advance version, while agent settings changes advance binding_version. Neither is a complete authorization revision or a promise that credentials remain valid. This endpoint adds no connection writes, database migration, UI flow, or changes to model tool offerings. Inspection (read). GET /api/agents/{slug}/conversations · GET …/conversations/{conversationID} · GET …/conversations/{conversationID}/messages/{messageID}/trace · GET /api/agents/{slug}/actors · GET …/actors/{actorID} · GET /api/agents/{slug}/prompt-snapshots/{snapshotID} Actor list/detail responses include nullable preferred_name, the participant’s explicit choice. display_name uses that choice once saved and otherwise follows connector name resolution. The actor can change it conversationally; a Slack profile refresh cannot replace it. Broader actor profiles use attributed memory with its normal audience permissions. See actor profiles (repository). Conversation list/detail responses include display_name, a connector-resolved channel name or DM counterpart name (empty when unavailable). The dashboard prefers it over stored title, then channel_external_id/conversation_key. The name is presentation metadata: channel filters and routing continue using external IDs. Historical conversations resolve on read through the same path; lookups are cached and best-effort. See Slack conversation names for existing-install permission requirements. Install settings. GET|PATCH /api/settings · GET|PUT /api/settings/memory · POST /api/settings/memory/test|reindex · POST /api/settings/error-tracking/test · POST /api/settings/agent-runtimes/{backend}/test · GET|POST /api/settings/api-tokens · DELETE /api/settings/api-tokens/{id} Activity (owner). GET /api/settings/activity Operators (owner). GET /api/settings/operators · POST /api/settings/operators/invites · PATCH /api/settings/operators/{id} · POST /api/settings/operators/{id}/invites|disable|enable · public: GET /api/invites/{token} · POST /api/invites/{token}/accept Session. GET /api/me · GET /api/me/sessions · DELETE /api/me/sessions/{id} · POST /api/me/sessions/revoke-others · POST /api/settings/operators/{id}/sessions/revoke (owner) · POST /api/login · POST /api/logout · POST /api/client-errors Install (owner). POST /api/settings/stop · DELETE /api/settings/stop · GET /api/settings/install · POST /api/settings/install/verify-secrets · GET /api/settings/security · POST /api/settings/security/sign-everyone-out

Durable commitments

Commitments are configured per agent. Dashboard/API agent creation automatically enables them when the commitment service and run worker are available; a failed settings write leaves them off without failing creation. Existing agents retain their setting on upgrade. These endpoints accept dashboard sessions or Formation API tokens; read tokens cannot change settings or commitments. All responses use Cache-Control: no-store. pending is {"open","notices","proposals","last_activity_at"}: non-terminal commitments the reconciler would dispatch, terminal commitments still owed a completion or failure notice, unanswered confirmation proposals, and the most recent update across them (null when nothing is pending). Flipping enabled on makes all of it eligible again at once, so a client enabling after a long pause should read pending first and send "existing":"clear" when that work is stale. Clearing never deletes a commitment: each cancelled row records an operator_clear event and each withdrawn notice a notice_dropped event, both attributed to the caller, and the records stay listable. See Clearing pending work (repository). Selective cleanup accepts an action of cancel or close_incomplete, a shared nonempty reason of at most 2,000 bytes, and items containing 1–100 unique {id, version} pairs. It validates the entire request before writing anything, then applies each existing operator action independently under the authenticated operator and agent. It works while commitments are disabled and never marks work completed or changes linked external issues/PRs. Example:
A valid batch returns HTTP 200 with results: each item has its id and either an updated commitment or an error with the same redacted code and message as single-item actions. One stale item does not prevent later items from being processed. Malformed batches return 400 before any task changes. A lost response may follow partial success: read current versions and review remaining work before retrying, rather than assuming the whole batch failed. Action requests require the current version, an action, and a nonempty reason of at most 2,000 bytes. Example:
Actions return the updated record. Stale revisions, terminal records, active attempts that cannot be retried, or exhausted budgets return 409 commitment_changed. Unknown external writes block retry/authorization with the distinct 409 commitment_ambiguous_effect. Invalid requests return 400 invalid_commitment; unavailable storage returns 503 commitments_unavailable; enabling without a run worker returns 503 commitment_worker_disabled. Internal failures use a redacted 500 commitment_failed response. An acknowledgement is human evidence, never an agent’s self-assessment. The original run, requester, conversation, audience, objective, and evidence contract remain immutable. Existing evidence contracts support {"kind":"operator_ack"} and tool_result with an exact enabled query tool, validated JSON-object arguments, an RFC 6901 pointer, and a JSON equals value. For JSON encoded inside an MCP text envelope, use json_source_pointer to select the string to decode first:
The tool name and argument schema must come from the current agent’s available tool catalog; this example is illustrative. A matching result establishes only the declared predicate. Errors, truncated observations, and effectful tools do not count as evidence. Existing agents do not gain any new connector permission. Defaults are a seven-day deadline, twelve attempts, and 480 reserved iterations. For nonterminal criteria tasks, grant_allowance can raise them to 48 attempts and 5,760 iterations. Each background attempt uses the agent’s turn budget, capped by the task’s remaining lifetime allowance and deadline. Existing tasks retain their previously granted limits after an upgrade. An objective is 1–4,000 bytes; an agent can have at most 100 nonterminal commitments. Status is one of open, in_progress, waiting, stalled, escalated, completed, closed_incomplete, failed, or cancelled. The health endpoint uses database time and fails when an enabled reconciler has no heartbeat within two minutes or a work/notice dispatch is overdue by five minutes. Configure an independent alert using scripts/check-commitments.sh; see the runtime runbook (repository). Incoming accepted work uses a criteria contract. Its required outcomes may use exact recorded query predicates, independent semantic verification of an actual reply/artifact, a richer verifier for complex artifacts, or specifically requested human attestation. An empty rubric is unconfigured, never satisfied. Every criterion and its evidence revision must be current before the task completes. waiting retains responsibility with a typed cause and a resolution condition; questions, delivery state, reminder timing, and remaining allowance appear in completion. Generic assent to start work does not attest completion.

Work sources

These routes use the same agent-scoped session/token authorization. Assignment plans use approved read-only MCP tools and an explicit operator assignment policy. They verify the agent’s own account, prove pagination coverage, and preserve canonical resource identities across connection changes. A connection alone does not authorize accepting assignments. Observation plans can also be derived from an exact query criterion already authorized by a task. See the implementation and rollout runbook (repository) for configuration, retention, and operational boundaries.

Agent progress updates

Progress updates run automatically for every active agent, including existing agents with no settings record. There is no enablement switch or configuration step. The working model is reminded to report meaningful progress every three minutes. The supervisor checks queued checkpoints every five seconds and enforces 60 seconds between delivery attempts. Empty ticks do not create progress events. Progress is delivered in the original conversation. It is never a final reply or evidence of task completion. The per-message /trace response includes run.progress, with text, source snapshot/provenance, status, model usage, timestamps, and connector receipt. Statuses are candidate, reserved, sent, unknown, and suppressed. A checkpoint keeps the same event ID through delivery; it is not also recorded as a suppressed/coalesced copy. Distinct updates queue in creation order; exact copies of the last delivered text are marked duplicate. reserved means delivery is pending or unconfirmed; the connector may already have accepted it. On execution takeover, unfinished reservations become unknown. Neither state is replayed. JSON-shaped drafts are rewritten by the fast model before sending, and the raw draft remains in the operator-only source snapshot. The trace distinguishes disclosure refusals by source category from operational check failures and rewrite failures. These records are operator-only; their content may be sensitive. A search_history tool call in the /trace response carries search_audit: the conversation search it recorded (docs/runtime/conversation-search.md). It has query_digest (the query is never stored as text; this is a keyed HMAC under a subkey of MESH_AGENT_MASTER_KEY, so it cannot be reversed by hashing guessed words, and it is empty when no master key is configured; the call’s arguments carry the text while bodies are retained), filters, the destination audience the search was authorized against, selections (each returned message id, its conversation, its score components, and the neighbor ids shown with it), and exclusions — how many text matches each rule removed: already_in_context, no_evidence, audience, lineage, and below_cutoff, beside matched (counted up to 1,000, with capped; past the ceiling below_cutoff is a lower bound, while lineage stays exact) and returned. It answers “why did the agent not find that?” without re-running the search. Counts only, never withheld text; the model never sees them. Other tool calls omit the key.

Instance model defaults

Owners configure defaults in Settings → Models (/settings/models). Both instance-default routes require an owner; agent-scoped inheritance requires access to that agent. Management scope refusals are included in the activity log. GET /api/settings/models returns the default provider and primary/fast/heartbeat model IDs, a providers map of key presence, and optional management provenance. It never returns plaintext credentials. PUT /api/settings/models replaces the non-secret defaults and optionally sets or removes the selected provider’s key:
Model ids must be concrete: router pseudo-models such as openrouter/auto are refused with 400 unpinned_model. The primary is also the fallback and price ceiling for automatic model selection. Omit api_key to retain a key; use clear_key: true to remove it. Providers are openrouter and router (Ramp Router). Keys are encrypted with the existing MESH_AGENT_MASTER_KEY. Successful writes return status: active or saved_restart_required; the latter means persistence succeeded but the runtime could not reload. GET exposes no secret material, including to read-only tokens. New agents inherit the instance credential: one instance key per provider (for example a single OpenRouter key) serves every inheriting agent’s turns, the catalog discovery behind automatic model selection, and native OpenRouter decision calls. Existing agents with a provider choice or stored credential retain their original payer. Saving/activating an agent key sets model_credential_source: agent; a missing or unreadable agent key never falls through to the instance key. To opt back into inheritance:
A provider string instead of null pins that provider while inheriting its instance key. The response is {"status": "active" | "saved_restart_required", "provider": "<effective provider>"}: provider names what this write committed (the pinned provider, else the instance default at the moment of the write), so a client never has to trust an earlier read of the instance defaults. Agent model-role choices remain explicit overrides. Clear those fields to inherit matching-provider instance models. The process-level OpenRouter key (OPENROUTER_API_KEY) remains a legacy fallback only for a self-hosted (MESH_AUTH_MODE=local) deployment, only for installation-billed agents on the default provider, and only before any instance-default document — self-managed or managed — has been saved. A hosted (MESH_AUTH_MODE=external) deployment never bills it, and an agent with its own credential (model_credential_source: agent) never falls back to it, including a legacy agent whose stored key has no provider recorded and cannot be read: its turns fail at the provider and boot logs name it. Hosted management may supply managed_by: control_plane plus managed_account_id with a key. Managed keys occupy separate encrypted records, so switching back to BYOK preserves the user’s original key. A disconnect should supply expected_managed_account_id and clear_key: true with no management provenance; a mismatched current owner returns status: not_managed without mutation. These provenance fields describe ownership; they are not authorization credentials. Any save that changes the payer — leaving a managed document unmanaged (that disconnect, or an owner saving self-managed defaults with or without a new key outside the control plane), or a managed save naming a different managed_account_id — deletes every managed key record for the install in the same transaction, before storing any replacement key. A provider switch within the same account keeps that account’s records for its other providers. Managed records are only resolved while the document is managed, so a superseded managed account can never become the payer again: clearing the owner’s own key afterwards leaves the instance with no key, not the managed one. Re-enabling management requires a fresh api_key. Owner BYO install keys and agent-owned keys are never touched by this cleanup.

Bootstrapped management identity

A hosting operator can set MESH_MANAGEMENT_TOKEN_SHA256 to the 64-character hex SHA-256 of a mesh_ bearer secret retained by its control plane. Formation stores a management-scoped token under a service principal with no usable password. This principal is excluded from the human operator roster, owner counts, and first-human administrator setup. Existing revocation survives process restart: a hash that was revoked from the dashboard is logged at boot and never reissued, and any other active management token is left in place rather than retired in favour of a dead hash. Supplying a different hash retires earlier management tokens for this identity. Bootstrap takes an install-wide advisory lock, so replicas booting with different hashes serialize instead of each leaving the other’s token active; keep all replicas on the same hash during normal operation. Two configurations stop boot with an actionable error rather than silently disabling management access: a human user already owning the reserved service email, and a hash that an active operator (admin or read) token already carries — the unique-hash constraint would otherwise let that broader token stand in as the control plane’s credential. The bearer may access model defaults, agent API routes, model catalogs, per-agent automatic model selection (and a read of the installation routing document), POST /api/management/users, the management contract below, Slack app provisioning and delegated Slack setup (below). It cannot mint API tokens, change authentication, read conversation history, traces, exports or the activity log, or edit unrelated install settings (GET|PATCH /api/settings stay refused). The allowlist is method- and route-exact (managementProvisioningRoutes in internal/admin/session.go), and a test walks every registered route to keep it that way. A human administrator can inspect/revoke it through the token settings UI.

Management contract v1

Owner routes, also reachable with the management bearer. A control plane negotiates first: a 404/403 from the capabilities route means the instance predates this contract and must be upgraded. Capabilities are agents.create, agents.configure, agents.runtime.select, models.settings, readiness.v1, and runtime.<backend>.settings / runtime.<backend>.test for each execution backend this binary has (so runtime.modal.* exactly when the Modal backend is built in), plus models.routing when the instance has automatic model selection (a database), slack.provisioning when it has Slack provisioning (a database), slack.delegated_setup when this instance can serve delegated Slack setup (external auth, a return origin, and Slack provisioning), and usage.v1 when it has usage reporting (a database). A capability is only advertised for an operation the management bearer can reach. capability_status is always an object. It carries the live readiness of an advertised capability that depends on configuration rather than on the binary — today only slack.delegated_setup:
ready means a delegation requested now could reach Slack; reason is null when ready, else provisioning_not_configured, provisioning_expired, public_url_invalid, master_key_missing or provisioning_unavailable. The capability string itself is still advertised while not ready, so a missing grant never reads as “upgrade required”: connect one (below) and re-check. A client that ignores capability_status sees exactly the capabilities it saw before this field existed. Runtime configuration. {backend} is an execution-catalog name (modal, fly); an unknown one is 404. The body has every config field of that backend as a top-level key — for Modal, environment and image — each a string or null (unset), all required, because the configuration is replaced whole. token is three-state: omitted keeps the stored credential, null clears it, a string replaces it; it is encrypted under the master key and never readable back. The backend is enabled exactly when a token remains stored after the write. if_match (optional) is a revision from a previous read ("unset" for a backend never configured): when the stored backend has moved on, the answer is 412 stale_revision and nothing is written. Writes serialize on one lock with the owner’s settings page, so two writers holding one revision cannot both succeed. When the deployment environment holds the backend’s token (MESH_MODAL_API_TOKEN), the answer is 409 environment_managed.
The test with no body (or {}) tests the configuration in force and records the outcome for readiness; a body may carry proposed config/token values, which are tested but not recorded. A connection test proves the credential reaches the vendor, not that a billable workspace can run. Readiness carries no content: no persona, message, memory, credential, token hint, or Slack workspace/team name.
runtime.backend is the agent’s pin (null = install default); configured means the pinned backend, or with no pin any backend, is enabled. Slack status is not_configured, credentials_saved (app created, or a connector whose bot identity was checked but to which Slack has not yet delivered a signed request), authorization_pending (waiting on Slack install), connected (Slack has delivered a signed request to this install; verified_at is when), or error with error_code (activation_incomplete, connector_disabled, or a recorded setup failure such as access_denied or slack_verification_failed). A connected install whose later re-authorization failed stays connected and reports that failure in error_code. first_inbound_at/first_response_at are when the install first accepted a human message and first delivered a reply — timestamps only. last_test_ok/last_test_at are null until a test of the configuration currently in force has run — a test of a since-replaced configuration is not reported. In external-auth mode, user provisioning accepts { "subject": "issuer-subject", "email": "user@example.com" } and returns { "id": "...", "email": "..." }. It always uses the deployment’s trusted issuer, is idempotent for an existing issuer and subject, and does not create a password/session or link an existing account by email alone. A user it creates starts as an agent_operator with no grants, and it never changes an existing user’s role; the role the issuer asserts is applied when that person launches. Local-auth instances reject the operation.

Usage reporting

GET /api/management/usage (capability usage.v1) is what a control plane reads to show an install’s consumption: raw usage only. Every number is a count, one of the provider’s own native token counts, the provider’s own reported cost, or a number of seconds. Formation never prices anything: it has no price table, and a cost here is the figure the provider attached to the call (OpenRouter’s usage.cost) or nothing. Pricing, margins and bills are whoever operates the install’s business and live outside Formation. Parameters. from and to (YYYY-MM-DD, inclusive, required); time_zone (an IANA name, default UTC; days are bucketed by each row’s end time in that zone); granularity (day, the default, for a window of at most 7 days, or call, for exactly one day, from equal to to); cursor (opaque, from a previous page’s next). A bad parameter is a 400 with a code: invalid_from, invalid_to, invalid_range, range_too_long, invalid_time_zone, invalid_granularity, invalid_cursor. Response (granularity=day):
  • models rows are keyed by (date, agent, source, provider, upstream_provider, model, credential_source, managed, outcome) and sorted in that order. model is the serving model id when the provider reported one, else the requested one. source is turn, background, dream or progress (taxonomy below). provider is the neutral provider name the agent’s model_provider resolved to; upstream_provider is the route that provider reported serving the request (an aggregator’s provider field), when it reports one. credential_source is agent (the agent’s own key), instance (the install’s key) or deployment (the process environment’s key); managed is true when the install key in use was installed by a control plane. outcome folds statuses to ok or failed. Unknown dimension values are empty strings, never null.
  • cost_micros is integer micro-dollars (one millionth of a dollar), summed over the calls that reported a cost; calls_with_cost counts those calls, so calls_with_cost < calls means some calls carry no cost rather than a cost of zero. total_tokens is the provider’s own total (it can exceed the sum of the prompt and completion counts, and is the only count a dream job made before this contract carries). token_basis is always native: counts are the providers’ own, by their own tokenizers; Formation never re-estimates them.
  • execution rows are keyed by (date, agent, backend, size); backend is the execution backend’s name (fly, modal, broker), size the tier the workspace was asked for (small, standard, large, xlarge, or empty for a row that predates this contract). A sandbox lands on the day it ended, by the backend’s terminated_at when it reported one, else Formation’s own released_at. seconds is the backend’s metered seconds when it reported them, else the whole seconds from ready to end, rounded up; seconds_unconfirmed is the part from sandboxes whose end the backend did not confirm (a broker still terminating, a workspace already gone), which a reader billing by this clock treats as provisional. A sandbox handed from one run to a continuation appears once per run, each row for its own share of the life.
Response (granularity=call): the same envelope with calls and sandboxes instead of models and execution:
cost_reported distinguishes a call priced at zero from one that reported no cost. terminated_at is null when the backend reported none; termination_reason is the backend’s own word (the broker protocol’s vocabulary; Formation writes handoff when a sandbox moved to a continuation), or empty. Calls are sorted by completed_at and sandboxes by their end time. Paging. A page holds at most 500 rows (day) or 200 rows (call) across both sections, in section order. When more remain the page answers "truncated":true and an opaque next cursor that names the last row’s full sort key, so a single day with more rows than a page completes across pages; pass it back as cursor with the same from, to, time_zone and granularity (a cursor presented with a different query is 400 invalid_cursor). The last page answers "truncated":false,"next":null. Every statement runs under a 15-second statement timeout; a window that cannot be answered in time is 503 usage_timeout. Source taxonomy. Every billed provider call is counted exactly once:
  • turn — the turn engine’s calls (model_calls, both outcomes). A failed call the provider never answered (no request id, no usage) is not a call for usage purposes; a failed call that was answered keeps the usage the provider billed.
  • background — classifiers, steering, memory advice, routing and other out-of-band calls (decision_calls), except: calls still running; dream relevance (task=dream_relevance), whose tokens and cost are folded into the dream job that made them; and a model-routing attempt (task=model_routing_attempt) that the turn recorded as its own call — the attempt audit wraps the very same provider call, matched by provider_request_id within the run whatever the attempt’s outcome (a succeeded attempt the provider gave no id is matched to a succeeded turn row of its run completed after it started). An attempt whose turn row kept neither the request id nor any usage — a failure before the provider answered, or a row from before this contract — cannot be matched and is counted here. A matched pair with no usage on either side is one call with nothing to attribute, reported once under turn.
  • dream — finished dream jobs (dream_jobs): all of a job’s calls summed.
  • progress — the rewrite call behind each progress update (progress_events).
Redaction. Same rule as readiness: agent slugs, model ids, provider and backend names, sizes, request ids, status words, counts, timestamps and cost — identifiers the control plane already holds or the provider minted — and never a message, prompt, body or credential. A model id outside the identifier alphabet ([A-Za-z0-9._:/@+-], at most 128 characters) is folded to other. Rows written before this contract carry empty provenance and no cost (calls_with_cost says so); they are reported, not invented.

Slack app provisioning

Automatic and delegated Slack setup need the install’s Slack App Configuration grant (see Automatic Slack setup). Owners connect it in Settings (POST /api/settings/slack-provisioning); a control plane connects it with the management bearer through its own redacted route. Capability: slack.provisioning. PUT /api/management/slack-provisioning takes {"refresh_token":"xoxe-…"} (a Slack configuration refresh token, 10–4096 characters; other fields are a 400). Formation rotates it immediately through tooling.tokens.rotate, stores the resulting access/refresh pair encrypted under MESH_AGENT_MASTER_KEY, and keeps it refreshed. The token is write-only: no response, log or activity row carries it. Connecting replaces any previous grant, which can change the provisioning workspace; existing bot installations keep working. The write is retry-safe: Slack refresh tokens are single use, so sending the refresh token the stored grant was connected from answers from that grant without contacting Slack, with Idempotent-Replayed: true. Errors: 400 invalid_token, 412 master_key_missing, 502 slack_<code> when Slack refuses the token (for example slack_invalid_refresh_token; generate a new configuration token pair), and 503 provisioning_unavailable on an instance without provisioning. DELETE /api/management/slack-provisioning removes the grant (idempotent). New automatic or delegated setup then stops with 409 provisioning_not_configured; apps and bot connections already created are untouched. GET (and both writes) return the status:
  • health: not_configured, ok, refresh_failing (the last refresh failed; the current access token still works until expires_at and refresh is retried every 15 minutes), expired (refresh failing and the access token has expired: setup fails until a retry succeeds or a new refresh token is connected), or unavailable (MESH_AGENT_MASTER_KEY is not set).
  • ready: setup can start now — a usable or refreshable grant and a valid PUBLIC_URL. refresh_failing is still ready until expiry.
  • problem: null or the most severe of master_key_missing, expired, public_url_invalid, refresh_failing.
  • workspace_name is best effort (Slack’s auth.test for the configuration token when it was connected) and may be null. last_rotated_at is null for a grant connected before this field was recorded, until its next rotation.
  • last_refresh_error: null, or {"code":"slack_invalid_refresh_token","at":"…"} — Slack’s error code prefixed slack_; for a request that got no usable answer, Formation’s classification with the same prefix (slack_request_failed when Slack was unreachable or timed out, slack_http_<status>, slack_invalid_response); or workspace_changed / rotation_failed. A rotation abandoned because its caller’s request or shutdown ended is not recorded. Cleared by the next successful rotation.
The owner’s GET /api/settings/slack-provisioning reports the same facts for the settings page (health, rotated_at, workspace_name, refresh_error, refresh_failed_at, problem_code, Unix seconds). Connect and disconnect are recorded in the activity log with the workspace id and the replay flag.

Delegated Slack setup

POST /api/management/agents/{slug}/connectors/slack/delegations lets a hosted control plane hand one console user the agent’s automatic Slack authorization without a Formation session. Only the management bearer may call it (other credentials get 403 management_token_required), and only in external-auth mode. Body: { "subject": "issuer-subject", "return_url": "https://console…/…", "repair": false }. Mesh resolves subject to the user provisioned through POST /api/management/users (409 subject_not_provisioned otherwise), applies that user’s own role and agent grants (an ungranted agent is the usual 404), advances the agent’s own Slack app to an authorizable state (one app per agent and provisioning workspace; repair: true also reapplies its callback, event URL and permissions), and returns { "authorize_url": "https://<instance>/api/slack/delegations/start?token=…", "expires_at": "<RFC 3339>" }. The link is single use and expires in ten minutes. return_url must be an absolute https URL whose origin is the origin of MESH_AUTH_AUTHORIZE_URL or is listed in MESH_CONTROL_PLANE_ORIGINS (comma-separated origins; 400 invalid_return_url otherwise). The browser opening the link gets a host-only binding cookie for that one OAuth attempt — not a session — and is sent to Slack. Slack’s redirect completes through the usual callback, which validates the delegation, binding, expiry and single use instead of a session, checks the app, workspace, bot user and scopes, activates the connector, and returns the browser to return_url with slack=connected or slack=error&code=<code>. Codes: delegation_expired, delegation_used, browser_mismatch, access_denied, authorization_cancelled, authorization_expired, authorization_replayed, workspace_mismatch, missing_permissions, slack_verification_failed, slack_app_in_use, activation_pending, activation_failed, exchange_failed, setup_state_changed, setup_unavailable (the link could not be started for another reason), authorization_invalid (the approval does not match its delegation or agent), or slack_<slack error>. An unknown or foreign link is a plain 400 with no redirect. Retry or repair by requesting a new delegation. Each step is recorded in the activity log against the delegated user, with codes only. The capability string is slack.delegated_setup; its live readiness (a working provisioning grant and a valid PUBLIC_URL) is in capability_status. Progress shows in the readiness report above: credentials_saved until Slack delivers a signed request, then connected, with the first-conversation timestamps. Conversation labels are shared across the agent overview, conversation roster, channel filter, conversation detail, and actor timeline. Actor message responses include conversation_display_name, resolved through the same cached, agent/install-scoped lookup as conversation display_name. All dashboard labels use conversationLabel to prefer the resolved name, then stored title, channel ID, and finally conversation key. Adding another conversation link should use that helper rather than recreate a fallback chain.

Automatic model routing

Agent routes require access to the selected agent; profile publication additionally requires owner. The installation document requires owner. The management bearer may read and write /api/agents/{slug}/model-routing and read /api/settings/model-routing (capability models.routing); the other routes here stay refused to it. A new agent’s first policy revision is the installation’s new-agent default: {"enabled":true,"shadow":false,"family":"any","preference":"balanced","revision":1} out of the box (POST /api/agents records model_routing in its activity details when it seeds one). With the default turned off, or for an agent created before this default existed, the policy is the disabled default (revision: 0). A seeded policy is not qualified against a catalog, because the agent has no connection yet: until the agent has a concrete primary model listed in its catalog, turns are served on the primary as with routing off, and GET reports available: false with a reason. Families are any, anthropic, openai, google, xai; preferences are balanced, speed, cost, quality. A normal save uses the agent’s actual serving connection to discover and store catalog facts with the policy. It does not require an uploaded evaluation. Changes apply to new turns. Policy writes are recorded in the activity log with the saved enabled, shadow, family and preference. See Automatic model selection for availability, conservative fallback and shadow behavior.

Decision model settings

Human owner/admin credentials can configure the independent installation decision role. Management-scoped provisioning tokens cannot access these endpoints.
  • GET /api/settings/decisions returns config: {provider, model, body_retention_days, last_source?}, available native providers: [{name, label, default_model}], boolean keys presence, and agents: the route each agent’s classifiers take under the saved configuration, [{slug, display_name, text_provider, disabled, route, provider?, billing?, model?}]. route is native (a decision model from provider; billing is decision_key for the installation decision account, or, when automatic mode reuses the text-model credential, instance_model_key for an agent billed by the installation’s model key, agent_model_key for the agent’s own, or deployment_model_key when serving substitutes the process fallback key: a legacy agent with no provider and no key of its own, or an installation-billed agent on an install that never saved model settings and holds no installation key for the default provider; no_model_key whenever the credential serving would use does not exist — no fallback key in those cases, or no stored key for an agent’s named provider or an installation’s saved provider — so the call cannot succeed; no_decision_key when an explicit source’s installation key is not stored), text (a text model on the agent’s own provider; model is the classification override, absent means the agent’s fast model) or unavailable (a stored provider this build cannot serve; classifiers fail closed). It is computed by the same resolver serving uses, after the same provider inheritance (an installation-billed agent with no provider of its own uses the installation model provider; a provider this build does not implement reports as the catalog default). It never returns credentials.
  • PUT /api/settings/decisions accepts {config: {provider, model, body_retention_days, last_source?}, api_key?}. last_source is kept only with provider: "fast": the source (auto or a native provider) to restore when decision models are turned back on, returned by GET so a reload does not silently switch provider and payer. An unsupported value is refused with invalid_last_source; with any other provider it is dropped. Providers are auto, fast, typesafe, and openrouter. fast means decision models are off: most classifiers use a text model through the agent’s own provider. Automatic model routing is an exception: without a ready native decision route it keeps its conservative model fallback. auto is the default and turns them on through each agent’s text provider: native OpenRouter decisions reuse each agent’s own text-provider billing key when available, while other text providers retain their fast classifier. Explicit native providers use a separate encrypted installation key and work independently of text-model provider selection. Omit api_key to preserve the selected provider’s existing key. Empty keys are rejected. For a native provider model may be blank to select the provider’s versioned default; for fast a model names the text model classifiers use instead of each agent’s fast model (it must exist on every agent’s provider); auto requires a blank model. body_retention_days is 0 (default, retain indefinitely) or 1–3650. Pruning removes request/response bodies while retaining outcomes, provenance and usage. Response status is active or saved_restart_required after registry reload.
  • POST /api/settings/decisions/test checks an explicitly configured native provider using synthetic text and returns {ok: true, model}. It does not test automatic mode or send conversation history.
See decision-models.md (repository) for scope, thresholds, conservative failure behavior, direct API comparison and the codebase roadmap. Decision invocation audit reads use the same operator authorization and agent access grants as conversation traces:
  • GET /api/agents/{slug}/decision-calls?before=<id> returns {calls, next_before} with up to 50 metadata summaries, newest first, including bodies_available.
  • GET /api/agents/{slug}/decision-calls/{id} includes exact request/response bodies when retained. Foreign-agent and nonexistent IDs return 404.
These records include pre-run engagement calls as well as run-linked consent, sensitivity, commitment-assent, shadow promise-check and tool-preselection calls. They have independent usage attribution and do not alter the primary model’s run token counters. Management tokens cannot read them.

Agent vault

Agent-scoped (owners and granted operators). The vault holds credentials an agent uses for its own work, separate from tool-secrets and connector credentials. Vault reads return metadata, never stored values. See the Vault guide for setup and supported use sites.
  • GET /api/agents/{slug}/vault returns {entries: [{name, description, source, created_at, updated_at, last_used_at}]}. source is operator or drop; last_used_at is null until a use-site applies the entry.
  • PUT /api/agents/{slug}/vault/{name} accepts {value, description} and adds or replaces the entry, returning its metadata. Names start with a letter and contain only letters, digits, or underscores, up to 64 characters; other names return 400 invalid_vault_name. Values are trimmed of surrounding whitespace. Blank values or values over 32 KiB return 400 invalid_value; descriptions over 500 characters return 400 invalid_description. Without MESH_AGENT_MASTER_KEY, saving returns 412 master_key_missing.
  • DELETE /api/agents/{slug}/vault/{name} permanently removes an entry; 204, or 404 vault_entry_not_found.
  • GET /api/agents/{slug}/vault/drops returns outstanding requests as {drops: [{id, name, description, renewals, created_at, expires_at, renewable_until, state}]}. It never returns a link token.
  • DELETE /api/agents/{slug}/vault/drops/{id} revokes the identified link; 204, or 404 drop_not_found.
vault_request creates a one-time URL at PUBLIC_URL/drop/{token} for the agent to share in its conversation. The public endpoints use that token as the credential; the submitter does not need an operator session.
  • GET /api/vault-drops/{token} returns {agent_name, name, description, requested_by, expires_at, renewable_until, state}. state is open during the 15-minute submission window or expired while renewal is allowed. GET does not consume the link, so previews do not prevent submission.
  • POST /api/vault-drops/{token} accepts {value}, trims surrounding whitespace, stores the value with source: drop, consumes the link, and queues a run for the agent to confirm receipt in the original thread; 204. Only one racing submission succeeds. An active conversation delays the wake, not the save. Blank or oversized values return 400 invalid_value; a missing master key returns 412 master_key_missing.
  • POST /api/vault-drops/{token}/renew requests a replacement; 204. The agent posts the new link in the original conversation; it is never in the response. A link can renew after its submission window closes and until 72 hours after it was issued, with at most five renewals across the request. Each replacement has fresh clocks. Repeating a request while its wake is pending or running has no extra effect. An expired page remains renewable within those limits if delivery fails; consuming any link retires the other links in its request.
Unknown, consumed, revoked, too-old, and expired links with no renewals left all return 404 drop_invalid. Renewing a still-open link also returns that error. Public writes require a same-host Origin or no Origin; all three routes share the sign-in throttle. Public lookup uses token hashes; encrypted copies of renewal tokens let workers retry with the original URL and expiry. All vault responses use Cache-Control: no-store. A deployment without a database returns 503 vault_unavailable. Mutations are audited without secret values; token path parameters are scrubbed. Vault entries and renewal-token envelopes are covered by rotate-key and verify-secrets; see secrets and recovery.

Sketch requests

Agent-scoped (owners and granted operators). An agent asks the person it is talking to for a drawing with the sketch_request tool, which creates a one-time link at PUBLIC_URL/sketch/{token}. The person draws on a canvas and sends it, and the drawing reaches the agent as an image in the same conversation. Links are issued only when PUBLIC_URL is https, or plain http on a loopback host.
  • GET /api/agents/{slug}/sketches returns the links that can still accept a drawing as {links: [{id, conversation_id, title, prompt, created_at, expires_at}]}. It never returns a link token.
  • DELETE /api/agents/{slug}/sketches/{id} revokes an open link; 204, or 404 sketch_not_found for an unknown, used, or already revoked link.
  • GET /api/agents/{slug}/conversations/{conversationID}/sketches returns the conversation’s requests, newest first, as {sketches: [{request_id, title, prompt, status, created_at, expires_at, submission_id, submitted_at, note, labels, labels_truncated}]}. status is open, submitted, expired, or revoked; the submission fields are present only when a drawing arrived. labels is the text in the drawing, at most 100 entries and 4,000 characters.
  • GET /api/agents/{slug}/conversations/{conversationID}/sketches/{submissionID}/image returns the drawing as image/png. An id from another conversation or agent returns 404 sketch_not_found.
The public endpoints use the link token as the credential; the person drawing does not need an operator session.
  • GET /api/sketch-links/{token} returns {agent_name, title, prompt, expires_at}. GET does not consume the link, so previews do not prevent submission.
  • POST /api/sketch-links/{token} accepts multipart/form-data with image (one PNG file, at most 2 MiB and 2048 px on either side), scene (the Excalidraw scene JSON, at most 512 KiB), and an optional note (at most 1,000 characters). It stores the drawing, consumes the link, and queues a run that gives the drawing to the agent in the original thread; 204. Only one racing submission succeeds, and an active conversation delays the run, not the save. Errors: 400 invalid_form, 400 invalid_image, 413 image_too_large, 400 invalid_scene, 413 scene_too_large, 400 invalid_note, and 413 body_too_large for a body over the combined limit. A refused submission does not consume the link.
A link accepts one drawing for 24 hours. Unknown, used, revoked, and expired links all return 404 sketch_link_invalid. The public write requires a same-host Origin or no Origin, and both routes share the sign-in throttle. Public lookup uses token hashes. All sketch responses use Cache-Control: no-store: the prompt is conversation content behind a bearer link. A deployment without a database returns 503 sketches_unavailable. Mutations are audited, and token path parameters are scrubbed. Drawings are deleted with their conversation.

Workspace network policy

Owner-only. Agent workspace networking is unrestricted by default. Optional allowlist mode restricts destinations on capable backends; this is independent of credential permissions and the document parser’s network isolation.
  • GET /api/settings/workspace-egress returns {config: {mode, allowlist, uncontrolled}, built_in, max_allowlist}. mode is "open" (default for an unsaved install) or "allowlist". allowlist contains additions to built_in (Formation’s source-host and package registry list); the runtime also permits the Formation broker host in restricted mode. uncontrolled is "allow" or "refuse": in allowlist mode, accept open egress on a backend without enforcement or refuse to create a workspace there. It has no effect in open mode. max_allowlist is 200.
  • PUT /api/settings/workspace-egress accepts {config: {mode, allowlist, uncontrolled}} and returns the same view. All three fields are required. Invalid modes, missing/null fields, invalid domains, or storage failures leave the existing policy unchanged. Domains are hostnames or single leading wildcards (*.example.com), not URLs, IPs, ports, paths, or bare *; entries are lowercased, deduplicated and sorted even in open mode.
  • Saved legacy documents without mode retain allowlist behavior. GET returns the explicit effective mode so owners can switch to "open"; omitted mode in new writes is rejected rather than silently changing a restriction.
  • Changes apply to newly created workspaces. Running and held/paused sandboxes keep their original policy until replaced. No live settings are changed by a software upgrade alone except the default used when no policy was ever saved.
Example of opting into enforcement, refusing backends that cannot honor it:
To restore unrestricted networking, set mode to "open"; retained allowlist and fallback settings are dormant. Open egress permits sending data and granted credentials to arbitrary reachable destinations; it does not grant credentials.

Run ledger retention

Owner-only. How long model_calls keeps the exact request and response bodies it records per model call — the largest and most conversation-laden bytes in the install (risks.md#r8 (repository)). Everything else on the row (status, requested and serving model, prompt snapshot reference, reply text, finish reason, token usage, latency, timestamps) is never pruned, so run history, cost rollups and traces stay readable after the bodies are gone.
  • GET /api/settings/retention returns {config: {model_call_body_days}, max_days}. model_call_body_days is 0 (default: keep indefinitely) or 1–3650; max_days is the ceiling the server enforces.
  • PUT /api/settings/retention accepts {config: {model_call_body_days}} and returns the same view. Out-of-range values are refused with invalid_retention and leave the saved window unchanged. There is nothing to reload: the maintenance pass reads the window on its next tick (every minute) and removes bodies of terminal calls older than it, oldest first, in batches of 500 — up to 20 batches per tick per replica, and replicas take disjoint batches — so a backlog drains within minutes and sustained pruning keeps pace with ingestion. A call still running is never touched, and a window the pass cannot read or that is out of range prunes nothing. The window must be supplied explicitly: {} or {"config":{}} is refused rather than read as zero.
A pruned call reports bodies_pruned: true on the trace (request_body is "" and response_body is null), so “removed under policy” is distinguishable from “this call never produced a response”.

Coding-provider credentials and sandbox delivery

Native GitHub and GitLab connections expose workspace_delivery with kind, enabled, revision, and source. Store an agent’s own PAT through its credential connection action (github_pat or gitlab_pat), then separately use workspace_delivery with that same target and input {"enabled":true,"revision":<current revision>}. A stale revision returns 409. The connection must belong to the addressed agent; credential kinds cannot be swapped across provider connections. Saving a PAT alone never enables delivery. See GitLab setup and supported scope (repository) and sandbox credential delivery (repository).

Retired integrations

GET /api/agents/{slug}/tools includes retired_integrations. An entry reports an obsolete integration’s id, display name, replacement_id, reason, saved tool_names, has_saved_config, and has_saved_credential. These are cleanup metadata, never active tools or transferable credentials. DELETE /api/agents/{slug}/retired-integrations/mesh.linear explicitly removes that agent’s old native Linear grants, configuration, and native credential in one transaction. It returns 204, including on retry. It leaves MCP connections and historical runs intact and requires the same agent access as other Tools writes. Unknown retirement IDs return 404. Native Linear credential saves, configuration saves, and enable requests return 410 integration_retired. The old credential’s GET/DELETE endpoints remain available for inspection and deletion. Agent exports record old grants and configuration under tools.retired instead of active tool settings. Its has_saved_credential flag also identifies credential-only obsolete setups; credential values and hints remain excluded.

First-conversation progress

GET /api/agents/{slug}/readiness accepts the same agent-scoped sessions and API tokens as the agent detail route. It returns one entry per saved connector: id, kind, status, verified_at, error_code, first_inbound_at, and first_response_at. It supports every serving communication connector. Only timestamps and status are exposed; no conversation text or participant identifiers. A saved credential is not delivery proof. first_inbound_at follows authenticated durable queue acceptance (or graph persistence on the direct path), even when a usage allowance pauses intake. first_response_at follows full transport acceptance of a normal reply. Readiness writes are advisory and may lag; absence means unverified, not proof of failure. Viewing progress never sends a test message.

Prompt snapshot retention

The retention.model_call_body_days window also bounds assembled prompt bodies. Expired snapshots retain their ID, revision, digest and timestamps. The snapshot body endpoint returns body_pruned: true with an empty prompt after cleanup. Active runs, running model calls, and recently completed uses protect the shared snapshot. Reusing identical prompt bytes refreshes the retention window and can make those bytes available again through the same digest. This is content retention, not erasure of messages, memories, persona revisions or authorization evidence; those records and usage metadata remain. Zero keeps bodies indefinitely.

Per-agent usage and safety allowances

GET /api/agents/{slug}/usage?from=YYYY-MM-DD&to=YYYY-MM-DD&cursor=... is available to operators granted access to that agent, including API tokens. Dates default to today UTC. It uses the existing usage source taxonomy and pagination, with an agent-bound cursor. The response carries models, execution, next, and allowance (daily_cost_micros, resumed_until, paused). An empty next ends pagination. Native token counts and provider-reported cost are raw measurements, not a bill. calls - calls_with_cost is unknown cost; zero reported cost is different from missing cost. Sandbox time is reported separately. PUT /api/agents/{slug}/usage/allowance accepts {"daily_cost_micros":1000000} (integer micros of provider-reported USD, positive through 1,000,000,000,000), or an explicit null to remove the allowance. Saving clears any resume override. POST /api/agents/{slug}/usage/resume bypasses the allowance until midnight UTC. These writes use the same agent authorization as other per-agent settings. The optional allowance pauses new dispatch across communication connectors, queued runs, schedules, commitments, and dreaming. Queued work is retained; task enablement, authorization and ownership are not changed. In-flight work and its recovery may finish, so concurrent work can exceed the allowance. This is not a strict provider spending cap or a replacement for a managed key budget. Today’s missing cost measurements pause new work rather than count as zero. Allowances reset at UTC midnight. Reporting and allowance policy share the same accounting view; there is no second cost ledger, pricing policy, or billing system.