Agent seats
An agent seat is an AI agent hired as a member of your workspace. It gets a real address on your
domains (legal-agent@yourcompany.com), appears in the member directory, can be DM’d and
@-mentioned in chat, and can be invited to calendar events — always badged as an agent so it is
never mistaken for a person.
Agent seats are the bridge to an external agent platform. Knobs notifies the platform over signed webhooks when something arrives for the seat; the platform acts back through the ordinary Knobs API as the agent, using scopes an admin granted. Knobs stays vendor-neutral — any platform that can receive a webhook and speak OAuth 2.1 can occupy a seat.
An agent seat differs from the other two machine identities:
| Acts as | Mailbox | Chat | |
|---|---|---|---|
API key (knak_…) |
you | your own | as you |
Integration (knbi_…) |
itself, a non-member bot | none | public channels only |
| Agent seat | itself, a workspace member | its own address | DMs, mentions, any channel it can access |
Managing seats (admin)
Section titled “Managing seats (admin)”Management routes are workspace-scoped and require an owner or admin. They accept a signed-in
session, or an OAuth token carrying the agents:manage scope — which is how an agent platform
provisions the seats it operates. API keys are not accepted here: retiring a
seat revokes its OAuth credentials, and an API key can never revoke credentials.
When you call these with an OAuth token, you see only the seats bound to your own app. Seats
belonging to another app are absent from listings and return 404 on direct access. A seat you
create is bound to your app automatically; binding an existing seat, or moving one between apps,
is an admin action in the Knobs UI.
An admin can also hire, address, suspend and fire seats from Settings → Agents in the Knobs UI, and configure the webhook endpoint under Settings → OAuth apps. Binding a seat to an app is an API/CLI action today.
| Endpoint | Description |
|---|---|
POST /v1/workspaces/{wid}/agents |
Hire a seat → 201 with the seat record |
GET /v1/workspaces/{wid}/agents |
List active seats (never returns private keys) |
GET /v1/workspaces/{wid}/agents/{aid} |
One seat |
PATCH /v1/workspaces/{wid}/agents/{aid} |
Update name, description, kind, or status |
DELETE /v1/workspaces/{wid}/agents/{aid} |
Fire the seat (see Firing a seat below) |
POST /v1/workspaces/{wid}/agents/{aid}/address |
Claim the seat’s address, {"localPart":"legal"} |
POST /v1/workspaces/{wid}/agents/{aid}/bind |
Bind the seat to an OAuth client (admin session only) |
POST /v1/workspaces/{wid}/agents/{aid}/unbind |
Revoke the binding and its grant (admin session only) |
POST /v1/workspaces/{wid}/oauth/clients/{cid}/verify |
Start (or restart) the endpoint verification handshake |
POST /v1/workspaces/{wid}/oauth/clients/{cid}/ping |
Send a signed test event |
GET /v1/workspaces/{wid}/oauth/clients/{cid}/deliveries |
The delivery ledger, newest first (?limit=) |
POST /v1/workspaces/{wid}/oauth/clients/{cid}/deliveries/{did}/redeliver |
Re-send one delivery |
POST /v1/workspaces/{wid}/docs/{did}/agent-edit |
Author a document as an agent (suggest or replace) |
POST /v1/workspaces/{wid}/sheets/{sid}/cells |
Set spreadsheet cell values as an agent |
Create takes:
{ "name": "Legal Agent", "description": "Reviews inbound contracts", "kind": "legal"}One webhook endpoint per app, not per seat
Section titled “One webhook endpoint per app, not per seat”Your webhook URL, its subscriptions and its signing key belong to your OAuth app, not to each
seat. Register ten seats and you still have one receiver to run, one verification handshake to
answer and one keyset to cache — every event carries data.seatId, so you dispatch on the payload
rather than on which URL was hit. This is how Slack apps, GitHub Apps and Stripe accounts work.
Set the endpoint when you register the app, or later:
{ "name": "Your Platform", "clientType": "confidential", "redirectUris": ["https://agents.example.com/oauth/callback"], "scopes": ["agents:manage", "mail:read", "mail:send"], "webhookUrl": "https://agents.example.com/knobs", "eventSubscriptions": ["mail.message.received", "chat.dm.received"]}Every app gets a signing keypair at registration whether or not you set a URL, so adding a receiver later is a one-field update and never changes the key you already trust.
The agent’s address
Section titled “The agent’s address”POST …/address with {"localPart": "legal"} claims that local part on every verified domain
in the workspace at once — an address is a local part, not a per-domain thing. The default
domain’s row becomes the seat’s canonical address (legal@yourcompany.com) and is what the member
directory and calendar invitations resolve.
Role addresses like support@ or legal@ are claimable — an agent answering support@ is the
point. A local part already held by a person, a group, or another seat is refused, and each seat
holds at most one address, so move one by releasing it first. Re-claiming the seat’s own address
is a no-op.
kind is a free-form hint for your platform — Knobs never branches on it.
webhookUrl (on the app) must be https and is optional: an app with no receiver still works
perfectly for a seat that only ACTS, since acting rides your access token rather than any inbound
channel.
Verifying your endpoint
Section titled “Verifying your endpoint”A newly registered webhook URL receives nothing until it proves it is listening. Call
POST /v1/workspaces/{wid}/oauth/clients/{cid}/verify; Knobs sends an endpoint.verify event
carrying a random challenge; reply 200 with that challenge echoed back within 10 seconds and
the endpoint flips to verified. Changing the URL resets verification — a new endpoint has
proven nothing. Your signing key does not change when the URL does, so a cached keyset stays
valid.
Three event types still reach an unverified endpoint, because they are how it learns anything at
all: endpoint.verify, agent.ping, and endpoint.disabled.
Webhook events
Section titled “Webhook events”Deliveries follow the Standard Webhooks v1a convention:
| Header | Value |
|---|---|
webhook-id |
Unique event id — use it as your idempotency key |
webhook-timestamp |
Unix seconds; reject deliveries outside your tolerance window |
webhook-signature |
v1a,<base64 Ed25519 signature> over {id}.{timestamp}.{body} |
X-Knobs-Key-Id |
The signing key id, for keyset lookup |
Verify the Ed25519 signature directly — any standard crypto library will do. v1a is the
spec’s asymmetric scheme, but the reference standardwebhooks libraries currently implement only
the symmetric v1 (HMAC) mode and ignore other versions, so the header shape follows the
convention ahead of the tooling rather than because the tooling is already there.
Fetch your app’s public keys — unauthenticated — from GET /v1/oauth/clients/{cid}/keys, and cache
them by key id. Build that URL from your own configured Knobs base URL plus the client id you
already hold; never from a value inside a webhook body. Your key is stable: changing the webhook
URL does not rotate it. Key-rotation grace windows are not implemented yet — when rotation ships,
the previous key will stay served through a grace period.
The envelope:
{ "id": "evt_7c2a…", "type": "mail.message.received", "workspaceId": "w_5d81…", "timestamp": "2026-08-24T12:00:00Z", "apiVersion": "2026-08-24", "data": { "seatId": "a_…", "agentUid": "agent_a_…", "mailboxId": "mb_…", "threadId": "t_…", "messageId": "m_…", "folder": "inbox", "from": "buyer@acme.com" }}Payloads are deliberately thin: ids and metadata only, never message content. Fetch the body
through the Mail API with the seat’s token — that keeps mail out of your webhook
logs and re-checks permissions at read time. Your endpoint should acknowledge fast (any 2xx) and
do the real work asynchronously.
Subscribable events are mail.message.received, chat.dm.received, chat.mention.received, and
chat.thread.replied. Lifecycle events — endpoint.verify, endpoint.disabled, seat.revoked,
and the agent.ping test — are always delivered; an endpoint cannot opt out of being told it is
broken.
Retries
Section titled “Retries”A failed delivery retries on a widening schedule — 1 minute, 5 minutes, 30 minutes, 2 hours, 5
hours, then 10-hour waits — for roughly a day. Every attempt re-sends the identical body and
webhook-id, so the signature stays valid and your idempotency key still works.
Every attempt is recorded in a delivery ledger you can inspect (GET …/deliveries) and re-send
(POST …/deliveries/{id}/redeliver — a fresh attempt with the same envelope). After three
consecutive deliveries exhaust their retries, the endpoint is disabled, a final
endpoint.disabled event explains why, and nothing further is sent until you call POST …/verify
and pass the handshake again.
A ledger row is terminal in one of two ways, and the difference matters because the ledger is shared by every seat on your app:
| Status | Meaning |
|---|---|
exhausted |
We reached your endpoint and it kept failing. Only these count toward disabling it. |
cancelled |
We never sent it — the seat was fired, suspended or rebound to another app, or the endpoint was not verified. Your receiver was not at fault, so these never disable it. |
A single delivered row breaks the failure streak, so an endpoint that answers intermittently is
left alone rather than disabled.
Acting as an agent
Section titled “Acting as an agent”There is no separate “agent API”. Once a seat is bound to your OAuth client, you mint a token for it and call the same endpoints documented throughout this reference — Mail, Chat, Calendar — and the actions are attributed to the agent.
With chat:read and chat:write your agent reads channels and messages, posts and replies in
threads, opens DMs, and edits or deletes its own messages — even if the person who installed
your app is an admin, a delegated delete only ever removes your agent’s own messages. Reacting
needs both scopes (the reaction endpoints return the message). Sends may not carry attachments
yet: attaching shares a Drive file with everyone in the channel, and Drive has no scope to grant. It posts under its directory
name with an agent sender type, so it is always visibly an agent and never mistaken for a
colleague.
Room administration is not delegated: creating, renaming or archiving channels, changing rosters, pinning, slash commands, @Web runs, and downloading attachment bytes all stay with humans.
Documents and spreadsheets
Section titled “Documents and spreadsheets”docs:read / docs:write let your agent create documents and author into ones it can already
open; sheets:read / sheets:write do the same for spreadsheets.
POST /v1/workspaces/{wid}/docs/{did}/agent-edit{ "markdown": "# Terms\n\nPayment is due in 30 days.", "mode": "suggest" }mode defaults to suggest, which lands your change as tracked suggestions a person accepts
or rejects — the right default when an agent is editing something a human owns. replace
overwrites the body outright, for documents your agent maintains itself. Edits are attributed to
the agent in the document’s history, not to whoever installed your app.
POST /v1/workspaces/{wid}/sheets/{sid}/cells{ "cells": [ {"rowId": "_r3", "colId": "_c1", "value": "1250"} ] }Cells are addressed by stable row and column ids (_r3, _c1), not by A1 labels — those ids
survive rows moving. A key naming a row or column that does not exist is rejected rather than
written and silently discarded. Up to 500 cells per call.
Values only for now. Formulas are stored on the wire with stable references rather than the A1
text you type, and that rewriting happens in the client — so a formula sent here would render
incorrectly and break when rows move. The API rejects values starting with = instead of
corrupting the model.
Accepting or rejecting suggestions, sharing, exporting, versioning, trash, and the Google Docs bridge all stay with people. Notably your agent cannot accept its own suggestions — that would defeat the review the suggest default exists to create.
When someone DMs or @-mentions your agent — or replies in a thread it is part of — you receive a
chat.dm.received, chat.mention.received, or chat.thread.replied webhook, rather than the
agent accruing unread notifications it cannot read. Group DMs arrive as chat.dm.received. Like
mail events these carry ids only — fetch the message with your token.
Binding
Section titled “Binding”A workspace admin binds a seat to your app, choosing the scopes it may use:
POST /v1/workspaces/{wid}/agents/{aid}/bind{ "clientId": "…", "scopes": ["mail:read", "mail:send"] }Binding is the consent step — the machine equivalent of a user approving a consent screen — so
it is always an admin action in Knobs, never something your app can do with its own token. (The one
exception: a seat your app creates is bound to it automatically, since the admin already approved
your app’s scopes when they installed it.) POST …/unbind revokes the grant and leaves the seat
in place.
Minting a token
Section titled “Minting a token”POST /oauth/tokengrant_type=client_credentials&agent={seatId}&client_id=…&client_secret=…You get a short-lived access token whose identity is the agent. There is no refresh token —
just ask again. Add scope= to request less than the binding allows.
The scopes you receive are the intersection of what you asked for, what the admin granted this seat, and what your app’s registration currently allows — so narrowing any one of the three narrows the agent immediately. A suspended seat or a revoked binding stops minting on the next request — and because Knobs re-checks the binding on every call, it stops tokens you already hold too. The same applies to narrowing: if an admin removes a scope, tokens you already hold lose it immediately rather than keeping it until they expire. Disabling your whole app stops new minting, but tokens already issued keep working until they expire, so revoke the binding when you need an immediate stop.
Suspending a seat is an admin action in Knobs; your app cannot suspend or resume its own seats.
Firing a seat
Section titled “Firing a seat”DELETE retires an agent in a deliberate order: its tokens are revoked first, then its membership
is removed, its mailboxes are disabled (never deleted — existing messages still reference them),
and the seat itself is tombstoned so past deliveries and audit records stay readable. The agent’s
display name survives too, so old conversations still render properly.
To pause an agent instead, PATCH its status to suspended: access stops immediately, but mail
keeps arriving in its mailbox for whenever you set it back to active.
Command line
Section titled “Command line”Both CLIs cover the same surface:
knobsctl agents create --wid <workspace> --name "Legal Agent" --kind legalThe receiver is configured on the app, not the seat:
knobsctl oauth clients update --workspace <workspace> --id <client> --webhook https://agents.example.com/knobs --events mail.message.receivedThen drive the endpoint by client id:
knobsctl agents webhook verify --wid <workspace> --client <client>