xarmian e4e914d399 fix(server): reject path segments the database cannot be asked about (BUG-2782) (#1207)
* fix(server): reject path segments the database cannot be asked about (BUG-2782)

Every handler that resolves a workspace, collection, item, comment or
attachment from a URL path segment passes that segment to the store
verbatim, and the store binds it into a text comparison. Postgres refuses
a text parameter that is not valid UTF-8 or that contains a NUL (SQLSTATE
22021 / 22P05); the driver surfaces that as a query error and the handler
answers 500. SQLite accepts both bytes and matches nothing, so the same
request is a clean 404 there — a dialect divergence that leaves the defect
invisible to self-hosted installs and live on Pad Cloud.

Measured before the fix, driving every route that carries a path parameter
with one segment set to "bad-%FF-x" (247 probes, one per parameter position
per method, real values elsewhere): Postgres answered 500 to 191 of them,
SQLite to 0. After: 0 and 0, all 247 answered 400.

Fixed with one root-level middleware rather than at ~112 chi.URLParam call
sites, because this is a transport-level input rule and per-call-site fixes
rely on every future route remembering. ValidatePath rejects a request whose
percent-DECODED path is not valid UTF-8 or contains a NUL, before routing.

It validates r.URL.Path rather than what chi hands the handler. chi routes
on RawPath when non-empty and Path otherwise, and Go populates RawPath only
when the client's escaping is not already canonical — Go escapes 0xff as
uppercase "%FF", so the CANONICAL form any ordinary client emits is exactly
the one that reaches the store decoded, and the lowercase "%ff" oddity is
the harmless one. Validating the decoded path answers both identically and
does not depend on chi continuing to prefer RawPath.

It cannot refuse Pad's own URLs: store.slugify emits only [a-z0-9-], ids
are UUIDs or hex, refs are a prefix plus digits. Valid non-ASCII segments
pass through untouched — the database accepts them and they may legitimately
name something. 400 rather than 404 because the request is malformed as a
URI and the answer does not depend on whether anything exists, so it is not
an existence oracle. Scope is the path; the query string is validated at its
points of use, per BUG-2774's validCursorID.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* fix(server): the invalid-path rejection must look like every other API error (BUG-2782)

Codex round 1, verified before acting on: ValidatePath runs on the root
router, so its rejection short-circuits ABOVE the /api/v1 group's
cors.Handler and jsonContentType and inherited neither. Measured — the 400
carried a JSON body sniffed as text/plain and no CORS headers at all, while
a normal 404 on the same route carried Content-Type: application/json plus
the full CORS set. On a cross-origin deployment (PAD_CORS_ORIGINS set) the
browser refuses to let the page read a response with no Access-Control-
Allow-Origin, so a debuggable 400 arrives as an opaque network error.

Fixed without duplicating the CORS configuration: the group's cors.Handler
is hoisted into one shared instance, the group mounts it as before, and
ValidatePath serves its rejection THROUGH the same instance. Content-Type
is set explicitly, since jsonContentType is mounted below and never runs
for a rejection.

Moving ValidatePath down into the group instead was rejected: two covered
routes live outside it — the SPA catch-all and /api/v1/collab/{itemID} —
and the mutant that makes that move is caught by exactly those two subtests.

A genuine preflight (Origin + Access-Control-Request-Method) to an invalid
path is answered 200 by the shared handler, the same as for any other path:
a preflight asks whether the method and headers are permitted, not whether
the resource exists. The real request that follows still gets the 400, and
can now be read. Asserted rather than described.

The new test compares each header on the 400 against the SAME route
answered normally, for an allowed origin AND a disallowed one, so it pins
parity with the API's own errors rather than a header list copied from a
spec — and the disallowed-origin leg is what would fail if the rejection
echoed origins the shared handler refuses.

Mutation matrix, all nine verified to COMPILE first: dropping the CORS
decoration and dropping the explicit Content-Type are each detected, and
only by this new test.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* test(server): pin the ordering decision the path check makes (BUG-2782)

Codex round 2, angle rotated to middleware contracts: a rejected request
never reaches TokenAuth, SessionAuth, RateLimit or CSRFProtect, because
ValidatePath sits on the root router above that group. The finding is
factually right and the ordering is deliberate, but nothing in the diff
said so and no test held it — which is the same defect shape as an
undocumented invariant: true today, unenforced tomorrow.

Verified rather than argued, because "bypasses the rate limiter" reads as
a weakening and here the direction is inverted. Before this middleware,
the same request ran SessionAuth — a store.ValidateSession round trip —
then the limiter, then a handler whose query the database refused, and
answered 500. It now costs a UTF-8 scan and a short JSON write with no
database contact, so the unmetered path is strictly cheaper than every
path the limiter protects. The answer is also constant for all inputs of
this shape, independent of auth and of existence, so a flood learns
nothing. And the limiter is a plain token bucket per key — no escalating
ban, no durable block — so skipping it defeats no state that outlives the
request.

The alternative, metering it inside the /api/v1 group, trades this for a
real coverage hole: the SPA catch-all and /api/v1/collab/{itemID} are
mounted outside that group.

The test floods 80 invalid paths from one IP (burst is 60), requires all
80 to be 400 and none 429, then requires a VALID request from the same IP
to still get the resolver's 404 — proving the budget was untouched. It
then asserts its own premise: the same volume of valid requests from a
second IP must actually hit the limiter, because an inert limiter would
produce an identical reading for the first half.

Both mutants land where they should: metering the rejection fails at
request 61 (burst 60 + 1, which independently confirms the constant
cited above), and disabling the limiter fails the premise check rather
than passing quietly.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* docs(server): correct four claims this branch's own measurements refute (BUG-2782)

Codex round 3, angle rotated onto prose accuracy. No behaviour changes —
every finding is a sentence that was stronger than what was verified, and
in two cases stronger than data already sitting in this branch.

1. "It rejects exactly what the DATABASE rejects." Too strong, and
   inherited verbatim from validCursorID. Postgres refuses these two
   classes under a UTF8 database encoding; SQL_ASCII accepts the same
   bytes, and SQLite's sqlite3_bind_text accepts arbitrary sequences with
   NUL undefined rather than erroring. Pad neither creates nor configures
   that database — nothing issues CREATE DATABASE or sets client_encoding
   — so the encoding is the operator's. Now stated as what it is: the
   strictest reading, applied uniformly so the two backends stop
   disagreeing about the same request, measured against postgres:17-alpine
   at its defaults.

   A first draft of this correction replaced the overstatement with a NEW
   unverified claim ("the encoding Pad's migrations create"). Grepping for
   CREATE DATABASE found it only in test helpers. Fixing an unchecked
   sentence with another unchecked sentence is the same defect wearing the
   repair's clothes.

2. "Against unfixed code these are 500 on Postgres and 404 on SQLite."
   False for 56 of the 247 pre-fix probes, and my own sweep output said so
   — routes whose authorization or configuration gate answers before any
   store call (admin user lookup; attachments with no storage configured).
   Replaced with the pasted distribution: 500:191 404:34 403:12 401:4
   503:4 400:2.

3. "Passed through untouched" oversold what this middleware guarantees.
   It does not touch a valid path, but chi still hands the handler the
   ESCAPED text whenever RawPath is populated: "caf%C3%A9" arrives as
   "café", the non-canonical "caf%c3%a9" arrives literally, and "%2F"
   never becomes a separator. Pre-existing chi behaviour, unaffected by
   this change, written down because the obvious reading is stronger than
   the truth.

4. "The request is malformed as a URI." It is not — "%FF" and "%00" are
   syntactically valid percent-encoded octets. The 400 is because the
   DECODED value cannot be a resource identifier here, which is the actual
   reason and a different one.

Also reconciled the two probe counts that appear in this branch's history
(111/94 GET-only, 247/191 all methods) so a reader meeting both does not
have to guess which is wrong; they are one sweep at two widths.

CONVE-23 sweep: finding 1 falsifies the same sentence in validCursorID
(handlers_timeline.go, BUG-2774), which is where this branch inherited it.
Corrected there too rather than left standing — the rule that comment
describes is unchanged and still right; only its claim about the database
was wrong.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* test(mcp): drive the in-process transport seam the path check walks past (BUG-2782)

Codex round 4's exploration pointed at the door I had asserted rather than
driven: the remote /mcp transport does not reach the server over a socket.
HTTPHandlerDispatcher SYNTHESIZES an *http.Request and calls
Handler.ServeHTTP in-process, so "the middleware covers every route" was a
claim about a path this transport bypasses on its face.

Driven, it is covered — and for a chain nothing in the tree stated: Handler
is the *server.Server, chi's Mux.ServeHTTP runs mx.handler (middlewares +
routeHTTP) on BOTH branches, and buildAuthedRequest forces the fresh-routing
branch with a typed-nil RouteCtxKey. Every link is load-bearing and none was
written down; this test is what notices if one changes.

The counterfactual was worth more than the confirmation. Unfixed, an MCP
agent that put an invalid byte in a ref got upstream_error on Postgres —
whose hint says the failure is "usually transient, retry" — for an input
that can never succeed. An agent obeying that hint retries forever. That is
the retry-hostile misclassification family BUG-2675 added a code for, and
this change removes an instance of it that nobody had noticed. Now
validation_failed: the agent is told its INPUT is wrong.

The first version of this test named upstream_error in its comment while
running on SQLite, where unfixed gives item_not_found instead — an
assertion that would have failed for a reason other than the one it named.
The comment now states both backends separately and the fixture takes
Postgres when PAD_TEST_POSTGRES_URL is set, so under make test-pg the
dangerous half is what actually runs.

Control leg included: a valid-but-absent ref must still return
item_not_found, or a dispatcher that refused every ref would pass.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* chore(mcp): remove a throwaway probe that was committed by accident (BUG-2782)

The probe that established the MCP seam behaviour was meant to be deleted
once dispatch_http_invalid_path_test.go replaced it. The 'rm' was written
as the first half of a compound command whose second half the tool layer
REJECTED, so the whole command never executed — and a later 'git add -A'
swept the file in. It duplicates the real test with printf-style output
and no assertions.

The rule this breaks is one I already hold: verify the mutation, not the
report of it. I read 'rm -f X && cat > Y' as having removed X because I
wrote it, when the command never ran at all. A rejected command and a
successful one look identical in a transcript if you do not look.

Caught by a Codex file listing showing an A for a file I believed gone.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* test: five corrections from the final review pass (BUG-2782)

Codex round 5, judging the whole change. All five are mine; none needed a
behaviour change.

**A flake I built in.** The rate-limit test flooded 80 requests against a
bucket of burst 60 — but a token bucket REFILLS while the loop runs, at
10/s here, so 20 tokens of headroom is 2 seconds of tolerance and a slow or
-race'd run would admit all 80 and fail spuriously. The margin that matters
is not flood-vs-burst but how long the loop must take for refill to cover
the excess. At 400 requests that is (400-60)/10 = 34 seconds against
in-process calls measured in microseconds: four orders of magnitude. The
constant now carries that derivation, including the rate and burst it
depends on. Both mutants still land, and metering the rejection still fails
at request 61 — burst 60 + 1, unchanged by the larger flood.

**A claim about MCP that JSON does not support.** The seam test's comment
said an agent could put "a raw invalid byte" into a ref. Measured with
encoding/json instead of assumed:

    raw 0xff / lone surrogate / truncated sequence → U+FFFD, valid UTF-8
    raw 0x00                                       → JSON parse error
    the u0000 ESCAPE                               → a real NUL

So exactly one of the five cases is reachable end to end over a JSON
transport, and it is the one only the NUL half of validPathText refuses.
The raw-byte cases stay — Dispatch is a Go API and the JSON decode is
upstream of that boundary, so they assert the seam holds for callers that
do not launder their strings through encoding/json — but the comment no
longer offers them as evidence a JSON client can send them.

**Two prose overstatements the earlier sweep missed.** The control test
still said the rule rejects "only what the database rejects", which the
previous commit had already established is false in the permissive
direction. And TestValidatePathPostgresNoInternalError was described as
reproducing the original 500 when it runs the FIXED server and can only
ever observe a 400; the 500 lives in the counterfactual sweep and in the
mutation matrix, and a test cannot both apply a fix and witness the bug.

**One dead construction**, plus a smaller instance of the same habit: the
MCP fixture built a SQLite store and discarded it in Postgres mode. My
first attempt replaced the comment with one claiming the branch had been
hoisted, and left the code as it was — writing the fix into the prose
instead of the code, in the same hour I committed a message about not
doing exactly that. Now actually branched.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* docs+test: seven more corrections, and one the emoji route earns (BUG-2782)

Codex round 6, reading the three files as they now stand. All prose or
assertion strength; one of them changes what the tests cover.

**Pad DOES emit a non-ASCII path segment, and I said it never does.**
`DELETE /workspaces/{ws}/comments/{commentID}/reactions/{emoji}` — the web
client sends `encodeURIComponent(emoji)`. So the justification I gave for
"it cannot refuse Pad's own URLs" was false in its premise while true in
its conclusion, which is the worst combination: a reader checking the
premise finds a counterexample and has no reason to trust the rest.

It is also the best possible illustration of why the rule permits valid
non-ASCII, so the control test now drives that ACTUAL route with a real
emoji rather than relying on an emoji-shaped item slug — the claim is true
by construction instead of by careful wording. A new mutant confirms the
leg discriminates: a rule that rejects all non-ASCII (the plausible wrong
version, not the absurd one) is caught there.

**"The handler answers 500" was universal and is not.** Handlers that
collapse a resolution error into not-found already answer 404 — the
timeline handler's `err != nil || item == nil` is the example. My own
measured distribution said so; the sentence did not.

**"Self-hosted installs never see it" was wrong about the axis.** The split
is by BACKEND, not deployment: a SQLite install never sees it, any Postgres
install does — Pad Cloud and a self-hoster on Postgres alike.

**A stale cross-reference of my own making.** The previous commit corrected
TestValidatePathPostgresNoInternalError's claim to reproduce the 500, and
left the sentence POINTING at it still saying it does. Fixing a claim at
one site and leaving its pointer false is the CONVE-23 case in miniature.

**The MCP test asserted too little.** validation_failed is how the
dispatcher classifies ANY 400, so the test could have passed on a
mapper-level refusal without ValidatePath running at all. It now pins the
middleware's own message, which rides through on the hint.

**Two overstatements in the same file.** "Exactly one case is reachable"
should be one input CLASS (two cases carry a NUL). And the raw-byte cases
do not cover "the stdio path": local stdio MCP is ExecDispatcher, which
shells out to the binary and never touches this in-process door. Scope now
says HTTPHandlerDispatcher and says what it does not speak for.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* docs(server): two qualifications the file already owed itself (BUG-2782)

Codex round 7. Two, both narrow — the review is converging (7 findings last
round, 2 this one), and both are internal inconsistencies rather than new
ground.

"Any Postgres install does" contradicted a qualification made forty lines
lower in the same file, where validPathText spells out that SQL_ASCII
Postgres accepts these bytes. Now says a Postgres install whose database
encoding is UTF8, notes that this is initdb's default, and points at the
place the qualification lives so the two cannot drift apart again.

validCursorID's paragraph still described the 500 in the present tense,
though BUG-2774 fixed it — it is the behaviour the guard PREVENTS, not what
the endpoint does. My first attempt at this appended "past tense throughout
this paragraph" and left the following sentence in the present tense, which
is annotating a problem instead of fixing it. Rewritten so the tense
carries the meaning without a note telling the reader to read it
differently.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* docs(server): the SQLite half of the claim needed the same narrowing (BUG-2782)

Codex round 8, one P3 and it is the mirror image of round 6's. I qualified
"the handler answers 500" on the Postgres side and left the symmetric
sentence — "the same request is a clean 404 there" — universal on the
SQLite side, in the same paragraph. Not every request reaches a store
resolution on either backend; a gate that answers first keeps its own
status, and my own GET-only sweep recorded 102 x 404 alongside 5 x 403,
2 x 200, 1 x 401 and 1 x 503 on SQLite.

Fixing one direction of a symmetric claim and leaving the other is a shape
I have hit before and evidently do not catch by intention. The paragraph
now says the divergence is in what happens once a value REACHES the store,
which is the true and symmetric statement, with the distribution pasted.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-25 23:20:53 -04:00
2026-03-26 01:52:36 +00:00

Pad

Project Management for the agent era.

CI Release Go Report Card Container image on GHCR License GitHub Sponsors

Website  ·  Docs  ·  Blog  ·  Changelog  ·  Reddit  ·  X  ·  Bluesky


One binary. Local-first. No accounts required. Pad gives you a CLI, a web UI, and an AI agent skill — all backed by SQLite, all running on your machine. Your project data stays on your laptop — unless you take it to Pad Cloud.

Pad dashboard showing collection summaries, active work, an active plan with progress, and a recent activity feed

Quick Start

brew install PerpetualSoftware/tap/pad
cd your-project
pad init                    # configure, auth, workspace, AI skill — all in one
pad server open             # opens the web UI at localhost:7777

pad init is the smart entry point — it auto-detects what's needed, walks you through each step, and is safe to re-run anytime (it skips finished steps and prints a status summary).

Then, in a fresh agent session in your project, say:

/pad onboard

Your new workspace ships with the canonical onboard playbook auto-activated. The agent walks an interview, inspects your codebase if it has shell access, and adapts your workspace's collections, conventions, roles, and playbooks to match the project. It's the fastest way to go from empty workspace to "okay, this is mine."

Why Pad?

Tools like Linear, Jira, and Notion are built for teams on the cloud. Pad is built for developers on their machine — and for the AI agents working alongside them. When you do want your projects on every device or a teammate on the board, Pad Cloud hosts the same product with sync, workspace invites, and role-based access.

Pad Linear / Jira Notion
Setup pad init Create account, invite team, configure Create account, pick template
AI agents Native /pad skill for 7+ tools Third-party integrations Third-party integrations
Data Local SQLite you own — or opt-in Pad Cloud Their cloud Their cloud
Offline Full functionality Read-only cache at best Limited
CLI First-class Afterthought None
Price Free, open source Per-seat pricing Per-seat pricing

Features

For Developers

CLI that doesn't get in your way. Create tasks, search items, check status — without leaving the terminal.

pad item create task "Fix OAuth redirect" --priority high
pad item create idea "Real-time collaboration" --category infrastructure
pad item list tasks --status in-progress
pad item search "authentication"
pad project dashboard                   # Project dashboard
pad project next                        # What should I work on?
pad server info                         # How this client is connected to Pad

Web UI that stays out of your way. A clean, dark-themed interface at localhost:7777 with:

  • Board, list, and table views — drag-and-drop between status columns
  • Keyboard navigationj/k to move, Enter to open, Esc to go back, Cmd+K to search
  • Rich text editor — Tiptap-based with markdown, formatting toolbar, and auto-save
  • Wiki-links — type [[Title]] to link between items
  • Real-time updates — agent creates a task in the terminal, it appears in the browser instantly (via SSE)
  • Dashboard — collection overview, active work, plan tracking, activity feed

Pad tasks board view: kanban columns for Open, In-Progress, Done, Cancelled with task cards in each

For AI Agents

Your agent becomes a project partner. Install the /pad skill once, and your AI coding tool can read, create, and update project items through natural language.

pad agent install        # Auto-detects your tools and installs the skill

Works with Claude Code, Cursor, Windsurf, Codex, OpenCode, GitHub Copilot, Amazon Q, and JetBrains Junie.

Then just talk to your project:

> /pad what should I work on next?
> /pad I finished the OAuth fix
> /pad create a task to add rate limiting
> /pad let's brainstorm about the API redesign

Conventions and playbooks teach agents how your project works:

  • Conventions — trigger-based rules like "run tests before marking a task done" or "use conventional commits"
  • Playbooks — multi-step workflows like "when implementing a feature: read the spec, create a branch, write tests first, then implement". Playbooks can declare a kebab-case invocation_slug so users can invoke them directly: /pad ship PLAN-42, /pad release 0.5.0. Fresh startup workspaces ship a generic ship playbook out of the box.
pad item create convention "Run tests before completing tasks" \
  --field trigger=on-task-complete \
  --field scope=all \
  --field priority=must

Agents load relevant conventions automatically, and every agent action is attributed in the activity feed — so you can see what the AI changed rather than finding it later in a diff.

Name your agents:

An agent that identifies itself gets its name shown on its writes — in the activity feed's Live and Audit views, on the dashboard's recent activity, on item timeline activity entries, and in the admin console's audit log and per-user activity views. With more than one agent working a project, that is the difference between "something automated touched this" and knowing which one.

Pad takes the first of these it finds:

# 1. Per-workspace, committed with the project — the deliberate choice.
#    In .pad.toml:
#      agent_name = "reviewer"

# 2. Per-process, runtime-agnostic. Any harness can set it.
export PAD_AGENT=reviewer

# 3. Otherwise Pad detects the runtimes it knows — Claude Code reports
#    "claude-code" — and that detected id is used as the name.

If none of the three produce a name, the write is not marked as an agent's at all — it is recorded as the person whose credentials it used, which is the case the caveat below is about. The generic agent label you may see on older entries is a write that identified itself before Pad stored names, or an event type that records the actor without the name (workspace membership changes, sign-ins).

The name is rendered exactly as sent — Pad keeps no list of approved names, and does not re-case or rewrite what you choose.

Sessions carry the name too, locally. A session with the Claude Code plugin records itself in ~/.pad/sessions on start (best effort — the plugin monitor is silent by contract, so a registration that fails, e.g. on a malformed pid variable, is only visible by running pad session register by hand) — the harness session's pid, the agent name above, and its working directory — and pad session list reads that back with a liveness verdict per row (alive, dead, or unknown where the platform cannot probe). It is a local, deterministic answer to "which of my sessions on this machine are running, and as which agent" — no server round-trip, no guessing from process names. What a row says about who is self-declared, like the name itself; on Linux the pid claim is additionally checked against the registering process's ancestry and reported as session_pid_verified. Any other harness gets the same by calling pad session register from its session-start hook with PAD_SESSION_PID (the session process) and PAD_AGENT exported. Records of sessions the register can see are dead are pruned on every register; pad session prune --older-than 72h also clears ones whose liveness cannot be determined. The record never leaves the machine.

Reading the output as a decision — "is this name in use here right now?" — takes a rule, and pad session list --help spells it out: count only rows that are alive, not legacy/malformed, and session_pid_verified; treat unknown, legacy, or malformed rows in the same directory as indeterminate rather than free (so list without --agent and filter yourself); read an empty result as "no registered row", not "nobody" — a harness that never registers is invisible; and never pick between two alive rows by registered_at, which is each session's own clock. The registry is per OS user.

Not every entry can show it. Activity entries store it, and comments (replies included) read it through the activity each one links to — so a comment written by an agent that sent a name shows that name in its chip, next to the person whose credentials it used. Version snapshots and implementation-note/decision entries record only that an agent acted, because nothing links them to a named row — they still read Agent.

What this does not claim. The name is supplied by the client and self-declared, so it records honesty, not identity. From ResolveAgentName's own contract in internal/cli/agent_identity.go:

  • an agent that omits it is indistinguishable from the human whose credentials it is using;
  • a human running ! pad ... inside an agent's terminal inherits that terminal's environment and will be attributed to the agent.

So it is not a basis for machine-verifiable provenance: treat it as a label an actor chose, useful for reading a trail, not as evidence about who acted. Because the credentials belong to a person either way, surfaces that exist for provenance show both — the admin audit log renders reviewer (via Dana) rather than picking one.

Since the name is chosen by whoever is writing, it is displayed as an isolated unit: it is shown as sent, but it cannot re-order or restyle the text around it, and the account half of name (via account) is rendered separately so a chosen name cannot forge it.

Onboard agents to a new codebase:

Open an agent session in the workspace directory and run /pad onboard. The agent walks an interview, detects your build/test/CI tooling, and adapts your workspace's collections, conventions, roles, and playbooks to match the project. Works for any agent that speaks Pad — Claude Code, MCP-only agents, etc.

Collections & Custom Fields

Pad organizes work into collections — typed containers with structured fields.

Built-in collections:

Collection Purpose
Tasks Work items with status, priority, assignee, effort, due date
Ideas Feature ideas with impact and category
Plans Project milestones with progress tracking
Docs Documentation, decisions, reference material
Conventions Project rules that guide agent behavior
Playbooks Multi-step workflows for agents to follow

Create your own with typed fields — select, text, date, number, url, relation, checkbox:

pad collection create "Bug Reports" \
  --fields "severity:select:low,medium,high,critical; browser:text; reproducible:checkbox"

Items get reference numbers automatically (TASK-5, BUG-12) and can be moved between collections with field migration.

Installation

Homebrew (macOS and Linux)

brew install PerpetualSoftware/tap/pad

Build from Source

git clone https://github.com/PerpetualSoftware/pad
cd pad
make build
cp pad ~/.local/bin/   # or /usr/local/bin/

Requires Go 1.26+ and Node.js 22+. Alternatively, nix develop provides a shell with the exact Go and Node versions pinned — see the Nix section below.

The go install github.com/PerpetualSoftware/pad/cmd/pad@latest path is not supported for the full Pad binary, because the web UI must be built and embedded during the source build.

Docker

docker run -p 127.0.0.1:7777:7777 -v pad-data:/data ghcr.io/perpetualsoftware/pad

This publishes Pad to localhost:7777 on the host machine, which is the recommended default for local use.

First run — create the first admin. Open http://localhost:7777 and you'll hit a setup page asking for a bootstrap token. On first start with no users, Pad logs a one-time setup URL to stderr (captured by docker logs) — grep it and open the printed link:

docker logs <container> 2>&1 | grep -A6 'Pad first-run setup'
# → http://<your-host>:7777/setup#token=<one-time-token>

Open that URL, create your admin account, and the token is consumed (the banner stops appearing). If you'd rather stay on the CLI, docker exec -it <container> pad auth setup works too — running inside the container counts as loopback, which the bootstrap gate allows. On a network you already trust, set PAD_BYPASS_SETUP_TOKEN=true to skip the token and create the admin straight from http://<your-host>:7777/setup (only safe when the port isn't reachable from the open internet).

Single user, more than one device? Publish to all interfaces so you can reach Pad from your phone, tablet, or another machine on the same LAN, Tailscale network, or home VPN:

docker run -p 7777:7777 -v pad-data:/data ghcr.io/perpetualsoftware/pad

For multi-instance deployments, Pad supports Postgres + Redis via docker-compose.yml — see docs/deployment.md for the full setup.

Nix

Run without installing:

nix run github:PerpetualSoftware/pad

Or install into your profile:

nix profile install github:PerpetualSoftware/pad

A flake devShell (Go, Node, and friends, pinned to the same versions CI uses) is also available for contributors:

nix develop

A nixpkgs package (nix-shell -p pad / environment.systemPackages) is planned but not yet merged upstream. Until then, use the github:PerpetualSoftware/pad flake reference above.

Binary Download

Pre-built binaries for macOS, Linux, and Windows are available on the releases page.

Pad Cloud (hosted)

Don't want to run anything? Pad Cloud is the managed option — same product, same CLI, same /pad skill, free during beta. Sign up on the web, then connect a project directory:

pad init --url https://app.getpad.dev --workspace my-workspace

Self-hosting stays first-class: the binary is unchanged and no features are Cloud-only.

Upgrading Pad

Pad ships a new binary on a roughly weekly cadence. Upgrades are designed to be boring: install the new binary and restart. Database migrations run automatically at startup, only the ones your database is missing are applied, and each migration commits atomically (a failed migration rolls back cleanly and is retried next boot).

The one rule: only ever move forward. Newer binaries know how to migrate an older database; older binaries do not understand a newer schema. Since Pad added its schema-ahead guard, a downgraded binary that finds a database newer than itself refuses to start rather than silently running old code against a newer schema (which can corrupt data):

database schema is newer than this pad binary: ... This almost always means the
binary was DOWNGRADED (e.g. brew/docker rollback) ... Upgrade pad back to a build
that includes those migrations, or re-run with `pad start --force`.

To recover, reinstall the newer binary (brew upgrade pad, pull the newer Docker tag, etc.). If you have intentionally downgraded and accept the risk, start with pad start --force (or set PAD_ALLOW_SCHEMA_AHEAD=1) to override the guard.

Automatic pre-migration snapshot (SQLite). Whenever a SQLite-backed instance has pending migrations to apply, Pad first copies the database file to pad.db.pre-<version> next to it. If an upgrade ever goes wrong, stop the server and copy that snapshot back over pad.db. This is a convenience net, not a backup strategy — keep your own backups (see docs/backup.md). PostgreSQL instances are skipped here; use pg_dump or a provider snapshot before upgrading.

Recommended upgrade flow:

# 1. Back up first (SQLite shown; see docs/backup.md for Postgres)
pad db backup -o pad-backup-$(date +%Y%m%d).db

# 2. Stop the server, install the new binary, restart
#    (migrations + the pre-migration snapshot run automatically on start)
brew upgrade pad        # or: docker pull, binary download, make install

# 3. Confirm it's healthy
pad --version
curl -s localhost:7777/api/v1/health

Getting Started

1. Set up Pad

cd ~/projects/myapp
pad init "My App"

pad init is the smart entry point that handles everything in one command:

  • Configures this client's connection (local server, remote, or Docker)
  • Auto-starts the local server
  • Creates the first admin account on a fresh local install (Docker / remote hosts run pad auth setup on the server instead)
  • Logs you in if needed
  • Creates or links a workspace for the current directory (writes .pad.toml)
  • Installs the /pad skill for any AI tools detected in the project

Run from your project root. Safe to re-run anytime — it skips finished steps and prints a status summary if nothing's needed.

Choose a template with --template, or omit it for an interactive picker grouped by category (Software / People / …):

pad workspace init --list-templates                   # See the full catalog grouped by category
pad init "My App" --template scrum                    # Scrum-style with sprints
pad init "My App" --template product                  # Product management focused
pad init "My Hiring" --template hiring                # Company-side: requisitions, candidates, interview loops, feedback
pad init "Job Search" --template interviewing         # Candidate-side: applications, interviews, companies, contacts
pad init "My App" --template blank                    # Custom: system collections only — let /pad onboard build the rest

Pad ships templates for software (startup / scrum / product), people workflows (hiring, interviewing), and a custom blank template — system collections (Conventions, Playbooks) only, with the /pad onboard playbook as its sole seeded content. blank is the entry point for the agent-driven /pad onboard flow: it walks you through shaping collections, conventions, and roles to match your actual project. Reserved categories for research, content, operations, and personal use await their first templates, so the same project-management primitives fit well beyond code projects. There's also a hidden demo template — the startup layout pre-loaded with realistic sample data — that's kept out of the picker but can be built explicitly with --template demo.

2. Start working

# From the CLI
pad item create task "Set up CI pipeline" --priority high
pad item create idea "Add WebSocket support" --category infrastructure
pad project dashboard

# From the web UI
pad server open              # Opens localhost:7777 in your browser

# From your AI agent
# Just use /pad in Claude Code, Cursor, etc.

3. Teach your agents the rules

In an agent session inside the workspace:

/pad onboard

The agent walks an interview, detects your tooling, and adapts the workspace's collections, conventions, roles, and playbooks. To browse the library directly:

pad library list --type conventions  # Pre-built conventions you can adopt
pad library list --type playbooks    # Pre-built multi-step workflows

4. Optional — connect a desktop AI app via MCP

Pad ships an MCP (Model Context Protocol) server so Claude Desktop, Cursor, Windsurf, Claude Code, or Codex can manage items, plans, ideas, and dependencies as native tools, read workspace state by URL, and load multi-step workflows as prompts.

pad mcp install claude-desktop   # or: cursor, windsurf, claude-code, codex, --all
# Restart the client; pad shows up as the "pad" MCP server.

pad mcp install writes each client's native config: JSON mcpServers for Claude Desktop / Cursor / Windsurf, a project-local .mcp.json in the current directory for claude-code, and an [mcp_servers.pad] table in ~/.codex/config.toml (TOML) for codex. Because Claude Code's config is project-scoped, it's install-on-request only — --all and pad mcp status cover the per-user clients (including Codex) and skip it.

Tool catalog (v0.25) — ten resource × action tools plus pad_set_workspace (eleven total), no flat verb explosion. Undeclared input keys are rejected with a structured error rather than silently dropped. pad_item create/update accept field values as a fields object (the same shape reads return) as an equivalent to the dedicated params / field: ["key=value"]. pad_item.list accepts unparented: true (mutually exclusive with parent) to select items with no parent or implements relationship, and is summary-shaped by default on both transports (full: true opts into complete content bodies):

Tool Actions
pad_item create, update, delete, get, list, move, restore, link, unlink, deps, star, unstar, starred, comment, list-comments, backlinks, bulk-update, note, decide, export, import, history
pad_workspace list, members, invite, storage, audit-log, create, claim, deleted, restore
pad_collection list, create, update, delete
pad_project dashboard, next, ready, stale, standup, changelog, report, activity
pad_role list, create, update, delete
pad_search query
pad_playbook list, get, run
pad_library list, get, activate
pad_attachment list, show
pad_meta server-info, version, tool-surface, bootstrap
pad_set_workspace session-default workspace pinning (response embeds the bootstrap blob)

Plus resources at pad://workspaces, pad://workspace/{ws}/dashboard, pad://workspace/{ws}/items, pad://workspace/{ws}/items/{ref}, pad://workspace/{ws}/collections, pad://workspace/{ws}/attachments/{id} (bounded image bytes), pad://workspace/{ws}/bootstrap, and pad://_meta/version.

Stability contract — two version constants, both advertised in the initialize handshake under capabilities.experimental.padCmdhelp and capabilities.experimental.padToolSurface (and queryable at pad://_meta/version):

  • cmdhelp_version: "0.1" — CLI help-tree contract (used at dispatch time)
  • tool_surface_version: "0.25" — MCP tool catalog contract (v0.5 added pad_library; v0.6 pad_item.backlinks; v0.7 pad_item export/import; v0.8 pad_workspace deleted/restore; v0.9 made pad_item.list summary-shaped by default with a default+max result cap; v0.10 enforced the draft-playbook gate server-side on pad_playbook.run with an allow_draft escape hatch; v0.11 added the read-only pad_attachment tool (list/show); v0.12 added pad_project.activity (agent-accessible non-streaming activity feed); v0.13 added pad_project ready/stale (agent-oriented backlog + attention queries); v0.14 added pad_item history + optimistic concurrency (TASK-2022); v0.15 added the pad_item.list unparented parameter (TASK-2096); v0.16 made an empty-string assigned_user_id / agent_role_id CLEAR the assignment instead of being silently dropped, so an agent can finally unassign an item (TASK-2571); v0.17 carried that to the LOCAL STDIO transport by teaching the CLI to lift those keys onto their columns instead of into the fields blob (BUG-2583); v0.18 added clear_assigned_user / clear_agent_role booleans — the canonical, schema-discoverable way to unassign, backed by new --clear-assigned-user / --clear-agent-role flags on pad item update (IDEA-2584); v0.19 added a clear_parent boolean — the canonical, schema-discoverable way to detach an item from its parent, backed by a new --clear-parent flag on pad item update (BUG-2078); v0.20 gave every tool an explicit annotation block derived from the catalogs read-only knowledge — fully-read-only tools advertise readOnlyHint: true / destructiveHint: false, all-additive-write tools (pad_workspace, pad_library) drop destructiveHint, overwrite/delete-capable tools stay conservatively destructive, openWorldHint: false everywhere — replacing mcp-gos defaults that marked every tool destructive (BUG-2302), and made pad_item.list summary-shaped on the remote HTTP transport too, with a declared full boolean as the opt-in for complete bodies on both transports (BUG-2305); v0.21 bounded pad_item.history, which was unbounded on every surface — limit now covers it (default 50, max 300, the NEWEST N; no offset, because reverse-patch storage makes only a newest-end window cheap), applied in the catalog action so it lands on both transports, and summary mode now asks the server to skip patch resolution rather than resolving bodies the dispatcher discards (BUG-2608); v0.22 stopped pad_item.move destroying an items system metadata — implementation notes, decision log, linked PR and convention data now survive a move, any field the destination schema has no home for is REPORTED in the moves activity entry rather than vanishing, and a field setter naming one of those reserved keys is refused with malformed_override instead of writing it (BUG-2674); v0.23 closed the same door on the ordinary update — a field setter naming implementation_notes, decision_log or convention is now refused on every transport at once (validation_error on HTTP, surfaced to MCP clients as validation_failed); the one gate covers the CLI, remote MCP and stdio MCP at once because all three lower a field setter into the same fields_patch; github_pr is deliberately exempt ON UPDATE (move and copy still refuse it), since pad github link cannot run on remote MCP and refusing it would leave those agents with no door at all (that door is itself broken — BUG-2696); item CREATE stays open, deliberately, because its full-fields payload is shared with Pads own writers. v0.23 also added the retry-hostile stored_state_unreadable error code so an agent told its target items stored data is unreadable stops instead of retrying a permanent failure (BUG-2627 / BUG-2675); v0.24 made the pad_item fields object a real write form on create/update — reads return fields as a native object, and writing that shape back was a silent no-op (accepted, never mapped, dropped while the PATCH still bumped updated_at) — merging it into the same path as field/the dedicated params with conflicting duplicate keys refused, and made input validation strict across all catalog tools: undeclared top-level keys now fail with a structured error instead of being silently dropped (#1066); v0.25 made pad_library.activate resolve its DESTINATION collection from the targets declared artifact kind (SPEC-5 collection traits) rather than the literal conventions / playbooks slugs, so activating into a workspace that renamed either collection lands correctly instead of failing not-found with the collection sitting right there (BUG-2702); a lookup ERROR is now surfaced rather than silently falling back to the canonical slug, because falling back on an error means writing to a slug nothing was confirmed about (TASK-2657); see internal/mcp/version.go for the full changelog)

External agents pin against these so a future rename doesn't break them silently. Errors come back as structured envelopes ({error: {code, message, hint, available_workspaces, ...}}) with a closed code taxonomy — 17 codes as of v0.23, enumerated in internal/mcp/errors.go. Branch on code, not on message text; a code you don't recognize is possible, and stored_state_unreadable in particular means STOP rather than retry.

Full guide at getpad.dev/mcp/local — install paths, action enums per tool, error taxonomy, troubleshooting.

On Pad Cloud? Skip the install: add https://mcp.getpad.dev as a remote MCP server in Claude Desktop, Claude.ai, Cursor, or Windsurf and sign in with OAuth — same tool surface, no local binary. Setup guide at getpad.dev/mcp/remote.

CLI Reference

pad auth configure                    Configure how this client connects to Pad
pad auth setup                        Initialize the first admin account
pad auth login                        Sign in
pad auth whoami                       Show current user

pad server start                      Start the Pad API server
pad server stop                       Stop the Pad server
pad server info                       Show client, connection, and local server status
pad server open                       Open web UI in browser

pad workspace init [name]             Initialize workspace in current directory
pad workspace link <workspace>        Link current directory to an existing workspace
pad workspace list                    List all workspaces
pad workspace switch <workspace>      Switch active workspace
pad workspace context                 Show structured workspace context
pad workspace context set --file X    Update structured workspace context from JSON
# Workspace onboarding: run `/pad onboard` from an agent session inside the workspace
pad workspace members                 List workspace members
pad workspace invite <email>          Invite a workspace member
pad workspace join <code>             Accept an invitation
pad workspace export                  Export workspace data
pad workspace import <file>           Import workspace data

pad project dashboard                 Project dashboard
pad project next                      Recommended next task
pad project ready                     Query actionable next items
pad project stale                     Query stalled or attention-worthy items
pad project standup [--days N]        Daily standup report
pad project changelog [--days N]      Release notes from completed items
pad project watch                     Real-time activity stream
pad project reconcile                 Reconcile item and PR state

pad item create <coll> "title"        Create item (task, idea, plan, doc, ...)
pad item list [collection]            List items (filters: --status, --priority, --all)
pad item show <ref>                   Show item detail
pad item open <ref>                   Open item in web UI
pad item update <ref>                 Update item fields
pad item delete <ref>                 Delete item
pad item move <ref> <collection>      Move item between collections
pad item edit <ref>                   Open item in $EDITOR
pad item search "query"               Full-text search across all items
pad item comment <ref> "text"         Add comment to an item
pad item comments <ref>               View item comments
pad item note <ref> "summary"         Append an implementation note to an item
pad item decide <ref> "decision"      Append a decision log entry to an item
pad item block <src> <target>         Create dependency
pad item blocked-by <item> <blk>      Mark item as blocked
pad item deps <ref>                   Show dependencies
pad item unblock <src> <target>       Remove dependency
pad item related <ref>                Show direct relationships for an item
pad item implemented-by <ref>         Show incoming implementers for an item
pad item bulk-update --status X       Batch update multiple items

pad collection list                   List collections with item counts
pad collection create <name>          Create a custom collection

pad library list                      Browse convention and playbook library
pad library activate <title>          Activate a convention or playbook

pad agent install [tool]              Install /pad skill for AI coding tools
pad agent status                      Show supported tools and installation status
pad agent update                      Update installed tool integrations

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 webhook list             List workspace webhooks
pad webhook create <url>     Create webhook

pad session register         Record this session (harness pid + agent name) locally
pad session list             Registered sessions on this machine, with liveness
pad session prune            Remove records of sessions that are dead

All commands accept --format json for machine-readable output and --workspace to target a specific workspace.

Shell completion

pad ships completion scripts for bash, zsh, fish, and PowerShell:

# Bash — current session only
source <(pad completion bash)
# Bash — persistent
pad completion bash > /etc/bash_completion.d/pad                   # Linux
pad completion bash > $(brew --prefix)/etc/bash_completion.d/pad   # macOS (Homebrew)

# Zsh (make sure compinit runs in your ~/.zshrc)
pad completion zsh > "${fpath[1]}/_pad"

# Fish
pad completion fish > ~/.config/fish/completions/pad.fish

# PowerShell (append the output to your $PROFILE)
pad completion powershell | Out-String | Invoke-Expression

Beyond command and flag names, completion is context-aware: collection arguments (e.g. pad item list <TAB>) complete against your workspace's collections, --workspace completes configured workspace names, and --status / --priority complete their valid values.

Authentication

Pad runs without authentication by default for frictionless local use. For local installs, pad init creates the first admin account inline. The lower-level commands are useful when you're hosting a Pad server (Docker / remote) and need to set up auth on the server host directly:

pad auth setup         # Initialize the first admin account (server host, non-local mode)
pad auth login         # Sign in
pad auth whoami        # Show current user
pad auth logout        # Sign out

Once a user exists, all API requests and web UI access require authentication. Credentials are stored in ~/.pad/credentials.json. Multiple users can be invited to workspaces with role-based access control (owner, editor, viewer).

Authenticating with an environment token

Set PAD_TOKEN to a Pad API token (minted under Settings → API tokens in the web UI) to authenticate without pad auth login:

PAD_TOKEN=pad_xxxxxxxx pad item list

PAD_TOKEN takes precedence over credentials saved by pad auth login — the same convention as gh's GH_TOKEN. This is useful for CI, scripts, and machines where several AI agents share one CLI install but should act as different Pad users: give each agent its own token in its process environment, and the credential store is never touched. pad auth whoami reports the token's identity (with an Auth: PAD_TOKEN environment override line), and pad auth login/logout warn when the override is active — they manage the stored credentials, which the override bypasses. Deliberately, pad auth logout never invalidates the PAD_TOKEN session itself: it signs out the stored session only, and the env token's lifecycle belongs to wherever it was minted (revoke it under Settings → API tokens).

pad workspace members               # List workspace members
pad workspace invite user@example.com
pad workspace join <code>

Architecture

┌──────────────────────────────────────────────┐
│              pad (single binary)              │
│                                               │
│  ┌──────────┐  ┌──────────┐  ┌────────────┐  │
│  │   CLI    │  │  REST    │  │  Embedded  │  │
│  │ (Cobra)  │  │  API     │  │  Web UI    │  │
│  └────┬─────┘  └────┬─────┘  │ (SvelteKit)│  │
│       │    HTTP      │        └────────────┘  │
│       └──────────────┤                        │
│                ┌─────▼─────┐                  │
│                │  SQLite   │                  │
│                │  + FTS5   │                  │
│                └───────────┘                  │
└───────────────────────────────────────────────┘
  • Go backend — chi router, SQLite via modernc.org/sqlite (pure Go, no CGO), FTS5 full-text search, SSE for real-time updates
  • SvelteKit frontend — Svelte 5, Tiptap editor, drag-and-drop, adapter-static, embedded via go:embed
  • Single binary — serves the API and web UI, runs on macOS, Linux, and Windows
  • Workspace-per-project — each project gets its own workspace linked by a .pad.toml file

Self-hosted, all data lives in ~/.pad/pad.db. Your data. Your machine. No telemetry, no accounts required — cloud only if you opt in.

Community

  • r/getpad — how-tos, roadmap discussion, and notes from the agents that run Pad's own workspaces
  • GitHub Issues — bugs and feature requests
  • X / Bluesky — release announcements

Contributing

See CONTRIBUTING.md for the development guide.

make build      # Build web UI + Go binary
make test       # Run Go tests
make dev-web    # SvelteKit dev server with hot reload
make install    # Build, install to ~/.local/bin, restart server

Security

See SECURITY.md for reporting vulnerabilities.

License

Apache License 2.0

Languages
Go 64.9%
TypeScript 21.9%
Svelte 12.6%
Shell 0.3%
CSS 0.1%