* fix(plugin): session start runs nothing; presence and the arm check wait for the first use of Pad (TASK-3970)
Dave: "the plugin should not be doing anything when a session is started."
session.start now only registers /pad-pane and restores the pane. It no
longer runs `pad session register` / `should-arm`, no longer spawns
pad-monitor.sh, and no longer starts the 5s refresh timer, which now runs
only while the pane is open.
wake() runs once per session, on the first pad Bash command, a Pad MCP tool
call, or opening the pane. The first pad command waits for registration
(never for should-arm), because register mints the worker id that leases key
on. A .pad.toml push.auto_arm opt-in is therefore read at first use.
Tests: session start runs no process and asks for no timer; non-pad Bash
wakes nothing; wake runs once; an MCP tool wakes it; registration happens
before the first pad command runs. Prose swept (skill, monitor script,
CLAUDE.md, registry comments); docs/plugin-releases.md gets the stuck-0.3.x
remedy.
Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
* fix(plugin): a pad command inside a shell string or after an assignment wakes the session (TASK-3970)
Hunter r1 F1: `bash -c "pad ..."`, `sh -c 'pad ...'` and `X=... pad` missed
the first-use match, so that call ran before presence was recorded. The
class now takes quotes and `=`; a false positive only wakes early.
Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
* fix(plugin): pad as a whole word, checked against a table of shapes (TASK-3970)
Round r2 found two more missed shapes (Codex: `\pad ...`; hunter: a bare
`pad` before a quote, `)` or `;`). Rather than add a third case, the match
is now `pad` as a whole word, not inside a longer name. watch.test.ts
tables 25 shapes that run pad and 8 that do not, and asserts that
registration ran before each matching command reached the tool
(Codex r2 note).
Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
* fix(plugin): a Pad MCP tool wakes the session whatever the server is called (TASK-3970)
Codex r3: the matcher required "pad" in the server part of the tool name,
so a Pad server registered under another name (work-tracker) was missed.
It now matches any mcp__<server>__pad_* tool. The tests table Pad tools
under four server names and four tools that are not Pad's, and add ./pad
and ../bin/pad to the Bash shapes.
Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
* fix(plugin): the pane refreshes after a Pad MCP edit under any server name (TASK-3970)
Hunter r4: the pane-refresh matcher still required "pad" in the server
name, so it disagreed with the wake matcher fixed in 25c39f2aa. Both now
take any server. The wake matcher also allows digits in the tool part.
New pane test.
Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
* docs(plugin): the file header describes session start and wake (TASK-3970)
Codex r5 nit: the header still said the mod only listens until /pad-pane
runs. Comment-only.
Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
46 KiB
Pad — Development Guide
What This Is
Pad is a project management tool for developers and AI agents. Single Go binary with embedded SvelteKit web UI, SQLite storage, and multi-agent skill support (Claude Code, Cursor, Windsurf, Codex, OpenCode, Copilot, Amazon Q, Junie).
Related repo: The marketing website (getpad.dev) lives at ../pad-web — a separate SvelteKit site deployed to Vercel.
Architecture
- Backend: Go (cmd/pad/main.go) → REST API (internal/server/) → SQLite (internal/store/)
- Frontend: SvelteKit 2 + Svelte 5 (web/src/) → static build embedded in Go binary
- Data model: Workspaces → Collections (typed with JSON schemas) → Items (structured fields + rich content)
- CLI: Cobra commands in cmd/pad/main.go, HTTP client in internal/cli/
- Agent skill: Single natural-language
/padskill in skills/pad/SKILL.md
Build & Install
make build # Build web UI + Go binary (./pad)
make install # Build, kill server, install to ~/.local/bin/pad, restart
make build-go # Build Go only (skip web — faster when only backend changes)
make test # Run Go tests
make web # Build web UI only
make dev-web # Run SvelteKit dev server (hot reload on :5173)
After making changes, always run make install to rebuild the binary, install it, and restart the server. The web UI at http://localhost:7777 will reflect the changes.
Quick iteration loop
- Backend only:
make install(skips web rebuild if no frontend changes — edit Makefile to usebuild-goinstead ofbuildin the install target) - Frontend only:
make web && make installor usemake dev-webfor hot reload during development - Full rebuild:
make install
Working in a git worktree
Agent sessions take a git worktree per task rather than sharing the main checkout (a shared checkout means a shared stash stack, branch state, and dirty files across sessions). Three rules keep web tooling working there:
-
Give each worktree its own
web/node_modules: runnpm ciin itsweb/. Do not symlink the main checkout's copy. It installs from the npm cache in about 5s warm, costs about 470 MB of disk per live worktree, and runssvelte-kit syncas a side effect. A symlink tests the worktree against whatever the MAIN checkout last installed.web/package-lock.jsonchanges every few days, and the main checkout is not kept current, so that is routinely the wrong dependency set: on day 86 (2026-10-05), worktrees borrowed Tiptap 3.31.3 after main had moved to 3.31.4, plus a materializer bundle built from it, and the corpus tests failed for reasons that had nothing to do with the change. SvelteKit 3 also writes its generated tsconfig insidenode_moduleswith relative paths, so through a symlink a worktree's type check would silently check the main checkout's tree (TASK-3423). (Dave's ruling, 2026-10-06. A pnpm-style shared store is the follow-up if disk becomes the constraint.) -
A fresh worktree has no
web/.svelte-kit(gitignored, generated). Runnpx svelte-kit syncinweb/before any vitest/vite command — ornpm run check, which syncs first. Without it, vitest fails withFailed to load tsconfig '.svelte-kit/tsconfig.json': Tsconfig not foundregardless of hownode_moduleswas set up. (This missing generated dir was historically misdiagnosed as a symlink problem —npm ci"fixed" it only because itspreparescript runssvelte-kit sync.) -
A fresh worktree has no
web/buildeither, and that one breaks the GO gates rather than the web ones.embed.godoes//go:embed all:web/build, somake testandmake lintfail withpattern all:web/build: no matching files foundbefore a single test runs —go build ./...dies first, two packages report[setup failed], and it reads as a broken tree rather than a missing generated directory.npx vite buildinweb/fixes it (make webwould too, and is FORBIDDEN here — see the next rule). It also has noweb/build-materializer(TASK-2198, gitignored, generated):embed.godoes//go:embed web/build-materializer/materializer.js, and a missing file failsgo build ./...the same way, withpattern web/build-materializer/materializer.js: no matching files found. Runnode scripts/build-materializer.mjsinweb/afternpx vite build(npm run builddoes both). It is built with esbuild, which is a devDependency of its own (vite 8 does not bring it), so aweb/node_modulesolder than the lock file that added it cannot build the bundle. Unlikeweb/build, a placeholder file here is not enough formake test:internal/materialize's tests run the real bundle. -
If you find a worktree whose
web/node_modulesis still a symlink (an older one), do not runnpm ciin it, including via make.npm ciwould delete through the symlink into the main checkout's tree and break every session that shares it, with a confusingvitest: not found.npm cilives in thewebtarget, so every target whose dependency chain reaches it is affected too: currentlyweb,build,install,serve,web-check, andcheck(viaweb-check). Replace the symlink first:rm web/node_modulesremoves the link only, then runnpm ci. In a worktree with its ownnode_modules, those targets are safe. -
make test-pgis safe to run from several worktrees at once, but NOT against itself in one worktree (TASK-2708; the second half measured on BUG-3000).TEST_PG_PROJECTis derived from the worktree's basename and a checksum of its absolute path, so two overlapping runs in the SAME worktree resolve to the SAME compose project: the secondup -d --waitattaches to the first run's container instead of creating its own, and the first run to finish executes the target's unconditionaldown -vand tears the database out from under the other. The survivor fails withdial tcp 127.0.0.1:<port>: connect: connection refusedwhile cloning the test database — which theTHE DATABASE DIED DURING THE RUNbanner reports correctly, but reads as evidence about the code to anyone who does not know a sibling run was up. Run it alone per worktree, and do not diagnose such a failure from surrounding connection-refused noise (a passing run carries ~41 redis connection-refused lines of its own); the failure TEXT is the evidence. It used to bind the Postgres test container to a fixed host port, so a second worktree failed withport is already allocatedand a stack orphaned by a removed worktree blocked the port for everyone. Docker now assigns the port and the Makefile reads it back, so each worktree gets its own container on its own port under its own compose project. If you have been starting a private container by hand to avoid the collision, you no longer need to. Two other things that target now does: it REFUSES to run, with aNO TESTS EXECUTEDbanner, when the database is unreachable —go testexiting 2 with zero FAIL lines had already been mistaken for a pass once — and it says so explicitly when the database dies mid-run, so the failures read as infrastructure rather than as evidence about the code. To reap a stack whose worktree was deleted before teardown:docker ps --filter name=padtest-lists them and the container name carries the compose project, sodocker compose -p <that-project> down -vreaps it from anywhere. From inside the worktree,make test-pg-projectprints the name andmake test-pg-downdoes it for you. (The project ispadtest-<basename>-<checksum-of-the-absolute-path>— NOT the bare directory name, which would collide between two checkouts sharing a basename.) -
Go build caches never go under
/tmp./tmpis a 7.3G tmpfs, so every byte written there is RAM. Two ad-hoc/tmp/docapp*-go-cachedirectories created by agent sessions held 3.6G between them and were what the harness's memory reaper was reacting to when it started killing background test runs. Use the default (~/.cache/go-build), or~/.cache/go-build-<worktree>on disk if you want a per-worktree cache to avoid lock contention on the shared one.GOTMPDIRalready points at~/.cache/go-tmpand stays there.
web/vitest.config.ts's server.fs.allow note covers the other worktree wrinkle (symlink realpaths vs the dev-server file-serving guard) and points back at this section.
Key Directories
cmd/pad/main.go — CLI entry point, all Cobra commands
internal/
server/ — HTTP API handlers, SSE, middleware
store/ — SQLite CRUD, migrations, FTS
models/ — Go types (Collection, Item, View, etc.)
items/ — Field validation against schemas
collections/ — Default definitions, workspace templates
cli/ — HTTP client, formatting helpers
events/ — EventBus for real-time SSE
config/ — Workspace detection, .pad.toml
diff/ — Version diff storage
webhooks/ — Webhook dispatcher with HMAC signing
email/ — Transactional email via Maileroo
links/ — Wiki-link parsing
web/src/
routes/ — SvelteKit pages
lib/api/client.ts — TypeScript API client
lib/types/index.ts — TypeScript types
lib/stores/ — Svelte 5 rune stores
lib/components/ — Reusable UI components
skills/pad/SKILL.md — Claude Code skill (embedded in binary)
API
REST API at /api/v1/. Before adding or changing an endpoint, a status or error code, or a request or response shape, read docs/api.md. It is the endpoint reference, moved out of this file verbatim (TASK-3485), and new endpoint notes go there, not here: accretion is how this file passed Claude Code's 150k-character limit.
What docs/api.md covers:
- The workspace 404 marker (
details.scope), and collection and item CRUD: list field filters; the write warnings (undeclared_fields,content_outcome,dropped_fields,options_added,pruned_pending_edits);content_state; reserved metadata keys andgithub_pr;refuse_undeclared_fields; pending-flush refusals; the append members; field usage andwarnings.orphaned; archived collections and restore. - Item copy and preflight, version restore and diff, collab set-aside rows, library activation and built-in origin, attachment attach, reminders, typed decisions, playbook match.
- Dashboard, activity, webhooks, item-scoped comment writes and tombstones, children, progress, links, search.
- SSE (
/events,/events/stream), the rule that every long-lived connection ends with its credential, the SSE and collab admission budgets, and the per-tab content-write ordering. - Members and app principals, workspace tabs, invitations, tutorials, UI dismissals.
- Auth (session, bootstrap, register, login, logout, me, password reset, local reset, tokens), admin settings, MCP over HTTP, the decision provider, and the agent bootstrap.
Authentication
User-based authentication with email/password. When no users exist (fresh install), everything works without auth until the instance is initialized with pad auth setup. Once the first admin exists, all API requests require authentication.
# First-time setup
pad auth setup # Create the first admin account on the server host
# Subsequent logins
pad auth login # Browser-based login (add -i for an email + password prompt)
pad auth whoami # Show current user
pad auth logout # Sign out
pad auth reset-password user@example.com # Recover a locked-out account (run ON THE SERVER HOST)
pad auth reset-password user@example.com --temp-password # ...set a temp password instead of a reset link
pad auth 2fa setup # Turn on 2FA: shows the otpauth URI + secret once, asks for a code, prints recovery codes once
pad auth 2fa disable # Turn off 2FA: asks for the password, or (no-password account) a code or recovery code; keeps this CLI signed in
# Credentials stored in ~/.pad/credentials.json (0600 permissions)
# CLI auto-attaches auth token to all API requests
Locked-out account recovery (self-host, no email)
When a self-hosted instance has no email provider, a forgotten password can't be reset by email. Two host-side recovery paths (both require shell access to the server — the same trust boundary as pad auth setup):
pad auth reset-password <email>— run it on the server host. It calls the loopback-only/api/v1/auth/local-resetendpoint (no login required — that's the point) and prints a single-use reset link. Add--temp-passwordto instead set a random temporary password printed to the terminal (headless boxes with no browser). The endpoint refuses proxied/remote requests and is disabled in cloud mode.- Server log — if a user submits the web
/forgot-passwordform on a non-cloud instance with no email, the server logs the reset path (slog.Info ... reset_path=/reset-password/<token>). Paste it after the instance's base URL to finish the reset by hand.
The web /forgot-password page detects email_configured == false (from the session payload) and shows the pad auth reset-password recovery instructions instead of a dead "we emailed you a link" message.
Code: internal/server/handlers_auth.go::handleLocalReset (loopback + non-cloud gates), cmd/pad/main.go::resetPasswordCmd, web/src/routes/forgot-password/+page.svelte.
After any workspace is created (via pad init or pad workspace init — note that pad auth setup only creates the admin account, not a workspace), the success output points new users at the canonical onboarding entry point. Open a fresh agent session in the workspace's directory and say:
/pad onboard
Every new workspace ships with the onboard playbook auto-activated (PLAN-1496 / TASK-1499 / TASK-1500). The playbook walks the agent through an interview that adapts the workspace's collections, conventions, roles, and seeded playbooks to match the actual project. Works regardless of which template the user picked (or no template — see the blank template).
The pre-PLAN-1496 design seeded IDEA-1 / PLAN-2 / TASK-3 / DOC-4 (and BACK-1 / FEAT-1 siblings for scrum/product) as first-person-future-self notes; that pattern was retired in TASK-1501 / TASK-1502 in favor of the playbook-driven flow.
Workspace membership
pad workspace members # List workspace members
pad workspace invite user@example.com # Invite: always a PENDING invitation, never a direct add (BUG-2136)
pad workspace invite user@example.com --role viewer # Invite with specific role
pad workspace invitations # Your pending invitations (with ids)
pad workspace accept <id> # Accept one of them
pad workspace decline <id> # Decline one (deletes it)
pad workspace join <code> # Accept a workspace invitation by code
An invitation becomes a membership only when the invitee accepts, from the CLI above, the web "+" menu (which shows a count badge), or the /join/<code> link. The link asks a signed-in visitor, including one who signs in from it, to Accept or Decline; only registering a NEW account through the code joins in one step. Inviting the same address again replaces the pending invitation.
Roles: owner (full access), editor (CRUD items), viewer (read-only).
Email (optional)
Transactional email via Maileroo. When configured, workspace invitations are sent by email. Without it, everything works via CLI-based join codes.
# Environment variables (or ~/.pad/config.toml)
PAD_MAILEROO_API_KEY=your-sending-key # Required to enable email
PAD_EMAIL_FROM=noreply@yourdomain.com # Sender address (default: noreply@getpad.dev)
PAD_EMAIL_FROM_NAME=Pad # Sender display name (default: Pad)
CLI
Items are referenced by issue ID (e.g. TASK-5, BUG-8) wherever a <ref> argument appears.
Slugs also work but issue IDs are preferred.
pad item create <collection> "title" [--status X] [--priority X] [--parent REF]
pad item list [collection] [--status X] [--parent REF] [--all]
pad item show <ref> # e.g. pad item show TASK-5
pad item update <ref> [--status X] [--priority X]
pad item delete <ref>
pad item move <ref> <target-collection>
# Collection change WITHIN a workspace (cross-workspace is `item copy`).
# Field values the target schema has no home for are dropped — and since
# BUG-2674 the move REPORTS them, in its activity entry's `dropped_fields`
# and in the item timeline. System metadata (implementation_notes,
# decision_log, github_pr, convention) always survives a move; it used to
# be destroyed silently.
# RELATION fields (TASK-2878): a carried value is resolved against the
# workspace, so a valid relation SURVIVES a move and only an unresolvable
# one is dropped (and reported). A `--field` OVERRIDE naming a relation is
# a write and is REFUSED if it does not name a live item in the collection
# that field declares.
# A `--field` OVERRIDE naming a field the TARGET collection does not declare
# is REFUSED (400 malformed_override, BUG-2379), as `item copy` always did.
# COMPUTED and UNIQUE target fields (BUG-2367, same on copy): a value is never
# carried into a computed field; a carried value another item in the target
# already holds on a unique_scope field is DROPPED and printed as
# ` dropped: <field> "<value>" is taken by <REF>` (the holder is named only
# when you may see it); a `--field` value that collides is refused 409.
# STATE CHANGE (BUG-2367 item 4): a move that would change the item between
# open, done and abandoned (e.g. a done task landing on ideas' default `new`)
# is REFUSED unless you name the done field; the CLI prints the exact
# `--field status=<a|b|c>` hint. Two values that both mean done carry.
pad item copy <ref> --to-workspace <slug> --collection <slug> [--dry-run] [--archive-source] [--field k=v]
# Cross-workspace copy; --archive-source makes it a move.
# --dry-run previews the field mapping + warnings.
# Refuses rather than guessing when a destination field needs a value,
# and NEVER retries the mutating call (no idempotency key — PLAN-2357 DR-13).
# Content semantics: markdown is copied verbatim except `pad-attachment:`
# refs that resolve to a LIVE attachment in the SOURCE workspace — those are
# repointed at the clones (+ variants). Foreign / soft-deleted / dangling ids
# are left literal and counted as unresolvable, never cloned.
# `[[wiki-links]]` are NOT rewritten — they re-resolve in the DESTINATION,
# so a link can silently retarget to a different item or break;
# `[[workspace::REF]]` stays a genuine cross-workspace reference.
# The web dialog (item pane ⋯ → "Copy or move to workspace…") says the same.
# sort_order does NOT carry: the copy is created at 0, like any new item, since
# a place in the source lane means nothing in the destination's (TASK-3525).
# System metadata (BUG-2674): implementation_notes and decision_log CARRY —
# they describe the item's own history and are true wherever it lands.
# github_pr does NOT carry across workspaces: it names the SOURCE project's
# repo, so on the copy it would render a live PR link about a project the
# destination may have nothing to do with. It is reported in the dropped
# bucket as `referent_not_portable`, and DOES carry on a same-workspace
# move/copy, where the repo context is unchanged.
# RELATION fields (TASK-2878): every CARRIED relation value is dropped on a
# cross-workspace copy without a lookup — it names a row in the SOURCE
# workspace, so nothing in the destination could make it true — and is
# reported as `referent_not_portable`, the same bucket github_pr uses. The
# preflight reports the identical drop; both doors call one store function,
# because they sit in different packages and that is how they drift.
# A supplied `--field` override naming a relation must resolve in the
# DESTINATION workspace or the copy is refused.
# None of these four keys
# (+ `convention`) is settable via `--field` on copy or move — they are
# written by `pad item note` / `pad item decide` / `pad github link`.
pad attachment attach <attachment-id> <item-ref> # bind an unattached ("upload -") attachment to an item, so a share of that item renders it
pad library diff <ref> # a built-in convention/playbook vs Pad's current library text: what the library changed,
# what you changed, and the settings an update would replace (TASK-3462). Read-only
pad library update <ref> [--overwrite-pending-edits]
# take the library's text (body + settings; never status or title), guarded by the seq
# read just before. Both refuse a server without the `builtin_update` capability. Not on MCP
pad item remind <ref> --remind-at <RFC3339> # arm a one-shot reminder; --rearm <id> moves an existing one
pad item reminders <ref> # list an item's reminders (armed / fired / acknowledged)
pad item ack <reminder-id> # acknowledge a fired reminder, removing it from `project next` / `ready`
pad item unremind <reminder-id> # disarm
# A reminder fires at an instant, emits item.reminder_due on the outbox rails,
# and appears in `pad project next` / `ready` until acknowledged. NOTHING else
# acknowledges one — completing the item does NOT, since a reminder may have
# been armed to fire after the work was done; a reminder on a completed item is
# hidden from the recommendation surface and left untouched in the table.
pad item search "query"
pad project dashboard # Project dashboard
pad project next # Recommended next task
pad project standup [--days N] # Daily standup report
pad project changelog [--days N] [--parent REF] # Release notes from completed items
pad item block <source> <target> # e.g. pad item block TASK-5 TASK-8
pad item blocked-by <item> <blocker>
pad item deps <ref> # Show dependencies
pad item unblock <source> <target>
pad collection list # List collections
pad collection create "Name" --fields "key:type[:opts]; ..." # compact DSL for simple schemas
pad collection create "Name" --schema '<json>' # full CollectionSchema (terminal_options, defaults, computed, relations)
pad item edit <ref> [--force] # Open in $EDITOR. The save is guarded by the seq it was seeded from (a conflict or failed
# save writes your text to a recovery file and prints its path); refuses a stale body
# (content_state) unless --force (BUG-3035), and --force also sends
# overwrite_pending_edits on the save (BUG-3133)
pad workspace init [--template X] # Create workspace
pad agent install [tool] # Install /pad skill for AI tools
# Every skill file pad writes ends with a stamp line (<!-- pad:skill v=… sha256=… -->,
# BUG-3466). pad init / workspace init / agent install / agent update KEEP a file that
# was edited (its body no longer hashes to its stamp, or an unstamped file matching no
# past release's output in internal/cli/skill_legacy_hashes.go) or that a newer pad
# wrote, and say so; --force (on agent install and agent update) replaces it. Hashes
# ignore CRLF and trailing newlines. A dev build never refuses as a downgrade.
# Workspace onboarding: run `/pad onboard` from an agent session inside the
# workspace (Claude Code, MCP, etc.). The /pad onboard playbook is
# auto-seeded into every new workspace.
pad server open # Open web UI in browser
pad project watch # Real-time activity stream
pad github link [item-ref] # Link current branch's PR to item
pad github status [item-ref] # Show PR status for linked items
pad github unlink <item-ref> # Remove PR link from item
pad item bulk-update --status done TASK-5 TASK-8 # Batch operations
pad webhook list/create/delete/test # Webhook management
pad session register [--agent NAME] # Record this session (harness pid + agent name) in ~/.pad/sessions; the plugin runs it on the session's first use of Pad, never at start (TASK-3970)
pad session list [--agent X] [--cwd D] [--all] # Registered sessions on this machine with a liveness verdict each (alive/dead/unknown); --format json is the stable shape
pad session prune [--older-than DUR] # Remove dead sessions' records; unknown-liveness ones only under an explicit age bound
pad auth setup # Initialize a fresh instance with the first admin
pad auth login # Log in
pad auth logout # Sign out
pad auth whoami # Show current user
pad workspace members # List workspace members
pad workspace invite <email> [--role X] # Invite user to workspace
pad workspace join <code> # Accept workspace invitation
Collection names accept singular forms: task→tasks, idea→ideas, doc→docs.
MCP server
Pad serves its CLI as MCP tools, over local stdio (pad mcp serve) and as remote Streamable HTTP at /mcp. Before adding or changing a tool, an action, a parameter, an annotation, a result shape or the server instructions, read docs/mcp.md. It is the MCP reference, moved out of this file verbatim (TASK-3485), and new MCP notes go there, not here.
What docs/mcp.md covers: the tool catalog and its actions, resources and prompts; the stability contract (CmdhelpVersion, ToolSurfaceVersion and its full changelog); the golden wire files; where result caps live; the dispatchers and resource fetchers; pad mcp install commands.
Two rules to apply without opening it:
- Any catalog, annotation, schema or
instructions.mdchange moves the golden bytes. Regenerate withPAD_UPDATE_MCP_GOLDEN=1 go test ./cmd/pad/ -run TestMCPWireGolden, and decide from that diff whetherToolSurfaceVersion(internal/mcp/version.go) owes a bump. - When adding a
padcommand, decide whether it belongs on the MCP surface; docs/mcp.md says how.
Data Model
- Collections have JSON schemas defining typed fields (select, text, date, number, etc.)
- Items have structured
fieldsJSON + optional richcontent(markdown) - Parent/child links: Any item can be a parent of child items (
--parent REF). Children get progress tracking, burndown charts, and nested rendering. Plans are the most common parent, but Ideas, Docs, or Tasks can also have children. - Terminal vs abandoned: a select field's
terminal_optionsare the values that CLOSE an item; its optionalabandoned_options(BUG-2347) are the subset that close it WITHOUT delivering (taskscancelled, bugswontfix, ideasrejected, candidatesrejected/withdrawn, …). Completed-work views —pad project changelog,standup's completed list, report throughput — count terminal − abandoned, and omit abandoned items entirely. A field that declares none falls back to a global name list (models.NegativeTerminals: rejected, cancelled, wontfix, duplicate, declined, abandoned, disabled…), so a custom abandon word likeoverturnedcounts as SHIPPED until the collection declares it.archivedis deliberately NOT in the fallback — declare it per collection where it means "not delivered". Set viapad collection update <slug> --schema …(or the web schema editor);abandoned_optionsmust be a subset ofterminal_optionsor the write is refused400 validation_error. Additive schema key, no MCP version bump. Templates seed it. - Wiki-links
[[Title]]resolve across all items, rendered as clickable links - Default collections: Tasks, Ideas, Plans, Docs (software /
startuptemplate) - Templates are grouped by category so Pad supports more than just software workflows:
- Software:
startup(default),scrum,product - People:
hiring(company-side: Requisitions → Candidates → Loops → Feedback),interviewing(candidate-side: Applications, Interviews, Companies, Contacts) - Custom:
blank— system collections only (Conventions, Playbooks), no user-facing seeds. Designed as the entry point for the/pad onboardagent-driven flow (see Onboarding below). PLAN-1496 / TASK-1498. - Research / Content / Operations / Personal are reserved categories awaiting their first templates.
- Software:
- Each non-blank template ships a curated starter pack (conventions + playbooks) appropriate to its domain — trigger vocabularies vary (
on-commitvson-candidate-advancevson-interview-scheduled). - The IDEA-1 / BACK-1 / FEAT-1 first-person seed-item pattern was retired in PLAN-1496 (TASK-1501 / TASK-1502). Templates no longer seed sample items; the
/pad onboardplaybook (auto-seeded into every workspace, TASK-1500) drives setup conversationally instead. - Set the template via
pad workspace init --template <name>. Runningpad initwith no flag in a TTY opens an interactive picker grouped by category. Runpad workspace init --list-templatesto see the current catalog. - See
PLAN-609andIDEA-583for original design history;PLAN-1496for the onboarding refactor.
Playbooks
Playbooks are first-class invokable procedures. They live in the playbooks collection (typed item, just like Tasks/Ideas/Plans) but carry two extra fields that make them user-callable:
invocation_slug— optional, workspace-unique, kebab-case (regex^[a-z0-9][a-z0-9-]*[a-z0-9]$, 2+ chars). When set, the playbook is invokable by intent (NL is canonical) and via the per-surface slug shortcut —/pad <slug>in Claude Code,$pad <slug>in Codex,pad_playbook action=run ref=<slug>via MCP ("slug routing"). Leave blank for trigger-only playbooks (e.g.trigger=on-releasethat auto-load on intent match).arguments— JSON array of{name, type, required, default, description, enum}entries. Types:ref,string,flag,enum,number. Mirrors the playbook body's## Argumentssection; the structured field is the queryable form (used bypad playbook run's strict parser) and the markdown is the human-readable mirror.
Invocation model. Three surfaces, one playbook:
- Claude Code (agent NL):
/pad ship PLAN-1377 stop-after-each— the/padskill matches the first token against the bootstrap's playbook slug list and binds the rest with flexible NL parsing. - CLI (strict positional):
pad playbook run ship TASK-10,TASK-11 merge-strategy=rebase— the server applies strict positional + bareword-flag +key=valueparsing. - MCP:
pad_playbooktool withaction: list | get | run.runaccepts either a pre-parsedargsmap or raw CLI tokens viaraw_args.
Bootstrap returns metadata at startup. pad bootstrap (CLI + GET /api/v1/workspaces/{ws}/agent/bootstrap + pad://workspace/{ws}/bootstrap resource + pad_set_workspace response embed) returns the workspace's playbook metadata in one round-trip — ref, title, slug, invocation_slug, trigger, scope, status, has_arguments, summary per entry. No bodies in the bootstrap blob; the agent loads the full body via pad playbook show <slug> only when invoking. Keeps context light while still letting the agent route /pad ship without a tool call.
Seeded ship playbook. The startup template ships a generic ship playbook (invocation_slug=ship) derived from the personal /ship-tasks slash command. Fresh pad workspace init --template startup workspaces get it as PLAYB-N out of the box. See internal/collections/templates_startup_ship.go for the body + de-personalization choices.
Library — discovery surface for invokable playbooks. Per PLAN-1397's invokable-first overhaul, the playbook library (web UI: /[username]/[workspace]/library?tab=playbooks; JSON: GET /api/v1/playbook-library) carries the three canonical invokable workflow playbooks — ship, plan, decompose (invokable by intent; /pad <slug> · $pad <slug> · the pad_playbook MCP form are per-surface shortcuts) — under a single agent-workflows category. Each library card surfaces a ▶ <slug> invoke chip (with an NL-canonical tooltip listing the per-surface shortcuts) and an N args badge so the invocation model is visible before activation. Software templates auto-seed plan + decompose via softwareStarterPlaybookTitles; startup separately prepends ship so all three land together at workspace init. The pre-PLAN-1377 trigger-only checklist entries (Implementation Workflow, Code Review Process, Plan Creation, Bug Triage, Retrospective, Onboarding to a Project, Release Process, Deployment, Incident Response) are stashed in playbook_library_archive.go::archivedPlaybooks() — compiled but not surfaced; per-entry "convert / promote to convention / retire" decisions tracked in IDEA-1396.
Web UI editor. web/src/routes/[username]/[workspace]/playbooks/[slug]/+page.svelte is the dedicated playbook editor — kebab-case slug input with debounced uniqueness check, structured arguments builder that round-trips with the body's ## Arguments section, trigger selector with custom-trigger escape, and a "Test invocation" helper that renders /pad, pad playbook run, and pad_playbook MCP JSON forms from a slug + sample inputs. The reusable component lives at web/src/lib/components/playbooks/PlaybookFormFields.svelte and the shared parser/generator at web/src/lib/playbooks/arguments.ts.
Code map:
internal/server/handlers_playbooks.go—pad playbook list|show|runHTTP handlers;ParsePlaybookCLIArgs,resolvePlaybook.internal/server/handlers_bootstrap.go—pad bootstrap; embeds playbook metadata.internal/mcp/catalog_playbook.go—pad_playbookMCP tool catalog entry.internal/collections/templates.go— playbooks collection schema (invocation_slug+argumentsfields);softwareStarterPlaybookTitles(auto-seed lineup for software templates).internal/collections/templates_startup_ship.go— the seededshipplaybook (ShipPlaybook(),shipPlaybookBody,shipPlaybookArguments).internal/collections/playbook_library.go— the invokable-first library (PlaybookLibrary(),LibraryPlaybookstruct withInvocationSlug+Arguments).internal/collections/playbook_library_plan.go— theplanlibrary entry (PlanPlaybook()).internal/collections/playbook_library_decompose.go— thedecomposelibrary entry (DecomposePlaybook()).internal/collections/playbook_library_archive.go— retired pre-PLAN-1377 bodies; not surfaced, but compiled for future migrations (IDEA-1396).web/src/lib/playbooks/arguments.ts—## Argumentsparser/generator,INVOCATION_SLUG_PATTERN,buildTestInvocation.
See PLAN-1377 (invocation model) and PLAN-1397 (library overhaul) in this workspace for the design history.
Onboarding
Workspace setup is driven by the canonical onboard invokable library playbook (PLAN-1496 / TASK-1499) — invoked by intent ("set up my workspace") or the per-surface shortcut (/pad onboard in Claude Code, $pad onboard in Codex, the pad_onboard MCP prompt). Pad does not run a baked-in CLI onboarding wizard; the playbook body IS the onboarding script, and any agent that can dispatch a playbook (Claude Code, MCP client, CLI) can run it.
Auto-seeded everywhere. pad workspace init (with any non-blank --template) seeds the onboard playbook into the new workspace as status=active, invocation_slug=onboard (TASK-1500). The blank template ships it as the workspace's ONLY user-facing content. Empty-template-name workspace creation (SeedCollectionsFromTemplate(ws, "") — used by tests and direct API callers) intentionally skips the seed; see internal/store/collections.go::SeedCollectionsFromTemplate for the gating logic.
Surface-agnostic body. The playbook body (internal/collections/playbook_library_onboard.go::onboardPlaybookBody) describes intent, not specific CLI commands. It instructs the agent to use whatever surface it has — pad_item MCP, pad item CLI, pad_collection MCP, etc. — and works for pure-MCP agents (no shell) the same as for Claude Code. The body's mode argument is auto (default — detects from workspace state; any user-created item routes to revisit), build (blank workspace, build from scratch), audit (templated workspace, adapt seeded items), or revisit (already-onboarded, change something specific), plus a separate defaults flag (escape hatch — skip the interview, pick sensible defaults and report).
Adaptation posture, not curation. The body explicitly tells the agent: library entries are STARTING POINTS, not finished artifacts. Read the rule, rewrite using the project's actual commands and vocabulary. Invent when the library has nothing close. If the template seeded something that doesn't fit, edit or delete it. This is the core posture PLAN-1496 codifies — software templates seed generic "run the test suite" conventions, and /pad onboard rewrites them to make test / go test ./... / whatever the project actually uses.
Mutation primitives. The adaptation posture depends on agent-facing mutation tools, exposed by TASK-1510 / TASK-1511 / TASK-1512:
pad collection update <slug>+pad_collection.action: update— rename collections, swap icons, reshape schemas (TASK-1510)pad collection delete <slug>+pad_collection.action: delete— remove user-created collections that don't fit (TASK-1511)pad role update <slug>+pad_role.action: update— rewrite role descriptions and icons (TASK-1512)
Server handlers existed pre-PLAN-1496; these tasks just wired CLI subcommands and MCP catalog actions to the existing HTTP endpoints. All three are owner-only server-side.
needs_onboarding bootstrap flag. AgentBootstrap.NeedsOnboarding (PLAN-1496 / TASK-1504) is true when the workspace has zero items with source != 'template' — i.e. nothing beyond what the template seeded. The agent skill (skills/pad/SKILL.md) and the MCP server instructions render an active, NL-canonical offer when true (PLAN-1847): "This workspace is brand new and isn't set up yet. Want me to set it up?" — an offer, not an auto-run. The flag flips to false the moment any user/agent-created item exists; the offer stops firing past that point. Computed per-request via Store.WorkspaceHasUserCreatedItems(workspaceID) (EXISTS-backed). PLAN-1496 / TASK-1505 also retired the standalone "Onboarding" workflow section from the skill — the playbook body owns that script now.
Retired surfaces. The pre-PLAN-1496 design had several surfaces that the playbook replaces; all retired:
pad onboardCobra subcommand (was: codebase scan + convention suggestions) — TASK-1502.OnboardingPrimaryReffield onWorkspaceTemplate(was: named IDEA-1 / BACK-1 / FEAT-1 per template) — TASK-1502. Dashboard banner auto-discovers seeds viaitem_number=1 + source='template'if a future template ever wants to reintroduce them.- The
*OnboardingItems()generators ininternal/collections/templates_onboarding*.go(deleted files) — TASK-1501. - The skill's standalone "Onboarding" workflow section — TASK-1505. Replaced by a one-paragraph pointer at the playbook.
Code map:
internal/collections/playbook_library_onboard.go— the canonical playbook body +OnboardPlaybook()library entry +OnboardSeedPlaybook()auto-seed.internal/collections/templates_blank.go— minimal trigger/scope vocabularies for the blank template's seeded system collections.internal/store/collections.go::SeedCollectionsFromTemplate— wires the auto-seed for every non-empty templateName.internal/store/items.go::WorkspaceHasUserCreatedItems— theneeds_onboardingquery predicate.internal/server/handlers_bootstrap.go::AgentBootstrap.NeedsOnboarding— the bootstrap field.skills/pad/SKILL.md— the nudge-rendering rule in Context Loading; the routing entry under "set up my workspace".
Testing
go test ./... # All Go tests
go test ./internal/store/ # Store tests only
cd web && npm run build # Verify frontend compiles
cd web && npm run test # Web unit tests (vitest, run once)
Common Tasks
Add a new API endpoint
- Add handler in
internal/server/handlers_*.go - Register route in
internal/server/server.gosetupRouter() - Add store method in
internal/store/if needed - Add CLI client method in
internal/cli/client.go - Add TypeScript type in
web/src/lib/types/index.ts - Add API method in
web/src/lib/api/client.ts make install
Add a new CLI command
- Add the command constructor to the matching resource file under
cmd/pad/—cmd_item.go,cmd_collection.go,cmd_workspace.go,cmd_auth.go,cmd_project.go,cmd_playbook.go,cmd_role.go,cmd_tag.go,cmd_github.go,cmd_webhook.go,cmd_agent.go,cmd_server.go,cmd_attachment.go,cmd_db.go,cmd_library.go,cmd_bootstrap.go(allpackage main, so helpers are shared across files). Create a newcmd_<resource>.goif none fits. Keepmain.goformain(),newRootCmd(), and top-level wiring only — don't grow it back into a god file. - Wire it into the resource group in
cmd/pad/groups.go(orrootCmd.AddCommand()inmain.gofor a new top-level group) make install
Modify the database schema
- Add migration file in
internal/store/migrations/ - Update models in
internal/models/ - Update store methods in
internal/store/ make install(migrations run automatically on server start)
Real-time collaboration (Yjs / Tiptap)
Collab is wired through /api/v1/collab/{itemID} (WebSocket, Yjs
binary protocol). The relevant code lives in:
internal/collab/— RoomManager, room lifecycle, dumb-relayinternal/store/yjs_updates.go— op-log persistenceweb/src/lib/collab/wsProvider.svelte.ts— client providerweb/src/lib/collab/schemaVersion.ts— client schema-version stampinternal/materialize/+web/src/lib/collab/materializer/— headless op-log materializer (TASK-2198): the editor's own JS, bundled byweb/scripts/build-materializer.mjs, run in goja in apad __materialize-workerprocess. Not wired into the server yet
Collab requires no additional container deps; the single Go binary remains the self-hosted shape. The dumb-relay design (server persists raw Yjs binary updates without parsing them) means there's no Yjs Go port to vendor and no separate sync-server process to run. The op-log lives in the same SQLite/Postgres as everything else, and the WebSocket relay is part of the main HTTP listener. Multi-instance Redis fanout is deliberately out of scope for v1 (single-instance everywhere); when horizontal scaling is needed it lands as a separate IDEA, not a self-host complication.
Tiptap multi-package coordinated bumps
The Y.Doc/ProseMirror schema is shared across three Tiptap packages:
@tiptap/core@tiptap/extension-collaboration@tiptap/y-tiptap
Rule: bump all three together, exact-pinned to the same version.
Mixing minor versions across these can change the persisted Y.Doc
shape silently — peers running mismatched bundles produce divergent
ops that the relay can't reconcile. The web/package.json pins
each one explicitly (e.g. "@tiptap/extension-collaboration": "3.22.5")
rather than using ^ ranges so npm can't slide one out of sync.
A coordinated bump that changes the ProseMirror node-spec MUST also
bump web/src/lib/collab/schemaVersion.ts::SCHEMA_VERSION AND
internal/collab/manager.go::DefaultSchemaVersion in lockstep. The
client announces the version on every WS connect; mismatch returns
HTTP 400 and the room manager empties the per-item op-log so the new
client doesn't replay incompatible old-schema ops. items.content is
untouched. The UNFLUSHED edits (content-bearing op-log rows above the
flush watermark) are not in it, and before BUG-3244 that prune deleted
them and cleared content_state, so the stale body read as current.
They are now SET ASIDE (item_yjs_updates_set_aside) and the item reads
content_state: superseded_set_aside until they are recovered or
discarded (pad item set-aside <ref> [--discard]). Nothing turns them
back into text yet, so the schema version stays FROZEN:
internal/collab/schema_version_guard_test.go fails on any bump until
TASK-3246 (a decoder for the outgoing era) lands and the freeze is ruled
lifted, and it also fails when the two constants disagree.
Pure UI/CSS/behavioural changes that don't alter the persisted document shape DO NOT bump the schema version. When in doubt, load an item edited under the old version after your change and confirm the rendered tree is identical.