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-Actorheader on everyPOST/PATCH(or set theCARDS_USERenv var). For retried writes, setIdempotency-Keyto a unique value so a duplicate is safely replayed. Seeimplementation-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]¶
Supports bounded replay viaLast-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>" }
settings.persist_conditions). Ordered by
event id ascending.
since=andcursor=are both event-id floors (events withid >the value).since=is the recovery floor — your last-persisted event id;cursor=is the pagination continuation (the previous response'snext_cursor) and overridessince=when both are present. Same mechanism, two names for two intents.next_cursoris 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]¶
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_timeout → status/since/max; card_idle → since/
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-Actorheader (orCARDS_USERenv). SetIdempotency-Keyto a unique value on any write you might retry — the server replays the original response instead of creating a duplicate. Seeimplementation-status.md§2/§5 for exact shapes.
- Claim work —
POST /v1/cards/take-next(oldest unowned matching),claim,release. [built] - Attach results —
POST /v1/cards/{id}/comments(text), append to awork_logrepeating field (structured progress), or upload raw bytes toPOST /v1/cards/{id}/artifacts/{field}. Artifact bytes are stored under the workspaceartifacts/directory and the card records their metadata; successful uploads emitartifact_added. [built] - Advance —
PATCH /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 DAG — depends-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_idbecomes nullable and aboard_idis 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_conditionsappend 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¶
- ~~Actor/owner stream filters +
GET /v1/eventsfeed (observe: watch/follow).~~ [done] - ~~Board-scoped event model (
scope, nullablecard_id,board_id).~~ [done] - ~~
status_sincedenormalized column (arming temporal deadlines).~~ [done] - ~~Monitors + instant condition events (WIP, empty lane, blocked) — synchronous.~~ [done]
- ~~Deadline-heap evaluator + temporal events (time-in-status, idle), lazy/refcounted.~~ [done]
- ~~
GET /v1/breaches(current-conditions catch-up for the ephemeral signals).~~ [done] - ~~
transition_rejected(watch friction).~~ [done] - ~~Artifact upload (attach files).~~ [done]
card_ready(DAG coordination) —card_unblockeditself is already [done] (seam 3c, see The event model above);card_ready(all dependencies satisfied, not just unblocked) remains unbuilt.- Priority/rank + reprioritize-on-
lane_drained. - Webhooks (push delivery).