21 KiB
Paperclip API — read-only probe, 2026-11-09
Probe only: no writes, no task created, nothing in the existing projects touched. All
requests were GET with Authorization: Bearer $PAPERCLIP_API_KEY read from the
environment; the key is never printed here.
Where it runs
- Local server:
http://127.0.0.1:3100, pid 1588, started 2026-11-05. From~/.paperclip/instances/default/runtime-info.json. - Public URL:
https://paperclip.lab.audasmedia.com.au. - Postgres:
postgres://paperclip@127.0.0.1:5433/paperclip(embedded, local). - CLI:
paperclipai2026.916.1, managed install under~/.paperclip/cli/installs.
Endpoints that answered
| Request | Result |
|---|---|
GET /api/health |
200, 899 bytes |
GET /api/openapi.json |
200, 1.1 MB — 685 paths |
GET /api/agents/me |
200 — identity, company, adapter |
GET /api/companies/{companyId}/issues?limit=200 |
200 — 145 issues |
GET /api/issues/{id} |
200 |
GET /api/issues/{id}/comments |
200 |
GET /api/issues/{id}/activity |
200 |
GET /api/issues/{id}/cost-summary |
200 |
GET /api/issues?limit=5 |
400 — "Missing companyId in path" |
The identity the API answered as
name: Hub, role: general, status: idle, adapterType: **pi_local**,
model omni/deepseek/deepseek-v4-flash. So Pi is the installed worker adapter, not
something we must write.
What already exists in this instance
- 10 projects, 145 issues: 139
done, 2blocked, 2in_progress, 1todo, 1backlog. - Every status the four-screen TUI needs is already a first-class value.
Endpoints the TUI and the executor will need (from the 685 in the spec)
- Read:
/api/companies/{companyId}/issues,/api/issues/{id},/api/issues/{id}/comments,/api/issues/{id}/activity,/api/issues/{id}/children,/api/issues/{id}/diagnostics/blockers,/api/issues/{id}/cost-summary,/api/issues/{id}/runs,/api/issues/{id}/heartbeat-context,/api/agents/me/inbox-lite. - Write (later, not in this probe):
/api/issues,/api/issues/{id}/comments,/api/issues/{id}/checkout,/api/issues/{id}/release,/api/approvals/{id}/approve|reject|request-revision,/api/cases/{caseId}/blockers,/api/issues/{id}/interactions/{interactionId}/respond. - Trees and holds:
/api/issues/{id}/tree-control/state|preview,/api/issues/{id}/tree-holds,/api/issues/{id}/diagnostics/subtree.
Hypotheses
- H1 — the control plane answers. PASS. Health, identity, 145 issues.
- H2 — task with a blocker via API. NOT TESTED (a write; out of scope for a
read-only probe). Note: the two blocked issues returned
blockedByIssueIds: None, so the blocker relation may live in/api/cases/{caseId}/blockersor/api/issues/{id}/diagnostics/blockersrather than on the issue row. This must be established before trusting screen 1. - H3 — a comment wakes an agent. NOT TESTED. The wake plumbing is visible in the
spec (
heartbeat-context,inbox-lite,queued-comments/steer,diagnostics/wakes), and agents run on heartbeats, so the shape is right. Unproven. - H4 — Pi can be a worker. Likely PASS:
adapterType: pi_localis the identity's own adapter, andPiis listed as a supported runtime upstream. - H5 — cost and run history readable. PASS.
/api/issues/{id}/cost-summaryreturnscostCents,inputTokens,cachedInputTokens,outputTokens,runCount.
What this changes
The premise holds: there is a live control plane, with the statuses, threads, activity, cost and blocker diagnostics a four-screen TUI needs, and Pi is already the adapter. The work that remains is small and specific: prove a blocker edge by API, prove a comment wakes an agent, and write the executor that puts a task into a git worktree, runs it and reports back.
Sandbox writes, 2026-11-09 (approved in advance)
Sandbox created: project spike-2026-11-09 (9e3f61f6-cce9-49b1-8083-3130345a04a5),
status backlog, workspace git_repo at /tmp/pc-sandbox (a throwaway repo with one
commit). It is safe to delete.
Two issues created by API, status todo:
AUD-157Spike T1 — write a file (2ce09ea8-…)AUD-158Spike T2 — read that file (abc5d318-…)
The permission rule, learned the hard way
With PAPERCLIP_API_KEY (the agent Hub), every write returned 403:
"Cross-issue writes need a run to attribute them to (Heartbeat run context). Every agent comment and task update is attributed to a heartbeat run so the cross-issue cap can be counted and the audit trail can name who acted for whom."
So an agent key is only usable inside a heartbeat run, with X-Paperclip-Run-Id.
A cockpit acting for the person must use the user credential
(PAPERCLIP_BOARD_KEY), not the agent key. That is a design fact, not an obstacle: the
TUI is the person's surface, and the audit trail correctly refuses anonymous agent
writes.
H2 — a task with a blocker: PASS
PATCH /api/issues/{id} with {"blockedByIssueIds":["<t1>"]} → 200, and the edge reads
back. GET /api/issues/{id}/diagnostics/blockers returns:
readiness: { allBlockersDone: false, isDependencyReady: false, unresolvedBlockerCount: 1 }
blockers: [ "Spike T1 — write a file" ]
Note: the blocker edge is not on the issue list rows (blockedByIssueIds: 0 there).
A tree view must read diagnostics/blockers (or the single-issue GET) per task, not the
list. That is the one place screen 1 could have lied to us.
H3 — a comment lands on a blocked task: PASS
POST /api/issues/{id}/comments with the user key → 201, authorType: user, and the
comment is on the thread of the blocked task. What this does not yet prove is that a
comment wakes an agent: that requires a real agent on the task and a heartbeat. The
wake plumbing is present in the spec (heartbeat-context, inbox-lite,
queued-comments/steer, diagnostics/wakes); the wake itself is unproven.
Updated verdict
H1 pass, H2 pass, H3 partly (comment lands; wake unproven), H4 likely (Pi is the
adapter), H5 pass. The remaining proof is one real agent doing one task, which is
Milestone 1 of plan/92-paperclip-spike.md, not a probe.
One more thing worth knowing
A project can be created with an executionWorkspacePolicy whose workspaceStrategy.type
is git_worktree, with baseRef, branchTemplate, worktreeParentDir, provision and
teardown commands. Paperclip may already do the worktrees we assumed we would build.
That deserves its own probe before any executor is written.
Milestone 1 measured, 2026-11-10 (writes, sandbox only)
Everything below was run with the user key (PAPERCLIP_BOARD_KEY). The key is never
printed. The sandbox is spike-2026-11-10 and its scratch repository
/tmp/pc-sandbox-2026-11-10. Both are safe to delete.
The order that works
The board guide's rule holds: never create a task and assign it in the same action. The four calls, in order, with the result each one returned:
| Call | Result |
|---|---|
POST /api/companies/{company}/projects |
201, project 17adbb47-f966-4a82-a8ef-e48c941443ea, workspace 4d4eb32a-08a8-4e3e-88c9-2a8cedfc288b |
POST /api/companies/{company}/issues ×2 |
201, AUD-159 34abf83f-…, AUD-160 21d1f49e-…, both todo, unassigned |
PATCH /api/issues/AUD-160 {"blockedByIssueIds":["34abf83f-…"]} |
200, echoed blockedBy with AUD-159; diagnostics then said unresolvedBlockerCount: 1 |
PATCH /api/issues/AUD-159 {"assigneeAgentId":"403de1f7-…"} |
200, todo |
The activity record of the assignment names the person, not an agent:
actorType: "user", authorizationReason: "allow_board_actor". That is the audit trail
the user key exists for.
The wake, timed
00:01:24.891Z PATCH assigneeAgentId -> 200
00:01:24.937Z wake requested source=assignment reason=issue_assigned status=claimed
00:01:25.036Z run aae77e89-7059-43b4-9154-972282770212 started, issue -> in_progress
00:01:51.351Z comment posted by agent 403de1f7-…
00:01:53.153Z issue -> done
00:01:59.377Z run -> succeeded (resultJson.stopReason = "completed")
GET /api/issues/AUD-159/diagnostics/wakes returned that single wake request and the
plain-English line "The most recent wake for AUD-159 is currently claimed for
issue_assigned." This endpoint answers the question we used to have to guess at: it names
each wake, the run it produced, and why a wake did not happen.
H3 is now PASS: a real agent woke on assignment, worked, commented and closed the task with no person in the loop and no timer.
What the agent reported
Comment 822f2924-dd2f-4f26-9046-c7801f2196a9, author agent, id 403de1f7-…:
Created
hello.txt… Commit hashb093301148b62ab0f2a34bec4f31d97a26a212e9… Working directory (absolute path):/tmp/pc-sandbox-2026-11-10… Branch:main.
Checked on disk: git log --oneline shows b093301 add hello.txt on main, the file
holds the expected line, and git status is clean.
Cost, read back
GET /api/issues/AUD-159/cost-summary →
costCents: 1, inputTokens: 101002, outputTokens: 1515, runCount: 1,
runtimeMs: 34341. The run's own usageJson says costUsd: 0.00749434.
No worktree was created, and why
The task's executionWorkspaceId and currentExecutionWorkspace are null. The run
worked in the project folder on main, so a second task would collide rather than work on
its own copy.
Cause, measured: GET /api/instance/settings/experimental returns
enableIsolatedWorkspaces: false and enableWorktreeRunExecution: false. With the first
flag off, Paperclip throws away the task's workspace settings before dispatch, so the mode
resolves to the shared project folder even though the project asked for git_worktree.
The supporting code is in the installed server:
dist/services/heartbeat.js sets issueExecutionWorkspaceSettings = null when the flag is
off, and dist/services/workspace-runtime.js is where git worktree add would run.
Default worktree location is <repo>/.paperclip/worktrees/<branch>; default branch
template is {{issue.identifier}}-{{slug}}.
Consequence for the plan. The "Paperclip makes the worktree for us" assumption is
true only with an experimental instance setting switched on. Until a person turns it on,
every task shares one folder and merges happen on main with no branch and no review
step. That is a decision to put to the person, not a bug to work around.
Second task left where it is
AUD-160 stayed todo, unassigned, blocked by a task that is now done. Nothing woke,
because no agent is assigned to it. Whether the issue_blockers_resolved wake fires for an
assigned dependent task is still unmeasured.
Worktrees switched on, measured, 2026-11-10
The first worktree attempt proved the flags, not the strategy: with
enableIsolatedWorkspaces: false the project's git_worktree policy was discarded before
dispatch. That is recorded above and is worth keeping, because it is what an operator will
see on any fresh instance.
After switching on enableIsolatedWorkspaces and enableWorktreeRunExecution (and leaving
enableIsolatedWorkspacesByDefault off, so the other projects are untouched), the same
project produced a real worktree on the next task:
| Fact | Value |
|---|---|
| Task | AUD-160, run 11a94551-dd12-4b13-92fe-835ea63d7753 |
| Workspace id | 7900dbf1-692c-4217-aebb-5d539c30aa98 |
| Mode / strategy | isolated_workspace / git_worktree |
| Branch | AUD-160-milestone-1-t2-read-hello-txt |
| Path | /tmp/pc-sandbox-2026-11-10/.paperclip/worktrees/AUD-160-milestone-1-t2-read-hello-txt |
| Base | main at b093301 |
| Delivery state | merged_by_ancestry (the task made no commit) |
Paperclip wrote its own comment on the task, titled Workspace Ready, naming the
strategy, the branch and the working directory. git worktree list shows the new path, and
git branch shows the new branch. The file hello.txt from the first task was present in
the worktree, so the branch really was cut from the finished work.
Two consequences worth remembering:
- Name the branch with a template. The default is
{{issue.identifier}}-{{slug}}; usable placeholders include{{issue.identifier}},{{slug}}, and the project and agent. Without a template every worktree of the same task would land on the same name. merged_by_ancestryis not a merge. A task that changes nothing leaves its branch at the base commit, and Paperclip marks it delivered because the branch adds nothing. A task that does commit will stayunmergeduntil something merges it. That is the state the review gate will read.
Budgets as they stand
Read from GET /api/companies/{companyId}/agents:
| Agent | Monthly budget (cents) | Spent (cents) |
|---|---|---|
| Builder | 5000 | 2 |
| Chief of Staff | 5000 | 0 |
| System | 3000 | 0 |
| researcher | 2000 | 0 |
| Documenter | 2000 | 0 |
| Hub | 0 | 1 |
The company's budgetMonthlyCents is 0. There is no currency field anywhere in the payload:
Paperclip counts cents and does not name a currency, so "5000" is a number, not "$50 AUD".
Milestone 2 passed from the terminal, 2026-11-10
The reply screen sent one call that both comments and changes status:
PATCH /api/issues/AUD-162 {"status":"todo","comment":"Unblocked: AUD-161 is done, …"}
→ 200, status todo
Read back afterwards:
GET /api/issues/AUD-162/comments → 1 comment, authorType: user, authorUserId: 2204TMD0…
GET /api/issues/AUD-162/activity → actorType: user, issue.comment_added, then
issue.updated status {from blocked, to todo}
Before the move, AUD-161 was set to done and diagnostics/blockers for AUD-162
returned allBlockersDone: true, unresolvedBlockerCount: 0 while the task itself stayed
blocked. So Paperclip does not walk a task forward on its own when its blocker clears; it
makes the task ready and leaves the decision to a person or a program. That is the job the
reply screen does, and the reason this milestone exists.
Milestone 3, from the terminal, 2026-11-10
Four screens now run as panes in the session paperclip-harness, on workspaces named
shell, tree, reply, new and log.
Screen 2 made a two-task plan
Typed in the new pane, twice, and read back from the API:
| Task | Created as | Blocker | Assignee |
|---|---|---|---|
AUD-163 |
todo |
none | none, until assigned from the tree |
AUD-164 |
todo |
AUD-163 via a separate PATCH |
none |
GET /api/issues/AUD-164/diagnostics/blockers then said
unresolvedBlockerCount: 1, and the tree drew [AUD-163 todo] against it. The verify
command typed in the form is stored in the task's own description:
Create NOTES.md at the repository root. …
Done when this command succeeds:
test -s NOTES.md
Assigning is a separate key (a in the tree), never part of creation. That is what woke
Builder both times: runs[0].invocationSource is assignment.
Screen 3 showed the work happening
Read from the log pane while the run was live, not afterwards:
runs
running assignment 2026-11-10T01:12:28.391Z
diff and worktree
worktree: …/worktrees/AUD-163-write-notes-md-with…
branch: AUD-163-write-notes-md-with-the-three-milestone-names base: main
no commits on this branch yet
uncommitted
?? NOTES.md
and after it finished:
succeeded assignment 2026-11-10T01:12:28.391Z → 2026-11-10T01:13:01.683Z
commits
754cdb5 AUD-163 add NOTES.md with the three milestone names
changed
NOTES.md | 3 +++
diff
diff --git a/NOTES.md b/NOTES.md
The gap this exposed: nothing merges
main never received NOTES.md:
$ git -C /tmp/pc-sandbox-2026-11-10 cat-file -e main:NOTES.md
fatal: path 'NOTES.md' does not exist in 'main'
So AUD-164, whose branch was cut from main, had no file to read. The agent worked
around it by reading the other task's branch directly —
git show AUD-163-write-notes-md-with-the-three-milestone-names:NOTES.md — and reported
that in its comment. Two tasks both show done, and the handoff never passed through
main.
A dependent task that finds its predecessor's work by reaching across branches is not a merge and not a review. Milestone 5 (merge one at a time, re-checked) is what closes this, and until it exists the second task in any chain is working from a guess.
Two behaviours worth knowing
A corrective wake is real. AUD-164's first run ended succeeded without setting a
status, and Paperclip immediately started a second run with invocationSource: automation to ask the agent to finish what it started. The docs call this
finish_successful_run_handoff. It doubles the cost of an agent that forgets to set a
status, and our screens should show that second run rather than hide it.
A comment can be attributed to the person when an agent wrote it. The first comment on
AUD-164 is authorType: user, and the agent's own next comment says the text "landed
under the board identity by mistake". So authorType: user is not proof that the person
typed it. Screen 4 currently prints user as "you"; that label is wrong often enough to
matter, and it needs a better rule than the author type alone.
Milestones 4 and 5, measured, 2026-11-10
The project layer
bin/office <folder> writes exactly one file, <folder>/.harness/harness.toml:
session = "paperclip-harness"
watching = "AUD-163"
[paperclip]
company = "d4921013-e5da-4d88-8016-57d5746e2d03"
project = "17adbb47-f966-4a82-a8ef-e48c941443ea"
[machine]
host = "nixos-desktop"
folder = "/tmp/pc-sandbox-2026-11-10"
That is the whole of what we store: which folder, on which machine, in which session, showing which task. Everything else is read from Paperclip.
Measured, in this order, on one project:
| Step | Result |
|---|---|
bin/office |
wrote the file, built five panes on workspaces 1–5 |
| run again | five panes adopted, none created |
bin/board --replace |
five closed, five created, then placed 1–5 in order |
bin/project-reset --dry-run |
listed the panes, three worktrees and three branches |
bin/project-reset --yes |
removed them; git worktree list left only the main checkout |
bin/office afterwards |
five panes built again from nothing |
Two failures found by doing it:
- A rebuild scatters the workspace order. Closing panes renumbers the rest, so the
pages landed as shell 2, tree 3, reply 4, log 5, new 1. The fix is a final pass that
moves each pane once to the workspace it belongs on (
tuios move-window) and names it again. Without that pass the rail order is meaningless. - Adopting a project must not invent a session. The first version defaulted the session
to the folder name, wrote that into the file, then failed because no such session
existed — and the good session name was already overwritten. Sessions are made by a
person, so
bin/officenow reads the recorded session first, checks it exists, and refuses with the exacttuios newcommand when it does not.
The merge gate
bin/merge takes a lock (flock on /tmp/paperclip-harness-merge.lock), rebases the
task's branch onto the base, runs the task's own verify command, runs ocr over the
rebased range, and only then merges — then runs the verify command again on the base
branch and rolls the merge back if it fails.
Every branch of that path was exercised, not reasoned about:
| Case | What happened |
|---|---|
clean task (AUD-163, docs only) |
merged at 2899981; ocr reported skipped, 0 files, and said so instead of pretending to have reviewed |
| verify command fails | exit 4, task left alone, comment written |
| review finds a critical | AUD-165 added cleanup.sh with rm -rf "$HOME/tmp"; exit 5, finding quoted, cleanup.sh never reached main |
| review cannot run | exit 5, refused |
| another merge holds the lock | exit 6, refused immediately |
ocr's JSON has status, summary.files_reviewed and comments[] with severity
(critical, high, …), path, start_line and content. Blocking severities are
critical and high; skipped with zero files is "nothing to review", recorded
plainly, while any other non-complete status refuses.
Two API behaviours that shape the code
A comment on an assigned task wakes its agent. The merge tool's first refusal comment
went onto a task still assigned to Builder, and Builder immediately woke and took the
task back to in_progress — paying for a run to read a message meant for a person. So a
tool's verdict is written only after the task is handed back:
PATCH {"assigneeAgentId": null, "assigneeUserId": "<the board user>"}. A person's reply
from screen 4 is the opposite case: there the wake is the point.
The issue list is a summary. GET /api/companies/{id}/issues carries no
currentExecutionWorkspace and no blockers; the single-task read has both. Any code that
needs a worktree path, a branch or a blocker must do a second GET /api/issues/{id}.
That is the third time this list-versus-record difference has bitten, after blockers and
after the earlier probe.