xarmian c73584088f fix(watchevents): detect a half-open Redis connection with a bus heartbeat (BUG-2769) (#1199)
* fix(watchevents): detect a half-open Redis connection with a bus heartbeat (BUG-2769)

internal/watchevents had the same defect as internal/events did, by the same
mechanism: ChannelWithSubscriptions on a connection whose go-redis health check
only writes. PubSub.Ping calls writeCmd and returns without reading a reply
(v9.22.0), so a route that stops carrying traffic without closing is invisible —
the instance blocks on a read forever while its replay buffer goes on looking
complete.

Named as a class sweep in BUG-2738's filing and deferred there. It became
load-bearing when that unit shipped: docs/deployment.md told operators the gap
was "closed on the activity stream and still open on the watch stream". This
diff falsifies that, which is why the prose sweep is part of it.

THE PORT IS SMALLER THAN THE ORIGINAL BY DESIGN. This bus holds ONE
process-wide subscription created in its constructor, off any request path, so
none of BUG-2747's establishment machinery exists to interact with: no
per-workspace map, no establishment record, no single-establisher wall, no
concurrency cap, no bounded-parallel recovery, and no per-workspace cycle
scoping. Cost is flat too — one frame per instance per interval regardless of
workspace count.

NO COMPANION COUNTER, and that was CHECKED rather than inherited.
internal/events needs pad_event_subscription_cycled_total because its
dropWorkspaceCoverage returns early when a workspace has no buffer, so the reset
reason under-reports the early-wedge case. dropCoverage here has no such branch:
it replaces the buffer and reports unconditionally, so idle_timeout is a
complete count on its own and a second metric would be a number needing to be
explained against its neighbour for no signal.

THE RECEIVE LOOP NOW OWNS ITS SUBSCRIPTION AND CONTEXT. A cycle replaces the
subscription under a running bus, and the loop reading the old one must tell "I
was replaced" from "the client died" — the second logs an ERROR and moves a
counter documented to mean the instance has gone deaf. The cycle cancels that
loop's own context before closing its PubSub, so it leaves by the quiet door.
Its own test.

I PORTED A FLAW ALONG WITH THE STRUCTURE, and the wiring test caught it: both
maintenance halves shared one kick channel, so whichever goroutine was waiting
consumed it and the other stayed on the stale cadence. internal/events' mutation
matrix found exactly that (M11c) and fixed it; the fix did not come across. That
is the contamination hazard this port's grounding warned about, in its literal
form, caught by the CONVE-19 test rather than by review.

Two more found by mutation, both missing tests rather than missing code: nothing
asserted that ordinary traffic keeps the instance alive (removing the per-frame
stamp survived, because every other test drives idleness through the clock), and
nothing asked for a SECOND detection (a replacement inheriting stale stamps
gives a detector that works exactly once, which is worse than one that never
runs because it looks like it works). The second needed a direct assertion on
the install stamps, because the behavioural route re-stamps the field it was
meant to be testing.

Trio in one commit as required: reason enumeration, the
pad_watchevents_sequence_resets_total Help string, and docs/deployment.md — plus
the two BUG-2738 sentences this falsifies and a new section explaining how the
watch bus differs from the activity one.

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

* fix(watchevents): fence stragglers and re-validate before the drop (codex r1)

Three findings, and two of them are BUG-2738 fixes I again failed to bring
across with the structure. That is now three times in one port: the shared kick
channel, the stale idle decision, and the missing generation. The mechanism is
the same each time — I ported what the code DOES and not what its review
history taught it, and each was caught by a test or a reviewer rather than by
me reading the source I was copying.

STALE IDLE DECISION. cycleIfIdle decided under one lock and tore down under
another; a heartbeat or notification arriving between them left a demonstrably
alive subscription being dropped and every client on the instance resynced for
nothing. BUG-2738 fixed exactly this at its round 11. Re-validated immediately
before the drop, with a positional seam so a test can land the recovery inside
the window rather than racing it.

NO GENERATION FENCE. Cancelling a receive loop and closing its PubSub does not
JOIN the goroutine, and go-redis's channel is buffered, so a frame from a
replaced subscription could still stamp the replacement's liveness, append to
its buffer, or drop its coverage. On a wedged route that is the worst
direction: the dead connection's buffered tail suppressing the detector for its
successor. One check at the top of the frame handler covers all three, because
the three must agree about whether a frame belongs to the live subscription.
The probe stamp is fenced separately, since a slow publish can outlive the
subscription it was sent for.

A COPIED COST PARAGRAPH THAT CONTRADICTED ITS OWN SECTION. The activity bus's
"each workspace has its own subscription, N frames per interval" text sat below
the new watch-specific section saying the opposite. Retitled and moved above it.

FOUR INSTRUMENT DEFECTS ON THE WAY, all found by mutation:

- Nothing asserted ordinary traffic keeps the instance alive — every other test
  drives idleness through the clock, so removing the per-frame stamp survived.
- Nothing asked for a SECOND detection, so a replacement inheriting stale stamps
  gave a detector that works exactly once — worse than one that never runs,
  because it looks like it works. Needed a direct assertion on the install
  stamps, since the behavioural route re-stamps the field under test.
- The generation tests asserted the PREDICATE, not that the loop calls it.
- And that wiring test could not discriminate on a frozen clock, where a stamp
  writes the value already there. It advances the clock first now.

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

* fix(watchevents): make the generation fence atomic with what it guards (r2)

Two P1s, both mine, both the same shape: a check in one lock acquisition and the
mutation it guards in another.

THE FENCE WAS NOT ATOMIC WITH ITS MUTATIONS. One check at the top of the frame
handler read well and guarded nothing reliably — a replacement between that
check and stampLastSeen / fanOutFromRedis / dropCoverage let a straggler through
to any of them. The generation now travels TO each mutation and is re-checked
under the same lock that mutates. A stale notification entering the
replacement's buffer is the worst of the three: it makes the instance vouch for
a span it never received, which is the false coverage claim this whole family
exists to remove.

THE OLD GENERATION STAYED CURRENT ACROSS THE REPLACEMENT. subGen was
incremented only after the new subscription was confirmed, leaving the cancel,
the close, the dial and a round trip during which the OLD generation still
passed every fence. Retired at teardown now, so during resubscribe NO generation
is current and a late frame is ignored everywhere. That also makes the failure
path honest: the "no notifications until restarted" log was false — no
generation is current, so the next idle tick tries again.

Revalidation and the drop are now ONE critical section rather than two, for the
same reason at one level down: a frame arriving between them was silently
discarded by a drop already decided on.

Also: phase 1 no longer starts the maintenance goroutines, and the watch bus's
phase is logged at startup — an operator cannot read an absence of idle_timeout
without knowing whether the detector was running, and the two flags are
independent.

DOCS still described the workspace model in the section that claims to cover
both buses: one heartbeat "per subscribed workspace", a phase table naming only
PAD_EVENTS_HEARTBEAT, and coverage described as a workspace's. Generalised.

Two more instrument gaps, both found by mutation: nothing asserted a straggler
cannot enter the replacement's BUFFER (only the stamp was covered), and the
phase-1 goroutine gate is untested by design — removing it changes no behaviour,
only goroutine count, and the only assertion is a census that would be flaky
here. Said out loud rather than left to look like coverage.

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

* test(watchevents): prove each generation fence on its own

Round 3's fix put a generation check in each of the four places a frame
from a replaced subscription can mutate shared state, rather than one
check at the top of the receive path — a check in one lock acquisition
and a write in another is a TOCTOU, which is what codex blocked.

Four checks means four mutations, and the matrix found the first pass of
tests could not tell them apart: removing the append's check, or the
coverage drop's, left every test green. Not because the guards were
redundant — because no test drove those paths with a stale generation.
The straggler tests all enter through fanOutFromRedis, whose own guard
returns early and hides the one below it, and nothing at all drove
dropCoverageForGen with a straggler.

So the fences are asserted one at a time, each through the entry point
that actually reaches it:

  epoch bookkeeping   fanOutFromRedis with a foreign epoch — the loudest
                      of the four, since an accepted straggler would
                      rewrite the id space and resync every client on the
                      instance
  buffer append       fanOutLocally directly, under the guard above it
  coverage drop       dropCoverageForGen, previously undriven
  liveness stamp      stampLastSeen, which would otherwise let a dead
                      socket's traffic hold detection open

Each fails against removal of the single check it names (M16/M17/M19 and
the existing stamp mutation), and the four together still pass the
end-to-end straggler tests unchanged.

Refs BUG-2769

* test(metrics): prove the two new watch signals reach the registry

Both were wired and neither was asserted at the metrics layer, which is
where docs/deployment.md's claims about them actually live. A reason or
a callback that never reaches the registry is a runbook pointing at a
series that does not exist, and nothing in internal/watchevents can
catch that — its observer is an interface, satisfied by a test double.

  pad_watchevents_heartbeat_publish_failures_total  incremented six
  times, a count no other assertion in that test uses, so a callback
  wired to the wrong counter cannot land on the right number by
  coincidence. Fails when the increment is pointed at a neighbour.

  sequence_resets_total{reason="idle_timeout"}  asserted with the
  literal label, alongside the four spellings already pinned there and
  for the same reason BUG-2739's rename left that test behind. Fails
  when the constant drifts.

Also corrects the shared "what happens if you run them out of order"
paragraph, which moved under a heading covering both buses while still
describing only one: it said the frame travels on "the workspace's event
channel" and that an un-upgraded instance resyncs "for every workspace",
neither of which is the watch bus, where there is one channel and one
buffer per instance. The blast radius differs in scale between the two
and the paragraph now says so.

Refs BUG-2769

* docs(watchevents): correct three counted claims that stopped being true

All three said "three" where the code now has four, and each was
accurate when written — the fourth fence (the epoch bookkeeping in
fanOutFromRedis) was identified after them, in the pass that found the
matrix could not tell the guards apart.

That is the whole failure mode: a count is a claim, and a claim written
before the last change is wrong afterwards with nothing to notice it.
Two of the three sat inside a comment ABOUT how carefully the guards
were enumerated, and one names them now instead of counting them, so
the next site added has to appear in the list or contradict it visibly.

Found by sweeping the branch diff for counted prose rather than by
rereading, which is what had already missed them twice.

Refs BUG-2769

* test(config): close the other half of the two-flag independence claim

The flag tests asserted PAD_WATCH_HEARTBEAT does not move
EventsHeartbeat and stopped there, while the comment above them and the
deployment doc both claim the two buses roll INDEPENDENTLY. That is a
biconditional and one leg does not establish it: a Load() that pointed
PAD_EVENTS_HEARTBEAT at both fields passed everything. Now both
directions are asserted, and the events leg checks its own premise
first, so a fixture that stopped setting the flag fails as a fixture
rather than as a pass.

Also pins env-over-file precedence for the watch flag, in the direction
that actually matters: PAD_WATCH_HEARTBEAT=false over
watch_heartbeat=true in config.toml. That is the rollback for a bad
phase-2 flip, and an operator reaching for it mid-incident cannot be
editing a file on every host.

Mutation matrix, each detected: the env var wired to the neighbouring
field, the env var never read at all, and the toml tag dropped.

Refs BUG-2769

* test(watchevents): fix five tests that passed for the wrong reason

Codex round 4 went at test honesty rather than correctness and found no
BLOCK, but it found five assertions that hold whether or not the thing
they name works. Each is now driven through the path it claims, and each
was mutation-checked against the specific defect it exists to catch.

  the malformed-frame contract  only ever called isWatchHeartbeat. The
  predicate can be perfect while the receive loop routes every "hb|…"
  payload to the ignore arm without asking it, which is the defect, and
  the test's name promises coverage ends — a claim about the loop. Now
  published on the real channel, with a well-formed frame as the control
  so the assertion cannot be satisfied by a loop that finds everything
  undecodable.

  the receive-loop wiring test  published, slept 300ms, and asserted
  nothing had changed. A loop that stalled or never started satisfies
  that perfectly. There is no natural signal to wait on instead, because
  a frame the fence refuses is by design invisible — hence a seam that
  fires after the loop handles a frame whichever arm it took. Bounded,
  so a stalled loop fails with a message rather than a package timeout,
  and followed by a control that the same loop still accepts a frame
  whose generation matches.

  the quiet-exit test  asserted only that no loud exit was reported,
  which a replaced goroutine that never exits at all also satisfies —
  a leak, and the worse outcome. Now joins the loop first via a
  process-wide live-loop count, then checks the counter, so it is a
  statement about a goroutine that has finished.

  the maintenance-loop wiring test  claimed both halves and observed a
  heartbeat, which a loop that started only the publisher passes. The
  idle half cannot be proved there at all: against a live miniredis this
  bus's own heartbeats come back and refresh liveness every cadence, so
  wedging it with the loop running is a race against the publisher —
  which is what my first fix for this turned out to be, flaky at 2 in 3.
  Renamed to what it proves, pointing at the blackhole end-to-end test,
  which drives the scanner for real and detects both mutations.

  the straggler test  never delivered a straggler. It incremented subGen
  by hand, called isCurrentGen, and compared an unchanged timestamp
  without touching a mutation path — green with every fence removed.
  Deleted rather than repaired: the four-way per-fence test added
  earlier covers it properly, and isCurrentGen went with it.

Plus two ordering changes in Close/resubscribe that ARE NOT fixes for an
observed race, and say so in the test. Making b.pubsub reassignable made
Close's unlocked read of it look wrong, and resubscribe's wg.Add outside
the lock look like it could land after Close reached Wait. Both windows
turn out to be shut already by resubscribe's b.closed check, which sits
under the same acquisition as the count — reverting either fix leaves
the new Close-during-cycle test green. Kept as defence because the
invariant they lean on is three functions away, and documented so
nobody later reads them as evidence of a bug that existed.

Also corrects the metric help and two comments that said an idle cycle
"replaced the connection" when it attempts a replacement that can fail;
the deployment doc already said attempted. And the deployment doc's
rollback, frame-validation, what-to-watch and startup-log paragraphs,
all of which moved under a heading covering both buses while still
describing only the activity one.

Refs BUG-2769

* refactor(watchevents): drop an always-empty return and the branch reading it

dropCoverageIfStillIdle returned (string, bool) where the string was
never anything but empty — the reset it reports goes out through the
pending/flush path inside the lock, so the caller's `if report != ""`
was unreachable. A second reporting path that exists in the signature
and never fires is a thing a later change wires up by accident.

Refs BUG-2769

* fix(watchevents): a failed re-dial retries without re-dropping coverage

Codex round 5, on behaviour across a full Redis outage. No BLOCK; this
was its one P2 and it is real.

The probe-failure suspension does not cover this case, and the reason is
worth stating because the suspension looks like it should. Suspension
asks "did our last probe get through", and that can be YES with the
route already gone: the last successful publish stamps lastProbeOK,
Redis dies before that frame comes back, and lastSeen stays behind it.
From there both timestamps are frozen — the probe fails so nothing
stamps lastProbeOK, nothing arrives so nothing stamps lastSeen — and the
cycle's precondition stays true for the whole outage. Every pass then
dropped coverage, announced to every subscriber, and re-dialled.

Only the re-dial is owed. The second drop empties an already-empty
buffer and re-announces a hole every subscriber has been told about,
and it moves pad_watchevents_sequence_resets_total{reason="idle_timeout"}
once per cadence — so a five-minute outage read as ten incidents on the
series operators are told to alert on.

cycleIfIdle now has a retry-only arm ahead of the decision, entered when
there is no subscription at all, and the teardown clears b.pubsub /
b.subCancel so that state is representable. Clearing them also stops
Close closing an already-closed PubSub a second time.

Two tests, discriminating in OPPOSITE directions, because the obvious
fix for the noise is to suspend the pass and that would trade a noisy
outage for one the instance never returns from — retrying the dial IS
the recovery path:

  three passes with Redis away        one reset, not three
  Redis returns after a failed pass   the subscription is re-established
                                      and the counter does not move again

Matrix: removing the retry arm, making it return without retrying, and
leaving the torn-down subscription in place are each detected, the
middle one only by the recovery test.

internal/events has no equivalent defect. Its teardown deletes the
workspace's subscription entry, so its next scan finds nothing live and
abandons; recovery there runs off the request path.

Refs BUG-2769

* fix(watchevents): only one caller may install a replacement subscription

Codex round 6, verifying round 5's fix. No BLOCK; this was its P2.

Both the cycle and its new retry arm dial with the lock RELEASED, which
is deliberate — a Redis round trip under the bus's hot mutex would stall
every fan-out on the instance — so two passes can each find no
subscription and each dial one. Installing both is wrong twice over: two
receive loops would run on the SAME generation, so both accept every
frame and each notification is processed twice, and the loser's PubSub
would be untracked, closed by nothing including Close.

The install is what needs serialising, not the dial, so the loser
discards its own connection under the lock rather than the two racing to
overwrite b.pubsub.

Only the idle scanner calls this today, so this guards an invariant
rather than fixing an observed fault. Written down because the invariant
lives in a different file from the code relying on it, and because the
failure is silent duplication rather than a crash.

The test races two resubscribes through the install seam. Two details it
needed, both found by running it rather than reading it:

  the loop count is incremented INSIDE the goroutine, so sampling it
  right after the constructor returns reads zero — the first version
  did, and measured every later count against that wrong baseline. It
  waits for the loop now.

  the seam release is deferred, because without it the guard's mutation
  parks both callers in the callback, Close waits on receive loops that
  cannot start, and the detection arrives as a package-wide hang with no
  message. That is how the mutation first appeared to pass.

Also completes the idle_timeout reason in three comment/help sites that
still enumerated four reasons and said "the last two" — the same stale
count corrected in the observer contract earlier on this branch, missed
in its neighbours because I fixed the one the reviewer named instead of
grepping for the claim.

Refs BUG-2769

* test(watchevents): count installs instead of waiting for one that never comes

Codex round 7 returned no BLOCK and no P2 on the production code, and
two NITs on what round 6 added. Both are real.

The concurrency test synchronised on a WaitGroup expecting BOTH callers
to reach the install seam. Only the winner does — that is the property
under test — so in the passing case the goroutine waiting on it blocks
forever. A leak inside a test written to prove a leak does not happen is
not a shape to leave standing. An atomic the abandoning caller never
touches carries the same information and blocks nobody, and it removes
the release channel and its deferred close along with it.

The final assertion also moved off liveReceiveLoops and onto that
count. A loop starts AFTER its install, so reading the loop count can
catch a second caller's goroutine before it has begun and see the
passing value on a failing run. Both callers have returned by the time
the install count is read, so it is final. Detection over ten runs with
the guard removed: 10/10, where the loop-count version was a race
against a goroutine's first instruction.

Also softens the retry arm's log line. It said the instance receives no
notifications until an attempt succeeds, which is true for today's
single scanner and stale the moment there are two: one caller's dial can
fail while another has already installed. It now claims only what the
failing call knows.

Refs BUG-2769

* test(watchevents): hold both callers at the window, and say what that misses

Codex round 8's P2, on the test the previous commit rewrote. Starting
two goroutines from a start gate makes overlap likely and guarantees
nothing: one can finish resubscribe before the other begins, so the
window the install guard closes need never have been open.

A seam at the dial/install boundary — connection dialled, lock not yet
taken — lets both callers announce their arrival and wait for each
other. Now the window is open by construction rather than by luck, and
the test fails as a fixture if only one caller ever reaches it, instead
of passing on evidence it never gathered.

AND IT STILL DOES NOT DETECT EVERYTHING, which the test now says in
place of leaving it implied. Measured:

  guard removed entirely                        10 runs, 10 detected
  guard checked in its own acquisition, then    10 runs,  0 detected
  the lock retaken to install

The second is the regression round 8 asked about, and catching it would
mean landing the second caller inside a check-to-install gap that exists
only in the mutant — there is nothing to yield on there, and no seam can
be placed in code that is not written. So this test covers "a guard
exists", not "the guard is in the right critical section". The latter is
held by the comment at the guard and by review, and a test comment
claiming otherwise would be worth less than the honest note.

Refs BUG-2769

* fix(watchevents): make the frame seam and the cycle log tell the truth

Codex round 9 was asked whether this should merge and said hold for a
cleanup pass. Five findings, no correctness blocker, and every one of
them a claim that had stopped matching the code.

  the frame seam did not fire for every arm, though its comment said so.
  The arms that decline to act — a heartbeat, an undecodable payload, an
  unsubscribe confirmation — were `continue` statements, which skipped
  everything after the switch. A test waiting on the seam for one of
  those frames would have HUNG rather than failed, which is the worst
  way to find this out. The switch is now its own method so every arm
  ends the frame by returning, and a test drives one frame per
  publisher-reachable arm and counts three. Detected against restoring
  the skip.

  the idle-cycle warning was emitted before the revalidation that can
  abandon the cycle, so it could announce coverage ending and resumes
  answering sync_required for a subscription that was then left alone —
  a log line with no counter behind it, and an on-call hunting a bug
  that is not there. internal/events learned this at its own round 6;
  the reason did not come across with the port. Moved after the decision
  is final, still saying "attempting" to replace because the resubscribe
  can fail.

  the quiet-exit test sampled liveReceiveLoops instead of waiting for
  it, so its "the replaced loop left" assertion could be satisfied by a
  loop that never ran. Same defect fixed in the sibling concurrency test
  a commit earlier and missed here, because I looked at the test the
  reviewer named rather than at the pattern. Latent rather than
  observed: sampling survives 10 runs, so this removes a possibility.

  the probe-failure log and metric help called an errored Publish a
  failure to publish. A returned error can also mean the reply was lost
  after Redis accepted the frame, so the honest claim is that the probe
  is UNCONFIRMED. It changes no behaviour — an unconfirmed probe is not
  evidence about the receive path either, so detection suspends the same
  way — but an operator reading the counter should not be told more than
  the instance knows.

  the deployment doc said the watch stream differs in "three things" and
  listed four, the fourth being the bullet I added last round. Third
  instance of that species on this branch; the count is gone rather than
  corrected.

Refs BUG-2769

* docs(watchevents): stop one unconfirmed probe standing in for a broken path

Codex round 10 confirmed four of round 9's five fixes and held the fifth
as partial. It was right on all three residual sites.

Renaming the condition to "could not confirm" did not fix the sentences
downstream of it. The log still said silence cannot be read as a finding
"when we could not ask" — but we may well have asked, and lost only the
answer. And both the metric help and the observer contract said an
instance in this state "is also failing to deliver its own notifications
to every other instance", which is a conclusion about the outbound path
drawn from a single call that did not come back.

The inference is sound at a SUSTAINED rate and worthless at one
increment, so both now say which is which. That distinction is the whole
value of the counter to an on-call: a blip is a lost reply, a rate is a
broken path, and the same wording for both makes the first look like the
second.

No behaviour change. An unconfirmed probe suspends detection exactly as
a definite failure does, because it is not evidence about the receive
path either way.

Refs BUG-2769

* docs: sweep the BUG-2738 prose this change makes false

BUG-2738 shipped documentation that describes the watch stream as still
carrying the half-open defect. Merging this makes those sentences wrong,
and I flagged the sweep as owed twice during the groundwork and then did
not do it — the lead caught that the package said nothing about it.

Five sites, each re-read after editing rather than grepped for, because
grepping for a phrasing I chose is how I have twice verified a sweep
that had not landed:

  the residual enumeration opened "One gap remains everywhere, and a
  second remains on the watch stream only", then described one gap and
  said it was open on both. The second WAS the half-open case. Now
  states one gap, on both streams, and says where the second went.

  the half-open paragraph already said "closed on both streams" — the
  one site I had fixed — but omitted that each half is behind its own
  phase-2 flag, so a reader takes it as closed on their deployment when
  it is closed only once they turn it on.

  "A third residual" counted the item it followed. With the second gone
  the ordinal was wrong; it does not need one.

  "these two gaps" in the closing sentence, same arithmetic.

  the pad_event_subscription_cycled_total row told an operator to read
  heartbeat_phase off the startup log. There are now two such fields on
  two lines under two flags, and only one bears on that counter. It
  names the line.

No code change; suite 28/28 and lint 0 re-run because the branch is
under review and a docs commit that skips them is a commit nobody
checked.

Refs BUG-2769
2026-08-25 11:04:18 -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.

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

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 65.2%
TypeScript 21.3%
Svelte 12.9%
Shell 0.3%
CSS 0.1%