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_keyOver 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"]}'| Bundle | Grants |
|---|---|
all | Every customer-grantable scope, present and future. |
all-read | Every customer-grantable scope ending :read, present and future. |
all-write | Every 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.