> ## Documentation Index
> Fetch the complete documentation index at: https://docs.formation.texturehq.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Operator API

> Administer agents and installation settings through authenticated API endpoints.

Web search provider configuration, instance credentials, and agent overrides are
documented in [Web capabilities (repository)](https://github.com/TextureHQ/formation/blob/main/docs/runtime/web-capabilities.md#operator-api).

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](https://docs.formation.texturehq.com/agent-provisioning.md). For
feature behavior and limits, use these guides before calling their APIs:

| Feature | Guide |
| - | - |
| Models and routing | [Model providers](https://docs.formation.texturehq.com/connectors/model.md), [automatic selection](https://docs.formation.texturehq.com/runtime/adaptive-model-routing.md) |
| Profile and picture | [Identity API](#profile-and-picture-generation), [automatic Slack setup](https://docs.formation.texturehq.com/slack-automatic-setup.md) |
| Tools and accounts | [Connection API](https://docs.formation.texturehq.com/runtime/connection-management.md), [MCP](https://docs.formation.texturehq.com/runtime/remote-mcp.md), [tool discovery](https://docs.formation.texturehq.com/runtime/tool-discovery.md) |
| Memory maintenance | [Dreaming](https://docs.formation.texturehq.com/runtime/dreaming.md) |
| Background work | [Commitments](https://docs.formation.texturehq.com/runtime/commitments.md), [schedules](https://docs.formation.texturehq.com/runtime/schedules.md) |
| Secrets | [Agent vault](https://docs.formation.texturehq.com/runtime/agent-vault.md), [workspace credentials](https://docs.formation.texturehq.com/runtime/workspace-credentials.md) |
| Investigation | [Observability](https://docs.formation.texturehq.com/runtime/observability.md) |

## 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:

```
Authorization: Bearer mesh_<43 base64url characters>
```

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](#management-contract-v1) (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

| Method | Path | Body | Returns |
| - | - | - | - |
| `GET` | `/api/settings/api-tokens` | | `{tokens: [{id, name, prefix, scope, created_by_email, created_at, last_used_at, revoked_at}]}` — never the secret |
| `POST` | `/api/settings/api-tokens` | `{name, scope?}` (scope defaults to `admin`) | `201` the row plus `token`, once |
| `DELETE` | `/api/settings/api-tokens/{id}` | | `200` the revoked row, naming its creator; `404` if it was not live |

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)](https://github.com/TextureHQ/formation/blob/main/docs/runtime/temporal-api.md). 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.

| Method | Path | Returns |
| - | - | - |
| `GET` | `/api/agents/{slug}/schedules` | `{schedules, latest, health, worker_available}`; `latest` maps schedule IDs to the last occurrence outcome |
| `GET` | `/api/agents/{slug}/schedules/{id}` | `{schedule, occurrences, events}` with the most recent 100 entries of each history |
| `POST` | `/api/agents/{slug}/schedules/{id}/actions` | Updated schedule; body `{action, version, request_id?, request?}` |
| `GET` | `/api/schedules/health` | Owner-only `{healthy, heartbeat_at, due_count}`; `503` when unhealthy |

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)](https://github.com/TextureHQ/formation/blob/main/docs/runtime/schedules.md) 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](https://docs.formation.texturehq.com/agent-provisioning.md). 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.

```sh theme={null}
export MESH=https://mesh.example.com
export TOKEN=mesh_…

# 1. The agent.
curl -sS -X POST $MESH/api/agents \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"slug":"scout","display_name":"Scout"}'

# 2. Its persona (system-prompt text), then make that revision active.
curl -sS -X POST $MESH/api/agents/scout/persona \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"body":"You are Scout, the on-call helper for the platform team. …"}'

# 3. Optional agent-specific model provider and billing key.
#    Use configured instance defaults instead when desired (see below).
curl -sS -X POST $MESH/api/agents/scout/model-provider \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"provider":"openrouter","api_key":"sk-or-…"}'

# 4. Memory, in bulk (below).
# 5. A connector: Slack manifest + credentials, or Telegram. Save the Slack
#    connector BEFORE pointing the Slack app's Event Subscriptions at Mesh:
#    the ingress looks the connector up by (api_app_id, team_id) before it
#    verifies a signature, so Slack's URL challenge gets a 401 until this row
#    exists. The body needs app_id, bot_token and signing_secret.
curl -sS $MESH/api/agents/scout/connectors/slack/manifest -H "Authorization: Bearer $TOKEN"
curl -sS -X POST $MESH/api/agents/scout/connectors/slack \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"app_id":"A0…","bot_token":"xoxb-…","signing_secret":"…"}'

# 5b. Alternatively, list and connect a number on the agent's own account.
curl -sS -X POST $MESH/api/agents/scout/connectors/phone/numbers \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"provider":"twilio","account_id":"AC…","api_secret":"…"}'
curl -sS -X POST $MESH/api/agents/scout/connectors/phone \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"provider":"twilio","account_id":"AC…","api_secret":"…","number_id":"PN…","allowed_senders":["+15557654321"]}'

# 6. Tools: capture the agent's own credential for a package, then enable.
curl -sS -X POST $MESH/api/agents/scout/tool-secrets/github_pat \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"value":"ghp_…"}'
curl -sS -X POST $MESH/api/agents/scout/tools/checkout_github_repo/enable -H "Authorization: Bearer $TOKEN"
curl -sS -X PUT $MESH/api/agents/scout/tool-config/mesh.github \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"values":{"default_repository":"TextureHQ/mesh"}}'
```

Model billing keys may use [instance defaults](#instance-model-defaults). Acting
accounts for connectors and tools belong to the agent; see [tool identity and
permissions](https://docs.formation.texturehq.com/runtime/tools.md#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}`:

| Method | Path | Request / result |
| - | - | - |
| `GET` | `/identity/models?provider=…` | `{supported, models: [{id, name, context_length}], recommended_model}` from the provider's current image catalog. Omitted `provider` uses the agent's provider. Unsupported image generation returns `supported: false`, an empty list and an empty recommendation. |
| `POST` | `/identity/generate` | `{kind: "persona", purpose, provider?}` returns `{persona}` as an editable draft. It does not save or activate a persona revision. Save the reviewed text through `/persona`. |
| `GET` | `/avatar/state` | `{source, revision, auto_generate}` with `Cache-Control: no-store`; see provenance and revision semantics below. |
| `GET` | `/avatar` | The stored PNG, or a deterministic default when no picture is stored. `?download=1` adds an attachment filename. |
| `PUT` | `/avatar` | Raw PNG/JPEG bytes, at most 8 MB; normalization strips metadata and bounds the stored PNG. Returns `{saved: true, avatar: {source, revision, auto_generate}}` with source `uploaded`. |
| `POST` | `/identity/generate` | `{kind: "avatar", profile, provider?, model?, automatic?, avatar_revision?}` returns `{saved: true, avatar: {source, revision, auto_generate}}` with source `generated`. |

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](https://docs.formation.texturehq.com/slack-automatic-setup.md) 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.

```json theme={null}
{
  "audience": { "kind": "public" },
  "skip_duplicates": true,
  "dry_run": false,
  "entries": [
    {
      "kind": "fact",
      "body": "The deploy window is Friday afternoon.",
      "source": {
        "system": "openclaw",
        "ref": "memory/2026-08-01.md#3",
        "created_at": "2026-08-01T15:04:05Z"
      }
    },
    { "kind": "preference", "body": "Prefers terse replies." }
  ]
}
```

* `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:

```json theme={null}
{
  "batch_id": "6f1c…",
  "audience": { "kind": "public" },
  "imported": 1,
  "skipped": 1,
  "entries": [
    { "index": 0, "status": "imported", "id": "9a3e…" },
    { "index": 1, "status": "skipped_duplicate" }
  ]
}
```

**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:

```json theme={null}
{
  "format": "mesh.agent-export/v1",
  "exported_at": "2026-09-10T18:00:00Z",
  "agent": { "slug", "display_name", "disabled", "model_provider", "model_primary", "model_fast", "model_heartbeat", "budget_max_iterations", "budget_max_wall_seconds" },
  "persona": { "revision", "body", "created_at" },
  "memory": [ { "id", "kind", "body", "visibility", "source_type", "current_revision", "created_at", "updated_at", "import_source": { "system", "ref", "original_created_at", "batch_id", "imported_at" } } ],
  "tools": { "enabled": ["checkout_github_repo"], "config": { "mesh.github": { "default_repository": "TextureHQ/mesh" } } },
  "connectors": [ { "type", "external_app_id", "external_workspace_id", "status" } ]
}
```

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.

| Source concept | OpenClaw | Hermes | Formation call |
| - | - | - | - |
| Who the agent is | `SOUL.md`, `IDENTITY.md` | the profile's system prompt | `POST /api/agents` then `POST /api/agents/{slug}/persona` with the text |
| Long-term memory | `MEMORY.md`, daily `memory/*.md` notes | memory entries with categories | `POST /api/agents/{slug}/memory/import`; one entry per note or bullet, `kind` from the section or category (`fact`, `preference`, `instruction`, …), `ref` = file path plus item index |
| Model | provider config | profile model | Use [instance model defaults](#instance-model-defaults), or configure `POST /api/agents/{slug}/model-provider` with an optional agent-specific billing key |
| Skills / tools | `SKILL.md` folders, plugins | skills, MCP servers | Not portable as code; enable the Formation tool that covers the capability, or set up an agent-owned [remote MCP connection](https://docs.formation.texturehq.com/runtime/remote-mcp.md). |
| Chat surface | channel config | messaging integration | `POST /api/agents/{slug}/connectors/slack`, `/telegram`, or `/phone` (see `GET /api/connector-catalog` for what each needs) |

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)](https://github.com/TextureHQ/formation/blob/main/skills/migrate-agent-to-mesh/SKILL.md)
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`):

| Role | Reaches |
| - | - |
| `owner` | the install: every agent, every route under `/api/settings` (the roster, install settings, Slack provisioning), agent creation, install-level catalogs, plus everyone's API tokens |
| `agent_operator` | the agents they hold a grant for, and only those: agent-scoped routes for a granted slug, except owner-only identity writes and model-routing profile writes; their own API tokens under `/api/settings/api-tokens` (the one `/api/settings` path that takes any operator); `/api/me` |

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](https://docs.formation.texturehq.com/external-authentication.md#roles)) 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.

```sh theme={null}
# Every session of yours; `current` is the one this cookie holds. The two
# clocks come back as seconds so a client need not hard-code them.
curl -sS $MESH/api/me/sessions -H "Authorization: Bearer $TOKEN"
# → {"sessions":[{"id":"…","created_at":"…","last_seen_at":"…","expires_at":"…","current":false}],
#    "idle_timeout_seconds":86400,"max_age_seconds":604800}

curl -sS -X DELETE $MESH/api/me/sessions/{id} -H "Authorization: Bearer $TOKEN"   # 204; your own only, else 404
curl -sS -X POST $MESH/api/me/sessions/revoke-others -H "Authorization: Bearer $TOKEN"  # → {"revoked":n}; the current cookie survives; a token caller has none and ends all

# Owner: sign another operator out everywhere. They keep their password.
curl -sS -X POST $MESH/api/settings/operators/{id}/sessions/revoke -H "Authorization: Bearer $TOKEN"
```

**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.

```sh theme={null}
# Owner. Idempotent: a second stop keeps the first stop's time, name and reason.
curl -sS -X POST $MESH/api/settings/stop -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"reason":"scout is posting garbage in #support"}'
# → {"stopped":true,"stopped_at":"…","stopped_by_email":"operator@example.com","reason":"…"}
curl -sS -X DELETE $MESH/api/settings/stop -H "Authorization: Bearer $TOKEN"   # → {"stopped":false}
# The state is on GET /api/settings/security as install_stop, and on GET /api/me
# as install_stopped_at while stopped, so every page can say so.
```

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.

```sh theme={null}
curl -sS $MESH/api/settings/install -H "Authorization: Bearer $TOKEN"
# → {"build":{"commit":"…","commit_time":"…","modified":false,"go_version":"go1.25","module":"github.com/TextureHQ/mesh"},
#    "environment":"production","public_url":"https://mesh.example.com","auth_mode":"local",
#    "hostname":"mesh-7f9c","started_at":"…",
#    "database":{"applied":51,"available":51,"latest":"0051","known":true,"state":"current","missing":[],"unexpected":[]},
#    "secrets":{"master_key_present":true,"fingerprint":"…","verifiable":true},
#    "trusted_proxies":["10.0.0.0/8"]}
# build.commit is empty for a binary built without a VCS stamp (go run,
# -buildvcs=false, a container stage without .git); a dirty checkout is still
# stamped, with build.modified true. database.state compares the ledger with
# the embedded set as SETS: current (identical), behind (the binary embeds
# migrations the ledger lacks — listed in `missing`; auto-migrate off or a
# failed boot migration), ahead (the ledger holds versions this binary does
# not embed — listed in `unexpected`; a rollback the forward-only runner cannot
# follow), diverged (both), or unknown (the ledger could not be read; known
# is false).

# `mesh verify-secrets` from the API: open every stored credential with the
# running key and report per kind. Changes nothing. 409 master_key_missing
# when the process holds no key; 404 not_available where it is not wired.
curl -sS -X POST $MESH/api/settings/install/verify-secrets -H "Authorization: Bearer $TOKEN"
# → {"total":12,"ok":12,"failed":0,"key":"…","descriptor":"…","descriptor_matches":true,
#    "entries":[{"owner_type":"agent_connector","kind":"slack_bot_token","ok":3,"failed":0,"legacy":0}]}

curl -sS $MESH/api/settings/security -H "Authorization: Bearer $TOKEN"
# → {"auth_mode":"local",
#    "sessions":{"max_age_seconds":604800,"idle_timeout_seconds":86400,"cookie_secure":true,"active_count":3},
#    "sign_in_throttle":{"window_seconds":900,"failures_per_account":10,"failures_per_source":30,"trusted_proxies":[]},
#    "passwords":{"min_length":8,"max_length":72},
#    "operators":{"owners":2,"agent_operators":1,"disabled":0,"open_invites":1},
#    "active_api_tokens":4}

# Owner: end every session on the install except the caller's own cookie
# session. A token caller keeps none and ends all. API tokens are untouched —
# a token is a program and is revoked one by one, on purpose.
curl -sS -X POST $MESH/api/settings/security/sign-everyone-out -H "Authorization: Bearer $TOKEN"
# → {"revoked":n}
```

## 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.

```sh theme={null}
# Owner. Newest first; before= pages older with the cursor a previous page
# returned (opaque; pass it back verbatim); actor= narrows to one operator
# id; limit= 1..200, default 50.
curl -sS "$MESH/api/settings/activity?limit=50" -H "Authorization: Bearer $TOKEN"
# → {"events":[{"id":812,"at":"…",
#      "action":"POST /api/agents/{slug}/disable",   # the route pattern, or auth.*
#      "outcome":"ok","status":200,                  # ok <400, refused 4xx, failed 5xx
#      "actor":{"id":"…","email":"operator@example.com","via":"session"},
#      "source_ip":"203.0.113.7",                    # what the login throttle keys on (MESH_TRUSTED_PROXIES applies)
#      "target":{"slug":"scout"},                    # the route's path parameters; invite tokens are dropped
#      "details":{}}],                               # what the handler added: invited email and role, token name and scope, agent slug, refusal reason
#    "next_cursor":"ODEx"}                          # null on the last page
curl -sS "$MESH/api/settings/activity?before=ODEx&actor=<user id>" -H "Authorization: Bearer $TOKEN"
```

`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`.

```sh theme={null}
# The roster: status is active | invited | disabled; `you` marks the caller.
curl -sS $MESH/api/settings/operators -H "Authorization: Bearer $TOKEN"

# Invite by email, with a role. For an agent_operator, the agents to grant.
# The response carries the link ONCE; Mesh stores only a hash.
curl -sS -X POST $MESH/api/settings/operators/invites \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"email":"peer@example.com","role":"agent_operator","agents":["scout"]}'
# → 201 {"operator":{…,"role":"agent_operator","agents":["scout"],"status":"invited"},"invite_url":"https://…/invite/<token>","expires_at":"…"}

# Change what someone is. Absent fields are unchanged; `agents` replaces the
# whole set; promoting to owner clears grants.
curl -sS -X PATCH $MESH/api/settings/operators/{id} \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"role":"agent_operator","agents":["scout","sentry"]}'

# A new link for an existing operator: replaces any open link, and for an
# active operator this is the password reset.
curl -sS -X POST $MESH/api/settings/operators/{id}/invites -H "Authorization: Bearer $TOKEN"

# Disable: ends every session, revokes every API token they created, retires
# any open link, keeps the row (so "who did this" stays answerable). Enable
# clears the flag; the person signs in again with the password they had.
curl -sS -X POST $MESH/api/settings/operators/{id}/disable -H "Authorization: Bearer $TOKEN"
curl -sS -X POST $MESH/api/settings/operators/{id}/enable  -H "Authorization: Bearer $TOKEN"
```

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](#profile-and-picture-generation) 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](https://docs.formation.texturehq.com/runtime/dreaming.md) for privacy and
cadence semantics. Scheduled dreaming starts disabled.

| Method | Agent-relative path | Request / result |
| - | - | - |
| GET | `/dreaming` | Settings, the 20 most recent cycles with their counts (scopes, messages engaged/observed, changes by category, lessons, pending, tokens, stop reason), latest jobs plus the jobs referenced by up to 100 review proposals, source snapshots and outcomes, verifier results, last check result and timestamp. Private administrator data; `Cache-Control: no-store`. |
| GET | `/dreaming/cycles/{cycleID}` | One cycle's report: the cycle, every job (snapshot and `outcome`: messages read, engaged/observed, relevance counts, `considered` notes, `retrospective`) and every proposal with its before-state. |
| PUT | `/dreaming/settings` | Full configuration, shown below. Invalidates in-flight jobs. |
| POST | `/dreaming/runs` | `{ "mode": "dry_run" }`, `{ "mode": "preview" }` or `{ "mode": "automatic" }`; opens a cycle over every eligible scope, still subject to quiet period and budgets. `dry_run` writes nothing and advances no checkpoint; its proposals have status `dry_run` and cannot be approved. A new request supersedes a running cycle. Allowed while scheduled dreaming is disabled. Returns 202 with `{ "status": "queued", "cycle": {…} }`. |
| POST | `/dreaming/proposals/{proposalID}/actions` | `{ "action": "approve" \| "reject" \| "undo", "approve_sharing": false }`. A changed shared revision requires `approve_sharing: true` for the displayed exact audiences. |
| POST | `/dreaming/public-sources` | `{ "url": "https://…", "body": "Exact public document text", "confirm_public": true }`; attests that exact body, without fetching the URL or exposing any requesting DM. Body maximum 12,000 bytes. |
| PUT | `/memory/{memoryID}/lifecycle` | `{ "keep_details": true }` protects detail from automatic compaction; `false` removes that protection. |

Paths are prefixed with `/api/agents/{slug}`. Default configuration:

```json theme={null}
{
  "enabled": false,
  "mode": "automatic",
  "learning_seconds": 14400,
  "quiet_seconds": 3600,
  "lifecycle_seconds": 86400,
  "grace_seconds": 604800,
  "daily_tokens": 262144,
  "daily_changes": 50
}
```

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](#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](#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)](https://github.com/TextureHQ/formation/blob/main/docs/runtime/actor-profiles.md).

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](https://docs.formation.texturehq.com/connectors/slack.md)
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`.

| Method | Path | Behavior |
| - | - | - |
| GET | `/api/agents/{slug}/commitments?status=open` | Latest 100 records (`status=active` for the non-terminal set, which the dashboard shows by default; omit for every status), stored `enabled` and `retract_idle` settings, installation `health`, `worker_available`, `intake_policy`, `intake_health`, `pending` — the work enabling would resume (below) — `unanswered` (`count`, `oldest_seconds`): this agent's messages that started a turn, or whose admitted work failed before a turn started, and got no reply within ten minutes, and `capacity` (`open`, `limit`, `exhausted`): open tasks against the per-agent limit of 100. At the limit a new request is answered but not recorded as a task, the agent tells the person so, and nothing is recorded until open tasks finish, are closed, or are cleared. Omit status for all states. A disabled agent cannot execute commitments, but operators can still clear its stored enabled setting before re-enabling it. |
| PUT | `/api/agents/{slug}/commitments/settings` | Set `{"enabled":true}` or `false`. Enabling requires the process run worker; disabling preserves history. The off-to-on transition also turns on requester retraction and incoming-task capture (`enforce`); an explicit `retract_idle` in the same request sets retraction only (capture still comes on), and a later write to either sub-setting is respected. The switch and those defaults are written in one transaction, so a failed request leaves commitments off and a retry performs the whole transition. Optional `"existing":"resume"` (default) or `"clear"` says what to do with pending work; `clear` runs the sweep below before the setting is written and adds `cleared` to the response. Optional `reason` is recorded on each cleared row. |
| POST | `/api/agents/{slug}/commitments/clear` | Clear pending work: cancel every non-terminal commitment, withdraw every unsent notice, discard every unanswered proposal, in one attributed sweep. Body optional: `{"reason":"…"}` (at most 2,000 bytes). Returns `{"cancelled","notices_dropped","proposals_discarded"}`. Works while commitments are off. |
| GET | `/api/agents/{slug}/commitments/{id}` | `commitment`, latest 200 attributed `events`, linked `references` (provider, tenant, kind, object\_id, url, relation), criteria/verification/wait state in `completion`, up to 250 origin `effects` with hash, tool, arguments, call ID, `completed` (a response is recorded), and `mutation_pending` (the operation still reserves a target), and `disclosure` (`allowed`, and when false a `reason` naming the kinds and counts of recorded sources that can no longer be reused in the task's conversation — the same sentence the reconciler writes on the row when it stops the task). Unresolved target reservations are listed first. |
| POST | `/api/agents/{slug}/commitments/{id}/actions` | Versioned operator action; see below. |
| POST | `/api/agents/{slug}/commitments/cleanup` | Cancel or close unfinished work for a selected batch of up to 100 versioned commitments; independent per-item results, described below. |
| GET | `/api/agents/{slug}/commitments/intake` | Incoming-task policy and intake health, independently of reconciler health. |
| PUT | `/api/agents/{slug}/commitments/intake` | Set `mode` (`off`, `shadow`, `enforce`), `decision_threshold` (0.5–1), and an attributed `policy_version`. Non-off modes require the run worker; capture runs only while commitments are enabled. |
| GET | `/api/commitments/health` | `enabled`, `healthy`, nullable `heartbeat_at`, `due_count`, `overdue_seconds`, `unanswered`, and `oldest_unanswered_seconds`; 503 when unhealthy: a stale or backlogged reconciler, or any agent leaving a message without a reply (whether or not commitments are on). Requires an owner; an owner's read-scoped token is sufficient. |

`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)](https://github.com/TextureHQ/formation/blob/main/docs/runtime/commitments.md#clearing-pending-work).

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:

```json theme={null}
{"action":"cancel","reason":"This work is no longer needed","items":[{"id":"00000000-0000-4000-8000-000000000001","version":7}]}
```

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:

```json theme={null}
{"version":1,"action":"cancel","reason":"No longer needed"}
```

| Action | Meaning |
| - | - |
| `cancel` | Cancel future work and pending notification; retain history. |
| `acknowledge` | Human attests completion of an `operator_ack` contract only. Rejects machine-evidence contracts. |
| `retry` | Resume already-authorized work after its active/origin run ends, preserving autonomy, deadline, and lifetime budgets. |
| `authorize` | Grant bounded work to a commitment that has none — a legacy parked row — with attributed reason; same inactive-run and budget checks as retry. Newly registered objectives already carry it. |
| `grant_allowance` | For a nonterminal criteria task, grant explicit new total `max_attempts`, `max_iterations`, and/or `deadline` after work stops. Usage is never reset; bounds and uncertain effects still apply. |
| `close_incomplete` | Explicitly close a nonterminal task with its attributed reason. This is not verified completion. |
| `record_effect_result` | Record an externally verified operation's result. Also requires `effect_hash` (64 characters) and nonempty `result` (at most 16,000 bytes). The attempt must be inactive. An operation with a recorded response can still require confirmation when `mutation_pending` is true. Its original receipt is preserved and its exact target reservation is released without resending the write. Resolving the last uncertain operation can wake an eligible criteria task; independent waits and existing allowance remain authoritative. |

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:

```json theme={null}
{
  "kind":"tool_result",
  "tool":"example_query_tool",
  "arguments":{"id":"issue-123"},
  "json_source_pointer":"/text/0",
  "pointer":"/state/name",
  "equals":"Done"
}
```

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)](https://github.com/TextureHQ/formation/blob/main/docs/runtime/commitments.md#reconciler-watchdog).

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.

| Method | Path | Behavior |
| - | - | - |
| GET | `/api/agents/{slug}/commitment-sources` | Saved `plans` and independent `source_health` counts (`enabled`, `unknown`, `partial`, `errors`, `overdue`). |
| POST | `/api/agents/{slug}/commitment-sources` | Save `{config: ...}` as a disabled plan. Configuration includes approved identity/list/read queries, verified principal and tenant, resource mapping, pagination limits, delivery route, and explicit assignment policy. |
| GET | `/api/agents/{slug}/commitment-sources/{id}` | Inspect the current plan, version, coverage, error category, and observation timestamps. |
| PUT | `/api/agents/{slug}/commitment-sources/{id}` | Save `{version, config}`; changes disable the plan and require a new preview. |
| POST | `/api/agents/{slug}/commitment-sources/{id}/preview` | Perform bounded approved reads and return a durably saved preview. Creates no commitments. Partial/failed coverage cannot authorize rollout. |
| POST | `/api/agents/{slug}/commitment-sources/{id}/settings` | Set `{version, preview_id, enabled}`. Enabling requires a fresh complete preview of that exact version; disabling does not require one. |
| POST | `/api/agents/{slug}/commitment-sources/{id}/refresh` | Mark the plan due for the existing observer. Does not start a task execution or a new conversation. |

See [the implementation and rollout runbook (repository)](https://github.com/TextureHQ/formation/blob/main/docs/runtime/commitment-completion.md)
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:

```json theme={null}
{
  "provider": "openrouter",
  "primary": "anthropic/claude-sonnet-4.5",
  "fast": "",
  "heartbeat": "",
  "api_key": "YOUR_PROVIDER_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](https://docs.formation.texturehq.com/runtime/adaptive-model-routing.md).

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:

```http theme={null}
POST /api/agents/{slug}/model-provider/inherit
Content-Type: application/json

{"provider":null}
```

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.

| Method | Path | Returns |
| - | - | - |
| `GET` | `/api/management/capabilities` | `{"contract":"mesh.management/v1","mesh_version":"<build commit or unknown>","capabilities":[…],"capability_status":{…}}` |
| `GET` | `/api/management/readiness` | redacted per-agent and install readiness (below) |
| `GET` | `/api/management/runtimes/{backend}` | one execution backend: `{backend, configured, source, editable, config, token_present, token_hint, revision}`; `ETag` is the revision |
| `PUT` | `/api/management/runtimes/{backend}` | `{status:"saved", runtime, reset_agents}` |
| `POST` | `/api/management/runtimes/{backend}/test` | the connection test, `{status:"connected", backend}` or `422 runtime_test_failed` |
| `POST` | `/api/agents/{slug}/runtime-backend` | `{backend}` pins an agent to an enabled backend (empty clears) |
| `GET` | `/api/management/slack-provisioning` | the Slack provisioning grant's redacted status (below) |
| `PUT` | `/api/management/slack-provisioning` | `{refresh_token}` connects or replaces the grant; returns the status |
| `DELETE` | `/api/management/slack-provisioning` | disconnects the grant; returns the status |
| `GET`/`PUT` | `/api/agents/{slug}/model-routing` | one agent's automatic model selection ([below](#automatic-model-routing)) |
| `GET` | `/api/settings/model-routing` | the installation routing document (read only for this bearer) |
| `GET` | `/api/management/usage` | raw usage per day or per call: counts, native token counts, provider-reported cost, sandbox seconds ([below](#usage-reporting)) |

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`:

```json theme={null}
"capability_status":{"slack.delegated_setup":{"ready":false,"reason":"provisioning_not_configured"}}
```

`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`.

```sh theme={null}
curl -sS $MESH/api/management/runtimes/modal -H "Authorization: Bearer $TOKEN"
# → {"backend":"modal","configured":false,"source":"none","editable":true,"config":{},
#    "token_present":false,"token_hint":"","revision":"unset"}
curl -sS -X PUT $MESH/api/management/runtimes/modal -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"environment":"tenant-a","image":null,"token":"ak-…:as-…","if_match":"unset"}'
curl -sS -X POST $MESH/api/management/runtimes/modal/test -H "Authorization: Bearer $TOKEN"
```

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.

```json theme={null}
{"generated_at":"…",
 "agents":[{"slug":"scout","display_name":"Scout","enabled":true,
   "model_provider":{"configured":true,"source":"agent|instance|none"},
   "runtime":{"backend":"modal","configured":true},
   "connectors":[{"kind":"slack","status":"connected","verified_at":"…","error_code":null,
     "first_inbound_at":"…","first_response_at":"…"}]}],
 "runtimes":{"modal":{"configured":true,"source":"environment|database|none",
   "last_test_ok":true,"last_test_at":"…"}},
 "models":{"configured":true,"provider":"openrouter"}}
```

`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`):

```json theme={null}
{"contract":"mesh.management/v1","granularity":"day","from":"2026-10-11","to":"2026-10-17","time_zone":"UTC","token_basis":"native","generated_at":"…",
 "models":[{"date":"2026-10-16","agent":"ops-bot","source":"turn","provider":"openrouter","upstream_provider":"anthropic","model":"anthropic/claude-sonnet-5.5","credential_source":"instance","managed":true,"outcome":"ok",
            "calls":412,"calls_with_cost":412,"prompt_tokens":1803422,"completion_tokens":92110,"total_tokens":1895532,"cached_prompt_tokens":120000,"cache_write_tokens":0,"reasoning_tokens":0,"cost_micros":7812300}],
 "execution":[{"date":"2026-10-16","agent":"ops-bot","backend":"broker","size":"standard","sandboxes":9,"seconds":5130,"seconds_unconfirmed":0}],
 "truncated":false,"next":null}
```

* `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`:

```json theme={null}
"calls":[{"completed_at":"…","agent":"ops-bot","source":"turn","provider":"openrouter","upstream_provider":"anthropic","requested_model":"anthropic/claude-sonnet-5.5","serving_model":"anthropic/claude-sonnet-5.5","provider_request_id":"gen-…","credential_source":"instance","managed":true,"outcome":"ok",
          "prompt_tokens":4210,"completion_tokens":188,"total_tokens":4398,"cached_prompt_tokens":4000,"cache_write_tokens":0,"reasoning_tokens":0,"cost_micros":18960,"cost_reported":true}],
"sandboxes":[{"external_ref":"ws_…","agent":"ops-bot","backend":"broker","size":"standard","created_at":"…","ready_at":"…","terminated_at":"…","released_at":"…","termination_reason":"released","termination_confirmed":true,"seconds":612}]
```

`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.

```sh theme={null}
curl -sS "$MESH/api/management/usage?from=2026-10-11&to=2026-10-17&time_zone=UTC" \
  -H "Authorization: Bearer $TOKEN"
curl -sS "$MESH/api/management/usage?from=2026-10-16&to=2026-10-16&granularity=call" \
  -H "Authorization: Bearer $TOKEN"
```

### Slack app provisioning

Automatic and delegated Slack setup need the install's Slack App Configuration
grant (see [Automatic Slack setup](https://docs.formation.texturehq.com/slack-automatic-setup.md)). 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:

```json theme={null}
{"configured":true,"ready":true,"health":"ok","problem":null,
 "workspace_id":"T0123","workspace_name":"Acme","last_rotated_at":"…","expires_at":"…",
 "last_refresh_error":null}
```

* `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.

| Method | Route | Contract |
| - | - | - |
| GET | `/api/agents/{slug}/model-routing` | `{policy, families, available, shadow_available, live_preferences, profile_revision, reason?}` for the effective serving connection. |
| PUT | `/api/agents/{slug}/model-routing` | Save `{enabled, shadow, family, preference, revision}`; returns the policy. `enabled` and `shadow` are mutually exclusive. Stale revisions return `409 model_routing_changed`. Turning a policy on needs a ready connection whose catalog supports it (`409 model_routing_unqualified` otherwise); turning it off never does. |
| GET | `/api/agents/{slug}/model-routing/profile` | Read the saved profile. |
| PUT | `/api/agents/{slug}/model-routing/profile` | Owner-only reviewed-profile publication; validates concrete model IDs against the serving catalog and appends a revision. Not needed for normal catalog-based setup. |
| GET | `/api/agents/{slug}/model-routes?before={run_id}` | `{routes, next_before}`, with up to 50 route snapshots. |
| GET | `/api/settings/model-routing` | `{"stopped":false,"new_agents":{"enabled":true,"preference":"balanced"}}`. |
| PUT | `/api/settings/model-routing` | Owner-only. `{stopped?, new_agents?: {enabled?, preference?}}`, at least one; omitted fields keep their values, so `{"stopped":true}` still only stops classification. Returns the document. `stopped` stops new classification while preserving eligible fallback and previously captured runs; `new_agents` is the policy seeded into agents created afterwards. |

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](https://docs.formation.texturehq.com/runtime/adaptive-model-routing.md) 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)](https://github.com/TextureHQ/formation/blob/main/docs/runtime/decision-models.md) 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](https://docs.formation.texturehq.com/runtime/agent-vault.md) 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`.

### Drop links

`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](https://docs.formation.texturehq.com/secrets-and-recovery.md).

## 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`.

### Sketch links

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:

```json theme={null}
{"config":{"mode":"allowlist","allowlist":["api.example.com"],"uncontrolled":"refuse"}}
```

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)](https://github.com/TextureHQ/formation/blob/main/docs/roadmap/risks.md#r8)). 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)](https://github.com/TextureHQ/formation/blob/main/docs/runtime/gitlab-coding.md) and
[sandbox credential delivery (repository)](https://github.com/TextureHQ/formation/blob/main/docs/runtime/workspace-credentials.md).

### 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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.