diff --git a/changelog.mdx b/changelog.mdx
index eb1d10a..3bbc56d 100644
--- a/changelog.mdx
+++ b/changelog.mdx
@@ -7,6 +7,27 @@ description: "Stay up to date with the latest agentsfleet product updates, new f
agentsfleet is in **stealth-mode testing** and pre-production. APIs and agent behavior may change between releases without long deprecation windows. Email [agentsfleet@agentmail.to](mailto:agentsfleet@agentmail.to) if you want a hand calibrating an agent or to join as a design partner.
+
+ ## `--provider` is checked against the model catalogue your server serves
+
+ A provider credential could name any string, and the failure surfaced later as a fleet that could not reach any model. `agentsfleet secret create` and `secret update` now check `--provider` against `GET /v1/models`, the same catalogue the dashboard's model picker reads. The client carries no provider list of its own, so a provider becomes available by being added to the catalogue rather than by upgrading the client.
+
+ ## Breaking changes
+
+ - **A provider your catalogue does not price is refused.** The message names what your server does price and points at the custom-endpoint form for anything it does not. Free-form `--data` bodies stay unvalidated.
+ - **The typed form requires `--provider`.** `--api-key` with `--model` and no provider used to store a credential whose provider was the empty string — a provider key that could never dial.
+ - **`claude-cli`, `codex-cli`, `gemini-cli`, `openai-codex`, and `claude-code` are refused by name.** Each names a local coding tool that carries no API key. The refusal says so and does not offer the custom-endpoint form — these tools have no endpoint to point at.
+
+ ## What's new
+
+ - **`agentsfleet models` lists the catalogue** — provider, model id, context window, and per-million-token rates, with `--provider ` scoping it to one provider. Rates are charged under platform-managed posture only; a self-managed credential is billed by your own provider account, so an unpriced row shows a dash.
+ - **`--model` is checked too**, against that provider's models only. It used to be validated nowhere, so a typo stored a credential that failed at the first event.
+ - **Provider ids match without case.** `--provider Anthropic` stores `anthropic`, the catalogue's spelling and what the runtime compares against. Model ids are matched exactly — they belong to the provider, and several are case-sensitive.
+ - **An empty or unreachable catalogue accepts the value.** A newly deployed server has no catalogue until an administrator primes it, and an outage is not a reason to refuse a credential. The server validates either way.
+ - **The credential store reads the caller's environment.** `AGENTSFLEET_STATE_DIR` handed to the process now reaches every read and write, not only the ones that consulted the process environment directly.
+ - **An unreadable `credentials.json` warns instead of impersonating logged-out.** A permission or disk error prints `warning: could not read credentials.json` with the cause, then continues as logged out. A missing file stays silent, as before.
+
+
## Sign-in events record the caller's address, not the proxy's
@@ -31,27 +52,27 @@ description: "Stay up to date with the latest agentsfleet product updates, new f
## Breaking changes
- **`agentsfleet login --token` is gone.** The flag is refused as an unknown option rather than ignored. Piped input goes with it: a login with no terminal attached and no `AGENTSFLEET_API_KEY` fails and names that variable. Unattended callers set `AGENTSFLEET_API_KEY` to a tenant API key, which authenticates every request and writes nothing to disk.
- - **`agentsfleet logout` no longer ends your dashboard session.** It revokes this computer's credential, aborts any unfinished sign-in, and clears local state. A browser session is a different credential class that refreshes through the identity provider, so signing out of a terminal leaves the dashboard you are reading signed in. A revoke that cannot reach the server still clears local state, and warns that the credential stays live until you revoke it from the dashboard.
+ - **`agentsfleet logout` no longer ends your dashboard session.** It revokes this computer's credential, aborts any unfinished sign-in, and clears local state. A browser session is a different credential class refreshed through the identity provider, so signing out of a terminal leaves the dashboard signed in. A revoke that cannot reach the server still clears local state, warning that the credential stays live until you revoke it from the dashboard.
- **A deployment that sets `OIDC_PROVIDER=custom` is refused at boot.** The value selected a second ladder that read capability and workspace claims from `custom_claims`, `app_metadata`, and namespaced keys, none of which anything writes. `OIDC_PROVIDER=clerk` is unchanged, so no deployment that runs today needs editing.
## What's new
- **One live credential per computer.** A second login from the same computer revokes what it left behind, and `core.cli_credentials` carries a partial unique index, so two live credentials for one person and computer cannot be recorded at all. Only a hash is stored, so the credential cannot be read back from anywhere. `agentsfleet logout` is how a computer ends its own.
- - **A credential records the server that issued it.** Later commands reach that server with no flag and no environment variable. The order is `--api`, then `AGENTSFLEET_API_URL`, then the recorded server, then the built-in URL. A saved credential that records no server, dialed at a server nobody named, is refused before anything is sent, with error code `DEPLOYMENT_UNKNOWN` and exit code `1`. The `logout` and `doctor` commands still run, because one ends a credential and the other explains the refusal.
+ - **A credential records the server that issued it.** Later commands reach that server with no flag and no environment variable, resolving `--api`, then `AGENTSFLEET_API_URL`, then the recorded server, then the built-in URL. A saved credential recording no server, dialed at a server nobody named, is refused before anything is sent with `DEPLOYMENT_UNKNOWN` and exit code `1`. `logout` and `doctor` still run — one ends a credential, the other explains the refusal.
- **Three error codes name what went wrong.** `UZ-AUTH-023` says the credential was revoked, `UZ-AUTH-024` says no live credential of yours has that identifier, and `UZ-AUTH-025` says login recovered a session but could not exchange it, so nothing was saved. A `401` or `403` from the client now names the API URL the credential was presented to.
## Security
- **Minting a credential takes a browser sign-in.** A credential still authenticates listing and revoking, so a terminal can end its own access without opening a browser, but it cannot mint a replacement. Minting used to accept an existing credential, which turned one stolen credential into an unbounded supply under machine names of the caller's choosing.
- **A stored value is checked against its whole shape.** The load path and the mint response both require `afc_` followed by exactly 64 lower-case hexadecimal characters. A prefix check passed values carrying trailing bytes.
- - **A tenant API key carries its creator's capabilities.** An `agt_t` key resolves its capability set from the identity provider, keyed on the `created_by` subject the key already stored, instead of a nine-entry set compiled into the binary that no operator could narrow. Narrowing that person narrows every key they minted, on the next request past the cache window. A creator the provider no longer knows resolves to no capability, and an unreachable provider past the staleness ceiling reports unavailable rather than an empty grant.
+ - **A tenant API key carries its creator's capabilities.** An `agt_t` key resolves its capability set from the identity provider, keyed on the `created_by` subject it already stored, replacing a nine-entry set compiled into the binary that no operator could narrow. Narrowing that person narrows every key they minted, on the next request past the cache window. A creator the provider no longer knows resolves to no capability; an unreachable provider past the staleness ceiling reports unavailable rather than an empty grant.
- **Each claim is read from exactly one key.** The capability claim is `scopes` and nothing else. The reader used to try OAuth2's `scope` first, so a token carrying both would have supplied a capability set we never granted, on the authorisation path. The `scp` spelling and the `workspaceId` alias are unread.
## Metered runs are priced from the model catalogue
- A promotional window sat in front of every price. It was a per-tenant timestamp that was nullable with no default, nothing in the product ever wrote it, and an empty value read as "the window is still open" — so every account held it empty and every metered stage priced to zero for the whole life of the account. The window is deleted rather than repaired: pricing resolves from the model catalogue and the execution posture alone, and no rate resolver takes a clock, so a price cannot drift with wall time. Free usage is the starter grant and the balance that drains from it, bounded by `exhausted_at` — the mechanism the product already describes, and now the only one.
+ A promotional window sat in front of every price: a per-tenant timestamp, nullable with no default, that nothing ever wrote, where empty read as "still open". Every account held it empty, so every metered stage priced to zero for the life of the account. The window is deleted rather than repaired — pricing resolves from the model catalogue and the execution posture alone, and no rate resolver takes a clock, so a price cannot drift with wall time. Free usage is the starter grant and the balance draining from it, bounded by `exhausted_at`.
## Breaking changes
@@ -60,16 +81,16 @@ description: "Stay up to date with the latest agentsfleet product updates, new f
## Bug fixes
- **A metered platform stage is charged the catalogue rate.** Run time and all three token tiers — input, cached, and output — accrue against the credit pool at the rates the catalogue carries, instead of resolving to zero. A `self_managed` stage is unchanged: the run fee only, with token counts recorded and not charged.
- - **A model the catalogue does not price is refused rather than run for nothing.** The window answered ahead of the catalogue lookup, so a provider-and-model pair with no rate row priced at zero instead of failing. Renewal and settlement now fail closed on an unpriced pair; the lease estimate still admits the run, so an unrated model surfaces as a billing error rather than a silent free run.
+ - **A model the catalogue does not price is refused rather than run for nothing.** The window answered ahead of the catalogue lookup, so a provider-and-model pair with no rate row priced at zero instead of failing. Renewal and settlement now fail closed on an unpriced pair; the lease estimate still admits the run, so it surfaces as a billing error rather than a silent free run.
- **The budget gates can refuse a fleet.** A fleet budget is spent against real rates, so `credit_deducted_nanos` accrues from the first metered slice. While every charge was zero no budget was ever consumed and neither gate could stop anyone.
- **The free trial is named early access.** The pricing card reads "Early access", and the answer to "What am I actually paying for?" no longer describes an ending — it used to say metering began "after the trial". Free usage is the starter grant, which ends when the balance does rather than on a date. The rates themselves are unchanged.
- - **Deleting a fleet erases its memory.** Rows in `memory.memory_entries` are removed with the fleet they belong to. They used to survive it, and because every sweep is scoped by a fleet the caller can enumerate, memory whose fleet was already gone was unreachable by all of them — an erased account kept it indefinitely. The isolation boundary is unchanged: the memory role still holds no grant on `core` and cannot name `core.fleets` at all.
+ - **Deleting a fleet erases its memory.** Rows in `memory.memory_entries` go with the fleet they belong to. They used to survive it, and because every sweep is scoped by an enumerable fleet, memory whose fleet was already gone was unreachable by all of them — an erased account kept it indefinitely. The isolation boundary is unchanged: the memory role holds no grant on `core` and cannot name `core.fleets`.
## A fleet with write access always parks for a human
- A fleet whose repository binding declares write access now parks every event at an approval card before anything runs — even when its configuration carries no gates block, and even when its gate rules match nothing. Gate rules are editable with the same permission that wakes the fleet, so they cannot hold this boundary; the write kind itself does. The card states the write blast radius (the repository and what one approval spends), and the evidence line is sealed so crafted backticks in model output cannot break out of its code span.
+ A fleet whose repository binding declares write access now parks every event at an approval card before anything runs — even with no gates block, and even when its gate rules match nothing. Gate rules are editable with the same permission that wakes the fleet, so they cannot hold this boundary; the write kind itself does. The card states the write blast radius (the repository and what one approval spends), and the evidence line is sealed so crafted backticks in model output cannot break out of its code span.
## What's new
@@ -82,7 +103,7 @@ description: "Stay up to date with the latest agentsfleet product updates, new f
## A runner you assign now runs the work
- A host could show **Active** and **Online**, take an isolation assignment, and still fail every job handed to it. Two faults stacked: the daemon never enabled the resource controllers systemd had delegated to it, and the sandboxed child had no `HOME` from which to resolve its configuration — so each lease died at startup with `UZ-EXEC-012` and the journal named a code and no cause. Controllers are now enabled when the daemon starts rather than when its first assignment arrives, `agentsfleet-runner.service` supplies the `HOME` the child needs, and a host that cannot enforce limits is refused before it is given a runner at all.
+ A host could show **Active** and **Online**, take an isolation assignment, and still fail every job. Two faults stacked: the daemon never enabled the resource controllers systemd had delegated to it, and the sandboxed child had no `HOME` from which to resolve its configuration — so each lease died at startup with `UZ-EXEC-012`, and the journal named a code and no cause. Controllers are now enabled at daemon start rather than on first assignment, `agentsfleet-runner.service` supplies the `HOME`, and a host that cannot enforce limits is refused before it is given a runner.
## Bug fixes
@@ -111,11 +132,11 @@ description: "Stay up to date with the latest agentsfleet product updates, new f
## Every runner lease says whose workspace it belongs to
- A runner's lease list showed every workspace's work in one undifferentiated table, so an operator looking at a shared host could not tell their own fleet's runs from anyone else's. Each lease now names its workspace, and one click narrows the table to that workspace alone. The same release stops the runner pages re-reading a host's whole history on every load, and starts pruning that history so it cannot grow without bound.
+ A runner's lease list showed every workspace's work in one table, so an operator on a shared host could not tell their own fleet's runs from anyone else's. Each lease now names its workspace, and one click narrows the table to it. The same release stops the runner pages re-reading a host's whole history on every load, and starts pruning that history so it cannot grow without bound.
## Upgrading
- - **Runner lease history is pruned 30 days after settlement.** A background sweep deletes a settled lease once 30 days pass from the moment it settled — not from when it started — along with the two per-lease activity records that describe it. A lease still running or renewing is never touched, and the runner's lifecycle activity (the Activity feed) is kept at any age, so a long-lived host never shows an empty feed. A lease whose runner died without reporting, on a fleet that is never used again, is marked expired once it passes the same window and then keeps its own window like any settled lease. The lifetime counts on the runner page are unaffected — they count transitions as they happen, not surviving rows — but if you read raw lease history older than 30 days through `GET /v1/fleets/runners/{id}/leases`, export it before upgrading.
+ - **Runner lease history is pruned 30 days after settlement.** A sweep deletes a settled lease 30 days from when it settled — not from when it started — with the two per-lease activity records describing it. A lease still running or renewing is untouched, and the runner's lifecycle activity (the Activity feed) is kept at any age, so a long-lived host never shows an empty feed. A lease whose runner died without reporting is marked expired at the same window, then keeps its own. Lifetime counts on the runner page are unaffected — they count transitions, not surviving rows — but export raw lease history older than 30 days from `GET /v1/fleets/runners/{id}/leases` before upgrading.
- **Four migrations apply on startup.** They add a per-runner counter table, an index for the activity feed's filtered reads, the delete grants the sweep needs, and two indexes that keep the sweep itself off whole-table scans. The counter table backfills itself from existing history inside the migration, so no step is manual and no existing row is rewritten.
## What's new
@@ -140,14 +161,14 @@ description: "Stay up to date with the latest agentsfleet product updates, new f
## The isolation you assign is the isolation the runner applies
- The sandbox tier picked in **Add Runner** used to be a label: the host read a different value from its own environment file, neither side compared the two, and a host that could not deliver its claimed isolation kept accepting work. Policy now flows one way — the dashboard assigns it, the runner row stores it, and every heartbeat delivers it to the host — while the host probes what its kernel can actually enforce and reports that back. A runner whose assignment exceeds its capability is marked **degraded**, receives no work on either side, and its row names the exact missing mechanism (for example `cgroup controllers not delegated`) instead of showing a green badge over a host that refuses every job.
+ The sandbox tier picked in **Add Runner** used to be a label: the host read a different value from its own environment file, neither side compared the two, and a host that could not deliver its claimed isolation kept accepting work. Policy now flows one way — the dashboard assigns it, the runner row stores it, every heartbeat delivers it — while the host probes what its kernel can enforce and reports that back. A runner whose assignment exceeds its capability is marked **degraded**, receives no work, and its row names the missing mechanism (for example `cgroup controllers not delegated`) instead of showing a green badge over a host that refuses every job.
## Upgrading
- **`POST /v1/runners` takes the assignment envelope** — the body is `{host_id, assigned_policy: {sandbox_tier, network_policy, registry_allowlist[], worker_count}, labels[]}`; the old top-level `sandbox_tier` answers `400` `UZ-REQ-001`. The response echoes the assignment as stored (`worker_count` clamped into `[1, 64]`).
- **The runner environment collapses to the bootstrap pair** — `AGENTSFLEET_API_URL` and `AGENTSFLEET_RUNNER_TOKEN`, plus the optional host-local `RUNNER_STORAGE_HOME` (renamed from `RUNNER_WORKSPACE_BASE`). `RUNNER_HOST_ID`, `RUNNER_SANDBOX_TIER`, `RUNNER_NETWORK_POLICY`, `RUNNER_REGISTRY_ALLOWLIST`, `RUNNER_WORKER_COUNT`, and the `RUNNER_CP_*_MS` deadlines are removed, not deprecated — the daemon no longer reads them, so a stale env file cannot fork the truth again.
- **`macos_seatbelt` leaves the tier vocabulary** — it never had enforcement code, and a tier that cannot be applied must not be assignable. A stray stored value parses fail-closed and the runner refuses to lease. The assignable tiers are `landlock_full`, `container_nested`, and `dev_none` (dev builds only).
- - **Existing runners read degraded once repaired** — pre-upgrade rows carry no assignment. A live, upgraded host marks its own row `degraded — no assigned policy` on its first heartbeat; a host that is down, or still running the pre-upgrade daemon, keeps a healthy-looking row until the manual statement below runs. Either way the fix is the same: open the runner and use **Edit policy**; the host applies it on its next heartbeat, no visit needed.
+ - **Existing runners read degraded once repaired** — pre-upgrade rows carry no assignment. A live, upgraded host marks its own row `degraded — no assigned policy` on its first heartbeat; a host that is down, or still on the pre-upgrade daemon, keeps a healthy-looking row until the manual statement below runs. The fix is the same either way: open the runner, use **Edit policy**, and the host applies it on its next heartbeat.
- **One migration applies on startup, one statement runs by hand** — the migration adds the policy, capability, and verdict columns to `fleet.runners` and rewrites no rows. Right after deploying, mark the pre-upgrade rows degraded yourself (idempotent; it only matches rows without an assignment): `UPDATE fleet.runners SET degraded = TRUE, degraded_reason = 'no assigned policy' WHERE network_policy IS NULL;`
## What's new
@@ -183,7 +204,7 @@ description: "Stay up to date with the latest agentsfleet product updates, new f
## A runner has a page of its own, and every list pages by cursor
- A runner used to be a table row with a dialog of raw events behind an icon, and the only number on that dialog counted both halves of every execution — so a host that had run 4,000 events reported about 8,000 of something. Each runner is now an addressable page that opens on its leases: what it is working on right now, and for anything that failed, the reason in plain English with a link into the fleet whose work it was. Separately, no list in the platform pages by number any more. Page numbers silently repeat and skip rows whenever something is inserted mid-traversal, which on a host acquiring leases continuously is every few seconds.
+ A runner used to be a table row with a dialog of raw events behind an icon, and that dialog's only number counted both halves of every execution — a host that had run 4,000 events reported about 8,000 of something. Each runner is now an addressable page opening on its leases: what it is working on now, and for anything that failed, the reason in plain English linked to the fleet whose work it was. Separately, no list pages by number any more — page numbers repeat and skip rows whenever something is inserted mid-traversal, which on a host acquiring leases continuously is every few seconds.
## Upgrading
@@ -225,7 +246,7 @@ description: "Stay up to date with the latest agentsfleet product updates, new f
## Replacing a secret means sending the secret you want stored
- A stored secret can never be read back, so changing one field by field could not be checked by the caller — and on most secret shapes the old field patch silently did nothing at all. Replacement is now total on every surface: the API takes the whole body, the client gets `agentsfleet secret update`, and the dashboard's Edit dialog is the same form as Add, prefilled.
+ A stored secret can never be read back, so a field-by-field change could not be checked by the caller — and on most secret shapes the old field patch silently did nothing. Replacement is now total everywhere: the API takes the whole body, the client gets `agentsfleet secret update`, and the dashboard's Edit dialog is the Add form, prefilled.
## Upgrading
@@ -255,12 +276,12 @@ description: "Stay up to date with the latest agentsfleet product updates, new f
## The dashboard loads the page you asked for, and a failed read says so
- The Fleet library gallery and the Models registry used to read everything they held before painting, then render a failed read as an empty one — a workspace whose library was merely unreachable was told it had none, with no retry and no way to tell the two apart. Both now load one page at a time, keep what is already on screen when a read fails, and state what they have not loaded. Creating a workspace secret has also stopped overwriting a secret that already holds the name.
+ The Fleet library gallery and the Models registry used to read everything they held before painting, then render a failed read as an empty one — a workspace whose library was merely unreachable was told it had none, with no retry and no way to tell the two apart. Both now load one page at a time, keep what is on screen when a read fails, and state what they have not loaded. Creating a workspace secret has also stopped overwriting a secret that already holds the name.
## Upgrading
- **Creating a secret no longer overwrites one that exists** — `POST /v1/workspaces/{workspace_id}/secrets` answers `409` `UZ-VAULT-005` when the name is already held, and writes nothing. Rotate with `PATCH /v1/workspaces/{workspace_id}/secrets/{secret_name}` instead. The database decides, so two concurrent creates on one name resolve to one `201` and one `409`. Reconnecting a provider and refreshing its token are unaffected — those are rotations, not creations.
- - **`agentsfleet secret create --force` is gone** — it relied on the endpoint upserting, which it no longer does, so the flag could only have failed. Passing it now exits non-zero before anything is sent. Creating a name that already exists reports `skipped` and exits `0`. Rotate in place with `PATCH /v1/workspaces/{workspace_id}/secrets/{secret_name}`, which holds the name for the whole call. The client has no rotate command yet, and deleting then creating through it leaves the name absent between the two calls — any fleet requiring it fails during that gap.
+ - **`agentsfleet secret create --force` is gone** — it relied on the endpoint upserting, so it could only have failed. Passing it exits non-zero before anything is sent; creating an existing name reports `skipped` and exits `0`. Rotate in place with `PATCH /v1/workspaces/{workspace_id}/secrets/{secret_name}`, which holds the name for the whole call. The client has no rotate command yet, and delete-then-create leaves the name absent between the two calls — any fleet requiring it fails in that gap.
- **`?q=` is gone from `GET /v1/models` and `GET /v1/workspaces/{workspace_id}/fleet-libraries`** — the substring filter published on Jul 27, 2026 had no caller on any plane. A request that still sends it is answered as though it were absent, so you get a page rather than an error. Page with `next_cursor` and filter client-side.
- **`support_files` has left every API response** — the admin catalog was the last surface carrying it. The manifest is still written and stored on import, and the content-addressed bundle tar remains the authoritative file list, so no install behaviour changes.
@@ -926,208 +947,14 @@ description: "Stay up to date with the latest agentsfleet product updates, new f
- **`UZ-CONN-004`** (404) — the `{provider}` matches no provider on this deployment; the body names it.
-
- ## Bring your own Fleet templates
-
- Onboard your own Fleet templates alongside the curated platform catalogue. Templates are stored once, content-addressed, and shared across the fleets you spin up from them.
-
- ## Upgrading
-
- - **Install is template-only.** `agentsfleet install --from ` has been removed. Install by id instead: `agentsfleet install --template `. To run a fleet you authored, onboard its `SKILL.md`/`TRIGGER.md` as a template first. To push local edits onto an already-installed fleet, use `agentsfleet fleet update --from ` (unchanged). Upgrade the CLI and server together.
- - **Fleet-create takes a template id, not a bundle.** `POST /v1/workspaces/{workspace_id}/fleets` accepts exactly one of `{platform_template_id}` or `{tenant_template_id}`, plus an optional `name`. Raw `source_markdown`, the per-workspace `bundle_id`, and the github-import-at-create body are rejected.
- - **Bundle import/snapshot endpoints removed.** `POST /v1/workspaces/{workspace_id}/fleets/bundles/snapshots` and the snapshot-detail read no longer exist.
-
- ## What's new
-
- - **Two tiers** — onboard a template into the global platform catalogue or into your own workspace; workspace templates are visible only to your workspace.
- - **One gallery, both tiers** — the install gallery unions the platform catalogue with your workspace's own templates.
- - **Templates explain their credentials** — entries carry a description and per-credential "why this is needed" copy.
-
- ## API reference
-
- - **`GET /v1/workspaces/{workspace_id}/fleet-templates`** — the workspace gallery. Each entry carries `id`, `name`, `description`, `visibility` (`"platform"` or `"tenant"`), `source_ref`, `requirements`, `required_credentials_reasons`, and support-file summaries — never an object-store key.
- - **`POST /v1/admin/fleet-templates`** (scope `platform-template:write`) and **`POST /v1/workspaces/{workspace_id}/fleet-templates`** (scope `template:write` + workspace ownership) — onboard from a GitHub source. The canonical bundle is written to object storage keyed by content hash; the response is metadata only.
- - **`UZ-BUNDLE-002`** now reads "No installable template or stored snapshot matches the request in this workspace."
-
- ## CLI
-
- - **`agentsfleet install --template `** resolves the template in your gallery and installs it by tier. `--from ` has been removed from `install`.
- - **`agentsfleet fleet update --from `** is unchanged.
-
-
-
- ## Authorization is now scope-based
-
- Every capability a token holds is an explicit `resource:action` scope read off the token, replacing the old `user`/`operator`/`admin` roles and the `platform_admin` flag. A correctly-configured principal sees no change.
-
- ## API reference
-
- - **`scopes` claim** — the session token and tenant API key carry a space-delimited `scopes` list (e.g. `fleet:admin credential:write workspace:admin`), set automatically for a workspace owner at signup.
- - **`403 UZ-AUTH-022` "Insufficient scope"** — a denial names the required scope in the detail (`Requires scope fleet:admin`).
- - **`read < write < admin` hierarchy** — per resource, `admin` satisfies `write` satisfies `read`.
-
- See the [error-codes reference](/api-reference/error-codes) for `UZ-AUTH-022`.
-
-
-
- ## Template installs explain why a credential is needed
-
- A missing required credential now tells you *why* — "this fleet needs github to review your pull requests" — instead of a bare "connect github".
-
- ## What's new
-
- - **Purpose-driven connect step** — the install gate reads a per-credential reason from the template; templates without reason copy fall back to the generic prompt.
- - **No skippable installs** — the dashboard no longer offers a way past a missing credential.
- - **Fleets empty state is the gallery** — the first-run page shows the full-width template gallery.
- - **Custom secrets copy** — values are encrypted and write-only once saved: replaceable, never viewable.
-
- ## API reference
-
- ```
- GET /v1/fleets/bundles each template row now carries required_credentials_reasons
- ```
-
- - **`required_credentials_reasons`** is an optional object keyed by credential name — `{ "github": "review your pull requests" }` — carrying display-only copy. It does **not** replace `required_credentials`: install validation still reads that array.
- - **Installing with a missing credential** still returns `UZ-BUNDLE-003` (424) with a `missing_credentials` list.
-
- ## Bug fixes
-
- - **Billing empty state** reads "No charges yet" instead of "No billable events yet".
- - **Dashboard loading states** keep each route's real title while data loads.
-
- ## CLI
-
- - No command or flag changed. `agentsfleet templates --json` passes `required_credentials_reasons` straight through.
-
-
-
- ## Models and Keys is one page, and credentials say what they are
-
- Model and key management is a single **Models & Keys** page. The standalone Credentials page is gone — `/credentials` redirects here.
-
- ## What's new
-
- - **One Models & Keys page** — the active model shows as a hero (`model · via · LIVE`); switching sets that credential's saved model with no key re-entry.
- - **Credentials are classified server-side** — each row carries its `kind` plus non-secret provider, model, and base URL. The API key is never returned.
- - **Replace a key in place** — rotating a provider key updates only the secret and keeps model, provider, and endpoint.
- - **Custom secrets keep their own section** — `NAME=value` secrets a SKILL reads by name stay listed apart from model-provider keys.
-
- ## API reference
-
- ```
- GET /v1/workspaces/{ws}/credentials each row carries kind + non-secret provider/model/base_url; never api_key
- PATCH /v1/workspaces/{ws}/credentials/{name} body {api_key} — rotate the key only, preserving provider/model/base_url
- ```
-
- - **`kind` is derived server-side** from the stored provider field — `provider_key`, `custom_endpoint`, or `custom_secret` — never from the user-chosen name. An unreadable legacy body lists as a custom secret and the call still returns 200.
- - **Rotate errors** — a missing credential returns `UZ-VAULT-003` (404); an empty key returns `UZ-REQ-001` (400).
-
-
-
- ## The sandboxed agent runner is hardened for general availability
-
- ## Upgrading
-
- - **Egress now fails closed by default.** A runner with `RUNNER_NETWORK_POLICY` unset no longer shares the host network. To keep the previous open-by-default posture, set `RUNNER_NETWORK_POLICY=allow_all` explicitly; typos and unrecognized values also fail closed.
-
- ## What's new
-
- - **Complete secret redaction on the live tail** — the model API key and every custom secret field become their `${secrets.NAME.FIELD}` placeholder before any progress frame leaves the run, even when a secret is split across two streaming chunks. On a memory failure the frame is dropped, never sent raw.
- - **Server-Side Request Forgery (SSRF) protection for tenant hosts** — a tool dialing a tenant-configured host pins the resolved address and rejects private, loopback, and link-local targets, including the cloud metadata endpoint.
- - **Bounded retries and call deadlines** — runner backoff is capped with jitter, and a call whose watchdog cannot start fails fast.
- - **Proven kernel enforcement** — seccomp syscall traps, Landlock filesystem denial, and cgroup process and memory limits are verified against a real kernel.
-
-
-
- ## Build the model catalogue and set the priced default from the dashboard
-
- Platform operators build the priced catalogue and choose the one active default model and key in the dashboard — no SQL seed, no redeploy.
-
- ## What's new
-
- - **Model catalogue** — `/admin/models` lists every priced model and adds one through a dialog (provider, model id, context cap, and per-million-token input / cached-input / output rates). Edits and deletes repopulate the in-process rate cache with no restart.
- - **Platform default** — a Platform Default card activates one model, pairing it with the key's source workspace and an optional OpenAI-compatible base URL. Exactly one default is active at a time.
- - **Catalogue-curated onboarding** — the catalogue ships empty and admins populate it.
-
- ## API reference
-
- Every route requires the `platform_admin` claim; a per-tenant admin role returns `UZ-AUTH-021`.
-
- ```
- GET /v1/admin/models list the catalogue (each row carries its uid)
- POST /v1/admin/models add a priced row
- PATCH /v1/admin/models/{uid} update a row's caps/rates
- DELETE /v1/admin/models/{uid} remove a row
- PUT /v1/admin/platform-keys set the one active default
- ```
-
- Rows are keyed by `uid` in the URL, not `provider/model_id` — a model id can contain `/`. A create body:
-
- ```json
- {
- "provider": "fireworks",
- "model_id": "accounts/fireworks/models/kimi-k2.6",
- "context_cap_tokens": 256000,
- "input_nanos_per_mtok": 950000000,
- "cached_input_nanos_per_mtok": 100000000,
- "output_nanos_per_mtok": 4000000000
- }
- ```
+
+ ## Earlier entries
- `PUT /v1/admin/platform-keys` takes `{"provider", "source_workspace_id", "model", "base_url"?}`. The context cap is read from the catalogue row, never the body, and the default's `(provider, model)` must already be a priced row (rejected with `UZ-PROVIDER-004`). New codes: `UZ-PROVIDER-006` (no catalogue row for that uid), `UZ-PROVIDER-007` (the model is the active default — repoint before deleting), `UZ-PROVIDER-008` (duplicate `(provider, model_id)`).
-
-
-
- ## One terminal-native dashboard, and any OpenAI-compatible endpoint
-
- The dashboard reads as one product: one tab style, one content width, a description under each page title. Own-key model setup also drops its named-provider limit.
-
- ## What's new
-
- - **Billing** — a balance card with a full-width consumption meter and a terminal-style ledger (`date · amount · type · description`). The per-seat grid is replaced by a single "Pay as you go" row.
- - **Models and Credentials are two destinations** — `Models` (`/settings/models`) and `Credentials` (`/credentials`).
- - **A credentials vault** — `/credentials` groups model-provider keys, custom `NAME=value` secrets, and integrations. Provider keys stay write-only and masked (Replace, never reveal).
- - **Custom — OpenAI-compatible model setup** — add a credential with a base URL and an optional key, then point own-key setup at it.
- - **One install flow** — Dashboard and Fleets share one install screen, taking a template, an `owner/repo` GitHub source, or pasted `SKILL.md`.
-
- ## API reference
-
- A self-managed credential may carry an OpenAI-compatible endpoint in its stored JSON. The `PUT /v1/tenants/me/provider` body is unchanged — the URL lives in the referenced credential:
-
- ```json
- {
- "provider": "openai-compatible",
- "api_key": "",
- "model": "",
- "base_url": "https://host/v1"
- }
- ```
-
- - **`base_url` is validated before any run** — it must be `https` and resolve to a public host. A loopback, private, link-local, or cloud-metadata target is rejected with `UZ-PROVIDER-005` and never dialed. A `base_url` set on a named provider is rejected too.
-
- ## Bug fixes
-
- - **Custom endpoints now activate** — self-managed endpoints bill against your own provider, so they no longer require a catalogued model.
- - **Account-modal email is readable in dark mode.**
- - **Outbound model calls pin the validated address** — the non-streaming provider dial connects to the address checked at validation time instead of re-resolving, closing a DNS-rebinding gap.
-
- ## CLI
-
- - **`agentsfleet credential add`** accepts `--provider openai-compatible --base-url --model [--api-key ]` — the key is optional, the model required, and a non-`https` `--base-url` is rejected at parse time with no network call. **`agentsfleet tenant provider add --credential `** then activates it. Tool secrets keep the existing `credential add --data=@-` form.
-
-
-
- ## Metrics export to Grafana Cloud
-
- `agentsfleetd` pushes metrics to Grafana Cloud, completing the OpenTelemetry triad next to traces and logs. Billing is untouched: the wallet, charge ledger, and per-event breakdowns stay in Postgres, and a metric is emitted only after its charge commits.
-
- ## What's new
-
- - **Three series** — credit drained (by posture and model), token throughput (by direction: input, cached, output), and a run-latency histogram, plus a `samples_dropped` counter reporting the exporter's own health.
- - **One credential triple** — `GRAFANA_OTLP_ENDPOINT`, `GRAFANA_OTLP_INSTANCE_ID`, and `GRAFANA_OTLP_API_KEY` enable traces, logs, and metrics together. Unset, the exporter stays off and says so in one startup log line.
- - **Collector requirement** — metrics use OpenTelemetry Protocol (OTLP) delta temporality; run an OpenTelemetry Collector with the `deltatocumulative` processor in front of Grafana Cloud Mimir.
-
- ## Bug fixes
+ Entries before July 1, 2026 covered the pre-release period when the API,
+ the CLI, and the data model were still changing shape weekly. They described
+ behaviour that later entries have superseded, so they are no longer a useful
+ guide to the product and have been removed from this page.
- - **Traces and logs** — span names, attributes, and log bodies were emitted with a stray extra quote that produced invalid JSON, so Tempo and Loki could reject them.
+ The full record is in version control:
+ [`changelog.mdx` history](https://github.com/agentsfleet/docs/commits/main/changelog.mdx).
diff --git a/cli/agentsfleet.mdx b/cli/agentsfleet.mdx
index 9d0908d..e915242 100644
--- a/cli/agentsfleet.mdx
+++ b/cli/agentsfleet.mdx
@@ -111,7 +111,8 @@ Commands:
| Command | Effect |
|---|---|
| `agentsfleet secret create ` | Stores a JSON object in the workspace vault under a name that is free. A name already in use is reported as skipped, and nothing is written. |
-| `agentsfleet secret update ` | Replaces a stored secret without releasing its name. It accepts the same data options as `agentsfleet secret create`. |
+| `agentsfleet secret create --provider --api-key --model ` | Stores a model-provider credential. `--provider` and `--model` are both checked against the model catalogue your server serves — run `agentsfleet models` to list it. `--provider openai-compatible` adds a required `https` `--base-url`, makes `--api-key` optional, and accepts any `--model`. |
+| `agentsfleet secret update ` | Replaces a stored secret without releasing its name. It accepts the same data and provider options as `agentsfleet secret create`. |
| `agentsfleet secret show ` | Confirms that a secret exists without showing its value. |
| `agentsfleet secret list` | Lists secret names and metadata. |
| `agentsfleet secret delete ` | Deletes a secret. Fleets using the secret fail on their next use. |
@@ -120,6 +121,21 @@ Commands:
`agentsfleet secret delete` has no dry-run option. The deleted secret value cannot be recovered.
+### Model catalogue
+
+| Command | Effect |
+|---|---|
+| `agentsfleet models` | Lists every model the server prices: provider, model id, context window, and per-million-token input and output rates. |
+| `agentsfleet models --provider ` | Lists one provider's models. |
+
+The dashboard's model picker reads the same catalogue, so both surfaces offer
+the same providers. Rates apply under platform-managed posture; a self-managed
+credential is billed by your own provider account, and its rows show a dash.
+
+An empty catalogue means an administrator has not primed this server yet. While
+it is empty, `--provider` accepts any value and the server validates the
+credential.
+
### Tenant settings
| Command | Effect |
diff --git a/docs.json b/docs.json
index 8ef1d8b..d23f2f6 100644
--- a/docs.json
+++ b/docs.json
@@ -58,6 +58,7 @@
"fleets/install",
"fleets/running",
"fleets/secrets",
+ "fleets/model-providers",
"fleets/connectors",
"fleets/webhooks",
"fleets/authoring",
diff --git a/fleets/model-providers.mdx b/fleets/model-providers.mdx
new file mode 100644
index 0000000..275f24d
--- /dev/null
+++ b/fleets/model-providers.mdx
@@ -0,0 +1,143 @@
+---
+title: Use your own model provider
+description: Store a model-provider credential and run fleets on it instead of the platform default.
+type: how-to
+audience: user
+verified: 2026-08-15
+product_version: 0.26.2
+executable: false
+---
+
+# Use your own model provider
+
+## What this does
+
+Fleets run on the platform's model by default. Your tenant can run them on a model you pay for instead. Store a provider credential in the workspace vault, then select it as the tenant provider.
+
+`--provider` is checked against the model catalogue your server serves, so a
+credential that could never work is never stored. Run `agentsfleet models` to
+see that catalogue — the dashboard's model picker reads the same one.
+
+## Before you begin
+
+Select the workspace that should own the credential. Have the provider's API
+key ready, and run `agentsfleet models` to pick a provider id and model this
+server serves:
+
+```bash
+agentsfleet models --provider anthropic
+```
+
+```text
+PROVIDER MODEL CONTEXT IN/MTOK OUT/MTOK
+anthropic claude-fable-5 1000k $10.00 $50.00
+anthropic claude-haiku-4-5 200k $1.00 $5.00
+anthropic claude-opus-4-8 1000k $5.00 $25.00
+anthropic claude-sonnet-5 1000k $3.00 $15.00
+```
+
+The provider column supplies `--provider` and the model column supplies
+`--model` in the next step.
+
+## Steps
+
+1. Store the provider credential. The provider id, API key, and model are all required.
+
+ ```bash
+ agentsfleet secret create my-openai --provider openai --api-key sk-... --model gpt-5.6-sol
+ ```
+
+ ```text
+ ✓ Secret 'my-openai' stored in vault.
+ ```
+
+ Provider ids are matched without case: `--provider Anthropic` stores
+ `anthropic`, the catalogue's own spelling. A provider the catalogue does not
+ price is refused, and the message names what it does price.
+
+ `--model` is checked the same way, against that provider's models only.
+ Model ids are matched exactly — they belong to the provider, and several are
+ case-sensitive (`MiniMaxAI/MiniMax-M3`), so a wrong-case model is refused
+ rather than silently corrected.
+
+2. Select the stored credential as the tenant provider.
+
+ ```bash
+ agentsfleet tenant provider create --secret my-openai
+ ```
+
+ ```text
+ Tenant provider added: mode=self_managed secret=my-openai
+ ```
+
+3. Confirm the active provider.
+
+ ```bash
+ agentsfleet tenant provider show
+ ```
+
+ ```text
+ FIELD VALUE
+ mode self_managed
+ provider openai
+ model gpt-5.6-sol
+ context_cap_tokens
+ secret_ref my-openai
+ ```
+
+Fleets you install or resume after this point run on your provider.
+
+## Point at your own endpoint
+
+A gateway or self-hosted server that speaks the OpenAI API takes the `openai-compatible` form. `--base-url` must be `https`; a plain `http` URL is refused before any request. The API key is optional, because some private gateways take none.
+
+```bash
+agentsfleet secret create my-gateway --provider openai-compatible \
+ --base-url https://vllm.corp.example/v1 --model qwen2.5-coder --api-key sk-...
+```
+
+```text
+✓ Secret 'my-gateway' stored in vault.
+```
+
+## Verify it works
+
+Run `agentsfleet tenant provider show` and check it names your secret. Then steer any fleet; its responses now come from your provider, and its usage lands on your provider account.
+
+## Common problems
+
+A `--provider` value your catalogue does not price is refused, and the message
+names what it does price. Run `agentsfleet models` for the full list. To use a
+provider the catalogue does not carry, point at it directly:
+`--provider openai-compatible --base-url https://host/v1 --model `.
+
+The ids `claude-cli`, `codex-cli`, `gemini-cli`, `openai-codex`, and
+`claude-code` are refused with their own message. They name local coding tools
+that carry no API key, so a stored credential could never reach them. The
+`openai-compatible` form does not help here — these tools have no endpoint.
+
+`--api-key` or `--model` without `--provider` is refused. The three flags describe one credential and travel together.
+
+A `--model` the provider does not serve is refused, and the message lists that
+provider's models. Run `agentsfleet models --provider ` for the list.
+
+`--base-url` with a named provider is refused. A named provider carries its own endpoint; the flag belongs to `openai-compatible` alone.
+
+## Remove or undo
+
+Return to the platform default:
+
+```bash
+agentsfleet tenant provider delete
+```
+
+```text
+Custom LLM provider removed — events will now run on agentsfleet's platform default.
+```
+
+The stored credential stays in the vault. Delete it with `agentsfleet secret delete my-openai` when no fleet needs it.
+
+## Related pages
+
+- [Manage secrets](/fleets/secrets)
+- [Install a fleet](/fleets/install)