13 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".