--- title: 'API keys' description: 'The credential a client outside the browser presents to call an agent or a workflow on your box: what it looks like, what it may reach, and how to turn one off.' position: 4 --- # 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. > [!WARNING] 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 ` or as `x-api-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. ```sh export STACKBONE_API_KEY='sbk_live_…' export STACKBONE_BOX='https://your-box.example.com' ``` ### Chat with an agent, OpenAI style ```sh 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 ```sh 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 ```sh 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 ```sh 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 ```sh 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](/docs/cli/protocol/chat) and [durable workflows](/docs/cli/protocol/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](/docs/cli/protocol/auth#session-keys) 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](/docs/cli/protocol/chat): the three chat wires in full, and the third-party clients you can point at them with a key. - [Authentication and sessions](/docs/cli/protocol/auth): the credential the wires expect, and the headers that make a conversation durable. - [Durable workflows](/docs/cli/protocol/durable-workflows): what the two workflow routes answer. - [Security and auth](/docs/home/features/security-auth): how the rest of the box decides who gets in.