Bots#
Bots are first-class identities in Ticket Board. Each bot has a username, a display name, a project allowlist, a read/write mode, and one active rotatable token. The same token authenticates the bot to both the REST API (as X-API-Key) and the MCP transport (embedded in the URL path).
Bots are not users in disguise — they are a distinct actor type with their own audit trail. Every ticket comment, wiki edit, and tool call is attributable to the specific bot that did it.
The bot model#
| Field | Purpose | Mutable later? |
|---|---|---|
username |
Stable handle. Unique per workspace. Convention: bot-<purpose>-<n> (bot-claude-1, bot-triage-1). |
No |
display_name |
Free-form display string for the UI. | Yes |
bot_mode |
read or write. Controls whether the bot can mutate state. |
Yes |
bot_project_allowlist |
Array of project UUIDs the bot may access. Empty = access to ALL projects (global-access sentinel). | Yes |
can_edit_global_wiki |
Whether the bot may edit project_id IS NULL wiki pages. Default sourced from BOT_CAN_EDIT_GLOBAL_WIKI_DEFAULT. |
Yes |
rate_limit_per_minute |
Per-bot rate-limit override. Default 60. Reserved for future per-actor enforcement; not enforced in v1 (see Rate limiting). | Yes |
is_active |
Whether the bot can authenticate at all. | Yes |
Only humans may create, edit, or revoke bots. The MCP tool catalog has no create_bot or rotate_bot_token tools — bot management is humans-only by design (see spec/03_mcp_integration.md#explicitly-excluded-from-the-bot-catalog).
Creating a bot#
From the UI#
- Navigate to
/settings/bots. - Click + New bot.
- Fill in:
- Username — convention:
bot-<purpose>-<n>. Pick something short and grep-friendly. - Display name — what appears in the UI.
- Mode —
readorwrite. Start withreadif you're unsure. - Project allowlist — multi-select of projects. Leave empty to grant access to all projects in the workspace (global-access mode). Add specific projects to restrict the bot to those projects only.
- Submit.
The response includes the bot's first token in plaintext, shown once. Copy it now — there is no recovery path. Example response:
Note: A
usagefield with pre-formatted connection snippets is planned for a future release (v1.1) but is not emitted by the backend in v1. The UI's "Save Your Token" modal generates the same snippets client-side from your token and host.
If you lose the plaintext, you must rotate (see Token rotation). The hash is irrecoverable.
From the API#
curl -s -X POST http://localhost/api/v1/bots \
-H "Content-Type: application/json" \
-H "X-Requested-By: web" \
-b "tb_session=$YOUR_SESSION" \
-d '{
"username": "bot-triage-1",
"display_name": "Triage Bot",
"mode": "read",
"project_ids": ["01H..."]
}'
Returns 201 Created with {bot: Bot, plaintext_token: "tkb_..."}. Same one-shot-plaintext rule.
The token format#
Bot tokens have a fixed shape:
tkb_is the visible prefix. It exists so leaked tokens are greppable:grep -E 'tkb_[a-z2-7]{32}'will find any token that slipped into a log or repo.- The remaining 32 base32 characters carry 160 bits of entropy from
secrets.token_bytes(20). - The first 8 chars after
tkb_are the lookup prefix, stored on thebot_tokenrow. The remainder participates only in the argon2id hash; the server never persists the plaintext.
You'll see the token prefix surface in the audit log and the bots list UI (e.g. "Active token: tkb_abc12345..."). Seeing the prefix is fine; seeing the full plaintext means something has gone wrong with secret handling.
REST vs MCP#
The same bot, the same token, two transports.
REST#
The bot puts the plaintext token in the X-API-Key header:
Every REST endpoint that humans use is also available to bots, subject to:
- The actor must be authenticated (
X-API-Keyvalid, bot active). - Read endpoints filter to the bot's project allowlist (plus global wiki pages).
- Write endpoints require
bot_mode = 'write'and the target project in the allowlist. - Admin endpoints (
/users,/audit,/bots,/webhooks) are humans-only.
CSRF (X-Requested-By: web) is not required from bots. The presence of X-API-Key is itself the intent signal; the CSRF header is browser-specific.
MCP#
The bot's MCP URL is its identity:
The token segment is the credential. The MCP middleware extracts it, resolves the actor, attaches current_actor to the MCP request context, and dispatches the tool call. There is no separate header; the URL is everything.
A bot client typically configures the URL once and posts JSON-RPC messages against the /mcp suffix. The tool catalog is available via tools/list:
curl -s -X POST -H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \
http://localhost/mcp/bot/$BOT_TOKEN/mcp
Returns the full catalog: list_projects, create_ticket, update_wiki_page, search, get_self, and the rest. See spec/03_mcp_integration.md#tool-catalog for the full list with parameter schemas.
Which transport to use#
| Use case | Recommended transport |
|---|---|
| LLM-driven workflows (Claude, ChatGPT, etc.) | MCP — designed for this. Tools have LLM-readable descriptions. |
| Shell scripts, cron jobs, glue code | REST — easier to curl, easier to grep, easier to log. |
| Bidirectional workflows (read state, decide, write state) | Either; the same identity, the same audit trail. |
Tokens are shared between the two surfaces; the same bot identity authenticates against both. Per-actor REST/MCP rate-limit buckets are not enforced in v1 (see Rate limiting).
The MCP URL is a bearer credential
Anyone with the URL has the bot's authority. Treat it like a password. Configure your reverse proxy to never log the path verbatim — Caddy's redaction is already wired in the included Caddyfile. The backend's structlog middleware redacts /mcp/bot/<redacted>/... before any log line is emitted.
Project allowlist#
The allowlist controls what the bot can see and act on. Mechanics:
bot_project_allowlistis an array of project UUIDs.- Empty array (
[]) = global access — the bot can read and write every project in the workspace, exactly like a human. - Non-empty array = the bot is restricted to only the listed project ids.
- Read endpoints filter results by
project_id IN allowlistwhen the allowlist is non-empty. A global-access bot (empty allowlist) sees all projects unfiltered. - Single-resource reads (
GET /tickets/{id}) return404 resource.not_foundwhen the allowlist is non-empty and the resource'sproject_idis outside the allowlist. We do not distinguish "doesn't exist" from "not visible to you" — same IDOR-defense pattern. Global-access bots can read any resource. - Write attempts on out-of-allowlist projects return
403 auth.forbidden(reason=project_not_in_allowlist) only when the allowlist is non-empty and misses the target project. The audit log records the attempt.
Global resources#
Some things are visible regardless of the allowlist:
| Resource | Bot visibility |
|---|---|
Global wiki pages (project_id IS NULL) |
Always readable. |
| Global wiki page edits | Gated by user.can_edit_global_wiki. See Global wiki permission. |
GET /projects |
Global-access bot: all projects. Non-empty-allowlist bot: listed projects only. |
GET /search results |
Global-access bot: all projects + global wiki pages. Non-empty-allowlist bot: listed projects + global wiki pages. |
GET /audit |
Humans only — bots receive 403. |
Editing the allowlist#
curl -s -X PATCH http://localhost/api/v1/bots/$BOT_ID \
-H "Content-Type: application/json" \
-H "X-Requested-By: web" \
-b "tb_session=$YOUR_SESSION" \
-d '{"project_ids": ["01H...", "01J..."]}'
Changes are immediate. Any in-flight tool call referencing a now-removed project fails with auth.forbidden. The next call sees the new allowlist.
Read vs write mode#
bot_mode is a coarse gate. read mode means the bot can call read tools/endpoints; write mode adds the ability to mutate state.
| Mode | Can read | Can create / update / delete |
|---|---|---|
read |
yes | no (403 auth.read_only_bot) |
write |
yes | yes (subject to allowlist) |
The check fires at two layers for defense-in-depth:
- MCP dispatch: even if a
readbot somehow knows a write tool's name, the dispatcher rejects it before the tool body runs. Logged asauth.bot.write_attempt_in_read_mode. - Service layer: every write endpoint depends on
current_bot_writer, which also rejects read-only bots.
There is no middle ground in v1 — no per-tool whitelisting, no per-project read/write asymmetry. If you need a bot that can only manage one specific kind of thing in one specific project, scope it to that project and trust the audit trail to surface misuse.
Global wiki permission#
The can_edit_global_wiki field gates bot writes to wiki pages with project_id IS NULL. It's a separate field because global pages affect everyone — a misconfigured bot editing the workspace's onboarding page is a different blast radius than editing a single project's wiki.
| Setting | Value | Meaning |
|---|---|---|
user.can_edit_global_wiki |
false (default for new bots) |
Bot may read global wiki pages but writes return 403 auth.forbidden (reason=bot_cannot_edit_global_wiki). |
user.can_edit_global_wiki |
true |
Bot may create, update, and delete global wiki pages. |
BOT_CAN_EDIT_GLOBAL_WIKI_DEFAULT (env var) |
false (default) |
Default can_edit_global_wiki for newly-created bots. Changing this does not affect existing bots. |
Toggle per-bot via PATCH /bots/{id} with {"can_edit_global_wiki": true}. Humans always bypass this check.
Editing a bot#
curl -s -X PATCH http://localhost/api/v1/bots/$BOT_ID \
-H "Content-Type: application/json" \
-H "X-Requested-By: web" \
-b "tb_session=$YOUR_SESSION" \
-d '{
"display_name": "Triage Bot (Claude 4.5)",
"mode": "write",
"project_ids": ["01H...", "01J..."],
"can_edit_global_wiki": false,
"is_active": true
}'
Fields you cannot change: username, type. The token is not part of this payload — rotation is its own endpoint.
Every PATCH produces a bot.updated audit event with a changes payload describing the diff. Useful when reviewing "why does this bot now have access to INFRA?"
Token rotation#
Rotation is the standard "I think the token may have leaked" response. The mechanics:
curl -s -X POST http://localhost/api/v1/bots/$BOT_ID/tokens/rotate \
-H "X-Requested-By: web" \
-b "tb_session=$YOUR_SESSION"
What happens server-side:
- The current active token row is updated:
is_active = false,rotated_at = now(). - A fresh token plaintext is generated, hashed, and inserted as the new active row.
- The plaintext is returned in the response once.
- An audit event
bot.token.rotatedlands with the old and new token ids and the new prefix.
There is no grace period in v1. The instant the new token is issued, the old one is rejected. Reconfigure the bot client before rotating, or be ready to reconfigure it the moment the new token comes back.
If a grace period becomes important (e.g. fleet rotations across many bots), the spec documents the future shape — a superseded_at column distinct from rotated_at with a configurable window. Not implemented in v1.
Token revocation#
Revocation is rotation without issuing a new token. The bot becomes unable to authenticate at all.
curl -s -X POST http://localhost/api/v1/bots/$BOT_ID/tokens/revoke \
-H "X-Requested-By: web" \
-b "tb_session=$YOUR_SESSION"
The current active token row gets revoked_at = now(), is_active = false. No new token is issued. The bot's MCP URL and any saved X-API-Key start returning 401 auth.invalid_credentials.
Use revocation when:
- The token has been leaked and you don't have a configured client to push the new token to immediately.
- You want to take the bot offline without removing its identity or audit history.
- You want to keep the audit trail clean while you investigate.
The bot is still in the database. Its history is intact. Calling rotate creates a fresh active token and the bot is back online.
Deactivating and deleting#
| Action | Endpoint | Effect |
|---|---|---|
| Deactivate | DELETE /api/v1/bots/{id} (the HTTP verb is misleading) |
user.is_active = false. All active tokens become unusable (the auth check fails on is_active). The bot stays in the database; its audit trail survives forever. |
| Reactivate | PATCH /api/v1/bots/{id} with {"is_active": true} |
The bot can authenticate again. Existing tokens that weren't revoked are usable. |
| Hard delete | — | Not in v1. Audit attribution means bots are never hard-deleted; the only way to "remove" one is to deactivate. |
Deactivation is the right tool when you're decommissioning a bot. The username stays reserved (because of the unique constraint); pick a new username for the replacement.
Rate limiting#
In v1 the only enforced rate-limit bucket is the per-IP / per-username login sliding window (see Troubleshooting → Login fails). Per-actor REST and MCP throttling is planned, not enforced — no middleware emits X-RateLimit-* headers and the user.rate_limit_per_minute column is reserved for the future shape.
The bot model still carries a rate_limit_per_minute field (default sourced from DEFAULT_RATE_LIMIT_PER_MINUTE). PATCHing it via PATCH /bots/{id} with {"rate_limit_per_minute": 120} stores the value but does not change runtime behavior in v1 — the column is wired up so existing bot configurations carry forward when per-actor enforcement lands.
If a misbehaving bot floods the API in v1, the levers are: revoke its token, deactivate it (is_active=false), or remove its project from the allowlist. Bucket-based throttling is on the roadmap, not in v1.
Where bots show up in the UI#
| Surface | What you see |
|---|---|
/settings/bots |
List of bots: username, mode, project allowlist count, active token prefix, last-used timestamp, status pill. |
/settings/bots (detail row expand) |
Token issued-at, last rotated-at, revoked-at if applicable. |
/settings/bots → row actions → MCP Details |
Bot metadata plus all three connection snippets (MCP + REST). Paste your saved token to fill the URLs — nothing is sent to the server, token clears on close. For existing bots without requiring a rotation. |
| Ticket detail activity feed | <bot-display-name> commented on PROJ-12 etc., with the bot's avatar (initials). |
/audit |
Filter by actor_user_id = <bot id> to see everything a bot did. |
/audit/tools |
Per-call tool-invocation log: which tool, what args (redacted), what duration, what result. |
The bots list is humans-only. Bots have no FE.
Audit attribution#
Every bot action lands as one or more audit_event rows with actor_user_id = <bot id> and actor_kind = 'bot'. MCP tool calls additionally produce:
- One
tool_invocation_logrow per call (tool_name, redactedarguments_json,result_status,duration_ms). - One
audit_eventrow withevent_type = 'bot.tool_invoked'pointing at the log via FK.
So a single MCP create_ticket call produces three rows:
audit_eventfor the domain event (ticket.created,category='activity').tool_invocation_logfor the per-call detail.audit_eventfor the tool-invoked pointer (category='audit_only').
All three share the same request_id. Filter /audit?request_id=<uuid> to see the lot in one query, or visit /audit/tools to drill into the tool-call detail directly. See Audit log for the full taxonomy.
Common questions#
Can a bot create another bot? No. Bot management is humans-only. There is no create_bot MCP tool.
Can a bot see the audit log? No. GET /audit and GET /audit/tools reject bots with 403 auth.forbidden. If a future workflow needs programmatic audit access, that gets a separate auth scope; not in v1.
Can a bot read another bot's tokens? No. GET /bots is humans-only. A bot can call get_self over MCP to see its own identity and allowlist; it cannot enumerate other bots.
What happens if a bot's token leaks? Revoke it immediately (POST /bots/{id}/tokens/revoke). The audit log is your forensic surface: filter by the token prefix in auth.bot.success payloads to see what the leaked token did. If you need the bot back online, rotate (which issues a fresh token) and reconfigure the client.
How do I migrate from MCP to REST (or vice versa)? You don't — both transports use the same token. Change your client's calling pattern; nothing on the server side needs to know.
Can I share a token between two bot processes? Technically yes (it's just a credential). Practically no — the audit trail will conflate the two processes, the rate-limit bucket is shared, and a leak from one taints both. Create a second bot and give each its own token.
What does get_self return? The bot's own identity: {user_id, username, display_name, mode, project_allowlist}. Useful for LLM agents to reason about their own constraints without inferring from failed calls. See spec/03_mcp_integration.md#get_self.
API surface recap#
| Endpoint | Verb | Purpose |
|---|---|---|
/api/v1/bots |
GET | List bots (humans only). |
/api/v1/bots |
POST | Create a bot; returns plaintext token once. |
/api/v1/bots/{id} |
GET | Bot detail. |
/api/v1/bots/{id} |
PATCH | Update display name, mode, allowlist, can_edit_global_wiki, rate_limit_per_minute, is_active. |
/api/v1/bots/{id} |
DELETE | Deactivate (is_active=false). Not a hard delete. |
/api/v1/bots/{id}/tokens/rotate |
POST | Issue new token, deactivate old. Plaintext returned once. |
/api/v1/bots/{id}/tokens/revoke |
POST | Revoke active token without issuing a new one. |
Full details: spec/02_api_design.md#bots and spec/04_auth_and_identity.md#bot-tokens.
Configuring your MCP client#
When you create or rotate a bot token, the Save your API token modal pre-fills these snippets with your actual token and host. You can also return to the snippets at any time via the MCP Details option in a bot row's actions menu — paste your saved token into the dialog to fill the URLs without requiring a token rotation.
Claude Desktop / Claude Code#
Add the following block to your Claude Desktop config file. The token is already embedded in the URL — no separate header is needed.
Config file locations:
| OS | Path |
|---|---|
| macOS | ~/Library/Application Support/Claude/claude_desktop_config.json |
| Windows | %APPDATA%\Claude\claude_desktop_config.json |
| Linux | ~/.config/claude/claude_desktop_config.json |
Replace your-host with the hostname where Ticket Board is running (e.g. tickets.example.com or localhost). Replace <plaintext_token> with the token shown at create/rotate time.
Generic MCP client#
Any client that speaks JSON-RPC 2.0 over Streamable HTTP can connect:
URL: https://your-host/mcp/bot/<plaintext_token>/mcp
Accept: application/json, text/event-stream
Content-Type: application/json
The endpoint is stateless — each POST receives a synchronous JSON-RPC response. No persistent connection or session cookie is required.
REST API#
The same token works as an X-API-Key header against the REST surface:
The same authorization rules apply — your bot's read/write mode and project allowlist are enforced identically across both transports.
The MCP URL is a bearer credential
Treat the full URL (and the token inside it) like a password. Never commit it to version control. Never send it over plain HTTP in production. If you suspect it leaked, rotate immediately from Settings → Bots using the row action menu. See also Auth and identity for the rationale.
The token modal shown at create and rotate time pre-fills all three snippets above with your actual host and token, so you can copy-paste directly.
Next#
- See MCP server for the full tool catalog, error mapping, and worked examples.
- Set up Webhooks if you want events fanned out to other systems.
- Use Audit log to review what bots actually did.
- If something looks wrong, head to Troubleshooting.