Tack

CI

A project board that can hand its own items to an AI coding agent — Claude Code, Codex, docket, or opencode — and track the run as part of the item's history. Self-hosted, one binary, no cloud account.

A board item assigned to Claude Code through Run with agent, tracked live from Leased to Succeeded, with its Execution tab showing the matched model and measured cost

Tack tracks work for any domain — software sprints, a kitchen renovation, thesis chapters, a maintenance schedule — through fully configurable vocabulary and workflow columns, and can hand any item that's agent-eligible to a real coding-agent run instead of just tracking it. No accounts. No cloud. No subscriptions. One binary, one SQLite file.

The Agents page, fully earned: agent execution on, Codex and Claude Code both detected, Claude Code's own login verified by a real test run, a project default model saved, and that test run's own attempt shown Succeeded.

The board and the runner

Under the hood, Tack is two components, built to be one product.

The board is the project manager: workflows, timelines, dependencies, per-project vocabulary — one binary, one SQLite file, no accounts, no cloud. It is the plan, the policy and the record. It decides what runs, when, under which limits, and it keeps the durable history of every run: events, decisions, artifacts, and what it measurably cost. It never executes code and never holds a model credential.

The runner is a small worker that lives where the code and the credentials already are — a laptop, a CI box, a machine with a GPU. It pulls work from the board, checks out an isolated workspace, launches the coding agent you already use — Claude Code, Codex, docket, or opencode — and reports back. It holds the keys; the board never sees them.

They are separate because they scale and fail differently. One board, many runners: a board on a small VPS dispatches to runners on ten developers' machines, each with its own agent, model and capacity. A runner that dies mid-run cannot corrupt the board — its lease expires and its fencing token stops writing. A board that restarts cannot lose a run — the runner's journal knows what it started. One developer runs both in one process with one command, on the same contract, with the same recovery.

Two components: the board (one) on the left holds workflows, timelines, leases, fencing, and history; runners (many) on the right each launch a harness — Claude Code, Codex, docket, or opencode — near your code and credentials. One arrow, from runner to board, labeled "pulls work": the board never calls out.

Core concepts

Six terms recur throughout this documentation:

TermWhat it means
ItemThe basic unit of work. One Item model backs everything — a task, bug, feature, epic, building, work order, assignment — and your project's vocabulary decides how it's labeled.
WorkflowThe set of named status columns an item moves through (e.g. To Do → Doing → Done), each with a category and an optional WIP limit. See Workflows & Statuses.
Project typeA template chosen at creation that pre-loads a matching workflow and vocabulary (software, construction, legal, …). Everything stays editable afterward.
VocabularyPer-project label overrides that rename built-in terms to your domain — "Task" → "Work Order", "Sprint" → "Phase". The UI, CLI, and API all follow your terms. See Vocabulary.
RunnerA small worker process that lives where your code and credentials already are, pulls eligible items from the board, launches a coding-agent harness, and reports back. See Agent Runners.
Run (attempt)One execution of an item by a runner — leased under a fencing token so at most one attempt is ever active, and recorded as durable history: events, decisions, artifacts, and measured cost.

How this documentation is organized

SectionFor whom
User GuideAnyone running Tack: setup, views, CLI, configuration
Developer GuideContributors and people extending the codebase
Learning PathDevelopers new to Rust, Axum, or SolidJS; explains the stack with analogies
RoadmapWhat each development phase set out to do, and what came of it

Keeping docs current

These docs live alongside the code in docs/book/src/. Every push to develop runs mdbook build in CI (with a broken-link check). If the book fails to build, CI fails, so structural drift is caught before it merges.

For prose changes (user-facing descriptions, learning explanations), update the relevant .md file in the same PR as your code change. The "Edit this page" link at the top of each page opens the file directly on GitHub.

docs/book/src/
├── user-guide/        ← user-facing docs
├── developer/         ← contributor docs
│   └── learning/      ← stack explanation with analogies
└── roadmap.md

Quick Start

Two ways to get Tack running: install the binary (the fast path — no build tools, you just want to use Tack) or run from source in development mode (for contributors and people who want hot reload). Pick the one that matches you.

Prefer pictures? The Step-by-Step Tutorial walks this same page's install → project → tasks → agent-run path once, end to end, with a real screenshot at every step.


Install

Tack is a single self-contained binary — the web UI, REST API, and SQLite engine are all inside one file. No runtime, database server, or container required. Pick any one method.

PlatformInstall → first agent attempt
Linuxmeasured
macOSnot_measured
Windowsnot_measured

One line (Linux / macOS):

curl -fsSL https://raw.githubusercontent.com/yielab/tack/main/install.sh | sh

Verifies the download against that release's SHA256SUMS and refuses to install on a mismatch. Pin a version with TACK_VERSION=v0.1.0-beta.10; choose the install directory with TACK_INSTALL_DIR (default ~/.local/bin).

Homebrew (macOS and Linux):

brew install yielab/tap/tack

Windows:

irm https://raw.githubusercontent.com/yielab/tack/main/install.ps1 | iex

Same TACK_VERSION and TACK_SKIP_CHECKSUM env vars as above, plus TACK_INSTALL_DIR (default %LOCALAPPDATA%\Programs\tack); it verifies SHA256SUMS and adds the install directory to your user PATH — open a new terminal afterwards. Or install with Scoop:

scoop install https://raw.githubusercontent.com/yielab/tack/main/packaging/scoop/tack.json

Or download the .msi from the releases page.

Desktop app. Download it from the releases page — the .AppImage or .deb on Linux, the .msi on Windows, or the .dmg on macOS (built separately for Apple Silicon and Intel). Opening it starts the same server this page describes, inside its own window, with an icon in your system tray: closing the window leaves it running, and the tray's Quit is what actually stops it. If the server it started stops on its own, the tray tells you once — the status line reads "Server stopped" with the exit reason, and reopening the app starts it again; if the app is instead pointed at a server it did not start and that server goes quiet, the tray says so after a few seconds rather than staying silent. Skip to First use once it's open.

Docker: the ghcr.io/yielab/tack image isn't publicly pullable yet — build it yourself from the repo's Dockerfile instead:

git clone https://github.com/yielab/tack.git && cd tack
docker build -t tack:latest .
docker run -d --name tack -p 3210:3210 -v tack-data:/data tack:latest

See Deployment → Docker for the compose file and configuration.

Manual download. Grab the archive for your platform and SHA256SUMS from the releases page, verify, then run:

# Linux / macOS
sha256sum -c --ignore-missing SHA256SUMS   # macOS: shasum -a 256 -c --ignore-missing SHA256SUMS
tar xzf tack-*.tar.gz && cd tack-*/
./tack

On Windows, extract the zip and double-click tack.exe.

From source. No cargo install path exists yet — see Development mode (run from source) below.

First-run note (unsigned binary). The binaries are not code-signed yet. On macOS, right-click → Open the first time (or run xattr -d com.apple.quarantine tack). On Windows, click More info → Run anyway if SmartScreen appears.

Start

tack            # starts the server + web UI at http://localhost:3210

Or start it with the embedded agent runner in one step — see Run an item with an agent below for what that adds:

tack serve --with-runner

Then open http://localhost:3210 in your browser. On first start, Tack creates tack.db and a storage/ folder next to the binary and runs the schema migrations automatically. Those two paths are your data — back them up and you've backed up everything.

Verify the server is up:

curl http://localhost:3210/api/health
# {"status":"ok","version":"0.1.0-beta.7","migrations_applied":62}

migrations_applied is how many migrations this build actually ran — trust that field over any number quoted here; it only grows as the schema does.

If this fails, see Troubleshooting.


First use

  1. Create a project. Click New Project on the Projects page, or press Ctrl+K and type "new project". Choose a project type — a template that pre-loads a matching workflow and vocabulary you can customize later.

  2. Add an item. On the Board, click the + inside any column, or the + New toolbar button. Give it a title and press Enter. An item is the basic unit of work — a task, bug, building, assignment, or whatever your vocabulary calls it.

  3. Move it. Drag the card to another column. Status changes save immediately with optimistic UI (the card moves before the server confirms).

  4. Open the detail drawer. Click the card body (not the drag handle) to see all fields, dependencies, comments, attachments, and custom fields. See Working with Items.

  5. Find your way around. Press Ctrl+K for the command palette (jump to any view, run an action) or Ctrl+/ to search items. Switch theme and palette from the sidebar footer — see Appearance.

Ready to put it on a network or add a token? See Administration & Security.


Run an item with an agent

By default, nothing here executes anything: the server you started above is a full project manager with agent execution off, and clicking an item's Run with agent button shows "Agent execution is off" instead of a form. Run with agent (the button) and --with-runner (a boot flag, further down) are two different things — the button needs some runner active to do anything, and --with-runner is one way to get one. The two paths below are alternatives, not sequential steps: pick the UI path if a server is already running (no restart needed), or the CLI path if you're starting fresh from a terminal.

The UI-first path. Open the Agents page from the sidebar (/agents). Four steps, top to bottom, each one a switch or a form field — no terminal, no id to copy:

  1. "Agent execution on this machine" — click Turn on. This is the one switch ADR 0061 decision 6 exists for: it starts an embedded runner in this same process, on this loopback bind, with no restart and no second binary. (A remote runner on a different machine still needs tack-runner started there — see Enrolling a runner — but nothing on this page requires that.)
  2. "Agents on this machine" — once execution is on, this section reports what it found: codex and/or claude-code, present or not, and whether either one's own vendor login (Claude Max, codex login, ...) already works. Nothing to configure here if a harness is already installed and logged in.
  3. "Vercel AI Gateway key" — paste a gateway key here if you don't want to rely on a harness's own subscription login. It is stored in the runner's own local secret store (the platform keychain where one exists, an owner-only file otherwise) — never in tack.db, never in a log, never echoed back by the API. Saving it immediately re-checks the gateway's model catalog; a real key shows real models below. If execution is already on, saving also restarts the embedded runner with the key, so the next run uses it — no "Re-check" needed in between.
  4. "Default model" — pick a model from that catalog (or from whatever the target harness itself declares) and save it as this project's default. This is what lets the next step submit with zero hand-typed identifiers.

Now open any item and click Run with agent. With a runner active and a default model configured, the dialog's defaults are already correct — confirm and click Run. The item's own Execution tab shows the attempt's state, its requested-vs-actual model, and its usage economics as they land.

The CLI path — scriptable, and what to reach for outside a browser. Every step below is a real command against a real server, copied from an actual run; the fewest steps from the binary you already have to a completed attempt, no second process and no operator-issued token.

Start the server with the embedded runner instead of plain tack (it self-provisions on first start — see Standalone mode; this is the console-command equivalent of step 1 above, not a second thing to do on top of it):

tack serve --with-runner

In another terminal, create an agent profile — its instructions travel with every request created against it:

tack agent-profile create "release-notes" \
  --instructions "Summarize the diff and write docs/CHANGELOG entries."
Created agent profile: release-notes (ap_91b4e)
  id: ap_91b4ea76-9f1a-4725-8a58-21a57d92572c

Find the runner id — the embedded runner enrolled itself under it:

curl -s http://127.0.0.1:3210/api/runners | jq -r '.data[].runner_id'

Using the item id from First use above, create the execution request (swap in your own harness — codex or claude-code — and whichever model it accepts; see Choosing a model and a provider if unsure):

tack execution create <ITEM_ID> \
  --runner <RUNNER_ID> \
  --agent-profile ap_91b4ea76-9f1a-4725-8a58-21a57d92572c \
  --harness claude-code --model-provider anthropic --model-id claude-sonnet-4-5 \
  --agent-profile-snapshot '{"name":"release-notes","instructions":"Summarize the diff and write docs/CHANGELOG entries.","tool_policy":{},"timeout_seconds":600,"budgets":{}}' \
  --repository '{"kind":"git","remote":"/path/to/your/repo","base_revision":"<COMMIT_SHA>","subdirectory":null}' \
  --permission-policy '{"tools":[],"network":false}' \
  --timeout-seconds 600
Created execution request: exec_0fe
  state: queued
  id:    exec_0fe7252989f5f3d40a056c1da45b035039e4a8247ad89e5222cf9280134ec5d1
tack execution get exec_0fe7252989f5f3d40a056c1da45b035039e4a8247ad89e5222cf9280134ec5d1
Execution request exec_0fe7252989f5f3d40a056c1da45b035039e4a8247ad89e5222cf9280134ec5d1
  state:   succeeded (done)

That is a completed attempt, reached either way. See Running an item with an agent for all four ways to create a request and the full field-by-field reference.


Development mode (run from source)

For contributing to Tack or running with hot reload. The API server and Vite dev server run as separate processes.

Prerequisites:

  • Rust toolchain via rustup (stable, 1.94+)
  • Node.js 22+ and npm
rustc --version
node --version

Terminal 1 — API server:

git clone https://github.com/yielab/tack.git
cd tack
cargo run -p tack-cli -- serve

The server binds to http://127.0.0.1:3210 and runs migrations on first start.

Terminal 2 — frontend dev server:

cd frontend
npm install
npm run dev

Vite starts at http://localhost:5173 and proxies all /api/* requests to the API server. Open that URL in a browser.

Build the single binary yourself

One process that serves both the API and the SPA — what the release archives ship:

# 1. Build the frontend
cd frontend && npm run build && cd ..

# 2. Build the API with the embedded SPA
cargo build --release --features embed-spa -p tack-cli

# 3. Run it
./target/release/tack
# open http://127.0.0.1:3210

Without --features embed-spa the binary serves only the API; use the Vite dev server or any static file host for the frontend.


Environment variables

VariableDefaultDescription
TACK_PORT3210Listen port
TACK_DATABASE_URLsqlite:tack.db?mode=rwcSQLite file path
TACK_LOG_LEVELinfotrace · debug · info · warn · error
TACK_API_TOKEN(none)When set, all API calls need Authorization: Bearer <token>

See Configuration for the full reference and tack.toml format, and Administration & Security for tokens, CORS, webhooks, and cloud backup.

Step-by-Step Tutorial

From a fresh machine to a finished agent run: install Tack, create a project, add tasks, say what "done" means for one of them, set up the agent that will work on it, hand the task to Claude Code, watch it run, and download the result. Every screenshot below is a real capture from one live session against a clean database — including the run itself, which is a real model call, not a mock. (The capture recipe lives in frontend/e2e/tutorial-assets.spec.ts; see Regenerating these screenshots.)

This page walks one path: the release binary, the web UI, and the embedded runner. The Quick Start covers the alternatives — desktop app, CLI-only agent runs, building from source.


1. Install and start Tack

Tack is a single self-contained binary — web UI, REST API, and SQLite engine in one file. One line installs it:

curl -fsSL https://raw.githubusercontent.com/yielab/tack/main/install.sh | sh
tack            # starts the server + web UI at http://localhost:3210

Verify it's up:

curl http://localhost:3210/api/health
# {"migrations_applied":81,"status":"ok","version":"0.1.0-beta.10"}

migrations_applied is how many migrations this build actually ran — trust that field over the number above, which is what the build this tutorial was captured on reported. If this fails, see Troubleshooting.

Homebrew, Windows, and the other install methods are in the [Quic## 2. First open

Open http://localhost:3210 in a browser. On a fresh database there is nothing yet — just the invitation to create a project:

Tack's first open on an empty database: the Projects page with a 'Create your first project' button and the sidebar showing All projects, Templates, Agents and Settings.

3. Create a project

Click New Project (or Create your first project). Give it a name, optionally a description, and pick a project type — the type is a template that pre-loads a matching workflow and vocabulary you can change later. This tutorial creates Website Relaunch as a Software (Scrum) project:

The Create New Project modal with name 'Website Relaunch', a description, 'Start blank' selected, and the Software (Scrum) project type chosen.

Click Create Project and the new board opens — empty columns from the Scrum workflow, plus a three-step onboarding card:

The empty board of the new project: Backlog, To Do, In Progress and In Review columns with zero items, and a 'Your project is ready' onboarding card on top.

4. Add tasks

Click the + in any column (or the onboarding card's + Add Item). Only the title is required; type, priority, description, story points, subtasks and tags are there when you want them. The first task is the one an agent will write:

The Create New Item modal: title 'Draft the launch announcement', type Task, priority Medium, and a one-line description of the post in the rich-text editor.

A few tasks later the board is a board. The banner on top offers to let this board run its items with an agent; this tutorial gets there in step 6:

The board with four tasks in Backlog — Draft the launch announcement, Redesign the pricing page, Migrate DNS to the new host, Refresh the screenshots in the docs — and a banner reading 'This board can run its items with an agent' with a Turn on link.

5. Say what "done" means: the brief

Click the task to open its drawer, then the Brief tab. A brief is the definition of done that travels with the task: acceptance criteria, constraints, a definition of done in your own words, and a risk level. Each criterion has a kind — a command, a test, a metric, a file that must or must not exist, or a check a person makes. Prefer a kind a machine can run; this announcement has nothing to run, so both criteria are Manual and the tab marks them as costing a person's time. Save brief stores it:

The Brief tab of 'Draft the launch announcement': two Manual criteria, 'About 400 words' and 'Ends with a call to action', each with what a person must check and marked 'Costs a person's time', no constraints, a definition of done, Risk not set, and a Save brief button.

The agent receives the brief with the task's title and description, and the run keeps a copy of it. See Brief tab for every kind of criterion.

6. Set up the harness: turn agent execution on

A harness is the coding agent that does the work — Claude Code here. Tack does not install it or sign you in to it: install Claude Code and sign in once with its own claude command, exactly as you would to use it by hand. Tack then finds it.

By default nothing here executes anything — the server is a full project manager with agent execution off. Open the Agents page from the sidebar. Step 1 is the one switch that matters:

The Agents page with execution off: step 1 'Agent execution on this machine' shows a Stopped badge and a Turn on button; the later steps are waiting on it.

Click Turn on. This starts an embedded runner inside the same server process — no restart, no second binary. Step 2 lists the harnesses it found on this machine, with their installed versions; this machine has Codex and Claude Code, and the tutorial uses Claude Code. Step 3 shows each one's sign-in command and says plainly that Tack cannot see whether that sign-in worked — the run in step 9 is what proves it. If you'd rather not use the harness's own subscription, paste a Vercel AI Gateway key there instead:

The Agents page after Turn on: execution Running with its start time, Codex and Claude Code both detected with their installed versions, each sign-in command listed as 'Present, unverified', the Vercel AI Gateway key panel, the empty Default model step and the Test run form.

7. Set up the agent: a default model and a profile

Step 4 on the same page sets the project's default model, so the run dialog needs no hand-typed identifiers. Pick Type a model id, enter the provider and a model your harness accepts, and Save:

The Default model section with mode 'Type a model id', provider 'anthropic', model ID 'claude-sonnet-5-5', and a Save button.

An agent profile is the agent's standing orders: a name and the instructions sent with every run that uses it, plus an optional tool policy and limits. Open Advanced at the bottom of the Agents page, then Agent profiles, and + Create agent profile. This one writes announcements and is told not to call any tool:

The Advanced section of the Agents page on its Agent profiles tab: the create form with name 'Announcement writer', instructions to write a 400-word launch announcement and reply with the text only without calling any tool, empty tool policy and limits, and a Create button.

Click Create. With one profile, the run dialog selects it on its own.

8. Assign the task to an agent

Back on the board, every card carries a Run with agent button (the ▶ on the card, or the same button in the item's drawer). The dialog reads top to bottom as the whole run, and every row says Ready or what is missing:

  • Who runs it — this machine's runner, the Announcement writer profile, the harness (choose Claude Code) and the model, already on Project default. The dialog states why this pairing is allowed: the runner reports that the harness passes the chosen model through as given.
  • What it gets — the task and its brief, and the repository the agent works in (Change for this run to point it at one; here a local demo repository).
  • How far it may go — Automatic lets the agent decide on its own; Ask me pauses it before each tool call until you answer in the task's decision inbox. Claude Code's tools are a checklist; none is ticked, matching the profile.
  • What happens after — verifying the result, pushing a branch and opening a pull request. This runner has none of them set up, so each is shown disabled with the reason and the config that turns it on (see After a succeeded attempt).
The Run with agent dialog for 'Draft the launch announcement'. Who runs it: runner connected, profile Announcement writer, harness Claude Code, model Project default — anthropic / claude-sonnet-5-5, each Ready. What it gets: the item, its brief, and a git repository with remote and base revision. How far it may go: Automatic, Claude Code's tool checklist, network off, timeout 3600. What happens after: Verify the result, Push the branch and Open a pull request disabled with their reasons. The Run button is enabled.

9. Run it and track it

Click Run. The request queues, the runner leases it and the attempt starts — and the board says so without being asked: the card carries a live state chip, here still Queued. Clicking the chip opens the task's Execution tab:

The board right after Run: the 'Draft the launch announcement' card carries a live 'Queued' chip.

The Execution tab shows the attempt as it happens: the request Leased, the attempt Running on this machine's runner, and cost tiles that read Not measured until the attempt reports real numbers:

The task drawer's Execution tab mid-run: the request is Leased, Attempt #1 shows a Running badge leased just now, provenance reads 'Not yet reported', and the cost tiles read Not measured.

10. See the result

When the attempt finishes, the same tab is the record of what happened: the state, whether the model that ran matched the one requested (reported by the harness, not assumed), and what it cost — measured figures labelled as measured, unmeasured ones saying so. This run took 30 seconds and $0.05:

The finished attempt: request Succeeded, Attempt #1 Succeeded, 'Matched request — ran on anthropic / claude-sonnet-5-5, as requested', model/token cost $0.05 measured, runner time 30s, runner time cost Not measured, and a 'Show events, decisions & artifacts' link; the board card behind carries a Succeeded chip.

Show events, decisions & artifacts expands the attempt's full record. Its Artifacts are what the run produced, each one click from download: the harness's own run log (the announcement is in it), and the record of the change — changes.patch (empty here: this agent only wrote text), files.json, the brief.json it was given, and the evidence.json manifest that ties them together:

The Artifacts section of the finished attempt: claude-code-run.log (log, 26.7 KB), changes.patch (patch, 0 B), files.json (files, 2 B), brief.json (brief, 637 B) and evidence.json (evidence, 6.5 KB), each with a Download button.

That's the whole loop: a task on a board with its definition of done, handed to a real agent, tracked live, and closed with a result you can check. From here:


make the board speak your domain's language.


Regenerating these screenshots

Every image on this page comes from frontend/e2e/tutorial-assets.spec.ts, driven against an already-running release build (--features embed-spa) of this checkout on a fresh database, with a real, signed-in claude on PATH. There is no make target for it on purpose: the run in steps 9–10 is a real, live, billed model call. The config file, frontend/playwright.tutorial-assets.config.ts, carries the exact recipe.

Views

Tack has six work tabs — Board, List, Table, Calendar, Timeline, Sprint — all showing the same item set. Switching between tabs never re-fetches data; an item created on the Board appears immediately in every other view. Two more screens, Overview (Dashboard) and Factory metrics, are accessible from the sidebar and show project statistics.

Every view shares the same shell: a sidebar to switch project and view, a top bar with item search and a + New button, and the command palette on Ctrl+K. Theme and accent palette are set from the sidebar footer — see Appearance.


Board

Kanban columns driven by the project's workflow statuses.

  • Each column corresponds to one workflow status. Names, order, and WIP limits come from Settings → Workflow. Columns derive directly from the workflow — no "create a board" step is needed.
  • Drag and drop a card to change its status. Changes save immediately with optimistic UI.
  • WIP limit is shown in the column header when set. Dragging a card into a full column is blocked.
  • Click a card to open the item detail drawer (Details, Fields, Dependencies, Activity, Files tabs).
  • Add an item via the + button inside a column header (pre-sets the status) or the toolbar + New button.
  • Board state syncs in real time via WebSocket — changes in one browser tab appear in another.
  • Empty projects show a three-step onboarding checklist until the first item is created.

List

Sortable, flat or hierarchical table of all items.

  • Flat mode (default): items sorted by creation date; use the sort dropdown to reorder by priority, status, or type.
  • Hierarchy toggle: enable in the toolbar to indent items by parent_id. Expand/collapse with the ▸ arrow. When every child of an item reaches a Done status, the parent auto-moves to Done — cascading up the hierarchy.
  • Inline create: click + at any level to create a new item at that position.
  • Inline edit: click any field (title, type, priority, status) in the row to edit it.
  • Bulk operations: check multiple rows, then use the bulk action bar to move all to a new status or delete them.
  • Filter by status, priority, and type using the toolbar dropdowns.

Table

A dense spreadsheet view of every item — title, type, status, priority, assignee, and due date in sortable columns.

  • Click a column header to sort by it; click again to reverse. Title, status, priority, assignee, and due date are all sortable.
  • Filter with the search box — matches across title, assignee, and status as you type.
  • Inline edit: click an editable cell (title, status, priority, assignee, due date) to change it in place; the edit saves immediately.
  • Best for scanning or bulk-triaging a large backlog where the card layout is too tall.

Calendar

Monthly grid positioned by due date. Drag to reschedule.

  • Items appear on the day matching their due_date. Items without a due date appear in the No Date tray at the bottom.
  • Drag an item from one day cell to another to change its due_date. Drag from the No Date tray onto a day to schedule it.
  • Navigate months with ← / → or jump to today.
  • Click an item to open the detail drawer.

Timeline

Gantt-style horizontal bar chart. Drag to reschedule.

  • Each item with a started_at and/or due_date is rendered as a bar spanning its date range.
  • Drag a bar horizontally to shift the date range. Drag either edge to resize (set started_at or due_date independently). Changes snap to the active grid.
  • View modes: Week, Month, Quarter — toggle in the toolbar.
  • Dependency overlay: items blocked by another item are marked with an indicator from the dependency graph.
  • Scroll horizontally to see longer projects. Click a bar to open the detail drawer.

Sprint

Two-pane sprint planning surface.

  • Left pane (Backlog): items not assigned to any sprint, sorted by priority.
  • Right pane (Sprint lanes): one lane per sprint in Planning or Active state, showing capacity vs. commitment (story points), item count, and a done/total progress bar.
  • Drag from the Backlog into a sprint lane to assign sprint_id. Drag between sprint lanes to reassign. Drag back to the Backlog to unassign.
  • The server enforces the rule that items can only join sprints in Planning or Active state.
  • Sprints move through four states: Planning → Active → Review → Closed. Advance the lifecycle with the status buttons on each sprint lane header.

Overview (Dashboard)

Read-only project statistics — accessible from the sidebar.

  • Throughput chart: items completed per time period.
  • Status breakdown: item counts per workflow column with colour-coded category bars.
  • Priority breakdown: counts by priority level.
  • All statistics are computed live from the item set — no aggregation job.

Factory metrics

Read-only, per project — Factory metrics in the sidebar. It answers how well agent work is going on this project: how often a run needed a person, how long the person took, what verification cost in tokens, how often a merge-readiness pack was accepted, and how many pull requests agents opened, merged, closed or had reverted. A card whose input does not exist yet says Not measured and why, never 0; no card shows money. Every number, its definition and the counts behind it are in Factory metrics in the Agent Runners chapter.

The Factory metrics page: escalation rate 50% (1 decision / 2 attempts), human minutes per decision Not measured with its reason, human minutes per pack review, pack acceptance rate 100%, verification tax 4.2% in tokens with its inputs, and pull-request outcomes.

Working with Items

Every piece of work in Tack — a task, bug, feature, building, work order, or whatever your project's vocabulary calls it — is an item. You inspect and edit an item through the item detail drawer, a panel with inline header editing and tabs for Details, Brief, Activity, Execution, Dependencies, Files, and Fields.


Opening an item

The drawer opens whenever the ?item=<id> query parameter is present in the URL. You open it by:

  • Clicking a card on the Board, a row in the List or Table, or a search result.
  • Navigating directly to a link such as https://tack.test/board?item=<id>.

Because the open item lives in the URL, item links are deep-linkable and shareable — paste the link to a teammate and the same drawer opens for them. Press Esc or use the close control to dismiss the drawer; focus returns to where you were.

At the top of the drawer, the header shows:

  • A type badge (labeled with your project's vocabulary, e.g. "Task" or "Work Order") and the item's short id — the first six characters of the id, uppercased.
  • The editable title. Edit it inline; the change commits when you blur the field or press Enter.
  • A row of status pills, one per workflow status. Click any inactive pill to transition the item to that status. The transition is validated by the project workflow (allowed transitions and WIP limits are enforced server-side); if it is rejected the item reverts and an error toast appears.
  • Priority, Estimate, Due date, and Sprint controls.
  • Tags — type a tag and press Enter to add; click the × on a tag to remove it.

All header edits save immediately (optimistic update: applied locally first, then persisted).


Details tab

The Details tab holds the item description, edited with the rich-text editor. Use it for details, acceptance criteria, or notes.

The description autosaves — edits are debounced and persisted automatically a short pause (about 0.6 seconds) after you stop typing. There is no save button.

Core metadata — assignee, priority, estimate, sprint, due date, and labels (tags) — lives in the header above the tabs (see Opening an item), so it is always visible regardless of which tab is active.


Brief tab

The Brief says what "done" means for an item, in a form a person or a program can check. It has four parts, and Save brief writes them all at once.

The Brief tab of an item: acceptance criteria, each with a kind — Manual (marked as costing a person's time), Test and Command — a title, and the fields its kind needs.

Acceptance criteria are the checks that must pass. Each has a title and one of six kinds:

  • Command — a shell command and the exit code it must return (0 by default), with an optional working directory.
  • Test — a named test, with an optional runner such as pytest.
  • Metric — a named measurement compared to a threshold: at most, at least, or exactly, with an optional unit.
  • File exists — a path that must be present.
  • File is absent — a path that must not be present.
  • Manual — something a person has to check by hand.

Use Manual as a last resort. Every other kind can be checked without anyone's time, so a Manual criterion is marked as one that costs a person. Reach for it only when nothing else can express the check.

Constraints limit how the work is done: a path that must not change, a dependency that is allowed, a maximum number of changed files, or a free-form note.

Definition of done is a few plain sentences on when someone can stop and call the item finished. It covers what the criteria cannot, and it is the first thing a reviewer reads.

Risk is low, medium or high. Leave it unset if you are not sure.

A brief holds up to 50 criteria and 100 constraints, and each criterion needs an id that is unique within the brief (the editor chooses one for you). If the server rejects a save, the reason appears next to the part it concerns.


Fields tab (custom fields)

Custom fields capture project-specific data that the built-in fields don't cover — a vendor name, a budget figure, a compliance flag, and so on. They are defined per project (in project settings); the Fields tab shows every field defined for the item's project, plus role assignment.

Set a value by typing or selecting in the control next to the field name. Each change is saved as you commit it; clearing a text-like field removes its value. Values are validated on save against the field's type — an invalid value is rejected and shown as an error toast, not stored.

Field types

TypeAcceptsExample
TextAny stringAcme Corp
Long textAny string (multi-line textarea)Multi-paragraph notes…
EmailA stringinfo@yielab.com
URLA string starting with http:// or https://https://example.com/spec
NumberA numeric value42
BooleanA checkbox (true/false)true
DateAn ISO 8601 date — YYYY-MM-DD or RFC 33392026-06-30
SelectOne value chosen from the field's defined optionsIn review
Multi-selectAn array of values, each from the field's options["frontend","urgent"]

A field definition may also carry extra validation rules that apply on top of the type check: a regex pattern and min_length/max_length for strings, min/max for numbers, and max_items for multi-select. Values that violate these rules are rejected with a descriptive message.

If a project has no custom fields, the tab shows "No custom fields — define fields in project settings."


Execution tab

The record of every time an agent was handed this item. Each request lists its attempts, with the state, the runner, the model that ran against the one requested, and what it cost (or Not measured). When the attempt's branch became a pull request, a PR #n link and a badge (open, merged, closed or reverted) sit on the attempt. Show events, decisions & artifacts opens its timeline, the questions the agent asked, the files it produced, and, when a verifier ran, the merge-readiness pack to accept or reject. A question that is still waiting appears in the decision inbox on the same tab, with the options, their risks and the agent's recommendation. Everything on this tab, and how to start a run, is in Agent Runners; the item's Brief is what the agent is given as the definition of done. An item that was never run shows an empty tab, not a hidden one.


Dependencies tab

Use this tab to record how an item relates to others in the same project. Two directions are supported:

  • Blocks — this item must be done before the linked item can proceed.
  • Blocked by — the linked item must be done before this one can proceed.

The tab lists current Blocks and Blocked by links. Click a linked item's title to open its drawer. Click the × to remove a link.

To add a dependency, pick a direction, choose the other item from the picker, and select Add.

Tack's dependency graph is a DAG (directed acyclic graph), so:

  • Cycles are rejected. If adding a link would create a loop (A blocks B, B blocks A), the server refuses it and the error is shown inline beneath the form.
  • Self-references are rejected — an item cannot depend on itself. The picker only offers other items in the project.

Activity tab

The Activity tab is the item's comment timeline. Comments are listed oldest-first, each showing the author (or "Anonymous" when none is recorded) and a relative timestamp (just now, 5m ago, 3h ago, 2d ago, then a calendar date for older entries).

To add a comment, type in the box at the bottom and select Comment. Posting is optimistic — your comment appears immediately and is rolled back with an error toast if the server rejects it.


Files tab (attachments)

Attach files to an item from the Files tab:

  • Drag and drop files onto the drop zone, or click it to browse. Multiple files at once are supported.
  • The maximum file size is 50 MB per file. A larger file is skipped with an error toast and the rest continue uploading.

Each uploaded file is listed with its size and MIME type. Click a filename to download it (served with the original filename); use Delete to remove it.

On the server, the file bytes are written under the configured storage directory (TACK_STORAGE_DIR, default ./storage), organized by item id under a collision-proof generated filename, while the metadata (filename, MIME type, size, storage path) is recorded in the database. Deleting an attachment removes both the file on disk and its database record.

API note. Uploads use multipart/form-data with a file field:

POST http://127.0.0.1:3210/api/items/<item-id>/attachments

The 50 MB limit is enforced server-side; oversize uploads return 400 Bad Request.


Comments

Comments live in the Activity tab (see above) — that tab is the item's discussion thread. Open the item, switch to Activity, write in the comment box, and select Comment to post.

Command Palette & Search

Tack has two keyboard-first ways to get around: a command palette for jumping to views and running actions, and a search bar for finding items by text.


Command palette — Ctrl+K

Press Ctrl+K (or ⌘K) anywhere to open the command palette. It opens centered, focused, and ready for input.

  • Type to filter. Results are grouped into sections — Actions (New Item, New Project) and Go to (Board, List, Table, Calendar, Timeline, Sprint, Overview, Project Settings) and Workspace (All Projects, Templates, Global Settings). The available commands depend on context — item/view commands appear only while you're inside a project.
  • Navigate with ↑ / ↓, run the highlighted command with ↵, and dismiss with esc.
  • The palette is also reachable from the Search… button in the sidebar and the ⌃K button in the top bar.

Search — Ctrl+/

Press Ctrl+/ (or ⌘/), or click the Search items… field in the top bar, to search items by text.

  • Searches run as you type (debounced) against the project you're in, or across the whole workspace when you're not scoped to a project.
  • Each result shows the item's type badge, priority, and current status.
  • ↑ / ↓ to move through results, ↵ to open the highlighted item — this deep-links straight to its detail drawer over the project board. esc closes the results.

Search matches item titles (and other indexed fields) via the backend's full-text search index, so it scales to large projects.

Appearance

Tack ships a two-axis theme system: a mode (light or dark) and a palette (the accent + surface colour family). Both are controlled from the bottom of the sidebar and take effect instantly across the whole app.

The controls live in the sidebar footer: a sun/moon button toggles the mode, and four coloured dots switch the palette.


Mode — light / dark

Click the sun/moon button in the sidebar footer to flip between light and dark.

Until you pick one explicitly, Tack follows your operating system's prefers-color-scheme setting. The first toggle pins an explicit choice that overrides the OS preference from then on.

Palette

Four palettes ship, each available in light and dark:

PaletteAccentFeel
Harbor (default)harbor blue, with coralsoft, nautical, the default brand
Tealtealcalm
Claywarm terracottawarm, earthy
Graphitelime on neutral greyhigh-contrast, understated

Click a swatch in the sidebar footer to switch. Every surface, accent, badge, and chart re-colours immediately — there is no reload and no per-view setting.


How it's stored

Both choices are saved in the browser's localStorage:

  • tack_theme → light | dark | system
  • tack_palette → harbor | teal | clay | graphite

Because Tack is local-first and single-user, appearance is per browser — it is not stored in the database and not synced between machines. Clearing site data resets both to their defaults (system mode, Harbor palette).

Accessibility

All palette/mode combinations are tuned to meet WCAG 2.1 AA contrast (4.5:1 for text), and the choice is verified automatically by an axe accessibility scan in CI. If you fork Tack and change the colour tokens, keep that bar in mind — see Frontend & Design System for where the tokens live.

Workflows

A workflow is the set of named status columns that items move through in a project. Each column has:

  • Name — the label shown on the board (e.g., "In Progress")
  • Category — todo, in_progress, or done
  • WIP limit — optional cap on how many items can be in this column at once
  • Order — left-to-right position on the board

Every project stores exactly one WorkflowConfig as JSON — no schema migration needed to change it.


Built-in Project Types

When you create a project, its type determines the starting workflow. Everything can be changed afterwards.

TypeStyleDefault Columns
software, web, mobileScrumBacklog → To Do → In Progress → In Review → Done
constructionPhase-based, strictPermit → Procurement → Build → Inspect → Handover
legalPhase-basedIntake → Discovery → Drafting → Review → Closed
researchKanbanHypothesis → Design → Experiment → Analysis → Published
eventPhase-basedIdeas → Booked → In Progress → Confirmed → Done
personal, homeworkSimpleTo Do → Doing → Done
maintenanceKanbanBacklog → In Progress → Done (no sprints)
customSimpleTo Do → Doing → Done (fully editable)

Strict Transitions (Construction Workflow)

Most workflows let you move an item to any column. The construction workflow enforces strict linear transitions: items must move through columns in order.

FromOnly allowed next step
PermitProcurement
ProcurementBuild
BuildInspect
InspectHandover

Attempting to move a "Build" item directly to "Handover" is rejected by the API (422 Unprocessable Entity). This prevents accidentally skipping required phases — regulatory sign-offs, physical dependencies, multi-party hand-offs.

Enable or disable strict transitions on any workflow from Settings → Workflow → Strict transitions toggle.


Changing a Workflow After Creation

In the UI: Settings → Workflow → add/remove/rename columns, set WIP limits, toggle strict transitions → Save.

Existing items keep their current status name. If you rename a column, items in the old status are not migrated automatically — update them via the board or the API.

Via API:

curl -X PATCH http://localhost:3210/api/projects/{id} \
  -H "Content-Type: application/json" \
  -d '{
    "workflow": {
      "columns": [
        {"name":"Permit",      "category":"todo",        "wip_limit":null, "order":0},
        {"name":"Procurement", "category":"in_progress", "wip_limit":2,    "order":1},
        {"name":"Build",       "category":"in_progress", "wip_limit":3,    "order":2},
        {"name":"Inspect",     "category":"in_progress", "wip_limit":1,    "order":3},
        {"name":"Handover",    "category":"done",        "wip_limit":null, "order":4}
      ],
      "strict_transitions": true
    }
  }'

WIP Limits

A WIP limit caps the number of items in a column. Adding a card beyond the limit is blocked in the UI (the card snaps back) and rejected by the API.

WIP limits are enforced on new moves, not retroactively. If you set a limit of 3 on a column that already has 5 items, the existing 5 are unaffected — but no more can enter until the count drops below 3.

Set limits on bottleneck stages (code review, inspection, QA) to surface overload before it compounds.


Status Categories

Every column belongs to one category:

CategoryMeaningSide effects
todoNot startedItems default here on creation
in_progressBeing worked onSets started_at on first move in
doneCompleteSets completed_at; triggers auto-complete check on parent

Categories drive reporting (cycle time, throughput) and auto-complete logic. Only column names are shown in the UI.


Auto-Complete (Parent Rollup)

When an item moves to a done column, Tack checks whether all siblings under the same parent are also done. If they are, the parent auto-moves to its own done column. This cascades up the hierarchy.

Example:

  1. Epic "User Auth" has three tasks: Register, Login, Logout.
  2. Complete Register → epic unchanged (Login and Logout still open).
  3. Complete Login → epic unchanged.
  4. Complete Logout → all children done → epic auto-completes. ✓

Auto-complete is best-effort: errors (e.g., parent has no done column) are silently ignored and do not block the child update.

Vocabulary

Each project has a VocabularyMap: 16 configurable label keys that rename terms throughout the UI. Two projects can use completely different language while running on the same underlying system.


The 16 Keys

KeyDefaultConstruction exampleHomework example
epicEpicBuildingCourse
featureFeatureSectionModule
taskTaskWork OrderAssignment
subtaskSubtaskActivityQuestion
bugBugDefectCorrection
requirementRequirementSpecificationRubric Item
sprintSprintPhaseWeek
backlogBacklogPending WorkUpcoming
boardBoardProject BoardPlanner
blockerBlockerHoldDependency
story_pointsStory PointsEffort HoursEffort
assigneeAssigneeResponsibleStudent
deliverableDeliverableDeliverableSubmission
phasePhasePhaseTerm
milestoneMilestoneInspection PointExam
releaseReleaseHandoverGraduation

All keys are optional. Omitted keys fall back to the default label.


Changing Vocabulary

In the UI: Settings → Vocabulary → edit fields → Save. Changes take effect immediately across all views for that project.

Via API:

curl -X PATCH http://localhost:3210/api/projects/{id} \
  -H "Content-Type: application/json" \
  -d '{"vocabulary":{"task":"Work Order","sprint":"Phase","epic":"Building"}}'

Only include the keys you want to change; omitted keys are left as-is.


Vocabulary is Per-Project

A "Sprint" in a software project and a "Phase" in a construction project are the same underlying concept — only the label differs. Vocabulary is scoped entirely to the project and does not affect other projects.


CLI Behavior

The CLI displays vocabulary-mapped labels in human-readable mode. Use --json to bypass labels and get raw field names:

tack list --project <id>          # shows "Work Order" instead of "Task"
tack list --project <id> --json   # returns {"item_type":"task", ...}

Practical Tip

Set vocabulary before adding items. Labels appear in the item creation form, board column headers, filter dropdowns, and export files. Changing vocabulary mid-project is safe (purely cosmetic) but can cause confusion in shared contexts.

Starter vocabulary for a construction project:

{
  "task":      "Work Order",
  "epic":      "Building",
  "sprint":    "Phase",
  "requirement": "Specification",
  "assignee":  "Responsible",
  "blocker":   "Hold",
  "release":   "Handover",
  "milestone": "Inspection Point"
}

Import and Export

Tack can pull issues in from GitHub and Linear, keep linked GitHub issues in sync both ways, and export an entire project to JSON, YAML, or CSV. All import and export operations are exposed over the HTTP API; there is no dedicated CLI subcommand for them.

The examples below use a base URL of http://127.0.0.1:3210 and assume a server started with tack serve. If you set TACK_API_TOKEN, add -H "Authorization: Bearer <token>" to every request.


How do I import GitHub issues?

POST /api/projects/{id}/import-github fetches the issues from a repository and creates one Tack item per issue in the target project. Pull requests are skipped automatically. Each created item is recorded in the github_links table so its status and comments can later sync with GitHub (see GitHub sync).

Via API:

curl -X POST http://127.0.0.1:3210/api/projects/3f1c2b9a-8d4e-4a77-9b21-0c5e6f7a8b90/import-github \
  -H "Content-Type: application/json" \
  -d '{
    "repo": "rust-lang/rust",
    "token": "ghp_yourPersonalAccessToken",
    "import_closed": false,
    "label_filter": ["bug", "good first issue"]
  }'

Request fields:

FieldRequiredDefaultDescription
repoyes—Repository as owner/repo or a full URL (https://github.com/owner/repo, with or without a .git suffix or trailing slash).
tokennononeGitHub personal access token. Unauthenticated calls work but are limited to 60 requests/hour; a token raises this to 5,000/hour. A token with repo scope is required to read private repositories.
import_closednofalseWhen false, only open issues are imported. When true, both open and closed issues are imported.
label_filterno[]When non-empty, only issues carrying at least one of these labels are imported (case-insensitive). All others are skipped.

Tack pages through the repository 100 issues at a time until every matching issue has been processed, so a single call imports the whole repo.

Field mapping:

GitHubTack item
number + titleTitle, formatted as [#123] Issue title
bodyDescription, prefixed with a GitHub Issue: <url> line
labelsTags (one tag per label)
state (open / closed)Status — the first workflow status by order for open issues, the first Done-category status for closed issues
assignee.loginAssignee

Every imported item is created as a Task. The response reports counts:

{ "created": 42, "skipped": 3, "rate_limit_remaining": 4958 }

skipped covers pull requests, issues filtered out by label_filter, and any rows that failed to create.


How do I import Linear issues?

POST /api/projects/{id}/import-linear fetches issues from Linear's GraphQL API and creates Tack items. Pagination is cursor-based (50 issues per page) and runs until all matching issues are imported.

Via API:

curl -X POST http://127.0.0.1:3210/api/projects/3f1c2b9a-8d4e-4a77-9b21-0c5e6f7a8b90/import-linear \
  -H "Content-Type: application/json" \
  -d '{
    "api_key": "lin_api_yourKeyHere",
    "team_id": "ENG",
    "import_completed": false,
    "label_filter": ["frontend"]
  }'

Request fields:

FieldRequiredDefaultDescription
api_keyyes—Linear personal API key. Create one at https://linear.app/settings/api.
team_idnononeImport only issues from this team. Accepts the team key/slug (for example ENG).
project_idnononeImport only issues from this Linear project ID. Takes precedence over team_id when both are set.
import_completednofalseWhen false, completed and cancelled issues are skipped. When true, they are imported.
label_filterno[]When non-empty, only issues carrying at least one matching label are imported (case-insensitive).

When neither team_id nor project_id is given, every issue accessible to the API key is fetched.

Field mapping:

LinearTack item
identifier + titleTitle, formatted as [ENG-123] Issue title
descriptionDescription, prefixed with a Linear Issue: <url> line
labelsTags (one tag per label)
state.type (completed / cancelled)Status — first Done-category status; all other states map to the first workflow status by order
assignee.nameAssignee
priorityPriority (see below)

Priority mapping:

Linear priorityTack priority
1 (Urgent)Critical
2 (High)High
3 (Medium)Medium
4 (Low)Low
0 (No priority)unset

The response reports { "created": N, "skipped": N }.


How do I keep GitHub issues in sync after import?

Items imported from GitHub stay linked to their source issue, and any other item can be linked by hand — the Link GitHub issue row in the item's side panel, or PUT /api/items/{id}/github-link with {"repo": "owner/name", "issue_number": 42} (DELETE unlinks). When TACK_GITHUB_TOKEN is set, the link carries state and comments in both directions:

  • Out, on every change: moving a linked item into a Done-category status closes its GitHub issue; moving it back out of Done reopens it. A comment posted on the item is posted onto the issue.
  • In, on a poll (TACK_GITHUB_POLL_SECONDS, off at 0): a closed issue moves its item to the workflow's first Done-category status, a reopened one to the first Todo-category status, and a new comment on the issue appears on the item, attributed to its GitHub login.

Both directions are best-effort — failures are logged but never block or fail the item update or the comment — and neither echoes: a change that came in never triggers a push back out. Title edits and same-category status moves trigger no GitHub call; labels and assignees are not mirrored. There is no webhook receiver: Tack listens on loopback by default, so GitHub could not reach it.

The feature is off by default and configured through environment variables. A project can also carry its own token as a secret reference (PATCH /api/projects/{id} with {"github_token_ref": "store:<name>"}), resolved before the global one and never returned by any route:

VariableDefaultDescription
TACK_GITHUB_TOKENnonePAT with repo scope. Enables both directions; never logged. Without it, the link is inert.
TACK_GITHUB_API_BASEhttps://api.github.comAPI root override for GitHub Enterprise or testing.
TACK_GITHUB_POLL_SECONDS0Inbound poll interval in seconds; 0 is off. Needs the token too.

For full details, see GitHub Sync.


How do I export a project to JSON?

GET /api/projects/{id}/export?format=json returns a complete, downloadable snapshot of the project as an attachment named <project-name>-export.json.

Via API:

curl -OJ "http://127.0.0.1:3210/api/projects/3f1c2b9a-8d4e-4a77-9b21-0c5e6f7a8b90/export?format=json"

The snapshot contains:

  • project — full project record including workflow and vocabulary
  • items — every item in the project
  • sprints — all sprints
  • dependencies — all dependency edges
  • briefs — every item's brief (acceptance criteria, constraints, definition of done, risk)
  • metadata — exported_at timestamp, the exporting Tack version, and totals for items, sprints, and dependencies

format defaults to json, so omitting the query parameter produces the same result. A format=yaml variant is also available and produces the identical structure as YAML.

This snapshot is the same shape accepted by POST /api/projects/import, so an exported JSON or YAML file can be re-imported to recreate the project (items, sprints, parent links, dependencies and briefs are all restored into a brand-new project). A brief in the file is checked by the same rules as a save from the Brief tab, and the import is refused if one breaks them, since an export file can be edited by hand. The response counts briefs_imported beside the other totals.


How do I export a project to CSV?

GET /api/projects/{id}/export?format=csv returns a flat, spreadsheet-friendly item list as an attachment named <project-name>-export.csv.

Via API:

curl -OJ "http://127.0.0.1:3210/api/projects/3f1c2b9a-8d4e-4a77-9b21-0c5e6f7a8b90/export?format=csv"

The CSV has one row per item with these columns:

ColumnDescription
idItem UUID
titleItem title (commas replaced with spaces)
typeItem type (task, bug, epic, etc.)
statusCurrent workflow status
priorityItem priority
assigneeAssignee, or empty if unassigned
parent_idParent item UUID, or empty if top-level
created_atCreation timestamp (RFC 3339)

CSV export covers items only — it does not include sprints, dependencies, briefs, or workflow configuration. Use JSON or YAML export for a full, re-importable backup.


Which should I use?

Use GitHub or Linear import to seed a Tack project from work already tracked elsewhere; choose GitHub import (with TACK_GITHUB_TOKEN set) if you also want completed Tack items to close their upstream issues. Use JSON (or YAML) export for a complete, re-importable backup or to move a project between Tack instances, since it preserves workflow, sprints, dependencies, and hierarchy. Use CSV export when you only need a quick item list for a spreadsheet or report.

Backup and Restore

Tack offers three data-protection mechanisms: hot backup (a database-only SQLite copy), remote/cloud backup (a full bundle including on-disk files), and JSON export (a human-readable per-project snapshot). All three can be triggered via the API or the CLI.


Hot Backup

Uses SQLite's VACUUM INTO to produce a clean, consistent copy of the database while the server is running. No downtime required.

Via CLI:

tack backup                        # timestamped file in current directory
tack backup --path /backups/tack.db

Via API:

curl -O -J http://127.0.0.1:3210/api/backup
# With token:
curl -O -J -H "Authorization: Bearer <token>" http://127.0.0.1:3210/api/backup

What the backup includes:

  • All projects, items, sprints, roles, comments, dependencies
  • Attachment metadata (filenames, sizes, MIME types)
  • Workflow configs and vocabulary maps
  • Migration history

What it does NOT include:

  • Attachment files and execution artifacts — both stored under TACK_STORAGE_DIR (default ./storage; execution artifacts specifically in TACK_STORAGE_DIR/execution-artifacts). Back that directory up separately, or use Remote/Cloud Backup below, which includes it automatically.

Staged Restore

Restore does not replace the live database while the server runs. You stage a file; the swap happens on the next startup.

Steps:

  1. Upload the backup:
tack restore /backups/tack.db

or via API:

curl -X POST http://127.0.0.1:3210/api/restore \
  -F "file=@/backups/tack.db"
# → {"status":"staged","message":"Restart the server to apply."}
  1. Restart the server. On startup, Tack:
    • Moves tack.db → tack.db.bak
    • Moves the staged file → tack.db
    • Runs any pending migrations

The previous database is kept as tack.db.bak. Delete it once you've verified the restore.


Remote/Cloud Backup

Unlike the hot backup above, a remote backup bundle includes the database and every file under TACK_STORAGE_DIR — item attachments and, since Part III, execution-artifacts/ (artifacts a runner uploaded for an execution attempt — logs, diffs, generated files; see Agent Runners). This is the one backup mechanism that captures artifacts without a separate rsync step. It requires cloud object storage to be configured — see Cloud Backup for the S3-compatible setup — and is otherwise inert.

tack backup --remote                 # push a bundle now
tack backups                         # list bundles in the bucket

The bundle is a zstd-compressed tar (database.db, the recursively-walked storage tree, and a manifest.json with a sha256 of the database and an item count). Secrets are scrubbed from the embedded database snapshot before it's bundled — see scrub_snapshot_secrets in crates/tack-api/src/remote_backup.rs, kept in sync with every secret-bearing column in the same commit that adds one.

Restore is staged the same way as local restore — download, stage, restart:

tack restore --remote --key <object-key>   # omit --key to restore the latest bundle

On the next startup, the staged database swaps in and the staged storage tree is merged into TACK_STORAGE_DIR — so a remote restore recovers execution artifacts and attachments together with the database, in one step, unlike the local hot-backup path below.


JSON Export (Project Snapshot)

A human-readable snapshot of a single project. Not a substitute for a full backup, but useful for archiving completed projects or migrating between instances.

# Full project snapshot (all items, sprints, roles, comments, dependencies)
curl "http://127.0.0.1:3210/api/projects/{id}/export?format=json" -o project.json

# Item list as CSV (for spreadsheets)
curl "http://127.0.0.1:3210/api/projects/{id}/export?format=csv" -o items.csv

Attachment Files

Attachment files live in TACK_STORAGE_DIR (default ./storage) and are not part of the database backup. Back them up separately:

rsync -a ./storage/ /backups/tack/storage/

For a complete restore you need both the database backup and the storage directory snapshot taken at approximately the same time.


SituationAction
Before a server upgradeFull backup first
Before bulk import or schema changesFull backup first
Routine protectionDaily cron (see below)
Completing a project phaseJSON export for archival

Daily cron example:

0 2 * * * curl -s -O -J \
  -H "Authorization: Bearer $TACK_TOKEN" \
  http://127.0.0.1:3210/api/backup \
  --output-dir /backups/tack/

CLI Reference

tack is a single binary that is both the server and the CLI client. Run tack with no arguments (or tack serve) to start the server + web UI; run tack <command> to use the CLI.

The CLI is an alternative to the web UI — reach for it when you want to script Tack, wire it into automation or CI, or work without leaving the terminal. It is also how you create a git branch straight from an item (tack branch) and run the MCP server for AI agents.

The CLI commands below talk to a running server over HTTP, so start the server first (tack serve) — all client commands require it to be reachable.

Configuration

# Set URL and token (saved to ~/.config/tack/config.toml)
tack config --url http://127.0.0.1:3210 --token your-token

# Print current config
tack config --show

# Or use environment variables
export TACK_API_URL=http://127.0.0.1:3210
export TACK_API_TOKEN=your-token

Shell completions:

tack completions bash  >> ~/.bashrc
tack completions zsh   >> ~/.zshrc
tack completions fish  > ~/.config/fish/completions/tack.fish

Projects

# Create a project
tack init "Kitchen Reno" --type construction

# Types: software · web · mobile · construction · personal · homework · maintenance · legal · research · event · custom

Items

# List items in a project
tack list --project <id>

# Add an item
tack add "Design login page" \
  --project <id> \
  --type task \
  --priority high

# Priorities: high · medium · low
# Types: task · epic · story · bug · feature · subtask · milestone (or vocabulary-mapped)

# Move an item to a different status column
tack move <item-id> "In Progress"
# The status name must exactly match the column name (case-sensitive)

# Derive a git branch name from an item
tack branch <item-id>
# → prints: git checkout -b feat/<short-id>-<title-slug>

# Create and switch to the branch in one step
tack branch <item-id> --checkout

# Override the type-derived prefix (default maps feature→feat, bug→fix, …)
tack branch <item-id> --prefix hotfix

tack branch reads the item over the API and builds a conventional branch name of the form <prefix>/<short-id>-<title-slug>. Without --checkout it prints the git checkout -b … command (handy to eval or copy-paste); with --checkout it runs it. Add --json for { branch, item_id, checked_out }.

# Move an item to the first in-progress status and check out its branch
tack start <item-id>

# Print an item's web URL (and open it in $BROWSER when set)
tack open <item-id>

tack start moves the item to the first status of the workflow's in-progress category (the same PATCH /items/{id} path tack move uses), then does what tack branch <id> --checkout does. If the workflow refuses the status change, the server's error is reported and no branch is created. Add --json for { item_id, status, branch }.

tack open prints the item's web URL. With $BROWSER set, it also opens the URL with that command. Add --json for { item_id, url }.


Sprints

tack sprint create --project <id> --name "Sprint 1"
tack sprint start  <sprint-id>    # Planning → Active
tack sprint close  <sprint-id>    # Active → Closed

Only one sprint can be Active per project at a time.


Templates

# List available templates (built-in + user-created)
tack template list
tack template list --type construction   # filter by project type

# Show a template's full details
tack template show <template-id>

# Create a new project from a template
tack template create-from <template-id> "My New Project"
tack template create-from <template-id> "My New Project" --description "Optional description"

Roles

Roles represent specialties or disciplines (Designer, Engineer, Reviewer, …) that can be assigned to items for tracking who is responsible.

# List roles in a project
tack role list --project <project-id>

# Create a role
tack role create "Designer" --project <project-id>
tack role create "Engineer" --project <project-id> --color "#4A90D9"

# Assign / unassign a role on an item
tack role assign  <item-id> <role-id>
tack role unassign <item-id> <role-id>

# Delete a role (removes all its item assignments)
tack role delete <role-id>

Comments

# List comments on an item
tack comment list <item-id>

# Add a comment
tack comment add <item-id> "Looks good to merge"
tack comment add <item-id> "Blocked on client sign-off" --author "Alice"

Custom Fields

Custom fields extend items with project-specific metadata.

# List field definitions for a project
tack field list --project <project-id>

# Create a field definition
tack field create "Client Name"   --project <project-id> --type text
tack field create "Story Points"  --project <project-id> --type number --required
tack field create "Phase"         --project <project-id> --type select \
    --options "Design,Development,QA,Done"

# Types: text · long_text · number · date · boolean · select · multi_select · url · email

# List all custom field values set on an item
tack field values <item-id>

# Set a value (parsed as JSON if valid, otherwise treated as a string)
tack field set <item-id> <field-id> "Acme Corp"
tack field set <item-id> <field-id> 8          # number
tack field set <item-id> <field-id> true        # boolean
tack field set <item-id> <field-id> '"Design"'  # string that looks like JSON — quote it

# Remove a value
tack field unset <item-id> <field-id>

# Delete a field definition (also removes all item values for that field)
tack field delete <field-id>

Backup and Restore

# Download backup to current directory (timestamped filename)
tack backup

# Download to a specific path
tack backup --path /safe/place/tack.db

# Stage a restore (applied on next server startup)
tack restore /safe/place/tack.db

Execution

Create, inspect, cancel and recover agent-fleet execution requests — the CLI form of POST /api/executions and friends. See Running an item with an agent for a full worked example reaching a completed attempt, and Choosing a model and a provider for what belongs in --model-provider/--model-id (they may be omitted; see that section for what fills them in when you do).

$ tack execution --help
Create/list/cancel/reconcile agent-fleet execution requests

Commands:
  create     Create (or idempotently replay) an execution request
  list       List execution requests (newest first)
  get        Get one execution request's current lifecycle state
  cancel     Request cancellation of an execution (recorded as a request only — the runner observes and reports the actual outcome)
  reconcile  Requeue a needs_operator execution after an audited recovery decision
tack execution list
ID        ITEM                              STATE         CREATED         
────────  ────────────  ────────────  ────────────
exec_9b3  691e721e                          queued        2026-09-03      
exec_1ec  1bb00bc7                          succeeded (…  2026-09-03      
exec_0fe  e1d5e03d                          succeeded (…  2026-09-03      
exec_14a  e4e3e686                          succeeded (…  2026-09-03      
tack execution get exec_0fe7252989f5f3d40a056c1da45b035039e4a8247ad89e5222cf9280134ec5d1
Execution request exec_0fe7252989f5f3d40a056c1da45b035039e4a8247ad89e5222cf9280134ec5d1
  item:    e1d5e03d-4610-45c2-b5f1-835e69f148a7
  state:   succeeded (done)
  created: 2026-09-03T19:00:16.697646760+00:00

tack execution create also takes --status-map-policy done_on_success|done_on_mrp_accepted to move the item to its workflow's first Done status when the attempt succeeds, or when its merge-readiness pack is accepted; without it the item's status is never touched (see After a succeeded attempt).

tack execution cancel <ID> only records the request — the runner observes it and reports the actual outcome (cancellation is advisory for every harness but docket on its newer contract, which can be held to stopping what it started; see the capability matrix). tack execution reconcile <ID> --recovery-key <KEY> --reason "..." requeues a needs_operator request after an audited decision; see the Recovery Runbook.


Runner

Enroll, revoke, and — for the runner side of the fence — actually run the runner role or check what this machine can do. tack runner enroll / revoke / revoke-token are the operator surface, covered in full in Enrolling a runner; this section covers the other subcommands, which act on the local machine rather than the operator's server.

tack runner doctor needs no server and enrolls nothing — it runs the same discovery/capability probe as runner start, read-only, so you can check what a machine can do before enrolling it:

tack runner doctor
Tack runner doctor — harness discovery for this machine

codex
  status:      present
  version:     0.149.1
  credentials: Codex authenticates itself (its own CLI login flow or an API key it reads from its own environment/config — see `codex --help`). Tack never reads, stores, or forwards it. This adapter forwards no ambient host environment into an actual run: only entries explicitly set on the execution request's own `environment` field ever reach the codex process.
  model_combinations: (none reported)
  model_passthrough: supported — the adapter forwards requested_model_id verbatim via --model and rejects specs without an explicit model pre-spawn; model validity is established by the Codex CLI at run time, so operator-specified opaque models are accepted without the probe claiming any model list

claude-code
  status:      present
  version:     2.1.252
  credentials: Claude Code authenticates itself: typically an OAuth session under $HOME/.claude established by its own login flow, or an API key it reads from its own environment. Tack never reads, stores, or forwards it. This adapter forwards only HOME and PATH from the runner process's own environment, so the installed CLI can find its existing session; anything else must come through the execution request's own `environment` field.
  model_combinations: (none reported)
  model_passthrough: supported — the adapter forwards requested_model_id verbatim via --model; the CLI validates it at run time (an invalid model returns is_error:true), so operator-specified opaque models are accepted without the probe claiming any model list

Runner-wide capabilities (apply identically to every harness above):
  cancel     advisory    — a process-group signal cannot reach a detached descendant unless the harness reports its process groups; see each harness's own cancel capability
  resume     unsupported — no resumable session contract
  decisions  supported   — at least one harness adapter opens a decision; see each harness's own decisions capability for which one
  artifacts  advisory    — uploaded when an adapter stages one; best-effort, not replayed on restart
  usage      advisory    — usage is reported only when a harness emits it

Tack does not proxy model providers. Each harness above authenticates itself using its own login/credential mechanism; Tack never reads, stores, or forwards what it finds. See docs/adr/0050-runner-control-plane.md and docs/adr/0058-standalone-single-binary-runner.md.

verify: disabled

The last line is the runner's [verify] table as this machine would read it: disabled, or enabled with the program, its arguments and timeout, and whether the program was found on PATH (see Runner verifier).

tack runner start runs the runner role in the current process, speaking runner-v1 over HTTP against a Tack server — the same composition root the standalone tack-runner binary and tack serve --with-runner both use:

$ tack runner start --help
Usage: tack runner start [OPTIONS]

Options:
      --config <CONFIG>                    Optional TOML configuration file
      --api-url <API_URL>                  Runner protocol endpoint. Overrides file and environment configuration
      --runner-id <RUNNER_ID>               Stable identifier sent to the control plane
      --state-dir <STATE_DIR>               Local directory for runner state. Overrides file and environment configuration
      --enrollment-token <ENROLLMENT_TOKEN>  Enrollment credential. Prefer TACK_RUNNER_ENROLLMENT_TOKEN so it is not visible in shell history

tack runner secret set|list|remove manages this machine's runner-local secret store — the provider keys a harness's environment can reference via secret_reference (see Choosing a model and a provider). It needs no server and enrolls nothing; it reads and writes the same on-disk/keychain state a real tack-runner process would.

tack runner secret set vercel-ai-gateway/default   # value read from stdin or TACK_RUNNER_SECRET_VALUE
tack runner secret list
tack runner secret remove vercel-ai-gateway/default

set never accepts the value as a command-line argument — only from TACK_RUNNER_SECRET_VALUE or, if that is unset, from stdin — so it never lands in shell history or a process listing. list prints entry names only, never values. The secret lives only in this store, local to the runner: the board and its database never receive it, at set time or any time after.


Service

Run tack as a background service that outlives the terminal — a systemd user unit on Linux, a launchd agent on macOS. This is the terminal-user equivalent of the desktop app's window: install it once and tack serve --with-runner keeps running after you close the shell, log back in, and reboot. Not supported on Windows; use the desktop app there instead.

The service always uses this OS's own per-user application-data folder (for example ~/.local/share/tack on Linux) for the database, storage, runner state, and log file — never the current directory, and never a tack.toml from wherever you happened to run the command.

tack service install
Created symlink /home/ox/.config/systemd/user/default.target.wants/tack.service → /home/ox/.config/systemd/user/tack.service.
Installed and started the tack user service.
  Unit file: /home/ox/.config/systemd/user/tack.service
  Data root: /home/ox/.local/share/tack
  Health:    http://127.0.0.1:3210/api/health
tack service status
State:  active
Health: http://127.0.0.1:3210/api/health
tack service uninstall
Removed "/home/ox/.config/systemd/user/default.target.wants/tack.service".
Removed the tack user service. The data root was left untouched.

uninstall stops the service and removes its unit file; it never touches the data root, so a later tack service install picks the same database back up. For a shared, root-owned deployment instead of a per-user one, see the systemd unit in the deployment guide.


Fleet

Manage runner fleets — a named group of runners sharing an optional concurrency limit and a default model policy (see Choosing a model and a provider). Adding a runner to a fleet has no CLI subcommand yet. Do it from the Agents page in the web UI (each fleet's roster has an add/remove control), or directly against the API: POST /api/runner-fleets/{fleet_id}/members adds a runner, DELETE /api/runner-fleets/{fleet_id}/members/{runner_id} removes one.

tack fleet create "opus-fleet" \
  --policy '{"default_model":{"provider":"anthropic","model_id":"claude-opus-4-1"}}'
Created fleet: opus-fleet (fleet_64)
  id: fleet_64ab2a19-9e38-4820-a8c7-1fa78e435767
tack fleet list --json
[
  {
    "concurrency_limit": null,
    "default_policy": { "default_model": { "model_id": "claude-opus-4-1", "provider": "anthropic" } },
    "fleet_id": "fleet_64ab2a19-9e38-4820-a8c7-1fa78e435767",
    "name": "opus-fleet"
  }
]

Agent Profiles

Reusable instructions, tool policy and limits, snapshotted into an execution request at creation time — later edits to the profile never change history already recorded. A {"default_model": {...}} object inside --limits is the second tier of the model precedence; see Choosing a model and a provider.

tack agent-profile create "sonnet-profile" \
  --instructions "Print the single word DONE and exit. Do not modify any files." \
  --limits '{"default_model":{"provider":"anthropic","model_id":"claude-sonnet-4-5"}}'
Created agent profile: sonnet-profile (ap_6e564)
  id: ap_6e5649b8-1844-473b-ba36-2a8da37a8256
tack agent-profile list
ID        NAME                            
────────  ────────────────────────────────
ap_91b4e  demo-profile                    
ap_6e564  sonnet-profile                  

MCP Server (AI agents)

tack mcp runs a Model Context Protocol server over stdio so AI agents (Claude Code, Codex, …) can drive the board: list/search/read items and create/update/move them or add comments. Writes go through the API, so workflow rules still apply.

# Reads JSON-RPC on stdin, writes responses on stdout — wire it into an MCP client,
# don't run it interactively. Honors TACK_API_URL / TACK_API_TOKEN.
tack mcp

See the MCP guide for the Claude Code .mcp.json snippet and the full tool reference.


Machine-Readable Output

All commands accept --json for raw JSON output:

tack list --project <id> --json | jq '.[] | select(.priority == "high")'

With --json, vocabulary mappings are bypassed and raw field names are returned.


Exit Codes

CodeMeaning
0Success
1General error (see stderr)
2Configuration error (no URL, bad token)
3API error (server returned 4xx/5xx)

Configuration

Configuration is loaded from tack.toml in the working directory. Environment variables override TOML values. Both are optional — all settings have built-in defaults.

The complete, authoritative table of every TACK_* variable — server, embedded runner, standalone runner, backup, orchestration, and the execution domain — is docs/CONFIG.md. That file is updated the moment a variable is added; this page is not a second copy of it. What follows here is the loading order and one worked example.


Example tack.toml

host         = "127.0.0.1"
port         = 3210
database_url = "sqlite:/var/data/tack.db?mode=rwc"
log_level    = "info"
log_json     = false
log_file     = "/var/log/tack/api.log"
storage_dir  = "/var/data/tack-storage"
# api_token  = "change-me"
allowed_origins = "https://pm.example.com"
max_body_size   = 4194304   # 4 MB

API Token

When api_token is set, every request to /api/* must include:

Authorization: Bearer <token>

Requests without a valid token receive 401 Unauthorized. The /api/health endpoint is always public.

Frontend: The bundled SPA reads the token from VITE_API_TOKEN in frontend/.env:

# frontend/.env
VITE_API_URL=http://127.0.0.1:3210
VITE_API_TOKEN=change-me

Rebuild the frontend after changing .env.


Logging

Development — plain text at debug level:

TACK_LOG_LEVEL=debug cargo run -p tack-cli -- serve

Trace all SQL queries:

RUST_LOG=tack_db=trace,tack_api=debug cargo run -p tack-cli -- serve

Production — JSON logs to a file:

log_level = "info"
log_json  = true
log_file  = "/var/log/tack/api.log"

Precedence

  1. Environment variables ← highest priority
  2. tack.toml in the current directory
  3. Built-in defaults ← lowest priority

Administration and Security

Tack has no identity model: there are no user accounts, no sessions, and no per-user permissions. assignee is a free-text label on an item, not an account — anyone can type any name into it. Every request that authenticates at all authenticates as the same single operator, via one shared TACK_API_TOKEN. Tack is built for one operator (or a small team willing to share that one secret), not for telling users apart; see ADR 0059 for the reasoning and what was deliberately left out.

Tack is local-first by default: it binds to 127.0.0.1, requires no authentication, and stores everything in a single SQLite file. This page covers the configuration you apply when you move beyond a single-machine setup — locking down the API, controlling network exposure, enabling cloud backups, wiring up webhooks, and tuning logs. Every setting below is read from tack.toml or environment variables at startup; see Configuration for how those are loaded.

All examples assume the default base URL http://127.0.0.1:3210.


Authentication

By default Tack accepts every request — appropriate for a pure-local install. To require a token, set TACK_API_TOKEN and restart the server. Once set, every /api/* route requires an Authorization: Bearer <token> header. One endpoint is exempt:

  • GET /api/health — liveness/readiness probe, always open.

Start the server with a token:

TACK_API_TOKEN='a-long-random-secret' tack serve

Requests without (or with a wrong) token receive 401 Unauthorized. Supply the token on every call:

# Rejected — no token
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3210/api/projects
# → 401

# Accepted
curl -s http://127.0.0.1:3210/api/projects \
  -H 'Authorization: Bearer a-long-random-secret'

The token value is never written to logs. Use a long, random string and rotate it by restarting with a new value.


CORS

Browsers block cross-origin API calls unless the server explicitly allows the page's origin. Tack's allow-list is TACK_ALLOWED_ORIGINS, a comma-separated list of exact origins (scheme + host + port). The default is:

http://localhost:8080,http://127.0.0.1:8080,http://localhost:3210,http://127.0.0.1:3210,https://tack.test

Change it when the browser loads the UI from a different origin than the API — for example a reverse-proxy hostname or a separate frontend dev server:

TACK_ALLOWED_ORIGINS='https://tack.example.com,https://app.example.com' tack serve

List every origin you serve the UI from; entries are matched exactly, with no wildcards. The bundled SPA served by the same process needs no extra entry.


Network exposure and TLS

Tack binds to TACK_HOST (default 127.0.0.1) on TACK_PORT (default 3210), so out of the box it is reachable only from the local machine. Because Tack has no per-user accounts (see above), a bind reachable from beyond the local machine with no TACK_API_TOKEN configured hands full read/write access — the board, and the runner-scheduling surface — to anyone who can reach the port. Tack refuses to start in that configuration:

TACK_HOST=0.0.0.0 TACK_PORT=3210 tack serve
# Error: refusing to bind 0.0.0.0 without TACK_API_TOKEN; bind to loopback,
# set TACK_API_TOKEN, or set TACK_API_ALLOW_UNAUTHENTICATED_NONLOOPBACK=1
# to accept the risk

To serve it on a LAN, set a token alongside the routable bind:

TACK_HOST=0.0.0.0 TACK_PORT=3210 TACK_API_TOKEN='a-long-random-secret' tack serve

If a token genuinely cannot be configured — for example a container reachable only on a network you already trust — set TACK_API_ALLOW_UNAUTHENTICATED_NONLOOPBACK=1 to start anyway. This is an explicit acceptance of the risk above, not a default; leave it unset unless you have a specific reason to widen the bind without a credential.

Tack does not terminate TLS itself. For any non-localhost deployment, place it behind a reverse proxy (Caddy, nginx, Traefik) that handles HTTPS and forwards to the local port. Keep TACK_HOST=127.0.0.1 and let only the proxy reach it. See Deployment for full proxy and TLS setup.


Request limits

Non-attachment requests are capped by TACK_MAX_BODY_SIZE (bytes, default 2097152 = 2 MB). This protects the JSON API from oversized payloads:

TACK_MAX_BODY_SIZE=5242880 tack serve   # raise to 5 MB

The file-upload endpoint (POST /api/items/{id}/attachments) is exempt from this limit and is always capped at 50 MB, regardless of TACK_MAX_BODY_SIZE.


Cloud backup (S3-compatible)

Tack can push database snapshots to any S3-compatible object store — AWS S3, Cloudflare R2, Backblaze B2, or MinIO. Remote backup is enabled only when a bucket, an access key, and a secret key are all present. For local snapshot/restore, see Backup and Restore.

Configuration sources

Two layers feed the effective config:

  1. Environment defaults (TACK_BACKUP_*), applied at startup.
  2. UI overrides (Settings → Cloud Backup), persisted in the app_meta table.

The UI values override the environment for these fields: endpoint, bucket, region, access key, secret key, prefix, retention. A blank UI field clears the override and falls back to the environment default. The one exception is the auto-backup interval, which is environment-only (TACK_BACKUP_INTERVAL_SECS) and applied at startup — it is not editable from the UI.

VariableDefaultPurpose
TACK_BACKUP_ENDPOINT(none)S3-compatible endpoint URL. Omit for AWS S3; set for R2/B2/MinIO (e.g. https://<account>.r2.cloudflarestorage.com)
TACK_BACKUP_BUCKET(none)Bucket name. Required to enable remote backup
TACK_BACKUP_REGIONautoRegion. AWS needs the real region; Cloudflare R2 uses auto
TACK_BACKUP_ACCESS_KEY(none)S3 access key ID. Required to enable remote backup
TACK_BACKUP_SECRET_KEY(none)S3 secret access key. Required; never logged
TACK_BACKUP_PREFIXtackObject key prefix inside the bucket
TACK_BACKUP_INTERVAL_SECS(none)Auto-backup interval in seconds; omit for manual-only. Env-only, applied at startup
TACK_BACKUP_RETENTION10Number of remote backups to keep after each upload

Example (Cloudflare R2):

TACK_BACKUP_ENDPOINT='https://<account>.r2.cloudflarestorage.com' \
TACK_BACKUP_BUCKET='tack-backups' \
TACK_BACKUP_REGION='auto' \
TACK_BACKUP_ACCESS_KEY='...' \
TACK_BACKUP_SECRET_KEY='...' \
TACK_BACKUP_INTERVAL_SECS=86400 \
tack serve

Reading and writing settings via the API

GET /api/settings/backup returns the effective config. The secret key is never sent to clients — it is replaced by a boolean secret_key_set:

curl http://127.0.0.1:3210/api/settings/backup
{
  "configured": true,
  "endpoint": "https://<account>.r2.cloudflarestorage.com",
  "bucket": "tack-backups",
  "region": "auto",
  "access_key": "...",
  "secret_key_set": true,
  "prefix": "tack",
  "retention": 10
}

PUT /api/settings/backup saves overrides. Sending a blank secret_key keeps the stored secret (so the masked UI field can be left untouched); any other blank string field clears that override and reverts to the environment default.

Manual backup endpoints

When remote backup is configured, these endpoints operate on demand. If it is not configured they return 409 Conflict.

Method & pathAction
POST /api/backup/remoteCreate a bundle and upload it; prunes to retention afterward
GET /api/backup/remoteList remote backups, newest first
POST /api/backup/remote/restoreDownload a bundle and stage it for the next restart

Restore is staged, not live — restart the server to apply it. Omit the body (or key) to restore the latest backup, or target a specific object:

# Upload now
curl -X POST http://127.0.0.1:3210/api/backup/remote

# Restore a specific object (then restart the server)
curl -X POST http://127.0.0.1:3210/api/backup/remote/restore \
  -H 'Content-Type: application/json' \
  -d '{"key":"tack/2026-06-26T12-00-00Z.tackbundle"}'

Webhooks

Set TACK_WEBHOOK_URL to receive an HTTP POST whenever work changes. Delivery is fire-and-forget: each event is sent on a background task with a 10-second timeout, and failures are logged but never block the originating request.

TACK_WEBHOOK_URL='https://hooks.example.com/tack' tack serve

Event types

The event name is sent both as the X-Tack-Event request header and as the event field in the JSON body.

EventWhen it fires
item.createdAn item is created
item.updatedAn item is updated (including status changes)
item.deletedAn item is deleted
sprint.startedA sprint transitions to Active
sprint.completedA sprint transitions to Closed
sprint.updatedAny other sprint status change
item.due_soonAn item is due within the next hour (background check runs hourly)

Payload shapes

Every payload carries event, an RFC 3339 timestamp, and project_id. The remaining fields depend on the event:

// item.created / item.updated / item.due_soon
{
  "event": "item.updated",
  "timestamp": "2026-06-26T12:00:00+00:00",
  "project_id": "1f0c…",
  "item": { /* full item object */ }
}
// item.deleted — carries the id only, since the item is gone
{
  "event": "item.deleted",
  "timestamp": "2026-06-26T12:00:00+00:00",
  "project_id": "1f0c…",
  "item_id": "9ab3…"
}
// sprint.started / sprint.completed / sprint.updated
{
  "event": "sprint.started",
  "timestamp": "2026-06-26T12:00:00+00:00",
  "project_id": "1f0c…",
  "sprint_id": "44de…",
  "sprint_name": "Sprint 7",
  "status": "active"
}

Signing

Set TACK_WEBHOOK_SECRET to sign every delivery. Tack computes an HMAC-SHA256 over the exact request body and sends it as:

X-Tack-Signature: sha256=<hex>

Verify it on the receiver by recomputing the HMAC of the raw body with the same secret and comparing (constant-time) against the header value. Reject any request whose signature does not match.

TACK_WEBHOOK_URL='https://hooks.example.com/tack' \
TACK_WEBHOOK_SECRET='shared-signing-secret' \
tack serve

Logging

Logging is controlled by three variables. Secrets — the API token, the webhook secret, the backup secret key, and the GitHub token — are never written to logs at any level.

VariableDefaultPurpose
TACK_LOG_LEVELinfoVerbosity: trace, debug, info, warn, error
TACK_LOG_JSONfalseEmit structured JSON lines (for log aggregators) when true/1
TACK_LOG_FILE(none)Write logs to this file path instead of (or in addition to) stderr
TACK_LOG_LEVEL=debug TACK_LOG_JSON=true TACK_LOG_FILE=/var/log/tack.log tack serve

Environment variable reference

Security- and administration-relevant settings, as read by the server at startup. Values can also be set in tack.toml; see Configuration.

VariableDefaultPurpose
TACK_HOST127.0.0.1Bind address. Set to 0.0.0.0 to expose on a LAN (front with a TLS proxy). Requires TACK_API_TOKEN (or the opt-out below) once set to anything non-loopback — see Network exposure and TLS
TACK_PORT3210Listen port
TACK_DATABASE_URLsqlite:tack.db?mode=rwcSQLite database location
TACK_API_TOKEN(none)When set, requires Authorization: Bearer <token> on all /api/* routes except /api/health. Never logged
TACK_API_ALLOW_UNAUTHENTICATED_NONLOOPBACKfalseExplicit opt-out for the non-loopback-without-token startup refusal (see ADR 0059). Off by default — set only when a TACK_HOST reachable beyond the local machine is intentional and a token genuinely cannot be configured
TACK_ALLOWED_ORIGINSsee docs/CONFIG.mdComma-separated CORS allow-list of exact origins
TACK_MAX_BODY_SIZE2097152Max body size in bytes for non-attachment requests (2 MB). Uploads are always capped at 50 MB
TACK_STORAGE_DIR./storageAttachment storage directory
TACK_WEBHOOK_URL(none)Outbound webhook URL; enables event POSTs
TACK_WEBHOOK_SECRET(none)HMAC-SHA256 signing secret; adds X-Tack-Signature: sha256=<hex>. Never logged
TACK_GITHUB_TOKEN(none)GitHub PAT (repo scope); status and comments sync out to linked issues, and the poll below may start. Never logged
TACK_GITHUB_API_BASEhttps://api.github.comGitHub API root (override for GitHub Enterprise)
TACK_GITHUB_POLL_SECONDS0Inbound poll interval for linked issues; 0 is off
TACK_BACKUP_ENDPOINT(none)S3-compatible endpoint URL; omit for AWS S3
TACK_BACKUP_BUCKET(none)Bucket name — required to enable remote backup
TACK_BACKUP_REGIONautoS3 region (auto for Cloudflare R2)
TACK_BACKUP_ACCESS_KEY(none)S3 access key ID — required to enable remote backup
TACK_BACKUP_SECRET_KEY(none)S3 secret access key — required; never logged
TACK_BACKUP_PREFIXtackObject key prefix inside the bucket
TACK_BACKUP_INTERVAL_SECS(none)Auto-backup interval in seconds; env-only, applied at startup
TACK_BACKUP_RETENTION10Remote backups to retain after each upload
TACK_LOG_LEVELinfoLog verbosity
TACK_LOG_JSONfalseStructured JSON logging
TACK_LOG_FILE(none)Optional log file path

For the workflow-facing side of these features, see Backup and Restore; for proxy and TLS setup, see Deployment.

Agent Runners & Fleet Execution

Tack can hand a board item to a coding agent — codex, Claude Code, docket or opencode — and track what it did as first-class project history: requested vs. actual harness/model, a fenced execution attempt, an event timeline, decisions the agent needs answered, and verified artifacts it produced. This page is the operator's guide to that surface: which harness to pick, running the tack-runner binary, enrolling and revoking runners, where credentials and workspaces live, what a runner can honestly promise (the capability matrix), and how version compatibility and network exposure work.

The question every new user asks first — which harness, which model, from which provider, and where do I put the key — has a direct answer below, not a negation: see Choosing a harness and Choosing a model and a provider. The four ways to turn a board item into a completed attempt are in Running an item with an agent, before the operational detail (enrollment, credentials, recovery) that follows it.

Docket's own control-plane history — retired, and what replaced it — is in Docket compatibility below.

Read this before you rely on it in production: the last section, What actually runs today, states plainly which parts of this pipeline are proven end-to-end against a real router and which parts stop at the enrollment step in the current build. Every claim on this page cites the test that proves it.


Do you need a runner?

Only if you want an item's Run with agent button to do something. Tack is two things bundled into one binary: a project manager that always runs, and an agent executor that is off until something turns it on. Nothing below requires a runner:

Works with zero runners
Board, timeline, dashboard, list, calendar views✅
Creating, moving, commenting on, searching items✅
The CLI, REST API, and tack mcp for everything above✅
Webhooks, GitHub sync, backups✅
Needs an active runner (embedded or remote)
Clicking Run with agent on an item❌ shows "Agent execution is off" instead of a form
A harness (Claude Code, Codex, docket or opencode) actually running against your code❌ nothing to run it

Nothing silently queues forever: the button tells you there's no runner to give the request to, rather than accepting one that no runner will ever claim.

The analogy, if you've used a CI runner (GitHub Actions, GitLab): the board is the pipeline definition and its history — it always exists, whether or not anything is attached to run it. The runner is the runner — a separate worker that polls for eligible work, executes it near your own code and credentials, and reports back. Zero runners attached is a normal, fully working state: the board just has nothing to hand work to yet.

Three ways to attach one, in order of effort:

HowWhen
Embedded, from the first secondtack serve --with-runnerOne developer, one machine — the fastest path to a completed attempt.
Embedded, turned on later, no restartAgents page (/agents) → Turn onYou started plain tack serve and changed your mind. Same runner as above, same process, flipped on live — a flag and this toggle are two doors to the identical on/off switch.
A separate tack-runner process, enrolled against this boardEnrolling a runner belowRequired for a shared or production deployment not bound to 127.0.0.1 — an embedded runner refuses to start there on purpose. Executing arbitrary agent processes on a machine reachable from outside the loopback interface is the one thing this product refuses to do silently; see Non-loopback and security posture.

"Run with agent" (the button on an item) and --with-runner (the boot flag) are two different things that happen to sound alike: the button always exists; the flag — or the Agents-page toggle, or a separate tack-runner — is what gives it something to run against.


Concepts

TermWhat it is
Execution requestA durable, idempotent record: "run this item through this harness, on this runner or fleet, with this agent profile." Created via POST /api/executions.
Execution attemptOne numbered try at a request. Owns a fencing token, a lease, and an isolated workspace. A request can have more than one attempt over its life (retries, recovery).
RunnerA tack-runner process, identified by a durable runner_id, enrolled once and then polling for work.
Runner fleetA named group of runners sharing an optional concurrency limit and default policy. An execution request targets either one exact runner or a fleet.
Agent profileReusable instructions + tool policy + limits, snapshotted into the request at creation time so later edits to the profile never change history.
HarnessThe coding-agent CLI a runner can launch: codex, claude-code, docket or opencode — see Choosing a harness. The wire value is an opaque string, so a runner may report a kind this build has no bundled adapter for.

Harness ≠ ModelProvider ≠ ModelId, and Item ≠ ExecutionRequest ≠ ExecutionAttempt — these stay distinct on the wire and in the database on purpose; see docs/contracts/runner-v1/protocol.json.

What the harness receives

Whoever creates the request, the server writes the instructions the harness is given: the agent profile's own text, then the item's title and description, then the item's brief (its definition of done, acceptance criteria and constraints) when it has one. The title and description are labelled with where they came from and marked as data to work from, not as instructions that change the agent's rules, because an imported item's text was written by someone else. The brief is also saved with the request, and the attempt keeps a copy of it as brief.json beside its evidence.json. An item without a brief still reaches the harness with its title and description.


Choosing a harness

Tack drives a coding-agent CLI — it never runs a model itself. Four are supported today: codex, claude-code, docket and opencode. Pick one by what it needs installed, how it reaches a model, and how far it lets Tack steer it — the rest of this page assumes you've already picked one.

HarnessInstallReaches a model throughWhat it measuresPauses to ask youHonours the permission policyCan't
codexThe official Codex CLI, e.g. npm install -g @openai/codexIts own login (ChatGPT/API-key session) needs no Tack configuration. A Tack-configured Vercel AI Gateway key also reaches it, over the OpenAI Responses wire — Anthropic's own API doesn't serve that wire, so it's never an option here.Tokens, read from the run's own terminal line (exec --json's turn.completed). Cost is never measured: no such field exists in the output. The model that actually served the request is never confirmed — Tack records the one you requested, tagged requested_not_confirmed.Yes — over codex app-server, and only when the request's permission policy sets approvals to ask and a command needs to escalate beyond the sandboxAdvisory — your tool list picks codex's sandbox mode (a write-capable tool grants --sandbox workspace-write, otherwise --sandbox read-only); your network flag and budget never reach itResume a session, report a cost, confirm which model served a request, or honour a network deny or a budget
claude-codenpm install -g @anthropic-ai/claude-codeIts own login (a Claude subscription or its own API key) needs no Tack configuration. A Tack-configured endpoint can be Anthropic's own API directly, or the Vercel AI Gateway — both over the Anthropic Messages wire.Tokens and cost, from the run's own result line — advisory, because an auxiliary model's cost is folded into the total while token counts were only confirmed to cover the primary turn. The served model is confirmed from its own session-start line on a direct connection; routed through the gateway, it's recorded requested_not_confirmed instead, since a gateway can still substitute a model underneath it.Yes — before every tool call, when the request's permission policy sets approvals to askAdvisory — the tool list and a cost budget are enforced through its own flags; a network deny only blocks the WebFetch/WebSearch tools by nameReattach to an already-running attempt (--resume starts a new process against stored history, not the in-flight one), or guarantee a network deny holds against everything it runs
docketFrom the docket project, following its own install instructionsAlways needs a Tack-configured endpoint — the Vercel AI Gateway, over the OpenAI Chat Completions wire; Anthropic's own API doesn't serve that wire either. It has no login of its own that this adapter uses.Tokens, genuinely read from its result line, and a served model that's a real observation of the endpoint's own response — never downgraded to requested_not_confirmed, even behind a gateway. Cost is never measured: the installed version always reports it null. A recipe run has no single turn to read a served model from, so none is recorded for it. When the installed docket reports the files a run wrote, they appear in the attempt's result (paths under .tack-runner/ are left out).Yes, when the installed docket accepts answers — tack runner doctor --json shows it as decisions: supported under docket. Then, when the request's permission policy sets approvals to ask, docket pauses before each tool call its own policy gates and waits for your answer; a call its policy allows runs without a question. A docket that doesn't accept answers refuses a gated call immediately insteadDepends on the installed docket. When it accepts a policy, your tool list and network flag reach it as a policy blocking every docket tool you don't allow, and your token budget reaches it as its own limit — except on a recipe run, which takes no token limit. A docket without those flags is passed none of them and applies only its own policy engineResume, report a cost, take a token budget on a recipe run, or run a recipe with an Implementer step (Tack passes no verify command, so that step fails)
opencodebrew install opencodeAlways needs a Tack-configured endpoint — its own vendor logins are out of scope for this adapter — the Vercel AI Gateway, over the same OpenAI Chat Completions wire as docket.Tokens, summed across every step of the run. The served model is never confirmed — every run is recorded requested_not_confirmed, because nothing in its output names what actually answered. Cost is never measured for a model it doesn't recognize.Yes — but only over opencode acp, and only when the request's permission policy sets approvals to ask: each granted tool then pauses on its own request until answeredAdvisory — network access and per-tool access (edit/bash/task) are each gated through its own permission block; a budget is never passedConfirm which model served a request, or run at all against a request that denies network — see below

A few things above are easy to trip over:

  • opencode reaches the npm registry on every attempt, not just a first run. Even a fresh, isolated config directory makes a real connection to registry.npmjs.org and installs about 220 MB before it does anything else. Because Tack can't make opencode keep a promise the CLI itself breaks, a request whose permission policy denies network is rejected before opencode ever spawns.
  • docket reads its key from one fixed variable. Whatever provider you configure, Tack injects its resolved credential under DOCKET_LLM_API_KEY — docket never sees a provider-named variable, and it refuses to start without one.
  • docket is asked what it can do when the runner starts. Tack asks the installed docket which harness contract it speaks (1.0 or 1.1) and which optional flags it accepts, using calls docket refuses before doing any work: no model is called and nothing is written. Tack never reads docket's version number to decide anything, because an install's version string can be stale while its code is current. What it found is what everything below follows from, and tack runner doctor prints docket's version and, with --json, its decisions entry, which names the contract it negotiated.
  • docket gets your limits, policy and a list of the files it wrote, when it can take them. When the installed docket accepts them, the run's token budget goes to docket as its own limit, and the run's tool list and network setting go to it as a policy that blocks every docket tool the run does not allow; an empty list blocks all of them. Only docket's own tool names can be used there (read, write, edit, glob, grep, bash, fetch, skill, consult): a run that names any other tool, or allows fetch with network off, is refused before it starts, with the field named, never quietly narrowed. docket then reports the files the run changed, and they appear in the attempt's result (files under .tack-runner/ are left out). A docket without these flags gets none of them and says so: its permission policy and artifacts are unsupported, never half-supported.
  • docket can run a named recipe instead of a single task. Put the recipe's name in the agent profile's tool policy, as {"docket": {"recipe": "research-review"}}. When the installed docket supports recipes, Tack passes --recipe and docket runs that recipe in place on the workspace. docket refuses a recipe run that also carries an agent id or a token limit, so Tack leaves both off: a recipe run takes no token budget. A recipe with an Implementer step needs a verify command, which Tack does not pass, so that step fails with verifyCmd required but not set; recipes without one (for example research-review) run to the end. The result's task block, with each step's role and outcome, is kept in the attempt's result.
  • codex maps your tool list onto its sandbox mode, and says what it still can't. A tool list granting bash, shell, edit, write or apply_patch runs codex under --sandbox workspace-write; an empty or read-only tool list runs it under --sandbox read-only. Under "Automatic" the sandbox never prompts on a denial — a write it disallows just fails inside the tool call. Under "Ask me" codex runs as codex app-server instead and pauses only for a command that needs to escalate beyond that sandbox; a command inside it still runs without asking. Your network flag and budget never reach it either way: codex has no flag for either.
  • claude-code's network deny is narrower than it sounds. network: false only blocks the WebFetch and WebSearch tools by name; its Bash tool can still reach the network if your tool list grants it, so a network-sensitive item needs its tool list narrowed too, not just the network flag.

Asking before acting

By default an agent decides everything on its own: every tool call, every file it touches. Setting a request's permission_policy.approvals to ask changes that. The harness pauses before each tool call and waits for a person to answer. In the "Run with agent" dialog this is the "Approvals" choice, "Automatic" or "Ask me"; the CLI and the API set it directly (--permission-policy '{"tools":[...],"network":...,"approvals":"ask"}'). Unset, or "auto", never pauses.

Claude Code, codex and opencode honour ask, and so does docket when the installed one accepts answers. Each one pauses at a different point: claude-code before every tool call, opencode before every call to a tool your list grants, codex only for a command that needs to escalate beyond its sandbox, docket before each call its own policy gates (a call its policy allows runs without a question). Which dockets accept answers is decided when the runner starts, by asking the installed one — see the docket notes above. For a harness that does not honour ask the dialog disables "Ask me" and says why, and a request built by hand that asks anyway is never scheduled onto a runner that would ignore the choice and run as auto. A docket without answer support still refuses a gated call outright.

With docket, choosing "Ask me" pauses the agent before each gated call and waits for your answer in Tack. Allow lets the call run; Deny refuses it, and the agent is told it was refused and carries on. This works the same whether docket is running a single task or a recipe.

A question waits on the item's Execution tab. Each option can carry a description, its risks and an estimate in tokens; the option the agent recommends is marked, with its reasoning and the files it looked at.

A pending question on the Execution tab: the agent asks which chart library to add, offers uPlot, Apache ECharts and hand-written SVG, each with a description, a risk in red and an estimate in tokens, and marks uPlot as Recommended with its reasoning and evidence files; a Details field and a Resolve button sit below.

The question appears in the attempt's decision inbox, in the same item view as the run's timeline and artifacts. Answering it needs TACK_EXECUTION_DECISION_TOKEN, a secret separate from the ordinary API token. A question nobody answers before the attempt's deadline is answered with its own deny option. It is never left open and never defaults to allow.

Each question shows its kind, so a tool permission reads differently from a choice the agent wants you to make. An option can carry a short description, the risks of choosing it and a rough token cost. When the agent has a preferred answer, that option is preselected and marked Recommended, with the reason and any evidence beneath it; nothing is applied until you press Resolve, and you can pick any option. The first time someone opens a pending question, the board records that it was seen.

Checking a machine

Run tack runner doctor on the machine that will actually run the harness before trusting anything above about your own install — it probes every harness this build has an adapter for, with no runner enrollment and nothing written to the board. For each harness it prints whether the binary was found and its version, one line on how it authenticates, and whether it accepts an operator-chosen model verbatim (model_passthrough) or only a declared list (model_combinations). Condensed from a real run on a machine with all four installed:

codex
  status:      present
  version:     0.149.1
  credentials: Codex authenticates itself (its own CLI login, or an API key it reads
               from its own config)...
  model_combinations: (none reported)
  model_passthrough: supported — requested_model_id is forwarded verbatim via --model...

claude-code
  status:      present
  version:     2.1.273
  ...

docket
  status:      present
  version:     0.2.0b1
  ...

opencode
  status:      present
  version:     1.18.30
  ...

Runner-wide capabilities (apply identically to every harness above):
  cancel     advisory    — a process-group signal cannot reach a detached descendant unless the harness reports its process groups; see each harness's own cancel capability
  resume     unsupported — no resumable session contract
  decisions  supported   — at least one harness adapter opens a decision; see each harness's own decisions capability for which one
  artifacts  advisory    — uploaded when an adapter stages one; best-effort, not replayed on restart
  usage      advisory    — usage is reported only when a harness emits it

Secret store (resolves `secret_reference` environment entries):
  backend: keychain
  ...

Provider endpoint (vercel_ai_gateway):
  reaches: codex, claude-code, docket, opencode
  status:  not configured

Provider endpoint (anthropic):
  reaches: claude-code
  status:  not configured

verify: disabled

The last line reports the runner's verifier: disabled, or enabled with the program, whether it was found on PATH, its arguments and its timeout. See After a succeeded attempt.

A harness absent from your machine prints status: absent and names every directory it searched, instead of the fields above — see Where Tack looks for a harness binary below.

The runner-wide capabilities block is a single, deliberately conservative statement that applies to every harness alike — it is not where claude-code's ability to pause and ask shows up. decisions: supported here only means the protocol path exists on this runner at all; which harness can actually use it is a per-harness fact, set by that request's own permission_policy.approvals and recorded in the attempt's own capability snapshot at claim time, not in this enrollment-time summary. Reading decisions: supported here does not mean every harness can pause; see the table above for which one actually does.

How a provider's endpoint and credential are configured — the TACK_RUNNER_PROVIDER_* variables, which secret-store entry each one reads, and the embedded runner's own key field — is the single authority in docs/CONFIG.md, not restated here.


Running an item with an agent

There are four ways to turn a board item into an execution request. All four produce the identical POST /api/executions record underneath — none is more "real" than another. For a UI-only user the fastest path to a working setup is the Agents page (/agents) first — turn execution on, confirm a harness is detected, paste a provider key if you're using one, and choose a project default model — then the "Run with agent" modal on any item, which reads that configuration back and needs no hand-typed identifiers once it's done. The worked example below still uses the CLI, because every field it sends is visible on the command line; see Quick Start for the UI-first walkthrough.

Entry pointWhereNotes
The "Run with agent" modalItem detail drawer, web UILays the run out top to bottom (see below), auto-selects the runner when exactly one is active, offers the target's own declared models plus "Project default", and blocks with a named reason (and, where one exists, a link to fix it) instead of submitting a request that would queue forever.
tack execution createCLIScriptable; every field the API accepts is a flag. Used for the worked example below.
POST /api/executionsRaw HTTPSame JSON body the CLI sends. See API Reference for a worked request/response pair.
MCP create_executiontack mcp, for an agent driving Tack itselfSame required fields as the REST call. See the MCP guide.

The Run with agent dialog

The dialog reads as the whole flow, top to bottom, in four groups:

The Run with agent dialog in four groups. Who runs it: a runner connected, the Implementer profile, Claude Code, the project default model, each with a Ready badge. What it gets: the item and its brief. How far it may go: Automatic approvals, Claude Code's tools as a checklist, network access and a timeout. What happens after: Verify the result and Push the branch ticked, Open a pull request disabled with its reason. Run stays disabled until a repository remote is entered.
  • Who runs it — the machine or group, the agent profile, the harness and the model.
  • What it gets — the item, and its brief when it has one (an item without one says so and the agent gets its title and description), plus the repository to check out.
  • How far it may go — approvals (Automatic or Ask me), the allowed tools, and the timeout. Claude Code's tools are offered as a checklist beside the free-text field; for codex, opencode and docket the field is free text, because none of them exposes a list of its tools.
  • What happens after — Verify the result, Push the branch and Open a pull request.

A step that something is missing for shows a link to the page that fixes it (the Agents page for a runner, a harness or a model) and Run stays disabled until it is fixed. A step that is not available is never hidden: it is shown disabled with the reason beside it. "Ask me" is disabled for a harness that cannot pause to ask. Verify the result and Push the branch become checkboxes when the selected runner has a verifier or branch push turned on (see After a succeeded attempt); otherwise they are disabled and say which section of its TOML config turns them on. Unticking one declines it for this run only. Open a pull request is always disabled in the dialog: a pull request is opened for you once a pushed branch's item is linked to a GitHub issue, not chosen per run.

The rest of this section is one complete run through the CLI path, executed against a real tack serve --with-runner with a stand-in claude binary standing in for a real, authenticated install (so it costs nothing and never leaves the machine) — every id and every line of output below is copied from that run, not constructed from the schema. Swap the stand-in for a real, logged-in harness and the same commands reach the same place against real work.

Before this: a project and an item exist (tack init, tack add), and either a real enrolled runner is polling (see Enrolling a runner below) or an embedded one is running (tack serve --with-runner — see Standalone mode below). POST /api/executions requires all thirteen fields shown below; the CLI fills in an idempotency_key (a fresh UUID) and empty objects for budgets/environment/metadata if you omit them, so five of the thirteen are effectively optional in practice.

Create an agent profile — its instructions and limits are snapshotted into every request created against it:

tack agent-profile create "demo-profile" \
  --instructions "Print the single word DONE and exit. Do not modify any files."
Created agent profile: demo-profile (ap_91b4e)
  id: ap_91b4ea76-9f1a-4725-8a58-21a57d92572c

Find the runner to target. An embedded runner self-provisions under a name starting local-; there is no tack runner list CLI subcommand today (see Enrolling a runner), so read it back over the API:

curl -s http://127.0.0.1:3210/api/runners | jq -r '.data[].runner_id'
runr_8fa0dfb9-638f-492d-b278-ee06a789ad04

Now the full request, all thirteen required fields:

tack execution create <ITEM_ID> \
  --idempotency-key "release-notes-001" \
  --runner runr_8fa0dfb9-638f-492d-b278-ee06a789ad04 \
  --agent-profile ap_91b4ea76-9f1a-4725-8a58-21a57d92572c \
  --harness claude-code \
  --model-provider anthropic \
  --model-id claude-sonnet-4-5 \
  --agent-profile-snapshot '{"name":"demo-profile","instructions":"Print the single word DONE and exit. Do not modify any files.","tool_policy":{},"timeout_seconds":120,"budgets":{}}' \
  --repository '{"kind":"git","remote":"/path/to/local/repo","base_revision":"ce38796ef4f70db35eeb8d6d8b8e86477e1a883c","subdirectory":null}' \
  --permission-policy '{"tools":[],"network":false}' \
  --timeout-seconds 120
Created execution request: exec_0fe
  state: queued
  id:    exec_0fe7252989f5f3d40a056c1da45b035039e4a8247ad89e5222cf9280134ec5d1

Poll for the terminal state — a fresh embedded runner claims and completes an attempt against a stand-in harness in well under a second:

tack execution get exec_0fe7252989f5f3d40a056c1da45b035039e4a8247ad89e5222cf9280134ec5d1
Execution request exec_0fe7252989f5f3d40a056c1da45b035039e4a8247ad89e5222cf9280134ec5d1
  item:    e1d5e03d-4610-45c2-b5f1-835e69f148a7
  state:   succeeded (done)
  created: 2026-09-03T19:00:16.697646760+00:00

That is a completed attempt, reached with the commands above and no browser. For the attempt-level detail tack execution get does not surface — fencing token, workspace id, the staged artifact, the harness's own capability report at claim time — use GET /api/executions/{request_id}/attempts:

curl -s http://127.0.0.1:3210/api/executions/exec_0fe7252989.../attempts | jq '.data[0]'
{
  "attempt_id": "att_fea7528f-853b-4c3c-a2d6-d1cf1b904e48",
  "state": "succeeded",
  "fencing_token": 1,
  "workspace_id": "ws_6174745f66656137353238662d383533622d346333632d613264362d643163663162393034653438",
  "actual_execution": {
    "harness_kind": "claude-code",
    "model_provider": "anthropic",
    "model_id": "unknown",
    "model_observation_source": "not_observed"
  },
  "terminal_reason": {
    "exit": "Exited(0)",
    "reason": "no structured result envelope was produced; inferred success from exit code 0",
    "artifact": { "kind": "log", "name": "claude-code-run.log", "size_bytes": 52 }
  }
}

Two things in that real output are worth reading closely rather than past: model_id came back "unknown" with source not_observed — the stand-in binary used for this page never echoes a model id the way a real harness does, and Tack reports that honestly rather than assuming the requested id was actually the one that ran (the same "not measured, never a fabricated value" rule as usage economics, applied to model identity instead of cost). And terminal_reason.reason came from an inferred exit code, not a structured result — a real harness's own structured output, when it produces one, is read instead; see What actually runs today.

Choosing a model and a provider

Two rules sit next to each other, on purpose, because conflating them is what causes confusion: the board never holds a provider credential — no TACK_* variable configures a vendor API key, and the API server itself never proxies a model call (ADR 0050, ADR 0058) — and the runner can hold one, on its own machine, in its own secret store, and use it to reach a model provider on the board's behalf. The two statements are about different halves of the product: see Local credential handling below and docs/CONFIG.md's "Embedded runner" section, "Vendor/provider credentials" bullet, for exactly where a credential may and may not live (ADR 0061 draws that line and bounds it).

Separately, and independently of where a credential lives, Tack routes the model choice: which (provider, model_id) pair reaches the harness for a given request is resolved server-side, deterministically, before the request is ever offered to a runner. Routing a choice and holding a secret are different things — this section's four-tier precedence below is the routing rule; the runner-side gateway path (the fastest UI-only route to a working key) is in Local credential handling.

The four-tier precedence

crates/tack-orch/src/model_policy resolves a request's model in this order, most specific first, stopping at the first tier that has a value:

  1. Request override — requested_model_provider/requested_model_id on the request itself (--model-provider/--model-id on the CLI, the POST /api/executions body fields of the same name). Set by an explicit CLI flag, a raw API caller, or the modal's model picker when it isn't left on "Auto".
  2. Agent-profile default — a {"default_model": {"provider": "...", "model_id": "..."}} object inside the agent profile's own limits field (POST /api/agent-profiles --limits '...' or tack agent-profile create --limits '...'). Live-verified: creating a profile with this default and a request that omits --model-provider/--model-id entirely still resolves and completes:
    tack agent-profile create "sonnet-profile" \
      --instructions "..." \
      --limits '{"default_model":{"provider":"anthropic","model_id":"claude-sonnet-4-5"}}'
    # then tack execution create <item> --agent-profile ap_6e5649b8... --harness claude-code ... (no --model-provider/--model-id)
    
    the resulting attempt's actual_execution.model_provider came back "anthropic" — resolved server-side from the profile, never supplied on the request.
  3. Project default — projects.default_model (migration 062), the same {"default_model": {"provider": ..., "model_id": ...}} (or literal "auto") convention as the other tiers, set from the Agents page's "Default model" step or PATCH /api/projects/{id}. resolve_request_model_policy reads it as this tier — the only one with a settings UI at all, which is why the "Run with agent" modal's own submit gate points its "set a default model" fix link here (crates/tack-orch/src/model_policy/wiring.rs).
  4. Fleet default — the same {"default_model": {...}} convention, inside agent_fleets.default_policy (tack fleet create --policy '...'). Applies only to a request that targets the fleet itself (selector_kind: "fleet") — an exact_runner-targeted request never consults any fleet's policy, since it never names one. Live-verified the same way as tier 2: a fleet created with --policy '{"default_model":{"provider":"anthropic","model_id":"claude-opus-4-1"}}', a request targeting --fleet <that fleet> with an agent profile that has no default of its own, resolved to actual_execution.model_provider: "anthropic".
  5. Auto-select — what happens when every tier above is empty. This is not a fallback that quietly picks something: see the next section.

All four tiers are resolved once, server-side, at request-creation time (crates/tack-api/src/handlers/executions.rs, immediately before the request is stored) — only when the caller supplied neither requested_model_provider nor requested_model_id; an explicit pair (even a deliberately wrong one) is never second-guessed by a lower tier.

Auto-select does not schedule today

Leaving every tier empty is a real, acceptable-looking state to reach — the modal's model picker defaults to "Auto (let the runner decide)", and a request created that way is accepted and stored as queued with no error. But no runner-v1 capability field attests that a harness safely accepts an unspecified model, so the scheduler rejects every candidate for an auto-select request with AutoSelectNotVerified (crates/tack-orch/src/scheduler/select.rs) rather than guess. Live-verified: a request created with requested_model_provider/requested_model_id both null and no tier above resolving to a value stayed queued, with zero attempts, indefinitely — no needs_operator, no error surfaced anywhere an operator would see it. The only fix today is to supply an explicit model somewhere in the four tiers above; there is currently no operator-visible signal that distinguishes a genuinely queued request from one that can never be scheduled.

What a runner will actually accept

An explicit (provider, model_id) pair is eligible to schedule when either the target harness's capability snapshot declares that exact pairing in model_combinations, or the harness attests model_passthrough: supported (the adapter forwards the operator's opaque model id verbatim and the harness validates it at its own run time). model_passthrough: advisory is treated as unverified and rejected exactly like unsupported — a capability claim below supported is not load-bearing (crates/tack-orch/src/scheduler/select.rs). Run tack runner doctor on the actual runner host to see this for real rather than trusting a stale copy of this page — the full command and its unabridged output are in the CLI reference, and what the rest of its output means is in Choosing a harness. Condensed from a real run on a machine with all four harnesses installed:

Harnessmodel_combinationsmodel_passthrough
codex(none reported)supported
claude-code(none reported)supported
docket(none reported)supported
opencode(none reported)supported

None of the four has a list-models command to probe, so every one declares zero combinations and relies entirely on passthrough — any operator-specified model is accepted pre-spawn and only the harness itself validates it at run time. A harness that can enumerate its own installed/configured models would declare them instead and refuse passthrough — rejecting an undeclared model before any process spawns rather than after — but no adapter in this tree does that today.


Enrolling a runner

Enrollment is operator-initiated and two-step: the operator creates a pending runner and gets a one-time token; the runner (or whoever configures it) redeems that token.

tack runner enroll my-first-runner \
  --total-capacity 2 \
  --available-capacity 2
Enrolled runner: my-first-runner (runr_d91da686-...)
  token id:   ent_da7943b1-...
  expires at: 2026-08-19T16:17:51Z
  enrollment token: enr_57cd3592-...
  (shown once — copy it into the runner's TACK_RUNNER_ENROLLMENT_TOKEN now;
   it cannot be retrieved again)

The raw enrollment_token is generated by the server in this one response and is never stored anywhere retrievable again — only its SHA-256 hash is kept (crates/tack-api/src/handlers/runner_admin.rs). If you lose it, revoke the token (tack runner revoke-token <runner-id> <token-id>) and enroll again. Pass --out <path> to write the response straight to an owner-only file instead of printing the token to the terminal — useful when handing enrollment off to a provisioning script:

tack runner enroll ci-runner-1 --total-capacity 1 --available-capacity 1 \
  --out /secure/place/ci-runner-1.enrollment.json

Verified: tack runner enroll against a live server returns a runner_id, token_id, and one-time enrollment_token, and a subsequent GET /api/runners shows the runner in pending_enrollment state (there is no tack runner list CLI subcommand today — only enroll/revoke/revoke-token; use curl or the Agents page to list runners) — reproduced by hand for this page and pinned by crates/tack-api/tests/handlers/production_router.rs and the runner-lifecycle handler tests in crates/tack-api/src/handlers/runner_admin.rs.

Redeeming the token (the runner side)

The runner process exchanges the enrollment token for a durable credential the first time it starts:

export TACK_RUNNER_API_URL=https://tack.example.com/api/runner/v1
export TACK_RUNNER_ID=runr_d91da686-...
export TACK_RUNNER_STATE_DIR=/var/lib/tack-runner
export TACK_RUNNER_ENROLLMENT_TOKEN=enr_57cd3592-...
tack-runner

Prefer the environment variable over --enrollment-token: the flag exists but the CLI help deliberately notes the env var keeps the secret out of shell history and process listings. RunnerConfig::require_enrollment_credential() fails closed with a typed error before any filesystem or network side effect if no credential is configured — crates/tack-runner/src/config.rs.

Revoking a runner

tack runner revoke runr_d91da686-...

Revocation is immediate: the runner's hashed credential stops authenticating on the next request. It does not retroactively invalidate an already-issued lease's fencing token — a mid-flight attempt still needs the ordinary lease/lifecycle machinery (see the recovery runbook) to reach a terminal or needs_operator state. Revoke an unredeemed enrollment token without touching the runner record with tack runner revoke-token <runner-id> <token-id>.


Standalone mode: tack serve --with-runner

Everything above assumes two processes: an operator running tack serve, and a separate tack-runner process enrolled against it by hand. tack serve --with-runner (or TACK_LOCAL_RUNNER_ENABLE=1) collapses that to one binary and one command — the common case of one developer running an agent against their own board. It is off by default; the full configuration reference (gate, state directory, provider-credential story per harness) is docs/CONFIG.md, section "Embedded runner". This section covers what changes about the enrollment/credential story above when you use it.

  • Not a shortcut. The embedded runner runs as a task inside the server's own process, but speaks runner-v1 over loopback HTTP exactly like a remote runner — the same client, the same server-side path, the same contract fixtures. See docs/adr/0058-standalone-single-binary-runner.md.
  • Zero-touch enrollment on a fresh state directory. The first start with no stored session self-provisions: it creates its own pending-runner row and redeems its own one-time token in-process. No tack runner enroll call is needed, and no token is ever printed, copied, or configured by hand. A second start against the same state directory reuses the stored credential on disk instead of provisioning a second runner — no second pending-runner row, no second token.
  • Loopback-only, refused before anything opens. TACK_HOST must be a loopback address for --with-runner to start at all; a non-loopback bind is refused as a startup error — before any socket or database is opened — never downgraded to a runner-less server. Verified by crates/tack-cli/src/local_runner.rs's embedded_runner_refuses_non_loopback_bind test and, live, by scripts/smoke.sh step 12: a real attempt to bind non-loopback with --with-runner exits non-zero naming "loopback", and no listener is ever opened on the refused port.
  • Off by default. Plain tack serve — no flag, no environment variable — starts no runner at all. Verified live by scripts/smoke.sh step 11, which queries GET /api/runners directly after a settle window and finds it empty, not merely the absence of a log line.
  • Proven end to end. scripts/smoke.sh step 10 drives tack serve --with-runner on a fresh state directory with no token, through self-provisioning, an active runner, and a real completed attempt, using the same fake-harness shim pattern the rest of the smoke script uses.
  • Log visibility. The embedded runner's own log lines are silent under default logging (a pre-existing tracing-filter gap, not specific to this mode) — see docs/CONFIG.md's "Embedded runner" section for the exact RUST_LOG setting that surfaces them.

Everything else on this page — the capability matrix, workspace/artifact storage, needs_operator recovery, version compatibility — applies identically whether the runner is embedded or a separate process; the wire contract does not know the difference.


Where Tack looks for a harness binary

The Agents page's "harness detected" check, and the runner's own startup probe, resolve codex, claude, docket and opencode the same way: the runner process's own PATH first, exactly as a shell would find it. If that search comes up empty, the runner then checks a fixed list of per-user install locations that a shell-launched terminal usually has on PATH but a desktop launcher (a .desktop entry, Finder, the Start menu) or tack service under systemd's user manager usually does not, since both start from a minimal session PATH:

  • ~/.local/bin
  • ~/.cargo/bin
  • ~/.bun/bin
  • ~/.npm-global/bin and ~/.npm/bin
  • every installed Node version's own bin directory under ~/.nvm/versions/node/
  • /opt/homebrew/bin and /usr/local/bin (Homebrew)
  • on Windows, %APPDATA%\npm and %LOCALAPPDATA%\nvm

This list is fixed, not configurable — there is no TACK_* variable for it. A machine whose shell can already see the install is unaffected: PATH is always searched first, and the first match wins. If neither search finds the binary, the reported error names every directory it actually checked, so "not installed" always comes with a way to confirm it — install the harness anywhere on that list, or make sure it is on the PATH the runner process actually inherits.


Local credential handling

  • The enrollment token is a bearer secret; the durable credential the runner receives after redeeming it is a second, longer-lived bearer secret. Both are hashed at rest — the server never stores either in a form it could hand back to you.
  • EnrollmentCredential's Debug and Display implementations are hardcoded to print [REDACTED] — this is structural, not a logging convention that a future println! could bypass by accident (crates/tack-runner/src/config.rs).
  • The runner's on-disk state directory (TACK_RUNNER_STATE_DIR, default .tack-runner) holds the attempt journal (below) and, since ADR 0061, a runner-local secret store for a configured provider's key — the platform keychain where one answers, an owner-only file otherwise, never app_meta or any other server-visible table. Set a value with tack runner secret set <name> (reads TACK_RUNNER_SECRET_VALUE or, failing that, stdin — never a CLI argument, which /proc would make world-readable), or, when the runner is embedded in the same process as the board (tack serve --with-runner), from the Agents page's own key field. A harness's own vendor login (Claude Max, codex login, ...) needs none of this — it is still the runner operator's own local environment, unmanaged by Tack either way. In every case, Tack's API and database never see, store, or forward a credential value — only the runner's own store resolves one, at spawn time, into the one subprocess that needs it.
  • Logs never carry the credential value. Redaction is tested with a positive control: the redaction tests capture real tracing output and assert the secret marker never appears and that an id does appear, ruling out "the capture rig just isn't observing anything."

Workspace and artifact storage

Each execution attempt gets one isolated workspace — a dedicated worktree, never shared across attempts or requests (crates/tack-runner/src/workspace.rs). The WorktreeProvisioner trait is the only boundary allowed to create it; tests inject a fake so unit tests never touch a real git checkout.

On the runner side, before any harness process is allowed to start, an owner-only journal record is written to TACK_RUNNER_STATE_DIR describing the workspace path and base revision (crates/tack-runner/src/journal.rs). This durability-before-spawn ordering is what makes crash recovery possible — see the recovery runbook.

On the server side, verified artifacts a runner uploads (logs, diffs, generated files — anything the harness produced worth keeping) are streamed to <TACK_STORAGE_DIR>/execution-artifacts, a dedicated subtree kept apart from ordinary item attachments (<TACK_STORAGE_DIR> itself) so the retention sweep documented below can never touch the wrong directory (crates/tack-api/src/router.rs, with_artifact_storage_root). Download is GET /api/executions/{request_id}/attempts/{n}/artifacts/{artifact_id}/content, under ordinary operator auth. GET .../attempts/{n}/artifacts and GET .../attempts/{n}/decisions list what an attempt produced without a caller already knowing an id — the UI's artifact panel and decision inbox both read these lists directly now.

Content integrity is checksum-verified end to end: execution-attempt-detail.spec.ts uploads real content through a real runner credential, computes its own sha256, downloads it back through the UI via a real Playwright download event, and asserts the downloaded bytes equal the uploaded bytes exactly.


Capability matrix

Every runner reports a capability snapshot at enrollment and refresh time — protocol version, concurrency, installed harness versions, and per-feature support for cancel, resume, decisions, artifacts, and usage, each as unsupported | advisory | supported with an optional reason (docs/contracts/runner-v1/capabilities.json). The scheduler and UI are required to read this snapshot rather than assume a feature works.

The one enforced rule: an adapter may claim cancel: supported only if its harness announces the process groups its shell tool starts. AdapterRegistry::register_probe rejects any other probe that does, at registration time, before any attempt can reference it — crates/tack-runner/src/harness/mod.rs, proved by harness::tests::registering_a_probe_overclaiming_cancel_support_is_rejected. The reason is structural: a harness's own shell tool spawns its subprocess in a new session outside the runner's process group, so without being told the group the runner cannot promise it stopped (confirmed with ps against each real harness installation; see each harness's own fixtures/<kind>/README.md). Only docket announces its bash calls' process groups (process_started / process_exited), and only when its negotiated contract is 1.1, so cancel is supported for docket on 1.1 and advisory otherwise and for the other three harnesses. On a cancellation the runner stops the main group, then sends SIGTERM and, after the grace period, SIGKILL to each announced group still live. The cancellation evidence carries details.groups as {tracked, killed, survived}. A harness that announces nothing always shows tracked: 0, which means nothing was reported, not that nothing was left.

FeatureCeiling in this buildWhy
cancelsupported for docket on contract 1.1, advisory for everything elseOnly a harness that announces its process groups can be held to stopping them — AdapterRegistry::register_probe rejects a stronger claim from any other, before any attempt can reference it
resumeadapter-reportedNo harness in this build declares a resumable session contract
decisionsadapter-reportedRunner-driven bounded decisions (POST .../decisions) work when the harness supports them — claude-code, codex and opencode, and docket when the installed one accepts answers, and only when the request asks for it; see Asking before acting
artifactssupported for docket when it reports the files a run wrote, advisory otherwiseThe other harnesses cannot guarantee artifact discovery; downgraded from an earlier supported claim
usageadvisoryToken totals may be absent from harness output; see usage economics

needs_operator and recovery

needs_operator is an explicit, non-automatically-retryable state for an attempt whose ownership after a crash or network split cannot be proven safe. Tack never blindly launches a second process against the same workspace. Full mechanics — including exactly which recovery observations lead where — are in the Recovery Runbook; the short version:

  1. A restarted runner reads its own journal and reports what it actually observed — ProcessStopped, ProcessRunning, or Ambiguous (POST /api/runner/v1/attempts/{id}/recovery-observation).
  2. The server computes a disposition: SafePreSpawnRequeue (nothing had started yet — automatically safe), NeedsOperator (anything else), or AlreadyTerminal.
  3. An operator resolves a needs_operator request explicitly, with an audited reason:
tack execution reconcile <request-id> \
  --recovery-key <key> \
  --reason "confirmed process was killed via ps on the runner host"

This calls POST /api/executions/{id}/requeue, which only succeeds for a request the server itself authoritatively recovered — an unresolved or already-queued request returns 409 invalid_transition with the current state named in details, never a silent no-op. Verified by hand against a live server (an unresolvable request returns {"code":"invalid_transition","details":{"from":"unknown","to":"queued"}}) and pinned by crates/tack-cli/tests/e6_scheduler_e2e_test.rs.


Version compatibility

Every runner-v1 request body carries "protocol_version": 1. The server rejects anything else outright — check_protocol_version in crates/tack-api/src/handlers/runner_protocol.rs returns unsupported-protocol (docs/contracts/runner-v1/errors/unsupported-protocol.json) for any value other than the literal integer 1, on every one of the 13 runner-protocol operations. There is no negotiated fallback: a v2 protocol, if it ever exists, is a new contract revision, not a version range this server tries to interpolate.

Separately, each runner reports its own runner_version (a free-form string in the capability snapshot) and each harness reports its installed_version — both are informational, logged and stored, never used to gate behavior today.


Docket compatibility

Docket is reached the same way as any other coding agent: as a harness (--harness docket) on a runner-v1 execution request, through the API and CLI surface described on this page — see Choosing a harness for what it needs installed and what it can and can't do. The earlier, separate Docket control-plane bridge — a standing background poller with its own dispatch, approval and budget routes — has been retired; Docket carries no special scheduling path or API surface of its own anymore.


Non-loopback and security posture

Runner-protocol routes (/api/runner/v1/*) are mounted as a structural sibling of the operator /api router, not nested inside it — they never traverse require_token (the operator bearer-token gate) at all, and each handler authenticates its own hashed runner credential independently (crates/tack-api/src/handlers/runner_protocol/runner_auth.rs). This is deliberate: a future edit that widens the operator auth exemption list cannot accidentally loosen runner auth, because runner auth was never implemented as an exemption in the first place.

For everything else about exposing Tack beyond 127.0.0.1 — TLS termination behind a reverse proxy, TACK_ALLOWED_ORIGINS, TACK_API_TOKEN, request body limits — see Administration and Security, which applies identically whether or not any runner is enrolled. The one addition specific to this domain: TACK_EXECUTION_DECISION_TOKEN is a separate, higher-privilege secret layered on top of TACK_API_TOKEN, fail-closed when unset (the route rejects rather than silently falling back to the ordinary operator token) and never logged. See docs/CONFIG.md for every TACK_* variable in this domain.

tack serve --with-runner (see "Standalone mode" above) adds a second, stricter loopback requirement on top of all of this: an embedded runner executes arbitrary coding-agent processes on the same host serving the UI, so --with-runner itself refuses to start unless TACK_HOST is a loopback address — independent of, and checked before, whichever TACK_API_TOKEN/TACK_API_ALLOW_UNAUTHENTICATED_NONLOOPBACK posture the server would otherwise apply.


After a succeeded attempt

What a runner does once an attempt has succeeded, and before its workspace is deleted. Every step below is the runner's, on its machine; the board never runs a program or pushes a branch. None of them can change the attempt's outcome: if one fails, the attempt is still succeeded and the failure is recorded as an event on its timeline.

Evidence. For every harness, and also for a cancelled attempt, the runner records what the attempt changed against the revision it started from. Three artifacts are uploaded: changes.patch (the full diff, cut at 8 MiB with a flag in the manifest when it is longer), files.json (each path and whether it was added, modified, deleted or renamed) and evidence.json (the manifest: the base and head commits, the patch's checksum and size, the files, the terminal reason and the usage). When the request carried a brief, brief.json is kept beside them. If git cannot read the workspace, only evidence.json is written and it says why. The shape is docs/contracts/evidence-v1/.

Verification. A runner with a [verify] section enabled in its TOML config runs a program of yours over that evidence and uploads the merge-readiness pack it writes as one more artifact. It is off by default. A failing, missing or timed-out verifier records attempt.verify_failed and nothing more. The pack is what you read under Reviewing a merge-readiness pack. The section is described in docs/CONFIG.md; tack runner doctor reports whether it is on and whether the program is found.

Branch push. A runner with push_branches = true under [git] commits what the harness left uncommitted onto a branch named <prefix><item short id>-a<attempt number> and pushes it to the remote the repository was fetched from, using your own git credentials. It is off by default. A failed push records attempt.push_failed. Turn it on only for agents you would trust with those credentials; docs/CONFIG.md says what the push does and does not guard against.

Pull request. When the attempt pushed a branch, its item is linked to a GitHub issue and a GitHub token is configured, the server opens a pull request from that branch, with the merge-readiness pack as its body. The attempt then shows a PR #n link and a badge: open, merged, closed or reverted. The state follows GitHub on the sync poll; see GitHub sync. An item with no link, or no token, gets no pull request and nothing else changes.

Moving the item. A request can carry a status policy: done_on_success moves the item to its workflow's first Done status when the attempt succeeds, and done_on_mrp_accepted moves it when you accept the attempt's merge-readiness pack. Without one the item's status is never touched. Set it with tack execution create --status-map-policy or status_map_policy_id in the API and MCP; the Run with agent dialog does not offer it. The move goes through the workflow's ordinary rules and open boards are told, like any edit.


Reviewing a merge-readiness pack

When a verifier checks an attempt, it leaves a merge-readiness pack. Open the attempt's details on the Execution tab and the pack appears under "Merge-readiness pack".

A merge-readiness pack on a succeeded attempt: Recommendation merge, Risk low, three reasons, a criteria table with every criterion passed and its evidence file, the verify command with exit code 0, mutation score, static analysis counts, a blind judge's verdicts, and Your review with a required reason before Accept or Reject.

From top to bottom it shows the verifier's recommendation and the risk level with its reasons, then one row per criterion from the brief (id, kind, status, evidence), the verify command with its exit code and output, mutation results, static-analysis counts and the judge's verdict on each criterion.

A section the verifier did not run says Not run. That is not a pass: nothing was checked.

To record your verdict, write a reason, then choose Accept or Reject. Both stay disabled until the reason has text. The first review is final; a second one is refused. If the run was created with the done_on_mrp_accepted status policy, accepting also moves the item to its workflow's first Done status.


Usage economics and "Not measured"

usage_economics.runner_time_cost.cost_usd_estimated is always {value: null, source: "not_measured"} in production — no runner infra cost-rate is stored anywhere in this schema, so there is nothing honest to compute it from. The UI renders the literal string Not measured, never $0.00: rendering zero would claim the run was free, which is not known to be true. attemptFormat.test.ts#formatUsdMeasurement asserts the exact string, that it does not contain $0, and — as a positive control — that a real {value: 0, source: "measured"} renders the genuinely different "$0.00 (measured)", proving the two cases are distinguished rather than both collapsing to one string.

Token usage, when the harness reports it, is measured. When it doesn't, it is not_measured — never a structural zero standing in for "unknown."


Factory metrics

The Factory metrics page (Factory metrics in the project's sidebar, at /projects/<id>/factory; GET /api/projects/{id}/metrics/factory for the same numbers) shows one card per metric from an aggregated summary of all attempts, decisions, and verification activity for a project. The page covers all time; the endpoint takes an optional since (RFC 3339) to start the window later. Each card displays the computed ratio or value (when it can be measured), or "Not measured" with its reason when measurement is not possible.

MetricDefinitionSource
Escalation rateDecisions ÷ Attemptsexecution_decisions ÷ execution_attempts in the window
Human minutes per decisionMedian and p90 of the time a decision waits for an answerFrom when someone first opened the decision (or from when it was raised, if nobody opened it first) to its resolution; execution_decisions in the window
Human minutes per pack reviewMedian and p90 of the time a merge-readiness pack waits for its verdictFrom when someone first opened the pack (or from when it was uploaded, if nobody opened it before the verdict) to the review; mrp_reviews in the window
Verification tax (tokens)(Verification + Rework) ÷ ImplementationImplementation = tokens from each request's first attempt; Rework = tokens from later attempts plus requests marked as rework; Verification = the tokens each verifier reported spending in its pack
Pack acceptance rateAccepted ÷ ReviewedCounts from mrp_reviews; also shows unreviewed and produced packs
OutcomesPull requests opened, merged, closed, revertedData from pull_requests; also shows PQC (pull request quality: merged and not reverted)

When a metric has nothing to count, the card says "Not measured" and why, never 0. No card shows money: token counts only. When timing data has no resolved samples in the window, it shows "Not measured" rather than zero minutes. The source column for each metric shows which table it reads and, for time-based metrics, whether resolution time started from viewed_at (human first opened it) or created_at (never viewed before resolution).


Known gaps

These are documented rather than papered over, per this project's "unsupported is typed, unknown is explicit" rule:

  • execution_requests has no real priority column. A metadata-convention stopgap exists, documented as non-binding.
  • A docket run that times out is not cancelled. Cancelling stops every process group docket announced; a timeout stops only docket's main process group, so a command docket started in its own group can outlive the attempt.
  • tack runner doctor does not print each harness's artifacts and permission_policy support in its readable output; for docket those are decided when the runner starts.
  • The branch push runs git in the attempt's workspace, which the agent could have reconfigured while it worked. Hooks are disabled; see docs/CONFIG.md.

What actually runs today

Read this section before treating any earlier claim on this page as a promise about a live, end-to-end run.

Proven end-to-end against the real production router (build_router, no card-local test scaffolding): creating an execution request, scheduling it to an exact runner or a fleet, the full runner-v1 protocol surface (enroll → claim → heartbeat → accept → start → events → decisions → artifacts → completion), cancellation and recovery-observation handling, the decision-resolve and artifact-download routes, and the retention/expiry sweeps — see crates/tack-api/tests/handlers/production_router.rs, crates/tack-orch/tests/runner_contract.rs (byte-pins all 46 frozen fixtures), and the integrator test files referenced throughout this page.

The tack-runner binary's own network transport is wired and proven. tack_runner::bootstrap::build_runtime (the one composition root both the standalone tack-runner binary and the embedded tack serve --with-runner/tack runner start paths call — see "Standalone mode" above) wires the real HttpPullProtocol, not UnavailableProtocolClient. UnavailableProtocolClient still exists in the crate as an explicit no-client fallback — reachable only if something constructs a runtime without attaching a protocol client — and stays pinned by runtime::tests::unavailable_protocol_is_a_typed_failure_not_success precisely so that path fails as a typed RunnerError::ProtocolUnavailable rather than a silent success; it is not what a real tack-runner startup wires today.

Live, end-to-end proof of the real client: crates/tack-runner/tests/ bootstrap_entrypoint.rs proves the composition root itself is reachable and shutdown-controllable from outside the crate against a real (mocked) HTTP enrollment exchange. Every run of scripts/smoke.sh exercises the real client against a real server: steps 6-9 enroll a separate tack-runner process and drive a real attempt through claim → checkout → harness → completion, restart recovery included; step 10 does the same inside a single tack serve --with-runner process. A fresh-machine operator today can enroll a runner and point a real tack-runner process (standalone or embedded) at a real server and watch it enroll, claim, and complete real work.

Recovery Runbook

What to do when a tack-runner process, its host, or the harness subprocess it launched dies mid-attempt, and how Tack decides whether it's safe to retry automatically or requires you to look.

The governing rule, stated once so nothing below contradicts it: Tack does not claim exactly-once harness execution. A database transaction can prevent two valid leases at once, but a runner or network crash after a process has actually launched can leave ownership genuinely ambiguous — nothing server-side can prove the process is dead. The contract is narrower and honest: at most one valid active lease at a time, a monotonically increasing fencing token per attempt, a local runner journal written before any process spawn, idempotent completion reports, and needs_operator whenever safe retry cannot be proven. A lease expiring never, by itself, launches a second process against the same workspace.


The lifecycle states involved

leased ──▶ preparing ──▶ running ──▶ waiting_decision
   │            │            │              │
   └────────────┴────────────┴──────────────┘
                       │  (recovery service observes)
                       ▼
              lost  or  needs_operator
  • lost — the recovery machinery is convinced no process was ever actually running for this attempt (it crashed or the runner restarted before spawning anything). Automatically safe to requeue.
  • needs_operator — anything less certain than that. Not automatically retryable. Requires an explicit, audited operator decision.

Only two actors can move an attempt into either state: (Leased | Preparing | Running | WaitingDecision) → (Lost | NeedsOperator), and only the recovery service (never the scheduler, never a bare lease-owner heartbeat timeout) may make that transition — crates/tack-orch/src/execution/lifecycle.rs::validate_transition. There is no background sweep that ages out a stale lease into lost on a timer; recovery is observation-driven, described next.


How recovery starts: the runner reports what it saw

When a tack-runner process restarts (after a crash, a host reboot, or an operator kill), it reads its own local journal — written to TACK_RUNNER_STATE_DIR before any harness process was ever allowed to spawn (crates/tack-runner/src/journal.rs) — and reports one of three observations to POST /api/runner/v1/attempts/{id}/recovery-observation:

ObservationWhat it means
process_stoppedThe runner found no live process for this attempt (checked by PID/process-group, not merely "the runner restarted")
process_runningThe runner found the harness process still alive and reattached to it
ambiguousThe runner could not determine either way — e.g. the process table is inconclusive, or the journal itself was corrupt/incomplete

The server decides the disposition from the observation and the journal state it already had on file for that attempt (crates/tack-db/src/repo/execution.rs, recover_attempt):

  • safe_pre_spawn_requeue — granted only when the observation is process_stopped, the attempt's started_at was never set, and the last known journal state was prepared (the harness process itself was never spawned in the first place). This is the one case narrow enough to resolve automatically: nothing ever ran, so there is nothing ambiguous to reconcile. The attempt moves to lost, the request moves back to queued, no operator involved.
  • needs_operator — every other combination: process_running (a live process exists but the runner that owned it just changed identity across a restart — no safe way to hand it back into scheduling from here), ambiguous, or process_stopped after the process had actually started (it may have made side effects — a partial commit, a half-written file — that a blind requeue would duplicate).
  • already_terminal — the attempt had already reached succeeded/failed/ cancelled before the observation arrived; the observation is recorded for audit but changes nothing.

Recovery observations are idempotent, scoped by a caller-supplied recovery_key: a second call with the byte-identical request replays the same response (RecoveryObservationResult::Replayed); a second call with a different body under the same key returns 409 idempotency_conflict rather than silently overwriting the first audit record.


Resolving a needs_operator attempt

This is the only path back to queued for anything other than the narrow safe_pre_spawn_requeue case. It requires a human decision and an audit trail — there is no API shortcut that skips the reason field.

  1. Investigate. Check the runner host directly: is the harness process actually still running? Did it leave a partial workspace/worktree behind (TACK_RUNNER_STATE_DIR, and the attempt's worktree path recorded in the journal)? Check the execution's event timeline (GET /api/executions/{id}/attempts/{n}/ events) for the last thing the runner reported before contact was lost.

  2. Decide and record why. Once you're confident it's safe — the process is confirmed dead, or you've manually cleaned up any side effects — requeue with an explicit reason:

    tack execution reconcile <request-id> \
      --recovery-key "ops-2026-08-19-host-restart" \
      --reason "confirmed via ps on runner-3 that the codex process is gone; \
    no uncommitted worktree changes found"
    

    This calls POST /api/executions/{id}/requeue (crates/tack-api/src/handlers/executions.rs::requeue_needs_operator), which:

    • succeeds only for a request the recovery service itself already authoritatively marked needs_operator — it is not a general-purpose "force requeue anything" escape hatch;
    • is idempotent on recovery_key: replaying the identical call returns replayed: true instead of double-queuing;
    • returns 409 conflict (idempotency_conflict) if the same key is reused with a different confirmation, and 409 invalid_transition — naming the actual current state in details.from — for any request that isn't in an authoritatively recovered needs_operator state.

    Verified against a live server for this page: requeuing a request that was never put into needs_operator in the first place returns exactly {"code":"invalid_transition","details":{"from":"unknown","to":"queued"}}, never a silent success. Reused in crates/tack-cli/tests/e6_scheduler_e2e_test.rs.

  3. Confirm. tack execution get <request-id> shows queued (or, if the reason field described something unrecoverable, resolve to failed by other means — the requeue path only ever re-queues, it does not force a terminal state).


Cancellation is a request, not a guarantee

POST /api/executions/{id}/cancel records a request for cancellation; the runner observes and reports the actual outcome via POST /api/runner/v1/attempts/{id}/cancellation-observation. This mirrors the recovery split deliberately: Tack cannot itself kill a process running on a runner's host, so it never pretends to. See the capability matrix for which harnesses can promise that a cancelled run's processes actually stop. Most cannot: a harness's own shell tool starts its subprocess in a new session outside the runner's process group, and the runner has no way to find it. A harness that reports each process group its tools start (docket does) is the exception, and its cancellation stops those groups too.

The cancellation observation carries what happened, in details. details.process_outcome says how the harness's own process stopped (stopped, killed, or signal_failed when the stop signal could not be delivered, which leaves the observation ambiguous). details.groups counts the process groups the harness reported and had not reported finished: tracked were running when the cancellation began, killed are gone afterwards, and survived are still running and need stopping by hand on the runner's host. A harness that reports no process groups always shows tracked: 0. That means nothing was reported, not that nothing was left behind.

A cancellation observation on an attempt that already reached a terminal state returns 409 invalid_transition with the actual terminal state named, not a false success.


What this runbook does not cover

  • Database or migration recovery (a crashed migration, a corrupted .db file) — see Backup and Restore and docs/adr/0008-transactional- migration-rebuild-recovery.md.
  • Adversarial proof of these boundaries — duplicated or revoked runner credentials, a stale fence retried against every attempt-scoped route, corrupt or truncated runner journals, oversized or path-traversal artifact uploads, and reordered/replayed event batches — is in crates/tack-api/tests/security/chaos_recovery.rs and crates/tack-runner/tests/g2_journal_corruption_test.rs, both run against the real production router and a real runner journal, not this page. Killing the API process at each protocol step is covered separately by crates/tack-runner/tests/crash_matrix.rs. Disk-full/ENOSPC mid-write has no existing test harness in this repository and remains unverified.
  • Chaos/adversarial proof of the runner's own recovery path against a live server beyond what scripts/smoke.sh's restart-recovery step (step 9) already covers — see the Agent Runners page. The runner binary's network transport is wired to the server (tack_runner::bootstrap::build_runtime attaches the real HttpPullProtocol, not a stub), and a runner can report a recovery observation over a live connection today.

Troubleshooting and FAQ

This page covers the problems you are most likely to hit while running Tack, and answers common questions about how it stores and protects your data. Tack is a single self-hosted binary backed by a local SQLite database, so most issues come down to the server process, the database file, or a few environment variables.


Troubleshooting

Work through the matrix below first, then read the detailed sections for the commands and SQL you need.

SymptomLikely causeFix
Server exits immediately on startPort 3210 already in useChange TACK_PORT or stop the conflicting process
database is locked errorsAnother process is holding tack.dbRun a single server; close other connections
Errors mentioning a failed migrationInterrupted/partial migrationInspect _migrations; restore or restage a backup
Board or UI never loadsAPI server not runningCheck GET /api/health
401 Unauthorized on every requestTACK_API_TOKEN set, but no/wrong Authorization headerSend Authorization: Bearer <token>
Browser console shows CORS errorsOrigin not in the allow-listAdd it to TACK_ALLOWED_ORIGINS
Uploads rejected (413 / too large)File over the size limitStay under 50 MB; raise TACK_MAX_BODY_SIZE for non-attachments
Search returns nothingSQLite built without FTS5Use a SQLite/binary with FTS5 enabled
Vocabulary or theme "didn't change"Stale page stateRefresh; theme/palette live in localStorage

Server won't start / "port already in use"

Cause. By default the server binds to 127.0.0.1:3210. If another process (often a previous Tack instance that did not exit) already holds that port, startup fails.

Fix. Either free the port or run Tack on a different one. The port comes from TACK_PORT (default 3210); the bind address comes from TACK_HOST (default 127.0.0.1).

# See what is using the default port
lsof -i :3210        # macOS / Linux

# Start on a different port
TACK_PORT=4000 tack serve

Open the UI at the host and port you started with, for example http://127.0.0.1:4000. See Configuration for the full list of variables.


"Database is locked"

Cause. SQLite allows only one writer at a time. This error means another process already has tack.db open for writing — usually a second tack serve instance, an open sqlite3 shell, or a database GUI.

Fix. Run exactly one Tack server against a given database file. Close any other process that has the file open (other tack instances, sqlite3 sessions, DB browsers). The default connection string already uses read-write-create mode (sqlite:tack.db?mode=rwc), so you should not need to change it.

If you genuinely need a second instance, point it at a different database with TACK_DATABASE_URL.


Migrations failed on startup

Cause. Tack runs its schema migrations automatically when the server starts and records each applied migration in the _migrations table. If the process is killed mid-migration, or the database file is from an incompatible build, startup can fail with a migration error.

Fix. Inspect which migrations were recorded:

sqlite3 tack.db
SELECT * FROM _migrations;

If a migration is recorded as applied but the schema is clearly incomplete, the safest recovery is to restore a known-good backup rather than hand-editing the schema. See Backup and Restore. For the full upgrade path — what runs automatically, when Tack takes its own pre-upgrade snapshot, and how to safely enable the Part III runner-fleet execution features — see docs/MIGRATION-GUIDE.md.

How staged restore interacts with this. A restore is staged, not applied live: Tack writes the uploaded database next to the live file as <db>.restore and tells you to restart. On the next startup, before opening the database, Tack swaps the files atomically:

  • the current database is moved aside to <db>.bak,
  • the staged <db>.restore becomes the live database.

So after a restore your previous database is preserved as tack.db.bak. If a restore leaves you worse off, stop the server and move tack.db.bak back to tack.db to return to the prior state.


Board or UI won't load

Cause. The web UI is served by the same binary as the API. If the page is blank, stuck on a skeleton loader, or shows network errors, the API server is almost always down or unreachable.

Fix. Confirm the server is up with the health endpoint:

curl http://127.0.0.1:3210/api/health

A healthy server responds with 200 OK. If the request hangs or is refused, the server is not running on that host/port — start it with tack serve and check the console for errors. If you changed TACK_HOST/TACK_PORT, query the address you actually bound to.

The /api/health endpoint is intentionally exempt from the API-token gate, so it always works even when TACK_API_TOKEN is set — making it a reliable liveness check.


401 Unauthorized / "token rejected"

Cause. When TACK_API_TOKEN is set on the server, every /api/* route except /api/health requires a matching Bearer token. A 401 means the header is missing, malformed, or does not match the configured token.

Fix. Send the token on every request:

curl -H "Authorization: Bearer <token>" http://127.0.0.1:3210/api/projects

The header value must be exactly Bearer followed by the same string you set in TACK_API_TOKEN. If you are using the web UI behind a token, make sure the UI is configured with the same token. If you did not intend to require a token, unset TACK_API_TOKEN and restart. See Administration & Security.


CORS errors in the browser console

Cause. The browser blocks requests from an origin that the server has not allow-listed. Tack's CORS allow-list defaults to local origins only:

http://localhost:8080
http://127.0.0.1:8080
http://localhost:3210
http://127.0.0.1:3210
https://tack.test

If you serve the UI from a different host, port, or scheme, the browser reports a CORS failure.

Fix. Add your origin to TACK_ALLOWED_ORIGINS (comma-separated) and restart:

TACK_ALLOWED_ORIGINS="https://tack.example.com,http://127.0.0.1:8080" tack serve

Use the exact scheme, host, and port the browser sends — http:// and https://, and a non-default port, are all distinct origins.


Uploads failing

Cause. There are two separate limits. File attachments have a fixed maximum of 50 MB. All other (non-attachment) request bodies are capped by TACK_MAX_BODY_SIZE, which defaults to 2 MB (2097152 bytes). A request over its limit is rejected (typically 413 Payload Too Large).

Fix. For attachments, keep individual files under 50 MB. For large JSON imports or other big non-attachment payloads, raise the global limit and restart:

# Allow 10 MB request bodies for non-attachment endpoints
TACK_MAX_BODY_SIZE=10485760 tack serve

Note that raising TACK_MAX_BODY_SIZE does not change the 50 MB attachment ceiling.


Search / FTS5 not working

Cause. Full-text search is built on SQLite's FTS5 extension. If your SQLite library was compiled without FTS5, the search index cannot be created or queried.

Fix. Verify FTS5 is available in the SQLite your environment uses:

sqlite3 tack.db "PRAGMA compile_options;" | grep FTS5

If ENABLE_FTS5 is not listed, use a SQLite build (or a Tack binary) with FTS5 enabled. The official Tack binaries ship with FTS5 support.


Vocabulary or theme change "didn't apply"

Cause. Two different things are at play. Display preferences — the light/dark mode and the colour palette — are stored in your browser's localStorage, not on the server, so they are per-browser and persist locally. Vocabulary changes are saved server-side per project, but an already-open tab may still show cached labels.

Fix. Refresh the page (a hard refresh if needed). Theme and palette will reload from localStorage; vocabulary labels will reload from the server. If a theme looks wrong only in one browser, clearing that browser's site data resets it to defaults. See Appearance and Vocabulary.

An agent attempt succeeded, but there is no pull request, pack or branch

Cause. Everything after a succeeded attempt is opt-in and best-effort, and none of it changes the attempt's outcome. Look at the attempt's event timeline: attempt.verify_skipped, attempt.verify_failed, attempt.push_skipped and attempt.push_failed each say why a step did not happen. The usual reasons are that the runner has no [verify] section enabled or no [git] push_branches = true, the verifier program is not on the runner's PATH, git has no credentials for the remote, or the item is not linked to a GitHub issue or no GitHub token is configured (a pull request needs both).

Fix. Run tack runner doctor on the runner's machine: its last lines report whether the verifier is enabled and found. Set the sections as described in docs/CONFIG.md, link the item to its issue and set TACK_GITHUB_TOKEN, then run the item again. See After a succeeded attempt.


FAQ

Is my data sent anywhere?

No. Tack is fully self-hosted. All your projects, items, and comments live in a local SQLite database on the machine you run it on. Nothing is sent to Anthropic or to any Tack-operated service. Outbound traffic only happens for features you explicitly configure — for example a webhook URL, a GitHub/Linear import, or an S3-compatible cloud backup you set up yourself.

Can I use Tack offline?

Yes, in the local-first sense. Tack runs entirely on your own machine or LAN, with no dependency on an external service. The one requirement is that the Tack server process is running: the web UI and CLI both talk to it over HTTP. As long as the server is up on your machine (or a machine on your network), you can work without any internet connection.

Where is my data stored?

In two places, both local:

  • Database — a single SQLite file. By default TACK_DATABASE_URL is sqlite:tack.db?mode=rwc, i.e. a tack.db file in the directory you run the server from.
  • Attachments — uploaded files live under TACK_STORAGE_DIR, which defaults to ./storage, organised by item.

To relocate either, set the corresponding variable (see Configuration) and restart.

How do I back up my data?

Use Tack's backup features rather than copying the live database while the server is running. You can download a local backup, or push a copy to an S3-compatible cloud store, and stage a restore that applies on the next restart. See Backup and Restore for the full workflow.

Is Tack for a single user or a team?

Both, with a focus on solo developers and small teams. Multiple people can work against the same server, and board changes propagate live to all connected clients over a WebSocket connection, so updates appear without manual refreshes.

Which platforms does it run on?

Tack ships as a single self-contained binary for Linux, macOS, and Windows. There is no separate runtime to install; the web UI can be embedded in the binary so one file serves both the API and the SPA.

How do I secure Tack on a network?

Tack defaults to 127.0.0.1 for local-only use. Before exposing it beyond your machine:

  1. Require a token. Set TACK_API_TOKEN so all /api/* routes (except /api/health) demand Authorization: Bearer <token>.
  2. Restrict origins. Set TACK_ALLOWED_ORIGINS to only the hostnames that should reach the UI.
  3. Terminate TLS at a reverse proxy. Tack itself serves plain HTTP; put it behind a reverse proxy (such as Caddy or nginx) that provides HTTPS, and bind Tack to a private address.

See Administration & Security for step-by-step setup.

Does Tack integrate with GitHub or Linear?

Yes. You can import issues from GitHub and from Linear into a project. GitHub integration also keeps linked issues and items in sync in both directions when a GitHub token is configured, and opens a pull request for an agent's pushed branch. See Import and Export for setup, filters, and token requirements.

Architecture Overview

They are separate because they scale and fail differently. One board, many runners: a board on a small VPS dispatches to runners on ten developers' machines, each with its own agent, model and capacity. A runner that dies mid-run cannot corrupt the board — its lease expires and its fencing token stops writing. A board that restarts cannot lose a run — the runner's journal knows what it started. One developer runs both in one process with one command, on the same contract, with the same recovery.

You have read the quick-start and can run the server. This document explains why the code is structured the way it is — the reasoning behind each layer, how a request actually travels through the system, and where the interesting domain logic lives.

This page is a narrative walkthrough, not a reference. For the condensed crate-boundary table, the full API endpoint list, the database schema, and troubleshooting, see docs/ARCHITECTURE.md — the authority on any fact the two disagree on.


1. The Layering Rule

Tack enforces a strict one-way dependency graph across the workspace's six crates:

tack-core  ←  tack-db  ←  tack-orch  ←  tack-api  ←  tack-cli

tack-runner  (a separate binary, sibling to this graph — talks to tack-api
              over the runner-v1 HTTP contract, not through any Rust dependency)

Each arrow means "depends on". No reverse arrows are allowed. A seventh crate, tack-desktop, is deliberately excluded from this workspace (its own Cargo.toml and lockfile) because Tauri drags GTK/WebKit/glib into whatever workspace holds it — see the Crate Tour for how it supervises tack instead of linking against it.

What this means in practice:

  • tack-core has zero I/O. It cannot open a file, touch a database, or make a network call. It only contains pure Rust structs, enums, and functions. You can run every test in it without a database process.
  • tack-db knows about tack-core (it persists those structs), but it knows nothing about HTTP, routing, or config files.
  • tack-orch knows about tack-core and tack-db but nothing about HTTP — it is the neutral runner-v1 execution domain, usable without Axum. See Crate Tour.
  • tack-api is the only place where HTTP concerns (status codes, request extraction, CORS) and database concerns meet. It depends on tack-orch to run the scheduler/retention/observability tasks and expose the execution routes.
  • tack-cli is the single tack binary. It depends on tack-api so that tack serve can start the server in-process, but its client commands only talk to a running server over HTTP — they never open the database directly. This means the CLI works whether the server is local or on a remote machine.
  • tack-runner is a separate binary entirely. It never depends on any of the crates above; it speaks the runner-v1 protocol to tack-api over loopback or the network, the same way a remote runner would.

Why bother? The layering prevents the kind of "everything knows about everything" entanglement that makes codebases brittle. It also means:

  • Workflow rules are always consistent. Because all validation lives in tack-core, neither a direct DB call nor an API call nor a CLI command can bypass it.
  • Tests are fast. Core tests are pure function calls — no test database, no async runtime, no cleanup.
  • The domain model can be reused. If a future project needs the same workflow engine, it can depend on tack-core without dragging in SQLite or Axum.

2. The Universal Item Model

Every piece of work in Tack — whether it is called a task, an epic, a bug, a work order, or an assignment — is stored as an Item. There is one table, one struct, one set of repository functions.

The Item struct in tack-core/src/models.rs carries an item_type field that records what kind of thing it is (Task, Epic, Bug, Feature, Subtask, Requirement, or a Custom(String) for anything else). The vocabulary system then translates that type name into whatever label the project uses.

Think of it like a spreadsheet: every row has the same columns. The header labels change depending on who is looking at the sheet. A construction project relabels task as Work Order and sprint as Phase, but the underlying row structure is identical.

Benefits:

  • One migration path. Adding a new field to items (say, a story_points column) means one migration, one struct update, one set of repository functions — not separate migrations per item type.
  • Hierarchy is free. Because every item can have a parent_id pointing to another item, the tree structure (epic → feature → task → subtask) falls out naturally without a separate parent-type/child-type mapping.
  • Filtering is uniform. The same ItemFilter struct handles filtering by item_type, status, priority, assignee, or tag regardless of project type.

The tradeoff is that the item_type field carries less compile-time enforcement than a proper type hierarchy. This is intentional — the flexibility outweighs the constraint for a tool meant to work across very different domains.


3. How a Request Flows Through the System

Here is a concrete walk-through of PATCH /api/items/{id} — the endpoint that moves an item to a new status.

HTTP client
    │
    ▼
Axum router (tack-api/src/router.rs)
    │  Extracts: Path(item_id), State(AppState), Json(UpdateItem)
    ▼
Handler: update_item (tack-api/src/handlers/items.rs)
    │  1. Calls repo.get_item(id) to load current item
    │  2. Calls repo.get_project(project_id) to load workflow
    │  3. Calls workflow.validate_transition(old_status, new_status)  ← pure, no I/O
    │  4. Calls workflow.check_wip_limit(new_status, current_count)  ← pure, no I/O
    │  5. Calls repo.update_item(id, patch)  ← writes to DB
    │  6. Calls repo.check_and_update_parent_status(...)  ← best-effort parent cascade
    │  7. Calls broadcast_event(ItemUpdated { ... })  ← fires WebSocket event
    ▼
Repository (tack-db/src/repo/items.rs)
    │  Runs parameterised SQL via sqlx
    ▼
SQLite
    │
    ◀── Result<Item, sqlx::Error>
    │
Handler maps error → ApiError → HTTP response
    │
    ▼
HTTP client receives JSON

A few things worth noting:

  • Steps 3 and 4 call pure functions in tack-core. No database round-trip is needed to validate the transition — the entire workflow config was loaded with the project in step 2, and it lives in memory as a WorkflowConfig struct.
  • The handler owns the orchestration. It decides the order of validation, persistence, and notification. The repository and the core crate do not know about each other.
  • Errors propagate upward via Rust's ? operator. The ApiError type (in tack-api/src/error.rs) implements IntoResponse, mapping each CoreError variant to the appropriate HTTP status code.

4. How Workflow Validation Works

The workflow for a project is a WorkflowConfig struct stored as JSON in the projects table. When the server loads a project, it deserialises that JSON back into the struct — no extra tables, no joins.

The WorkflowConfig holds:

  • A list of StatusDef entries (name, category, optional WIP limit, sort order).
  • An optional list of Transition pairs (from, to).

validate_transition(from, to) does two things:

  1. Checks that both from and to exist in the status list.
  2. If an explicit transition list exists, checks that the pair is in it.

If the transition list is absent (None), any move between two valid statuses is allowed. This is the Scrum and Kanban default. The construction workflow, by contrast, defines an explicit list — you cannot skip from Permit to Handover.

Why is this in tack-core and not in the database layer?

Because it is a business rule, not a storage rule. The database does not know what statuses are valid — that is configuration data stored in a JSON column. Only tack-core knows how to interpret that configuration and enforce the rules. Putting the validation in the repository would mean the repository would need to import workflow logic, violating the layer boundary. Putting it in the handler (without core) would scatter the logic and make it untestable without a running server.

By living in tack-core, the validation is:

  • Testable with zero dependencies (73 unit tests, none of which touch a database).
  • Reusable by any layer that loads a WorkflowConfig.
  • Independent of how the config was persisted.

5. How Real-Time Updates Work

The real-time board feature uses a Tokio broadcast channel. Here is the architecture:

AppState
  └── broadcast_tx: broadcast::Sender<BoardEvent>   (capacity: 100)

On any mutating API call:
  handler calls broadcast_event(&state, BoardEvent::ItemUpdated { ... })
    └── state.broadcast_tx.send(event)  ← non-blocking, drops if no subscribers

On WebSocket connection (GET /api/projects/{id}/boards/live):
  handler upgrades the connection
  spawns two tasks:
    send_task:  rx.recv() in a loop → filter by project_id → send JSON to client
    recv_task:  reads messages from client (handles Close, Ping)
  tokio::select! on both tasks — whichever ends first, the other is aborted

The broadcast channel is the pub/sub backbone. Every handler that modifies data calls broadcast_event(); every WebSocket connection subscribes to the channel and filters events by project_id before forwarding them to the client.

Important properties:

  • Fire and forget. broadcast_tx.send() returns immediately whether or not there are subscribers. If nobody is listening, the event is dropped. This keeps the write path fast.
  • Per-project filtering happens in the send task. The channel carries events for all projects; each WebSocket connection only forwards events that match its project_id. This keeps the channel logic simple while allowing one channel to serve many concurrent connections.
  • No persistence. Events are not stored. A client that disconnects and reconnects will not receive missed events — it will simply see the current board state on the next page load.

6. Directory Map

.
├── crates/
│   ├── tack-core/         Pure domain logic; no I/O
│   │   └── src/
│   │       ├── models.rs    All domain structs and DTOs
│   │       ├── workflow.rs  WorkflowConfig, validation, presets
│   │       ├── vocabulary.rs VocabularyMap, resolve(), presets
│   │       ├── dependency.rs DependencyGraph, cycle detection
│   │       ├── error.rs     CoreError enum
│   │       └── lib.rs       Re-exports
│   │
│   ├── tack-db/           SQLite persistence layer
│   │   └── src/
│   │       ├── lib.rs       init_pool() — WAL mode, foreign keys on
│   │       ├── migrations.rs Ordered migrations as embedded SQL strings
│   │       ├── repo.rs      Repository struct — delegates to submodules
│   │       └── repo/        One file per entity (items, projects, sprints, …)
│   │
│   ├── tack-orch/         The runner-v1 execution domain
│   │   └── src/
│   │       ├── lib.rs       Module declarations, OrchError
│   │       ├── execution/   Lifecycle validation, fencing/idempotency types
│   │       ├── scheduler/   Deterministic runner selection
│   │       ├── model_policy/ Deterministic model-selection precedence
│   │       ├── execution_retention.rs Cancellable retention sweep
│   │       ├── execution_observability.rs Fleet health snapshots
│   │       └── usage_provenance.rs Requested-vs-actual model + usage economics
│   │
│   ├── tack-api/          Axum HTTP server + WebSocket
│   │   └── src/
│   │       ├── main.rs      Startup: config, pool, migrations, listen
│   │       ├── router.rs    All routes + AppState + middleware wiring
│   │       ├── config.rs    AppConfig — TOML / env var loading
│   │       ├── error.rs     ApiError → HTTP status code mapping
│   │       ├── debug.rs     /api/health, /api/debug/* endpoints
│   │       ├── middleware.rs Bearer token gate
│   │       └── handlers/    One file per entity group + websocket.rs
│   │
│   ├── tack-cli/          CLI (clap), talks to API over HTTP
│   │   └── src/
│   │       ├── main.rs      Command tree + implementation functions
│   │       ├── client.rs    TackClient — thin reqwest wrapper
│   │       ├── config.rs    ~/.tackrc reader/writer
│   │       ├── service.rs   `tack service` — systemd/launchd unit management
│   │       └── vocab.rs     Fetch and cache project vocabulary
│   │
│   ├── tack-runner/       Separate binary; pull-based execution runner
│   │   └── src/
│   │       ├── main.rs      Startup, config, registry
│   │       ├── engine.rs    Claim → prepare → run → report loop
│   │       ├── harness/     Adapters per coding agent (codex, claude_code, docket, opencode)
│   │       ├── journal.rs   Local attempt journal for crash recovery
│   │       └── workspace.rs Per-attempt working directory + credentials
│   │
│   └── tack-desktop/      Tauri shell; excluded from the workspace above
│       └── src/            Supervises `tack` as a bundled sidecar process
│
├── frontend/                SolidJS + TypeScript + Tailwind v4
│   └── src/
│       ├── app/             Router, layout, route definitions
│       ├── features/        One directory per feature (board, projects, …)
│       ├── shared/          Reusable UI components, state helpers
│       └── types/           TypeScript type definitions (mirrors API shapes)
│
└── docs/book/               mdBook developer documentation

Crate Tour

This chapter walks through each of the six Rust crates in the main workspace in depth: what it owns, what it deliberately does not own, the key files, and the patterns worth understanding. tack-core, tack-db, tack-api, and tack-cli predate the Part III runner-fleet cycle; tack-orch and tack-runner were added by it. A seventh crate, tack-desktop, sits outside that workspace by design — its own section at the end of this chapter says why.

The SolidJS web UI in frontend/ is covered separately in Frontend & Design System — structure, the design-token system, and the shared/ui component kit.

This chapter is a per-file walkthrough, not a reference. For crate boundaries stated as a condensed table, design patterns, the DB schema, the full API endpoint list, and troubleshooting, see docs/ARCHITECTURE.md — the authority when the two disagree on a fact rather than depth.


tack-core

Lives in: crates/tack-core/src/

Owns: domain models, workflow engine, vocabulary system, dependency DAG, typed error enum, and the two contracts Tack reads and writes as plain data: an item's brief (brief.rs: the types, their validation and a Markdown rendering for the harness) and the merge-readiness pack (mrp.rs: the mrp-v1 types and their pull-request rendering).

Does not own: anything that performs I/O. No sqlx, no reqwest, no file operations, no tokio. This is enforced by the Cargo.toml — the crate has no async runtime dependency at all.


models.rs

The single source of truth for every domain struct. Notable types:

  • Item — the universal work unit. Fields include item_type: ItemType, status: String, parent_id: Option<Uuid>, tags: Vec<String>. The status field is a plain string rather than an enum because valid statuses are project-specific configuration, not compile-time constants.
  • Project — carries the workflow: WorkflowConfig and vocabulary: VocabularyMap inline. Both are serialised to JSON when stored in SQLite.
  • ItemType — an enum with Epic, Feature, Task, Subtask, Bug, Requirement, and Custom(String). The Custom variant allows ad-hoc item types without a code change.
  • CreateItem, UpdateItem, CreateProject, UpdateProject, etc. — all DTOs used for both API deserialization and repository function parameters. Keeping them in tack-core means the API and CLI reference the same validated shapes.

Validation constraints (length, range) are expressed via the validator crate's derive macros directly on the DTO structs. The API handlers call .validate()? before doing anything with the data.


workflow.rs

Defines WorkflowConfig, which is what gets stored as JSON per project.

#![allow(unused)]
fn main() {
pub struct WorkflowConfig {
    pub workflow_type: WorkflowType,
    pub statuses: Vec<StatusDef>,
    pub transitions: Option<Vec<Transition>>,
}
}

Each StatusDef has a name, a category (Todo, InProgress, or Done), an optional wip_limit, and an order integer for display sorting.

validate_transition(from, to) is the central enforcement function. It:

  1. Checks that both names exist in the status list.
  2. If transitions is Some(list), checks that the pair appears in the list.
  3. Returns Ok(()) or Err(CoreError::InvalidTransition { from, to }).

If transitions is None, any move between two known statuses is allowed. This is the default for Scrum and Kanban workflows.

check_wip_limit(status, current_count) looks up the StatusDef for the target column and returns Err(CoreError::WipLimitExceeded { ... }) if current_count >= limit.

Preset functions produce ready-made configs for each domain:

FunctionTypeStatusesTransitions
scrum_workflow()ScrumBacklog, To Do, In Progress (WIP 5), In Review (WIP 3), DoneNone (open)
kanban_workflow()KanbanQueue, In Progress (WIP 3), Review (WIP 2), DoneNone (open)
simple_workflow()SimpleTo Do, Doing, DoneNone (open)
construction_workflow()ConstructionPermit, Procurement, Build, Inspect, HandoverExplicit linear list

workflow_for_type(project_type) maps each ProjectType to the right preset. Adding a new project type means adding a variant to the ProjectType enum, a preset function, and a match arm here.

The test suite in this file covers initial status selection, transition validation for open and constrained workflows, WIP limit edge cases, done-status detection, and parent-completion logic — all without any database or async runtime.


vocabulary.rs

VocabularyMap is a type alias for HashMap<String, String>. It maps canonical keys (like "task", "sprint", "epic") to their display labels for a given project.

resolve(vocab, key) looks up a key in the project's vocabulary, falls back to the default vocabulary, and finally falls back to the key itself. This means partial vocabularies work fine — a construction project only needs to override the terms it cares about.

vocabulary_for_type(project_type) provides preset vocabularies. The construction preset, for example, maps:

  • "task" → "Work Order"
  • "sprint" → "Phase"
  • "epic" → "Building"
  • "bug" → "Defect"

validate(vocab) ensures that all keys in a user-supplied vocabulary are from the recognised list (VOCABULARY_KEYS). Unknown keys return Err(CoreError::InvalidVocabularyKey(...)) — this prevents typos from silently creating orphaned entries.


dependency.rs

DependencyGraph is an adjacency-list representation of item dependencies:

#![allow(unused)]
fn main() {
pub struct DependencyGraph {
    edges: HashMap<Uuid, Vec<(Uuid, DependencyType)>>,   // item → items it blocks
    reverse_edges: HashMap<Uuid, Vec<(Uuid, DependencyType)>>,  // item → items that block it
}
}

DependencyGraph::from_edges(edges) builds the graph from a slice of DependencyEdge values. The API handler loads all existing dependencies for the involved items, builds the graph, then calls validate_new_edge(source, target) before inserting.

would_create_cycle(source, target) runs a depth-first search starting from target, following the edges adjacency list. If it ever reaches source, adding source → target would close a cycle and the function returns true. The check is O(V + E) over the existing graph.

validate_new_edge wraps the check in a Result, also catching the self-reference case (source == target).


error.rs

CoreError is a thiserror-derived enum that covers every domain-level failure:

  • ItemNotFound(Uuid), ProjectNotFound(Uuid), SprintNotFound(Uuid), RoleNotFound(Uuid) — map to HTTP 404.
  • InvalidTransition { from, to }, WipLimitExceeded { column, limit, current }, DependencyCycle(Uuid), DuplicateDependency { ... }, InvalidVocabularyKey(String), EmptyWorkflow, HasChildren(Uuid, usize), Validation(String) — map to HTTP 400.

The mapping from CoreError to HTTP status codes lives in tack-api/src/error.rs, keeping the core crate free of HTTP knowledge.


tack-db

Lives in: crates/tack-db/src/

Owns: SQLite connection pool initialisation, migration runner, repository pattern over all entities.

Does not own: HTTP concerns, config loading, or business rule enforcement. The repository functions are thin: they translate between Rust structs and SQL rows.


lib.rs

init_pool(database_url) creates a SqlitePool using SqlitePoolOptions with a max of 5 connections, then immediately runs two PRAGMA statements:

  • PRAGMA journal_mode=WAL — enables Write-Ahead Logging for better concurrent read performance.
  • PRAGMA foreign_keys=ON — SQLite does not enforce foreign keys by default; this enables them.

migrations.rs

Contains every migration (check GET /api/health's migrations_applied field for the current count rather than trusting a hand-written number here) as const arrays of SQL strings. Each entry is (&str name, &[&str] statements). The runner:

  1. Creates _migrations table if absent.
  2. For each migration, checks if the name is already recorded.
  3. Executes each SQL statement in order.
  4. Records the migration name on success.

Migrations are idempotent — running them on an existing database is safe. Notable migrations:

  • 004_items — creates the items table with indexes on project_id, status, priority, parent_id, and sprint_id.
  • 010_fts — creates the FTS5 virtual table items_fts and three triggers (after_item_insert, after_item_update, after_item_delete) that keep the FTS index in sync with the items table.
  • 012_custom_fields — custom_field_definitions and custom_field_values tables.
  • 016_perf_indexes — additional composite indexes added after profiling.
  • 039–048 — the ten neutral runner-v1 execution-domain tables (execution requests, attempts, events, decisions and artifacts; agent profiles; runner fleets and their members; model profiles). 049+ refine execution replay, recovery and attempt-start facts.
  • 078–081 — item briefs, the decision pack columns (options' details and risks, the recommendation, when a decision was first seen), merge-readiness pack reviews and pull requests.

Each ordinary migration runs in its own transaction with the _migrations record inserted at commit; a failing statement rolls the whole migration back. Applied migrations are checked at every startup against the binary's own ordered list by name and a deterministic checksum — an edited or reordered history refuses to boot rather than running silently. A small subset (037/038, a table copy/verify/swap rebuild predating Part III) additionally takes an automatic VACUUM INTO snapshot before its first attempt. See docs/MIGRATION-GUIDE.md for the operator-facing version of this and docs/adr/0008-transactional-migration-rebuild-recovery.md for the design rationale.


repo.rs and repo/

repo.rs declares the Repository struct, which holds a SqlitePool, and re-exports a method for each database operation by delegating to the appropriate submodule:

#![allow(unused)]
fn main() {
pub struct Repository {
    pool: SqlitePool,
}

impl Repository {
    pub async fn create_item(&self, ...) -> Result<Item, sqlx::Error> {
        items::create_item(self.pool(), ...).await
    }
    // ...
}
}

This design gives callers a single repo value to pass around while keeping each entity's SQL in its own file.

Per-entity submodules (items.rs, projects.rs, sprints.rs, roles.rs, comments.rs, dependencies.rs, attachments.rs, boards.rs, templates.rs, custom_fields.rs, briefs.rs, pull_requests.rs, metrics.rs, github_links.rs, execution.rs):

  • Functions take &SqlitePool (or &self for the struct-based submodules) and return Result<T, sqlx::Error> or Result<T, DependencyError>.
  • Queries use sqlx::query / sqlx::query_as with positional ? parameters.
  • UUIDs are stored as TEXT — bound as .bind(id.to_string()) and parsed back from the row.
  • JSON fields (workflow, vocabulary, tags) are serialised to/from strings with serde_json.
  • Timestamps are stored as RFC 3339 strings and parsed via chrono::DateTime<Utc>.

Notable function: check_and_update_parent_status (in items.rs). After an item is moved to a Done-category status, the handler calls this function with the item's parent_id. It queries whether all sibling items are also in a done status, and if so, updates the parent. The WorkflowConfig::should_complete_parent(all_siblings_done) call in tack-core provides the decision logic — the repository only handles the data queries.


tack-orch

Lives in: crates/tack-orch/src/

Owns: the neutral runner-v1 execution domain (execution/): lifecycle validation, fencing/idempotency types, and the pure state-machine rules that both tack-api's handlers and tack-runner's protocol implementation must agree on, plus the scheduler, model-policy resolver, and the execution domain's own retention/observability/provenance background modules.

Does not own: HTTP handling or SQL. Depends only on tack-core and tack-db; the dependency points inward deliberately — tack-api depends on this crate (to run the scheduler/retention/observability tasks and expose the execution routes), never the reverse. This boundary is load-bearing: it's what lets tack-runner's tests exercise the same lifecycle rules as the server without linking Axum.


execution/lifecycle.rs

validate_transition(from, to, actor) is the single authority for every legal execution-attempt state change. States: queued | leased | preparing | running | waiting_decision | succeeded | failed | cancelled | lost | needs_operator. Each transition is validated against both the state pair and which TransitionActor (Scheduler, Operator, LeaseOwner, RecoveryService) is allowed to request it — for example, only RecoveryService may move an attempt into lost or needs_operator; a lease owner reporting the same crash cannot self-authorize it. See the Recovery Runbook for what drives those transitions operationally.

execution/types.rs

Wire-shape-adjacent types shared by both the server and (once wired) the runner: AgentProfileSnapshot { name, instructions, tool_policy, timeout_seconds, budgets }, RepositorySnapshot { kind, remote, base_revision }, PermissionPolicy { tools, network }, and EnvironmentValue { value, secret_reference } — the last one is why execution requests never store a raw secret: every environment entry is either a literal non-secret value or an opaque reference the runner resolves locally.

scheduler/

The deterministic fleet scheduler: given a candidate set of runners (health, capacity, labels, declared harness and model support) and a request (exact runner or fleet selector, required harness, optional provider/model, priority), it decides which runner gets the work, or a typed reason none qualify. Two entry points: select::select_runner for one request against a candidate pool, and batch::schedule for several requests sharing one pool, ordered by priority then FIFO fairness. Both are pure and synchronous — no database, no network client — and neither grants the authoritative lease; only the repository's fenced claim (docs/contracts/runner-v1/) can do that. wiring::choose_request_for_runner is the live bridge: it loads real agent_runners / agent_fleet_members / execution_requests rows and calls into the pure core above, called ahead of the naive ORDER BY created_at LIMIT 1 match in tack-db's claim query. The only production caller is the claim handler in tack-api's runner protocol.

model_policy/

Deterministic model-selection precedence: request override → agent-profile default → project default → fleet default → nothing configured, meaning auto-select. resolve_model_policy is pure; wiring is the tack-db-backed caller that fetches each tier's configured default and hands the result in, mirroring the scheduler's own pure-core/live-wiring split. Every resolved value is still a request, whichever tier supplied it — intersecting it against a runner's declared capability is the scheduler's job, not this module's; and a resolved value is never conflated with the actual model an attempt reports back, which usage_provenance compares separately.

execution_retention.rs and execution_observability.rs

Two sibling background tasks — not submodules of execution/, which is deliberately I/O-free — because both are persistence-bearing work that runs on a timer. Retention sweeps stale terminal-attempt event rows out of execution_events on an injectable clock (RetentionClock, so tests never depend on wall time) with a cancellation signal raced against its inter-sweep sleep; there is no daily roll-up table for execution_events yet, so this purges rows outright rather than aggregating them, and says so rather than calling itself a "roll up". Observability computes a periodic, id-free snapshot of runner/queue/lease/ event counts and logs alerts from it — keyed only by the domain's two small, closed state vocabularies (agent_runners.state, execution_requests.state), never by attempt/request/runner id, so the label set stays bounded regardless of fleet size.

usage_provenance.rs

Two independent pure concerns, neither performing I/O. compare_model_provenance checks the request's resolved model (or "no model requested") against the attempt's actual, observed execution — visible as a mismatch, never silently reconciled. build_usage_economics keeps runner-observed wall-clock time cost structurally separate from the harness/vendor's own self-reported token or dollar usage, never summed into one opaque number. Every dollar-valued field in this crate is named *_usd_estimated, never *_usd alone, and absent usage is a Measurement with source: NotMeasured, never a fabricated 0.


tack-api

Lives in: crates/tack-api/src/

Owns: HTTP server startup, route registration, request/response handling, configuration, WebSocket management, error mapping.

Does not own: SQL queries (those are in tack-db) or business rules (those are in tack-core). Handlers orchestrate calls to both.

This crate is a library only — it does not produce its own binary. The single tack binary (in tack-cli) calls tack_api::serve() to start the server.


server.rs

Exposes pub async fn serve(), the server entry point. It does these things in order:

  1. Loads AppConfig (TOML file or environment variables).
  2. Initialises the tracing subscriber (plain text or JSON depending on config).
  3. Applies any staged database restore (rename .restore file into place).
  4. Calls init_pool() and migrations::run_all().
  5. Ensures a default workspace row exists (creates one if the table is empty).
  6. Builds AppState, calls build_router(state), and starts axum::serve with graceful shutdown on CTRL+C.

tack-cli builds a Tokio runtime and calls serve() when you run tack with no subcommand (or tack serve).


router.rs

AppState is the shared state cloned into every handler:

#![allow(unused)]
fn main() {
pub struct AppState {
    pub repo: Repository,
    pub config: AppConfig,
    pub workspace_id: Uuid,
    pub broadcast_tx: broadcast::Sender<BoardEvent>,
}
}

build_router(state) assembles the Axum Router. Routes are grouped by entity and nested under /api. The file also wires up:

  • CORS — reads config.allowed_origins, constructs a tower_http::cors::CorsLayer.
  • Body limit — DefaultBodyLimit::max(config.max_body_size_bytes) globally; the attachment upload route overrides this to 50 MB.
  • Security headers — X-Content-Type-Options: nosniff, Referrer-Policy: same-origin, X-Frame-Options: DENY via SetResponseHeaderLayer.
  • Request tracing — TraceLayer logs every request with method and URI.
  • Token gate — middleware::from_fn_with_state(state, require_token) wraps all /api routes.
  • embed-spa feature — when compiled with --features embed-spa, a fallback handler serves the bundled SPA.

handlers/

One file per entity group. The agent-work ones: executions.rs and attempt_lists.rs (requests, and the attempts with their pull request), decisions.rs, briefs.rs (/items/{id}/brief), mrp.rs (an attempt's merge-readiness pack, its viewed mark and its review), metrics.rs (/projects/{id}/metrics/factory) and runner_protocol/ (the runner's own surface).

A typical handler follows this shape:

#![allow(unused)]
fn main() {
pub async fn update_item(
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
    Json(input): Json<UpdateItem>,
) -> ApiResult<Json<Item>> {
    input.validate().map_err(|e| ApiError::BadRequest(e.to_string()))?;
    // load, validate, persist, broadcast
}
}

Handlers return ApiResult<Json<T>>, which is Result<Json<T>, ApiError>. The ApiError type implements IntoResponse, so Axum converts errors to JSON automatically.

websocket.rs is somewhat different from other handler files — see the Architecture Overview for a full walkthrough of the connection lifecycle. The key public API it exposes to other handlers is:

#![allow(unused)]
fn main() {
pub fn broadcast_event(state: &AppState, event: BoardEvent) { ... }
}

Any handler that mutates data calls this after a successful write.


config.rs

AppConfig::load() tries to read tack.toml from the current directory. If that fails, it reads environment variables (TACK_HOST, TACK_PORT, TACK_DATABASE_URL, etc.) over a Default::default() base. There is no figment or other config framework — the logic is a straightforward chain of if let Ok(v) = std::env::var(...) assignments.

The API token is never logged. The only place it appears in logs is a boolean "token configured: true/false" in the startup message.


error.rs

ApiError is the unified error type for all handlers. It implements IntoResponse with this mapping:

ApiError variantHTTP status
NotFound404
BadRequest400
Conflict409
Core(CoreError::*NotFound*)404
Core(CoreError::InvalidTransition | WipLimitExceeded | …)400
Database500
Internal500

The response body is always { "error": { "status": <code>, "message": "<text>" } }.


tack-runner

Lives in: crates/tack-runner/src/

Owns: its own binary (tack-runner, entirely separate from tack) — local enrollment/credential handling, the isolated per-attempt workspace, the owner-only attempt journal, the harness adapter layer, and the steps that follow a succeeded attempt (evidence.rs, verify.rs and the branch push in git.rs). Does not own anything the API must not touch: vendor credentials, workspace contents, and the harness subprocess never leave this crate. See Agent Runners & Fleet Execution for the operator-facing view of everything below.

config.rs

RunnerConfig::from_sources layers defaults → TOML file → environment (TACK_RUNNER_API_URL, TACK_RUNNER_ID, TACK_RUNNER_STATE_DIR, TACK_RUNNER_ENROLLMENT_TOKEN) → CLI flags. EnrollmentCredential's Debug/ Display are hardcoded to print [REDACTED] — redaction here is structural, not a convention a future println! could accidentally bypass.

journal.rs and workspace.rs

A WorkspaceJournal record is written to TACK_RUNNER_STATE_DIR before any harness process is allowed to spawn — this ordering is what makes crash recovery possible (see the Recovery Runbook). JournalState tracks Prepared → ProcessObservedRunning → ... → Reported; a restart finds this file and reports what it actually observed rather than guessing. WorktreeProvisioner is the sole trait allowed to create a workspace's git worktree — tests inject a fake so unit tests never touch a real checkout.

client.rs — RunnerProtocolClient

Defines the trait the runner's runtime loop drives: enroll, refresh, claim, heartbeat, and per-attempt accept/start/events/decisions/artifacts/ completion/cancellation/recovery-observation. The HTTP-backed implementation is transport::HttpPullProtocol, which bootstrap::build_runtime wires for both the standalone binary and the embedded runner. UnavailableProtocolClient remains only as the typed RunnerError::ProtocolUnavailable fallback for a runtime built without a client — see What actually runs today.

evidence.rs, verify.rs and the push in git.rs

What the engine does between a terminal outcome and deleting the workspace. evidence.rs reads the attempt's change out of the workspace for every harness (changes.patch, files.json, evidence.json, plus brief.json when the request carried one; the shape is docs/contracts/evidence-v1/). verify.rs runs the operator's [verify] program over that evidence and stages the merge-readiness pack it writes. git.rs also holds the branch push ([git] push_branches). None of the three can change the attempt's outcome: a failure is an event (attempt.verify_failed, attempt.push_failed) and the attempt still completes.

harness/

The adapter layer: process.rs (bounded output capture, timeouts, process-group cancellation), event_sink.rs (backpressure), redact.rs, artifact.rs, and local_process.rs — the one lifecycle every local CLI harness shares: locating the binary, the version probe, the request policy, environment and secrets, provider injection, spawn, cancel, reconcile, log staging and the outcome. A harness adds a HarnessDescriptor (data) and a four-method HarnessGrammar (its command line, how its output is read, what it supports, and optionally how it drives a conversation so a run can pause and ask): codex.rs, claude_code.rs, docket.rs, opencode.rs. docket's contract and flags are probed when the runner starts, never assumed from its version. Adding one is a module plus a line in harness::DESCRIPTORS and one in harness::discover. The harness vocabulary itself stays open (HarnessKind::Other(String)). Two engine-facing traits: HarnessAdapter (per-attempt lifecycle: validate/start/cancel/wait/ reconcile) and HarnessProbe (version/capability discovery). AdapterRegistry implements HarnessAdapter by dispatching on harness kind and refuses to register any probe claiming cancel: supported unless its harness announces the process groups its tools start (only docket on contract 1.1 does) — every other harness's own shell tool spawns its subprocess in a new session outside the runner's process group, confirmed against the real binaries with ps. Live harness tests are #[ignore]d and never required in CI; harness/fixtures/fake_harness.sh, driven by TACK_FAKE_HARNESS_MODE, is the always-runnable path every required test uses instead.


tack-cli

Lives in: crates/tack-cli/src/

Owns: the single tack binary — both starting the server and the command-line client (parsing, human-readable output, HTTP calls to the API).

Does not own: any tack-core or tack-db types directly. The client commands work entirely through the HTTP API — they serialise to JSON for requests and deserialise from serde_json::Value for responses (no strongly-typed response structs). This keeps the CLI decoupled from internal model changes that do not affect the API contract. (To run the server it depends on tack-api and calls tack_api::serve().)


main.rs

Uses clap's derive API. The top-level Cli struct has two global flags (--api-url, --token) and an optional Commands enum. Run tack --help for the authoritative, current list; as of this writing it is:

  • Board basics: serve, init, projects, add, list, move, board, branch, search, sprint, config, completions
  • Backup/restore: backup, backups, restore
  • Project setup: template, role, comment, field
  • Agent onboarding and the runner-fleet surface: mcp (MCP server over stdio), execution (create/list/cancel/reconcile execution requests), fleet (runner fleets), runner (enroll/revoke execution runners), service (run tack as a systemd/launchd background service), agent-profile (instructions, tool policy, limits)

Running tack with no subcommand — or tack serve — starts the server + web UI: run_server() builds a Tokio runtime and calls tack_api::serve(). This is the primary, UI-first entry point. Everything else is the CLI client.

Three cases (serve, config, and completions) are handled before the TackClient is constructed, since they do not need a live connection.

All other commands instantiate a TackClient, call the appropriate method, and then either print raw JSON (with --json) or format a human-readable table. The table formatter (print_table_row) pads and truncates columns to fixed widths, which keeps the output readable in standard terminals.

The add and list commands fetch the project's vocabulary via vocab::fetch() and translate item_type strings through it before printing — so a construction project shows Work Order rather than task in the output.


client.rs

TackClient wraps reqwest::blocking::Client. All methods prepend /api to the supplied path and attach the Authorization: Bearer <token> header when a token is configured.

Public methods:

  • get(path) → serde_json::Value
  • post(path, body) → serde_json::Value
  • patch(path, body) → serde_json::Value
  • delete(path) → ()
  • get_bytes(path) → Vec<u8> — used for backup download
  • post_bytes(path, data) → serde_json::Value — used for restore upload

Error handling: the extract() helper parses the response body regardless of status code, then returns the body on success or extracts the error.message field and surfaces it as an anyhow::Error on failure.


config.rs

Config::load(base_url_override, token_override) applies a precedence chain:

  1. CLI flag value (passed as Option<String>)
  2. Environment variable (TACK_API_URL, TACK_API_TOKEN)
  3. ~/.tackrc — a TOML file with base_url and optional token fields
  4. Default: http://127.0.0.1:3210

config::save(base_url, token) writes ~/.tackrc. This is what tack config --url <url> does.


tack-desktop

Lives in: crates/tack-desktop/src/

Owns: the Tauri shell — a window, a system tray icon, and the supervisor that either attaches to a tack serve already answering on the configured port or starts one itself as a bundled sidecar. Built with make desktop; excluded from the root workspace (see the crate map in the top-level CLAUDE.md) so a contributor without Tauri's system dependencies (GTK, WebKit) still builds every other crate with a plain cargo build --workspace. Its own Cargo.lock, CI job, and Dependabot entry follow from that same exclusion.

Does not own: the server. It never opens the database or reimplements anything tack-api already does — only starts, attaches to, and supervises the tack binary as a child process, and only ever stops a server it started itself.


main.rs

Builds the Tauri app and calls attach_or_start before creating the window — the window exists only on Ok. Of the ways that call can fail, two render a dialog naming the reason before exiting (a port already held by something that isn't Tack; an attached server older than the bundle), and a catch-all arm covers everything else. The window opens at 1200x800 (WebviewWindowBuilder::inner_size).

supervisor.rs

attach_or_start probes the configured port for a Tack server already answering at a compatible version and attaches to it instead of spawning a second one; otherwise it spawns the bundled sidecar binary and waits up to a fixed health timeout for /api/health to answer. A server this process did not start is never signalled to stop, on any exit path.

first_run.rs

ensure_settings runs before the supervisor does: on an empty data root it shows a first-run dialog (database path, port) and writes the answer to settings.json before anything tries to reach a server. The dialog call itself runs off the main thread — Tauri's dialog plugin deadlocks the event loop if it doesn't.

tray.rs

Builds the tray icon and its menu: Open Tack (show or refocus the window), a disabled agent execution status line, a checked Launch at login toggle (on by default the first time), and Quit. The status line polls GET /api/local-runner every three seconds — at the port this app's own settings.json names, falling back to the default — and renders one of six typed labels: on, off, turning on…, turning off… (the persisted preference and the runtime state disagreeing while a toggle takes effect), waiting for the server… (nothing answering yet), and status unavailable (request failed). It is a status line and not a switch: it never writes, and a server that has not answered is never rendered as off. Closing the window only hides it — the server keeps running; Quit is the action that actually stops it.

Frontend & Design System

The web UI is a SolidJS single-page app in frontend/, built with Vite and Tailwind v4. It talks to the API over fetch and a WebSocket, and is embedded into the tack binary at release time via the embed-spa feature.

This page covers how the frontend is organized and, in particular, the design token system every component relies on.

Layout

frontend/src/
├── app/          App shell — Router, Layout (sidebar + top bar), routes
├── features/     One folder per surface: board, list, table, calendar,
│                 timeline, sprints, item-detail (incl. the Brief tab),
│                 dashboard (Overview and Factory metrics), projects,
│                 settings, templates, agents
├── shared/
│   ├── ui/       The component kit (Button, Badge, Modal, Drawer, Tabs,
│   │             CommandPalette, SearchBar, Sidebar, ToastContainer …) plus
│   │             redesign primitives: Avatar/AvatarStack, TypeBadge,
│   │             PriorityDot, WipChip, KbdHint, and the icon set (icons.tsx)
│   ├── state/    Context stores + signals (project, items, theme, palette,
│   │             commandPalette, optimistic updates, toasts)
│   ├── api/      Typed fetch client (api.*), one module per resource
│   ├── realtime/ Reconnecting board WebSocket
│   ├── runWithAgent/ The run dialog (RunFlow), attempt list with its pull-request
│   │             badge, decision inbox, merge-readiness panel, event timeline
│   ├── execution/, agents/ Execution types and API calls; the Agents page's pieces
│   ├── vocab/    Per-project terminology resolution (useVocab)
│   └── types/    DTOs mirroring the backend
└── index.css     The design tokens (see below)

Module boundary

Features are isolated: a features/* file may import from shared/*, never from another feature. This is enforced by frontend/src/architecture.test.ts — a failing import shows up as a unit-test failure. Anything two features need goes in shared/.

Design tokens

All colour, surface, and shadow values live as CSS custom properties in frontend/src/index.css. Components consume only these --color-* tokens (via inline style), never raw hex or Tailwind colour literals. That single indirection is what lets one attribute flip restyle the entire app.

The system has two axes:

  • Mode — a .dark class on <html> (managed by shared/state/theme.ts). :root holds the light values; .dark overrides only what differs.
  • Palette — a data-palette="harbor|clay|graphite" attribute on <html> (managed by shared/state/palette.ts). Harbor is the default the app applies; no attribute = the Teal base values in :root.

So the cascade is :root → .dark → :root[data-palette="…"] → .dark[data-palette="…"]. Each block redefines only the primitive values (backgrounds, text tiers, the accent, semantic solids); everything derived (the primary ramp, hover/active surfaces, inverse text, focus ring) is expressed once as var() aliases that re-resolve against whichever palette is active. Adding another palette means adding one primitives block — nothing else changes.

A Tailwind @theme inline block re-exposes the runtime tokens under utility names (bg-surface, text-content, border-line, bg-brand-*, …) for the places that use classes instead of inline styles.

Accessibility constraint

Token values are tuned to WCAG 2.1 AA (4.5:1 text contrast) and verified by an axe scan in the E2E suite (frontend/e2e/a11y.spec.ts). When changing a colour, keep white-on-accent and faint-text-on-surface above 4.5:1 — the CI a11y job will fail otherwise.

Typography

Three self-hosted fonts (via @fontsource, so they work offline):

  • Caprasimo — the display face: headings, step numbers, buttons (--font-heading, utility font-heading). One weight; never bold it.
  • Figtree — the UI body (--font-body, also --font-sans).
  • JetBrains Mono — ids, estimates, and keycaps (--font-mono).

Shape

The shape language is over-rounded. Controls — buttons, inputs, tags, tab pills, banners — are pills (--radius-pill). Containers such as board columns and settings cards take --radius-card (28px) on the --color-bg-panel fill, and the items inside them take --radius-item (20px) back on the page ground.

Adding a UI component

  1. Build it in shared/ui/ as a small Solid component. Style it with inline token styles — style={{ 'background-color': 'var(--color-bg-base)' }} — so it re-themes for free. Reuse existing primitives (Avatar, TypeBadge, PriorityDot, WipChip, KbdHint) rather than re-inlining markup.
  2. Export it from shared/ui/index.ts.
  3. If it has pure logic (a colour map, a formatter), put that in a co-located *.ts and unit-test it (shared/ui/primitives.test.ts is the pattern).
  4. Consume it from a feature — never reach into another feature.

Design source

The current visual language was imported from a Claude Design project (Tack.dc.html) and implemented onto the token system above. The design is a reference; the source of truth is index.css plus the shared/ui kit.

Running it

cd frontend
npm install
npm run dev          # http://localhost:5173, proxies /api to :3210
npm run type-check
npm test             # Vitest unit tests
npm run build

Start the API (cargo run -p tack-cli -- serve) before the dev server. See Testing for the Playwright E2E setup.

Adding Features

This chapter describes the three most common extension patterns: adding a new entity, adding a new workflow preset, and extending the workflow engine. Each section includes a concrete example and explains the reasoning behind the step order.


Adding a New Entity

Example: Milestone

A Milestone is a date-anchored checkpoint associated with a project. It has a name, an optional description, a target date, and a status.

Step 1: Define the model in tack-core

Open crates/tack-core/src/models.rs and add the struct and any associated DTOs:

#![allow(unused)]
fn main() {
// ─── Milestone ────────────────────────────────────────────────

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Milestone {
    pub id: Uuid,
    pub project_id: Uuid,
    pub name: String,
    pub description: Option<String>,
    pub target_date: Option<DateTime<Utc>>,
    pub completed_at: Option<DateTime<Utc>>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

#[derive(Debug, Deserialize, Validate)]
pub struct CreateMilestone {
    #[validate(length(min = 1, max = 200))]
    pub name: String,
    pub description: Option<String>,
    pub target_date: Option<DateTime<Utc>>,
}

#[derive(Debug, Deserialize, Validate)]
pub struct UpdateMilestone {
    #[validate(length(min = 1, max = 200))]
    pub name: Option<String>,
    pub description: Option<String>,
    pub target_date: Option<DateTime<Utc>>,
    pub completed_at: Option<DateTime<Utc>>,
}
}

tack-core is the right home for these types because it is the layer all other crates share. The API crate uses CreateMilestone for deserialization; the DB crate uses it as the input to the repository function. Keeping them in one place avoids duplication.

Step 2: Add the migration in tack-db

Open crates/tack-db/src/migrations.rs. Migration names are numbered sequentially and never reused — find the highest number already in all_migrations() (grep -o '"[0-9]\{3\}_[a-zA-Z0-9_]*"' crates/tack-db/src/migrations.rs | sort -u | tail -1) and pick the next one. As of this writing that's 081, so the new migration is 082. Find the migrations vec in all_migrations() and append:

#![allow(unused)]
fn main() {
("082_milestones", &MIGRATION_082[..]),
}

Then add the constant near the end of the file:

#![allow(unused)]
fn main() {
const MIGRATION_082: [&str; 2] = [
    "CREATE TABLE IF NOT EXISTS milestones (
        id TEXT PRIMARY KEY NOT NULL,
        project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
        name TEXT NOT NULL,
        description TEXT,
        target_date TEXT,
        completed_at TEXT,
        created_at TEXT NOT NULL DEFAULT (datetime('now')),
        updated_at TEXT NOT NULL DEFAULT (datetime('now'))
    )",
    "CREATE INDEX IF NOT EXISTS idx_milestones_project ON milestones(project_id)",
];
}

Migrations are append-only and run in order. Never edit an existing migration — if you need to change the schema, add a new one.

Step 3: Create the repository module

Create crates/tack-db/src/repo/milestones.rs. Follow the same pattern as other repository files: functions take &SqlitePool, bind parameters, run the query, and return the struct.

#![allow(unused)]
fn main() {
use chrono::Utc;
use uuid::Uuid;
use sqlx::SqlitePool;
use tack_core::models::{CreateMilestone, Milestone, UpdateMilestone};

pub async fn create_milestone(
    pool: &SqlitePool,
    project_id: Uuid,
    input: CreateMilestone,
) -> Result<Milestone, sqlx::Error> {
    let id = Uuid::new_v4();
    let now = Utc::now();
    sqlx::query(
        "INSERT INTO milestones (id, project_id, name, description, target_date, created_at, updated_at)
         VALUES (?, ?, ?, ?, ?, ?, ?)"
    )
    .bind(id.to_string())
    .bind(project_id.to_string())
    .bind(&input.name)
    .bind(&input.description)
    .bind(input.target_date.map(|d| d.to_rfc3339()))
    .bind(now.to_rfc3339())
    .bind(now.to_rfc3339())
    .execute(pool)
    .await?;

    Ok(Milestone {
        id,
        project_id,
        name: input.name,
        description: input.description,
        target_date: input.target_date,
        completed_at: None,
        created_at: now,
        updated_at: now,
    })
}

// add get_milestone, list_milestones, update_milestone, delete_milestone
}

Step 4: Register in repo/mod.rs

Open crates/tack-db/src/repo.rs and add:

#![allow(unused)]
fn main() {
pub mod milestones;
}

Then add delegating methods to the Repository impl:

#![allow(unused)]
fn main() {
pub async fn create_milestone(
    &self,
    project_id: Uuid,
    data: CreateMilestone,
) -> Result<Milestone, sqlx::Error> {
    milestones::create_milestone(self.pool(), project_id, data).await
}

pub async fn list_milestones(&self, project_id: Uuid) -> Result<Vec<Milestone>, sqlx::Error> {
    milestones::list_milestones(self.pool(), project_id).await
}

// get, update, delete …
}

This pattern — thin delegating methods on Repository — keeps the struct as the single entry point callers interact with while keeping each entity's SQL isolated in its own file.

Step 5: Create the handler

Create crates/tack-api/src/handlers/milestones.rs:

#![allow(unused)]
fn main() {
use axum::{
    extract::{Path, State},
    http::StatusCode,
    response::Json,
};
use uuid::Uuid;
use validator::Validate;

use tack_core::models::{CreateMilestone, Milestone, UpdateMilestone};

use crate::{error::{ApiError, ApiResult}, router::AppState};

pub async fn create_milestone(
    State(state): State<AppState>,
    Path(project_id): Path<Uuid>,
    Json(input): Json<CreateMilestone>,
) -> ApiResult<(StatusCode, Json<Milestone>)> {
    input.validate().map_err(|e| ApiError::BadRequest(e.to_string()))?;
    // Verify project exists
    state.repo.get_project(project_id).await?
        .ok_or_else(|| ApiError::NotFound(format!("Project {project_id} not found")))?;
    let milestone = state.repo.create_milestone(project_id, input).await?;
    Ok((StatusCode::CREATED, Json(milestone)))
}

pub async fn list_milestones(
    State(state): State<AppState>,
    Path(project_id): Path<Uuid>,
) -> ApiResult<Json<Vec<Milestone>>> {
    let milestones = state.repo.list_milestones(project_id).await?;
    Ok(Json(milestones))
}

// get_milestone, update_milestone, delete_milestone …
}

Step 6: Register routes in router.rs

Open crates/tack-api/src/router.rs. Add the import:

#![allow(unused)]
fn main() {
use crate::handlers::milestones;
}

Then add the routes in the api router builder:

#![allow(unused)]
fn main() {
// ─── Milestones ───────────────────────────────────────────────────────────
.route("/projects/{project_id}/milestones", post(milestones::create_milestone))
.route("/projects/{project_id}/milestones", get(milestones::list_milestones))
.route("/milestones/{id}", get(milestones::get_milestone))
.route("/milestones/{id}", patch(milestones::update_milestone))
.route("/milestones/{id}", delete(milestones::delete_milestone))
}

At this point the feature is complete. Run cargo nextest run --workspace to verify nothing is broken, then add repository tests in crates/tack-db/tests/repository.rs and handler tests in crates/tack-api/tests/handlers.rs — each test file is its own binary, so add to the file whose subject fits rather than creating a new one (see Testing).


Adding a New Workflow Preset

Example: education_workflow for an online-course project type

Step 1: Add the preset function in tack-core

Open crates/tack-core/src/workflow.rs and add the function after the existing presets:

#![allow(unused)]
fn main() {
pub fn education_workflow() -> WorkflowConfig {
    WorkflowConfig {
        workflow_type: WorkflowType::Custom,
        statuses: vec![
            StatusDef {
                name: "Not Started".into(),
                category: StatusCategory::Todo,
                wip_limit: None,
                order: 0,
            },
            StatusDef {
                name: "In Progress".into(),
                category: StatusCategory::InProgress,
                wip_limit: None,
                order: 1,
            },
            StatusDef {
                name: "Under Review".into(),
                category: StatusCategory::InProgress,
                wip_limit: None,
                order: 2,
            },
            StatusDef {
                name: "Completed".into(),
                category: StatusCategory::Done,
                wip_limit: None,
                order: 3,
            },
        ],
        transitions: None,
    }
}
}

Step 2: Write unit tests

Add tests in the #[cfg(test)] module in the same file:

#![allow(unused)]
fn main() {
#[test]
fn education_initial_status_is_not_started() {
    assert_eq!(education_workflow().initial_status().unwrap(), "Not Started");
}

#[test]
fn education_allows_in_progress_to_completed() {
    let wf = education_workflow();
    assert!(wf.validate_transition("In Progress", "Completed").is_ok());
}
}

Unit testing presets here is valuable because these tests run without any database or runtime — they are essentially free to run and will catch any mistake in the status list.

Step 3: Add the ProjectType variant

Open crates/tack-core/src/models.rs and add Education to the ProjectType enum. The enum is exhaustive — every match on it must add an arm — so this is the full current list, not an excerpt:

#![allow(unused)]
fn main() {
pub enum ProjectType {
    Software,
    Web,
    Mobile,
    Construction,
    Personal,
    Homework,
    Maintenance,
    Legal,
    Research,
    Event,
    Education,  // ← new
    Custom,
}
}

Also update the Display impl:

#![allow(unused)]
fn main() {
Self::Education => write!(f, "education"),
}

Step 4: Update workflow_for_type

In workflow.rs, add the new arm to the match in workflow_for_type:

#![allow(unused)]
fn main() {
pub fn workflow_for_type(project_type: &ProjectType) -> WorkflowConfig {
    match project_type {
        ProjectType::Software | ProjectType::Web | ProjectType::Mobile => scrum_workflow(),
        ProjectType::Construction => construction_workflow(),
        ProjectType::Personal | ProjectType::Homework => simple_workflow(),
        ProjectType::Maintenance => kanban_workflow(),
        ProjectType::Legal => legal_workflow(),
        ProjectType::Research => research_workflow(),
        ProjectType::Event => event_workflow(),
        ProjectType::Education => education_workflow(),  // ← new
        ProjectType::Custom => simple_workflow(),
    }
}
}

Step 5: Add a vocabulary preset

Open crates/tack-core/src/vocabulary.rs and add a case to vocabulary_for_type:

#![allow(unused)]
fn main() {
ProjectType::Education => HashMap::from([
    ("epic".into(), "Course".into()),
    ("feature".into(), "Module".into()),
    ("task".into(), "Lesson".into()),
    ("subtask".into(), "Exercise".into()),
    ("bug".into(), "Correction".into()),
    ("sprint".into(), "Week".into()),
    ("milestone".into(), "Exam".into()),
    // … other terms
]),
}

At this point POST /api/projects with "project_type": "education" will auto-select the new workflow and vocabulary.


Extending the Workflow Engine

Sometimes you need new logic in the workflow engine itself — for example, a rule that prevents moving an item to Done if any of its dependencies are still incomplete.

Step 1: Add the function to tack-core

Open crates/tack-core/src/workflow.rs. Add a pure function:

#![allow(unused)]
fn main() {
impl WorkflowConfig {
    /// Return an error if attempting to mark `item_id` done when it has
    /// unresolved blocking dependencies.
    pub fn check_dependencies_resolved(
        item_id: Uuid,
        blockers: &[(Uuid, DependencyType)],
        target_status: &str,
    ) -> Result<(), CoreError> {
        if self.is_done_status(target_status) && !blockers.is_empty() {
            return Err(CoreError::Validation(format!(
                "Item {item_id} has {} unresolved blocker(s)",
                blockers.len()
            )));
        }
        Ok(())
    }
}
}

Note that this function takes its inputs as parameters — it does not query the database. The caller (the handler) is responsible for loading the blocker list and passing it in.

Step 2: Write unit tests

Add tests in the #[cfg(test)] block in the same file. Test both the passing case (no blockers, or target not Done) and the failing case (blockers present, target is Done).

Unit tests for workflow logic are intentionally cheap to write here because there is no I/O to mock — you just call the function with constructed data.

Step 3: Update the handler

Open crates/tack-api/src/handlers/items.rs. In update_item, after loading the project and before calling repo.update_item, add the new check:

#![allow(unused)]
fn main() {
if let Some(new_status) = &input.status {
    let project = state.repo.get_project(item.project_id).await? ...;
    project.workflow.validate_transition(&item.status, new_status)?;
    project.workflow.check_wip_limit(new_status, current_count)?;

    // New check: load blockers and verify they are resolved
    let all_deps = state.repo.list_dependencies(item.id).await?;
    let graph = DependencyGraph::from_edges(&all_deps);
    let blockers = graph.blockers_of(item.id);
    project.workflow.check_dependencies_resolved(item.id, &blockers, new_status)?;
}
}

What not to change

No migration is needed. The workflow engine is pure logic — it reads from a WorkflowConfig struct that is already stored as JSON. Adding a new method to WorkflowConfig does not require any database schema change.


Anti-Patterns to Avoid

The crate layering is the project's load-bearing constraint. Most review feedback on new features comes down to one of these:

  • Don't put I/O in tack-core. No file access, no HTTP calls, no sqlx, no tokio runtime needs. Core is pure, synchronous domain logic so it stays trivially testable. If a rule needs data, take it as a function parameter and let the handler load it (as in the dependency check above).
  • Don't scatter validation across handlers. Transition rules, WIP limits, cycle checks, and field validation belong in tack-core, called from the handler. Duplicating a rule inline in a handler means the CLI, API, and MCP server can disagree about what's valid.
  • Don't reach past the repository layer. Handlers call Repository methods; they never build SQL or touch the pool directly. New queries go in the matching repo/<entity>.rs module.
  • Don't let tack-cli import tack-db. The CLI is an HTTP client — all data access goes through the API so workflow rules are enforced server-side. The same applies to the MCP server.
  • Don't edit an existing migration. Migrations are append-only and idempotent. Changing a shipped migration corrupts databases that already applied it. Add a new numbered migration and wire it into all_migrations().
  • Don't hardcode colors in the frontend. Components consume --color-* design tokens via inline style, never raw hex, so the theme/palette system keeps working. See Frontend & Design System.
  • Don't add an endpoint without a handler test. Every new route gets at least a success-path and an error-path test in crates/tack-api/tests/handlers.rs (or the file matching the route's subject — see Testing).

When a change feels like it needs to break one of these, that's usually a sign the logic belongs in a different layer — move it rather than bending the boundary.

If the new logic requires new configuration (for example, a per-project flag to enable or disable dependency-blocking), then you would add a field to WorkflowConfig, update the struct, and add a migration to handle existing rows that do not have that field (SQLite will use the column default).

Configuration Reference

The user-facing Configuration page covers loading order and a worked tack.toml example. This page is the book's rendering of the complete, authoritative TACK_* variable table — server, embedded runner, standalone runner, backup, orchestration, and the execution domain. Edit docs/CONFIG.md, not this file.

Configuration Reference

The complete environment/TOML configuration for the API server and the runner. Moved from CLAUDE.md (2026-08-19) so agent context stays lean; this file is the single authority for these tables — update it, not CLAUDE.md, when adding a variable.

The API server loads configuration from tack.toml (if present) or environment variables:

VariableDefaultDescription
TACK_HOST127.0.0.1Server bind address
TACK_PORT3210Server port
TACK_DATABASE_URLsqlite:tack.db?mode=rwcSQLite database path
TACK_LOG_LEVELinfotrace, debug, info, warn, error
TACK_LOG_JSONfalseStructured JSON logging
TACK_LOG_FILE(none)Write logs to this file in addition to stdout, in the same format TACK_LOG_JSON selects. Missing parent directories are created; if the directory cannot be created the server logs to stdout only rather than refusing to start. tack service install and the desktop app both set this so logs survive without a service manager attached
TACK_STORAGE_DIR./storageAttachment storage directory
TACK_API_TOKEN(none)Optional Bearer token — requires Authorization: Bearer <token> on all API requests
TACK_API_ALLOW_UNAUTHENTICATED_NONLOOPBACKfalseExplicit opt-out for the startup refusal to bind a non-loopback address with no TACK_API_TOKEN set (see docs/adr/0059-single-operator-identity-posture.md). Loopback binds are unaffected either way. Off by default — this widens who can reach an unauthenticated API, so it must be a deliberate choice, never a fallback the code takes on its own
TACK_ALLOWED_ORIGINShttp://localhost:8080,http://127.0.0.1:8080,http://localhost:3210,http://127.0.0.1:3210,https://tack.testComma-separated allow-list of browser origins. Setting it replaces the default list. It gates CORS, and it gates the board's live WebSocket upgrade for any request whose Origin is not a loopback host — a bind to a loopback address (the default) additionally authorizes any loopback-hosted Origin on its own, so a local UI on any port (Vite's 5173 dev server included) receives live events with no configuration; a non-loopback bind, or a UI served from a non-loopback origin, still needs its origin listed here or the handshake is refused before it completes
TACK_MAX_BODY_SIZE2097152Global request body limit in bytes (default 2 MB; upload endpoint is always 50 MB)
TACK_WEBHOOK_URL(none)Outbound webhook URL — when set, POSTs JSON events on item create/update/delete, sprint status changes, and due-soon alerts
TACK_WEBHOOK_SECRET(none)HMAC-SHA256 signing secret; adds X-Tack-Signature: sha256=<hex> to each delivery
TACK_GITHUB_TOKEN(none)GitHub PAT (repo scope). When set, item status changes are pushed back to linked GitHub issues (item done ⇄ issue closed), and the inbound poll below can start. Never logged. See docs/GITHUB-SYNC.md
TACK_GITHUB_API_BASEhttps://api.github.comGitHub API root — override for GitHub Enterprise or to point tests at a mock. Used by import, push-back, and the inbound poll
TACK_GITHUB_POLL_SECONDS0Inbound poll interval in seconds; 0 is off. Also requires TACK_GITHUB_TOKEN. Moves a linked item through its project's ordinary workflow on a GitHub issue close/reopen — see docs/GITHUB-SYNC.md
TACK_BACKUP_ENDPOINT(none)S3-compatible endpoint URL (e.g. https://<acct>.r2.cloudflarestorage.com); omit for AWS S3
TACK_BACKUP_BUCKET(none)Bucket name — required to enable remote backup
TACK_BACKUP_REGIONautoAWS/S3 region; Cloudflare R2 uses auto
TACK_BACKUP_ACCESS_KEY(none)S3 access key ID — required to enable remote backup
TACK_BACKUP_SECRET_KEY(none)S3 secret access key — required; never logged
TACK_BACKUP_PREFIXtackObject key prefix inside the bucket
TACK_BACKUP_INTERVAL_SECS(none)Auto-backup interval in seconds; omit for manual-only. Values below 60 are raised to 60 with a warning — a tighter loop copies the database more often than it can change
TACK_BACKUP_RETENTION10Number of remote backups to keep after each upload
TACK_LOCAL_RUNNER_ENABLEfalseStartup default for whether the embedded runner runs — the same gate tack serve --with-runner sets; either satisfies it. 1 or true (case-insensitive) turn it on; anything else, including unset, is off. Read into AppConfig::local_runner_enable; a PUT /api/local-runner from the UI (ADR 0061 decisions 2 and 6) overrides it at runtime in app_meta — see Embedded runner below. Off by default and refused outright (never silently downgraded) on a non-loopback bind
TACK_EXECUTION_RETENTION_ENABLEfalseEnables the execution-domain retention sweep. Off by default (crates/tack-api/src/config.rs#default_execution_retention_enable) — this sweep deletes rows and on-disk blobs, so data deletion must be an explicit operator opt-in. Covers four things, across two runtime tasks: (a) replay/idempotency bookkeeping and (b) terminal execution_events purge (tack-orch), plus (c) execution_artifacts rows and their TACK_STORAGE_DIR/execution-artifacts blobs and (d) overdue-decision expiry (pending → expired). Artifact blobs are typically the largest consumer in this domain. Decision expiry deliberately shares this one gate rather than running always-on — a test pins that posture so changing it is a reviewed diff
TACK_EXECUTION_RETENTION_DAYS90Days of history kept before the sweep purges it — applies to all four categories above (replay/idempotency bookkeeping, terminal execution_events, execution_artifacts rows and blobs, and decision expiry deadlines)
TACK_EXECUTION_RETENTION_INTERVAL_SECS3600Interval, in seconds, between execution-retention sweeps
TACK_EXECUTION_HEALTH_ENABLEtrueEnables the execution-domain health watch (runner/queue/lease/event counts; logs a warn! on stale-lease/needs_operator onset). On by default, unlike retention above — this reads and logs only, deletes nothing
TACK_EXECUTION_HEALTH_INTERVAL_SECS60Interval, in seconds, between execution health-watch checks
TACK_EXECUTION_DECISION_TOKEN(none)Separate shared secret required to resolve a scoped execution decision via POST /api/attempts/{attempt_id}/decisions/{decision_id}/resolve. Distinct from TACK_API_TOKEN, fail-closed when unset (the route rejects rather than falling back to the operator token). Never logged

The tack CLI client — every subcommand other than serve — talks to a running server over HTTP and never opens the database. It resolves the server's base URL from --base-url, then TACK_API_URL, then ~/.tackrc, then http://127.0.0.1:3210.

VariableDefaultDescription
TACK_API_URLhttp://127.0.0.1:3210Base URL of the server the CLI talks to, unless --base-url overrides it
TACK_API_TOKEN(none)Bearer token the CLI sends, when the server it talks to requires one. Same variable the server reads to require a token — one value, two ends of the same connection

The tack-runner binary is configured separately (defaults → TOML → environment → CLI flags, in that order):

VariableDescription
TACK_RUNNER_API_URLTack API base URL the runner polls
TACK_RUNNER_ENROLLMENT_TOKENOne-time operator-issued token; exchanged for a durable credential and never persisted
TACK_RUNNER_IDRunner identity once enrolled
TACK_RUNNER_STATE_DIROwner-only directory for the journal and credential
TACK_RUNNER_SECRET_VALUEValue tack runner secret set stores; when unset it reads the value from stdin instead. Never a command-line argument, which would be visible in ps and shell history. Read once, not persisted by the variable — the store keeps it (OS keychain, or an owner-only file where none answers within PLATFORM_STORE_TIMEOUT; tack runner doctor reports which backend a given boot picked)
TACK_RUNNER_PROVIDER_VERCEL_AI_GATEWAY_ENABLEDTurns on the vercel_ai_gateway provider endpoint ([provider.vercel_ai_gateway] in the TOML config). 1 or true (case-insensitive) enable it; default false. Off by default — this points a harness at a network endpoint and needs a credential, so it is a deliberate opt-in, never a fallback the runner takes on its own
TACK_RUNNER_PROVIDER_VERCEL_AI_GATEWAY_SECRETSecret-store entry name the provider's credential is resolved from. Default vercel-ai-gateway/default — SecretStore::resolve does not append /default on its own, so a bare vercel-ai-gateway here resolves nothing
TACK_RUNNER_VERCEL_AI_GATEWAY_TEST_BASE_URLTest-only, not a real deployment knob. scripts/smoke.sh step 13's only intended setter. When set to a loopback URL (http://127.0.0.1:*, http://localhost:*, or http://[::1]:*), rebases the catalog fetch and both per-harness endpoints under it instead of the real ai-gateway.vercel.sh, so a smoke run can prove key → catalog → spawn → actual-model against a local fake shim with a fake key, never a real vendor call. This redirects wherever fetch_catalog's bearer_auth sends the resolved credential — it is not inert with respect to the secret, only with respect to whether the real vendor is ever reached. A non-loopback value is ignored outright (treated as unset), a cheap guard against an accidental redirect of a real key to a non-loopback host; it is not a defense against a process whose environment an attacker already controls, since that attacker could read the same credential directly out of the secret store this same process has open. Unset in every real deployment
TACK_RUNNER_PROVIDER_ANTHROPIC_ENABLEDTurns on Anthropic's own API as a provider endpoint ([provider.anthropic] in the TOML config) — a runner-held key pointed at api.anthropic.com directly, distinct from claude-code's own subscription login. Same on/off convention and off-by-default posture as the Vercel entry above
TACK_RUNNER_PROVIDER_ANTHROPIC_SECRETSecret-store entry name this provider's credential is resolved from. Default anthropic/default

Runner credentials are redacted in every log, Debug impl and error — the redaction is structural (RunnerCredential's Debug/Display are hardcoded to [REDACTED]), not convention.

The TACK_BACKUP_* values are defaults. Cloud-backup settings (endpoint, bucket, region, access/secret key, prefix, retention) can also be edited at runtime from the UI (Settings → Cloud Backup) and are stored in the app_meta table; UI values override the env defaults. TACK_BACKUP_INTERVAL_SECS (automatic scheduling) remains env-only and takes effect at startup. The secret key is write-only over the API — never returned to clients.

Embedded runner (tack serve --with-runner)

tack serve --with-runner (or TACK_LOCAL_RUNNER_ENABLE=1) runs the runner role as a task inside the same process as the server, speaking runner-v1 over loopback HTTP exactly like a remote runner would — see docs/adr/0058-standalone-single-binary-runner.md for why that HTTP hop is kept rather than shortcut. This is the fewest-steps way to see a real agent attempt run against your own board: no second binary, no tack runner enroll call, no token to copy anywhere.

  • Gate. Off by default. TACK_LOCAL_RUNNER_ENABLE (table above) and --with-runner are equivalent; either turns it on. Refused outright — before any socket or database is opened — when the server is not bound to loopback (TACK_HOST other than 127.0.0.1/localhost/an equivalent loopback address); this is a startup error, never a silent downgrade to a runner-less server, because an embedded runner executes arbitrary coding-agent processes on the host serving the UI.

  • UI toggle (ADR 0061 decisions 2 and 6). GET/PUT /api/local-runner let a loopback-only UI turn the embedded runner on/off after tack serve is already up, with no restart — a PUT persists the choice to app_meta (overriding TACK_LOCAL_RUNNER_ENABLE from then on) and starts or stops the runner task to match. PUT/GET/DELETE /api/local-runner/secrets(/{name}) hand the runner a provider key the same way — write-only, never echoed, stored in whichever backend tack runner secret set would have used. Every one of these routes is absent (a plain 404, not a gate that refuses) on any non-loopback bind, or when the process embedding the server never wired an embedded runner in at all (a bare library caller of tack_api::serve()).

  • First run. A fresh state directory with no stored session self-provisions: it creates its own pending-runner row and redeems its own one-time enrollment token in-process, so no token is ever printed, copied, or configured by hand. A later start against the same state directory reuses the credential already on disk instead of provisioning a second runner.

  • State directory. Defaults to <TACK_STORAGE_DIR>/runner — scoped to the same configuration as the database, so a server started against a different TACK_DATABASE_URL (paired, as every other per-install artifact in this crate already assumes, with its own TACK_STORAGE_DIR) never resolves to another server's runner state. TACK_RUNNER_STATE_DIR still overrides this default when set, exactly as it does for the standalone tack-runner binary (whose own default remains the bare, cwd-relative .tack-runner — it has no database or storage_dir to scope against). Holds the runner's credential (session.json) and its attempt journal, both written owner-only (session.json mode 0600; the directory itself and journal entries 0700/0600) — confirmed with stat -c '%a' against a real run, not assumed from the write path. An install upgrading from before this default existed has its already- enrolled state moved there automatically, once, the first time the new directory is found missing and the old one is not — never the reverse, and never once the new directory already exists.

  • Vendor/provider credentials — Tack is never a model gateway. Each harness authenticates itself using its own mechanism; Tack does not read, store, forward, or proxy any of it, embedded or standalone. tack runner doctor reports exactly what this machine's own harnesses declare — run it yourself rather than trusting a stale copy in this file. The two harnesses with a login of their own, mirrored from a real tack runner doctor run on a machine with both installed — the harness vocabulary itself is open (a runner may report any kind string); docket and opencode always need a configured endpoint and are described in the book's Choosing a harness:

    HarnessHow it authenticatesGateway-routed variant ([provider.vercel_ai_gateway])
    codexIts own CLI login flow or an API key it reads from its own environment/config (codex --help). This adapter forwards no ambient host environment into a run — only entries explicitly set on the execution request's own environment field ever reach the process.When a request's provider names the configured endpoint: per-invocation -c model_provider=…/model_providers.<key>.* flags plus AI_GATEWAY_API_KEY in the spawned environment — never a write to ~/.codex/config.toml. A request for a direct model receives none of it.
    claude-codeTypically an OAuth session under $HOME/.claude from its own login flow, or an API key from its own environment. This adapter forwards HOME and PATH from the runner process's own environment so the installed CLI can find its existing session.ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN (plus a defensive empty ANTHROPIC_API_KEY) in the spawned environment, only when the request's provider names the configured endpoint. A request for a direct model receives none of it.

    OpenRouter access and local-model endpoints (llama.cpp and similar) are configured the same way: through the harness's own configuration or environment. No TACK_* variable on the API server names a model provider or endpoint, and the API server itself never holds, forwards, or proxies a provider credential. The runner is not under that restriction: it may hold a provider key in its own owner-only state directory, and a loopback-only, embedded-runner-only route hands one to that store without the key ever touching tack.db, a log line, or the operator API otherwise — see docs/adr/0061-provider-credentials-at-the-runner-boundary.md for what a runner may hold, how a key reaches it, and how a gateway's model catalog is fetched. See docs/adr/0050-runner-control-plane.md ("the Tack API never starts a coding harness and never becomes a model proxy") and docs/adr/0058-standalone-single-binary-runner.md ("Vendor credentials remain outside Tack") for the decisions this one bounds.

  • Model selection is a separate question from credentials, and it is answered. Which (provider, model_id) reaches the harness for a given execution request is resolved server-side through a four-tier precedence (request override → agent-profile default → project default (projects.default_model, set from the Agents page or PATCH /api/projects/{id}) → fleet default → auto-select), live-verified end to end and fully documented in Choosing a model and a provider — including why an auto-select request accepts today but never schedules. No TACK_* variable is involved on either side of this: routing the choice and holding the credential are different operations, and this file's table above has no row for a model provider or endpoint by design.

  • Log visibility. The embedded runner's own log lines (self-provisioning, enrollment, claim, completion — anything logged by tack_runner::* or by the tack binary's own local_runner/local_enrollment modules) do not appear under default logging. init_tracing's default filter (tack_api={level},tack_db={level},tack_core={level},tower_http=debug) only ever names tack_api, tack_db and tack_core — TACK_LOG_LEVEL changes {level} for those three crates but cannot add a target the filter string never mentions, so this is not fixable by raising TACK_LOG_LEVEL alone. Set RUST_LOG explicitly to include the runner's own targets:

    RUST_LOG=tack=info,tack_runner=info,tack_api=info,tack_db=info,tack_core=info \
      tack serve --with-runner
    

    Verified on a fresh state directory: under default logging, tack_runner::* and tack::local_enrollment/tack::local_runner produced zero log lines while the embedded runner enrolled and ran a real attempt; with the RUST_LOG override above, the same run showed tack::local_enrollment: self-provisioned a local runner for the embedded runner to redeem ..., tack_runner::runtime: runner runtime started ... and tack_runner::client::transport: runner enrolled .... Server-side handler logs (e.g. tack_api::handlers::runner_protocol's own runner enrolled runner_id=... line) are visible either way, since tack_api is already in the default filter — only the runner's own log lines were missing.

Runner verifier ([verify])

A runner can run a program of your choosing over each attempt that succeeded, after the attempt's changes are captured and before its workspace is deleted. The program reads the captured evidence and writes a merge-readiness pack; the runner uploads that pack as one more artifact on the attempt. The runner runs it, on your machine: the board never runs it.

[verify]
enabled = false          # off by default; there is no environment variable for it
program = "assay"        # looked up on PATH
args = ["verify"]        # placed before the flags the runner adds
timeout_seconds = 1800

The runner appends --evidence <dir> --workspace <dir> --output <dir>/mrp.json to args and starts the program with an environment of PATH only. A program that exits non-zero, times out, is not on PATH, or writes a pack that does not parse never changes the attempt's outcome: the attempt still completes succeeded and records an attempt.verify_failed event carrying the exit code and the start of the program's error output. With enabled = false, nothing is started. A run request can decline the verifier for one run (the run dialog's "Verify the result" box); it can never turn on a verifier the runner has off. A request that asks for one from a runner without it changes nothing, and the attempt records an attempt.verify_skipped event saying why. A program that is not on PATH is logged as a warning when the runner starts.

Pushing the attempt's branch ([git])

A runner can push the work of each succeeded attempt to the remote it fetched the repository from, so the result is a branch you can open, review and merge. The runner pushes it, on your machine, with your git credentials (your credential helper or SSH agent): Tack stores no git credential, and the board never pushes.

[git]
push_branches = false    # off by default; there is no environment variable for it
branch_prefix = "tack/"
author = "Tack Runner <tack-runner@localhost>"

When push_branches = true and an attempt succeeded with a non-empty change, the runner creates the branch <branch_prefix><item short id>-a<attempt number>, commits what the harness left uncommitted (with author as the commit author; the message names the attempt and the item, never the item's description) and runs git push origin <branch>. Git hooks in the workspace do not run. The branch, its head commit and whether it was pushed are recorded in the attempt's evidence and its completion report. A push that fails (no credentials, a rejected branch, an unreachable remote) never changes the attempt's outcome: the attempt still completes succeeded and records an attempt.push_failed event. With push_branches = false, nothing is committed or pushed.

Turn it on only for agents you would trust with your git credentials. The push runs git in the attempt's workspace, which the agent could write to while it worked: hooks are disabled, but the repository's own git configuration there (an SSH command or a URL rewrite, for example) is whatever the agent left.

As with the verifier, a run request can decline the push for one run but never enable it; asking for a push from a runner with push_branches = false changes nothing and the attempt records an attempt.push_skipped event.

Debugging

# Debug logging
TACK_LOG_LEVEL=debug cargo run -p tack-cli -- serve

# Trace SQL queries
RUST_LOG=tack_db=trace,tack_api=debug cargo run -p tack-cli -- serve

# JSON logs (for log aggregators)
TACK_LOG_JSON=true cargo run -p tack-cli -- serve

# See the embedded runner's own log lines under `--with-runner` (see
# "Embedded runner" above — off by default, silent by default)
RUST_LOG=tack=info,tack_runner=info,tack_api=info,tack_db=info,tack_core=info \
  cargo run -p tack-cli -- serve --with-runner

MCP Server

This page is the book's rendering of the MCP server's design notes and usage. Edit docs/MCP.md, not this file.

Tack MCP Server

tack mcp exposes a Tack instance to AI agents (Claude Code, Codex, etc.) via the Model Context Protocol. An agent can list projects, search and read items, and create/update/move items and add comments — all through the same HTTP API the CLI uses, so workflow validation, WIP limits, and parent-auto-completion still apply and the live board updates over WebSocket.

Transport decision

Two options were considered:

OptionHowVerdict
(a) stdio sidecar — tack mcpA subcommand spawned per-agent; speaks JSON-RPC 2.0 over stdin/stdout; reaches tack serve over HTTP using the existing CLI client.Chosen for v1.
(b) HTTP/SSE endpoint in tack serveMount an MCP transport inside the server.Deferred — adds an auth surface and SSE plumbing to the server for no v1 benefit.

Decision: ship the stdio sidecar. It is the simplest thing to wire into Claude Code's MCP config, adds no new server-side attack surface, and reuses the blocking reqwest client already in tack-cli. The protocol layer is a thin, hand-rolled JSON-RPC 2.0 loop (newline-delimited messages per the MCP stdio transport) — no heavyweight async MCP SDK is pulled into the otherwise-blocking CLI, keeping the single binary small.

If a remote/multi-agent HTTP transport is needed later, option (b) can be added without changing the tool definitions.

Usage

tack mcp reads JSON-RPC from stdin and writes responses to stdout. It needs a running Tack server; point it at one with the same flags/env as any CLI command:

# Defaults to http://127.0.0.1:3210, no token
tack mcp

# Explicit server + token
TACK_API_URL=http://127.0.0.1:3210 TACK_API_TOKEN=secret tack mcp

stdout is the protocol channel — the MCP server prints only JSON-RPC. Do not pipe anything else into its stdin or expect human-readable output.

Wiring into Claude Code

Add Tack to your project's .mcp.json (or the global Claude Code MCP config):

{
  "mcpServers": {
    "tack": {
      "command": "tack",
      "args": ["mcp"],
      "env": {
        "TACK_API_URL": "http://127.0.0.1:3210",
        "TACK_API_TOKEN": "your-token-if-set"
      }
    }
  }
}

Then start the Tack server (tack serve) and the agent can call the tools below.

Tools

ToolKindArgumentsMaps to
list_projectsread—GET /api/projects
list_itemsreadproject_id*, status, item_type, assigneeGET /api/projects/{id}/items
get_itemreadid*GET /api/items/{id}
search_itemsreadquery*, project_idGET /api/search or /projects/{id}/search
create_itemwriteproject_id, title, item_type, priority, parent_id, assigneePOST /api/projects/{id}/items
update_itemwriteid*, title, description, priority, assignee, status, due_datePATCH /api/items/{id}
move_itemwriteid, statusPATCH /api/items/{id}
add_commentwriteitem_id, content, authorPOST /api/items/{id}/comments

* = required. Read tools return a compact projection (id, title, type, status, priority, assignee) to keep agent context small; get_item returns full detail.

Execution/fleet/profile tools (Part III, card E5)

Runs a Tack item through the agent-fleet execution surface — the same /api/executions, /api/runner-fleets, and /api/agent-profiles operator routes the tack execution|fleet|agent-profile CLI commands use, via the exact same request-body builders (see crates/tack-cli/src/execution.rs), so an agent-issued create_execution call can never diverge in shape from what the CLI (or, once it ships, the web UI) would send for the same operation.

ToolKindArgumentsMaps to
list_fleetsread—GET /api/runner-fleets
list_agent_profilesread—GET /api/agent-profiles
list_executionsread—GET /api/executions
get_executionreadrequest_id*GET /api/executions/{id}
cancel_executionwriterequest_id*POST /api/executions/{id}/cancel
create_executionwriteitem_id, one of runner_id/fleet_id, agent_profile_id, harness, agent_profile_snapshot* (object), repository* (object), permission_policy* (object), timeout_seconds*, model_provider, model_id, budgets, environment, metadata, status_map_policy_id, idempotency_keyPOST /api/executions

status_map_policy_id is done_on_success (the item moves to its first Done status when the attempt succeeds) or done_on_mrp_accepted (when its merge-readiness pack is accepted); omit it to leave the item's status untouched.

Use the two list_* tools first to discover valid fleet_id/agent_profile_id values before calling create_execution. get_execution's state can be needs_operator or lost — an ambiguous outcome, not just another in-progress value; the tool description says so explicitly so an agent surfaces it to the human rather than treating it like queued/running.

Deliberately CLI-only, not exposed as MCP tools: tack runner enroll/revoke (enrollment returns a one-time secret — keeping it off the MCP surface means that secret can never land in an agent's tool-call transcript; see crates/tack-cli/src/secure_fs.rs), tack fleet create, tack agent-profile create (admin-ish setup, in the same spirit as backup/restore/template/role/field never having been exposed here), and tack execution reconcile (an operator's explicit, audited recovery decision after reviewing an ambiguous needs_operator state — not something an agent should be able to trigger on its own say-so).

Security

The MCP server inherits the CLI's TACK_API_TOKEN. It has the same access as any API client — there is no per-tool scoping in v1. Run it against a server you control, and treat the token as a secret in the MCP config. Writes are validated server-side, so an agent cannot bypass workflow rules, but it can create and modify items within the projects the token can reach. The same holds for the execution/fleet/profile tools above: an agent with this token can create executions and cancel them but cannot enroll or revoke runners, create fleets or profiles, or reconcile a needs_operator request — those remain CLI-only.

API Reference

Generated from docs/openapi.json (84 paths, 119 operations) by scripts/gen-api-reference.py — do not hand-edit. Regenerate with ./scripts/regen-generated.sh after the spec changes.

This page lists every path, method, parameter and request/response schema name. It does not inline schema bodies — load docs/openapi.json into an OpenAPI viewer (Redocly, Scalar, Swagger Editor) for the full definitions, or read them directly in the spec file.

Two authentication surfaces, the WebSocket endpoint (not in this spec), and worked examples are in docs/API-REFERENCE.md.


System

Health and debug probes.

GET /api/debug/db-stats

Database statistics

StatusMeaningSchema
200Per-table row counts—

GET /api/debug/info

System info (only in debug builds)

StatusMeaningSchema
200Build, version, database size, and non-sensitive config—

GET /api/health

Liveness + readiness check

StatusMeaningSchema
200Service is live; reports version and applied migration count—

Projects

Projects: the top-level container for work.

GET /api/projects

StatusMeaningSchema
200All projects in the workspaceProject[]

POST /api/projects

Request body: CreateProject

StatusMeaningSchema
200Project createdProject
400Validation errorErrorEnvelope

DELETE /api/projects/{id}

ParamInTypeRequiredDescription
idpathstringyesProject ID
StatusMeaningSchema
200Deleted—
404Project not foundErrorEnvelope

GET /api/projects/{id}

ParamInTypeRequiredDescription
idpathstringyesProject ID
StatusMeaningSchema
200The projectProject
404Project not foundErrorEnvelope

PATCH /api/projects/{id}

ParamInTypeRequiredDescription
idpathstringyesProject ID

Request body: UpdateProject

StatusMeaningSchema
200Updated projectProject
400Validation errorErrorEnvelope
404Project not foundErrorEnvelope

Items

Items: the universal work unit (epics, tasks, bugs, …).

DELETE /api/items/{id}

ParamInTypeRequiredDescription
idpathstringyesItem ID
StatusMeaningSchema
200Deleted—
404Item not foundErrorEnvelope

GET /api/items/{id}

ParamInTypeRequiredDescription
idpathstringyesItem ID
StatusMeaningSchema
200Item with roles and dependencies; carries an ETag header for a later conditional PATCHItemDetail
404Item not foundErrorEnvelope

PATCH /api/items/{id}

ParamInTypeRequiredDescription
idpathstringyesItem ID
If-Matchheader['string', 'null']noOptional ETag from GET /api/items/{id}; a stale or malformed value returns 412 and writes nothing

Request body: UpdateItem

StatusMeaningSchema
200Updated item; carries the ETag for the exact returned snapshotItem
400Invalid transition / validation errorErrorEnvelope
404Item not foundErrorEnvelope
412If-Match did not match the current item version — nothing was writtenErrorEnvelope

Removes an item's manual (or imported) GitHub link. 204 even when the

ParamInTypeRequiredDescription
idpathstringyesItem ID
StatusMeaningSchema
204Unlinked (or was already unlinked)—
404Item not foundErrorEnvelope
ParamInTypeRequiredDescription
idpathstringyesItem ID
StatusMeaningSchema
200The item's current GitHub linkGithubLinkBody
404Item not found, or not linkedErrorEnvelope

Manually links an item to a GitHub issue so status and comments sync both ways — the

ParamInTypeRequiredDescription
idpathstringyesItem ID

Request body: GithubLinkBody

StatusMeaningSchema
204Linked—
400Invalid repo or issue numberErrorEnvelope
404Item not foundErrorEnvelope

GET /api/projects/{project_id}/items

ParamInTypeRequiredDescription
project_idpathstringyesProject ID
statuspath['string', 'null']yes
item_typepath—yes
prioritypath—yes
sprint_idpath['string', 'null']yes
parent_idpath['string', 'null']yes
assigneepath['string', 'null']yes
tagpath['string', 'null']yes
searchpath['string', 'null']yes
pagepath['integer', 'null']yes
per_pagepath['integer', 'null']yes
StatusMeaningSchema
200Paginated itemsPaginatedItems

POST /api/projects/{project_id}/items

ParamInTypeRequiredDescription
project_idpathstringyesProject ID

Request body: CreateItem

StatusMeaningSchema
200Item createdItem
400Validation errorErrorEnvelope
404Project not foundErrorEnvelope

GET /api/projects/{project_id}/items/tree

ParamInTypeRequiredDescription
project_idpathstringyesProject ID
StatusMeaningSchema
200Item hierarchy (parents with nested children)Item[]

Sprints

Sprints / iterations within a project.

GET /api/projects/{project_id}/sprints

ParamInTypeRequiredDescription
project_idpathstringyesProject ID
StatusMeaningSchema
200Sprints for the projectSprint[]

POST /api/projects/{project_id}/sprints

ParamInTypeRequiredDescription
project_idpathstringyesProject ID

Request body: CreateSprint

StatusMeaningSchema
200Sprint createdSprint
400Validation errorErrorEnvelope

GET /api/sprints/{id}

ParamInTypeRequiredDescription
idpathstringyesSprint ID
StatusMeaningSchema
200The sprintSprint
404Sprint not foundErrorEnvelope

PATCH /api/sprints/{id}

ParamInTypeRequiredDescription
idpathstringyesSprint ID

Request body: UpdateSprint

StatusMeaningSchema
200The updated sprintSprint
400Validation errorErrorEnvelope
404Sprint not foundErrorEnvelope

PATCH /api/sprints/{id}/status

ParamInTypeRequiredDescription
idpathstringyesSprint ID

Request body: UpdateSprintStatus

StatusMeaningSchema
200Status updated—
400Validation errorErrorEnvelope
404Sprint not foundErrorEnvelope

Roles

Roles / specialties and their assignment to items.

DELETE /api/items/{item_id}/roles/{role_id}

ParamInTypeRequiredDescription
item_idpathstringyesItem ID
role_idpathstringyesRole ID
StatusMeaningSchema
200Role removed from item—

PUT /api/items/{item_id}/roles/{role_id}

ParamInTypeRequiredDescription
item_idpathstringyesItem ID
role_idpathstringyesRole ID
StatusMeaningSchema
200Role assigned to item—

GET /api/projects/{project_id}/roles

ParamInTypeRequiredDescription
project_idpathstringyesProject ID
StatusMeaningSchema
200Roles for the projectRole[]

POST /api/projects/{project_id}/roles

ParamInTypeRequiredDescription
project_idpathstringyesProject ID

Request body: CreateRole

StatusMeaningSchema
200Role createdRole
400Validation errorErrorEnvelope

DELETE /api/roles/{id}

ParamInTypeRequiredDescription
idpathstringyesRole ID
StatusMeaningSchema
200Deleted—
404Role not foundErrorEnvelope

Mrp

An attempt's Merge-Readiness Pack and the human verdict on it.

GET /api/executions/{request_id}/attempts/{attempt_number}/mrp

GET /api/executions/:request_id/attempts/:attempt_number/mrp

ParamInTypeRequiredDescription
request_idpathstringyesExecution request ID
attempt_numberpathintegeryes1-based attempt number
StatusMeaningSchema
200The parsed pack and its review recordMrpResponse
404No pack for this attemptErrorEnvelope

POST /api/executions/{request_id}/attempts/{attempt_number}/mrp/review

POST /api/executions/:request_id/attempts/:attempt_number/mrp/review

ParamInTypeRequiredDescription
request_idpathstringyesExecution request ID
attempt_numberpathintegeryes1-based attempt number

Request body: MrpReviewRequest

StatusMeaningSchema
200The recorded reviewobject
400Blank reasonErrorEnvelope
404No pack for this attemptErrorEnvelope
409The pack was already reviewedErrorEnvelope

POST /api/executions/{request_id}/attempts/{attempt_number}/mrp/viewed

POST /api/executions/:request_id/attempts/:attempt_number/mrp/viewed

ParamInTypeRequiredDescription
request_idpathstringyesExecution request ID
attempt_numberpathintegeryes1-based attempt number
StatusMeaningSchema
200The review record, with viewed_at stamped onceobject
404No pack for this attemptErrorEnvelope

Metrics

Factory metrics measured from a project's rows.

GET /api/projects/{id}/metrics/factory

GET /api/projects/:id/metrics/factory

ParamInTypeRequiredDescription
idpathstringyesProject ID
sincequerystringnoStart of the window (RFC 3339); absent means all time.
StatusMeaningSchema
200Factory metrics measured from the project's rowsFactoryMetrics
400since is not RFC 3339ErrorEnvelope
404Project not foundErrorEnvelope

Briefs

An item's brief: acceptance criteria, constraints, definition of done.

DELETE /api/items/{item_id}/brief

DELETE /api/items/:item_id/brief

ParamInTypeRequiredDescription
item_idpathstringyesItem ID
StatusMeaningSchema
204Brief deleted—
404Item not found, or it has no briefErrorEnvelope

GET /api/items/{item_id}/brief

GET /api/items/:item_id/brief

ParamInTypeRequiredDescription
item_idpathstringyesItem ID
StatusMeaningSchema
200The item's briefItemBrief
404Item not found, or it has no briefErrorEnvelope

PUT /api/items/{item_id}/brief

PUT /api/items/:item_id/brief

ParamInTypeRequiredDescription
item_idpathstringyesItem ID

Request body: UpsertItemBrief

StatusMeaningSchema
200The brief as storedItemBrief
400Validation errorErrorEnvelope
404Item not foundErrorEnvelope

Comments

Comments on items.

GET /api/items/{item_id}/comments

ParamInTypeRequiredDescription
item_idpathstringyesItem ID
StatusMeaningSchema
200Comments on the itemComment[]

POST /api/items/{item_id}/comments

ParamInTypeRequiredDescription
item_idpathstringyesItem ID

Request body: CreateComment

StatusMeaningSchema
200Comment createdComment
400Validation errorErrorEnvelope

Dependencies

Directed dependency edges between items.

GET /api/items/{item_id}/dependencies

ParamInTypeRequiredDescription
item_idpathstringyesItem ID
StatusMeaningSchema
200Dependency edges for the itemDependency[]

POST /api/items/{item_id}/dependencies

ParamInTypeRequiredDescription
item_idpathstringyesSource item ID

Request body: CreateDependency

StatusMeaningSchema
200Dependency createdDependency
400Cycle detected or duplicateErrorEnvelope

DELETE /api/items/{item_id}/dependencies/{dep_id}

ParamInTypeRequiredDescription
item_idpathstringyesItem ID
dep_idpathstringyesDependency ID
StatusMeaningSchema
200Deleted—
404Dependency not foundErrorEnvelope

Attachments

File attachments on items.

DELETE /api/attachments/{id}

DELETE /api/attachments/:id

ParamInTypeRequiredDescription
idpathstringyesAttachment ID
StatusMeaningSchema
204Attachment deleted—
404Attachment not foundErrorEnvelope

GET /api/attachments/{id}

GET /api/attachments/:id

ParamInTypeRequiredDescription
idpathstringyesAttachment ID
StatusMeaningSchema
200Attachment file bytes—
404Attachment not foundErrorEnvelope

GET /api/items/{item_id}/attachments

GET /api/items/:id/attachments

ParamInTypeRequiredDescription
item_idpathstringyesItem ID
StatusMeaningSchema
200Attachments on the itemarray
404Item not foundErrorEnvelope

POST /api/items/{item_id}/attachments

POST /api/items/:id/attachments

ParamInTypeRequiredDescription
item_idpathstringyesItem ID

Request body: string

StatusMeaningSchema
200Attachment metadata—
400Missing/oversized fileErrorEnvelope
404Item not foundErrorEnvelope

Boards

Saved board views and their grouped item layout.

DELETE /api/boards/{id}

Delete a board

ParamInTypeRequiredDescription
idpathstringyesBoard ID
StatusMeaningSchema
204Board deleted—

GET /api/boards/{id}

Get a specific board

ParamInTypeRequiredDescription
idpathstringyesBoard ID
StatusMeaningSchema
200The boardBoard
404Board not foundErrorEnvelope

PATCH /api/boards/{id}

Update a board

ParamInTypeRequiredDescription
idpathstringyesBoard ID

Request body: UpdateBoard

StatusMeaningSchema
200Updated boardBoard
422Validation errorErrorEnvelope

GET /api/boards/{id}/view

Get board state with items grouped and filtered

ParamInTypeRequiredDescription
idpathstringyesBoard ID
StatusMeaningSchema
200Board with items grouped into columnsBoardViewResponse
404Board not foundErrorEnvelope

GET /api/projects/{project_id}/boards

List all boards for a project

ParamInTypeRequiredDescription
project_idpathstringyesProject ID
StatusMeaningSchema
200Boards for the projectBoard[]

POST /api/projects/{project_id}/boards

Create a new board for a project

ParamInTypeRequiredDescription
project_idpathstringyesProject ID

Request body: CreateBoard

StatusMeaningSchema
200Board createdBoard
404Project not foundErrorEnvelope
422Validation errorErrorEnvelope

Custom Fields

Per-project custom field definitions and values.

DELETE /api/custom-fields/{id}

Delete a custom field

ParamInTypeRequiredDescription
idpathstringyesCustom field ID
StatusMeaningSchema
204Field deleted—

GET /api/custom-fields/{id}

Get a specific custom field

ParamInTypeRequiredDescription
idpathstringyesCustom field ID
StatusMeaningSchema
200The field definitionCustomFieldDefinition
404Field not foundErrorEnvelope

PATCH /api/custom-fields/{id}

Update a custom field

ParamInTypeRequiredDescription
idpathstringyesCustom field ID

Request body: UpdateCustomField

StatusMeaningSchema
200Updated fieldCustomFieldDefinition
500Update failedErrorEnvelope

GET /api/items/{item_id}/custom-fields

Get all custom field values for an item

ParamInTypeRequiredDescription
item_idpathstringyesItem ID
StatusMeaningSchema
200All custom field values for the itemCustomFieldValue[]

DELETE /api/items/{item_id}/custom-fields/{field_id}

Delete a custom field value

ParamInTypeRequiredDescription
item_idpathstringyesItem ID
field_idpathstringyesCustom field ID
StatusMeaningSchema
204Value deleted—

GET /api/items/{item_id}/custom-fields/{field_id}

Get a specific custom field value

ParamInTypeRequiredDescription
item_idpathstringyesItem ID
field_idpathstringyesCustom field ID
StatusMeaningSchema
200The field valueCustomFieldValue
404Value not foundErrorEnvelope

PUT /api/items/{item_id}/custom-fields/{field_id}

Set a custom field value for an item

ParamInTypeRequiredDescription
item_idpathstringyesItem ID
field_idpathstringyesCustom field ID
StatusMeaningSchema
200Value setCustomFieldValue
404Item or field not foundErrorEnvelope
422Value failed field validationErrorEnvelope

GET /api/projects/{project_id}/custom-fields

List all custom fields for a project

ParamInTypeRequiredDescription
project_idpathstringyesProject ID
StatusMeaningSchema
200Custom field definitionsCustomFieldDefinition[]

POST /api/projects/{project_id}/custom-fields

Create a custom field for a project

ParamInTypeRequiredDescription
project_idpathstringyesProject ID

Request body: CreateCustomField

StatusMeaningSchema
200Field createdCustomFieldDefinition
404Project not foundErrorEnvelope

Templates

Reusable project templates.

POST /api/projects/from-template/{id}

Create a project from a template

ParamInTypeRequiredDescription
idpathstringyesTemplate ID

Request body: CreateProjectFromTemplate

StatusMeaningSchema
200Project created from templateProject
404Template not foundErrorEnvelope
422Validation errorErrorEnvelope

POST /api/projects/{project_id}/save-as-template

Snapshot a project's configuration as a reusable template

ParamInTypeRequiredDescription
project_idpathstringyesProject ID

Request body: SaveAsTemplateRequest

StatusMeaningSchema
200Template snapshot createdProjectTemplate
404Project not foundErrorEnvelope

GET /api/templates

List all project templates

ParamInTypeRequiredDescription
project_typequeryProjectTypeno
StatusMeaningSchema
200Templates (optionally filtered by project type)ProjectTemplate[]

POST /api/templates

Create a new project template

Request body: CreateProjectTemplate

StatusMeaningSchema
200Template createdProjectTemplate
422Validation error (workflow shape, custom field options)ErrorEnvelope

DELETE /api/templates/{id}

Delete a template (user-created only)

ParamInTypeRequiredDescription
idpathstringyesTemplate ID
StatusMeaningSchema
204Template deleted—

GET /api/templates/{id}

Get a specific template

ParamInTypeRequiredDescription
idpathstringyesTemplate ID
StatusMeaningSchema
200The templateProjectTemplate
404Template not foundErrorEnvelope

Import

Import from JSON/YAML/CSV, GitHub Issues, and Linear.

POST /api/projects/import

POST /api/projects/import

StatusMeaningSchema
200Import result with the new project and stats—
400Invalid import payloadErrorEnvelope

POST /api/projects/{id}/import-csv

csv

ParamInTypeRequiredDescription
idpathstringyesProject ID

Request body: string

StatusMeaningSchema
200Counts of created and skipped rows—
400Malformed CSVErrorEnvelope
404Project not foundErrorEnvelope

POST /api/projects/{id}/import-github

github

ParamInTypeRequiredDescription
idpathstringyesProject ID

Request body: GitHubImportRequest

StatusMeaningSchema
200Counts of created/skipped issues and rate-limit remaining—
400Bad repo, token, or rate limitErrorEnvelope
404Project or repo not foundErrorEnvelope

POST /api/projects/{id}/import-linear

linear

ParamInTypeRequiredDescription
idpathstringyesProject ID

Request body: LinearImportRequest

StatusMeaningSchema
200Counts of created and skipped issues—
400Bad API key, filter, or rate limitErrorEnvelope
404Project not foundErrorEnvelope

Export

Project export to JSON / YAML / CSV.

GET /api/projects/{id}/export

GET /api/projects/:id/export

ParamInTypeRequiredDescription
idpathstringyesProject ID
formatquerystringno
StatusMeaningSchema
200Export file (JSON, YAML, or CSV per the format query)—
400Unsupported formatErrorEnvelope
404Project not foundErrorEnvelope

Full-text search within a project or globally.

GET /api/projects/{project_id}/search

ParamInTypeRequiredDescription
project_idpathstringyesProject ID
qquerystringyes
StatusMeaningSchema
200Matching itemsItem[]

GET /api/search

ParamInTypeRequiredDescription
qquerystringyes
StatusMeaningSchema
200Matching items across all projectsItem[]

Backup

Local and S3-compatible cloud backup / restore.

GET /api/backup

VACUUM INTO snapshot streamed as application/octet-stream.

StatusMeaningSchema
200SQLite snapshot (secrets scrubbed)—
400Not a file-based databaseErrorEnvelope

GET /api/backup/remote

list remote backups newest-first.

StatusMeaningSchema
200Remote backup manifests, newest first—
409Remote backup not configuredErrorEnvelope

POST /api/backup/remote

create a bundle and upload it to the configured S3

StatusMeaningSchema
200Backup manifest—
409Not configured, or another device has newer workErrorEnvelope

POST /api/backup/remote/restore

download a bundle and stage it for next restart.

Request body: RestoreRemoteRequest

StatusMeaningSchema
200Restore staged for next restart—
404No remote backups foundErrorEnvelope
409Not configured, or restore would lose newer workErrorEnvelope

POST /api/backup/remote/verify

download a bundle and validate it (sha256 +

Request body: RestoreRemoteRequest

StatusMeaningSchema
200Verification verdict plus the manifest—
404No remote backups foundErrorEnvelope
409Remote backup not configuredErrorEnvelope

POST /api/restore

Validate a SQLite backup and stage it for the next restart.

StatusMeaningSchema
200Restore staged for next restart—
400Not a valid SQLite fileErrorEnvelope
409Uploaded schema is newer than this binaryErrorEnvelope

Settings

Runtime-editable server settings (cloud backup).

GET /api/settings/backup

current cloud-backup configuration (secret masked).

StatusMeaningSchema
200Cloud-backup config (secret masked as secret_key_set)—

PUT /api/settings/backup

save cloud-backup configuration.

Request body: UpdateBackupSettings

StatusMeaningSchema
200Updated config (secret masked)—
422Validation errorErrorEnvelope

Execution Operator

Harness-agnostic runner fleet (Part III): PM-side execution-request/fleet/runner-enrollment/agent-profile management. Authenticated the same way as the rest of this API (operator session or API token); scopes idempotency and audit actor to the server-derived x-tack-principal, which a client cannot set (see crate::middleware::inject_operator_principal).

GET /api/agent-profiles

StatusMeaningSchema
200Every agent profile, by nameAgentProfileListResponse

POST /api/agent-profiles

Request body: CreateProfile

StatusMeaningSchema
200Agent profile createdCreateProfileResponse
409conflict (name already exists)RunnerV1ErrorEnvelope

POST /api/attempts/{attempt_id}/decisions/{decision_id}/resolve

Resolve a pending decision with an operator-supplied answer

ParamInTypeRequiredDescription
attempt_idpathstringyesAttempt ID the decision belongs to (opaque)
decision_idpathstringyesDecision ID, scoped to attempt_id: a decision_id that exists but belongs to a different attempt resolves as 404 not_found, indistinguishable from one that never existed at all — an attacker guessing another attempt's decision_id learns nothing.
x-tack-decision-tokenheaderstringyesTACK_EXECUTION_DECISION_TOKEN — a second, independent operator credential on top of the ordinary operator auth every other /api route uses (never a substitute for it). Fail-closed: every call is rejected with 403 whenever the server has not configured TACK_EXECUTION_DECISION_TOKEN at all — there is no "no secret configured, allow everything" fallback the way the plain Bearer gate has for an unset TACK_API_TOKEN.

Request body: ResolveDecisionRequest

StatusMeaningSchema
200Decision resolved — either a fresh write or a byte-identical idempotent replay of one (replayed distinguishes the two).ResolveDecisionResponseSchema
400invalid_request (missing/malformed answer, or answer.option_id is not one of this decision's own recorded options)RunnerV1ErrorEnvelope
401unauthorized — no x-tack-principal; a runner bearer credential never satisfies this, by constructionRunnerV1ErrorEnvelope
403forbidden — x-tack-decision-token missing, unconfigured server-side, or mismatched (details.required_scope = "operator:decisions")RunnerV1ErrorEnvelope
404not_found — no decision exists for this exact (attempt_id, decision_id) pairRunnerV1ErrorEnvelope
409decision_expired / idempotency_conflictRunnerV1ErrorEnvelope
413payload_too_large (answer exceeds decision_answer_bytes_max, 32768 bytes)RunnerV1ErrorEnvelope

GET /api/executions

ParamInTypeRequiredDescription
item_idquerystringno
item_idsquerystringnoComma-separated item ids — returns exactly one row per id, its own
limitqueryintegerno
StatusMeaningSchema
200Execution requests, newest first. item_ids present: exactly one row per id that has at least one execution, the batch's own most recent one. Else scoped to one item when item_id is given, otherwise every request the install has recorded up to limitExecutionListResponse
400invalid_request (a malformed item_ids entry, or more ids than the route's cap)RunnerV1ErrorEnvelope

POST /api/executions

Request body: CreateExecution

StatusMeaningSchema
200Execution request created or idempotently replayedCreateExecutionResponse
400invalid_requestRunnerV1ErrorEnvelope
404not_found (item does not exist)RunnerV1ErrorEnvelope
409conflict / idempotency_conflict / runner_revokedRunnerV1ErrorEnvelope

GET /api/executions/{request_id}

ParamInTypeRequiredDescription
request_idpathstringyesExecution request ID (opaque)
StatusMeaningSchema
200Execution request detailExecutionDetailResponse
404not_foundRunnerV1ErrorEnvelope

GET /api/executions/{request_id}/attempts

the operator read path

ParamInTypeRequiredDescription
request_idpathstringyesExecution request ID (opaque)
StatusMeaningSchema
200Every attempt made against this request, oldest first (may be empty)AttemptListResponse
404not_foundRunnerV1ErrorEnvelope

GET /api/executions/{request_id}/attempts/{attempt_number}/artifacts

ParamInTypeRequiredDescription
request_idpathstringyesExecution request ID (opaque)
attempt_numberpathintegeryes1-based attempt number
StatusMeaningSchema
200Every artifact manifested for this attempt, oldest first (may be empty)ArtifactListResponse
404not_found (execution_request or execution_attempt)RunnerV1ErrorEnvelope

GET /api/executions/{request_id}/attempts/{attempt_number}/artifacts/{artifact_id}/content

Download a verified artifact's raw content

ParamInTypeRequiredDescription
request_idpathstringyesExecution request ID (opaque)
attempt_numberpathintegeryes1-based attempt number within the execution request
artifact_idpathstringyesArtifact ID, scoped to the attempt that reported it (opaque)
StatusMeaningSchema
200The artifact's raw bytes. Content-Type is the artifact's declared media_type, or application/octet-stream when none was declared.string
401unauthorized — no authenticated operator principalRunnerV1ErrorEnvelope
404not_found (details.artifact_id) — no artifact manifest matches this (request_id, attempt_number, artifact_id) tripleRunnerV1ErrorEnvelope
409conflict (details.artifact_id) — the artifact manifest exists but its content has not been verified yet; distinct from not_found, never silently treated as "gone" or zero bytesRunnerV1ErrorEnvelope

GET /api/executions/{request_id}/attempts/{attempt_number}/decisions

ParamInTypeRequiredDescription
request_idpathstringyesExecution request ID (opaque)
attempt_numberpathintegeryes1-based attempt number
StatusMeaningSchema
200Every decision raised against this attempt, oldest first (may be empty)DecisionListResponse
404not_found (execution_request or execution_attempt)RunnerV1ErrorEnvelope

GET /api/executions/{request_id}/attempts/{attempt_number}/events

ParamInTypeRequiredDescription
request_idpathstringyesExecution request ID (opaque)
attempt_numberpathintegeryes1-based attempt number
StatusMeaningSchema
200Every event this attempt has reported, oldest first (may be empty)EventListResponse
404not_found (execution_request or execution_attempt)RunnerV1ErrorEnvelope

POST /api/executions/{request_id}/cancel

ParamInTypeRequiredDescription
request_idpathstringyesExecution request ID (opaque)
StatusMeaningSchema
200Cancellation requested — not yet terminalCancellationRequestedResponse
404not_foundRunnerV1ErrorEnvelope
409conflict (already terminal)RunnerV1ErrorEnvelope

POST /api/executions/{request_id}/requeue

ParamInTypeRequiredDescription
request_idpathstringyesExecution request ID (opaque)

Request body: RecoveryConfirmation

StatusMeaningSchema
200Requeued (or replayed) after an audited recovery decisionRequeueResponse
409conflict / idempotency_conflict / invalid_transitionRunnerV1ErrorEnvelope

GET /api/runner-fleets

StatusMeaningSchema
200Every runner fleet, by nameFleetListResponse

POST /api/runner-fleets

Request body: CreateFleet

StatusMeaningSchema
200Fleet createdCreateFleetResponse
409conflict (name already exists)RunnerV1ErrorEnvelope

POST /api/runner-fleets/{fleet_id}/members

ParamInTypeRequiredDescription
fleet_idpathstringyesFleet ID (opaque)

Request body: AddFleetMember

StatusMeaningSchema
200Runner is now (or already was) a member of the fleetFleetMemberResponse
404not_found (fleet or runner does not exist)RunnerV1ErrorEnvelope

DELETE /api/runner-fleets/{fleet_id}/members/{runner_id}

ParamInTypeRequiredDescription
fleet_idpathstringyesFleet ID (opaque)
runner_idpathstringyesRunner ID (opaque)
StatusMeaningSchema
200Runner removed from the fleetFleetMemberResponse
404not_found (runner was not a member of this fleet)RunnerV1ErrorEnvelope

GET /api/runners

the read path for agent_runners

ParamInTypeRequiredDescription
fleet_idpath['string', 'null']yesOptional roster filter — a runner is included only if it is a
StatusMeaningSchema
200Every enrolled runner (optionally filtered to one fleet's roster)RunnerListResponse

POST /api/runners/enrollment

Creates a pending runner and stores only a SHA-256 enrollment-token hash.

Request body: CreatePendingRunner

StatusMeaningSchema
200Pending runner created; the raw enrollment token is returned exactly onceCreatePendingRunnerResponse
400invalid_requestRunnerV1ErrorEnvelope
409conflict (name already exists)RunnerV1ErrorEnvelope

POST /api/runners/{runner_id}/enrollment-tokens/{token_id}/revoke

ParamInTypeRequiredDescription
runner_idpathstringyesRunner ID (opaque)
token_idpathstringyesEnrollment token ID (opaque)
StatusMeaningSchema
200Token revokedRevokeEnrollmentTokenResponse
404not_foundRunnerV1ErrorEnvelope
409conflict (already consumed)RunnerV1ErrorEnvelope

POST /api/runners/{runner_id}/revoke

ParamInTypeRequiredDescription
runner_idpathstringyesRunner ID (opaque)
StatusMeaningSchema
200Runner revokedRevokeRunnerResponse
404not_foundRunnerV1ErrorEnvelope

Runner Protocol V1

Harness-agnostic runner fleet (Part III): the pull protocol a tack-runner process speaks at /api/runner/v1 (enroll, claim, heartbeat, report). Authenticated by a distinct, per-runner hashed bearer credential — never the operator token, and never substitutable for it (docs/contracts/runner-v1/protocol.json: credentials_are_not_substitutable). Every wire shape is frozen by docs/contracts/runner-v1/, not independently re-specified here.

POST /api/runner/v1/attempts/{attempt_id}/accept

Report the attempt entering preparing

ParamInTypeRequiredDescription
attempt_idpathstringyesAttempt ID, issued at claim time (opaque)
StatusMeaningSchema
200Transition accepted or replayed—
400invalid_requestRunnerV1ErrorEnvelope
401unauthorizedRunnerV1ErrorEnvelope
403forbidden / runner_revokedRunnerV1ErrorEnvelope
404not_foundRunnerV1ErrorEnvelope
409conflict / idempotency_conflict / invalid_transition / stale_leaseRunnerV1ErrorEnvelope

POST /api/runner/v1/attempts/{attempt_id}/artifacts

Submit an artifact manifest (content upload is the separate PUT .../artifacts/{artifact_id}/content operation below; content download is a distinct, operator-facing route — see execution-operator's "Download a verified artifact's raw content")

ParamInTypeRequiredDescription
attempt_idpathstringyesAttempt ID, issued at claim time (opaque)
StatusMeaningSchema
200Manifest accepted; per-artifact upload URLs issued—
400invalid_requestRunnerV1ErrorEnvelope
401unauthorizedRunnerV1ErrorEnvelope
403forbidden / runner_revokedRunnerV1ErrorEnvelope
404not_foundRunnerV1ErrorEnvelope
409conflict / idempotency_conflict / invalid_transition / stale_leaseRunnerV1ErrorEnvelope

PUT /api/runner/v1/attempts/{attempt_id}/artifacts/{artifact_id}/content

Upload one manifested artifact's verified raw content

ParamInTypeRequiredDescription
attempt_idpathstringyesAttempt ID, issued at claim time (opaque)
artifact_idpathstringyesArtifact ID from this attempt's prior manifest submission (POST .../artifacts, opaque)
x-tack-fencing-tokenheaderstringyesThe attempt's current fencing token. The request body is raw bytes, so — unlike every other runner-protocol write — the fencing token cannot travel inside a JSON body, so it travels as a header instead. docs/contracts/runner-v1/ fixes the manifest exchange's payload shape, not this upload URL (see this fragment's own doc comment).
StatusMeaningSchema
200Content verified and committed: {protocol_version, attempt_id, artifact_id, state: "content_verified", size_bytes, sha256}—
400invalid_request (Content-Type mismatch, or the upload stream ended early)RunnerV1ErrorEnvelope
401unauthorizedRunnerV1ErrorEnvelope
403forbidden / runner_revokedRunnerV1ErrorEnvelope
409conflict (content already recorded and is immutable; or the attempt is not currently running/waiting_decision) / artifact_checksum_mismatch / stale_leaseRunnerV1ErrorEnvelope
413payload_too_large (artifact_content_bytes_max)RunnerV1ErrorEnvelope

POST /api/runner/v1/attempts/{attempt_id}/cancellation-observation

Report the observed effect of a requested cancellation

ParamInTypeRequiredDescription
attempt_idpathstringyesAttempt ID, issued at claim time (opaque)
StatusMeaningSchema
200Cancellation observation committed or replayed—
400invalid_requestRunnerV1ErrorEnvelope
401unauthorizedRunnerV1ErrorEnvelope
403forbidden / runner_revokedRunnerV1ErrorEnvelope
404not_foundRunnerV1ErrorEnvelope
409conflict / idempotency_conflict / invalid_transition / stale_leaseRunnerV1ErrorEnvelope

POST /api/runner/v1/attempts/{attempt_id}/completion

Report the attempt's terminal outcome

ParamInTypeRequiredDescription
attempt_idpathstringyesAttempt ID, issued at claim time (opaque)
StatusMeaningSchema
200Completion committed or replayed—
400invalid_requestRunnerV1ErrorEnvelope
401unauthorizedRunnerV1ErrorEnvelope
403forbidden / runner_revokedRunnerV1ErrorEnvelope
404not_foundRunnerV1ErrorEnvelope
409conflict / idempotency_conflict / invalid_transition / stale_leaseRunnerV1ErrorEnvelope

POST /api/runner/v1/attempts/{attempt_id}/decisions

Create a decision for later out-of-band operator resolution

ParamInTypeRequiredDescription
attempt_idpathstringyesAttempt ID, issued at claim time (opaque)
StatusMeaningSchema
200Decision recorded—
400invalid_requestRunnerV1ErrorEnvelope
401unauthorizedRunnerV1ErrorEnvelope
403forbidden / runner_revokedRunnerV1ErrorEnvelope
404not_foundRunnerV1ErrorEnvelope
409conflict / idempotency_conflict / invalid_transition / stale_leaseRunnerV1ErrorEnvelope

POST /api/runner/v1/attempts/{attempt_id}/decisions/poll

Poll for decision resolutions since a given timestamp

ParamInTypeRequiredDescription
attempt_idpathstringyesAttempt ID, issued at claim time (opaque)
StatusMeaningSchema
200Resolved decisions since after, plus the new next_after cursor—
400invalid_requestRunnerV1ErrorEnvelope
401unauthorizedRunnerV1ErrorEnvelope
403forbidden / runner_revokedRunnerV1ErrorEnvelope
404not_foundRunnerV1ErrorEnvelope
409conflict / idempotency_conflict / invalid_transition / stale_leaseRunnerV1ErrorEnvelope

POST /api/runner/v1/attempts/{attempt_id}/events

Append a fenced, checkpointed batch of execution events

ParamInTypeRequiredDescription
attempt_idpathstringyesAttempt ID, issued at claim time (opaque)
StatusMeaningSchema
200Batch committed (accepted/duplicate event ids, committed checkpoint)—
400invalid_requestRunnerV1ErrorEnvelope
401unauthorizedRunnerV1ErrorEnvelope
403forbidden / runner_revokedRunnerV1ErrorEnvelope
404not_foundRunnerV1ErrorEnvelope
409conflict / idempotency_conflict / invalid_transition / stale_leaseRunnerV1ErrorEnvelope

POST /api/runner/v1/attempts/{attempt_id}/recovery-observation

Report a post-restart recovery observation for an attempt (additive v1 operation; exact path fixed by protocol.json)

ParamInTypeRequiredDescription
attempt_idpathstringyesAttempt ID, issued at claim time (opaque)
StatusMeaningSchema
200Recovery observation committed or replayed; server-authoritative disposition returned—
400invalid_requestRunnerV1ErrorEnvelope
401unauthorizedRunnerV1ErrorEnvelope
403forbidden / runner_revokedRunnerV1ErrorEnvelope
404not_foundRunnerV1ErrorEnvelope
409conflict / idempotency_conflict / invalid_transition / stale_leaseRunnerV1ErrorEnvelope

POST /api/runner/v1/attempts/{attempt_id}/start

Report the attempt entering running

ParamInTypeRequiredDescription
attempt_idpathstringyesAttempt ID, issued at claim time (opaque)
StatusMeaningSchema
200Transition accepted or replayed—
400invalid_requestRunnerV1ErrorEnvelope
401unauthorizedRunnerV1ErrorEnvelope
403forbidden / runner_revokedRunnerV1ErrorEnvelope
404not_foundRunnerV1ErrorEnvelope
409conflict / idempotency_conflict / invalid_transition / stale_leaseRunnerV1ErrorEnvelope

POST /api/runner/v1/claim

Claim the next eligible execution request for this runner or its fleet

StatusMeaningSchema
200A fenced lease and the immutable request snapshot, or no_eligible_work—
400invalid_requestRunnerV1ErrorEnvelope
401unauthorizedRunnerV1ErrorEnvelope
403forbidden / runner_revokedRunnerV1ErrorEnvelope
404not_foundRunnerV1ErrorEnvelope
409conflict / idempotency_conflict / invalid_transition / stale_leaseRunnerV1ErrorEnvelope

POST /api/runner/v1/enroll

Exchange a single-use enrollment token for a runner identity and bearer credential

StatusMeaningSchema
200Runner enrolled; the raw bearer credential is returned exactly once—
400invalid_requestRunnerV1ErrorEnvelope
401unauthorizedRunnerV1ErrorEnvelope
403forbidden / runner_revokedRunnerV1ErrorEnvelope
404not_foundRunnerV1ErrorEnvelope
409conflict / idempotency_conflict / invalid_transition / stale_leaseRunnerV1ErrorEnvelope

POST /api/runner/v1/heartbeat

Report liveness, capacity, and active-attempt state in one fenced batch

StatusMeaningSchema
200Renewed lease facts per reported attempt—
400invalid_requestRunnerV1ErrorEnvelope
401unauthorizedRunnerV1ErrorEnvelope
403forbidden / runner_revokedRunnerV1ErrorEnvelope
404not_foundRunnerV1ErrorEnvelope
409conflict / idempotency_conflict / invalid_transition / stale_leaseRunnerV1ErrorEnvelope

POST /api/runner/v1/refresh

Refresh reported capabilities and optionally rotate the runner's bearer credential

StatusMeaningSchema
200Capabilities accepted; a rotated credential, if requested, is returned exactly once—
400invalid_requestRunnerV1ErrorEnvelope
401unauthorizedRunnerV1ErrorEnvelope
403forbidden / runner_revokedRunnerV1ErrorEnvelope
404not_foundRunnerV1ErrorEnvelope
409conflict / idempotency_conflict / invalid_transition / stale_leaseRunnerV1ErrorEnvelope

Local Runner

GET /api/local-runner

the persisted preference, the live runtime

StatusMeaningSchema
200Embedded-runner preference, runtime state, and provider catalog—

PUT /api/local-runner

save the preference and start/stop the embedded

Request body: UpdateLocalRunner

StatusMeaningSchema
204Preference saved and the runtime reconciled to match—

GET /api/local-runner/secrets

names and set-at timestamps only.

StatusMeaningSchema
200Stored secret names and set-at timestamps, never values—

DELETE /api/local-runner/secrets/{name}

not an error if already absent.

StatusMeaningSchema
204Removed (or already absent)—

PUT /api/local-runner/secrets/{name}

store a value. Never echoes it

Request body: SetLocalRunnerSecret

StatusMeaningSchema
204Stored; the value is never echoed back—

Testing

This chapter is the book's rendering of the repository's one testing guide — there is no second copy to keep in sync. Edit docs/TESTING.md, not this file.

Tack Testing Guide

Every Rust test runs with one command and needs no external service:

cargo nextest run --workspace

The summary line says how many ran — N tests run: N passed, M skipped. The skipped ones are #[ignore]d on purpose: the perf test and the live-harness runner tests, which bill a real agent account. cargo nextest is not part of cargo; install it once with cargo install cargo-nextest --locked, or take the prebuilt from https://get.nexte.st. tack-desktop is a workspace of its own: cargo nextest run --manifest-path crates/tack-desktop/Cargo.toml.

Frontend: cd frontend && npm test (Vitest). Browser E2E: make e2e.

A count appears in this guide only next to the command that produces it, and — like any load-bearing number quoted anywhere in this repository — gets re-measured before it is repeated rather than copied from the last time someone ran it. A number that drops between two runs on the same branch is a finding — a suite silently stopped running — even when everything passes.

Quick start

cargo nextest run --workspace                                   # everything — ~15 s to execute on a warm build
cargo nextest run --workspace -E 'package(tack-db)'             # one crate
cargo nextest run --workspace -E 'binary(runner_contract)'        # one test binary
cargo nextest run --workspace -E 'test(/fencing/)'              # tests whose name matches a regex
cargo nextest run --workspace --no-capture -E 'test(<name>)'    # see println!/tracing output (runs serially)
cargo nextest run --workspace --run-ignored ignored-only -E 'package(tack-db)'   # the perf test (50k items, p95 < 100 ms)
cargo nextest list --workspace                                  # what would run, without running it

Filtersets: cargo nextest run --help and https://nexte.st/docs/filtersets/.

Two rules, and the measurements behind them

Always --workspace; select with -E, never with -p. cargo test -p tack-api and cargo test --workspace resolve dependency features differently, so target/ keeps two copies of every tack crate and each source change is compiled once per form you use. Measured 2026-09-05 on a warm cache: switching from --workspace to -p tack-api with no source change recompiled tack-core, tack-db, tack-orch and tack-api — 13 s. The -E filter selects what runs; the build is the workspace's either way, and that is the point. Reproduce: cargo test --workspace --no-run && cargo test -p tack-api --no-run and count the Compiling lines of the second.

Never read a green run's output. .config/nextest.toml prints failures and one summary line — a green run is ~8 lines. cargo test --workspace prints one line per passing test: ~2,700 lines, ~84k tokens for a reader that is a language model, to learn the word "ok". Reproduce: cargo test --workspace 2>&1 | wc -c against cargo nextest run --workspace 2>&1 | wc -c.

cargo test still works — nothing forbids it — but it is not what CI, make test or the /gate skill call, and nothing in this repository should tell anyone to run it.

A test's temporary paths come from a guard. Ask tempfile for the directory and hold the guard; never build a path with env::temp_dir().join(...):

#![allow(unused)]
fn main() {
let dir = tempfile::tempdir().expect("temporary directory");
let db_path = dir.path().join("subject.db");   // -wal, -shm and the migration
                                               // runner's snapshot land beside it
}

Both failure modes of the hand-built form are gone: the guard removes the directory when it drops, so a panicking test cleans up too, and it removes everything inside, so nothing has to be named — the reason the old form leaked was that no test knew the migration runner writes a .before-037_orch_runs_rebuild.sqlite next to any file-backed database. Measured 2026-09-05: one green run left 83 entries in /tmp before, 0 after; 4,499 had accumulated. Reproduce: touch /tmp/mark && cargo nextest run --workspace && find /tmp -maxdepth 1 -newer /tmp/mark | wc -l.

The one thing the compiler will not catch: a helper that builds the directory and returns only a path deletes it as it returns. Return the guard alongside — (Repository, TempDir) — or take the directory as a parameter. clippy.toml's disallowed-methods rejects a bare std::env::temp_dir() anywhere in the workspace, test or production; production code that legitimately needs the OS temp directory carries its own #[allow(clippy::disallowed_methods)] with a one-line reason instead of being exempted by scope.

Where the tests live, and how each crate is tested

A behaviour is tested once, at the lowest layer that can express it (ADR 0068 decisions 5 and 6): pure unit tests in tack-core, repository tests against SQLite in tack-db, one HTTP test per route outcome in tack-api, contract tests for runner-v1 and the OpenAPI spec, fake-harness tests in the runner, Playwright for critical journeys. An invariant is pinned at its lowest layer plus at most one test through the HTTP API, besides the contract fixtures. A test that proves how a change was built rather than what the product does — a narrative test asserting several unrelated claims, a test of a private helper, a near-copy of another layer's test — is deleted once its real claim has a home elsewhere.

CrateWhereHarnessWhat belongs here
tack-core#[cfg(test)] next to the code, or <module>/tests.rs past 150 linesplain #[test]; the crate has no I/Obusiness rules — a rule that can be tested without a database is tested here, not above
tack-dbcrates/tack-db/tests/common::setup_test_db(): a fresh sqlite::memory: pool with every migration applied, per testrepository round-trips, migrations, FTS, cascades. Locking claims need a file-backed DB — the in-memory harness masks races
tack-orch#[cfg(test)] (or <module>/tests.rs) and crates/tack-orch/tests/runner_contract byte-pins docs/contracts/runner-v1/the runner-v1 execution domain: capability negotiation, request/attempt lifecycle, scheduling, model policy, usage provenance. docs/contracts/runner-v1/ fixtures outrank any Rust/TS type — a fixture edit updates the pin table in tests/runner_contract.rs in the same change
tack-apicrates/tack-api/tests/common::test_app(), test_app_with_config(), test_app_with_file_db(): a wired router over an in-memory DB, driven with tower::ServiceExt::oneshot — no portstatus codes, response shapes, auth surfaces, wiring that proves a handler is reachable
tack-runnermostly #[cfg(test)] (or <module>/tests.rs); crates/tack-runner/tests/src/harness/fixtures/fake_harness.sh, captured vendor transcripts under fixtures/<kind>/<version>/, the crash matrixcredential handling, journal, subprocess boundary. The harness lifecycle is proved once, in harness/local_process/tests.rs, through a grammar that adds nothing: spawn, environment, secrets, redaction, probe, cancel and reconcile against fixtures/fake_harness.sh. A harness's own tests are pure — a request in, a command line out; a captured transcript in, a report out — and never spawn a process to re-prove the core. The one exception, per open-wire harness, is a single test that runs the real binary against a fake model server on loopback: it proves the vendor's contract, is not billed, and returns early when the binary is absent. Vendor output is a file under fixtures/<kind>/<version>/, its provenance (captured or constructed) stated in that directory's README. A rule that binds requests goes in the core so it binds every harness; a policy a harness cannot enforce is declared in its permission_policy capability, never silently ignored. Shape and rules for docket and opencode: docs/closed-cycles/plans/harnesses.md (archived); what each harness can still gain is a task in docs/plans/phase-65.md. Live-harness tests are #[ignore] and billed
tack-clicrates/tack-cli/tests/wiremock stubs the API; the scheduler E2E spawns a real tack serve on a bind-then-drop port, so .config/nextest.toml runs each of its tests with nothing alongsiderequest shaping, error surfacing, the end-to-end scheduler path

Each tests/*.rs file is its own binary — its own crate, its own full link, seconds of CPU and tens of megabytes on disk per file. Add a test to the existing file whose subject fits; do not add a file per feature. (ADR 0064 groups the existing files by subject.)

Where a test lives, and how big it may be

crates/<crate>/
  src/<module>.rs              production; a trailing `mod tests` of ≤ 150 lines may stay
  src/<module>/tests.rs        that module's unit tests once they outgrow 150 lines
                               (`#[cfg(test)] mod tests;` — `use super::*` still works)
  tests/common/mod.rs          the crate's shared fixtures — the only place a helper lives
  tests/<subject>.rs + dir     one binary per subject
  tests/contract/, tests/live/ byte-pinned fixtures; real binaries or billed runs, #[ignore]d
  tests/scratch_*.rs           gitignored: your local proof, never tracked
crates/tack-test-support/      fixtures for the layers below the API

An integration test imports the crate (use tack_api::handlers::decisions;). It never pulls a source file in with #[path]: that compiles a second copy of the module into the test binary, the tests pass against the copy, and coverage counts nothing for the real one.

Size rules are standard lints, not a custom ratchet (ADR 0068 decision 11): cargo clippy --workspace --all-targets -- -D warnings catches an oversized function against clippy.toml's too-many-lines-threshold and a disallowed hand-built temp path; rustfmt carries formatting. Review carries the rest — a test file, a name, a preamble that has grown past what a reader can hold is a normal review comment, not a script's exit code:

ConventionGuideline
One claim per test; variants are rows of a table-driven test—
The name states the claim, no articles or narrativeshort
A trailing #[cfg(test)] mod tests in a production filemoves to <module>/tests.rs once it crowds the file
An invariant is pinned at the repository and at one router-level test, not a third time2 layers
Fixed waits (sleep) in test code0 — poll with a bound, or pause time
A test that early-returns on an env var inside a unit module0 — it belongs under tests/live/, #[ignore]d

Conventions that hold everywhere: assert_matches! for enum variants; #[tokio::test] for async; a test of "writes nothing" or "rejects before X" asserts the absence directly (row counts, an untouched checkpoint) and proves itself load-bearing by reverting the fix once; a wait is a bounded poll on a condition, never a fixed sleep; a flaky test is recorded, never retried into green (retries = 0 in the nextest config).

Example — a handler test with the shared harness:

#![allow(unused)]
fn main() {
#[tokio::test]
async fn health_returns_ok() {
    let (app, _workspace) = common::test_app().await;
    let res = app
        .oneshot(Request::builder().uri("/api/health").body(Body::empty()).unwrap())
        .await
        .unwrap();
    assert_eq!(res.status(), StatusCode::OK);
}
}

Contract and regeneration gates

Two tests guard artifacts that are committed rather than computed. They run inside the full suite; the first also rewrites the artifact when asked, and CI fails when that rewrite differs from what is committed:

UPDATE_OPENAPI=1 cargo nextest run --workspace -E 'binary(openapi_contract)' && git diff --exit-code docs/openapi.json
cargo nextest run --workspace -E 'binary(runner_contract)'   # never regenerated: the fixtures are the authority

Three more contracts under docs/contracts/ are held the same way, each by tests that read its committed example: brief-v1 (crates/tack-core/src/brief/tests.rs), mrp-v1 (crates/tack-core/tests/mrp_contract.rs, with its four fixtures) and evidence-v1 (crates/tack-runner/tests/evidence_contract.rs, which also pins the example's bytes). Change the example first, then the type.

docs/openapi.json and frontend/src/shared/api/schema.gen.ts are generated; ./scripts/regen-generated.sh does both plus the lockfiles. Never hand-edit or hand-merge them.

With the embedded SPA

cd frontend && npm run build && cd ..
cargo nextest run -p tack-api --features embed-spa    # -p on purpose: a feature build is its own resolution anyway

Continuous integration

.github/workflows/ci.yml (ADR 0068 decision 9) runs in three tiers, by trigger, in the same file — no separate workflow per tier:

TierTriggerJobs
Pull requestevery push (main, develop, claude/**) and every pull requestrust, coverage, frontend, docs, deny, security
Mergepush to develop or mainadds embed-spa, desktop, e2e (Chromium only)
Scheduleweekly cron, or by hand (workflow_dispatch runs every job in every tier)msrv, mutants, e2e (all three browsers)
JobWhat it runs
rustscripts/check-comments.sh → cargo fmt --check → cargo clippy --workspace --all-targets -- -D warnings → cargo doc --workspace --no-deps with RUSTDOCFLAGS="-D rustdoc::broken_intra_doc_links" → the OpenAPI regenerate-and-diff gate
coveragecargo llvm-cov nextest --workspace --lcov --output-path lcov.info --fail-under-lines 78.93 — the one run of the whole suite, instrumented; it replaces both the old rust job's plain test step and the five per-crate coverage builds. The floor (78.93%) is the workspace line total measured 2026-09-18 (75.81%, cargo llvm-cov report --summary-only) minus one point. On a pull request, diff-cover lcov.info --compare-branch=origin/<base> --fail-under=80 additionally requires 80% coverage of the lines the pull request itself changes; lcov.info is uploaded as an artifact either way
frontendschema drift, type-check, Vitest with coverage thresholds (70% lines/functions/statements, 60% branches — decision 7), token lint, build, entry-bundle budget
docsmdbook build + link check
deny, securitylicenses and duplicate versions; cargo audit + npm audit
msrvcargo build --workspace --locked on the pinned dependency floor
mutantscargo-mutants over tack-core and tack-db's repository layer. Report-only: surviving mutants are listed in the job summary and the mutants-* artifacts, and never fail the build. Read it when deciding where the next test goes — a survivor is a line a test runs but would not miss
desktopfmt, clippy, cargo test in the tack-desktop workspace
embed-sparelease build with the SPA embedded, binary-size budget
e2ePlaywright, a11y scan, API contract — Chromium only on a merge, all three browsers on the schedule run

The suite runs exactly once per applicable trigger, in the coverage job's cargo llvm-cov nextest step. The rust job's last step does run one test a second time, deliberately: the OpenAPI contract test is re-invoked with UPDATE_OPENAPI=1 through a targeted -E filter, which makes it regenerate docs/openapi.json from the current code, and the step then diffs that output against what's committed. This is a second pass over the same test in generate mode to catch drift, not a second verdict from the first run. CARGO_INCREMENTAL=0 throughout: CI never reuses incremental state, and keeping it only inflates the cache.

main's branch-protection ruleset still names the ten job names this tiering replaced; it is updated by the repository owner (gh api) from the pull-request tier's job list above, when develop is next released — not by this file.

Pre-push hook

git config core.hooksPath .githooks activates it. It runs exactly three things: cargo fmt --all --check for the root workspace and, separately, for crates/tack-desktop (its own workspace, excluded from the root one — nothing else local sees that crate at all), cargo clippy --workspace --all-targets -- -D warnings, and the lockfile freshness check — not the test suite, on purpose: a hook that takes a minute is a hook people bypass, and the suite is CI's job. The schema.gen.ts staleness check only runs when frontend/node_modules exists, so a checkout that has never run npm install gets no local protection against schema drift there — CI's frontend job still catches it. Run cargo nextest run --workspace yourself before pushing anything you claim is green.

Coverage

make coverage   # CI's coverage floors locally: one instrumented workspace run + Vitest thresholds

Floors make coverage and CI enforce: workspace line coverage ≥ 78.93 % (the total measured by the command in ci.yml's coverage job, minus one point); frontend Vitest ≥ 70 % lines/functions/statements and ≥ 60 % branches. On a pull request CI also requires 80 % of the changed lines to be covered (diff-cover).

For an HTML report instead of the pass/fail gate:

cargo install cargo-llvm-cov
cargo llvm-cov nextest --workspace --html --output-dir coverage/

Manual smoke test

With the server running (cargo run -p tack-cli -- serve):

BASE=http://localhost:3210/api

# Health
curl -s $BASE/health | jq

# Create → add → move
PID=$(curl -s -X POST $BASE/projects \
  -H "Content-Type: application/json" \
  -d '{"name":"Smoke","project_type":"software"}' | jq -r '.id')

IID=$(curl -s -X POST $BASE/projects/$PID/items \
  -H "Content-Type: application/json" \
  -d '{"title":"Test task","item_type":"task"}' | jq -r '.id')

curl -s -X PATCH $BASE/items/$IID \
  -H "Content-Type: application/json" \
  -d '{"status":"In Progress"}' | jq .status

# WebSocket (requires websocat)
websocat "ws://localhost:3210/api/projects/$PID/boards/live"

# Search
curl -s "$BASE/projects/$PID/search?q=test" | jq

# GitHub import (requires a valid token for private repos)
curl -s -X POST $BASE/projects/$PID/import-github \
  -H "Content-Type: application/json" \
  -d '{"repo":"owner/repo","label_filter":["bug"]}' | jq

# Backup
curl -s $BASE/backup -o smoke-backup.db
file smoke-backup.db   # should say "SQLite 3.x database"

# Cleanup
curl -s -X DELETE $BASE/projects/$PID

End-to-end, accessibility & API-contract tests (Playwright)

Browser-level tests that drive the real app — the tack-api server plus the Vite-served SPA — in Chromium, Firefox and WebKit. Playwright owns both server lifecycles, so a single command is all that's needed; the API runs against a throwaway e2e.db so your working database is never touched.

make e2e-install     # one-time: download the browser engines
make e2e             # run the whole suite (chromium + firefox + webkit)
make e2e-ui          # interactive runner for debugging

The database is reset every run, not just named "throwaway"

frontend/playwright.config.ts's API webServer entry deletes e2e.db* and storage-e2e/ (in that order — see the config's own comment for why the order matters) before it runs cargo run -p tack-cli -- serve, so every invocation starts from an empty database and an empty storage dir. Before this reset existed, nothing ever threw the file away: across one real session it reached 383 enrolled runners, 100 projects, 277 execution requests and 723 items, and the suite's own failure count tracked that growth — different tests failing at each level, all passing when run alone. A flake rate measured against this database means nothing unless the database's starting state is stated with it.

The reset itself is not the expensive part. Measured directly against the command the webServer entry runs (time (rm -rf storage-e2e && rm -f e2e.db*)): 2ms against a single run's leftovers (~900KB database, 40KB storage dir) and 8ms against ~284 accumulated agent_runners rows (~8.2MB database, 240KB storage dir) — rm unlinks, it doesn't read, so the cost does not grow with what's being thrown away.

The expensive part, if you skip the reset, is everything downstream. Three consecutive full chromium runs from a clean state (time npx playwright test --project=chromium --workers=2, CARGO_TARGET_DIR pointed at a warm target) measured 58s, 33s, 34s (the first pays a one-time compile-check cost the other two don't) with row counts identical at the end of every run: agent_runners 15, projects 4, items 21, execution_requests 11. Left to accumulate instead — a manually-run server reused across repeated invocations, never reset — the same class of run slowed as agent_runners climbed, on an otherwise idle machine: 17s at 0, 24s at 225. A later run against a further-accumulated database (~285 agent_runners) took nearly two minutes and failed 16 tests that pass at every other level measured here, none of them the same test — but the machine was no longer idle by then (an unrelated CPU load spike, not from this suite, was independently confirmed via uptime and ps), so that number is directional corroboration, not a clean measurement. It is nonetheless consistent with this cycle's earlier report of 383 runners producing 9 failures where a clean database produces none. Resetting every time is faster than not, not merely more correct.

If you need to inspect what a run left behind — debugging a failure, checking a migration — copy e2e.db/storage-e2e aside before the next run reclaims them; there is no flag to skip the reset.

Running this suite while another instance is also running it

The API server binds a fixed port (3399) and the SPA a fixed port (5199), the same for every checkout — there is nothing per-worktree or per-process about them. Locally (never in CI), Playwright's reuseExistingServer means a second invocation that finds something already answering the health check on that port reuses it instead of starting its own — and the reset above only ever runs in the codepath that starts a fresh server. Reuse is silent: nothing reports that the server answering your requests belongs to a different checkout, with a different database, possibly mid-run itself.

This is not hypothetical — it is the concrete explanation for several irreconcilable flake-rate measurements produced across this codebase's history, each taken without realizing another process on the same machine was answering the same port. If a run reports failures that don't reproduce solo and don't match anything you changed, check for another instance before trusting the number:

pgrep -af "playwright|tack serve"   # any other run or leftover server
ss -ltnp | grep -E '3399|5199'      # who actually holds this suite's ports

A tack serve on a different port (3210 is the plain tack serve default; an installed release build or another tool may sit there) is unrelated and safe to ignore. One already on 3399 or 5199 is not — either wait for it to finish or coordinate with whoever's running it; there is no per-worktree isolation for these ports today.

E2E is for a journey that crosses surfaces — a click in the browser, the production router, the database, and back to the screen without a reload. A rule one layer can state (which runner may claim a request, what a route returns for bad input) is pinned in that layer and not re-proved through a browser. The suite, in frontend/e2e/:

SpecJourney
smoke.spec.tsEvery primary surface renders without a blank screen or a page error
journey.spec.ts, table.spec.tsAn item is created, appears on the board and in the list, opens with its title, and an inline edit persists
run-with-agent.spec.ts"Run with agent" from the board, the item and the sprint: required fields block submit, a run is created and shows up without navigation, the project's model default is sent unchanged
scheduler-e2e.spec.tsA request made in the UI is claimed by a runner and the UI shows it live; a model no runner attests is blocked in the form
execution-attempt-detail.spec.tsAn attempt's events, decisions and artifacts: a pending decision resolves through the UI, an artifact downloads, a poll tick never discards typed input
agents-page.spec.ts, execution-toggle.spec.ts, provider-key-panel.spec.tsTurning agent execution on and off, the installed agents it reveals, a test run reaching the timeline, and a saved provider key that never reaches the DOM
board-websocket-subprotocol.spec.tsA real browser WebSocket offering tack.v1 stays connected and receives a board event — only a browser enforces that handshake rule
a11y.spec.tsWCAG 2.0/2.1 A & AA scans via axe-core — new violations fail CI
api.spec.tsWire-contract checks: health shape, hardening headers, response envelopes, 404s
shared-project-identity.spec.tsGuards the suite itself: the shared project keeps one identity while specs run concurrently

helpers.ts is the single source of truth for API response shapes and setup calls. Three more files in the directory are recorders, not tests — screenshots.spec.ts, recovery-demo.spec.ts and agent-assets.spec.ts produce the README's images, each through its own config and its own header recipe. The default config ignores them and CI never runs them.

A flaky E2E test. CI retries a failed test once, only to record a trace and a video; a test that passes on the retry still fails the run (failOnFlakyTests). The same day, mark it test.fixme() with a one-line comment that says what was seen and links the issue, so the rest of the suite keeps gating. Within two weeks it is fixed or deleted. Never raise retries or a timeout to get to green, and never wait a fixed time — poll a condition with a bound, as expect(...).toBeVisible({ timeout }) does.

Config: frontend/playwright.config.ts. Cross-browser coverage is the projects list; engine-independent specs (a11y, api) self-skip to chromium only.

Triaging existing a11y debt: add the axe rule id to KNOWN_ISSUES in a11y.spec.ts with a tracking note instead of deleting the assertion, so the gate keeps blocking new regressions.


Dependency vulnerability scanning

make audit           # cargo audit (Rust) + npm audit --audit-level=high (frontend)

Runs in CI as the security job (cargo-audit via the RustSec advisory DB + npm audit). Dependabot opens grouped monthly update PRs for cargo, npm and GitHub Actions, and security updates as advisories appear.

npm audit exits 1 both for a finding and when npm's advisory endpoint is down. CI tries three times; a finding fails the job, an outage that outlasts the tries is a warning on the run, because no commit can fix it.

Known, justified Rust advisory exceptions live in .cargo/audit.toml with a documented reason each — the gate still fails on any new advisory. Re-review that list on every dep bump.

Known a11y debt: none currently. The KNOWN_ISSUES list in e2e/a11y.spec.ts is empty — the earlier color-contrast and select-name suppressions have been fixed and removed, so the axe scan gates on a fully clean baseline. If a justified, hard-to-fix violation ever needs suppressing, add its axe rule id to KNOWN_ISSUES with a tracking note rather than deleting the assertion, so the suite keeps blocking new classes of regression.


Load / performance testing (k6)

HTTP-level load test establishing the performance baseline. Not part of default CI (needs a running server, time-consuming) — run on demand.

# terminal 1: a server with a throwaway DB
TACK_DATABASE_URL='sqlite:load.db?mode=rwc' cargo run -p tack-cli --release -- serve
# terminal 2:
make load

Ramps to 50 VUs on the read hot path + a write path, asserting p95 latency and error-rate thresholds. The write p95 threshold is where SQLite's single-writer model shows up first. See tests/load/README.md.


Deployment

Tack is a single process with no external service dependencies. The deployment model is intentionally minimal: copy a binary, point it at a directory, run it.

This page is the book's rendering of the deployment guide — for the single-binary, systemd, Docker, reverse-proxy, backup, and troubleshooting models, see docs/DEPLOYMENT-GUIDE.md, included below. Edit that file, not this one, except for the Local Development section, which is specific to this workspace and has no home there. For tokens, CORS, webhooks, and cloud backup configuration, see Administration & Security.


Local Development with Caddy (.test domain)

For local use behind the project's Caddyfile.local:

tack.test {
    reverse_proxy 127.0.0.1:3210
}

Import it from the global /home/ox/Sites/Caddyfile and reload:

sudo systemctl reload caddy

The app is then available at https://tack.test.


Tack Deployment Guide

This guide covers deploying Tack to production.

Tack is a single, self-contained binary (about 21 MiB — see Benchmarks) with the SolidJS SPA embedded. One process serves the REST API (/api/*), the WebSocket, and the web UI — same-origin, so there is no separate frontend service, no CORS to configure for the bundled UI, and no static host to run. All state lives in one SQLite file plus an attachments directory. Deployment is therefore "run one binary behind a reverse proxy."


Table of Contents

  1. Get the binary
  2. Run it
  3. Systemd service (recommended)
  4. Per-user service (no root)
  5. Reverse proxy + HTTPS (Caddy)
  6. Reverse proxy (nginx)
  7. Docker
  8. Environment configuration
  9. Backups
  10. Monitoring & logging
  11. Security checklist
  12. Scaling considerations
  13. Troubleshooting
  14. Maintenance

Get the binary

Option A — download a release

Prebuilt binaries for Linux (x86_64), macOS (Intel + Apple Silicon), and Windows are attached to each GitHub Release. Each archive contains the single tack executable, LICENSE, README.md, and a QUICKSTART.txt. From v0.1.0-beta.7 on, releases also ship a SHA256SUMS file, build provenance attestations, and an SBOM — verify before deploying:

sha256sum -c SHA256SUMS            # checksums
gh attestation verify tack --repo yielab/tack   # provenance (optional)

Option B — one-line installer

curl -fsSL https://raw.githubusercontent.com/yielab/tack/main/install.sh | sh

Resolves the newest release asset for your platform and installs tack. It verifies the archive against that release's own SHA256SUMS before extracting anything, and refuses to install on a mismatch — Option A's manual check, done for you. Releases before v0.1.0-beta.7 predate that file; installing one of those needs TACK_SKIP_CHECKSUM=1, which is the only way to skip the check.

Option C — build from source

The SPA must be built first so --features embed-spa can embed it. The Makefile does both steps:

git clone https://github.com/yielab/tack.git
cd tack
make build
# → npm --prefix frontend ci && npm --prefix frontend run build
#   cargo build -p tack-cli --release --features embed-spa
# Produces target/release/tack  (SPA embedded)

For a fully static Linux binary (no glibc dependency — ideal for minimal hosts and containers):

rustup target add x86_64-unknown-linux-musl
sudo apt-get install -y musl-tools
npm --prefix frontend ci && npm --prefix frontend run build
cargo build --release --target x86_64-unknown-linux-musl -p tack-cli --features embed-spa
# → target/x86_64-unknown-linux-musl/release/tack

Run it

# Bare `tack` (or `tack serve`) starts the server + web UI.
./tack

# Open http://127.0.0.1:3210 — the SPA loads and talks to /api same-origin.

By default Tack binds 127.0.0.1:3210, writes tack.db in the current directory, and stores attachments in ./storage. Point those anywhere with env vars:

TACK_HOST=127.0.0.1 \
TACK_PORT=3210 \
TACK_DATABASE_URL="sqlite:/var/lib/tack/tack.db?mode=rwc" \
TACK_STORAGE_DIR="/var/lib/tack/storage" \
  ./tack

The same binary is also the CLI client (./tack --help, ./tack add, ./tack list, …) — it talks to a running server over HTTP, never the DB directly.

Security note: bind to 127.0.0.1 and put a reverse proxy in front. If you must bind a non-loopback address (TACK_HOST=0.0.0.0), set TACK_API_TOKEN — otherwise the API (including the "download the whole database" endpoint) is open to anyone who can reach the port. In this exact configuration (non-loopback bind and no TACK_API_TOKEN), the server refuses to start: its security preflight rejects the configuration before any network resources open. Set TACK_API_ALLOW_UNAUTHENTICATED_NONLOOPBACK=1 only to accept that risk deliberately (e.g. behind a trusted authenticating proxy). See docs/CONFIG.md for the full TACK_* variable table.


For a native Linux host, run Tack as a hardened systemd unit bound to loopback, with a reverse proxy terminating TLS.

# 1. Install the binary and create a service user + data dirs
sudo install -m 0755 tack /usr/local/bin/tack
sudo useradd --system --home /var/lib/tack --shell /usr/sbin/nologin tack
sudo mkdir -p /var/lib/tack/storage
sudo chown -R tack:tack /var/lib/tack

# 2. Write the unit
sudo tee /etc/systemd/system/tack.service >/dev/null <<'EOF'
[Unit]
Description=Tack project management
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=tack
Group=tack
WorkingDirectory=/var/lib/tack
Environment=TACK_HOST=127.0.0.1
Environment=TACK_PORT=3210
Environment=TACK_DATABASE_URL=sqlite:/var/lib/tack/tack.db?mode=rwc
Environment=TACK_STORAGE_DIR=/var/lib/tack/storage
Environment=TACK_LOG_LEVEL=info
Environment=TACK_LOG_JSON=true
# Uncomment to require a bearer token on every API request:
# Environment=TACK_API_TOKEN=change-me-to-a-long-random-secret
ExecStart=/usr/local/bin/tack serve
Restart=always
RestartSec=5

# Hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/tack

[Install]
WantedBy=multi-user.target
EOF

# 3. Enable and start
sudo systemctl daemon-reload
sudo systemctl enable --now tack
sudo systemctl status tack

Logs go to the journal: sudo journalctl -u tack -f.


Per-user service (no root)

For a personal machine — no dedicated service account, no sudo — tack service installs the same idea as a user unit instead of a system one: a systemd user unit on Linux (~/.config/systemd/user/tack.service, systemctl --user), a launchd agent on macOS. It uses this OS's own per-user application-data folder for the database, storage, runner state, and log file, so nothing is written next to wherever the command was run. Not supported on Windows; install the desktop app there instead.

tack service install     # writes the unit, then `systemctl --user enable --now tack`
tack service status       # prints the unit's state and the health URL
tack service uninstall    # stops and removes the unit; the data root is left untouched

See tack service for real output from all three commands. Prefer the system-level unit above for a shared or internet-facing deployment — this one runs as your own user and stops when your user session's systemd instance does (loginctl enable-linger keeps it running across logouts).


Reverse proxy + HTTPS (Caddy)

Caddy terminates TLS (automatic Let's Encrypt), forwards everything to the single Tack process, and transparently upgrades the WebSocket — no special block needed in modern Caddy, reverse_proxy handles the upgrade automatically.

tack.example.com {
    encode gzip

    # One upstream serves the API, the WebSocket, and the SPA.
    reverse_proxy 127.0.0.1:3210

    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options    "nosniff"
        X-Frame-Options           "SAMEORIGIN"
        Referrer-Policy           "strict-origin-when-cross-origin"
    }

    log {
        output file /var/log/caddy/tack.log
    }
}

Reload Caddy after editing (sudo systemctl reload caddy). This project's own local dev setup uses exactly this pattern — a single upstream behind a systemd-managed Caddy (see Caddyfile.local and /home/ox/Sites/LOCAL-DOMAINS.md).


Reverse proxy (nginx)

server {
    listen 80;
    server_name tack.example.com;

    location / {
        proxy_pass http://127.0.0.1:3210;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # WebSocket upgrade (board live-updates)
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 86400;
    }
}

Add TLS with Certbot: sudo certbot --nginx -d tack.example.com.


Docker

A Dockerfile and docker-compose.yml ship at the repo root. The image is a three-stage build (build SPA → compile a static musl binary that embeds it → copy onto a distroless base) producing a shell-less image barely larger than the binary. There is a single service and a single volume for the database and attachments.

# Build and run with compose
docker compose up -d
curl http://localhost:3210/api/health
docker compose logs -f

# Or plain docker
docker build -t tack:latest .
docker run -d --name tack -p 3210:3210 -v tack-data:/data tack:latest

The container binds 0.0.0.0 internally and stores everything under /data (/data/tack.db + /data/storage), persisted by the named volume. If you publish the port beyond localhost, set TACK_API_TOKEN in docker-compose.yml (a commented example is included there). The distroless image has no shell, so health-check the container from the host (curl .../api/health) or via a proxy.


Environment configuration

Tack reads config from tack.toml (if present) or environment variables. The full table lives in the configuration reference and the API reference; the deployment-relevant ones:

# Server
TACK_HOST=127.0.0.1                                  # bind address (loopback by default)
TACK_PORT=3210
TACK_DATABASE_URL=sqlite:/var/lib/tack/tack.db?mode=rwc
TACK_STORAGE_DIR=/var/lib/tack/storage               # attachments

# Auth & CORS
TACK_API_TOKEN=<long-random-secret>                  # require Authorization: Bearer <token>
TACK_ALLOWED_ORIGINS=https://tack.example.com        # comma-separated CORS allow-list

# Logging
TACK_LOG_LEVEL=info                                  # trace|debug|info|warn|error
TACK_LOG_JSON=true                                   # structured logs for aggregators
TACK_LOG_FILE=/var/log/tack/tack.log                 # optional file sink

# Body limits
TACK_MAX_BODY_SIZE=2097152                           # 2 MB default (upload endpoint is always 50 MB)

TACK_ALLOWED_ORIGINS is only relevant if you point a separate-origin browser client at the API. The bundled SPA is same-origin and needs no CORS config. There is no TACK_CORS_ORIGIN variable — the allow-list is TACK_ALLOWED_ORIGINS.

Optional integrations (outbound webhooks, GitHub sync, and S3-compatible cloud backup) are configured with their own TACK_* variables — see CLAUDE.md.

Configuration file (tack.toml)

host = "127.0.0.1"
port = 3210
database_url = "sqlite:/var/lib/tack/tack.db?mode=rwc"
storage_dir = "/var/lib/tack/storage"
log_level = "info"
log_json = true
log_file = "/var/log/tack/tack.log"
allowed_origins = "https://tack.example.com"

Backups

Tack has built-in backup/restore — you do not need to reach into SQLite manually.

Built-in local backup

# Download a consistent snapshot (VACUUM INTO) over the API
curl -s http://127.0.0.1:3210/api/backup -o tack-backup.db

# Or via the CLI
tack backup > tack-backup.db          # writes a snapshot
tack restore tack-backup.db           # stages a restore (applied on next restart)

Restore is staged: the uploaded DB is written next to the live one and swapped in atomically on the next server start. Restart the service after restoring.

File-level backup (systemd host)

# Stop-free snapshot of the live DB (WAL-safe)
sqlite3 /var/lib/tack/tack.db ".backup /var/backups/tack/tack-$(date +%F).db"
# Attachments live on disk, back them up too:
tar czf /var/backups/tack/storage-$(date +%F).tar.gz -C /var/lib/tack storage

Automate with cron or a systemd timer, and prune old files with find /var/backups/tack -mtime +30 -delete.

Cloud backup (S3-compatible)

Tack can back up the database plus attachments as one .tar.zst bundle to any S3-compatible store (Cloudflare R2, Backblaze B2, AWS S3, self-hosted MinIO). Set the TACK_BACKUP_* variables (see CLAUDE.md) or configure it at runtime under Settings → Cloud Backup, then:

tack backup --remote        # upload a bundle now
tack backups                # list remote bundles (newest first)
tack restore --remote       # stage the latest remote bundle, then restart

Set TACK_BACKUP_INTERVAL_SECS to schedule automatic uploads. The secret key is never logged and is write-only over the API.

Bundles carry a generation counter and an install ID: an upload that would overwrite newer work from a different install is rejected (pass force to override), and a restore that would clobber newer local work requires confirmation. Restores verify the bundle's SHA-256 and schema version before staging, snapshot the current state first, and roll back if the swap fails.

Encryption at rest is not implemented. Bundles are compressed but unencrypted in the bucket. Use a private bucket with encryption-at-rest enabled on the provider side, and scope the access key to that one bucket.


Monitoring & logging

Health check

curl http://127.0.0.1:3210/api/health
# {"status":"ok","version":"0.1.0-beta.7","migrations_applied":18}

Point uptime monitoring (Uptime Kuma, Healthchecks.io, a load-balancer probe) at /api/health and alert on non-200 or a stalled migrations_applied count.

Logs

sudo journalctl -u tack -f                       # systemd
sudo journalctl -u tack --since "24 hours ago"   # export a window
docker compose logs -f                            # Docker

Set TACK_LOG_JSON=true for structured logs an aggregator can parse.

Debug endpoints

/api/debug/info and /api/debug/db-stats return build/config and per-table row counts. They sit behind the same bearer-token gate as the rest of the API when TACK_API_TOKEN is set — keep the token set on any exposed instance.


Security checklist

  • Bind TACK_HOST=127.0.0.1; expose only through the reverse proxy.
  • If binding non-loopback, set a long random TACK_API_TOKEN.
  • Terminate TLS at Caddy/nginx (HSTS enabled).
  • Restrict TACK_ALLOWED_ORIGINS if a separate-origin client uses the API.
  • Run under a dedicated, unprivileged service user (the systemd unit above).
  • Firewall the raw app port so only the proxy can reach it.
  • Automate backups and test a restore.
  • Add rate limiting at the proxy if the instance is public.
  • Keep TACK_LOG_LEVEL=info or warn in production; TACK_LOG_JSON=true.

Firewall (UFW)

sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw deny  3210/tcp    # block direct app access; only the proxy reaches it
sudo ufw enable

Rate limiting (Caddy)

tack.example.com {
    rate_limit {
        zone tack {
            key    {remote_host}
            events 100
            window 1m
        }
    }
    reverse_proxy 127.0.0.1:3210
}

Scaling considerations

Tack is a single-writer SQLite application, designed for a solo developer or a small team. There is no horizontal write scaling — that is a deliberate design choice, not a gap. Practical guidance:

  • Vertical: the binary is tiny (~12 MB idle RSS) and reads are sub-millisecond; a modest VM handles a small team comfortably.
  • Write throughput: SQLite serializes writes. This is fine at small-team scale; it is the first thing you would feel under heavy concurrent writes.
  • Concurrency: the DB runs in WAL mode for better read/write concurrency.
  • Multi-user auth, per-user identity, and Postgres are explicitly out of scope for the current design (see the roadmap's "Future / Optional").

Troubleshooting

Service won't start — check logs:

sudo journalctl -u tack -n 50      # systemd
docker compose logs tack            # Docker

Common causes: port already in use (change TACK_PORT), the data directory is not writable by the service user, or a migration failure (inspect the _migrations table).

Database locked — SQLite allows one writer at a time. Make sure only one tack process points at the DB file. Keep ?mode=rwc in the URL (the default).

FTS5 not found — search needs SQLite compiled with FTS5. The bundled/static builds include it; a system SQLite without FTS5 will fail migrations.

WebSocket not updating — ensure the proxy forwards the Upgrade/Connection headers (Caddy does automatically; nginx needs the two proxy_set_header lines above) and does not time the connection out (proxy_read_timeout 86400).

Database corruption — sqlite3 tack.db "PRAGMA integrity_check;", then restore from a backup if needed.


Maintenance

Update procedure

# 1. Back up first
tack backup > /var/backups/tack/pre-upgrade-$(date +%F).db

# 2. Replace the binary (download a new release or rebuild)
sudo install -m 0755 tack /usr/local/bin/tack

# 3. Restart — migrations run forward automatically on startup
sudo systemctl restart tack
curl http://127.0.0.1:3210/api/health

Database vacuum

sqlite3 /var/lib/tack/tack.db "VACUUM;"    # reclaim space; run occasionally

Log rotation (systemd host with a file sink)

sudo tee /etc/logrotate.d/tack >/dev/null <<'EOF'
/var/log/tack/*.log {
    daily
    rotate 14
    compress
    delaycompress
    notifempty
    create 0640 tack tack
    postrotate
        systemctl reload tack
    endscript
}
EOF

If you use journalctl (no TACK_LOG_FILE), systemd already rotates the journal.


Support

Learning Path

This section explains the Tack stack for developers coming from other backend and frontend backgrounds. It is not a comprehensive Rust or SolidJS tutorial — it is enough context to read, understand, and contribute to this specific codebase without drowning in language novelty.

You do not need to read all of it. Pick the chapters that match where you are.


Suggested reading order

Coming from Node.js / Express / TypeScript

Start with Rust for Backend Developers to understand the type system and ownership model. Then Async/Await in Rust — the concepts transfer directly, the mechanics differ slightly. Axum — HTTP Without Magic will feel familiar: it is close to Express in philosophy. Read The Data Layer last if you work on database queries or migrations. If you touch the frontend, SolidJS for Frontend Developers shows how SolidJS differs from React.

Coming from Python / Django / FastAPI

Start with Rust for Backend Developers — the ownership section is the most important thing to internalize. Async/Await in Rust is worth reading because Python's asyncio and Tokio have similar structure but different failure modes. Axum — HTTP Without Magic maps to FastAPI concepts well (both are typed, extractor-based). The Data Layer will feel very different from SQLAlchemy/Django ORM — read it carefully.

Coming from Java / Spring

Start with Rust for Backend Developers. The structs-and-traits model maps loosely to interfaces-and-classes, but the differences matter. Axum — HTTP Without Magic shows how Spring MVC concepts (controllers, dependency injection, request mapping) translate. Spring Boot does a lot more magic than Axum — the chapter explains what you have to do explicitly. The Data Layer maps to plain JDBC + a DAO layer, not Hibernate.


What each chapter covers

Rust for Backend Developers — Ownership, borrowing, structs, enums with data (Option, Result), traits, the module system, and error handling. Uses Tack models and error types as examples. This is the densest chapter; take it slow if ownership feels confusing.

Async/Await in Rust — How Rust's async model compares to JavaScript Promises, Python asyncio, and Java's CompletableFuture. Covers Tokio (the runtime Tack uses), spawning tasks, broadcast channels, and the patterns you will see in every Axum handler.

Axum — HTTP Without Magic — How Tack's HTTP layer works. Routing, extractors (how request data flows into handler functions), shared state (AppState), response types, error mapping, and middleware. Concrete before/after comparisons to Express and FastAPI.

The Data Layer (sqlx & Repository Pattern) — sqlx is not an ORM. It checks your SQL at compile time and maps rows to structs. Covers the Repository struct, migrations, JSON fields in SQLite, FTS5 search, and the auto-complete parent status logic.

SolidJS for Frontend Developers — For developers who know React (or Vue/Angular). SolidJS looks like React but never re-renders components. Covers signals, derived state, effects, control flow primitives (<Show>, <For>), context, routing, and what that means when reading Tack's frontend code.


What this section does not cover

These chapters do not replace a full language tutorial. If you want to go deeper:

Rust for Backend Developers

This chapter covers the Rust concepts you will encounter repeatedly in Tack's codebase. It assumes you already know at least one backend language well. We are not starting from zero — we are translating.


The ownership model — no GC, no manual malloc

Rust manages memory without a garbage collector and without malloc/free. It does this through a compile-time rule: every value has exactly one owner. When the owner goes out of scope, the value is freed. The compiler enforces this at compile time, not at runtime.

Think of it like a library book checkout system. Only one person can hold a book at a time. You can lend it out temporarily (borrowing), but the person who checked it out is responsible for returning it. When they leave, the book goes back to the shelf automatically — no librarian (garbage collector) needed.

#![allow(unused)]
fn main() {
let x = String::from("hello"); // x owns the string
// memory is allocated here

// end of scope — x goes out of scope, string is freed
// no GC needed, no memory leak possible
}

Moving — ownership transfers:

#![allow(unused)]
fn main() {
let x = String::from("hello");
let y = x;  // y now owns the string; x is no longer valid
// println!("{}", x);  // this would not compile: x was moved
}

In JavaScript you would never think about this — both variables would point to the same string. In Rust, the compiler prevents you from using x after the move, which eliminates entire classes of bugs (use-after-free, double-free).

Borrowing — temporary access without taking ownership:

#![allow(unused)]
fn main() {
fn print_title(title: &str) {
    println!("{}", title);
}

let item_title = String::from("Build login page");
print_title(&item_title);  // borrow — print_title sees it, does not own it
println!("{}", item_title); // still valid — we only lent it
}

The & means "reference" (borrow). The function sees the value but does not take ownership. You will see &str, &Pool, &AppState constantly in Tack's handler code.

Why this matters practically: The compiler catches data races at compile time. If two async tasks could write to the same data simultaneously without synchronization, Rust will refuse to compile. This is why Tack's WebSocket broadcast channel (broadcast::Sender<BoardEvent>) uses a typed channel rather than shared mutable state — Rust's rules guide you toward the correct concurrency pattern.


Structs and impls — not classes, but similar

Rust does not have classes. It has structs (data) and impl blocks (behavior). The combination is equivalent to a class without inheritance.

#![allow(unused)]
fn main() {
// From crates/tack-core/src/models.rs

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Item {
    pub id: Uuid,
    pub project_id: Uuid,
    pub parent_id: Option<Uuid>,
    pub title: String,
    pub description: Option<String>,
    pub item_type: ItemType,
    pub status: String,
    pub priority: Priority,
    pub estimate: Option<f64>,
    pub tags: Vec<String>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
    // ...
}
}

Compare this to what you already know:

  • Python: equivalent to a @dataclass or Pydantic model
  • TypeScript: equivalent to an interface with all fields required
  • Java: equivalent to a POJO record — record Item(UUID id, UUID projectId, ...)

The #[derive(...)] line above the struct is a derive macro — the compiler auto-generates trait implementations for those capabilities. Serialize/Deserialize comes from serde and handles JSON automatically. Debug gives you {:?} formatting for logging. Clone lets you copy the value. You do not write any of this code — the #[derive] handles it.

Methods go in a separate impl block:

#![allow(unused)]
fn main() {
impl WorkflowConfig {
    pub fn validate_transition(&self, from: &str, to: &str) -> Result<(), CoreError> {
        // self is a reference to the WorkflowConfig instance
        let from_exists = self.statuses.iter().any(|s| s.name == from);
        // ...
    }
}
}

&self is like Python's self or Java's this. There is no new keyword — constructors are just associated functions that return Self:

#![allow(unused)]
fn main() {
impl Item {
    pub fn new(project_id: Uuid, title: String) -> Self {
        Item {
            id: Uuid::new_v4(),
            project_id,
            title,
            // ...
        }
    }
}
}

There is no inheritance. If two types share behavior, they share a trait (covered below). This forces composition over inheritance, which tends to produce simpler code for a codebase like Tack.


Enums with data — the Rust superpower

Rust enums are not Java enums. They can carry data, making them the most expressive type in the language.

Option<T> — nullable values:

#![allow(unused)]
fn main() {
pub parent_id: Option<Uuid>,   // Some(uuid) or None
pub description: Option<String>, // Some("text") or None
}

This is equivalent to T | null in TypeScript, Optional<T> in Java, or T | None in Python. The critical difference: you cannot accidentally use a None value as if it were Some. The compiler forces you to check:

#![allow(unused)]
fn main() {
// Wrong — won't compile:
let id: Option<Uuid> = item.parent_id;
let str = id.to_string(); // Error: Option<Uuid> has no to_string()

// Right:
if let Some(parent_id) = item.parent_id {
    // parent_id is a Uuid here, unwrapped
}

// Or:
let parent_str = item.parent_id.map(|id| id.to_string());
}

Result<T, E> — typed errors:

#![allow(unused)]
fn main() {
pub fn validate_transition(&self, from: &str, to: &str) -> Result<(), CoreError>
}

Result is either Ok(value) or Err(error). This is like a forced try/catch that is visible in the type signature. Compare to:

  • TypeScript: similar to Either<Error, T> from functional libraries
  • Java: like a checked exception that shows in the method signature
  • Go: like (T, error) return tuples

Pattern matching — exhaustive, compiler-enforced:

#![allow(unused)]
fn main() {
match item.priority {
    Priority::Critical => handle_urgent(item),
    Priority::High     => handle_high(item),
    Priority::Medium   => handle_normal(item),
    Priority::Low      => handle_low(item),
    Priority::None     => handle_no_priority(item),
    // If you add a new Priority variant and forget to handle it here,
    // the code will not compile. The compiler is your checklist.
}
}

You will see match throughout Tack's error handling and workflow code.

The ? operator — short-circuit error propagation:

#![allow(unused)]
fn main() {
pub async fn get_item(&self, id: Uuid) -> Result<Option<Item>, sqlx::Error> {
    let row = sqlx::query_as::<_, ItemRow>(/* ... */)
        .fetch_optional(self.pool())
        .await?;  // <-- the ? here

    Ok(row.map(|r| r.into_item()))
}
}

The ? means: "if this is Err(e), return Err(e) immediately from the current function; if it is Ok(value), unwrap it and continue." It is like a one-line try/catch that re-throws. Every handler in Tack uses this pattern — you will read it everywhere.


Traits — interfaces with superpowers

Traits define shared behavior. The closest analogies:

  • Java: interfaces
  • Go: interfaces (but explicit in Rust)
  • Python: abstract base classes or protocols
  • TypeScript: interfaces (structural typing)

The most important traits in Tack are auto-derived via #[derive]:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project { ... }
}
TraitWhat it doesAnalogy
Debug{:?} formatting for tracing::debug!Python __repr__
Clone.clone() to copy the valueJava .clone(), Python copy.copy()
SerializeConvert to JSON (via serde)Jackson, json.dumps, JSON.stringify
DeserializeParse from JSON (via serde)Jackson, json.loads, JSON.parse
PartialEq== comparisonJava .equals(), Python __eq__
DefaultA sensible zero valueJava default field values

The Display trait controls how a type formats itself as a string (like Python's __str__). Tack uses this for enums so they serialize correctly:

#![allow(unused)]
fn main() {
impl std::fmt::Display for ProjectType {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Software    => write!(f, "software"),
            Self::Construction => write!(f, "construction"),
            Self::Personal    => write!(f, "personal"),
            // ...
        }
    }
}
}

You implement IntoResponse from Axum on your error type to teach Axum how to turn it into an HTTP response — more on this in the Axum chapter.


The module system

Rust's module system works differently from Node's require / Python's import. The key rules:

#![allow(unused)]
fn main() {
mod items;          // includes crates/tack-db/src/repo/items.rs as a submodule
pub use items::*;   // re-export everything public from items
}

pub controls visibility — only pub items are accessible outside the module. Everything else is private by default (stricter than Python, similar to private in Java).

#![allow(unused)]
fn main() {
use tack_core::models::{Item, Project, ItemType};
use chrono::Utc;
use uuid::Uuid;
}

use is like Python's from x import y or TypeScript's import { y } from 'x'. It brings names into scope without needing to write the full path every time.

Crates are the compilation unit — analogous to npm packages, Python packages, or Maven artifacts. Tack's workspace has six crates, plus a runner binary and a desktop shell built separately (see the Architecture Overview and Crate Tour for the full picture); the four at the center of the layering rule are:

crates/
├── tack-core/   # pure business logic, zero I/O
├── tack-db/     # database access layer
├── tack-api/    # HTTP server
└── tack-cli/    # command-line tool

Each crate has its own Cargo.toml (equivalent to package.json or pom.xml). A crate can depend on other crates in the workspace. tack-api depends on tack-db and tack-core; tack-db depends on tack-core. This hard boundary enforces the architectural rule that HTTP concerns do not leak into database code, and database concerns do not leak into pure business logic.


Error handling in practice

Tack uses thiserror to define typed error enums, and anyhow in main/CLI code where any error is acceptable.

#![allow(unused)]
fn main() {
// From crates/tack-core/src/error.rs

#[derive(Debug, thiserror::Error)]
pub enum CoreError {
    #[error("Item not found: {0}")]
    ItemNotFound(Uuid),

    #[error("Invalid status transition from '{from}' to '{to}'")]
    InvalidTransition { from: String, to: String },

    #[error("WIP limit exceeded for column '{column}': limit is {limit}, current is {current}")]
    WipLimitExceeded { column: String, limit: usize, current: usize },

    #[error("Dependency cycle detected involving item {0}")]
    DependencyCycle(Uuid),
}
}

The #[error("...")] attribute generates the Display implementation — the string you see in logs and API responses. The {0}, {from}, {column} are interpolated from the enum variant's fields.

The ApiError type in tack-api wraps CoreError and implements IntoResponse to map each variant to the correct HTTP status code:

#![allow(unused)]
fn main() {
CoreError::ItemNotFound(_) => (StatusCode::NOT_FOUND, err.to_string()),
CoreError::InvalidTransition { .. } => (StatusCode::BAD_REQUEST, err.to_string()),
CoreError::WipLimitExceeded { .. }  => (StatusCode::BAD_REQUEST, err.to_string()),
}

This is exhaustive — if a new CoreError variant is added without handling it here, the code will not compile. That is the point.

The ? operator chains these cleanly:

#![allow(unused)]
fn main() {
pub async fn update_item(/* ... */) -> ApiResult<Json<serde_json::Value>> {
    let project = state.repo.get_project(project_id).await?;  // sqlx::Error → ApiError
    project.workflow.validate_transition(&old_status, &new_status)?;  // CoreError → ApiError
    // ...
}
}

Each ? uses the From trait conversions defined on ApiError to automatically convert from sqlx::Error or CoreError into ApiError, then return early. The net effect: error paths are explicit in the type signature and boilerplate-free in the body.

Async/Await in Rust

If you have written async code in JavaScript, Python, or Java, the concepts here transfer directly. The mechanics differ in ways that matter. This chapter covers what you need to know to read and write async code in Tack.


Same concept, explicit runtime

async fn and .await work the same way conceptually as in other languages. The key difference: Rust does not ship with an async runtime. You choose one.

LanguageRuntimeYour choice?
Node.jslibuvNo — it is baked in
PythonasyncioNo — it is in the stdlib
JavaForkJoinPool / virtual threadsSomewhat — you configure it
Rustnone built inYes — Tack uses Tokio

Tack uses Tokio, the most widely used async runtime in the Rust ecosystem. The entry point of the server annotates main with #[tokio::main]:

// crates/tack-api/src/main.rs

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let config = AppConfig::load();
    // ...
    let pool = init_pool(&config.database_url).await?;
    // ...
    axum::serve(listener, app)
        .with_graceful_shutdown(shutdown_signal())
        .await?;
    Ok(())
}

#[tokio::main] is a macro that wraps your function in a Tokio runtime initialization. Everything inside main runs on the Tokio executor. Without this annotation, calling .await anywhere would be a compile error.


Futures — Promises with a different name

An async fn returns a Future<Output = T>. The analogy to JavaScript is direct:

JS Promise<T>  ≈  Rust Future<Output = T>
async function ≈  async fn
await expr     ≈  expr.await

The one critical difference: Futures are lazy. In JavaScript, creating a Promise starts it running immediately. In Rust, a Future does nothing until you .await it (or pass it to an executor).

#![allow(unused)]
fn main() {
// This does nothing — the future is created but not driven:
let fut = sqlx::query("SELECT 1").execute(&pool);

// This actually runs it:
let fut = sqlx::query("SELECT 1").execute(&pool).await;
}

This laziness is a feature. It means you can construct, compose, and cancel futures before they run. In practice, you almost always just chain .await immediately, so this rarely catches you off guard.


Why this matters for Tack

Every interaction with the database or network is async. The sqlx queries do not block a thread while waiting for disk I/O — they yield control to Tokio, which runs other tasks in the meantime:

#![allow(unused)]
fn main() {
// This does not block a thread for the duration of the disk read:
let item = state.repo.get_item(id).await?;

// While SQLite is reading, Tokio can process other HTTP requests
// on the same thread pool.
}

The Axum HTTP server is also fully async. A server handling 1,000 concurrent connections does not need 1,000 threads — Tokio multiplexes them on a small thread pool (by default, one thread per CPU core).

The WebSocket handler in crates/tack-api/src/handlers/websocket.rs is the clearest example of the async model in action:

#![allow(unused)]
fn main() {
async fn handle_socket(socket: WebSocket, project_id: Uuid, state: AppState) {
    let (mut sender, mut receiver) = socket.split();

    // Subscribe to the broadcast channel
    let mut rx = state.broadcast_tx.subscribe();

    // Spawn one task to forward broadcast events to this client
    let mut send_task = tokio::spawn(async move {
        while let Ok(event) = rx.recv().await {
            if event_matches_project(&event, project_id) {
                let msg = serde_json::to_string(&event).unwrap();
                if sender.send(Message::Text(msg.into())).await.is_err() {
                    break;
                }
            }
        }
    });

    // Spawn another task to read messages from the client
    let mut recv_task = tokio::spawn(async move {
        while let Some(Ok(msg)) = receiver.next().await {
            // handle pings, close frames, etc.
        }
    });

    // Wait for either task to finish, then abort the other
    tokio::select! {
        _ = &mut send_task => { recv_task.abort(); }
        _ = &mut recv_task => { send_task.abort(); }
    }
}
}

Each WebSocket connection spawns two lightweight async tasks that run concurrently. There is no thread-per-connection overhead.


Spawning tasks

tokio::spawn runs a future concurrently, similar to asyncio.create_task() in Python or setTimeout(fn, 0) in JavaScript:

#![allow(unused)]
fn main() {
// Fire-and-forget background work
tokio::spawn(async move {
    let result = do_background_work().await;
    // ...
});
}

The spawned task runs independently of the caller. tokio::spawn returns a JoinHandle<T> that you can .await to get the result, or ignore if you do not care when it finishes. In Tack's WebSocket handler, tokio::select! waits for the first of two tasks to complete, then cleans up the other.


The broadcast channel

The real-time board updates use Tokio's broadcast channel:

#![allow(unused)]
fn main() {
// Created once at startup in main.rs:
let (broadcast_tx, _) = tokio::sync::broadcast::channel(100);

// Stored in AppState — every handler and WebSocket connection
// shares the same sender:
pub struct AppState {
    pub broadcast_tx: broadcast::Sender<BoardEvent>,
    // ...
}
}

broadcast::channel(100) creates a multi-producer, multi-consumer channel with a buffer of 100 messages. Any handler can send an event:

#![allow(unused)]
fn main() {
// In items.rs, after updating an item:
websocket::broadcast_event(
    &state,
    BoardEvent::ItemUpdated {
        project_id: item.project_id,
        item_id: item.id,
        old_status: Some(old_status),
        new_status: item.status.clone(),
    },
);
}

Each WebSocket connection calls state.broadcast_tx.subscribe() to get its own receiver. The event is delivered to every active subscriber. The filter in handle_socket discards events for other projects.


Common async patterns in this codebase

Handler signature:

#![allow(unused)]
fn main() {
#[instrument(skip(state))]
pub async fn create_item(
    State(state): State<AppState>,
    Path(project_id): Path<Uuid>,
    Json(input): Json<CreateItem>,
) -> ApiResult<Json<serde_json::Value>> {
    // ...
}
}

Every handler is async fn. The #[instrument(skip(state))] macro from tracing wraps the function in a tracing span — you get timing and structured logging automatically.

Awaiting a query:

#![allow(unused)]
fn main() {
let item = state
    .repo
    .get_item(id)
    .await?;   // await the future; ? propagates sqlx::Error → ApiError
}

Chained async operations with ?:

#![allow(unused)]
fn main() {
let project = state.repo.get_project(project_id).await?;
let initial_status = project.workflow.initial_status().map_err(ApiError::Core)?;
let item = state.repo.create_item(project_id, &initial_status, input).await?;
}

Each line can fail independently; ? short-circuits the entire function on the first error. This is cleaner than nested try/catch blocks and equivalent in safety.

The move keyword in async closures:

#![allow(unused)]
fn main() {
tokio::spawn(async move {
    while let Ok(event) = rx.recv().await { ... }
});
}

move means the closure takes ownership of the variables it captures (rx in this case). This is required when the closure outlives the current function — which is always true for spawned tasks, since the spawned task may run after the function that spawned it has returned.

Axum — HTTP Without Magic

Axum is the HTTP framework Tack uses for its API server. If you come from Express, FastAPI, or Spring MVC, the concepts map clearly — but the amount of "magic" is different in each case.


What Axum is (and is not)

Axum is a Rust HTTP framework built on Tokio (async runtime) and Tower (middleware stack). Its job is routing requests to handler functions. That is roughly where it stops.

Compare the scope:

FrameworkRoutingDI containerValidationORMAuthTemplating
Spring BootYesYes (full)YesYesYesYes
FastAPIYesPartialYes (Pydantic)NoNoNo
ExpressYesNoNoNoNoNo
AxumYesNoNoNoNoNo

Axum is closer to Express in philosophy: it gives you routing and a composable middleware system, then gets out of the way. Everything else you compose yourself. In Tack:

  • Database access: sqlx + the Repository pattern
  • Validation: validator crate on request DTOs
  • Auth: a custom require_token middleware
  • JSON serialization: serde_json

There is no hidden dependency injection container. Shared state is passed explicitly.


Routing

In Express:

app.get('/api/projects', listProjects)
app.post('/api/projects', createProject)
app.get('/api/projects/:id', getProject)
app.patch('/api/projects/:id', updateProject)
app.delete('/api/projects/:id', deleteProject)

In Axum (from crates/tack-api/src/router.rs):

#![allow(unused)]
fn main() {
Router::new()
    .route("/projects",     get(list_projects).post(create_project))
    .route("/projects/{id}", get(get_project).patch(update_project).delete(delete_project))
}

The method functions (get, post, patch, delete) are Axum's equivalents of Express's app.get, app.post, etc. Multiple methods on the same path chain with .method(handler).

Notice /projects/{id} uses curly braces — that is Axum's path parameter syntax. FastAPI also uses {id}; Express uses :id.

Routes are nested under /api using .nest("/api", api_router). This is equivalent to Express's app.use('/api', router) or Spring's @RequestMapping("/api") on a controller class.


Handlers and extractors

A handler is just an async fn. Its parameters are extractors — types that know how to pull information out of an incoming HTTP request.

#![allow(unused)]
fn main() {
// From crates/tack-api/src/handlers/items.rs

pub async fn create_item(
    State(state): State<AppState>,       // shared application state
    Path(project_id): Path<Uuid>,        // URL path parameter
    Json(input): Json<CreateItem>,       // request body, deserialized from JSON
) -> ApiResult<Json<serde_json::Value>> {
    // ...
}
}

Compare to the same handler in other frameworks:

Express:

async function createItem(req, res) {
    const state = req.app.locals;           // State
    const project_id = req.params.id;      // Path
    const input = req.body;                // Json (after body-parser middleware)
}

FastAPI:

async def create_item(
    project_id: UUID,                      # Path (from route)
    input: CreateItem,                     # Json (Pydantic model, auto-validated)
    db: Session = Depends(get_db),        # State (dependency injection)
):

Spring MVC:

@PostMapping("/projects/{id}/items")
public ResponseEntity<Item> createItem(
    @PathVariable UUID projectId,          // Path
    @RequestBody CreateItem input,         // Json
    // State typically injected via @Autowired at class level
) { }

The key Axum extractors you will see throughout Tack:

ExtractorWhat it extractsAnalogy
State(state): State<AppState>Shared application stateExpress req.app.locals, FastAPI Depends(get_state)
Path(id): Path<Uuid>URL path segment, parsedExpress req.params.id, FastAPI path parameter
Json(body): Json<CreateItem>Request body, deserialized from JSONExpress req.body, FastAPI @RequestBody
Query(params): Query<ItemFilter>Query string, deserializedExpress req.query, FastAPI query parameters

If extraction fails (e.g. invalid JSON, missing required path parameter, body exceeds size limit), Axum rejects the request with 400 or 422 automatically before your handler code runs.


Responses

Handlers return impl IntoResponse — anything that implements the IntoResponse trait. The most common patterns:

#![allow(unused)]
fn main() {
// 200 OK with JSON body
Ok(Json(item))

// 201 Created with JSON body
Ok((StatusCode::CREATED, Json(item)))

// 204 No Content
Ok(StatusCode::NO_CONTENT)

// Error (handled by ApiError's IntoResponse implementation)
Err(ApiError::NotFound("Item 42 not found".into()))
}

ApiResult<T> is a type alias for Result<T, ApiError>. The ApiError type implements IntoResponse, which maps each error variant to the correct HTTP status:

#![allow(unused)]
fn main() {
// From crates/tack-api/src/error.rs

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, message) = match &self {
            ApiError::NotFound(msg)  => (StatusCode::NOT_FOUND, msg.clone()),
            ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
            ApiError::Core(err) => match err {
                CoreError::ItemNotFound(_)       => (StatusCode::NOT_FOUND, ...),
                CoreError::InvalidTransition {..} => (StatusCode::BAD_REQUEST, ...),
                CoreError::WipLimitExceeded {..}  => (StatusCode::BAD_REQUEST, ...),
                // ...
            },
            ApiError::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, ...),
        };

        let body = json!({ "error": { "status": status.as_u16(), "message": message } });
        (status, axum::Json(body)).into_response()
    }
}
}

This is the pattern that makes handlers clean: each handler returns ? on every fallible call, and the single IntoResponse impl handles the translation from domain errors to HTTP status codes.


Shared state — AppState

#![allow(unused)]
fn main() {
// From crates/tack-api/src/router.rs

#[derive(Clone)]
pub struct AppState {
    pub repo: Repository,
    pub config: AppConfig,
    pub workspace_id: Uuid,
    pub broadcast_tx: broadcast::Sender<BoardEvent>,
}
}

AppState is the equivalent of Express's app.locals (or a FastAPI app.state). It contains everything handlers need that is not in the request itself: the database connection pool, application config, and the WebSocket broadcast sender.

It is registered once at startup and then cloned into every handler call:

#![allow(unused)]
fn main() {
// main.rs — wire up state at startup:
let state = AppState { repo, config, workspace_id, broadcast_tx };
let app = build_router(state);

// build_router — attach state to the router:
outer.with_state(state)
}

Because AppState derives Clone, Axum clones it cheaply for each request. SqlitePool and broadcast::Sender are internally reference-counted, so cloning them is cheap (just incrementing a reference count) — they do not copy the underlying connection pool or channel.


Middleware

Axum uses Tower's middleware model: a stack of layers wrapping the router. Conceptually identical to Express middleware, Spring HandlerInterceptor, or FastAPI middleware — code that runs on every request before and/or after the handler.

Tack's middleware stack (bottom of build_router in router.rs):

#![allow(unused)]
fn main() {
outer
    .layer(DefaultBodyLimit::max(config.max_body_size_bytes))
    .layer(SetResponseHeaderLayer::overriding(/* security headers */))
    .layer(cors)
    .layer(TraceLayer::new_for_http().make_span_with(|req| {
        tracing::info_span!(
            "http_request",
            method = %req.method(),
            uri = %req.uri(),
        )
    }))
    .with_state(state)
}

Layers apply from bottom to top (the last .layer() call runs first on each request). In order of execution:

  1. TraceLayer — creates a tracing span per request (structured logging + timing)
  2. CorsLayer — handles CORS headers and preflight OPTIONS requests
  3. SetResponseHeaderLayer — appends security headers (X-Frame-Options, X-Content-Type-Options, etc.)
  4. DefaultBodyLimit — rejects request bodies exceeding max_body_size_bytes

The auth middleware (require_token) is applied specifically to the /api sub-router before the global layers, so the health/debug endpoints remain public:

#![allow(unused)]
fn main() {
let api = Router::new()
    .route("/health", get(debug::health))
    // ...all protected routes...
    .layer(middleware::from_fn_with_state(state.clone(), require_token));
}

middleware::from_fn_with_state creates a Tower middleware from a plain async function, with access to AppState. The require_token function reads the Authorization: Bearer <token> header and returns 401 if it is missing or wrong.


Putting it together — a full request lifecycle

Here is what happens when PATCH /api/items/{id} is called:

  1. TraceLayer creates a span: http_request{method=PATCH, uri=/api/items/abc-123}
  2. CorsLayer checks the Origin header; adds CORS response headers
  3. DefaultBodyLimit checks the body size; rejects if over limit
  4. require_token checks Authorization: Bearer ...; returns 401 if invalid
  5. Axum router matches /api/items/{id} → patch(items::update_item)
  6. Extractors run: State clones AppState; Path parses the UUID; Json deserializes the body into UpdateItem; if any extractor fails, request is rejected before reaching handler
  7. update_item handler runs: fetches old item, validates workflow transition, checks WIP limit, calls repo.update_item(), broadcasts WebSocket event, triggers parent auto-complete
  8. Handler returns Ok(Json(item)) → Axum serializes to JSON, sets Content-Type: application/json, returns 200
  9. If any step returned Err(ApiError::...), IntoResponse converts it to the appropriate status + JSON error body

The Data Layer (sqlx & Repository Pattern)

Tack's data layer looks nothing like Sequelize, SQLAlchemy, Hibernate, or ActiveRecord. It uses sqlx, which is not an ORM. This chapter explains how it works and why it is structured the way it is.


sqlx is not an ORM

Traditional ORMs generate SQL from model definitions. sqlx goes the other direction: you write SQL, sqlx validates it and generates the type mapping.

#![allow(unused)]
fn main() {
// SQLAlchemy (Python) — ORM generates the SQL:
items = session.query(Item).filter(Item.project_id == project_id).all()

// Sequelize (Node) — ORM generates the SQL:
const items = await Item.findAll({ where: { projectId } })

// sqlx (Rust) — you write the SQL; sqlx validates it at compile time:
let rows = sqlx::query_as::<_, ItemRow>(
    "SELECT id, project_id, title, status FROM items WHERE project_id = ?"
)
.bind(project_id.to_string())
.fetch_all(pool)
.await?;
}

The key feature: sqlx checks your SQL against a real database at compile time. If the column does not exist, the type is wrong, or the parameter count is off, it will not compile. This is enforced by a cached database schema checked in at .sqlx/. If you add a column to a migration and forget to update a query that reads that table, cargo build fails with a clear error before you can ever run the broken code.

This comes at a cost: you write more SQL. The payoff: you have full SQL power — CTEs, WITH RECURSIVE, json_each, FTS5 MATCH, window functions — without fighting an ORM's abstraction layer.


The Repository pattern

All database operations live in crates/tack-db/src/repo/. The structure:

crates/tack-db/src/
├── lib.rs           # declares Repository struct; re-exports all repo modules
├── migrations.rs    # 18 migrations embedded as strings
└── repo/
    ├── items.rs
    ├── projects.rs
    ├── sprints.rs
    ├── boards.rs
    ├── comments.rs
    ├── dependencies.rs
    ├── roles.rs
    ├── attachments.rs
    └── ...

The Repository struct is a thin wrapper around SqlitePool:

#![allow(unused)]
fn main() {
pub struct Repository {
    pool: SqlitePool,
}

impl Repository {
    pub fn new(pool: SqlitePool) -> Self { Self { pool } }
    pub fn pool(&self) -> &SqlitePool { &self.pool }
}
}

All database functions are methods on Repository, defined in impl Repository blocks spread across the repo modules. There is no object instance with mutable state — the pool handles connection management internally.

Compare to patterns you know:

  • Java/Spring: this is the DAO layer. Each repo/ module is a DAO class. The key difference: there are no singleton beans, no @Repository annotation, no dependency injection container — just methods on a struct.
  • Django: equivalent to Manager methods on a model (Item.objects.filter(...)), but in a separate layer instead of on the model class.
  • Laravel: equivalent to a Repository class that the service layer injects.

A typical query

#![allow(unused)]
fn main() {
// From crates/tack-db/src/repo/items.rs

pub async fn get_item(&self, id: Uuid) -> Result<Option<Item>, sqlx::Error> {
    let row = sqlx::query_as::<_, ItemRow>(
        "SELECT id, project_id, parent_id, title, description, item_type,
                status, priority, estimate, estimate_unit, tags, sort_order,
                sprint_id, assignee, due_date, started_at, completed_at,
                created_at, updated_at
         FROM items WHERE id = ?"
    )
    .bind(id.to_string())
    .fetch_optional(self.pool())
    .await?;

    Ok(row.map(|r| r.into_item()))
}
}

Breaking this down:

  • sqlx::query_as::<_, ItemRow>(sql) — execute SQL and map each row to ItemRow (a raw DB struct with string fields)
  • .bind(id.to_string()) — bind the ? placeholder; UUIDs are stored as TEXT in SQLite, so .to_string() converts before binding
  • fetch_optional — returns Option<ItemRow>: Some(row) if found, None if not, Err if the query itself fails
  • .await? — await the async operation; ? propagates sqlx::Error up to the caller
  • .map(|r| r.into_item()) — convert the raw DB row into the domain Item struct

The three fetch methods:

MethodReturnsUse when
fetch_allVec<T>Listing queries
fetch_oneTExactly one row expected; errors if missing
fetch_optionalOption<T>Zero or one row; missing is a valid state

UUIDs as TEXT: SQLite has no native UUID type. Tack stores them as TEXT (id.to_string() on write, Uuid::parse_str(&row.id) on read). The conversion is handled in the ItemRow::into_item() method that converts raw row strings into typed domain structs.


Dynamic query building

Some queries build SQL dynamically based on optional filters. The list_items query is a good example:

#![allow(unused)]
fn main() {
pub async fn list_items(
    &self,
    project_id: Uuid,
    filter: &ItemFilter,
) -> Result<Vec<Item>, sqlx::Error> {
    let mut query = String::from(
        "SELECT ... FROM items WHERE project_id = ?"
    );
    let mut binds: Vec<String> = vec![project_id.to_string()];

    if let Some(ref status) = filter.status {
        query.push_str(" AND status = ?");
        binds.push(status.clone());
    }
    if let Some(ref priority) = filter.priority {
        query.push_str(" AND priority = ?");
        binds.push(priority.to_string());
    }
    // ... more optional filters ...

    let per_page = filter.per_page.unwrap_or(100).min(500) as i64;
    let page = filter.page.unwrap_or(1).max(1) as i64;
    let offset = (page - 1) * per_page;
    query.push_str(&format!(" LIMIT {per_page} OFFSET {offset}"));

    let mut q = sqlx::query_as::<_, ItemRow>(&query);
    for bind in &binds {
        q = q.bind(bind);
    }
    let rows = q.fetch_all(self.pool()).await?;
    Ok(rows.into_iter().map(|r| r.into_item()).collect())
}
}

This dynamic approach is necessary because sqlx's compile-time checking only works for static string literals. For dynamic queries you build the SQL string at runtime and fall back to runtime checking. The tradeoff is acceptable here — the base SQL is always fixed; only the WHERE clauses vary.


Migrations

Tack has 18 migrations, embedded as constant string arrays in crates/tack-db/src/migrations.rs. They run automatically on server startup via migrations::run_all(&pool).

#![allow(unused)]
fn main() {
// From crates/tack-db/src/migrations.rs

pub async fn run_all(pool: &SqlitePool) -> Result<(), sqlx::Error> {
    // Create the tracking table if it does not exist
    sqlx::query(
        "CREATE TABLE IF NOT EXISTS _migrations (
            id INTEGER PRIMARY KEY,
            name TEXT NOT NULL UNIQUE,
            applied_at TEXT NOT NULL DEFAULT (datetime('now'))
        )"
    )
    .execute(pool).await?;

    let migrations: Vec<(&str, &[&str])> = vec![
        ("001_workspaces",       &MIGRATION_001[..]),
        ("002_projects",         &MIGRATION_002[..]),
        ("003_sprints",          &MIGRATION_003_SPRINTS[..]),
        ("004_items",            &MIGRATION_004_ITEMS[..]),
        ("005_dependencies",     &MIGRATION_005[..]),
        // ...
        ("010_fts",              &MIGRATION_010[..]),  // FTS5 virtual table + triggers
        // ...
        ("016_perf_indexes",     &MIGRATION_016[..]),
    ];

    for (name, statements) in migrations {
        let already_applied = /* check _migrations table */;
        if !already_applied {
            for statement in statements {
                sqlx::query(statement).execute(pool).await?;
            }
            // Record as applied
        }
    }
}
}

This is exactly the same concept as:

  • Django's python manage.py migrate (tracks in django_migrations)
  • Flyway / Liquibase (tracks in flyway_schema_history)
  • Knex / node-pg-migrate (tracks in knex_migrations)

The tracking table is _migrations (not __django_migrations, but same idea). If a migration name is in the table, it is skipped. This makes startup idempotent — safe to call on every startup.

Each migration is an array of SQL statements because SQLite does not support multi-statement strings in all contexts. Migration 010 creates the FTS5 full-text search table and three triggers:

-- items_fts is a virtual table — SQLite handles the inverted index internally
CREATE VIRTUAL TABLE IF NOT EXISTS items_fts
USING fts5(title, description, tags, content='items', content_rowid='rowid');

-- Triggers keep the FTS index in sync with the items table
CREATE TRIGGER IF NOT EXISTS items_fts_insert
AFTER INSERT ON items BEGIN
  INSERT INTO items_fts(rowid, title, description, tags)
  VALUES (new.rowid, new.title, new.description, new.tags);
END;

The FTS search query that uses this:

#![allow(unused)]
fn main() {
// From repo/items.rs
"SELECT i.* FROM items i
 JOIN items_fts fts ON i.rowid = fts.rowid
 WHERE i.project_id = ? AND items_fts MATCH ?
 ORDER BY rank
 LIMIT 50"
}

MATCH is FTS5 syntax; rank orders by relevance. This is pure SQLite FTS5 — no search library needed.


JSON fields in SQLite

Some columns store structured data as JSON text. The main cases in Tack:

  • projects.workflow — a WorkflowConfig struct, serialized to JSON
  • projects.vocabulary — a HashMap<String, String>, serialized to JSON
  • items.tags — a Vec<String>, serialized to JSON

SQLite stores these as TEXT. sqlx reads them back as String, and the row conversion methods deserialize them:

#![allow(unused)]
fn main() {
// In ItemRow::into_item():
let tags: Vec<String> = serde_json::from_str(&row.tags).unwrap_or_default();
}

For projects, the workflow and vocabulary are deserialized from the TEXT column into typed structs:

#![allow(unused)]
fn main() {
let workflow: WorkflowConfig = serde_json::from_str(&row.workflow)
    .map_err(|e| sqlx::Error::Decode(Box::new(e)))?;
}

If the JSON is malformed (e.g. a migration corrupted it), this returns a sqlx::Error::Decode — surfaced as a 500 from the API. In practice this should not happen because writes go through serde_json::to_string which is infallible for well-typed structs.


Auto-complete: check_and_update_parent_status

When an item moves to a Done status, Tack automatically checks if all siblings are also done. If so, the parent is updated to Done too. This logic lives in the data layer because it requires querying sibling state:

#![allow(unused)]
fn main() {
// From repo/items.rs

pub async fn siblings_all_done(
    &self,
    parent_id: Uuid,
    done_status: &str,
) -> Result<bool, sqlx::Error> {
    let total: i64 = sqlx::query_scalar(
        "SELECT COUNT(*) FROM items WHERE parent_id = ?"
    )
    .bind(parent_id.to_string())
    .fetch_one(self.pool()).await?;

    if total == 0 { return Ok(false); }

    let not_done: i64 = sqlx::query_scalar(
        "SELECT COUNT(*) FROM items WHERE parent_id = ? AND status != ?"
    )
    .bind(parent_id.to_string())
    .bind(done_status)
    .fetch_one(self.pool()).await?;

    Ok(not_done == 0)
}
}

The handler in items.rs calls this after every successful status update:

#![allow(unused)]
fn main() {
// After update_item succeeds:
if let Some(parent_id) = item.parent_id
    && item.status != old_status
    && proj.workflow.is_done_status(&item.status)
    && let Ok(all_done) = state.repo.siblings_all_done(parent_id, done_status).await
    && all_done
{
    let _ = state.repo.check_and_update_parent_status(parent_id, done_status).await;
}
}

The let _ and the fact that errors are ignored (let _ = ...) is intentional — this is a best-effort feature. If the parent update fails for any reason, the primary operation (the child's status change) still succeeds. That is the correct tradeoff for an auto-complete feature.


Testing with in-memory SQLite

Because every repository function takes a pool as input (via &self where self contains the pool), you can swap in an in-memory SQLite database for tests:

#![allow(unused)]
fn main() {
// In test code:
let pool = sqlx::SqlitePool::connect("sqlite::memory:").await?;
migrations::run_all(&pool).await?;
let repo = Repository::new(pool);

// Now use repo exactly as production code does — no mocking needed
let item = repo.create_item(project_id, "todo", input).await?;
assert_eq!(item.title, "My test item");
}

There is no mocking framework involved. The in-memory database runs migrations, creates real tables, and the repository code runs against it unchanged. This makes integration tests both simple and high-confidence.

SolidJS for Frontend Developers

Tack's frontend uses SolidJS. If you know React, the JSX syntax will feel familiar — but the execution model is fundamentally different. This chapter explains what changes and why it matters when reading Tack code.


SolidJS is not React

The surface similarity is deceptive. React and SolidJS both use JSX and look similar in small examples. But:

  • React re-renders components on state changes — the component function runs again, producing a new virtual DOM, which is diffed against the previous one.
  • SolidJS runs component functions exactly once, on mount. DOM updates are surgical: only the specific DOM node that reads a changed value updates.

The mental model shift is from "what should render?" to "what will update?". Components are not render functions — they are setup functions that establish reactive bindings and return a DOM structure. After mount, the component function never runs again.

This has practical consequences for reading Tack code:

  • Variables declared inside a component do not "reset" on re-render — there is no re-render
  • Reactive values are signals, not state variables — you call them as functions to read them
  • Control flow uses dedicated components, not ternaries and .map()

Signals — useState, but reactive

// React
const [count, setCount] = useState(0)
// Reading count in JSX triggers a re-render of the whole component
<div>{count}</div>

// SolidJS
const [count, setCount] = createSignal(0)
// Reading count() in JSX creates a reactive binding at that exact DOM node
<div>{count()}</div>  // only this text node updates when count changes

The key differences:

  1. count in SolidJS is a getter function — you call it with () to read the current value. This is how SolidJS's reactivity system tracks which signals you read — it wraps reads in a subscription.
  2. When setCount(1) is called, SolidJS does not re-run the component function. It directly updates the DOM nodes that read count() and nothing else.

You will see this throughout Tack's frontend. From Board.tsx:

const [isDragging, setIsDragging] = createSignal(false)

// In JSX:
classList={{ 'opacity-40': isDragging() }}

isDragging() — the parens are always there. If you see a function call in JSX without obvious arguments, it is almost certainly reading a signal.


createResource — async data fetching

createResource is SolidJS's built-in primitive for async data. It is similar to React Query's useQuery, but built into the framework:

// From Projects.tsx

const [projects, { refetch }] = createResource(() => api.projects.list())
  • projects() returns undefined while loading, the data when loaded
  • projects.loading is true while the fetch is in progress
  • projects.error holds the error if the fetch failed
  • refetch() triggers a new fetch

In projectContext.tsx, the active project is loaded once and shared across all views:

const [project, { refetch }] = createResource(
    projectId,                               // source signal — re-fetches when projectId changes
    (id) => (id ? api.projects.get(id) : null),
)

The first argument is a source signal. When projectId() changes (because the user navigated to a different project), the resource automatically refetches with the new ID. This replaces the useEffect(() => { fetch() }, [projectId]) pattern from React.


createMemo — derived state with no dependency array

// React — you must declare dependencies:
const doubled = useMemo(() => count * 2, [count])

// SolidJS — dependencies are tracked automatically:
const doubled = createMemo(() => count() * 2)

SolidJS tracks which signals are read inside createMemo. When any of them change, the memo recomputes. No dependency array to forget to update.

createMemo returns a read-only signal (called with doubled()). It caches the result and only recomputes when its dependencies change — equivalent to React's useMemo in behavior, but without manual dependency management.


createEffect — side effects with auto-tracking

// React — must list dependencies:
useEffect(() => {
    document.title = `${count} items`
}, [count])

// SolidJS — tracks automatically:
createEffect(() => {
    document.title = `${count()} items`
})

Like createMemo, effects track their signal reads automatically. The effect re-runs whenever any signal read inside it changes.

One important difference from React's useEffect: SolidJS effects run synchronously after DOM updates, not asynchronously in a microtask. In most cases this distinction does not matter, but it means you will not see the "stale closure" bugs that are common in React.


Control flow primitives — Show and For

SolidJS does not use JavaScript's native ternary operator or .map() for control flow in JSX. It uses components:

Conditional rendering — <Show>:

// React
{items.length > 0 ? <ItemList items={items} /> : <EmptyState />}

// SolidJS (from Projects.tsx)
<Show
    when={projects() && projects()!.length > 0}
    fallback={<div>No projects yet...</div>}
>
    <div class="grid grid-cols-3 gap-6">
        {/* children render only when `when` is truthy */}
    </div>
</Show>

<Show> is cleaner than a ternary for the fallback case and also avoids React's famous 0 rendering bug (where {count && <Component />} renders 0 when count is 0).

List rendering — <For>:

// React
{items.map(item => <ItemCard key={item.id} item={item} />)}

// SolidJS (from Projects.tsx)
<For each={projects()}>
    {(project) => (
        <a href={`/projects/${project.id}/board`}>
            {project.name}
        </a>
    )}
</For>

<For> is keyed by identity (the object reference). When the projects() array updates, only the DOM nodes for items that actually changed are updated. It is not a re-render of the whole list — it is a targeted reconciliation.

Loading states — the fallback prop:

// From Projects.tsx

<Show when={!projects.loading} fallback={<ProjectsGridSkeleton />}>
    <Show when={projects()?.length > 0} fallback={<EmptyState />}>
        <For each={projects()}>
            {(project) => <ProjectCard project={project} />}
        </For>
    </Show>
</Show>

Three states, three <Show> components — no if/else chains in the JSX.


Context — same idea, signals inside

SolidJS context works the same way as React context conceptually:

// From frontend/src/shared/state/projectContext.tsx

const ProjectContext = createContext<ProjectContextValue>()

export const ProjectProvider: ParentComponent = (props) => {
    const params = useParams()
    const projectId = () => params.id as string | undefined

    const [project, { refetch }] = createResource(
        projectId,
        (id) => (id ? api.projects.get(id) : null),
    )

    const value: ProjectContextValue = {
        projectId,
        project,
        workflow:   () => project()?.workflow,
        vocabulary: () => project()?.vocabulary,
        refetch:    () => { void refetch() },
    }

    return (
        <ProjectContext.Provider value={value}>
            {props.children}
        </ProjectContext.Provider>
    )
}

export function useProject(): ProjectContextValue {
    const ctx = useContext(ProjectContext)
    if (!ctx) throw new Error('useProject must be used within a ProjectProvider')
    return ctx
}

Notice that workflow and vocabulary are accessor functions — () => project()?.workflow. When a consumer calls workflow(), they are reading project() inside a reactive context, so any change to the project resource will automatically propagate to components that call workflow().


The vocabulary hook

The useVocab() hook builds on useProject() to provide reactive label translation:

// From frontend/src/shared/vocab/useVocab.ts

export function useVocab(): Vocab {
    const { vocabulary } = useProject()
    return {
        t: (key: string) => resolveLabel(vocabulary(), key),
        // ...
    }
}

Usage in a component:

const vocab = useVocab()

// In JSX:
<label>{vocab.t('task')}</label>
// Renders "Task" for software projects, "Work Order" for construction, etc.
// Updates automatically the moment the project vocabulary is saved in Settings —
// no page reload, no manual refresh.

When vocabulary() changes (after a settings save), every component that calls vocab.t('...') inside a reactive context (JSX, createMemo, createEffect) will update automatically. This is the SolidJS reactivity model in practice — you did not write any subscription or effect code. The signal tracking handled it.


Routing with @solidjs/router

The mental model from React Router v6 transfers directly:

// Route definitions (from src/app/routes.tsx)
<Route path="/projects" component={Projects} />
<Route path="/projects/:id/board" component={Board} />
<Route path="/projects/:id/settings" component={Settings} />

Reading route parameters:

// useParams returns a reactive object — reading params.id inside JSX is reactive
const params = useParams()
const projectId = () => params.id  // signal-like accessor

// Equivalent React Router:
const { id: projectId } = useParams()

Programmatic navigation:

const navigate = useNavigate()
navigate(`/projects/${project.id}/board`)

// Equivalent React Router:
const navigate = useNavigate()
navigate(`/projects/${project.id}/board`)

Linking:

// SolidJS
<A href="/projects">Projects</A>

// React Router
<Link to="/projects">Projects</Link>

What to keep in mind when reading Tack code

  • items() with parens — reading a signal. The value changes reactively; no re-render needed.
  • Component functions run once — anything in a component body runs at mount time, not on every update. If you want something to run reactively, it belongs in createEffect, createMemo, or JSX.
  • <Show>, <For>, <Switch> are the primary control flow constructs — not ternaries or .map().
  • createResource is the data-fetching primitive — covers loading/error/data states without a library.
  • useProject() / useVocab() are signal-based context hooks — any component that reads from them updates automatically when the project data changes.
  • No key prop on <For> items — SolidJS tracks by identity, not by a key string. If you see <For each={...}>, the identity tracking is implicit.

Roadmap

This file records intent, not status. What shipped is in CHANGELOG.md and the commit history; closed phases are archived under docs/closed-cycles/boards/.

Tack is delivered through Phase 64. That covers the project-management core, the harness-agnostic runner fleet, the single-binary embedded runner, adoption and distribution, agent onboarding and provider choice, the desktop app with its background service, and the codebase cleanup that retired the Docket control-plane bridge (ControlPlane, tack orch, the Fleet/Approvals/Economics/Provision screens).

Phase 64 closed on 2026-09-19. Its plan and its chapter of this file are archived; what it left open is in Phase 65.

Phase 66 closed on 2026-10-05 and ships as v0.1.0-beta.10; what it left open and what it parked are in its plan, docs/plans/phase-66.md. Phase 65's plan holds the rest of its Wave 0, the maintainer's: docs/plans/phase-65.md. It folds together what the roadmap, the ADRs and the harness plan still owed — the release tag and docs/LAUNCH-CHECKLIST.md, inbound GitHub sync, tack start, and the harness upgrades — as one ordered sequence of tasks in four waves, with a "Decided" table so no task re-opens a settled decision and a "Parked" list so nothing has to be re-inventoried.


Phase 65 — the release, and every open item in one sequence

Status: open 2026-09-20; Waves 1–4 landed 2026-09-21 and v0.1.0-beta.9 shipped the same day — the first tag with the four-harness fleet and the desktop bundles (.AppImage, .deb, two .dmg, .msi). What remains of Wave 0 is the publish list and the platforms this machine cannot verify. Plan: docs/plans/phase-65.md.

WaveWhat landsWho
0Ruleset required checks, branch and build-dir cleanup, the Dependabot decision, the repository description, the v0.1.0-beta.9 tag, the publish list, one macOS and one Windows install, the signing decision.the user; refused to agents or costs money
1Measurements of codex, opencode and docket's contract, each a captured fixture; tack start / tack open; inbound GitHub issue state by polling.Sonnet agents
2The capture cap leaves the harness descriptor; opencode's served model; GitHub comments both ways.Sonnet agents
3codex reads usage and the served model, then applies the permission policy; opencode attempts share one package cache; per-project GitHub token and manual issue link.Sonnet agents
4A run that asks before it acts on codex and opencode where the measurement allows it; docket cancel, artifacts and asking once docket ships harness-v1.1.Sonnet agents

What Waves 1–4 build ships as v0.1.0-beta.9. Nothing in the phase waits on a decision: the two it needed — inbound sync polls rather than receiving webhooks; opencode attempts share a package cache and nothing else — are taken in the plan and are one line each to reverse.


Phase 66 — close the Level 3 loop: evidence, briefs, escalation packs, merge-readiness

Status: closed 2026-10-05; every task in the table below is built and merged, and ships as v0.1.0-beta.10. What is still open is listed under the table. Plan: docs/plans/phase-66.md. ADRs: 0069 (the brief is an entity on the item and travels), 0070 (docket contract 1.1, negotiated at boot; cancel becomes per-harness evidence), 0071 (evidence before deletion, the verifier boundary, the pushed branch and its pull request), 0072 (the "Run with agent" flow).

After this phase every attempt leaves a patch, both commits and a file list; an item can carry a typed brief that reaches the harness and an independent verifier; a question arrives as a pack with options and a recommendation; a verifier's merge-readiness pack is rendered and accepted or rejected with a reason; a branch is pushed and its pull request followed to merged, closed or reverted; and one page shows escalation rate, human minutes, the verification tax in tokens and the acceptance rate. The verifier itself is a separate program Tack never contains.

The interface comes first. The "Run with agent" dialog shows the whole flow from the second wave on — who runs it, what it gets, how far it may go, what happens after — and a step whose integration has not landed is shown disabled with the reason, never hidden.

docket is a moving target by design. Tack negotiates the harness contract and probes each optional flag when the runner starts, so one Tack build works with docket 0.2.0-beta.3, the coming 0.2.0-beta.4 and what follows; each docket link below starts when docket's own board merges the card it needs, not when docket cuts a release.

WaveWhat landsWho
0Decided 2026-10-03.the maintainer
1Evidence captured before the workspace is deleted; the phase's four migrations; status_map_policy_id moves the item; the mrp-v1 and brief-v1 contracts; each harness's tool list, measured; the README leads with the desktop app.Sonnet and Haiku agents
2The whole flow in the dialog, deferred steps disabled; the brief's routes and export; the consultation pack; the verifier step behind [verify].Sonnet agents
3The brief travels to the harness and the evidence; the brief editor; the merge-readiness review record; the pack in the runner and the inbox.Sonnet agents
4–5The merge-readiness panel; the runner pushes the branch behind [git]; the pull request and its fate from the poll that already runs; verification and push become live controls in the dialog.Sonnet agents
6–7Factory metrics, endpoint then page; the pull-request badge.Sonnet, then Haiku
Adocket in five links, following docket's Phase 35: negotiation and contract 1.1; process events and a proven cancel; asking over stdin; limits, policy and files; the recipe flag.Sonnet; the last link Haiku

Left open by the work, each listed in the Agent Runners chapter's "Known gaps" or beside the feature it limits: a docket recipe run takes no token budget (docket refuses one with --recipe) and a recipe with an Implementer step fails for want of a verify command; a docket run that times out, rather than being cancelled, stops only its main process group; the branch push runs git in a workspace the agent could have reconfigured; on a docket that accepts a policy, a request must name docket's own tools; and tack runner doctor does not yet print each harness's artifacts and permission_policy lines. Opening a pull request as a choice in the "Run with agent" dialog is shown disabled: a pull request is opened for a pushed branch whose item is linked to a GitHub issue.


Archived

  • Phases 0–57 — the original engineering phases, the audit-driven cycle (26–32), the Agent-Factory Control Center (33–38), the Agnostic Control Plane (39–49), and the Harness-Agnostic Runner Fleet (50–57).
  • Phases 58–63 — standalone single-binary packaging, the first public release, agent onboarding & provider UX, the desktop app and background service, and human maintainability.

Contributing

See CONTRIBUTING.md for code style, PR process, and how to add new features. The Adding Features guide walks through the three most common extension patterns.