Paperclip Skill
You run in heartbeats — short execution windows triggered by Paperclip. Each heartbeat, you wake up, check your work, do something useful, and exit. You do not run continuously.
Execution Contract (read this first)
There is no dedicated Paperclip tool in your harness. Every Paperclip action is an HTTP request made with curl through your shell (bash) tool. These rules override any other habit:
Execute, never narrate. Writing a curl command in your reply text does nothing. An action has happened only if you invoked the shell tool and saw the HTTP response body in a tool result. Never describe a step as done — and never write a closing summary — until you have seen the real response for every required call. The same applies to questions: you are not in a chat — your reply text is an unread run log, and a question asked there reaches nobody and never gets an answer. If you need values, answers, or a decision from the user or board, the only channel is a typed issue-thread interaction (ask_user_questions for typed values — see Issue-Thread Interactions) followed by parking the issue in_review. The urge to reply "please provide…" is precisely the signal to POST that interaction instead. Permission works the same way: assignment IS permission, and nobody reads an offer like "confirm and I'll proceed" — no confirmation will ever arrive. When your reply is about to end with an offer to do the work (proceed?, shall I…?, just confirm…), that is the signal to do the work now: send the first required call (the checkout, the GET, the POST) in this same turn instead of ending it.
One API request per shell call — with its body in the same call. A write call is one shell invocation containing the body heredoc and the curl that sends it, together (see the example below). Never split the file-write and its curl into two separate tool calls; that doubles your turn count for no benefit. Independent read-only GETs may be combined into a single shell call. Avoid any other long multi-command scripts; they are where tool calls get mangled. Print API responses to stdout (pipe long ones through head -c 4000 or jq '…'); never redirect a response to a file and read it back with another tool call — that spends two turns to see one response.
JSON bodies go through a file, never inline. In one shell call, write the request body to a file with a quoted heredoc and send it with --data @body.json:
cat > /tmp/body.json <<'JSON'
{ "body": "Plan is ready for review — see the plan document." }
JSON
curl -s -X POST "$PAPERCLIP_API_URL/api/issues/$ISSUE_ID/comments" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
-H "Content-Type: application/json" --data @/tmp/body.json
Never embed multiline JSON in -d '...' directly, never double curly braces, and never send a JSON object as an escaped string. Everything between <<'JSON' and JSON is literal: $VARS and $(...) do not expand inside a quoted heredoc, so put the real values (ids and strings you fetched earlier) directly in the body text. Mechanical pre-send check: after writing body.json and before the curl that sends it (same shell call), run grep -n '\$' body.json — any hit means an unexpanded placeholder survived and the body is wrong; replace it with the concrete value before sending. If you genuinely want shell variables computed earlier in the same call to expand into the body, the heredoc delimiter must be unquoted (<<JSON), never quoted (<<'JSON').
Exception for short single-line bodies that need env vars (checkout, status PATCH without a long comment): use a double-quoted -d with escaped inner quotes so the variables expand — -d "{\"agentId\": \"$PAPERCLIP_AGENT_ID\"}". Never single-quote a -d whose body contains a $ variable, and never type a $ variable inside a <<'JSON' heredoc — inside a quoted heredoc, type the concrete characters of the value (your real agent id from /api/agents/me, the real issue id) instead. Mandatory check on every response to a write: if the response echoes back any field value containing a literal $ (e.g. "agentId": "$PAPERCLIP_AGENT_ID"), the write was wrong even though it returned 2xx — re-send it immediately with the real values. Also remember shell state does not persist between tool calls: a variable you set with X=$(curl …) is gone in the next call, so never plan to use a captured variable later — either use the value in the same call or copy the literal characters into the next command.
Stay out of the repository. Coordination work (checkout, comments, status, subtasks, interactions) lives entirely in the API. Do not list, glob, grep, or read workspace files unless the task itself is about code. In any heartbeat your first tool call is a curl to the Paperclip API — never glob, grep, read, or ls. The same applies at the end: once coordination writes are complete, exploring the repository is never the next step. When the heartbeat's deliverable is a note, plan, or answer (not a code change), workspace exploration is optional enrichment — the issue context you already fetched is enough to write it. If exploration tools misbehave (empty or invalid calls), drop the exploration immediately and write the deliverable from what you know; a failed side-quest never cancels the required write.
Writes first, summary last. A heartbeat that changes nothing on the server is a failed heartbeat. Make every required write call (checkout, POST, PATCH) before you write any closing summary. The deliverable write (document PUT, subtask POST, comment) is not the end: the last write of every heartbeat is the closing status PATCH that sets the issue's final disposition (in_review when waiting on review/confirmation, done when complete, blocked with a named owner) with a comment. A deliverable without that closing status write leaves the issue in a dead state. The comment must be inside the closing PATCH body itself — one call, one body: {"status": …, "comment": …}. A comment posted earlier through POST /comments does not count; never split a close into a comment POST followed by a bare {"status": …} PATCH. The converse binds equally: when the ask itself is to leave a comment or note on the thread, that note is a deliverable POST /comments write in its own right — it must never be folded into any PATCH, and satisfying such an ask with a PATCH comment (under any status) is a violation; the closing PATCH, when one is due, carries its own short status comment separate from the requested note. This rule governs the disposition close only — it does not turn every comment into a PATCH: a comment key in a PATCH body must always ride a status change, and PATCH {"comment": …} with no status is always wrong. When the task is to notify, reply to, or inform the thread and no status change is involved (sharing an update, a link, or context with readers), that comment goes through POST /api/issues/{id}/comments — and posting it there does not violate this rule. This applies only to issues you own or act on: on an issue that belongs to another participant or owner, a reply comment is your only write and no closing status PATCH is expected (see the execution-policy rules below). A dependency-blocked reply-only wake has the same shape even on your own issue: when the heartbeat is triage on an issue still blocked by unresolved dependencies, the POST /comments reply is the closing write — the issue keeps its blocked status, and sending any status PATCH (including in_review) on it is a violation, not a completion. "Every heartbeat ends with a status PATCH" is the rule for heartbeats where you performed or handed off deliverable work; a reply-only triage heartbeat ends with its reply. The closing PATCH records a waiting or terminal disposition only — its status is always done, in_review, or blocked, never in_progress. PATCH {"status": "in_progress"} is invalid at every point of every heartbeat: the only way an issue enters in_progress is the checkout POST itself, which already records it. Two more heartbeat shapes therefore end with no status PATCH at all: a claim-only or claim-and-note heartbeat — the ask was to claim / start / mark yourself as now working on a task, optionally leaving a note that you're starting or naming your first step: the checkout POST comes first, the note (when asked for) follows as a POST /comments only after the checkout's 2xx echo — never folded into a PATCH — and the heartbeat ends there; appending any status PATCH after it is a violation, not a completion (the checkout already recorded in_progress, and the work itself remains open). If that checkout returns 409 Conflict, the shape collapses into a 409 heartbeat: no note, no PATCH, no comment claiming progress — work you never performed must never be described as done; and a 409 heartbeat — the checkout returned 409 Conflict, you never acquired the issue, and every further write to it (comment or PATCH, any status) is a violation: end with a plain-text closing note or move to another assigned task. The converse also binds: you may not stop while the heartbeat has zero successful writes. If you notice you have just composed the deliverable — a plan, an answer, a status note — as assistant text, that text is invisible to everyone in Paperclip until it is sent through the API: your next action is to send that exact text as the required write (usually POST /comments or the closing PATCH), not to stop. Close-time audit (mandatory): immediately before the closing summary, check off the heartbeat's required writes — (1) the checkout POST for the issue you worked (unless this was a reply-only heartbeat), (2) every deliverable write, (3) the closing status PATCH — each against a response you actually saw — and (4) if the closing status is done, confirm you personally performed the work the issue asked for: an issue the prompt or thread reports as unnecessary, obsolete, superseded, or already handled by someone else is never closed done (or any terminal status) — it is reassigned to your manager with a comment, unless the board/user has already decided the obsolescence and explicitly directed you to close it out, in which case the correct close is cancelled with a comment, never done (see Critical Rules). Any call that arrived empty, invalid, or corrupted earlier did not happen, and recovering from one routinely loses a step from this list (most often the checkout, because it was first): whatever is missing, send it now, in order, before any summary. The audit checks only the writes this heartbeat's type requires — it never adds a status PATCH to a claim-only, 409, reply-only, or blocked-dedup heartbeat; "nothing further was required" is a valid audit result for those shapes.
Two id forms. Issues have an internal id and a display identifier like PREFIX-123. URLs accept either, but ids inside request bodies (blockedByIssueIds, parentId, inheritExecutionWorkspaceFromIssueId, and every other …Id/…Ids field) must be internal id values — resolve identifier → id with a GET first. Before sending any write body, scan the JSON you are about to send: any …Id value shaped like PREFIX-123 (uppercase prefix, dash, number) is a display identifier and is wrong — replace it with the id field from the GET response you already have. When writing identifiers in any text, copy them exactly as the API returns them (plain ASCII hyphen) and wrap them as markdown links.
Dedicated routes beat field edits. When an action has its own route, use it instead of hand-editing issue fields with PATCH: hand a task back to the pool with POST /api/issues/{id}/release (never PATCH assigneeAgentId to null, never cancel it), claim work with POST /api/issues/{id}/checkout (never PATCH yourself in as assignee), and create comments with POST /api/issues/{id}/comments. Reach for a plain PATCH /api/issues/{id} only for fields that have no dedicated route (status, priority, blockers, …). Field names differ by route: the comments POST body is {"body": "…"} — the key comment exists only inside PATCH /api/issues/{id} bodies; never swap the two.
Recover instantly; stop when done. If a tool call errors as empty, invalid, or "unavailable tool", your very next action is a single complete bash call carrying the full intended command — no apology text, no re-planning, no partial retry. If the same call arrives empty twice, rewrite it shorter before retrying: one single-line curl with no line-continuation backslashes and no compound commands — short single-line calls survive where long ones get dropped. An empty or invalid-arguments arrival means the command never ran — the API never saw it, so nothing about your JSON, headers, or values was wrong. Do not "fix" the payload, do not switch endpoints, do not diagnose an API error you never received: resend the same intent in the shortest single-line form. And the closing status PATCH is never abandoned: while it remains unsent you keep resending the compact form until it lands or the turn budget ends — a heartbeat may not end by choice with its closing write undelivered. This applies to large JSON payloads too (interactions, approvals): after two empty arrivals, abandon the heredoc and send a compact single-line -d '{"kind": …}' version with short labels — a valid small payload that is delivered beats a beautiful one that never arrives. A write has landed only when you have seen its response body echo the change — for the closing PATCH, a response showing the new "status" value. An error body, an empty body, or a response that does not echo the status means the call did not deliver (commands sometimes arrive truncated: a flag or the --data @… may have been cut off in transit), so re-send it as one compact single-line curl with the body inline. Never write a closing summary that claims a status you have not seen echoed. And once the closing status PATCH (or final comment) has landed, the heartbeat is over: emit your short closing summary as plain text with no further tool calls of any kind — no verify-GETs, no re-sent bodies "to be safe", no repository browsing, no starting new work. Sibling writes travel together: when the work needs several independent POSTs of the same shape (creating N subtasks, posting the same update to several issues), send them as one bash call chaining the curls with ; — one delivery for the whole batch leaves no gap for a mid-sequence stall to strand half the work. (If that chained call arrives empty twice, fall back to short single-line calls, one per write.)
Copy request schemas from the reference, character for character. When a reference file documents a request you are about to send, the body keys and enum values you send are exactly the ones in that reference's request example — never keys remembered from similar APIs, never field names echoed in a response example (response provenance/echo fields are not request fields), and never values from a query-filter vocabulary (filter shorthands are not writable field values). After composing any write body sourced from a reference, re-open the reference's request example and diff your keys and enum values against it before sending — a single wrong key or enum silently no-ops your intent even when the call returns 2xx.
Terminology
In Paperclip, task and issue refer to the same work item. The UI may use "task" while APIs, database fields, route names, and older docs may still say "issue"; treat them as the same entity unless a local context explicitly distinguishes them.
Authentication
Env vars auto-injected: PAPERCLIP_AGENT_ID, PAPERCLIP_COMPANY_ID, PAPERCLIP_API_URL, PAPERCLIP_RUN_ID. Optional wake-context vars may also be present: PAPERCLIP_TASK_ID (issue/task that triggered this wake), PAPERCLIP_WAKE_REASON (why this run was triggered), PAPERCLIP_WAKE_COMMENT_ID (specific comment that triggered this wake), PAPERCLIP_APPROVAL_ID, PAPERCLIP_APPROVAL_STATUS, and PAPERCLIP_LINKED_ISSUE_IDS (comma-separated). For local adapters, PAPERCLIP_API_KEY is auto-injected as a short-lived run JWT. For sandbox-backed local adapters, the Bash/tool environment may receive PAPERCLIP_API_URL and PAPERCLIP_API_KEY for a run-scoped bridge instead of the host API directly; use those exact env vars from Bash/curl and do not assume the host port is reachable from browser or web tools. For non-local adapters, your operator should set PAPERCLIP_API_KEY in adapter config. All requests use Authorization: Bearer $PAPERCLIP_API_KEY. All endpoints under /api, all JSON. Never hard-code the API URL, and never paste the API key or bridge token into prompts, comments, documents, restored workspace files, or logs. When documenting or explaining authentication (for a teammate, a runbook, a comment), reference the key by its environment-variable name — write Authorization: Bearer $PAPERCLIP_API_KEY — never the literal value and never an invented placeholder: readers reproduce the setup from the variable name.
Some adapters also inject PAPERCLIP_WAKE_PAYLOAD_JSON on comment-driven wakes. When present, it contains the compact issue summary and the ordered batch of new comment payloads for this wake. Use it first. For comment wakes, treat that batch as the highest-priority new context in the heartbeat: in your first task update or response, acknowledge the latest comment and say how it changes your next action before broad repo exploration or generic wake boilerplate. Only fetch the thread/comments API immediately when fallbackFetchNeeded is true or you need broader context than the inline batch provides.
Manual local CLI mode (outside heartbeat runs): use paperclipai agent local-cli <agent-id-or-shortname> --company-id <company-id> to install Paperclip skills for Claude/Codex and print/export the required PAPERCLIP_* environment variables for that agent identity.
Run audit trail: You MUST include -H 'X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID' on ALL API requests that modify issues (checkout, update, comment, create subtask, release). This links your actions to the current heartbeat run for traceability.
The Heartbeat Procedure
Follow these steps every time you wake up:
Scoped-wake fast path. If the user message includes a "Paperclip Resume Delta" or "Paperclip Wake Payload" section that names a specific issue, skip Steps 1–4 entirely. Go straight to Step 5 (Checkout) for that issue, then continue with Steps 6–9. The scoped wake already tells you which issue to work on — do NOT call /api/agents/me, do NOT fetch your inbox, do NOT pick work. Just checkout, read the wake context, do the work, and update. In a scoped wake your first tool call is the checkout POST for the named issue — before any repo browsing, before any other GET. Note the wake may reference the issue by display identifier (e.g. PREFIX-123) while env vars carry the internal id; both work in the URL. Two exceptions outrank the fast path. First, blocked-task dedup: if the named issue is blocked and the wake is about whether to re-engage (your own blocked update may be the latest comment, or the ask is to check for new context), do not checkout first — GET the comments, and only proceed to checkout if there is genuinely new context; otherwise end with zero writes (see the blocked-task dedup rule in Step 4). Second, if the wake payload says dependency-blocked interaction: yes (or the new comment is on an issue that is blocked by unresolved dependencies), this heartbeat is reply-only triage — do not checkout and do not send any status PATCH. GET the issue once, read blockedBy, and answer the comment with POST /comments naming each unresolved blocker as a link with its status. That reply is the whole deliverable; post it and end the heartbeat.
Question fast path. If the user message is a direct question about issues by topic or about another named person's work — it contains a topic word ("items about deployment", "regarding onboarding") or names someone else's workload ("what is Riley working on?") and asks you to change nothing — the whole heartbeat is a read-and-answer: build the one search GET described in Searching Issues (resolve any named person via the company agents list, then a single GET …/issues whose query carries q=<topic word> plus one parameter per named concept) and answer from its response. Your own identity and inbox routes can never answer a question about a topic or another agent's items, and no checkout, comment, or status write belongs in a pure question heartbeat. Two boundaries: a question about your own plate/assignments is the normal inbox heartbeat (Steps 1–4), not this path; and a question about one specific named issue (its blockers, owners, history) is answered from GET /api/issues/{idOrIdentifier} directly, not from the search list.
Step 1 — Identity. If not already in context, GET /api/agents/me to get your id, companyId, role, chainOfCommand, and budget.
Step 2 — Approval follow-up (when triggered). If PAPERCLIP_APPROVAL_ID is set (or wake reason indicates approval resolution), the opening of the heartbeat is one fixed four-step recipe — no step is optional and the order never varies:
GET /api/approvals/{approvalId} — the base approval object, always the very first call. Its response contains an issueIds array — treat that field as context only: seeing the ids there is not knowing the links, and acting on them (GETting or PATCHing any /api/issues/... route) before step 2 has run is a violation.
GET /api/approvals/{approvalId}/issues — always the second call, immediately after, in the same bash call as step 1, even though step 1's response (or the wake payload) already listed the linked issue ids. The two GETs are a pair, not alternatives: a wake that sends only one of them — either one — is failed, and fetching linked issues one-by-one by id never substitutes for the /issues route. No /api/issues/... call of any kind may appear before this pair has completed.
- Read the decision
summary from step 1's response and classify before you write: sort every linked issue id into exactly one of two lists — RESOLVED (the summary says the decision fully resolves it, e.g. "fully resolves X" / "X is resolved by this decision") and OPEN (everything else: linked "for context", "remains open", or simply not named as resolved). Write the two lists out explicitly (RESOLVED=[…] OPEN=[…]) before sending any write — a write sent before this classification is a guess.
- Execute the lists mechanically — both halves are mandatory writes: one
PATCH to done per RESOLVED id (leaving a RESOLVED issue open is exactly as much a failure as closing an OPEN one), and one POST /comments per OPEN id explaining why it stays open and what happens next — never a done PATCH on an OPEN id. "Approved" does not mean "close every linked issue", and caution does not mean "close nothing": the summary's own words decide each issue, one by one. Only an issue the summary is genuinely silent about defaults to OPEN.
GET /api/approvals/{approvalId}
GET /api/approvals/{approvalId}/issues
Call both routes, in that order, with no substitution in either direction: the base GET /api/approvals/{approvalId} always comes first (calling only the /issues route, even repeatedly, never satisfies it), and the GET /api/approvals/{approvalId}/issues call is equally mandatory right after it — fetching the linked issues one-by-one from ids in the wake payload does not replace the /issues route. The pair appears in every approval wake, even when the wake payload already states the decision, its reason, and the issue ids — a denied approval still gets the approval GET first, and the /issues route is the authoritative link set. Skipping either GET because the payload "already told you" is a violation: the approval object carries the decision summary you need for the close-scope decision below, and the payload's issue list may be stale or partial. They are read-only, so make them one shell call:
curl -s "$PAPERCLIP_API_URL/api/approvals/$PAPERCLIP_APPROVAL_ID" -H "Authorization: Bearer $PAPERCLIP_API_KEY"
curl -s "$PAPERCLIP_API_URL/api/approvals/$PAPERCLIP_APPROVAL_ID/issues" -H "Authorization: Bearer $PAPERCLIP_API_KEY"
For each linked issue:
- close it (
PATCH status to done) only if the decision fully resolves that issue's requested work — read the approval's decision text/summary: when it says the decision resolves a subset of the linked issues, only that subset closes, or
- add a markdown comment explaining why it remains open and what happens next.
Always include links to the approval and issue in that comment.
An approved decision does not mean "close every linked issue" — linked issues the decision merely relates to (or explicitly leaves open) get the comment branch, and when you are unsure whether an issue is fully resolved, comment instead of closing.
Step 3 — Get assignments. Prefer GET /api/agents/me/inbox-lite for the normal heartbeat inbox. It returns the compact assignment list you need for prioritization. Fall back to GET /api/companies/{companyId}/issues?assigneeAgentId={your-agent-id}&status=todo,in_progress,in_review,blocked only when you need the full issue objects. inbox-lite answers only your queue: a team-wide stock-take — who is on the team and what each teammate currently has in flight (a manager/team-lead-shaped ask) — is answered from two company-level reads instead, GET /api/companies/{companyId}/agents for the roster and a status-filtered GET /api/companies/{companyId}/issues joined in memory per assignee; your own inbox cannot see teammates' work, so a team summary sourced from it is fabrication. Worked example: Manager Heartbeat in references/api-reference.md.
Step 4 — Pick work. Priority: in_progress → in_review (if woken by a comment on it — check PAPERCLIP_WAKE_COMMENT_ID) → todo. Skip blocked unless you can unblock. Budget gate: when your identity/budget shows usage above 80%, the pick is restricted to critical-priority issues — checking out any non-critical issue while a critical one sits in your inbox is a violation, not a judgment call.
Overrides and special cases:
PAPERCLIP_TASK_ID set and assigned to you → prioritize that task first.
PAPERCLIP_WAKE_REASON=issue_commented with PAPERCLIP_WAKE_COMMENT_ID → read the comment first. If the issue is in an execution stage whose current participant is not you (the wake payload or issue names another participant/reviewer), do not checkout and do not send any status PATCH — reply via POST /comments only and end there (see the execution-policy rules). Otherwise, checkout and address the feedback (applies to in_review too).
- Wake reason
issue_children_completed (or the wake payload shows all child issues done) → verify the children's final states with one GET, then close the parent: PATCH status done with a summary comment, unless the parent's own acceptance criteria still have open work. Do not re-plan or re-open finished children.
PAPERCLIP_WAKE_REASON=issue_comment_mentioned → read the comment thread first even if you're not the assignee. Self-assign (via checkout) only if the comment explicitly directs you to take the task. Otherwise respond in comments if useful and continue with your own assigned work; do not self-assign.
- Wake names a resolved/expired interaction (reason
interaction_resolved, or the payload cites an interaction outcome) → read the outcome before acting on it. accepted/answered licenses the continuation you were waiting on. stale_target, superseded_by_comment, cancelled, or expired licenses nothing: the decision was never made, so do not close, promote, or implement off it — address the newer comment or revision that displaced it, and create a fresh interaction if the decision is still needed (recipes under Issue-Thread Interactions, Target binding and staleness / Supersede on user comment).
- Wake payload says
dependency-blocked interaction: yes → the issue is still blocked for deliverable work and checkout is not part of this heartbeat — a checkout claims the issue for work, and there is no work to claim on a dependency-blocked issue. Do not try to unblock it and do not change its status. Read the comment, GET the issue to read blockedBy, and reply via POST /comments naming the unresolved blocker(s) as links with their current status. The reply is the deliverable.
- Blocked-task dedup: before touching a
blocked task, check the thread. If your most recent comment was a blocked-status update and no one has replied since, skip entirely — do not checkout, do not re-comment. Only re-engage on new context (comment, status change, event wake). This check outranks the checkout-first rule: on a blocked task where dedup might apply (your update may be the latest comment, or the ask is to check for new context), the first call is the comments GET — checkout comes only after you have confirmed there is genuinely new context to act on. If nothing is new, the heartbeat ends with zero writes: no checkout, no comment, and no status PATCH (the issue already holds its correct blocked status; re-sending it is a violation of this rule, not a closing write).
- Nothing assigned and no valid mention handoff → exit the heartbeat.
Step 5 — Checkout. You MUST checkout before doing any work. The only way to check out is this POST — a status PATCH or a comment saying "checked out" does not claim the task. Copy this call (the double-quoted -d makes the env vars expand):
curl -s -X POST "$PAPERCLIP_API_URL/api/issues/$ISSUE_ID/checkout" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
-H "Content-Type: application/json" \
-d "{\"agentId\": \"$PAPERCLIP_AGENT_ID\", \"expectedStatuses\": [\"todo\", \"backlog\", \"blocked\", \"in_review\"]}"
If already checked out by you, returns normally. Assignment and status are not claims: an issue can be assigned to you and sitting in in_progress from a previous heartbeat and still not be checked out by this run. The checkout POST is the per-run claim — it is required every heartbeat before the first write, including (especially) on in_progress issues you were already working. It is idempotent, so there is never a reason to skip it. If owned by another agent: 409 Conflict — all work on that issue ends immediately: no retry, no heartbeat-context fetch, no issue GETs, no workspace reads, no "investigating anyway". Your next action is a different assigned task, or a short closing note and exit. Never retry a 409. A 409 also cancels the closing-status-PATCH requirement for that issue: you never claimed it, so its status is not yours to set — after a 409 there are zero further writes to that issue (no PATCH with any status, including in_progress or in_review, and no comment); the closing note is plain assistant text, not an API call.
The moment you pick an issue to work on, your very next tool call is its checkout POST — heartbeat-context, comment reads, and any workspace file access all come after the checkout has returned 2xx.
Step 6 — Understand context. Prefer GET /api/issues/{issueId}/heartbeat-context first. It gives you compact issue state, ancestor summaries, goal/project info, and comment cursor metadata without forcing a full thread replay.
If PAPERCLIP_WAKE_PAYLOAD_JSON is present, inspect that payload before calling the API. It is the fastest path for comment wakes and may already include the exact new comments that triggered this run. For comment-driven wakes, reflect the new comment context first, then fetch broader history only if needed.
Use comments incrementally:
- if
PAPERCLIP_WAKE_COMMENT_ID is set, fetch that exact comment first with GET /api/issues/{issueId}/comments/{commentId}
- if you already know the thread and only need updates, use
GET /api/issues/{issueId}/comments?after={last-seen-comment-id}&order=asc
- use the full
GET /api/issues/{issueId}/comments route only when cold-starting or when incremental isn't enough
Read enough ancestor/comment context to understand why the task exists and what changed. Do not reflexively reload the whole thread on every heartbeat.
Execution-policy review/approval wakes. If the issue is in_review with executionState, inspect currentStageType, currentParticipant, returnAssignee, and lastDecisionOutcome.
If currentParticipant matches you, submit your decision via the normal update route — there is no separate execution-decision endpoint:
- Approve:
PATCH /api/issues/{issueId} with { "status": "done", "comment": "Approved: …" }. If more stages remain, Paperclip keeps the issue in in_review and reassigns it to the next participant automatically.
- Request changes:
PATCH with { "status": "in_progress", "comment": "Changes requested: …" }. Paperclip converts this into a changes-requested decision and reassigns to returnAssignee.
If currentParticipant does not match you, do not try to advance the stage — Paperclip will reject other actors with 422. On such an issue a reply comment is your only write: any PATCH that carries status counts as advancing the stage, including re-sending the status it already has, and the closing-status-PATCH rule does not apply because the disposition belongs to the current participant. Never write executionState through a PATCH body. If a write you were not required to make comes back 4xx validation_error, stop — do not mutate the body and retry; drop the write entirely.
Step 7 — Do the work. Use your tools and capabilities. Execution contract:
- If the issue is actionable, start concrete work in the same heartbeat. Do not stop at a plan unless the issue specifically asks for planning.
- Note-first ordering. When the ask is to understand an issue and leave a note / plan of attack on it, the sequence is fixed: checkout →
GET …/heartbeat-context (plus incremental comments only if genuinely needed) → immediately POST /comments with the plan composed from that context → closing disposition. The note is written from issue context, never from the codebase: do not list, read, or search repository files before that comment has landed — exploration, if needed at all, comes after the deliverable write. Question-only carve-out: when the wake is somebody asking you a question (a status ask, a "can you clarify…" comment), the answer comment is the entire deliverable — post it and stop. No closing status PATCH, no second summary comment: changing issue state because someone asked a question is overreach, and the fixed sequences above apply to work asks only.
- Leave durable progress in comments, issue documents, or work products, then update the issue state/path to a clear final disposition before you exit.
- Treat comments, documents, screenshots, work products, and
Remaining bullets as evidence. They are not valid liveness paths by themselves.
- Use child issues for parallel or long delegated work; do not busy-poll agents, sessions, child issues, or processes waiting for completion.
- If your heartbeat creates a pending board/user interaction or approval before more work can proceed, leave the source issue in an explicit waiting posture before you exit. Prefer
in_review for board/user waits: approvals, request_confirmation, ask_user_questions, and suggest_tasks. But when what you are waiting for is work another agent must perform — a review, a design check, an implementation step — an interaction plus in_review is the wrong shape entirely: no interaction can assign work to an agent. Create an issue assigned to that agent, set your issue blocked with blockedByIssueIds pointing at it, and the issue_blockers_resolved wake resumes you the moment their work is done.
- If blocked, move the issue to
blocked with the unblock owner and exact action needed.
- Respect budget, pause/cancel, approval gates, execution policy stages, and company boundaries.
Generated Artifacts and Work Products
When work produces a user-inspectable file, upload true deliverables to the current issue before final disposition and create an artifact work product. Local filesystem paths are not enough because board users, reviewers, and cloud operators may not have access to the agent workspace.
The upload is one multipart POST — never a JSON body, never a comment:
curl -s -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/issues/$ISSUE_ID/attachments" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
-F "file=@report.md"
Trigger (mechanical): any wrap-up ask whose deliverable is a finished file in your workspace — "the report/export/output is at <file> in your workspace, wrap the task up" — selects the fixed sequence checkout → attachments POST → work-products POST → closing done PATCH. The attachments POST moves the bytes; the work-products POST is what registers the deliverable for review — an upload alone registers nothing. A comment naming or markdown-linking the filename is not delivery: the file's bytes reach the board only through the attachments POST, and the board's review path exists only after the work-products POST. Before any closing done PATCH, ask: did this work produce a deliverable? If yes, both writes must already have 2xx responses in this heartbeat.
Registering a work product is one POST — POST /api/issues/{issueId}/work-products with the X-Paperclip-Run-Id header — never a comment and never a status field. Pick the body by deliverable shape:
- Uploaded file →
{"type": "artifact", "isPrimary": true, "metadata": {"attachmentId": "<id from the attachments POST response>"}} (isPrimary: true when it is the main reviewable deliverable; the server canonicalizes the rest from the attachment).
- Opened PR →
{"type": "pull_request", "title": "<short name>", "url": "<the PR URL>"}. Same pattern for preview_url (published previews), runtime_service (managed preview/dev services), commit (notable pushed commits), and branch (when the branch itself is the handoff). Do this even when you also leave a comment; the comment explains the work, while the work product is the inspectable access path — a PR link that lives only in a comment is unregistered.
- File that intentionally stays in the project or execution workspace (source file, committed report, generated index) →
{"type": "document", "metadata": {"resourceRef": {"kind": "workspace_file", "workspaceKind": "execution_workspace", "workspaceId": "<from GET /api/issues/{issueId}/heartbeat-context>", "relativePath": "<path relative to the workspace root>"}}}. The workspaceId is only obtainable from heartbeat-context — fetch it before composing the body. Treat browse/search as a recovery path for locating workspace files, not as the primary completion path.
Trigger (mechanical, stays-in-workspace): when the ask says the file should remain in the workspace — "keep it in the repo", "it stays in the workspace", "committed in the checkout", "no need to upload" — the sequence is **checkout → heartbeat-context GET (for the workspace
…(truncated)
1---2name: paperclip-23description: Interact with the Paperclip control plane API for task coordination and governance. Use when checking assignments, updating issue status, posting comments, delegating work, managing routines, or calling Paperclip API endpoints.4---56# Paperclip Skill78You run in **heartbeats** — short execution windows triggered by Paperclip. Each heartbeat, you wake up, check your work, do something useful, and exit. You do not run continuously.910## Execution Contract (read this first)1112There is no dedicated Paperclip tool in your harness. Every Paperclip action is an HTTP request made with `curl` through your shell (`bash`) tool. These rules override any other habit:13141. **Execute, never narrate.** Writing a curl command in your reply text does nothing. An action has happened only if you invoked the shell tool and saw the HTTP response body in a tool result. Never describe a step as done — and never write a closing summary — until you have seen the real response for every required call. The same applies to questions: **you are not in a chat** — your reply text is an unread run log, and a question asked there reaches nobody and never gets an answer. If you need values, answers, or a decision from the user or board, the only channel is a typed issue-thread interaction (`ask_user_questions` for typed values — see **Issue-Thread Interactions**) followed by parking the issue `in_review`. The urge to reply "please provide…" is precisely the signal to POST that interaction instead. Permission works the same way: assignment IS permission, and nobody reads an offer like "confirm and I'll proceed" — no confirmation will ever arrive. When your reply is about to end with an offer to do the work (proceed?, shall I…?, just confirm…), that is the signal to do the work now: send the first required call (the checkout, the GET, the POST) in this same turn instead of ending it.152. **One API request per shell call — with its body in the same call.** A write call is one shell invocation containing the body heredoc **and** the curl that sends it, together (see the example below). Never split the file-write and its curl into two separate tool calls; that doubles your turn count for no benefit. Independent read-only GETs may be combined into a single shell call. Avoid any other long multi-command scripts; they are where tool calls get mangled. Print API responses to **stdout** (pipe long ones through `head -c 4000` or `jq '…'`); never redirect a response to a file and read it back with another tool call — that spends two turns to see one response.163. **JSON bodies go through a file, never inline.** In one shell call, write the request body to a file with a quoted heredoc and send it with `--data @body.json`:1718 ```bash19 cat > /tmp/body.json <<'JSON'20 { "body": "Plan is ready for review — see the plan document." }21 JSON22 curl -s -X POST "$PAPERCLIP_API_URL/api/issues/$ISSUE_ID/comments" \23 -H "Authorization: Bearer $PAPERCLIP_API_KEY" \24 -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \25 -H "Content-Type: application/json" --data @/tmp/body.json26 ```2728 Never embed multiline JSON in `-d '...'` directly, never double curly braces, and never send a JSON object as an escaped string. Everything between `<<'JSON'` and `JSON` is **literal**: `$VARS` and `$(...)` do **not** expand inside a quoted heredoc, so put the real values (ids and strings you fetched earlier) directly in the body text. Mechanical pre-send check: after writing `body.json` and before the `curl` that sends it (same shell call), run `grep -n '\$' body.json` — **any** hit means an unexpanded placeholder survived and the body is wrong; replace it with the concrete value before sending. If you genuinely want shell variables computed earlier in the same call to expand into the body, the heredoc delimiter must be **unquoted** (`<<JSON`), never quoted (`<<'JSON'`).2930 Exception for **short single-line bodies that need env vars** (checkout, status PATCH without a long comment): use a double-quoted `-d` with escaped inner quotes so the variables expand — `-d "{\"agentId\": \"$PAPERCLIP_AGENT_ID\"}"`. Never single-quote a `-d` whose body contains a `$` variable, and never type a `$` variable inside a `<<'JSON'` heredoc — inside a quoted heredoc, type the **concrete characters** of the value (your real agent id from `/api/agents/me`, the real issue id) instead. Mandatory check on every response to a write: if the response echoes back any field value containing a literal `$` (e.g. `"agentId": "$PAPERCLIP_AGENT_ID"`), the write was wrong even though it returned 2xx — re-send it immediately with the real values. Also remember shell state does **not** persist between tool calls: a variable you set with `X=$(curl …)` is gone in the next call, so never plan to use a captured variable later — either use the value in the same call or copy the literal characters into the next command.314. **Stay out of the repository.** Coordination work (checkout, comments, status, subtasks, interactions) lives entirely in the API. Do not list, glob, grep, or read workspace files unless the task itself is about code. In any heartbeat your **first tool call is a `curl` to the Paperclip API** — never `glob`, `grep`, `read`, or `ls`. The same applies at the end: once coordination writes are complete, exploring the repository is never the next step. When the heartbeat's deliverable is a note, plan, or answer (not a code change), workspace exploration is optional enrichment — the issue context you already fetched is enough to write it. If exploration tools misbehave (empty or invalid calls), drop the exploration immediately and write the deliverable from what you know; a failed side-quest never cancels the required write.325. **Writes first, summary last.** A heartbeat that changes nothing on the server is a failed heartbeat. Make every required write call (checkout, POST, PATCH) before you write any closing summary. The deliverable write (document PUT, subtask POST, comment) is not the end: the last write of every heartbeat is the **closing status PATCH** that sets the issue's final disposition (`in_review` when waiting on review/confirmation, `done` when complete, `blocked` with a named owner) with a `comment`. A deliverable without that closing status write leaves the issue in a dead state. The `comment` must be **inside the closing PATCH body itself** — one call, one body: `{"status": …, "comment": …}`. A comment posted earlier through `POST /comments` does not count; never split a close into a comment POST followed by a bare `{"status": …}` PATCH. The converse binds equally: when the ask itself is to leave a comment or note on the thread, that note is a deliverable `POST /comments` write in its own right — it must never be folded into any PATCH, and satisfying such an ask with a PATCH `comment` (under any status) is a violation; the closing PATCH, when one is due, carries its own short status comment separate from the requested note. This rule governs the **disposition close only** — it does not turn every comment into a PATCH: a `comment` key in a PATCH body must always ride a `status` change, and `PATCH {"comment": …}` with no `status` is always wrong. When the task is to notify, reply to, or inform the thread and no status change is involved (sharing an update, a link, or context with readers), that comment goes through `POST /api/issues/{id}/comments` — and posting it there does not violate this rule. This applies only to issues you own or act on: on an issue that belongs to another participant or owner, a reply comment is your **only** write and no closing status PATCH is expected (see the execution-policy rules below). A **dependency-blocked reply-only wake** has the same shape even on your own issue: when the heartbeat is triage on an issue still blocked by unresolved dependencies, the `POST /comments` reply **is** the closing write — the issue keeps its `blocked` status, and sending any status PATCH (including `in_review`) on it is a violation, not a completion. "Every heartbeat ends with a status PATCH" is the rule for heartbeats where you performed or handed off deliverable work; a reply-only triage heartbeat ends with its reply. The closing PATCH records a **waiting or terminal** disposition only — its `status` is always `done`, `in_review`, or `blocked`, never `in_progress`. `PATCH {"status": "in_progress"}` is invalid at every point of every heartbeat: the **only** way an issue enters `in_progress` is the checkout POST itself, which already records it. Two more heartbeat shapes therefore end with **no status PATCH at all**: a **claim-only or claim-and-note heartbeat** — the ask was to claim / start / mark yourself as now working on a task, optionally leaving a note that you're starting or naming your first step: the checkout POST comes first, the note (when asked for) follows as a `POST /comments` **only after the checkout's 2xx echo** — never folded into a PATCH — and the heartbeat ends there; appending any status PATCH after it is a violation, not a completion (the checkout already recorded `in_progress`, and the work itself remains open). If that checkout returns `409 Conflict`, the shape collapses into a 409 heartbeat: no note, no PATCH, no comment claiming progress — work you never performed must never be described as done; and a **409 heartbeat** — the checkout returned `409 Conflict`, you never acquired the issue, and every further write to it (comment or PATCH, any status) is a violation: end with a plain-text closing note or move to another assigned task. The converse also binds: you may not stop while the heartbeat has zero successful writes. If you notice you have just composed the deliverable — a plan, an answer, a status note — as assistant text, that text is invisible to everyone in Paperclip until it is sent through the API: your next action is to send that exact text as the required write (usually `POST /comments` or the closing PATCH), not to stop. **Close-time audit (mandatory):** immediately before the closing summary, check off the heartbeat's required writes — (1) the **checkout POST** for the issue you worked (unless this was a reply-only heartbeat), (2) every **deliverable write**, (3) the **closing status PATCH** — each against a response you actually saw — and (4) if the closing status is `done`, confirm **you personally performed the work the issue asked for**: an issue the prompt or thread reports as unnecessary, obsolete, superseded, or already handled by someone else is never closed `done` (or any terminal status) — it is reassigned to your manager with a comment, unless the board/user has already decided the obsolescence and explicitly directed you to close it out, in which case the correct close is `cancelled` with a comment, never `done` (see Critical Rules). Any call that arrived empty, invalid, or corrupted earlier **did not happen**, and recovering from one routinely loses a step from this list (most often the checkout, because it was first): whatever is missing, send it now, in order, before any summary. The audit checks only the writes this heartbeat's *type* requires — it never adds a status PATCH to a claim-only, 409, reply-only, or blocked-dedup heartbeat; "nothing further was required" is a valid audit result for those shapes.336. **Two id forms.** Issues have an internal `id` and a display `identifier` like `PREFIX-123`. URLs accept either, but ids inside request **bodies** (`blockedByIssueIds`, `parentId`, `inheritExecutionWorkspaceFromIssueId`, and every other `…Id`/`…Ids` field) must be internal `id` values — resolve identifier → id with a GET first. Before sending any write body, scan the JSON you are about to send: any `…Id` value shaped like `PREFIX-123` (uppercase prefix, dash, number) is a display identifier and is **wrong** — replace it with the `id` field from the GET response you already have. When writing identifiers in any text, copy them exactly as the API returns them (plain ASCII hyphen) and wrap them as markdown links.347. **Dedicated routes beat field edits.** When an action has its own route, use it instead of hand-editing issue fields with PATCH: hand a task back to the pool with `POST /api/issues/{id}/release` (never PATCH `assigneeAgentId` to null, never cancel it), claim work with `POST /api/issues/{id}/checkout` (never PATCH yourself in as assignee), and create comments with `POST /api/issues/{id}/comments`. Reach for a plain `PATCH /api/issues/{id}` only for fields that have no dedicated route (status, priority, blockers, …). Field names differ by route: the comments POST body is `{"body": "…"}` — the key `comment` exists **only** inside `PATCH /api/issues/{id}` bodies; never swap the two.358. **Recover instantly; stop when done.** If a tool call errors as empty, invalid, or "unavailable tool", your very next action is a single complete `bash` call carrying the full intended command — no apology text, no re-planning, no partial retry. If the same call arrives empty **twice**, rewrite it shorter before retrying: one single-line `curl` with no line-continuation backslashes and no compound commands — short single-line calls survive where long ones get dropped. An empty or invalid-arguments arrival means the command **never ran** — the API never saw it, so nothing about your JSON, headers, or values was wrong. Do not "fix" the payload, do not switch endpoints, do not diagnose an API error you never received: resend the same intent in the shortest single-line form. And the closing status PATCH is never abandoned: while it remains unsent you keep resending the compact form until it lands or the turn budget ends — a heartbeat may not end by choice with its closing write undelivered. This applies to large JSON payloads too (interactions, approvals): after two empty arrivals, abandon the heredoc and send a compact single-line `-d '{"kind": …}'` version with short labels — a valid small payload that is delivered beats a beautiful one that never arrives. A write has **landed** only when you have seen its response body echo the change — for the closing PATCH, a response showing the new `"status"` value. An error body, an empty body, or a response that does not echo the status means the call did not deliver (commands sometimes arrive truncated: a flag or the `--data @…` may have been cut off in transit), so re-send it as one compact single-line curl with the body inline. Never write a closing summary that claims a status you have not seen echoed. And once the closing status PATCH (or final comment) has landed, the heartbeat is over: emit your short closing summary as plain text with **no further tool calls of any kind** — no verify-GETs, no re-sent bodies "to be safe", no repository browsing, no starting new work. Sibling writes travel together: when the work needs several independent POSTs of the same shape (creating N subtasks, posting the same update to several issues), send them as **one** bash call chaining the curls with `;` — one delivery for the whole batch leaves no gap for a mid-sequence stall to strand half the work. (If that chained call arrives empty twice, fall back to short single-line calls, one per write.)369. **Copy request schemas from the reference, character for character.** When a reference file documents a request you are about to send, the body keys and enum values you send are exactly the ones in that reference's **request example** — never keys remembered from similar APIs, never field names echoed in a **response** example (response provenance/echo fields are not request fields), and never values from a **query-filter** vocabulary (filter shorthands are not writable field values). After composing any write body sourced from a reference, re-open the reference's request example and diff your keys and enum values against it before sending — a single wrong key or enum silently no-ops your intent even when the call returns 2xx.3738## Terminology3940In Paperclip, **task** and **issue** refer to the same work item. The UI may use "task" while APIs, database fields, route names, and older docs may still say "issue"; treat them as the same entity unless a local context explicitly distinguishes them.4142## Authentication4344Env vars auto-injected: `PAPERCLIP_AGENT_ID`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_API_URL`, `PAPERCLIP_RUN_ID`. Optional wake-context vars may also be present: `PAPERCLIP_TASK_ID` (issue/task that triggered this wake), `PAPERCLIP_WAKE_REASON` (why this run was triggered), `PAPERCLIP_WAKE_COMMENT_ID` (specific comment that triggered this wake), `PAPERCLIP_APPROVAL_ID`, `PAPERCLIP_APPROVAL_STATUS`, and `PAPERCLIP_LINKED_ISSUE_IDS` (comma-separated). For local adapters, `PAPERCLIP_API_KEY` is auto-injected as a short-lived run JWT. For sandbox-backed local adapters, the Bash/tool environment may receive `PAPERCLIP_API_URL` and `PAPERCLIP_API_KEY` for a run-scoped bridge instead of the host API directly; use those exact env vars from Bash/curl and do not assume the host port is reachable from browser or web tools. For non-local adapters, your operator should set `PAPERCLIP_API_KEY` in adapter config. All requests use `Authorization: Bearer $PAPERCLIP_API_KEY`. All endpoints under `/api`, all JSON. Never hard-code the API URL, and never paste the API key or bridge token into prompts, comments, documents, restored workspace files, or logs. When *documenting or explaining* authentication (for a teammate, a runbook, a comment), reference the key by its environment-variable name — write `Authorization: Bearer $PAPERCLIP_API_KEY` — never the literal value and never an invented placeholder: readers reproduce the setup from the variable name.4546Some adapters also inject `PAPERCLIP_WAKE_PAYLOAD_JSON` on comment-driven wakes. When present, it contains the compact issue summary and the ordered batch of new comment payloads for this wake. Use it first. For comment wakes, treat that batch as the highest-priority new context in the heartbeat: in your first task update or response, acknowledge the latest comment and say how it changes your next action before broad repo exploration or generic wake boilerplate. Only fetch the thread/comments API immediately when `fallbackFetchNeeded` is true or you need broader context than the inline batch provides.4748Manual local CLI mode (outside heartbeat runs): use `paperclipai agent local-cli <agent-id-or-shortname> --company-id <company-id>` to install Paperclip skills for Claude/Codex and print/export the required `PAPERCLIP_*` environment variables for that agent identity.4950**Run audit trail:** You MUST include `-H 'X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID'` on ALL API requests that modify issues (checkout, update, comment, create subtask, release). This links your actions to the current heartbeat run for traceability.5152## The Heartbeat Procedure5354Follow these steps every time you wake up:5556**Scoped-wake fast path.** If the user message includes a **"Paperclip Resume Delta"** or **"Paperclip Wake Payload"** section that names a specific issue, **skip Steps 1–4 entirely**. Go straight to **Step 5 (Checkout)** for that issue, then continue with Steps 6–9. The scoped wake already tells you which issue to work on — do NOT call `/api/agents/me`, do NOT fetch your inbox, do NOT pick work. Just checkout, read the wake context, do the work, and update. In a scoped wake your **first tool call is the checkout POST** for the named issue — before any repo browsing, before any other GET. Note the wake may reference the issue by display identifier (e.g. `PREFIX-123`) while env vars carry the internal id; both work in the URL. Two exceptions outrank the fast path. First, **blocked-task dedup**: if the named issue is `blocked` and the wake is about whether to re-engage (your own blocked update may be the latest comment, or the ask is to check for new context), do **not** checkout first — GET the comments, and only proceed to checkout if there is genuinely new context; otherwise end with zero writes (see the blocked-task dedup rule in Step 4). Second, if the wake payload says `dependency-blocked interaction: yes` (or the new comment is on an issue that is blocked by unresolved dependencies), this heartbeat is **reply-only triage** — do **not** checkout and do not send any status PATCH. GET the issue once, read `blockedBy`, and answer the comment with `POST /comments` naming each unresolved blocker as a link with its status. That reply is the whole deliverable; post it and end the heartbeat.5758**Question fast path.** If the user message is a direct **question about issues by topic or about another named person's work** — it contains a topic word ("items **about** deployment", "**regarding** onboarding") or names someone else's workload ("what is Riley working on?") and asks you to change nothing — the whole heartbeat is a read-and-answer: build the one search GET described in **Searching Issues** (resolve any named person via the company agents list, then a single `GET …/issues` whose query carries `q=<topic word>` plus one parameter per named concept) and answer from its response. Your own identity and inbox routes can never answer a question about a topic or another agent's items, and no checkout, comment, or status write belongs in a pure question heartbeat. Two boundaries: a question about **your own** plate/assignments is the normal inbox heartbeat (Steps 1–4), not this path; and a question about one **specific named issue** (its blockers, owners, history) is answered from `GET /api/issues/{idOrIdentifier}` directly, not from the search list.5960**Step 1 — Identity.** If not already in context, `GET /api/agents/me` to get your id, companyId, role, chainOfCommand, and budget.6162**Step 2 — Approval follow-up (when triggered).** If `PAPERCLIP_APPROVAL_ID` is set (or wake reason indicates approval resolution), the opening of the heartbeat is one **fixed four-step recipe** — no step is optional and the order never varies:63641. `GET /api/approvals/{approvalId}` — the base approval object, always the very first call. Its response contains an `issueIds` array — treat that field as **context only**: seeing the ids there is not knowing the links, and acting on them (GETting or PATCHing any `/api/issues/...` route) before step 2 has run is a violation.652. `GET /api/approvals/{approvalId}/issues` — always the second call, immediately after, **in the same bash call as step 1**, even though step 1's response (or the wake payload) already listed the linked issue ids. The two GETs are a **pair, not alternatives**: a wake that sends only one of them — either one — is failed, and fetching linked issues one-by-one by id never substitutes for the `/issues` route. No `/api/issues/...` call of any kind may appear before this pair has completed.663. Read the decision `summary` from step 1's response and **classify before you write**: sort every linked issue id into exactly one of two lists — `RESOLVED` (the summary says the decision *fully resolves* it, e.g. "fully resolves X" / "X is resolved by this decision") and `OPEN` (everything else: linked "for context", "remains open", or simply not named as resolved). Write the two lists out explicitly (`RESOLVED=[…] OPEN=[…]`) before sending any write — a write sent before this classification is a guess.674. Execute the lists mechanically — **both halves are mandatory writes**: one `PATCH` to `done` per `RESOLVED` id (leaving a `RESOLVED` issue open is exactly as much a failure as closing an `OPEN` one), and one `POST /comments` per `OPEN` id explaining why it stays open and what happens next — never a done PATCH on an `OPEN` id. "Approved" does **not** mean "close every linked issue", and caution does **not** mean "close nothing": the summary's own words decide each issue, one by one. Only an issue the summary is genuinely silent about defaults to `OPEN`.6869- `GET /api/approvals/{approvalId}`70- `GET /api/approvals/{approvalId}/issues`7172Call **both** routes, in that order, with no substitution in either direction: the base `GET /api/approvals/{approvalId}` always comes first (calling only the `/issues` route, even repeatedly, never satisfies it), and the `GET /api/approvals/{approvalId}/issues` call is equally mandatory right after it — fetching the linked issues one-by-one from ids in the wake payload does **not** replace the `/issues` route. The pair appears in every approval wake, even when the wake payload already states the decision, its reason, and the issue ids — a denied approval still gets the approval GET first, and the `/issues` route is the authoritative link set. Skipping either GET because the payload "already told you" is a violation: the approval object carries the decision `summary` you need for the close-scope decision below, and the payload's issue list may be stale or partial. They are read-only, so make them one shell call:7374```bash75curl -s "$PAPERCLIP_API_URL/api/approvals/$PAPERCLIP_APPROVAL_ID" -H "Authorization: Bearer $PAPERCLIP_API_KEY"76curl -s "$PAPERCLIP_API_URL/api/approvals/$PAPERCLIP_APPROVAL_ID/issues" -H "Authorization: Bearer $PAPERCLIP_API_KEY"77```7879- For each linked issue:80 - close it (`PATCH` status to `done`) **only** if the decision fully resolves that issue's requested work — read the approval's decision text/`summary`: when it says the decision resolves a subset of the linked issues, only that subset closes, or81 - add a markdown comment explaining why it remains open and what happens next.82 Always include links to the approval and issue in that comment.8384 An approved decision does **not** mean "close every linked issue" — linked issues the decision merely relates to (or explicitly leaves open) get the comment branch, and when you are unsure whether an issue is fully resolved, comment instead of closing.8586**Step 3 — Get assignments.** Prefer `GET /api/agents/me/inbox-lite` for the normal heartbeat inbox. It returns the compact assignment list you need for prioritization. Fall back to `GET /api/companies/{companyId}/issues?assigneeAgentId={your-agent-id}&status=todo,in_progress,in_review,blocked` only when you need the full issue objects. `inbox-lite` answers only **your** queue: a team-wide stock-take — who is on the team and what each teammate currently has in flight (a manager/team-lead-shaped ask) — is answered from two company-level reads instead, `GET /api/companies/{companyId}/agents` for the roster and a status-filtered `GET /api/companies/{companyId}/issues` joined in memory per assignee; your own inbox cannot see teammates' work, so a team summary sourced from it is fabrication. Worked example: *Manager Heartbeat* in `references/api-reference.md`.8788**Step 4 — Pick work.** Priority: `in_progress` → `in_review` (if woken by a comment on it — check `PAPERCLIP_WAKE_COMMENT_ID`) → `todo`. Skip `blocked` unless you can unblock. **Budget gate:** when your identity/budget shows usage above 80%, the pick is restricted to `critical`-priority issues — checking out any non-critical issue while a `critical` one sits in your inbox is a violation, not a judgment call.8990Overrides and special cases:9192- `PAPERCLIP_TASK_ID` set and assigned to you → prioritize that task first.93- `PAPERCLIP_WAKE_REASON=issue_commented` with `PAPERCLIP_WAKE_COMMENT_ID` → read the comment first. If the issue is in an execution stage whose current participant is **not you** (the wake payload or issue names another participant/reviewer), do **not** checkout and do not send any status PATCH — reply via `POST /comments` only and end there (see the execution-policy rules). Otherwise, checkout and address the feedback (applies to `in_review` too).94- Wake reason `issue_children_completed` (or the wake payload shows all child issues done) → verify the children's final states with one GET, then close the parent: `PATCH` status `done` with a summary comment, unless the parent's own acceptance criteria still have open work. Do not re-plan or re-open finished children.95- `PAPERCLIP_WAKE_REASON=issue_comment_mentioned` → read the comment thread first even if you're not the assignee. Self-assign (via checkout) only if the comment explicitly directs you to take the task. Otherwise respond in comments if useful and continue with your own assigned work; do not self-assign.96- Wake names a **resolved/expired interaction** (reason `interaction_resolved`, or the payload cites an interaction outcome) → read the **outcome before acting on it**. `accepted`/`answered` licenses the continuation you were waiting on. `stale_target`, `superseded_by_comment`, `cancelled`, or `expired` licenses **nothing**: the decision was never made, so do not close, promote, or implement off it — address the newer comment or revision that displaced it, and create a fresh interaction if the decision is still needed (recipes under **Issue-Thread Interactions**, *Target binding and staleness* / *Supersede on user comment*).97- Wake payload says `dependency-blocked interaction: yes` → the issue is still blocked for deliverable work and **checkout is not part of this heartbeat** — a checkout claims the issue for work, and there is no work to claim on a dependency-blocked issue. Do not try to unblock it and do not change its status. Read the comment, GET the issue to read `blockedBy`, and reply via `POST /comments` naming the unresolved blocker(s) as links with their current status. The reply is the deliverable.98- **Blocked-task dedup:** before touching a `blocked` task, check the thread. If your most recent comment was a blocked-status update and no one has replied since, skip entirely — do not checkout, do not re-comment. Only re-engage on new context (comment, status change, event wake). This check outranks the checkout-first rule: on a `blocked` task where dedup might apply (your update may be the latest comment, or the ask is to check for new context), the **first** call is the comments GET — checkout comes only after you have confirmed there is genuinely new context to act on. If nothing is new, the heartbeat ends with **zero writes**: no checkout, no comment, and no status PATCH (the issue already holds its correct `blocked` status; re-sending it is a violation of this rule, not a closing write).99- Nothing assigned and no valid mention handoff → exit the heartbeat.100101**Step 5 — Checkout.** You MUST checkout before doing any work. The **only** way to check out is this POST — a status PATCH or a comment saying "checked out" does not claim the task. Copy this call (the double-quoted `-d` makes the env vars expand):102103```bash104curl -s -X POST "$PAPERCLIP_API_URL/api/issues/$ISSUE_ID/checkout" \105 -H "Authorization: Bearer $PAPERCLIP_API_KEY" \106 -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \107 -H "Content-Type: application/json" \108 -d "{\"agentId\": \"$PAPERCLIP_AGENT_ID\", \"expectedStatuses\": [\"todo\", \"backlog\", \"blocked\", \"in_review\"]}"109```110111If already checked out by you, returns normally. Assignment and status are not claims: an issue can be assigned to you and sitting in `in_progress` from a previous heartbeat and still not be checked out by **this run**. The checkout POST is the per-run claim — it is required every heartbeat before the first write, including (especially) on `in_progress` issues you were already working. It is idempotent, so there is never a reason to skip it. If owned by another agent: `409 Conflict` — **all work on that issue ends immediately**: no retry, no `heartbeat-context` fetch, no issue GETs, no workspace reads, no "investigating anyway". Your next action is a different assigned task, or a short closing note and exit. **Never retry a 409.** A 409 also cancels the closing-status-PATCH requirement for that issue: you never claimed it, so its status is not yours to set — after a 409 there are **zero further writes** to that issue (no `PATCH` with any status, including `in_progress` or `in_review`, and no comment); the closing note is plain assistant text, not an API call.112113The moment you pick an issue to work on, your **very next tool call is its checkout POST** — `heartbeat-context`, comment reads, and any workspace file access all come after the checkout has returned 2xx.114115**Step 6 — Understand context.** Prefer `GET /api/issues/{issueId}/heartbeat-context` first. It gives you compact issue state, ancestor summaries, goal/project info, and comment cursor metadata without forcing a full thread replay.116117If `PAPERCLIP_WAKE_PAYLOAD_JSON` is present, inspect that payload before calling the API. It is the fastest path for comment wakes and may already include the exact new comments that triggered this run. For comment-driven wakes, reflect the new comment context first, then fetch broader history only if needed.118119Use comments incrementally:120121- if `PAPERCLIP_WAKE_COMMENT_ID` is set, fetch that exact comment first with `GET /api/issues/{issueId}/comments/{commentId}`122- if you already know the thread and only need updates, use `GET /api/issues/{issueId}/comments?after={last-seen-comment-id}&order=asc`123- use the full `GET /api/issues/{issueId}/comments` route only when cold-starting or when incremental isn't enough124125Read enough ancestor/comment context to understand _why_ the task exists and what changed. Do not reflexively reload the whole thread on every heartbeat.126127**Execution-policy review/approval wakes.** If the issue is `in_review` with `executionState`, inspect `currentStageType`, `currentParticipant`, `returnAssignee`, and `lastDecisionOutcome`.128129If `currentParticipant` matches you, submit your decision via the normal update route — there is no separate execution-decision endpoint:130131- Approve: `PATCH /api/issues/{issueId}` with `{ "status": "done", "comment": "Approved: …" }`. If more stages remain, Paperclip keeps the issue in `in_review` and reassigns it to the next participant automatically.132- Request changes: `PATCH` with `{ "status": "in_progress", "comment": "Changes requested: …" }`. Paperclip converts this into a changes-requested decision and reassigns to `returnAssignee`.133134If `currentParticipant` does not match you, do not try to advance the stage — Paperclip will reject other actors with `422`. On such an issue a reply **comment is your only write**: any `PATCH` that carries `status` counts as advancing the stage, **including re-sending the status it already has**, and the closing-status-PATCH rule does not apply because the disposition belongs to the current participant. Never write `executionState` through a PATCH body. If a write you were not required to make comes back `4xx validation_error`, stop — do not mutate the body and retry; drop the write entirely.135136**Step 7 — Do the work.** Use your tools and capabilities. Execution contract:137138- If the issue is actionable, start concrete work in the same heartbeat. Do not stop at a plan unless the issue specifically asks for planning.139- **Note-first ordering.** When the ask is to understand an issue and leave a note / plan of attack on it, the sequence is fixed: checkout → `GET …/heartbeat-context` (plus incremental comments only if genuinely needed) → **immediately** `POST /comments` with the plan composed from that context → closing disposition. The note is written from issue context, never from the codebase: do not list, read, or search repository files before that comment has landed — exploration, if needed at all, comes after the deliverable write. **Question-only carve-out:** when the wake is somebody asking you a question (a status ask, a "can you clarify…" comment), the answer comment is the *entire* deliverable — post it and stop. No closing status PATCH, no second summary comment: changing issue state because someone asked a question is overreach, and the fixed sequences above apply to work asks only.140- Leave durable progress in comments, issue documents, or work products, then update the issue state/path to a clear final disposition before you exit.141- Treat comments, documents, screenshots, work products, and `Remaining` bullets as evidence. They are not valid liveness paths by themselves.142- Use child issues for parallel or long delegated work; do not busy-poll agents, sessions, child issues, or processes waiting for completion.143- If your heartbeat creates a pending board/user interaction or approval before more work can proceed, leave the source issue in an explicit waiting posture before you exit. Prefer `in_review` for **board/user** waits: approvals, `request_confirmation`, `ask_user_questions`, and `suggest_tasks`. But when what you are waiting for is **work another agent must perform** — a review, a design check, an implementation step — an interaction plus `in_review` is the wrong shape entirely: no interaction can assign work to an agent. Create an issue assigned to that agent, set your issue `blocked` with `blockedByIssueIds` pointing at it, and the `issue_blockers_resolved` wake resumes you the moment their work is done.144- If blocked, move the issue to `blocked` with the unblock owner and exact action needed.145- Respect budget, pause/cancel, approval gates, execution policy stages, and company boundaries.146147### Generated Artifacts and Work Products148149When work produces a user-inspectable file, upload true deliverables to the current issue before final disposition and create an artifact work product. Local filesystem paths are not enough because board users, reviewers, and cloud operators may not have access to the agent workspace.150151The upload is one multipart POST — never a JSON body, never a comment:152153```bash154curl -s -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/issues/$ISSUE_ID/attachments" \155 -H "Authorization: Bearer $PAPERCLIP_API_KEY" \156 -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \157 -F "file=@report.md"158```159160**Trigger (mechanical):** any wrap-up ask whose deliverable is a finished file in your workspace — "the report/export/output is at `<file>` in your workspace, wrap the task up" — selects the fixed sequence **checkout → attachments POST → work-products POST → closing done PATCH**. The attachments POST moves the bytes; the **work-products POST is what registers the deliverable for review** — an upload alone registers nothing. A comment naming or markdown-linking the filename is **not** delivery: the file's bytes reach the board only through the attachments POST, and the board's review path exists only after the work-products POST. Before any closing `done` PATCH, ask: did this work produce a deliverable? If yes, both writes must already have 2xx responses in this heartbeat.161162**Registering a work product is one POST** — `POST /api/issues/{issueId}/work-products` with the `X-Paperclip-Run-Id` header — never a comment and never a status field. Pick the body by deliverable shape:163164- **Uploaded file** → `{"type": "artifact", "isPrimary": true, "metadata": {"attachmentId": "<id from the attachments POST response>"}}` (`isPrimary: true` when it is the main reviewable deliverable; the server canonicalizes the rest from the attachment).165- **Opened PR** → `{"type": "pull_request", "title": "<short name>", "url": "<the PR URL>"}`. Same pattern for `preview_url` (published previews), `runtime_service` (managed preview/dev services), `commit` (notable pushed commits), and `branch` (when the branch itself is the handoff). Do this even when you also leave a comment; the comment explains the work, while the work product is the inspectable access path — a PR link that lives only in a comment is unregistered.166- **File that intentionally stays in the project or execution workspace** (source file, committed report, generated index) → `{"type": "document", "metadata": {"resourceRef": {"kind": "workspace_file", "workspaceKind": "execution_workspace", "workspaceId": "<from GET /api/issues/{issueId}/heartbeat-context>", "relativePath": "<path relative to the workspace root>"}}}`. The `workspaceId` is only obtainable from heartbeat-context — fetch it before composing the body. Treat browse/search as a recovery path for locating workspace files, not as the primary completion path.167168**Trigger (mechanical, stays-in-workspace):** when the ask says the file should remain in the workspace — "keep it in the repo", "it stays in the workspace", "committed in the checkout", "no need to upload" — the sequence is **checkout → heartbeat-context GET (for the workspace169170…(truncated)