Architecture — Go Core With Extension Integration¶
This document describes the implementation platform for Work Cards. The design
is a Go kernel distributed as a small cards binary, with an extension
model where behavior is added by independent processes in any language.
Python and Node client packages are planned but not yet built; for now the
binary, CLI, HTTP API, and MCP server are the integration surfaces.
Normative product behavior lives in index.md; the principles
behind these choices live in philosophy.md; the extension
contract lives in index.md; schema authoring lives in
workspace-and-boards.md; the vocabulary and mental
model live in index.md. For a code-verified drift audit of
the claims in this document (which features are built vs. proposed), see
implementation-status.md; for the events subsystem
design, see index.md.
Goals¶
- Run as a single local service or CLI with minimal setup.
- Serve exactly one workspace per process (one SQLite file, possibly
assembled from multiple definition files). Multi-workspace deployments run
multiple processes on different ports/paths — the binary, CLI, and clients
all take
--workspace/--url, so this is trivial and is the supported multi-tenancy path for v1. - Work as a sidecar for MCP clients, CLIs, scripts, and other HTTP clients.
- Use commonly available libraries and avoid mandatory external services.
- Keep runtime state in SQLite and workspace artifacts on disk.
- Load git-backed config from
definitions/in the workspace directory and merge them into one workspace. - Keep the core logic transport-independent so HTTP, CLI, MCP, TUI, and library use all share the same validation and storage behavior.
Non-goals:
- High-throughput multi-writer transaction workloads.
- Large payload storage inside cards.
- Distributed clustering or server-managed config editing.
- A multi-tenant router in the kernel (run multiple instances instead).
Platform Choice¶
The default implementation target is Go.
Why Go fits:
- Produces a small portable
cardsbinary. - Standard library HTTP server is sufficient for REST and SSE.
- Cross-compilation is practical for npm/Python packaging.
- Memory use and startup time are good for agent sidecars.
- It can expose the same behavior as CLI, HTTP service, MCP subprocess, terminal UI, or Go library without changing the core model.
Recommended dependencies (all in use):
- HTTP router:
net/httppluschi. - SQLite:
modernc.org/sqlite(pure-Go, no CGO, FTS5 supported). - UUID:
github.com/google/uuid.
Keep dependency choices boring. The project should be easy to build and reason about without a framework.
Runtime Shape¶
The Go binary provides several entry points:
cards serve --workspace ./examples/demo-workspace --port 8787
cards mcp --workspace ./examples/demo-workspace
cards list --board engineering --owner me
Internally, these entry points call the same service layer.
Actual package boundaries:
cmd/cards/ CLI binary and subcommand wiring (serve, mcp, extensions, do)
internal/core/ cards, schemas, transitions, validation, events, Store interface
internal/config/ load/merge/validate JSON core definitions + extension config
internal/sqlite/ SQLite implementation (FTS5, migrations)
internal/httpapi/ REST, SSE, and the server-rendered web UI (Go templates + Alpine.js)
internal/mcp/ MCP adapter over core services
internal/tui/ terminal UI behind bare `cards` (serverless; in-process bus refresh)
internal/hooks/ hook supervisor (spawns subprocesses on events)
internal/cli/ CLI client (serverless by default — runs the /v1 router
in-process; talks to a server only when CARDS_URL is set;
see workspace-and-boards.md §9)
internal/seed/ demo workspace seed data
internal/starter/ starter workspace scaffolding (cards init, zero-config seed)
internal/artifacts/ workspace file/artifact helpers
Public Go library use can be added later by promoting stable packages out of
internal/, but v1 keeps API stability focused on HTTP/CLI/MCP.
Core Service Boundary¶
All transports call a small internal API, conceptually:
type Service interface {
Workspace(ctx context.Context, name string) (*WorkspaceSnapshot, error)
ListCards(ctx context.Context, q CardQuery) (*Page[Card], error)
CreateCard(ctx context.Context, req CreateCardRequest) (*Card, error)
PatchCard(ctx context.Context, id string, req PatchCardRequest) (*Card, error)
AppendField(ctx context.Context, id, field string, entry any) (*Card, error)
AddLink(ctx context.Context, id string, link LinkInput) (*Card, error)
AddComment(ctx context.Context, id string, body string) (*Comment, error)
ClaimCard(ctx context.Context, id string, req ClaimRequest) (*Card, error)
Events(ctx context.Context, q EventQuery) (EventStream, error)
}
The service layer owns:
- schema lookup and pinned
schema_versionvalidation, - transition evaluation,
- filter compilation,
- optimistic concurrency,
- idempotency handling,
- event writing,
- artifact metadata validation.
The HTTP layer does not reimplement these rules.
Storage¶
SQLite is the operational store.
Tables:
cards: materialized card snapshot; JSONfields; denormalizedtype_id,status,owner, timestamps,schema_version,version.events: append-only events with actor, timestamp, type, diff JSON.links: source card, link type, target card, note, timestamps.comments: card id, author, markdown body, timestamps.users: registered users.idempotency_keys: request key, actor, status, body, created_at (composite PKkey, actor).fts_cards: FTS5 index for title plus field values (upsertFTS). A type'ssearchable_fieldsrestricts which field values are indexed; a type declaring none indexes all of them (title is always indexed). The declaration reaches the store viacore.SearchableFieldsSetter, installed byNewService, so a definitions reload refreshes it; a changed declaration rebuilds the index once, gated on a digest in themetatable. Seedesign/fts-vs-like-disposition.md.
Definitions are not stored in SQLite. They are loaded from definitions/
and cached in memory as normalized config. Git-backed files remain the source
of truth. Live reloads (POST /v1/workspace/reload, cards serve --watch)
swap the in-memory generation around the same store and bus — see
reload.md for the debounce / self-write / failure contract.
Workspace Loading¶
A workspace is a directory with definitions/ loaded at startup. The binary,
CLI, and clients all take --workspace <dir>:
cards serve --workspace ./examples/demo-workspace --port 8787
cards list --workspace ./examples/demo-workspace
The --workspace flag points at a directory containing definitions/
(workspace.json, card-types/, boards/, extensions.json). The loader reads
and merges these into exactly one in-memory workspace, exposed through
GET /v1/workspace.
Remote/CLI clients use --url (or CARDS_URL) to point at a running server
and --as (or CARDS_USER) to set the actor.
This lets an app ship a base workspace config while an agent harness layers on local boards, views, or card types.
Process Modes¶
Sidecar HTTP¶
Primary mode for Python and Node harnesses:
Clients connect to http://127.0.0.1:8787/v1. SSE streams use the same process.
Two event fan-outs exist, deliberately distinct: the SSE stream
(/v1/events/stream) is the multi-process, cursor-resumable consumer
contract; the in-process bus (core.Bus) serves same-process subscribers
(the TUI's live refresh, the hook supervisor) — ephemeral, slow consumers
dropped, never cross-process. A serverless TUI does not see a running
server's writes until its own refresh; multi-process live coordination is
what SSE (and a future outbox) is for. See
design/tui-bus-disposition.md.
CLI¶
Useful for scripts and humans:
CLI commands call the same service layer directly when local, or optionally call
CARDS_URL when configured for remote/sidecar mode.
MCP¶
The Go binary can expose MCP over stdio:
MCP tools should be generated from the same normalized workspace introspection used by HTTP clients. The MCP adapter should delegate mutations to the service layer, not bypass it.
Embedded Go¶
Go consumers can eventually import the core as a library, but this is secondary to the binary/sidecar contract for v1.
Extension Supervisor¶
The Go binary includes an optional supervisor for declared extensions.
Supported home: cards serve --run-extensions (shared construction with
standalone cards run-extensions). Lifecycle vocabulary
(autostart × restart_policy × three kinds) and the bimodal event
boundary are normative in lifecycle-schema.md.
The supervisor is deliberately bimodal — not a single event-feeding path:
- Hooks: subscribe to the in-process bus; on filter match, spawn subprocess-per-event with event JSON on stdin.
- Services: pure process lifecycle (start / restart per policy / drain).
The supervisor does not feed events into service children; services dial
/v1/events/streamas ordinary API clients.
Responsibilities:
- Read
definitions/extensions.{yaml,yml,json}. - For
kind: hook— bus subscribe + spawn (built today). - For
kind: servicewithautostart: true— [built] start after the HTTP listener is accepting; restart perrestart_policy(on-failuredefault when omitted, oralways/never); bounded backoff with min-healthy-uptime; SIGTERM→grace→SIGKILL drain. Schema field and load-time validation are [built] (P5a). Reconcile-on-reload is [built] P5c (identity key + decision table inreload.md; board-create reload ⇒ zero service churn; hook/run decls remain frozen). - For
kind: run— invoke oncards do <id>. - Capture stdout/stderr to per-extension logs in
.cards/logs/.
expose (port/protocol) is parsed but unconsumed. The supervisor is not
required: extensions can be started by systemd, docker compose, or by hand.
The supervisor never loads extension code into the core process. Crashes are isolated.
See index.md for the declaration format and
lifecycle-schema.md for Autostart / RestartPolicy rules.
Event Taxonomy: Mutation vs Condition¶
Integrators talk to Cards on three planes — observe (SSE stream, catch-up
feed, breaches), act (claim / attach / advance), and coordinate
(transition graphs, dependency links, condition signals). Events themselves
have two origins. Mutation events (status_changed, comment_added, …)
are the synchronous consequence of a write and are always card-scoped.
Condition events are emitted when a declared threshold crosses: instant ones
(wip_exceeded, lane_drained, card_blocked, transition_rejected)
evaluated right after the triggering mutation, and temporal ones
(status_timeout, card_idle) emitted by a monitor evaluator goroutine
that is driven by a deadline min-heap (it sleeps until the next deadline rather
than polling on a fixed tick, and only schedules deadlines a live consumer is
listening for unless the condition is escalated via
settings.persist_conditions).
Condition watchers are declared as board data — wip_limits for WIP caps and
monitors for the rest (empty lane, time-in-status, idle, rejection) — and
publish onto the same bus as mutation events, so SSE and hooks consume one
unified stream. Board-level conditions use event scope (card | board)
with a nullable card_id and a recorded board_id.
Critically, the core only emits condition signals — it never acts on them;
reprioritizing, escalating, and reassigning are the integrator's policy. See
integration.md for the full contract.
Planned Integrations¶
Python and Node client packages are planned but not yet built. They will be thin clients and launchers around the Go binary, so agent harnesses can embed Work Cards without managing a separate process.
Python (work-cards):
from work_cards import Cards
cards = Cards.start(workspace="./examples/demo-workspace")
cards.create(type_id="programming-task", title="Update docs", status="todo",
fields={"description": "Clarify setup", "branch": "docs/setup"})
Node/TypeScript (@work-cards/client):
import { Cards } from "@work-cards/client";
const cards = await Cards.start({ workspace: "./examples/demo-workspace" });
await cards.create({ type_id: "programming-task", title: "Update docs", status: "todo",
fields: { description: "Clarify setup", branch: "docs/setup" } });
Both will provide connect(url) for an existing server and start(workspace)
to launch the bundled binary. Binary distribution will use platform wheels /
npm platform packages so no Go toolchain is required at install time.
For MCP-heavy TypeScript harnesses, launch cards mcp directly as the MCP
server — it keeps one source of tool behavior.
Until these land, use the HTTP API, CLI, or MCP server directly.
Release and Packaging Pipeline¶
CI builds the Go binary for common targets:
Release artifacts (planned):
cards_<version>_<os>_<arch>.tar.gz- checksums (
SHA256SUMS) - optional SBOM
- npm platform packages (when Node client lands)
- Python wheels (when Python client lands)
The same binary supports serve, mcp, and CLI commands. No separate
server and CLI binaries.
Port and Lifecycle Management¶
The server binds to 127.0.0.1 by default and serves one workspace per
process. Health endpoint:
Response includes version, workspace id (singular — one process serves one workspace), config digest, and SQLite path.
Future Python/Node launchers will prefer CARDS_URL if set, otherwise start
the bundled binary on 127.0.0.1 and read the selected port from the health
endpoint.
Security and Trust Boundary¶
Work Cards is designed for local single-instance coordination. The server
binds to 127.0.0.1 by default.
Important boundaries:
commandfield type was removed from the core (seedesign-notes.mdD2); extensions own execution contracts. The core never executes anything.path/json/yamlfield types were also removed; store such content asstring/text/artifactand let an extension validate and annotate.- If exposed beyond localhost, put the service behind a reverse proxy or an auth extension. There is no baked-in auth; Work Cards is treated as an internal tool.
- Mirror import is version-gated: each markdown file declares the
versionit was edited from; stale imports are409 version_conflict, never a silent overwrite (seeindex.md§3). [planned, not yet implemented]
Summary¶
The core is built in Go, distributed as a self-contained cards binary, with
Python/Node packages planned as thin clients plus launchers. State lives in
SQLite, core definitions are git-backed JSON, extension declarations may be YAML
or JSON, artifacts live on disk, and all business logic stays inside the Go
service layer.
This gives agent harnesses a low-friction local dependency while preserving a clean API boundary for other applications.