BetterClaw
Agent API

Authentication

API keys, short-lived session tokens for browsers, agent pinning, and revocation.

Authentication

The Agent API accepts two credentials: the API key you generate in the app (bc_sk_…), and a short-lived session token (bcs_…) that your server mints from that key. Both are sent as a bearer token.

Sending an API key

Send the key in the Authorization header on every request:

curl https://api.betterclaw.io/workspaces \
  -H "Authorization: Bearer bc_sk_…"
const res = await fetch('https://api.betterclaw.io/workspaces', {
  headers: { Authorization: `Bearer ${process.env.BETTERCLAW_API_KEY}` },
});

The API enforces two rules for raw keys, because a leaked key works until someone revokes it:

  • Header only. A key in a query string (?token=bc_sk_…) is rejected with 401. URLs end up in server logs, proxy logs, and browser history.
  • Server only. A key on a request that carries an Origin header, which browsers add to cross-origin requests, is rejected with 403. If a server-side HTTP client you use adds Origin on its own, remove the header.
Store the key the way you store any production secret: in an environment variable or secrets manager, never in source control.

Session tokens

A browser, mobile app, or anything else you don't control should never hold an API key. Instead, your server exchanges the key for a session token and passes that to the client. A session token:

  • expires after 10 minutes by default (at most 15),
  • can be narrowed to fewer permissions than the key has,
  • may be sent from a browser, and in a query string (?token=bcs_…), which is how browsers authenticate a WebSocket.

Exchange a key for a token

curl -s https://api.betterclaw.io/auth/sdk-token \
  -H "Authorization: Bearer $BETTERCLAW_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"ttlSeconds": 600, "scopes": ["chats:read", "chats:write"]}'
FieldTypeRequiredDescription
ttlSecondsintegerNoLifetime in seconds, from 60 to 900. Defaults to 600. Values outside the range are rejected, not clamped.
scopesstringNoPermissions for the token. Defaults to all of the key's permissions. Can only narrow them.

Response (201):

{
  "token": "bcs_eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
  "expiresIn": 600,
  "expiresAt": "2026-10-08T10:25:00.000Z",
  "workspaceId": "6f1c2d4e-8a90-4b1c-9d2e-3f4a5b6c7d8e",
  "userId": "2d3e4f5a-6b7c-4d8e-9f0a-1b2c3d4e5f6a",
  "agentId": null,
  "scopes": ["chats:read", "chats:write"]
}

agentId is the agent the key is pinned to, or null for Any agent. Asking for a permission the key doesn't have returns 403.

Hand tokens to a browser

Add an endpoint to your own backend that checks your user is signed in, mints a token, and returns it. The browser calls the Agent API with that token and asks your endpoint for a new one before expiresAt.

// Your server (Express). The API key never leaves it.
app.post('/betterclaw-token', requireSignedInUser, async (req, res) => {
  const r = await fetch('https://api.betterclaw.io/auth/sdk-token', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.BETTERCLAW_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ scopes: ['chats:read', 'chats:write'] }),
  });
  if (!r.ok) return res.status(502).json({ error: 'Could not get a BetterClaw token' });
  const { token, expiresAt, workspaceId } = await r.json();
  res.json({ token, expiresAt, workspaceId });
});
// The browser.
const { token, workspaceId } = await (await fetch('/betterclaw-token', { method: 'POST' })).json();
const chats = await fetch(`https://api.betterclaw.io/chats?workspaceId=${workspaceId}`, {
  headers: { Authorization: `Bearer ${token}` },
}).then((r) => r.json());
The JavaScript SDK handles both halves: mintSessionToken from @better-claw/sdk/server on your backend, and SessionTokenAuth in the browser, which fetches a new token from your endpoint when the current one is about to expire.
Anyone holding a session token can act as the key until the token expires, so only give tokens to users you would let use the agent yourself, and keep the lifetime short.

Failed exchanges are rate-limited

After 10 failed exchanges from the same IP address within a minute, POST /auth/sdk-token answers 429 until the window passes. Successful exchanges are never limited. The body says how long to wait:

{ "message": "Too many failed attempts — try again shortly", "retryAfter": 42 }

Who a key acts as

A key acts as the workspace admin who generated it. Everything it does is attributed to that person: chats it starts are theirs, and they're visible to the rest of the workspace like any other chat. The key keeps working if that person is later changed to a member, but stops working the moment they leave the workspace. The API keys tab then marks it Not working, and requests fail with 401. Revoke it and generate a replacement from an admin who is staying.

Pinning a key to an agent

When you generate a key you can pin it to one agent. A pinned key:

  • sees only that agent in GET /workspaces/{workspaceId}/agents,
  • gets 403 if a request names a different agent (for example agentId when creating a chat),
  • gets 404 when opening, messaging, or downloading from a chat that belongs to another agent,
  • always starts chats with the pinned agent when you leave out agentId.
Pinning doesn't yet hide other agents' chats from the chat list (GET /chats) or the live stream: both cover the whole workspace. Don't rely on a pin to keep chat titles or messages from other agents away from whoever holds the key.

Revoking a key

Revoke a key from the API keys tab of the Agents page. It stops working on its next request — there is no cache to wait out. Session tokens minted from it stop working at the same moment, and any open stream that used it is closed within about half a minute.

Each key shows when it was last used (updated at most once a minute), which helps you spot keys that are safe to revoke.