Files
paperclip-harness/docs/PAPERCLIP-API-NOTES.md

14 KiB
Raw Blame History

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: paperclipai 2026.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, 2 blocked, 2 in_progress, 1 todo, 1 backlog.
  • 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}/blockers or /api/issues/{id}/diagnostics/blockers rather 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_local is the identity's own adapter, and Pi is listed as a supported runtime upstream.
  • H5 — cost and run history readable. PASS. /api/issues/{id}/cost-summary returns costCents, 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-157 Spike T1 — write a file (2ce09ea8-…)
  • AUD-158 Spike 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 hash b093301148b62ab0f2a34bec4f31d97a26a212e9 … 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_ancestry is 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 stay unmerged until 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.