Skip to content

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

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.

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.

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.

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.

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.

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.

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.

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.

POST /oauth/token
grant_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.

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.

Both CLIs cover the same surface:

Terminal window
knobsctl agents create --wid <workspace> --name "Legal Agent" --kind legal

The receiver is configured on the app, not the seat:

Terminal window
knobsctl oauth clients update --workspace <workspace> --id <client> --webhook https://agents.example.com/knobs --events mail.message.received

Then drive the endpoint by client id:

Terminal window
knobsctl agents webhook verify --wid <workspace> --client <client>