Extensions¶
Extensions are independent processes that add behavior to Work Cards. The core exposes typed cards, events, and an HTTP API. Extensions consume those to implement everything else: CI dispatchers, file watchers, report builders, agent dispatchers, UI backends, integrations, custom validators.
Extensions are not plugins. The core does not load extension code. Extensions can be written in any language and crash without affecting the kernel.
Status legend: [built] available today · [proposed] planned but not yet implemented. Individual sections are tagged below.
Why this model¶
- Language-agnostic. Use Python for ML and data, Node for browser and HTTP, Go/Rust for performance-sensitive work, Bash for glue.
- Crash-isolated. A failing extension does not take down the core.
- Composable. Multiple extensions can coexist; each does one thing.
- Inspectable. Each extension has its own logs, process, and source.
- Replaceable. Fork or swap an extension without touching the kernel.
Extension kinds¶
| Kind | When the core invokes it | Lifetime | Input |
|---|---|---|---|
hook |
An event matches its filter | One-shot subprocess | Event JSON on stdin (supervisor bus delivery) |
service |
When autostart: true under --run-extensions [built]; otherwise started externally |
Long-running supervised process | None from supervisor — dials HTTP/SSE itself |
run |
When called via cards do <id> |
One-shot subprocess | Args from CLI |
All three communicate with the core via the same HTTP API. Event delivery is
bimodal by design (see lifecycle-schema.md):
- Hooks receive events from the in-process bus as stdin JSON (subprocess-per-event).
- Services are not fed events by the supervisor. A service that needs the
stream dials
/v1/events/stream(withLast-Event-IDreplay) as an ordinary API client — same contract as any external integrator.
Do not unify these paths later; the split keeps long-running peers crash-isolated
from bus fan-out. (The command kind was renamed to run to avoid colliding
with the removed command field type — see design-notes.md D2/D18.)
Declaration¶
Workspaces declare extensions in definitions/extensions.{yaml,yml,json}:
extensions:
- id: ci-dispatcher
kind: hook
description: Start CI builds when build cards reach `ready`
on: status_changed
filter:
board_id: ci
to_status: ready
run: ["python", ".cards/ext/ci_dispatcher.py"]
- id: dropbox
kind: service
description: Watch ./drop and create intake cards
autostart: true
restart_policy: on-failure
run: ["node", ".cards/ext/dropbox.mjs"]
- id: build-report
kind: run
description: Assemble research cards into a Markdown report
run: ["python", ".cards/ext/report.py"]
- id: web-ui
kind: service
description: Browser dashboard backend
autostart: false
run: ["node", ".cards/ext/web/server.mjs"]
expose:
port: 8788
protocol: http
Lifecycle fields (autostart × restart_policy)¶
Normative detail: lifecycle-schema.md.
| Field | Applies to | Role |
|---|---|---|
autostart |
service (enable-gate) |
Whether the supervisor should start the process after the HTTP listener is ready |
restart_policy |
service only |
Recovery shape after exit: on-failure (default when omitted), always, or never |
restart_policy on hook or run is a load-time rejection (not a silent
no-op). Unknown values are rejected the same way. expose remains parsed but
unconsumed (no reverse proxy / port registry).
The core runs declared hook and run extensions when asked; service
supervision is [built] (P5b):
cards serve --run-extensions --workspace ./.work-cards # supported home
cards run-extensions --workspace ./.work-cards # standalone
serve --run-extensions binds the HTTP listener first, then starts the
supervisor so autostart services see an accepting port. Children receive
CARDS_URL (loopback base, e.g. http://127.0.0.1:8787/v1),
CARDS_WORKSPACE, and CARDS_USER. Restart uses restart_policy with
bounded backoff (min-healthy-uptime before streak reset). Shutdown is
SIGTERM → grace → SIGKILL of the process group.
On successful definition reload, the supervisor reconciles service declarations (P5c): identity is the extension id; a change to run/args/env (and cwd / restart_policy) is "same service, changed declaration" and triggers drain+restart of that child only. Unchanged decls (including a routine board-create reload) leave running services alone. Hook/run declarations stay frozen for the supervisor's lifetime.
Event contract for hooks¶
When a hook fires, the core spawns the configured command with:
- Event JSON on stdin.
- Environment variables:
CARDS_URL,CARDS_WORKSPACE,CARDS_USER,CARDS_EVENT_ID,CARDS_EVENT_TYPE. - Working directory: workspace root (unless
cwd:is set in the declaration).
Event JSON shape:
{
"id": 4217,
"type": "status_changed",
"card_id": "card_auth_api",
"actor": "coder-agent",
"at": "2026-06-25T14:30:00Z",
"workspace_id": "demo",
"diff": { "before": "in_progress", "after": "review" }
}
Hooks should be idempotent. Use Idempotency-Key headers on API writes so
retries are safe. A non-zero exit code marks the hook run as failed and is
logged, but does not roll back the originating event.
Delivery guarantees and ordering¶
- At-most-once by default. A non-zero exit is logged, not retried. For
critical paths (CI dispatch, deployments), use a
serviceextension with its own retry/dead-letter logic, or have the hook enqueue durable work and return quickly. - Spawn is ordered, completion is not. Hooks are spawned in event order,
but run asynchronously and may complete out of order. If a workflow needs
strict ordering, serialize through a single
serviceconsumer. - Idempotency is the hook's job. The core does not redeliver; design hooks
so the same event applied twice is harmless (e.g. guard on
commit_hashor an idempotency key on the write-back). - Event replay for reconnect. Services and hooks that subscribe to
/v1/events/streamshould sendLast-Event-IDon reconnect to replay missed events; one-shot hooks receive only the triggering event.
Worked examples¶
[proposed] Examples 1, 2, 3, and 4 below use a planned
work_cards(Python) /@work-cards/client(Node) convenience client that is not yet implemented (seeindex.md's planned integrations). Until it ships, substitute raw HTTP calls to the/v1/*API usingrequests/httpx(Python) orfetch(Node), as shown inintegration.md. Example 5 uses only the CLI and is runnable today [built]; Example 6 is built but ships in its own repo [built — external]; Example 7 is a runnable Node service seed in the demo workspace [built].
Example 1 — CI dispatcher (Python hook) [proposed]¶
definitions/extensions.yaml:
- id: ci-dispatcher
kind: hook
on: status_changed
filter:
board_id: ci
to_status: ready
run: ["python", ".cards/ext/ci_dispatcher.py"]
.cards/ext/ci_dispatcher.py:
import json, os, subprocess, sys
from work_cards import Cards
event = json.load(sys.stdin)
cards = Cards.connect(url=os.environ["CARDS_URL"])
card = cards.get(event["card_id"])
workflow = card["fields"]["workflow"]
ref = card["fields"]["ref"]
result = subprocess.run(
["gh", "workflow", "run", workflow, "--ref", ref],
capture_output=True, text=True,
)
cards.append(card["id"], "ci_log", {
"event": "dispatched",
"workflow": workflow,
"stdout": result.stdout,
"stderr": result.stderr,
})
Example 2 — File dropbox (Node service) [proposed]¶
definitions/extensions.yaml:
.cards/ext/dropbox.mjs:
import { Cards } from "@work-cards/client";
import chokidar from "chokidar";
import { basename } from "node:path";
const cards = await Cards.connect();
chokidar.watch("./drop", { ignoreInitial: false }).on("add", async (path) => {
await cards.create({
type_id: "intake",
title: basename(path),
status: "queued",
fields: { source_path: path },
});
});
A second extension — a hook on card_created filtered to type_id=intake
— can then dispatch a per-type processor without the dropbox knowing anything
about the downstream work.
Example 3 — Agent session dispatcher (Python hook) [proposed]¶
Spawning an agent session when a card reaches ready is a single hook.
- id: agent-dispatcher
kind: hook
on: status_changed
filter:
board_id: engineering
to_status: ready
run: ["python", ".cards/ext/spawn_agent.py"]
.cards/ext/spawn_agent.py:
import json, subprocess, sys
from work_cards import Cards
event = json.load(sys.stdin)
cards = Cards.connect()
card = cards.get(event["card_id"])
prompt = (
f"Work on card {card['id']}: {card['title']}\n\n"
f"{card['fields'].get('description', '')}"
)
subprocess.Popen([
"pi", "--print",
"--session", f".cards/sessions/{card['id']}.jsonl",
prompt,
])
cards.add_comment(card["id"], body="Agent session started.")
The same pattern works for any harness: claude, codex, an opencode
binary, or your own.
Example 4 — Research report builder (Python command) [proposed]¶
definitions/extensions.yaml:
- id: build-report
kind: run
description: Assemble linked research cards into a Markdown report
run: ["python", ".cards/ext/report.py"]
Invocation:
The core passes --param flags through to the declared command.
.cards/ext/report.py:
import argparse
from work_cards import Cards
ap = argparse.ArgumentParser()
ap.add_argument("--param", action="append", default=[])
args = ap.parse_args()
params = dict(p.split("=", 1) for p in args.param)
cards = Cards.connect()
topic = cards.get(params["topic"])
sources = cards.list(filter={
"has_link": {"$eq": "supports"},
"link_target": {"$eq": topic["id"]},
})
print(f"# {topic['title']}\n")
print(topic["fields"]["hypothesis"])
print("\n## Sources\n")
for s in sources["items"]:
print(f"- [{s['title']}]({s['fields']['url']})")
Example 5 — Bash one-liner hook [built]¶
Not every extension needs a runtime. A status-change hook that pings Slack can be a one-file script. For detailed CLI commands, distribution patterns, and running the extension supervisor, refer to the hooks package documentation.
Best practices¶
- Keep extensions small and single-purpose. One process per concern.
- Use idempotency keys on writes so retries are safe.
- Log to stdout/stderr; the supervisor captures both.
- Treat workspace files as the source of configuration; never store secrets
there. Use environment variables passed via
env:in the declaration. - Prefer hooks over polling. Use
serviceonly when continuous attention is required (file watching, custom HTTP server, websocket bridge). - For long-running work spawned by hooks, return quickly and let the worker run in the background; record progress by appending to the card.
What about MCP?¶
MCP tools are generated by the core from card-type schemas, scoped per
session (see mcp.md). MCP is a transport for agents that prefer
typed tools to raw HTTP. Extensions are about adding behavior (things that
happen to cards and events), not about adding tools for agents to call. The
two mechanisms complement each other.
Extension territory: structured-payload validation¶
The core field catalog is intentionally small (string, text, number,
date, enum, tags, user, card_link, repeating, artifact — see
data-model.md §4). Validation of richer
payloads is an extension's job:
- JSON/YAML payloads: store as
text; an extension validates against its own schema and posts findings as a comment or arepeatingentry. - Path confinement: store arbitrary paths as
string; an extension validates they stay under the workspace root before anartifactis created. - Command specs / execution: the core never executes. An extension defines
the execution contract (argv-array, no shell, env allowlist,
cwdconfined to the workspace, timeout) and reports results back onto the card.
This keeps the core small and the security surface where it belongs — in a crash-isolated, replaceable process.
Example 6 — pi agent extension (TypeScript) [built — external]¶
pi-cards is a pi
coding-agent extension that treats Cards as a first-class surface inside an
agent session: a /cards board view with SSE live refresh, cards_* tools
(read/write/claim/take-next), cards-as-context mentions, subagent card
execution (/cards work), and git board persistence. From the core's
perspective it is just another /v1 client — a sixth surface, shipped in
its own repo per the extensions-over-plugins rule. Spec:
docs/design/pi-extension.md.
Example 7 — Runnable SSE worker (Node service) [built]¶
The demo workspace ships a supervised service that runs the worker loop
from integration.md (§ Quickstart, "3. The
worker loop", line 37) as a real process:
review-bot.mjs
subscribes to /v1/events/stream?board_id=engineering&types=status_changed
and, on every status_changed into review, claims the oldest unowned
review card via POST /v1/cards/take-next and comments back as actor
review-bot. Reconnects send Last-Event-ID, so a server restart
mid-stream replays the gap instead of losing events. It is declared in
extensions.json
with autostart: true and restart_policy: on-failure, starts under
cards serve --workspace examples/demo-workspace --run-extensions, and
requires Node on PATH (stdlib fetch + a hand-rolled SSE reader —
zero npm dependencies). The review-bot.sh launcher gates the runtime: on
a Node-less machine it logs node: not found — service review-bot skipped
and exits 0, so the restart policy does not loop. The same transition also
fires the review-notify hook declared above it — one event, two
extension kinds (hook: bus JSON on stdin; service: dials SSE itself).
scripts/review-bot_test.sh
(also go test ./cmd/cards -run TestReviewBotScript) proves the loop:
card → review ⇒ bot comment asserted by author; kill/restart mid-stream
⇒ resumption from the last event id; supervisor stability (≤1 restart in a
5s window). See examples/demo-workspace/README.md.
Related documents¶
| Doc | Contents |
|---|---|
philosophy.md |
Why the system stays small |
index.md |
Normative API and event types |
index.md |
Go core, packaging, supervisor |
lifecycle-schema.md |
Autostart × RestartPolicy × kinds; bimodal supervisor |
card-definitions.md |
Card definitions (schema authoring) |
index.md |
End-to-end lifecycles |