API keys

A workspace API key is the credential an outside client presents to reach one agent or one workflow on your box. You mint it in Studio, the box shows it once, and it carries the exact list of names it may call. It opens the five invocation surfaces below and nothing else on the box.

Mint a key in Studio

Open Integrations → API keys in Studio and press Create API key. Give the key a name, tick each action it may take, and type the agent or workflow names it reaches beside that action, separated by commas. Leave a row's names blank to grant it everywhere. The expiry date is optional.

The screen is for owners and admins. Other seats never see the row in the sidebar, and the box refuses every route behind it, the list included.

The key is shown once, in the dialog that mints it. Copy it or download it before you close that window. The box stores the selector and a SHA-256 digest of the secret, never the secret, so no screen and no route can show the value a second time. If you lose it, revoke the key and mint another.

Studio mints over POST /api/api-keys on your own box. That route takes your operator identity, not a key, so it is not one you drive from a script with a key you already hold.

What a key looks like

sbk_live_4f3c2b1a9e8d7c6b_zK7Wq2xR-4nT8vB1yE0sM6dH3jL5pC9fG2aU4iO7rQ8
└───┬───┘└──────┬───────┘ └────────────────────┬────────────────────┘
 prefix     selector                        secret
Part What it is
sbk_live_ The fixed prefix. live names the environment, not a product tier, so treat a future prefix as a different credential rather than the same one.
Selector 16 lowercase hex characters. The public half: it names the row, and Studio's list shows the prefix and the selector with the secret masked (sbk_live_4f3c2b1a9e8d7c6b_••••••).
Secret 43 base64url characters, 256 bits of randomness. Shown once and stored nowhere.

Send the key whole. You only need to take it apart to match a key in your password manager against a row in Studio's list, and that comparison is on the prefix and the selector.

Do not split a key on _

The secret is base64url and _ belongs to that alphabet, so about half of all keys carry one inside the secret. key.split('_') tears those in the wrong place and the client fails on some keys and works on others. Read the selector as the 16 characters after the prefix, and the secret as everything after the single _ that follows it, whatever it contains.

What a key may call

A key carries one or more grants. A grant is an action plus the resource names it reaches:

Action What it authorizes
agent:chat One chat turn against a named agent, on any of the three chat wires
workflow:start Starting a durable run of a named workflow
workflow:chat Streaming a named workflow's interactive run

Resource names are matched exactly. A key granted the agent support cannot call support-2, and there is no prefix or glob syntax. The only wide grant is the literal *, and it stays inside the action it was written under: a key that may chat with every agent is not a key that may start every workflow.

Workflow actions are not agent actions either. An agent and a workflow that happen to share a name never authorize each other, and starting a workflow is a separate grant from chatting with one.

Grants are fixed when the key is minted. There is no route that edits them. To change what a key reaches, mint a new key and revoke the old one.

The two model catalogs answer through the same grants instead of refusing: GET /openai/v1/models and GET /anthropic/v1/models list only the agents the presented key may chat with, so a client's model picker shows exactly what the key can call.

The five surfaces a key opens

Operation Action Names the resource by
POST /openai/v1/chat/completions agent:chat the model field
POST /anthropic/v1/messages agent:chat the model field
POST /agui/v1/agents/{name} agent:chat the URL
POST /api/workflows/{name}/start workflow:start the URL
POST /api/workflows/{name}/chat workflow:chat the URL

Every other route that asks for a credential refuses a key outright, whether the key is valid or not: secrets, config, the runs list, the approvals inbox, the DB explorer and the API keys themselves belong to a person in a seat. A key presented there comes back 401 with code: "api_key_not_accepted", which is the box saying you hold the right credential for a different door.

Send the key as Authorization: Bearer <key> or as x-api-key: <key>. Both work on all five. Authorization wins when you send both, and a broken Authorization header is the answer rather than a reason to read x-api-key, so do not send one header as a fallback for the other. x-api-key takes API keys only.

Call each surface

The examples use $STACKBONE_API_KEY for the key you copied from the mint dialog and https://your-box.example.com for your box's address.

export STACKBONE_API_KEY='sbk_live_…'
export STACKBONE_BOX='https://your-box.example.com'

Chat with an agent, OpenAI style

curl "$STACKBONE_BOX/openai/v1/chat/completions" \
  -H "Authorization: Bearer $STACKBONE_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "model": "support",
    "messages": [{ "role": "user", "content": "What plans do you offer?" }]
  }'

Chat with an agent, Anthropic style

curl "$STACKBONE_BOX/anthropic/v1/messages" \
  -H "x-api-key: $STACKBONE_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "model": "support",
    "max_tokens": 1024,
    "messages": [{ "role": "user", "content": "What plans do you offer?" }]
  }'

Chat with an agent over AG-UI

curl -N "$STACKBONE_BOX/agui/v1/agents/support" \
  -H "Authorization: Bearer $STACKBONE_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "threadId": "thread-1",
    "runId": "run-1",
    "messages": [{ "id": "msg-1", "role": "user", "content": "What plans do you offer?" }],
    "tools": [],
    "context": [],
    "state": null
  }'

Start a workflow run

curl "$STACKBONE_BOX/api/workflows/refund/start" \
  -H "Authorization: Bearer $STACKBONE_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{ "orderId": "ord_42", "amount": 19.99 }'

Stream a workflow run

curl -N "$STACKBONE_BOX/api/workflows/refund/chat" \
  -H "Authorization: Bearer $STACKBONE_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{ "orderId": "ord_42", "amount": 19.99 }'

The bodies and the responses are the same ones documented for the chat wires and durable workflows. A key changes who may call them, never what they answer.

A session belongs to the key that opened it

Durable sessions are scoped to the key that started them. Two keys that pick the same x-stackbone-session value, or the same AG-UI threadId, get two separate conversations, and neither can read the other's history. The same holds between a key and the Studio playground, so a thread you opened in the browser is not one a key can continue. Rotating a key therefore starts the conversation again: the new key cannot pick up a thread the old one owned. See Authentication and sessions for what the session headers do.

Expire and revoke

A key can carry an expiry, chosen when it is minted. Leave it empty and the key is valid until somebody revokes it, which is a choice rather than a missing value.

Revoking is one press of Revoke on the row in Studio, over POST /api/api-keys/{id}/revoke. The row does not disappear: revocation stamps when it happened and who did it, so the list can still explain a key that used to work. There is no un-revoke. Mint another key instead. Revoking a key that is already revoked answers 409 with code: "api_key_already_revoked", and the first revocation stands.

Two things to know about the timing, because they decide what you do in an incident:

  • The next request fails. The box reads the key's row on every call and keeps no positive cache, so the first request after you revoke is already refused. There is no window to wait out.
  • A stream already open drains. The check runs once, when the request arrives. A chat completion or a workflow stream that was authorized before you revoked runs to its end instead of being cut mid-frame. If you need an in-flight run stopped as well, stop the run itself.

When a key is refused

Status What it means Body
401 Unknown, forged, revoked or expired key code: "invalid_api_key" on the workflow routes, and the wire's own shape on the chat wires
401 A real key presented on a route that does not take one code: "api_key_not_accepted"
403 A valid key, on a surface it opens, for a name it was not granted code: "permission_denied" (OpenAI, AG-UI), type: "permission_error" (Anthropic), code: "invocation_not_permitted" (workflows)

Sending no credential at all is a different answer again: the chat wires refuse it in their own shape, and the two workflow routes answer the box gate's own invalid_token (or invalid_signature behind the control-plane proxy), because nothing was presented for the key check to read.

The 401 is one answer for all of unknown, forged, revoked and expired. The box will not tell a caller which of the four it got right. The 403 names no agent and no workflow for the same reason: a key narrowed to one name cannot map the rest of your box by reading the difference between an answer for a name that exists and one that does not.

A stackbone dev box checks keys the same way, so you can test a key's grants on your laptop before you deploy.

What's next

BUILT WITH ❤️ FROM CANADA AND SPAIN