Authentication & keys

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

Every agent request authenticates with one scoped sky_ 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.

Sign into your workspace

Open Skysay sign-in. Email sign-in sends a six-digit code by default. You can also use Google, GitHub, or choose Use a password instead. A new account starts with a verified Google/GitHub identity or an email code; password sign-in does not create an unverified account. All methods use the same account and workspaces.

Set up or change a password

Sign in and open Settings → Your account security. Choose Set up password to receive a one-use link at your account email. If you already have a password, enter your current password, new password and confirmation, then choose Change password. This changes your own account, not a teammate's credentials.

A password must be 10–128 characters with an uppercase letter, lowercase letter, number and symbol. Spaces are allowed and are not removed.

Recover a forgotten password

Choose Use a password instead, then Forgot password? on sign-in. Enter your email, then follow the email link and confirm your new password. The link expires after 15 minutes and works once. Opening it does not use it up. The confirmation is the same whether an account exists; a reset request does not create a new account. If a link has expired or been used, request another.

Setting, changing or resetting a password signs you out of browser sessions and disconnects your MCP OAuth connections. Sign in again and reconnect the assistant, reviewing its consent screen. Other teammates' sessions and your workspace's machine API keys are unaffected; revoke keys separately if needed.

An isolated demo account uses these same controls and normal workspace permissions. There is no separate reviewer login or reviewer-only permission mode.

Bearer auth

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

Authorization: Bearer sky_your_scoped_key

For Claude Code and Codex, supply the key through SKYSAY_API_KEY in the process environment. Keep the value out of committed configuration, prompts, screenshots and logs. Environment variables are available to local programs with access to that process; they are not a secret boundary against the assistant itself.

Minting a key

For ordinary setup, sign in and create a narrow key on the workspace's API keys page. The REST example below is for an existing authenticated administration client; it is not a prerequisite for connecting your first MCP client.

curl -s -X POST "$SKYSAY_BASE_URL/v1/api-keys" \
  -H "Authorization: Bearer $SKYSAY_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"MCP client",
       "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 "$SKYSAY_BASE_URL/v1/api-keys" \
  -H "Authorization: Bearer $SKYSAY_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"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 "$SKYSAY_BASE_URL/v1/api-keys/scopes" \
  -H "Authorization: Bearer $SKYSAY_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 skygw_ value for SKYSAY_GATEWAY_API_TOKEN=skygw_....

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.

SKYSAY_WORKER_API_TOKEN is an organization-scoped sky_ 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 skygw_ token in that variable, or a sky_ 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 SKYSAY_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 Skysay'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 skygw_ 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 Skysay.

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.

organization_id is optional with a workspace key

A workspace key belongs to exactly one workspace, so you never need to name it. Wherever a REST route accepts organization_id, as a query parameter or a request-body field, you can leave it out and the key's own workspace is used:

curl -s "$SKYSAY_BASE_URL/v1/calls?limit=20" \
  -H "Authorization: Bearer $SKYSAY_API_KEY"

If you do send it, it must be the key's own workspace. Naming any other workspace answers 403, whether or not that workspace exists. project_id is different: it is never filled in for you, and a route that requires it, such as POST /v1/agents, answers 400 without it. The Quickstart with curl sends no organization_id at all.

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.

What the API expects a request body to be

Every write you make takes a UTF-8 JSON object. (Provider callback routes that have always accepted form-encoded or plain-text bodies still do; those are URLs you give a carrier, not endpoints you call.) Four answers follow, and none of them is a 500:

  • A body that is absent, or contains only whitespace, is read as an empty object. Action routes that take no fields can keep sending nothing.
  • A body that is not valid JSON, is not UTF-8, or is valid JSON that is not an object — an array, a number, a bare string — answers 400.
  • A request that carries a Content-Encoding other than identity answers 415. Skysay does not accept a compressed request body; send it uncompressed. Response compression is unaffected, and so is Accept-Encoding.
  • A required field that is missing, null, or blank answers 400 naming the field, rather than failing further in. POST /v1/agents and POST /v1/numbers require project_id this way; POST /v1/agent-endpoints also requires webhook_url and audio_stream_url. organization_id is never required from a workspace key; see organization_id is optional.

If you are retrying a request because it returned 500, check it against this list first: several shapes that used to answer 500 now answer 400 or 415, and those are answers about the request, not about the service.

Rotate / revoke

Mint a new key, update SKYSAY_API_KEY in the environment that launches your client, restart the client and verify a read, then revoke the old one:

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

Do not revoke the old key before verifying the new connection if you need an uninterrupted workflow. See the scope catalog for every scope and the tool it gates.