Authentication & keys

Scoped tai_ keys and gateway tgw_ tokens, Bearer auth, scope clamping and fail-closed semantics, org isolation, and rotate/revoke.

Every agent request authenticates with one scoped tai_ API key passed as a Bearer token. The key is also your permission boundary — it decides which tools run and which organization they run against. Numbers, compliance, billing, and provider operations are never on the key; the agent gets capability tools and nothing else.

Bearer auth

Pass the key on every call to POST /mcp (or the REST API):

Authorization: Bearer tai_your_scoped_key

Over the OpenClaw connector the key lives in the mcporter env block as OPENPHONEX_API_KEY; the stdio bridge attaches it to each JSON-RPC call. It is a secret — never commit it, never put it in SKILL.md.

Minting a key

curl -s -X POST "$OPENPHONEX_BASE_URL/v1/api-keys" \
  -H "Authorization: Bearer $OPENPHONEX_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"organization_id":"org_...",
       "name":"OpenClaw agent",
       "scopes":["account:read","coverage:read","agents:write",
                 "calls:read","recordings:read"]}'

The response returns token once. Store it immediately — it is never retrievable again.

Grant bundles

Instead of listing every scope by hand, scopes may name a grant bundle: all, all-read, or all-write. A bundle is stored on the key by name and expanded against the live grant catalogue at use time, not frozen at mint time — a scope added to the catalogue after the key was minted is covered automatically, with no need to reissue the key.

curl -s -X POST "$OPENPHONEX_BASE_URL/v1/api-keys" \
  -H "Authorization: Bearer $OPENPHONEX_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"organization_id":"org_...",
       "name":"Read-only reporting key",
       "scopes":["all-read"]}'
BundleGrants
allEvery customer-grantable scope, present and future.
all-readEvery customer-grantable scope ending :read, present and future.
all-writeEvery customer-grantable write/send/arm scope (:write, :send, :arm), present and future.

A bundle is not a wildcard. Scope clamping still applies — a key can only mint a bundle its own minting credential also holds the full expansion of — and no bundle, no matter who mints it, ever yields:

  • Operator-only scopes: the operator console, worker job queues, release gates, provider-mutation review and approval, usage writes, and the platform-media worker role.
  • Specialized-principal scopes: the scopes reserved for self-hosted gateway tokens (calls:authorize, gateways:report, artifacts:upload, worker_jobs:enqueue, number-rules:read, component_credentials:lease) and the platform-media worker pair (platform_media:write, knowledge:query).

GET /v1/api-keys shows the bundle name itself in scopes (e.g. ["all-read"]), not a snapshot of what it currently expands to — the key stores the bundle, and every use resolves it fresh. Existing keys minted with an explicit scope list are unaffected: this only changes what scopes may contain on a new key, not how any already-minted, exact-scope key behaves.

The workspace API Keys page's key-creation dialog offers the same choice visually: the three bundles first, then every grantable scope grouped by namespace, with an inline note listing the operator-only scopes it cannot grant from there.

Reading the grant catalogue

GET /v1/api-keys/scopes returns the catalogue the dialog above renders from — the bundles with what each currently expands to, every customer-grantable scope grouped by namespace, and the operator-only scopes no workspace key can ever hold. It requires api_keys:read, the same scope as GET /v1/api-keys.

curl -s "$OPENPHONEX_BASE_URL/v1/api-keys/scopes" \
  -H "Authorization: Bearer $OPENPHONEX_ADMIN_TOKEN"
{
  "bundles": [
    {
      "id": "all",
      "expands_to": ["account:read", "agents:read", "agents:write", "..."],
      "description": "Every customer-grantable scope, including scopes added later."
    },
    {
      "id": "all-read",
      "expands_to": ["account:read", "agents:read", "api_keys:read", "..."],
      "description": "Every customer-grantable read scope, including scopes added later."
    },
    {
      "id": "all-write",
      "expands_to": ["agents:write", "api_keys:write", "calls:write", "sms:send", "..."],
      "description": "Every customer-grantable write/send/arm scope, including scopes added later."
    }
  ],
  "groups": {
    "account": ["account:read"],
    "agents": ["agents:read", "agents:write"],
    "sms": ["sms:read", "sms:send"]
  },
  "operator_only": ["operator:read", "operator:write", "usage:write", "worker_jobs:read", "..."],
  "specialized": ["calls:authorize", "gateways:report", "knowledge:query", "..."]
}

The response as of this release grants 29 scopes under all, 17 under all-read, and 11 under all-write, grouped across 18 namespaces (groups above shows 3 for brevity); operator_only names 12 scopes and specialized 8. Those counts grow as the catalogue does — call the endpoint for your workspace's exact, current lists rather than hardcoding one.

Self-hosted gateway credentials

An inbound self-hosted media gateway uses one credential, and it is not an ordinary API key. In the workspace API Keys page, use the Gateway Tokens card to create the deployment-bound tgw_ value for OPENPHONEX_GATEWAY_API_TOKEN=tgw_....

That token routes the inbound leg, reads that call's tenant-bound runtime configuration, and lists MCP tool schemas, in addition to its number-rules, lifecycle and artifact operations. Every one of those derives its organization server-side from the token row rather than from the request body, so the credential cannot act outside the workspace its deployment belongs to. Its MCP admission is exactly one method — tools/list, the static tool catalogue — and tools/call stays refused, so it never becomes an organization MCP key.

OPENPHONEX_WORKER_API_TOKEN is an organization-scoped tai_ key. It is still accepted where a deployment already sets one, and the optional outbound-dial profile needs it (claiming outbound call jobs is worker_jobs:* authority a gateway token does not carry), but an inbound-only host holds no organization-wide credential. Do not put a tgw_ token in that variable, or a tai_ key in the gateway variable. Secrets are shown once only and are absent from list views. See deployment modes for the full host setup, the two-value bootstrap, and fail-closed behavior. Because tools/list has no target DID, the first inbound route — not the smoke check — validates that OPENPHONEX_ORGANIZATION_ID, the token, and the DID belong to one workspace.

Hosted workers hold no workspace credential

Managed outbound calls and real-time Simulations Lab sessions run on OpenPhonex's own shared workers: one hosted dialer and one hosted simulation runner serve every workspace. They authenticate with platform credentials that belong to no organization and carry only the job-claim and heartbeat scopes their own work needs. Each claims only its own kind of hosted job — never a self-hosted gateway's organization queue — and takes every job's workspace, project, agent, caller number and configuration from the call record it claimed, not from anything the worker holds or is told. The hosted outbound gateway credential likewise carries only calls:authorize and gateways:report: it can authorize a hosted call that already exists, and cannot enqueue work or read arbitrary agent configuration.

You never create, hold, or install one of these credentials. They are not a customer credential option, and there is nothing to provision for your workspace before a managed call is placed or a real-time simulation runs. Your own keys are unchanged and stay organization-bound: omitting an organization never grants cross-workspace access, and a self-hosted gateway's tgw_ token still reaches only the workspace its own deployment belongs to.

Scope clamping (cannot self-escalate)

Requested scopes are clamped server-side to what your admin credential already holds. A key can never grant itself more than its minter has. Ordinary agent keys omit control-plane scopes such as api_keys:write, wallet:write, operator:*, and provider_mutations:*. Self-hosted gateways use the deployment-bound token from Gateway Tokens; they do not need a separate worker API-key preset. Platform worker credentials are managed by OpenPhonex.

Fail-closed semantics

tools/list returns every tool, but a tools/call for a tool whose required_scopes are not on your key fails with missing MCP scopes: X. There is no partial or best-effort execution — unauthorized calls fail closed. Mint only the scopes you need.

Least privilege

For a cautious agent, drop sms:send, calls:write, and numbers:write and use only the read/configuration scopes needed: account:read, coverage:read, agents:write, calls:read, recordings:read. The trial_calls:write scope only creates offline synthetic fixtures, not live shared-number phone trials.

Org isolation

Every call runs pinned to the key's organization. A tool argument whose organization_id differs from the key's org is rejected with MCP organization mismatch. You cannot read or write another tenant.

A resource outside your workspace is absent, not forbidden

Every REST GET route that names a resource by id in its path answers 404 when that id belongs to another workspace — the same status and the same body it answers for an id that never existed at all. The two cases are deliberately indistinguishable, so a status code can never be used to discover which ids are real in someone else's workspace.

Treat 404 on a by-id read as "not yours, or not there". Do not branch on 403 versus 404 to decide whether a record exists: on a by-id read a 403 now says something about your credential — a missing scope, or a request that named an organization other than the key's — and never something about the record.

Two answers are unchanged on those reads, because neither depends on the id:

  • 401 — the credential is missing or invalid.
  • 403 — the credential lacks the scope the route requires, or the request names another organization explicitly (as a body field or a query value).

GET /v1/organizations/{organization_id}/... is the one read where the path id is the tenant. It answers 403 for any organization that is not the credential's own, whether or not that organization exists.

Writes are moving to the same answer, and are not all there yet

Some by-id writes already answer this way: PATCH /v1/campaigns/{campaign_id} and its launch, pause, resume, cancel and duplicate actions, POST /v1/campaigns/{campaign_id}/reviews/{attempt_id}/resolve, and POST /v1/agents/{agent_id}/workflow/phone-pin with its /disarm sibling.

Every other by-id write — PATCH /v1/agents/{agent_id} and PATCH /v1/agents/{agent_id}/voice-stack among them — still answers 403 for a record in another workspace, so on those routes a 403 does not yet carry the meaning it carries on a read. Write your client so that either status means "you cannot act on this record", and never treat a 403 as evidence that the record exists: a 403 you branch on today may answer 404 in a later release, without that being a breaking change.

Rotate / revoke

Mint a new key, swap the mcporter env, then revoke the old one:

curl -s -X POST "$OPENPHONEX_BASE_URL/v1/api-keys/{key_id}/revoke" \
  -H "Authorization: Bearer $OPENPHONEX_ADMIN_TOKEN"

The bridge is stateless, so there is no downtime on the OpenPhonex side. See the full scope catalog for every scope and the tool it gates.

On this page