| read_when |
|
|---|
ClickClack bots are first-class chat identities. They use the same message, thread, DM, upload, search, and realtime surfaces as humans, but their credentials and permissions are explicit, scoped, revocable, and visible in the UI.
This spec covers two bot shapes:
- Service bot: an independent workspace member, not owned by a human. Use
for shared infrastructure agents such as
openclaw,deploy-bot, ortriage-bot. - User bot: a sub-identity owned by a human user. Use for "Peter's OpenClaw bot", personal assistants, or automation that should be visibly attached to a real person and limited to a subset of that person's access.
Both shapes are users with kind=bot. The difference is ownership and maximum
permission source.
- Humans and bots are distinguishable in API responses and UI.
- Bots can post, reply, read, and subscribe without browser cookies or magic link human sessions.
- User-owned bots cannot exceed their owner's permissions.
- Service bots can exist without a human owner, but only through explicit workspace membership and scoped tokens.
- Token scopes are narrow enough for OpenClaw channel extensions and CI agents.
- OpenClaw can run as one service bot and as one user-owned bot in the same ClickClack workspace.
- Crabbox can prove the full path with a desktop/browser/WebVNC demo.
- OAuth app marketplace.
- Slack-compatible app manifests.
- Per-channel ACLs beyond the scopes below.
- Multi-tenant hosted billing or app review.
- Letting bot tokens impersonate arbitrary humans.
users grows identity metadata:
kind TEXT NOT NULL DEFAULT 'human';
owner_user_id TEXT REFERENCES users(id) ON DELETE CASCADE;Rules:
kindis eitherhumanorbot.- A human has
owner_user_id = NULL. - A service bot has
kind = botandowner_user_id = NULL. - A user bot has
kind = botandowner_user_id = <human user id>. - A bot can have its own
display_name,handle, andavatar_url. - A bot may not own another bot.
- Deleting a human owner revokes/deletes user-owned bots and tokens.
messages.author_idstays a plainusers.id. Deleting a bot retires that immutable ID instead of reassigning it, so old messages keep rendering while the former handle becomes available to a new bot ID.
API User payloads include:
Historical payloads for a deleted bot clear handle and add former_handle
plus deleted_at. A newly created bot that reuses the handle has a different
ID and does not inherit the deleted marker.
UI:
- Profile panes show a
Botbadge for all bots. - User-owned bot profiles show
Bot of <owner>; the current UI uses the owner user ID until owner-profile hydration lands. - Service bot profiles show
Service bot. - Sidebar People can either include bots with badges or split into
PeopleandBots; the API must expose enough metadata for either UI. - Historical messages, threads, quotes, search results, and DMs show the former
handle with a
deleted botmarker. Deleted identities are not profile or mention targets.
Bot tokens are bearer credentials with a ccb_ prefix. The server stores only a
SHA-256 hash.
bot_tokens (
id TEXT PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
bot_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
name TEXT NOT NULL,
scopes_json TEXT NOT NULL,
created_by TEXT REFERENCES users(id),
created_at TEXT NOT NULL,
last_used_at TEXT,
revoked_at TEXT
)Rules:
- Raw token is returned once on creation.
- Token auth resolves to the bot user, never to the owner.
owner_user_idon the token is copied from the bot at creation for audit.- Bundle names are expanded to concrete scopes when a token is issued. Bundle
changes are not applied retroactively; rotate or mint a token to gain a newly
added scope such as
commands:write. - Revoked tokens fail auth.
- Tokens update
last_used_atafter successful auth. - Tokens are workspace-scoped. A token cannot access another workspace even if the bot user is later added there.
- A bot may have multiple tokens for different runtimes.
MVP scopes:
workspaces:readchannels:readchannels:writemessages:readmessages:writeagent_activity:write(explicit only; excluded from allbot:*bundles)threads:readthreads:writedms:readdms:writerealtime:readuploads:writeprofile:readcommands:write
Derived bundles:
bot:read= workspace/channel/message/thread/DM/realtime read scopes.bot:write= read scopes plus message/thread/DM/upload writes andcommands:write.bot:admin= all MVP scopes including channel creation/update.
Enforcement:
- Human sessions keep today's membership-based behavior.
- Bot-token requests check membership, token workspace, and scope.
- User-owned bots additionally require the owner to still be a member of the target workspace.
- Service bots require their own workspace membership.
- Store-level membership remains the final data access guard.
MVP endpoint mapping:
GET /api/me:profile:readGET /api/workspaces*:workspaces:readGET /api/workspaces/{id}/channels:channels:readPOST/PATCH channel endpoints:channels:writeGET channel/DM/thread messages: matching read scopePOST channel/DM/thread messages: matching write scope- Durable
agent_commentaryandagent_toolchannel/DM messages additionally require a bot token withagent_activity:write; human sessions and ordinary bot bundles cannot publish them. GET /api/realtime/eventsand/ws:realtime:readPUT /api/bots/self/commands: bot tokens only, withcommands:writeGET /api/workspaces/{id}/bot-commands:workspaces:readPOST /api/uploads:uploads:writePOST /api/messages/{id}/attachments:uploads:writeandmessages:writePOST /api/realtime/ephemeral:messages:write; theagent.progressevent type additionally requires a bot token and exactly one concrete target (channel_idordirect_conversation_id), never workspace-wide. DM progress also requiresdms:write.PATCH /api/me: human sessions only; bot tokens cannot mutate profiles.
CLI MVP:
clickclack admin bot create \
--workspace wsp_... \
--created-by usr_manager \
--name "OpenClaw Service" \
--handle openclaw \
--scopes bot:write \
--plain
clickclack admin bot create \
--workspace wsp_... \
--owner usr_peter \
--created-by usr_peter \
--name "Peter's OpenClaw" \
--handle peter-openclaw \
--scopes bot:write \
--plainPlain output returns the raw token only. JSON output returns {bot, token, bot_token}.
Service bot creation requires --created-by to be a workspace owner or
moderator. User-owned bot creation requires --created-by to match --owner;
workspace managers cannot mint or rotate tokens for someone else's user-owned
bot.
For the practical install flow, including Docker commands and OpenClaw configuration, see Bot installs.
HTTP API:
POST /api/workspaces/{workspace_id}/botsGET /api/workspaces/{workspace_id}/botsDELETE /api/workspaces/{workspace_id}/bots/{bot_user_id}/membershipGET /api/workspaces/{workspace_id}/bots/{bot_user_id}/tokensPOST /api/workspaces/{workspace_id}/bots/{bot_user_id}/tokensPOST /api/workspaces/{workspace_id}/bots/{bot_user_id}/setup-codesPOST /api/bot-setup-codes/claimDELETE /api/bots/{bot_user_id}GET /api/bots/{bot_user_id}/tokensPOST /api/bots/{bot_user_id}/tokensPOST /api/bot-tokens/{token_id}/revokeGET /api/me/botsPUT /api/bots/self/commandsGET /api/workspaces/{workspace_id}/bot-commands
The creation and token-management API requires a human session. Bot tokens can
read and write through the normal chat APIs according to scope, but cannot mint,
list, or revoke bot tokens. The workspace-scoped token routes are preferred;
the legacy /api/bots/{bot_user_id}/tokens routes only work when the bot is
installed in exactly one workspace.
POST /api/workspaces/{workspace_id}/bots returns {bot, bot_token}. The
bot_token.token field is the one-time raw ccb_... token and is never
returned by list calls. Passing "initial_token": false skips the initial
token mint entirely — the response then contains only {bot} — for
setup-code installs where the token is minted at claim time.
setup_nonce works for both modes: tokenless retries return the same bot,
while token-mode retries also reuse the token row and rotate its one-time
plaintext value. GET /api/workspaces/{workspace_id}/bots returns
{bots: [{bot, tokens}]} with redacted token metadata for workspace members.
Raw token values are never returned by list calls. Rotation is create-new, move
the runtime, then revoke-old through
POST /api/bot-tokens/{token_id}/revoke.
Setup codes hand a token to an installer without the plaintext ever passing
through a clipboard or shell history. A setup code is a pending token grant:
POST /api/workspaces/{workspace_id}/bots/{bot_user_id}/setup-codes (same
authorization as token creation, human session required) returns a one-time
plaintext code (XXXX-XXXX-XXXX, 10-minute expiry) while storing only its
hash. No bot token exists yet.
The installer claims the code with the unauthenticated, rate-limited
POST /api/bot-setup-codes/claim ({"code": "XXXX-XXXX-XXXX"}). The claim
atomically consumes the code and mints the bot token at that moment,
returning {token, bot, workspace, defaults, contract_version, api_base_url}
with the one-time raw token,
minimal bot and workspace identity, and a suggested defaultTo channel when
one exists. Codes are single use;
unknown, expired, and already-claimed codes all answer with the same 404.
Re-minting a code for the same bot and token name replaces the pending code,
and removing or deleting the bot invalidates its pending codes. An expired,
unclaimed code never creates a token.
The web app asks for one connect method up front, so exactly one credential
exists per install. Choosing the recommended setup code creates the bot with
initial_token: false and the reveal panel shows only the one-liner
openclaw channels add clickclack --code "https://server/#XXXX-XXXX-XXXX"
with a countdown and one-click regeneration after expiry — the token is
minted when the code is claimed. Codes created by the Integrations wizard
also carry its selected defaultTo, allowFrom, and agentActivity values
so the claiming client can write the same configuration. Generic codes fall
back to the workspace's active default channel when no defaultTo was
captured. Choosing the manual token mints the raw token immediately and no
setup code is created. If a code expires unclaimed, the bot row's "New setup
code" action mints a fresh one without recreating the bot.
When a trusted canonical API base is configured, mint responses add the
versioned fields contract_version: 1, claim_url, and api_base_url.
claim_url is the exact endpoint; consumers must not append a fixed API path.
The claim response repeats contract_version and api_base_url so installers
can persist the server-selected base, including any path prefix. Remote URLs
come only from validated administrator configuration. Same-origin setup
commands retain the legacy https://server/#CODE shape for installed clients;
split-origin and path-mounted commands use https://api/.../claim#CODE.
Bots publish command discovery metadata with
PUT /api/bots/self/commands. The endpoint accepts bot tokens only, requires
commands:write, and derives the bot user and workspace from the token. It
atomically replaces that bot's full menu; {"commands":[]} clears it.
Commands accept an optional leading slash and are stored in lowercase canonical
form such as /status. A menu can contain at most 100 unique commands.
Descriptions are required and limited to 100 characters; optional args_hint
values are also limited to 100 characters. Any validation failure leaves the
previous menu unchanged.
Workspace members read the merged bot menus through
GET /api/workspaces/{workspace_id}/bot-commands. Bot tokens need
workspaces:read and must be bound to that workspace. Results embed the bot's
ID, handle, display name, and avatar, and are sorted by bot handle then command.
Removing a bot from a workspace deletes its menu in the same transaction.
The TypeScript SDK exposes these endpoints as
client.bots.setCommands(commands) and
client.bots.listCommands(workspaceId).
Bot command menus are discovery metadata only. This backend contract does not change dispatch or implement the web composer merge. The web integration's precedence rule is that an HTTP-registered slash command wins when the same name exists in both systems; bot-declared and unknown commands continue through normal plain-message delivery. There is no cross-system uniqueness constraint.
Workspace owners and moderators can remove any bot from a workspace with
DELETE /api/workspaces/{workspace_id}/bots/{bot_user_id}/membership; this
removes the workspace membership and revokes that bot's tokens for that
workspace. The bot user row remains for history and future installs.
Deleting a bot is a separate global action through
DELETE /api/bots/{bot_user_id}. It revokes all tokens, app installations,
slash commands, event subscriptions, command menus, and workspace memberships
for that bot in one transaction, along with connected-account bindings owned by
the bot. It then records a tombstone and releases the active handle. User-owned
bots can be deleted only by their owner. Service bots require the requester to
be an owner or moderator in every workspace where the bot still has active
resources. If the bot is already orphaned with no active resources, deletion
falls back to every workspace found in its retained token, integration,
message, and DM history so an ordinary member cannot retire the global
identity.
Deletion returns {deleted_bot: {id, display_name, former_handle, deleted_at}}.
Repeating it returns 404; it never targets a replacement bot that later
reuses the former handle because deletion is always by immutable bot ID.
Long-running bots should:
- Authenticate with
Authorization: Bearer ccb_.... - Resolve workspace/channel IDs through normal APIs.
- Backfill durable events through
/api/realtime/events?after_cursor=.... - Connect to
/api/realtime/ws?workspace_id=...&after_cursor=.... - Persist the latest cursor after each processed event.
- Ignore events authored by their own
bot_user_id. - Post replies through normal message/thread/DM endpoints.
The TypeScript SDK exposes ClickClackBot, a light runner around
ClickClackClient and the realtime WebSocket:
const bot = new ClickClackBot({
baseUrl,
token,
workspaceId,
afterCursor,
onEvent: async (event, client) => {
if (event.type === "message.created" && event.channel_id) {
await client.channels.sendMessage(event.channel_id, { body: "ack" });
}
},
});
bot.start();Runtimes are still responsible for reconnect backoff, cursor persistence, and own-message filtering.
OpenClaw should ship a ClickClack channel extension that treats ClickClack as a normal chat transport.
Channel id: clickclack.
Config:
{
"channels": {
"clickclack": {
"baseUrl": "https://app.clickclack.chat",
"token": "$CLICKCLACK_BOT_TOKEN",
"workspace": "clickclack",
"defaultTo": "channel:general",
"allowFrom": ["*"],
},
},
}Target grammar:
channel:<name-or-id>dm:<user-id-or-handle>thread:<message-id>
Inbound:
- The extension subscribes to ClickClack realtime events.
message.createdevents become OpenClaw inbound turns.- Events authored by the configured bot user are ignored.
- Channel messages map to OpenClaw group/channel turns.
- DMs map to direct turns.
- Thread replies preserve the source thread/message ID.
Outbound:
- OpenClaw replies post through ClickClack message/thread/DM endpoints.
- Thread context routes back to the originating ClickClack thread when present.
- The extension stores the latest realtime cursor per account.
The live proof must configure two ClickClack accounts:
openclaw-service: independent service bot.peter-openclaw: user-owned bot with Peter as owner.
Success criteria:
- ClickClack tests pass locally and in CI-shaped gates.
- OpenClaw extension tests pass in the chosen OpenClaw checkout.
- A Crabbox managed Linux lease is created with
--desktop --browser. - ClickClack runs with bot identities and tokens.
- OpenClaw runs with an OpenAI API key and both ClickClack bot accounts.
- WebVNC opens the browser to ClickClack.
- The visible chat shows both bots distinctly:
- one service bot
- one bot of Peter
- A screenshot is captured with
crabbox screenshot. - The screenshot is inspected and confirms the expected chat/browser state.
Preferred Crabbox flow:
crabbox warmup --desktop --browser
crabbox run --id <lease> -- ./scripts/run-clickclack-openclaw-bot-smoke.sh
crabbox desktop launch --id <lease> --browser --url http://127.0.0.1:8080/app --webvnc --open
crabbox screenshot --id <lease> --output .artifacts/clickclack-bots-webvnc.pngSecrets:
- Load
OPENAI_API_KEYfrom the local environment or approved secret source. - Do not print bot tokens, session tokens, or OpenAI keys.
- Store raw bot tokens only in ephemeral Crabbox env files for the live test.
- Add user kind/owner migration and backfill all existing users as humans.
- Add bot token table and store methods.
- Extend auth resolution to return actor metadata and token scopes.
- Gate bot-token requests by scope in HTTP handlers.
- Add CLI bot creation.
- Add
kind/owner_user_idto OpenAPI, SDK, and UI. - Add SDK bot runner and example.
- Add OpenClaw ClickClack channel extension.
- Add local and Crabbox live tests.
{ "id": "usr_...", "kind": "bot", "owner_user_id": "usr_owner...", // omitted for humans and service bots "display_name": "Peter's OpenClaw", "handle": "peter-openclaw", }