ClickUp tracker adapter¶
Bernstein can pull work directly from a ClickUp list, post progress comments back to the underlying task, and move tasks between statuses when work transitions. The adapter also supports per-step CLI choice: a custom field can name the CLI adapter (claude, codex, aider, ...) Bernstein should pick for each ticket.
The adapter implements bernstein.core.trackers.AbstractTrackerAdapter and lives under bernstein.core.trackers.builtin.clickup_adapter. It is disabled by default and must be opted in via bernstein.yaml.
It talks to the public ClickUp REST API v2 directly rather than the ClickUp MCP server, which keeps the call surface predictable and avoids paying the MCP metering bill.
Auth¶
ClickUp accepts a personal API token in the Authorization header. Set the environment variable named by token_env and reference it from bernstein.yaml:
| Environment variable | Purpose |
|---|---|
CLICKUP_API_TOKEN | Personal API token (default env name). |
trackers:
clickup:
list_id: "9012345"
workspace_id: "1234567"
space_id: "8901234"
auth:
token_env: CLICKUP_API_TOKEN
OAuth bearer tokens work too; pass the full token string in the environment variable and the adapter forwards it as-is.
Status mapping¶
status_map maps canonical Bernstein statuses to the list's display status names. ClickUp status names are case-sensitive and per-list:
transition(ticket_id, status_id) resolves the target name through status_map first, then sends it on the wire.
Per-step CLI choice (worked example)¶
Add a custom field to the list (single-select or short-text) with values claude, codex, aider. Capture the field id from the ClickUp UI or the /list/{list_id}/field endpoint, then point the adapter at it:
trackers:
clickup:
list_id: "9012345"
cli_choice_custom_field_id: "1f0c9c8d-c0ff-ee00-1234-deadbeefcafe"
auth:
token_env: CLICKUP_API_TOKEN
The adapter exposes the field value on the ticket dataclass:
from bernstein.core.trackers.builtin import (
ClickUpAdapter,
ClickUpConfig,
)
config = ClickUpConfig(
list_id="9012345",
status_filter="to do",
cli_choice_custom_field_id="1f0c9c8d-c0ff-ee00-1234-deadbeefcafe",
token_env="CLICKUP_API_TOKEN",
)
with ClickUpAdapter(config) as adapter:
for ticket in adapter.pull_open_tickets():
cli = ticket.routing_hint.cli # "claude" / "codex" / "aider" / None
# hand cli + ticket.id to the orchestrator's routing layer
When the orchestrator's routing layer sees a non-empty ticket.routing_hint.cli, it pins the spawned CLI for that ticket.
Comments and transitions¶
add_comment(ticket_id, body)posts to/task/{task_id}/comment. Whenidempotency_keyis set, the key is appended as an HTML marker so re-tries stay traceable without duplicating the visible body.transition(ticket_id, status_id)issuesPUT /task/{task_id}with the resolved status name.
Rate limiting¶
- A cooperative per-adapter token-bucket throttles outbound calls when
rate_limit_min_intervalis set. Size it per plan tier:
| Plan | Suggested rate_limit_min_interval |
|---|---|
| Free | 0.6 s (about 100 rpm) |
| Unlimited | 0.06 s (about 1000 rpm) |
| Enterprise | 0.006 s (about 10000 rpm) |
- Server-side
429responses are translated intoRateLimited(retry_after=...). The hint is taken fromRetry-Afterfirst, thenX-RateLimit-Reset. - ClickUp also returns
{"err": "...", "ECODE": "RATE_..."}in a200body when a soft limit is exceeded. The adapter surfaces those asRateLimitedtoo.
Exceptions¶
| Exception | When |
|---|---|
TrackerUnavailable | 5xx, missing token, error payload from ClickUp. |
RateLimited | 429 or inline RATE_... ECODE. |
OptimisticConcurrencyError | 412 Precondition Failed on a write. |
Out of scope¶
- ClickUp Docs (this adapter is tasks-only).
- ClickUp MCP server consumer mode (separate ticket if requested).
- Webhooks. Pair this adapter with the existing webhook trigger-source subsystem if you need push-style updates instead of polling.