Skip to content

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 cards binary.
  • 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/http plus chi.
  • 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_version validation,
  • 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; JSON fields; denormalized type_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 PK key, actor).
  • fts_cards: FTS5 index for title plus field values (upsertFTS). A type's searchable_fields restricts which field values are indexed; a type declaring none indexes all of them (title is always indexed). The declaration reaches the store via core.SearchableFieldsSetter, installed by NewService, so a definitions reload refreshes it; a changed declaration rebuilds the index once, gated on a digest in the meta table. See design/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:

cards serve --workspace ./examples/demo-workspace --port 8787

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:

cards list --owner me --status todo,in_progress
cards patch CARD_ID --status review --version 3

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:

cards mcp --workspace ./examples/demo-workspace

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/stream as ordinary API clients.

Responsibilities:

  • Read definitions/extensions.{yaml,yml,json}.
  • For kind: hook — bus subscribe + spawn (built today).
  • For kind: service with autostart: true[built] start after the HTTP listener is accepting; restart per restart_policy (on-failure default when omitted, or always / 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 in reload.md; board-create reload ⇒ zero service churn; hook/run decls remain frozen).
  • For kind: run — invoke on cards 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:

darwin-arm64
darwin-amd64
linux-amd64
linux-arm64
windows-amd64

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:

GET /v1/health

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:

  • command field type was removed from the core (see design-notes.md D2); extensions own execution contracts. The core never executes anything.
  • path/json/yaml field types were also removed; store such content as string/text/artifact and 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 version it was edited from; stale imports are 409 version_conflict, never a silent overwrite (see index.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.