# 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":[""]}` → 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 `/.paperclip/worktrees/`; 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".