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
- Conversing with an agent: the three chat wires in full, and the third-party clients you can point at them with a key.
- Authentication and sessions: the credential the wires expect, and the headers that make a conversation durable.
- Durable workflows: what the two workflow routes answer.
- Security and auth: how the rest of the box decides who gets in.