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_keyFor 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"]}'| 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 "$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-Encodingother thanidentityanswers415. Skysay does not accept a compressed request body; send it uncompressed. Response compression is unaffected, and so isAccept-Encoding. - A required field that is missing,
null, or blank answers400naming the field, rather than failing further in.POST /v1/agentsandPOST /v1/numbersrequireproject_idthis way;POST /v1/agent-endpointsalso requireswebhook_urlandaudio_stream_url.organization_idis never required from a workspace key; seeorganization_idis 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.