Skip to content

Integration & Events

How other apps, agents, and scripts integrate with Cards: observe changes, dispatch and complete work, coordinate multi-step flows, and react to time/threshold conditions. This is the "API first, the UI is one view" contract.

Status legend: [built] exists today · [proposed] designed here, not yet implemented. See index.md for the normative contract of built features and index.md for the runtime.

No client library exists yet in any language — every snippet below is raw HTTP against the REST API. All writes require an actor: set the X-Work-Cards-Actor header on every POST/PATCH (or set the CARDS_USER env var). For retried writes, set Idempotency-Key to a unique value so a duplicate is safely replayed. See implementation-status.md §2/§5 for exact request/response shapes and the full actor model — this doc does not repeat wire-level detail.

Quickstart (Node) [built]

Cards emits an event on every state change, on a single stream, so your app reacts to work instead of polling for it. There are three ways in.

1. Catch up — the feed. A cursor-paged log of what happened; use it on startup to replay what you missed.

const res = await fetch(`http://127.0.0.1:8787/v1/events?board_id=engineering&since=${lastSeen}`);
const { items: events } = await res.json();  // items: [{ id, type, actor, at, card_id, diff }, …]

2. React live — the SSE stream. A long-lived connection, filtered by card, board, or type. ?card_id= is "watch this card"; ?types=status_changed is "watch transitions".

import { EventSource } from "eventsource"; // or browser-native

const es = new EventSource(
  "http://127.0.0.1:8787/v1/events/stream?board_id=engineering&types=card_created,status_changed,comment_added"
);
es.addEventListener("status_changed", (e) => {
  const evt = JSON.parse(e.data);
  if (evt.diff.after === "review") runReviewBot(evt.card_id);
});

Durable events carry an SSE id, which EventSource sends back as Last-Event-ID on reconnect. The server performs a bounded replay; use the paged feed for authoritative catch-up rather than treating automatic reconnect as a gap-free guarantee.

3. The worker loop. Events pair with the claim API to pull work, do it, and write results back:

const API = "http://127.0.0.1:8787";
const headers = { "Content-Type": "application/json", "X-Work-Cards-Actor": "alice" };

es.addEventListener("card_created", async (e) => {
  // Claim the oldest unowned matching card (POST /v1/cards/take-next)
  const claimRes = await fetch(`${API}/v1/cards/take-next`, {
    method: "POST",
    headers: { ...headers, "Idempotency-Key": crypto.randomUUID() },
    body: JSON.stringify({ type_id: "programming-task", status: "in_progress" }),
  });
  if (!claimRes.ok) throw new Error(`claim failed: ${claimRes.status}`);
  const { card } = await claimRes.json();   // response: { card: Card | null }
  if (!card) return;

  await doWork(card);

  // Attach a comment (POST /v1/cards/{id}/comments)
  const commentRes = await fetch(`${API}/v1/cards/${card.id}/comments`, {
    method: "POST",
    headers: { ...headers, "Idempotency-Key": crypto.randomUUID() },
    body: JSON.stringify({ body: "done ✓" }),
  });
  if (!commentRes.ok) throw new Error(`comment failed: ${commentRes.status}`);
  const commentedCard = await commentRes.json();

  // Every mutation increments version, so advance from the comment response.
  const advanceRes = await fetch(`${API}/v1/cards/${card.id}`, {
    method: "PATCH",
    headers: { ...headers, "Idempotency-Key": crypto.randomUUID() },
    body: JSON.stringify({ version: commentedCard.version, status: "review" }),
  });
  if (!advanceRes.ok) throw new Error(`advance failed: ${advanceRes.status}`);
});

Mutation events (above) and condition events (status_timeout, wip_exceeded, …) arrive on the same stream, so this consumer code does not change between mutation and condition handling. The rest of this document is the design contract; for exact request/response wire shapes of every [built] endpoint, see implementation-status.md §2/§4.

Three planes

  • Observe — learn what changed: the event stream (live), the events feed (catch-up/audit), and webhooks (push).
  • Act — change state: claim work, attach results (comments / work-log / files), advance status, upgrade schema.
  • Coordinate — compose: transition graphs, dependency links, and a class of condition events (timeouts, WIP, empty lanes) that turn implicit state into signals.

A guiding line from philosophy.md: Cards emits signals; the integrator owns the response. Cards is not a workflow engine — it never acts on a condition. It tells you a card sat in review too long; you decide to escalate. This keeps policy in your domain and mechanism in the core.

The event model

Every event has id, type, actor, at, diff, and a scope. Card events are card-scoped (card_id always set); board-scoped events for board-level conditions are already built.

There are two origins of events:

Card mutation events — emitted on a write [built]

A direct, synchronous consequence of an API call. Always card-scoped.

Event Fires when
card_created a card is created
card_deleted a card is deleted (tombstone; rides along on --state-only export)
status_changed status moves
field_updated a scalar field changes
owner_changed owner set/cleared
tags_changed tags added/removed
item_appended / item_updated / item_removed a repeating-field entry changes
link_added / link_removed a link changes
comment_added / comment_edited a comment changes
artifact_added a file is attached to an artifact field
schema_upgraded a card is re-pinned to a new schema version

Definition lifecycle signals are also built, but are not card mutations: definition_reloaded is published per affected board after POST /v1/workspace/reload or serve --watch; definition_reload_failed reports that reload kept the last-good definitions and drives the UI banner.

Condition events — emitted when a declared threshold crosses [built]

Not tied to a single write. Declared as monitors (below) and emitted by the core's evaluator. Two trigger kinds:

Instant (evaluated synchronously right after the mutation that could trip them):

Event Scope Fires when
wip_exceeded / wip_cleared board a column's card count rises above / falls back under its WIP limit
lane_drained / lane_refilled board a watched column's matching count hits 0 / recovers
card_blocked / card_unblocked card a card's open-blocker set becomes non-empty / empties
transition_rejected card an enforced transition is attempted and refused (opt-in)

Temporal (nothing mutates at the deadline — see the deadline scheduler below):

Event Scope Fires when
status_timeout card a card has been in a status longer than its declared max
card_idle card no event on a card for longer than the idle threshold

Condition events flow onto the same bus as mutation events, so every consumer below receives them identically. Unlike mutation events they are ephemeral and derived — see Ephemeral signals.

Monitors — declaring conditions [built]

Monitors are data, not code (schema-is-the-process). Declared per board, they tell the core which condition events to emit. WIP caps sit on the board itself (wip_limits) rather than inside monitors, because they also shape board UI; every other watcher lives under monitors:

// definitions/boards/engineering.json
{
  "wip_limits": { "in_progress": 5, "review": 3 },   // → wip_exceeded / wip_cleared
  "monitors": {
    "max_time_in_status": { "in_progress": "8h", "review": "2d" }, // → status_timeout
    "idle_after":         "3d",                                    // → card_idle
    "alert_when_empty":   ["todo"],                                 // → lane_drained / lane_refilled
    "emit_rejections":    true                                      // → transition_rejected
  }
}

card_blocked / card_unblocked need no declaration — they fire from open blocked-by / depends-on link sets on any board.

Durations use Go-style strings plus a d (days) suffix ("8h", "2d" → normalized via ParseMonitorDuration). The core emits the matching event once per crossing (idempotent): it tracks the last-emitted state per (board, column, condition) and per (card, status-entry) so a condition that stays tripped does not re-fire. The inverse event (wip_cleared, lane_refilled, card_unblocked) fires when it recovers.

The core only emits — it does not promote cards, reassign owners, or move status in response. That is the integrator's job (see Coordinate).

Escalating a condition to a durable fact

By default condition events are ephemeral signals. To also append them to the event log (audit / restart-safe replay), list the types on the workspace, not the board:

// definitions/workspace.json
"settings": {
  "persist_conditions": ["wip_exceeded", "status_timeout"]
}

Escalation is per event type, workspace-wide. Bus delivery follows the same path, but persisted events receive a durable id and appear in the feed; ephemeral signals have no replay cursor. See core.md §11.2.

A temporal event, step by step

max_time_in_status: { review: "8h" }. A card enters review at T (a status_changed event with at=T); its deadline is T + 8h, computed, not discovered. At the deadline the core re-checks the card is still in review and, if so, emits status_timeout once. Two pieces of state make this exact: status_since (when the card entered its current status — a denormalized column, since updated_at moves on any edit) and a fired-marker keyed by (card, status, status_since) so re-entering review arms a fresh deadline. If the card moves out of review first, the deadline is simply discarded.

The deadline scheduler (no fixed tick)

There is no polling interval. Pending deadlines live in a min-heap ordered by fire time, and a single timer is set to the earliest one:

  • on status_changed (consumed from the bus): cancel the card's old deadline, push the new one, reset the timer if the head changed;
  • on wake: pop everything now-due, re-verify + emit, reset the timer to the new head;
  • empty heap ⇒ no timer at all — zero wakeups when nothing is pending.

Resolution is automatic: the scheduler always sleeps until the nearest real deadline. The heap is reconstructible from state (query cards in monitored statuses, compute status_since + max, skip fired), so a restart or definition_reloaded just rebuilds it — nothing to persist. One safety net: a max-sleep cap (~1h) so a dropped bus event can't strand a deadline. Instant conditions never touch the heap; they evaluate synchronously on the mutation that could trip them.

Lazy: monitors run only while someone is listening

Because condition events are ephemeral and derived, a monitor's deadlines are scheduled only while it has a live consumer — an SSE subscriber whose types filter includes the event, a declared hook/webhook on that type, or the type being listed in workspace settings.persist_conditions. When the last consumer for status_timeout disconnects (and the type is not escalated), those deadlines are dropped from the heap and the core stops computing them; when a consumer re-subscribes, the relevant deadlines are rebuilt from current state. A signal that would have fired while nobody was listening was, by definition, for nobody — and catch-up does not rely on replay (see below). So cancellation has two clean triggers: the condition no longer holds, or no one is left to tell.

Condition events are ephemeral

Mutation events are facts: persisted, replayable via Last-Event-ID, always emitted. Condition events are a derived view over state: by default not persisted, computed only for whoever is watching. This is what makes the lazy scheduler above safe — there is no stored stream to fall behind on.

Catch-up therefore splits in two: replay missed facts from the feed, and ask for current conditions via the breaches query. A breach is itself derivable from the facts — the feed shows a card entered review at T and is still there, so "it's 9h overdue" is computable; the condition event is just a convenience signal on top. Types listed in settings.persist_conditions are also recorded in the feed (audit/history), at which point they replay like mutation events.

Observe

Live stream (SSE) [built]

GET /v1/events/stream?card_id=&board_id=&types=&actor=&owner=
Supports bounded replay via Last-Event-ID / since=. All five filters are built: card_id, board_id, types (CSV), actor= (events a user caused), and owner= (events on a user's cards). They make "watch this card", "follow @alice", and "follow my cards" the same primitive. The live stream is best-effort: a slow consumer whose buffer fills is dropped with a : dropped, reconnect comment — reconnect and replay from the feed (below). For durable catch-up, use the feed, not the stream. Durable events carry an SSE id: cursor; ephemeral condition signals omit it so they cannot replace the client's last durable replay position.

Catch-up feed [built]

GET /v1/events?actor=&owner=&type=&board_id=&since=&cursor=&limit=
→ { "items": [ {id, type, actor, at, card_id, diff}, … ], "next_cursor": "<id>" }
A cursor-paged query over the persisted events table for audit and "what did I miss while disconnected". The feed is a log of facts — mutation events only (plus any condition type listed in settings.persist_conditions). Ordered by event id ascending.

  • since= and cursor= are both event-id floors (events with id > the value). since= is the recovery floor — your last-persisted event id; cursor= is the pagination continuation (the previous response's next_cursor) and overrides since= when both are present. Same mechanism, two names for two intents.
  • next_cursor is the id of the last item in the page, or ""/absent when there are no more events. Keep paging until it's empty, then switch to the live stream from that id (Last-Event-ID).
  • Filters: actor=, owner= (current card owner), type=/types= (CSV), board_id= (the board's card types). limit= defaults to 100, max 500.

Retention / replay guarantee. The events table is append-only and never trimmed, so the feed is a complete durable log — replay is gap-free from any id, however long the consumer was gone. (event_retention_days exists in workspace settings as a future knob but is not enforced today — retention is currently unbounded.) (The in-memory SSE buffer is bounded and may drop under backpressure; that is why durable recovery goes through the feed, then resumes the live stream.) The SSE handler replays at most 500 events and subscribes to live delivery after replay, so opening a stream is not an atomic feed-to-live handoff. Consumers that require strict continuity should page the feed from their saved id, open the stream, then reconcile the feed once more from the last processed durable id.

Current breaches (catch-up for conditions) [built]

GET /v1/breaches?board_id=&type=
Condition events are ephemeral, so you don't replay missed ones — you ask the current truth. This computes, on demand, which instant conditions are currently true by evaluating thresholds against live state. It's the catch-up path for conditions and doubles as a dashboard's "needs attention" panel. A reconnecting integrator does two things: replay missed mutations via the feed (Last-Event-ID), then query current breaches.

Temporal conditions (status_timeout, card_idle) are included as a cold projection: cards past their monitor deadline are reported even if the scheduler never fired (no subscriber, server restarted). The projection is read-only — it never arms deadlines or marks conditions fired — and uses the same deadline math as the live verify path, so "past due" means exactly "would fire if the deadline came due now". Item fields discriminate by type: status_timeoutstatus/since/max; card_idlesince/ threshold. Item scans cap at 500 cards (the ListCards ceiling): when a scan clamps, the report carries truncated: true and echoes limit — treat the result as a partial view, not a complete catch-up (WIP/lane counts are uncapped).

Webhooks [proposed]

A webhook extension kind: the core POSTs each matching event to an external URL with retry + HMAC signature + cursor replay. For integrators that cannot hold an SSE connection. (Today a hook extension can shell out to curl; a first-class webhook adds delivery guarantees.)

Per-card watch [built]

GET /v1/cards/{id}/events and /history for replay/poll of one card.

Act

Every write below requires the X-Work-Cards-Actor header (or CARDS_USER env). Set Idempotency-Key to a unique value on any write you might retry — the server replays the original response instead of creating a duplicate. See implementation-status.md §2/§5 for exact shapes.

  • Claim workPOST /v1/cards/take-next (oldest unowned matching), claim, release. [built]
  • Attach resultsPOST /v1/cards/{id}/comments (text), append to a work_log repeating field (structured progress), or upload raw bytes to POST /v1/cards/{id}/artifacts/{field}. Artifact bytes are stored under the workspace artifacts/ directory and the card records their metadata; successful uploads emit artifact_added. [built]
  • AdvancePATCH /v1/cards/{id} (status/fields, optimistic concurrency), upgrade-schema. [built]

A worker's loop: take-next → do work → attach a comment + artifact → patch status to review/done.

Coordinate

Multi-step flows

Two patterns, both built on existing primitives: 1. One card, staged statuses — a board transition graph (todo → in_progress → review → done) with a hook per stage that dispatches the next step. 2. Linked-card DAGdepends-on / blocked-by links between cards; the blocked query hides a card until its blockers are done. The built card_unblocked event turns "a step became ready" into a push signal so a coordinator reacts instead of polling.

Reprioritization when the ready lane empties

take-next returning empty is the pull signal today. Add [proposed]: - a priority/rank field with take-next/list ordering by it, so reprioritizing is "set priority" and is honored deterministically.

The built lane_drained event supplies the push signal: an extension subscribes to it, then promotes the next backlog card by priority — the policy (which card, when) lives in the extension, the signal and ordering in the core.

Runtime notes (architecture)

  • Event scope. To carry board-level condition events, the event model gains a scope (card | board); card_id becomes nullable and a board_id is recorded for board-scoped events. Card-scoped events are unchanged.
  • The evaluator. Instant conditions are evaluated in the service immediately after the triggering mutation commits. Temporal conditions run in a monitor evaluator goroutine — a sibling to the hook supervisor already spawned in serve — driven by a deadline min-heap, not a fixed tick: it sleeps until the earliest pending deadline, wakes, re-verifies and emits, and sleeps again (no wakeups when the heap is empty). Deadlines are scheduled only while a monitor has a live consumer and are reconstructible from state, so nothing is persisted. It holds no policy; it only emits. See the deadline scheduler and lazy-monitor sections above.
  • Delivery. Mutation events and condition types listed in settings.persist_conditions append to the log and publish to the in-process bus. Ephemeral condition signals are published to the bus only, so live consumers (SSE, hooks) see one unified stream, while the durable feed contains only facts plus any escalated conditions.

Build order

  1. ~~Actor/owner stream filters + GET /v1/events feed (observe: watch/follow).~~ [done]
  2. ~~Board-scoped event model (scope, nullable card_id, board_id).~~ [done]
  3. ~~status_since denormalized column (arming temporal deadlines).~~ [done]
  4. ~~Monitors + instant condition events (WIP, empty lane, blocked) — synchronous.~~ [done]
  5. ~~Deadline-heap evaluator + temporal events (time-in-status, idle), lazy/refcounted.~~ [done]
  6. ~~GET /v1/breaches (current-conditions catch-up for the ephemeral signals).~~ [done]
  7. ~~transition_rejected (watch friction).~~ [done]
  8. ~~Artifact upload (attach files).~~ [done]
  9. card_ready (DAG coordination) — card_unblocked itself is already [done] (seam 3c, see The event model above); card_ready (all dependencies satisfied, not just unblocked) remains unbuilt.
  10. Priority/rank + reprioritize-on-lane_drained.
  11. Webhooks (push delivery).