# Agent manual

Everything an AI agent needs to manage providers, applications, users and usage through the API, in one page.

Manage App AI Gateway on a person's behalf with the CLI or an existing MCP
connection. This manual is also served as plain markdown at
[https://docs.appaigateway.com/agents.md](https://docs.appaigateway.com/agents.md).
Use [the documentation index](https://docs.appaigateway.com/llms.txt) to find task-specific guides and
[OpenAPI](https://docs.appaigateway.com/openapi.json) for exact HTTP schemas. Prefer the connected server's
capabilities and the installed CLI's `--help` when versions differ.

## Choose the connection before changing anything

Hosted defaults:

| Purpose | Address |
| --- | --- |
| Console, sign-in and browser handoffs | `https://console.appaigateway.com` |
| App requests and management API | `https://api.appaigateway.com` |
| MCP | `https://console.appaigateway.com/mcp` |

For self-hosting, substitute that deployment's console and API origins; they
may be the same. Do not deploy infrastructure unless the person asked for
self-hosting. `GATEWAY_URL` below means the selected API origin, with no `/v1`
suffix. Use the URLs returned by handoff operations instead of constructing them.

With MCP, start with `get_account` and read `agw://guide`; see "MCP" below.
For the CLI, install `@maxceem/agw` on Node.js 22.19 or later, then:

```sh
agw deployment status --json --no-input
agw account status --json --no-input
```

The CLI defaults to hosted only when no connection is saved. Inspect its
reported URL, deployment and account; do not assume a previous task left the
right connection active. A configured credential is not proof it is valid.

- **Existing account:** use the saved connection if it matches. Otherwise run
  `agw account login --no-open --json --no-input`. To sign in to hosted again,
  use `agw deployment connect --cloud --no-open --json --no-input`. Without a
  supplied management key, this returns a pending browser login just like
  `account login`; have the person approve it before continuing.
- **Existing self-hosted gateway:** use `agw deployment connect --url URL
  --no-open --json --no-input`.
- **New hosted account, when requested:** on a fresh hosted connection, the
  first provider, provider-gateway or app creation initializes an unclaimed
  account. Do not use this path to recover a failed login to an existing one.
  Check account status for its expiry, start `agw account claim --no-open
  --json --no-input`, and give the person the URL to keep the account.

Help, status, lists, validation and dry runs never create accounts. Claiming
requires the person to create their sign-in in their own browser; do not open
or approve the claim yourself. Claiming preserves the agent's access.

## CLI task workflow

1. Read `agw provider list --json` and `agw app list --json`. Reuse the IDs and
   slugs returned; inspect a target with `agw app show <app-id> --json`.
2. Add a missing provider with `agw provider add --type openai --browser
   --no-open --json --no-input`. Give the person the returned URL and wait
   with `agw operation wait <id> --json`. Never request the secret in chat.
3. Read the command's `--help` and make the requested change. For iOS, supply
   the person's Team ID and Bundle ID. Set `--attest-environments production`
   for a production-only app: CLI flag-based creation otherwise enables both
   production and development. For server keys, use `--key-output` with an
   unused private path, or retain the CLI's managed private output file.
4. For a new app file, use `agw app validate --file app.json --json`. For an
   existing app, use `agw app update <app-id> --file app.json --dry-run --json`
   so validation is performed as an update of that app. The file contains only
   `name`, `config` and `status` from `result.app`; exclude metadata such as
   `id`, `revision` and timestamps. Keep every existing configuration field.
5. Apply the requested update, then reread the app. File updates replace the
   whole document. The CLI reads a revision when the update command starts;
   this protects against a competing write during that command, not edits
   made since an earlier file export. Refresh and merge an old file first.
6. Run `agw app check <app-id> --json` and
   `agw app snippet <app-id> --json`. Resolve any placeholders named in
   `result.notes`. The check reads configuration only: no provider key,
   physical device, sign-in or entitlement is exercised. Do not send inference
   unless the person requested it.
7. Report the app ID, provider slugs, API base URL, key file location, checks
   and remaining work. Report pending handoffs as pending, never completed.

For every unattended command, use `--json --no-input`; for browser handoffs,
add `--no-open`. `--json` returns `{schemaVersion:1,ok:true,context,result}` or
`{schemaVersion:1,ok:false,error}`. Inspect both `ok` and the result: a successful
pending operation still needs the browser step and polling. Exit codes are
0 success, 2 invalid input, 3 remote failure, 4 state/authentication recovery
and 5 wait timeout. A timeout does not cancel the operation. Follow
`error.nextAction`, preserving retry state after a lost response.

Use `agw usage show --month YYYY-MM --json` for retained account totals,
including deleted apps. Configuration files transfer neither credentials nor
usage history between deployments.

## Signing in

- `agw account login --no-open --json` opens a login and returns
  `state: "pending"` with `url`, `userCode` and `id`. Give the person the URL and
  the pairing code. They open the URL in their own browser, check that the page
  shows the same code, and approve. Then run `agw operation wait <id> --json`;
  the CLI stores its new management key and prints only non-secret fields.
  Without `--no-open` the CLI also opens the page in the local browser when
  one is available (not over SSH, on Linux with no display, or without a
  terminal), and without `--json` it waits for the approval itself and prints
  the pairing code to stderr.
- For your own deployment, `agw deployment connect --url URL` runs the same
  login against that URL.
- A declined login fails with `operation_denied`; an expired one with
  `operation_expired`. Start a new login; do not retry the old id.
- In CI, or when the person gives you an existing management key, use the
  `AGW_MANAGEMENT_KEY` environment variable. When it is set it is the credential
  for every command, ahead of any saved login, and the environment key is not saved to disk;
  `agw account login` refuses with `environment_credential`. Alternatively pipe
  the key once with `agw account login --key-stdin` (or
  `agw deployment connect --url URL --key-stdin`) to save it. Never pass a key
  as an argument.
- `agw account logout --json` revokes the saved key and removes it locally. Its
  result is `{loggedOut: true, revoked: true|false}`; `revoked: false` means the
  key was already refused or the deployment could not be reached.
- Every key a CLI login creates appears on the console's Access page,
  tagged CLI and labelled with the machine it was issued to, for example
  `CLI on mac-studio`. The person can revoke it there at any time.

## MCP

If you are connected to the gateway as an MCP server, at the console's address
followed by `/mcp` (`https://console.appaigateway.com/mcp` when hosted), you
can work with the same account through its tools instead of the CLI. A client
connects with OAuth by itself: the person approves it on the console's consent
page, choosing the account and whether you may read or manage, or continues
without an account, which creates an unclaimed account your connection manages.
An unclaimed account expires unless a person claims it: when `get_account`
says so, call `claim_account` and give the person its URL. A management key as
a bearer token works too. Use the tools:
`get_account` first, then `list_providers`, `list_apps`, `get_app`,
`validate_app`, `check_app`, usage and event tools, and the change tools.
`add_app` and `add_app_key` take two calls: the first reserves and creates
nothing, the second, with the same arguments and the answer's `handle`,
creates it once, and repeating it is safe. An answer that names an operation
calls it `id`; that is what `get_operation` takes. A key is revealed on a page a
person opens, never in a tool result: give them `reveal_url`. If a read contains
`[redacted]` configuration, do not send that marker back in an update or drop
those fields from a full replacement; have the person preserve them through
the console or another authorized client. A provider key
or gateway token is never a tool argument: `add_provider` and the rotate tools
answer a URL where the person enters it, and `get_operation` reports when they
have finished. A refusal is a tool result marked as an error with the
gateway's code and the next step to take. The server's `agw://guide` resource
has its full rules, and the MCP server guide at `/automation/mcp` lists every
tool.

The API reference below is useful for integrations that do not use the CLI.

## What the gateway is

App AI Gateway is a proxy between a person's applications and AI providers
(OpenAI, Anthropic, Gemini, xAI and others). The person adds **provider keys**
once; they are shared by all their apps. They create **applications**; each
app has a permanent ID, an authentication policy, a proxy policy saying
which providers, paths and models it may use, optional custom endpoints,
and limits on its users. Client apps call
`{GATEWAY_URL}/v1/apps/{app}/proxy/{provider_slug}/{provider_path}` with the
provider's own request format. You manage all of this through the admin API.

## Authentication

- Use the explicitly selected gateway URL; the hosted API is
  `https://api.appaigateway.com`. `GET /v1/healthz` checks basic availability.
- Direct integrations send a management credential as `Authorization: Bearer`
  using their protected credential store. The CLI uses `AGW_MANAGEMENT_KEY` when
  it is set, and otherwise the key its active connection saved.
- All admin routes are under `/v1/admin/`. Request and response bodies are
  JSON with `Content-Type: application/json`.
- A management key has the current role of its owning identity. It does not
  expire. It cannot perform human-only actions such as claiming an account,
  changing human ownership, or creating, listing and revoking other management
  keys, which are console-only and answer `403 session_required` to a key. A
  key can revoke only itself, with `DELETE /v1/cli/credential`; that is what
  `agw account logout` sends.



## Rules

1. Never print, log or echo any secret: management keys,
   provider keys, application API keys. When a call returns a key, store it
   in a private file where the person asked, then report its location.
2. Never send `id` when creating an application. The gateway assigns the ID
   from the name and returns it as `app.id`. IDs cannot be changed.
3. Validate an application body before writing it: a new application with
   `POST /v1/admin/app-drafts/validate`, an existing one with
   `POST /v1/admin/apps/{app}/validate`.
4. `PUT /v1/admin/apps/{app}` replaces the whole application. Always `GET`
   first, modify the returned `app.name`, `app.config` and `app.status`, and
   send all three back together with the `app.revision` from that read. A
   missing revision returns 400; a stale revision returns 409. Reread and merge.
5. Read before you write. List providers and apps before changing anything,
   so you use slugs and IDs that exist.
6. App and provider changes take up to one minute to apply to live traffic.
   Blocking an authenticated user refuses new token exchanges immediately;
   existing gateway tokens stop working within ten seconds. Header-based
   users must be blocked in the backend.
7. Prefer reversible actions. Disable an app or provider rather than deleting
   it unless deletion was asked for. Deleting a provider also deletes its
   custom prices; deleting an app removes its users and keys.
8. Do not test the setup by sending provider requests unless asked, because
   each one costs the person money at the provider.

## Errors

Every refusal is JSON:

```json
{ "error": { "code": "slug_taken", "message": "…", "data": { } } }
```

| Status | Codes | Meaning |
| --- | --- | --- |
| 400 | `invalid_request` | The body is malformed or violates a rule; `message` says which. A create body that carries `id` gets this |
| 401 | `auth_required` | Missing or wrong management key |
| 403 | `forbidden` | The owning identity's role does not permit this operation |
| 403 | `grant_insufficient` | The key is read-only and this call would change something; ask the person for a key with the manage grant |
| 404 | `not_found`, `app_not_found` | No such app, provider, gateway or key |
| 409 | `slug_taken` | A provider already has this slug; supply another |
| 409 | `gateway_in_use` | A gateway still has providers routed through it |
| 409 | `provider_gateway_managed` | This provider's key lives in a gateway; rotate the gateway token instead |
| 400 | `provider_not_supported_by_gateway` | That provider type cannot be routed through that gateway |
| 400 | `provider_key_invalid` | The provider refused the key during a test |
| 400 | `app_revision_required` | Include the revision from the app read |
| 409 | `app_revision_conflict`, `conflict` | Reread the resource and merge the requested changes before retrying |
| 502 | `provider_unavailable` | The gateway could not decrypt a stored key |

## Workflow for setting up an app

1. `GET /v1/admin/providers`. Note which provider types exist and their
   `slug` values.
2. If a provider is missing, use a CLI or MCP browser handoff so the person
   can enter the key privately.
3. Compose the application body from the reference below.
4. `POST /v1/admin/app-drafts/validate` with the body. Fix every error.
5. Create through the [retry-safe operation](#retrying-creation-safely), using
   `kind: "app.add"` and that body as `payload`. Read `result.app.id` and
   store `result.api_key.key` privately for a server app. A plain
   `POST /v1/admin/apps` instead returns `app` and `api_key` at the top level,
   but cannot safely be retried after a lost response.
6. Report the app ID, the base URL
   `{GATEWAY_URL}/v1/apps/{app.id}/proxy/{provider_slug}/{provider_path}`,
   and where the key went. Do not include the key.

## Providers

A provider row is one key for one provider type, or a reference to a gateway
that holds the key. Its `slug` is the URL segment clients use. The first
provider of each type defaults to the type name as its slug (`openai`,
`anthropic`, `gemini`, `xai`, `perplexity`, `deepseek`, `groq`, `mistral`,
`together`, `fireworks`, `cerebras`, `moonshot`, `huggingface`, `baseten`,
`bytedance`, `openrouter`). A second provider of the same type must be given
a slug, or the create answers `409 slug_taken`.

| Call | Body | Notes |
| --- | --- | --- |
| `GET /v1/admin/providers` | | Returns `providers[]` with `id`, `type`, `slug`, `name`, `secretHint`, `providerGatewayId`, `baseUrl`, `pricing`, `route`, `capability`, `revision`, `status`. `capability` lists the `apiStyles` and `endpointStyles` the instance serves on its route and the `modelPrefix` the route adds |
| `POST /v1/admin/providers/test` | `{ type, secret }` or `{ type, providerGatewayId }`, optional `baseUrl` | Dry run. `validated: true` means the provider accepted the key. `validated: false` with a `reason` means nothing was proven. A refused key answers `400 provider_key_invalid` |
| `POST /v1/admin/providers` | `{ type, name, secret }` or `{ type, name, providerGatewayId }`; optional `slug`, `baseUrl`, `pricing`, `gatewayRoute` | Exactly one of `secret` and `providerGatewayId`. `baseUrl` only with `secret` |
| `PUT /v1/admin/providers/{id}` | `revision` plus any of `name`, `secret`, `baseUrl`, `pricing`, `gatewayRoute`, `status` | `revision` is the value from the provider read this edit is based on. `secret` rotates the key in place. A new non-null `baseUrl` requires `secret` in the same call. `pricing: null` clears prices. `status: "disabled"` pauses without freeing the slug |
| `DELETE /v1/admin/providers/{id}` | | Permanent. Frees the slug, deletes custom prices |

`pricing` is per model, in USD per million tokens:

```json
{ "my-model": { "input": 0.5, "output": 1.5, "cached_input": 0.05 } }
```

`input` and `output` are required. Any other per-token rate the catalog uses
is optional under the same name: `cached_input`, `cache_write`,
`cache_write_1h`, `image_input`, `audio_input`, `video_input`,
`image_output`, and the `long_*` rates, which also need
`long_context_threshold`. A custom price replaces the catalog's rates for that
model and inherits none of them: a rate left out bills as on a catalog entry
without it, so a cache rate falls back to `input`.

A model that is in neither the built-in catalog (`GET /v1/admin/prices`)
nor the provider's `pricing` cannot be used by any app: requests answer
`400 pricing_not_configured`, and an app configuration that names it in
`allowed_models`, `model_rewrites` targets or endpoints is rejected on
validation. OpenRouter reports its own cost and needs no price, and so does
xAI on Chat Completions, Responses and image generation, directly or through
Cloudflare; its Messages API, `v1/stt` and the Vercel route still need one.
The catalog prices OpenAI's `gpt-image-*` and Gemini's image models per token,
with separate image rates, and BytePlus Seedream 5.0 flash, 5.0 lite, 4.5 and
4.0 per generated image. Other image models billed per image or per
megapixel, such as Together AI's and Seedream 5.0 pro, are not supported, and
per-token `pricing` cannot express them.

Custom `baseUrl` points an `openai`-type provider at an OpenAI-compatible
server. It requires `https`, a public hostname, the default port and no query.
The client path is appended literally: use `https://my-vllm.example.com/`
with `v1/chat/completions`, or Azure's `https://RESOURCE.openai.azure.com/openai/`
with that same path. Do not duplicate `v1/`. See [Custom base URLs](https://docs.appaigateway.com/providers/custom-base-urls).

### Gateways

A gateway row is a connection to a Cloudflare AI Gateway or Vercel AI Gateway
whose token is stored once and shared by providers routed through it.

| Call | Body |
| --- | --- |
| `GET /v1/admin/provider-gateways` | |
| `POST /v1/admin/provider-gateways/test` | `{ type: "cf_aig", accountId, gatewayId, token }` or `{ type: "vercel", token }` |
| `POST /v1/admin/provider-gateways` | Same plus `name` |
| `PATCH /v1/admin/provider-gateways/{id}` | `{ name, revision }` |
| `POST /v1/admin/provider-gateways/{id}/rotate` | `{ token, revision }` |
| `DELETE /v1/admin/provider-gateways/{id}` | Refused with `409 gateway_in_use` while any provider references it |

Provider and gateway updates use the revision returned by their list route.
A missing or invalid revision returns 400. If another edit wins first, the
write returns `409 conflict`; read the row again and merge before retrying.

Cloudflare routes `openai`, `anthropic`, `xai`, `gemini`, `perplexity`.
Vercel routes those plus `deepseek` and `moonshot`, and accepts only the
paths `v1/responses`, `v1/chat/completions` and `v1/messages`.

## Applications

| Call | Body | Notes |
| --- | --- | --- |
| `GET /v1/admin/apps` | | `apps[]` with each app's month-to-date usage |
| `GET /v1/admin/apps/{app}` | | `{ app: { id, name, config, status, revision, created_at, updated_at, warnings } }` |
| `POST /v1/admin/app-drafts/validate` | `{ name, config, status? }` | `{ valid: true }` or a 400 naming the problem. For an app that does not exist yet |
| `POST /v1/admin/apps/{app}/validate` | `{ name, config, status? }` | `{ valid: true, app_id }` or a 400 naming the problem. Judged as an update of that app; 404 if it does not exist |
| `POST /v1/admin/apps` | `{ name, config, status? }` | Returns the app plus `api_key` (server apps only, once) |
| `PUT /v1/admin/apps/{app}` | `{ name, config, status?, revision }` | Full replace; `revision` must be the one the last read returned |
| `DELETE /v1/admin/apps/{app}?confirm={app}` | | Query repeats the app ID. Removes users, keys and diagnostics; retains usage history in account totals |
| `GET /v1/admin/apps/{app}/keys` | | Server apps only |
| `POST /v1/admin/apps/{app}/keys` | `{ name }` | Returns `key` once |
| `POST /v1/admin/apps/{app}/keys/{key}/revoke` | | |

`status` is `active` or `disabled`. A disabled app refuses every request
with `403 app_disabled` and keeps everything.

An app's `warnings` describe behaviour that will stop on the stated date:
each has a `code`, a `message`, and usually `until` and `docs_url`. Tell the
person about every warning and help them act before its date. Warnings are
read-only: leave `warnings`, `id`, `created_at` and `updated_at` out of the
body you write back, or the write is refused, and keep the `revision` from the
read, which an update requires.

## Application configuration reference

`config` is one JSON object with four top-level keys. Unknown keys are
rejected.

```json
{
  "authentication": { … },
  "routing": { … },
  "limits": { … },
  "endpoints": { … }
}
```

### `authentication`

Discriminated on `type`.

**iOS application** (`apple_app_attest`):

| Key | Type | Meaning |
| --- | --- | --- |
| `type` | `"apple_app_attest"` | The app proves itself with Apple App Attest |
| `app_attest.team_id` | string | Apple Team ID |
| `app_attest.bundle_id` | string | Bundle ID |
| `app_attest.environments` | `["production"]`, `["development"]` or both; optional | Which App Attest environments are accepted. Omitted means production only |
| `app_attest.verify_on` | `"every_request"` or `"token_exchange"`; optional | When the install's App Attest assertion is verified. `every_request`, the default when omitted: every request carries `X-AppAttest-Proof`, which needs the Swift package 1.4 or later. `token_exchange`: only at the hourly token exchange, and requests carry the token alone. Apps created before this setting was added were set to `token_exchange`. Switch an app to `every_request` only after the person confirms every build in use is on package 1.4 or later; older builds are refused from then on |
| `end_user` | object, required | `{ "source": "issuer", "issuer": {…} }` for signed-in users, or `{ "source": "app_install" }` to count each install as a user |

**Server application** (`api_key`):

| Key | Type | Meaning |
| --- | --- | --- |
| `type` | `"api_key"` | The backend proves itself with an API key |
| `end_user` | object, required | `{ "source": "none" }` for no user identity. `{ "source": "header", "header": "x-end-user-id" }` when the backend names the user in a header. `{ "source": "issuer", "issuer": {…} }` for signed-in users, in which case the key is exchanged with the user's ID token for a gateway token |

**The `issuer` object** (signed-in users):

| Key | Type | Meaning |
| --- | --- | --- |
| `jwks_url` | URL | Where the identity provider publishes its signing keys |
| `issuer` | string or string[] | Accepted `iss` values. Required |
| `audience` | string or string[] | Accepted `aud` values. Required |
| `user_id_claim` | string | Claim used as the user ID. Use `"sub"` |
| `required_claims` | array | Entitlement requirements, each `{ "path": "...", "contains": "x" }` or `{ "path": "...", "equals": value }`. `contains` may be a list of alternatives. Empty array for none |
| `max_token_lifetime_seconds` | integer | Reject tokens whose `exp - iat` exceeds this. Use `86400` |
| `token_header` | string, optional | Rarely needed |
| `provider` | `"firebase"`, `"supabase"`, `"auth0"`, `"clerk"`, `"custom"`, optional | Lets the console show the preset. Set it to match what you configured |
| `entitlement` | `"revenuecat"` or `"custom"`, optional | Same, for the subscription check |

Preset values for common identity providers:

| Provider | `jwks_url` | `issuer` | `audience` |
| --- | --- | --- | --- |
| Firebase | `https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com` | `https://securetoken.google.com/<project-id>` | `<project-id>` |
| Supabase | `https://<ref>.supabase.co/auth/v1/.well-known/jwks.json` | `https://<ref>.supabase.co/auth/v1` | `authenticated` |
| Auth0 | `https://<domain>/.well-known/jwks.json` | `https://<domain>/` (trailing slash required) | the API identifier |
| Clerk | `https://<frontend-api-host>/.well-known/jwks.json` | `https://<frontend-api-host>` | the `aud` the JWT template sets |
| Sign in with Apple | `https://appleid.apple.com/auth/keys` | `https://appleid.apple.com` | the bundle ID |

A RevenueCat entitlement check is
`{ "path": "revenueCatEntitlements", "contains": "<entitlement id>" }` with
`"entitlement": "revenuecat"`.

### `routing`

| Key | Type | Meaning |
| --- | --- | --- |
| `providers.mode` | `"all"` or `"selected"` | `all` allows every provider the account has, now and later. `selected` allows only those in `providers.selected` |
| `providers.selected` | object keyed by provider slug | Required when mode is `selected`. Each value is a policy object below. Every slug must be an existing provider on create |
| `model_rewrites` | object | Client model name to real model name. Required key; use `{}` for none. Targets must be priced |

Policy object for one provider:

| Key | Type | Meaning |
| --- | --- | --- |
| `allowed_paths` | array | Empty allows inference APIs, and each provider's own image, embedding, speech and legacy completion endpoints; the text ones honour `max_output_tokens`, the rest are never capped. A non-empty array allows only its entries, as strings like `"v1/responses"` or objects `{ "path", "fixed_model"?, "clamp"? }`. `clamp` is one of `responses`, `chat_completions`, `gemini_native`, `anthropic`, `none` |
| `allowed_models` | string[] | Empty allows every priced model |
| `max_output_tokens` | positive integer, optional | Requests above it are refused; requests without an output limit get it injected |

### `limits`

Optional. Omit for no limits. Every field is `null` (unlimited) or a number.
Non-null `per_user` limits require an app that identifies users; an all-null
block is accepted without user identity. Request limits
must be positive; a spend budget may be `0` to stop all spend.

On an iOS app, `per_user` request limits count per install (one copy of the
app on one device), and so does the budget unless
`per_user.spending.scope` is `"user"`, which needs `end_user.source`
`issuer`. `scope` defaults to `"install"` on iOS apps and `"user"` on server
apps, where `"install"` is refused. The gateway writes the default in.

```json
"limits": {
  "per_user": { "requests": { "per_minute": 10, "per_day": 300 }, "spending": { "monthly_usd": null } },
  "per_app":  { "requests": { "per_minute": null, "per_day": null }, "spending": { "monthly_usd": 100 } }
}
```

### `endpoints`

Optional. Keyed by slug (`^[a-z0-9-]{1,64}$`). Each endpoint:

| Key | Type | Meaning |
| --- | --- | --- |
| `api_style` | `"responses"`, `"chat_completions"`, `"anthropic_messages"`, `"gemini_native"`, `"audio_transcription"`, `"image_generation"`, `"embeddings"` or `"gemini_embedding"` | Request format the client sends |
| `kind` | `"text"`, `"image"`, `"speech"`, `"transcription"` or `"embedding"`, optional | What the endpoint does. Defaults to the format's only kind; `gemini_native` carries text (the default), image and speech |
| `provider` | provider slug | A provider whose type and route serve the format: OpenAI and xAI for `responses`, the OpenAI-compatible types and OpenRouter for `chat_completions`, Anthropic for `anthropic_messages`, Gemini for both Gemini formats, OpenAI, xAI and Groq for `audio_transcription`, OpenAI, xAI and ByteDance for `image_generation`, OpenAI for `embeddings` |
| `model` | string | Must be of the endpoint's kind and priced. A model only custom pricing covers counts as text. On a provider that reports its own cost, an unpriced model is accepted |
| `params` | object, optional | Deep-merged over the client body, server wins. Every format but `audio_transcription` |
| `max_output_tokens` | positive integer, optional | Text endpoints only |
| `fallback` | array of `{ provider, model }`, optional | Tried in order when the primary fails before streaming starts. Same format and kind |

`listModelPrices` gives every catalog model's `kind`: `text`, `image`,
`speech`, `transcription`, `embedding` or `realtime`. Pick an endpoint's
models by it; a `realtime` model serves no endpoint.

### Complete examples

A server app for a backend that names its users, restricted to one OpenAI
model:

```json
{
  "name": "Search service",
  "config": {
    "authentication": {
      "type": "api_key",
      "end_user": { "source": "header", "header": "x-end-user-id" }
    },
    "routing": {
      "providers": {
        "mode": "selected",
        "selected": {
          "openai": { "allowed_paths": ["v1/responses"], "allowed_models": ["gpt-5.6-sol"], "max_output_tokens": 4096 }
        }
      },
      "model_rewrites": {}
    },
    "limits": {
      "per_user": { "requests": { "per_minute": 30, "per_day": 1000 }, "spending": { "monthly_usd": 10 } },
      "per_app": { "requests": { "per_minute": 300, "per_day": 10000 }, "spending": { "monthly_usd": 100 } }
    }
  }
}
```

An iOS app with Firebase sign-in, a RevenueCat entitlement, and a named
endpoint:

```json
{
  "name": "Calorie Tracker",
  "config": {
    "authentication": {
      "type": "apple_app_attest",
      "app_attest": { "team_id": "ABCDE12345", "bundle_id": "com.example.calorietracker" },
      "end_user": {
        "source": "issuer",
        "issuer": {
          "jwks_url": "https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com",
          "issuer": "https://securetoken.google.com/calorie-1a2b3",
          "audience": "calorie-1a2b3",
          "user_id_claim": "sub",
          "required_claims": [{ "path": "revenueCatEntitlements", "contains": "pro" }],
          "max_token_lifetime_seconds": 86400,
          "provider": "firebase",
          "entitlement": "revenuecat"
        }
      }
    },
    "routing": { "providers": { "mode": "all" }, "model_rewrites": {} },
    "limits": {
      "per_user": { "requests": { "per_minute": 20, "per_day": 500 }, "spending": { "monthly_usd": 5 } },
      "per_app": { "requests": { "per_minute": null, "per_day": null }, "spending": { "monthly_usd": 500 } }
    },
    "endpoints": {
      "chat": {
        "api_style": "responses",
        "provider": "openai",
        "model": "gpt-5.6-luna",
        "params": { "reasoning": { "effort": "low" } },
        "fallback": [{ "provider": "xai", "model": "grok-4.5" }]
      }
    }
  }
}
```

## Users

| Call | Query or body | Notes |
| --- | --- | --- |
| `GET /v1/admin/apps/{app}/users` | `month=YYYY-MM`, `status=active|blocked`, `query=`, `limit=`, `offset=` | Each user with requests, tokens and cost for the month, and `installs`: the App Attest keys they hold, one per install |
| `GET /v1/admin/apps/{app}/users/{user}` | | |
| `POST /v1/admin/apps/{app}/users/{user}/block` | | New token exchanges are refused immediately; existing gateway tokens stop working within ten seconds. Users named by a backend header cannot be blocked |
| `POST /v1/admin/apps/{app}/users/{user}/unblock` | | |

## Usage and events

| Call | Query | Returns |
| --- | --- | --- |
| `GET /v1/admin/apps/{app}/usage` | `month=YYYY-MM` | Totals: requests, tokens, cost |
| `GET /v1/admin/apps/{app}/usage/timeseries` | `from=`, `to=` (ISO dates) | Daily buckets by provider |
| `GET /v1/admin/apps/{app}/usage/breakdown` | `by=`, `from=`, `to=`, `limit=` | Grouped totals. `by` is one of `model`, `model_author`, `provider`, `provider_slug`, `provider_gateway`, `credential_source`, `user`, `install`, `status`, `cost_source`, `route`, `endpoint`, `app_version` |
| `GET /v1/admin/apps/{app}/events` | `limit=`, `status=`, `user=`, `install=`, `model=`, `before_id=` | Individual provider attempts, newest first, each with `install_id` on iOS apps. Page with `next_before_id` |
| `GET /v1/admin/apps/{app}/auth-events/summary` | `days=` | Token exchange success rate, failures by cause, claim delay percentiles |
| `GET /v1/admin/apps/{app}/auth-events` | `limit=`, `outcome=`, `before_id=` | Individual token exchanges and registrations |
| `GET /v1/admin/apps/{app}/rejection-events` | `limit=`, `reason=`, `scope=`, `user=`, `from=`, `to=`, `before_id=` | Sampled requests stopped before a provider call; page with `next_before_id` |

Usage event `status` values: `ok`, `provider_error`. Refusal sample `reason`
values: `blocked_app_rate`, `blocked_app_budget`, `blocked_user`,
`blocked_billing`, `blocked_replay` (a replayed App Attest proof). `scope` is
`install`, `user`, `app` or `account`. Samples are limited to one per identity per minute, so
sample counts are not exact request totals. Event
`cost_source` values: `computed`, `reported`, `unresolved`.

## What clients send

You do not normally call these, but you may need to explain them or write
client code.

- Proxy: `{METHOD} /v1/apps/{app}/proxy/{provider_slug}/{provider_path}`
  with the provider's own body. Credential: `Authorization: Bearer <gateway
  token>` plus `X-App-Version` for iOS and signed-in-user apps, or
  `Authorization: Bearer agw_…` for a server app without user identity. When
  an iOS app's `verify_on` is `every_request`, every request also carries
  `X-AppAttest-Proof`, an App Attest assertion over that request; without it
  the gateway answers `401 assertion_required`. With `token_exchange` the
  token alone is enough.
- Custom endpoint: `POST /v1/apps/{app}/endpoints/{slug}`.
- Token exchange for a server app with signed-in users:
  `POST /v1/apps/{app}/auth/token` with `{ "api_key", "issuer_token" }`,
  returning `{ "access_token", "expires_in": 3600 }`.
- iOS apps use the AppAIGateway Swift package 1.4 or later, which handles App
  Attest, the token exchange and, when the token exchange answers
  `verify_on: "every_request"`, the per-request proof. Send requests with
  `withAuthorizedRequest` (1.4.1 or later): it builds the request, hands it to
  the app's own send closure, and on a `401` discards the token and sends a
  rebuilt request once. Pass the request body to it, never set it afterwards.
  `authorizedRequest` builds the request alone, for an app that handles the
  retry itself. See the human documentation under Integrate your app.

For Auth0's API-identifier preset, `issuer_token` must be a JWT access token
for that API. Firebase uses its ID token. In every case the configured issuer
and audience must match; see [User authentication](https://docs.appaigateway.com/applications/auth-policy/user-authentication).

## Contacting human support

Where the deployment offers human support, you can write to it on the
person's behalf: `agw support send` with the CLI, `contact_support` over MCP,
or `POST /v1/admin/support` with a management key. Support replies to the
person's own email address, never to you.

1. Only when the person asked you to contact support. Show them the exact
   message first, and send it only once they agree. Without a terminal,
   `agw support send` refuses to send unless you pass `--yes`, which says you
   have done this.
2. Choose the type: `bug` for something broken, `support` for a question or a
   request for help, `feedback` for an idea or a missing feature.
3. Write what happened, what they expected and what was tried. Put ids and
   codes in context so support can act without asking back:
   `--context app_id=my-app --context request_id=… --context error_code=…`.
4. Never include a key, token or password. A message or context value that
   looks like one is refused with `invalid_request`, and nothing is sent.
5. Tell the person the answer's `reference`, such as `SUP-7F3K2Q`. The same
   message sent again within ten minutes sends nothing new: the answer has
   `duplicate: true` and the first reference, so do not resend to make sure.
6. If the gateway refuses because the account cannot use human support, tell
   the person what the message says instead of retrying.

```sh
agw support send --type bug --message "Streaming responses from my-app stop after about 30 seconds since Monday." \
  --context app_id=my-app --context error_code=provider_timeout --yes --json --no-input
```

## Reporting back

When you finish a task, report: what you created or changed, by ID and slug;
the client base URL for any app; where each secret was stored; and anything
you could not do, such as a provider whose key was not available. Do not
include secret values.

## Retrying creation safely

A plain `POST` to create an app, application key, provider or provider gateway
is not safe to repeat: a response lost in transit leaves you not knowing
whether it landed. Send the creation as an operation instead:
`POST /v1/cli/operations` with `{ kind, payload, token }`, where `kind` is
`app.add`, `app.key.add`, `provider.add` or `provider-gateway.add`, `payload`
is the body the plain endpoint takes (plus `app` for `app.key.add`), and
`token` is a random URL-safe string of 32–256 characters you persist before
sending. Repeat the same kind, payload and token to recover the original
result; a different payload under the same token is 409. A new application
key is returned in `result.api_key.key` for 15 minutes after creation; past
that the result still names the resource and the key's id, and no second key
is minted. Records are kept for 90 days.
