mirror of
https://github.com/paperclipai/paperclip-docs.git
synced 2026-11-07 23:02:28 -10:00
## Why
The build derived `<title>` from the **sidebar label** and the
description from the **first paragraph clipped to 220 characters**. Both
are the wrong source. A sidebar label is written for a 240px-wide nav
that supplies its own context; a search result has none.
On production that produced:
- **7 pages titled "Overview"**, and 32 pages sharing a title with
another page
- **109 of 193 descriptions cut off mid-word**, and 174 over 160
characters (where Google truncates)
```
/guides/welcome/key-concepts/
BEFORE (220): ...and if you misunderstand them early, the product wi
```
Near-identical titles are the weak-differentiation signal behind
"crawled — currently not indexed", which is the exact symptom on this
property.
## What changed
`seo_title` and `seo_description` frontmatter on **every one of the 192
pages**, written by hand against each page's real content and optimised
for the click — lead with what the reader can do, name the concrete
things covered, no keyword stuffing, no repeating the title.
| | Before | After |
|---|---|---|
| Duplicate titles | 32 pages | **0** |
| Duplicate descriptions | — | **0** |
| Descriptions cut mid-word | 109 | **0** |
| Descriptions over 160 chars | 174 | **0** |
| Title length (incl. suffix) | up to "Overview" | **26–60** |
| Description length | 31–240 | **122–158** |
**No prose was rewritten.** I measured content overlap between the
colliding pairs first — only 17–23%, so these were always distinct pages
wearing the same label, not duplicate content. The `docs/` diff is **564
insertions, zero deletions**.
### Examples
```
BEFORE — all seven identical: "Overview | Paperclip Docs"
AFTER: "Adapters Overview" "API Overview" "CLI Overview"
"Deployment Overview" "Bundled Skills" "Optional Skills"
"Experimental Features"
BEFORE — both identical: "Issues | Paperclip Docs"
AFTER: "Issues: Creating and Tracking Work" /guides/day-to-day/issues/
"Issues API" /reference/api/issues/
```
```
/guides/getting-started/five-minute-path/
BEFORE (220): The shortest route from a running Paperclip instance to an agent that has
just completed work for you. After Paperclip is installed, the path takes
about 5 minutes: create a company, hire a CEO agent, approve its first s
AFTER (131): Install Paperclip, create a company, hire a CEO agent, and approve its
first strategy — a working AI company in about five minutes.
```
## Plumbing
- **`site/build-release.mjs`** prefers the authored fields; the derived
path stays only as a fallback the CI gate prevents ever reaching.
`<title>`, `<meta name="description">`, and the `TechArticle` JSON-LD
all read from the same source.
- **`site/app.js`** uses the same fields, so a client-side transition
cannot leave a different title on screen than the crawler was served.
Verified in-browser: navigating `/guides/day-to-day/issues/` →
`/reference/api/issues/` swaps to exactly the server-rendered values, no
JS errors.
- **`scripts/verify-seo-metadata.mjs`** (new) enforces presence,
uniqueness, length, complete-sentence endings, and the
frontmatter-safety rules. Wired into `docs:test` and CI as its own step.
I negative-tested the gate rather than assuming it works — it correctly
fails on a duplicate title, a mid-word description, and a missing field,
and passes again once restored.
## Automating it going forward
`skills/sync-docs/SKILL.md` now:
- gains an **"Authored SEO metadata"** section: why the fields exist,
the enforced rules as a table, and how to actually write them (title
names the subject not the nav position; description writes for the
click; never generate mechanically from the first paragraph — *that is
the fallback the gate exists to prevent*).
- requires both fields in the **page-authoring instruction** given to
every subagent, with guidance on when to update an existing description
versus leave it.
- runs `docs:test:seo-metadata` and `docs:test:crawlable-links` as
**fail-loud gates in Phase 7** before commit.
- documents all six build gates in the files/gates section.
So once this lands, a new page cannot ship without hand-written
metadata, and a title collision cannot reach production.
## Verified
All six verifiers pass:
```
Static route verification passed
Asset fingerprint verification passed
Hierarchical Skills nav verification passed
Skill source block verification passed (11 skill pages, 1 control page)
Crawlable-link contract passed: 40886 internal links across 194 pages resolve in one hop
Authored-SEO contract passed: 192 pages carry a unique, hand-written seo_title and seo_description
```
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
485 lines
13 KiB
Markdown
485 lines
13 KiB
Markdown
---
|
||
seo_title: Approvals API
|
||
seo_description: The board-review layer for actions that should not happen automatically — hires, CEO strategy sign-off, budget overrides, and other consequential moves.
|
||
---
|
||
|
||
# Approvals
|
||
|
||
Approvals are the board-review layer for decisions that should not happen automatically. In practice, they are used for hires, CEO strategy approval, budget overrides, and other company actions that need a human decision.
|
||
|
||
Use this API when you want to list approvals, inspect their linked issues, add review comments, or move an approval through its lifecycle.
|
||
|
||
---
|
||
|
||
## Approval Types
|
||
|
||
The API accepts these approval types:
|
||
|
||
| Type | What it means |
|
||
|---|---|
|
||
| `hire_agent` | A new agent hire or pending agent activation needs board review. |
|
||
| `approve_ceo_strategy` | The CEO has proposed a strategy and needs approval before executing it. |
|
||
| `budget_override_required` | A budget action needs board intervention. |
|
||
| `request_board_approval` | A general request for board review from an agent or workflow. |
|
||
|
||
---
|
||
|
||
## List Approvals
|
||
|
||
```http
|
||
GET /api/companies/{companyId}/approvals
|
||
```
|
||
|
||
Query parameters:
|
||
|
||
| Param | Description |
|
||
|---|---|
|
||
| `status` | Optional status filter such as `pending`, `revision_requested`, `approved`, or `rejected`. |
|
||
|
||
This endpoint returns company-scoped approvals only. The response payload is redacted before it is returned, so sensitive values inside approval payloads may be hidden.
|
||
|
||
---
|
||
|
||
## Get Approval
|
||
|
||
```http
|
||
GET /api/approvals/{approvalId}
|
||
```
|
||
|
||
Returns one approval record, including:
|
||
|
||
| Field | Notes |
|
||
|---|---|
|
||
| `type` | The approval type. |
|
||
| `status` | Current lifecycle state. |
|
||
| `payload` | The approval body, with sensitive values redacted when needed. |
|
||
| `requestedByAgentId` | The requesting agent, if one created it. |
|
||
| `requestedByUserId` | The requesting board user, if one created it. |
|
||
| `decisionNote` | The board note attached to the latest decision. |
|
||
| `decidedByUserId` | Who made the latest decision. |
|
||
| `decidedAt` | When the latest decision happened. |
|
||
|
||
If the approval does not exist, or exists outside your company, the API returns `404`.
|
||
|
||
---
|
||
|
||
## Create Approval
|
||
|
||
```http
|
||
POST /api/companies/{companyId}/approvals
|
||
Content-Type: application/json
|
||
```
|
||
|
||
Body:
|
||
|
||
| Field | Required | Notes |
|
||
|---|---|---|
|
||
| `type` | Yes | One of the approval types above. |
|
||
| `payload` | Yes | Free-form JSON payload for the approval. |
|
||
| `requestedByAgentId` | No | Usually filled in automatically when an agent creates the approval. |
|
||
| `issueIds` | No | Optional issue links to attach to the approval. Duplicate IDs are ignored. |
|
||
|
||
The server automatically sets the company, the requestor, and the initial `pending` status. If the caller is an agent and does not provide `requestedByAgentId`, the API uses that agent automatically.
|
||
|
||
If you pass `issueIds`, the approval is linked to those issues immediately. The linked issues must belong to the same company.
|
||
|
||
### Payload shape per type
|
||
|
||
The `payload` field is type-specific. The recommended shapes are:
|
||
|
||
**`request_board_approval`** — agent asks the board to sign off on a proposed action. Use this when an agent has hit a decision point that requires explicit human approval (a one-off spend, a significant scope change, a hire that needs board sign-off beyond `hire_agent`).
|
||
|
||
| Field | Notes |
|
||
|---|---|
|
||
| `title` | One-line headline shown in the board's approval queue. |
|
||
| `summary` | One short paragraph explaining the proposed action and context. |
|
||
| `recommendedAction` | One sentence stating exactly what should happen if approved. |
|
||
| `risks` | Array of strings naming the most material risks. Keep it tight. |
|
||
|
||
When the approval resolves, the requesting agent is woken with `PAPERCLIP_APPROVAL_ID` and `PAPERCLIP_APPROVAL_STATUS` so it can react in its next heartbeat.
|
||
|
||
```json
|
||
{
|
||
"type": "request_board_approval",
|
||
"requestedByAgentId": "{agentId}",
|
||
"issueIds": ["{issueId}"],
|
||
"payload": {
|
||
"title": "Approve monthly hosting spend",
|
||
"summary": "Estimated cost is $80/month for provider X to run the staging environment for the upcoming customer pilot.",
|
||
"recommendedAction": "Approve provider X and continue setup.",
|
||
"risks": ["Costs may increase with usage."]
|
||
}
|
||
}
|
||
```
|
||
|
||
**`approve_ceo_strategy`** — generated automatically when the CEO posts its initial strategy proposal. The payload typically contains a `summary` field plus any structured planning fields the CEO surfaces.
|
||
|
||
**`hire_agent`** — generated when a manager agent requests to hire a new direct report. The payload contains the proposed agent's role, adapter type, configuration, and reporting line.
|
||
|
||
<!-- tabs: cURL, JavaScript, Python -->
|
||
|
||
<!-- tab: cURL -->
|
||
|
||
```bash
|
||
curl -X POST "http://localhost:3100/api/companies/company-1/approvals" \
|
||
-H "Authorization: Bearer <token>" \
|
||
-H "Content-Type: application/json" \
|
||
-d '{
|
||
"type": "approve_ceo_strategy",
|
||
"requestedByAgentId": "agent-1",
|
||
"issueIds": ["issue-1", "issue-2"],
|
||
"payload": {
|
||
"summary": "Focus on onboarding, retention, and a first customer interview loop.",
|
||
"priority": "high"
|
||
}
|
||
}'
|
||
```
|
||
|
||
<!-- tab: JavaScript -->
|
||
|
||
```js
|
||
const res = await fetch("http://localhost:3100/api/companies/company-1/approvals", {
|
||
method: "POST",
|
||
headers: {
|
||
Authorization: "Bearer <token>",
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
type: "approve_ceo_strategy",
|
||
requestedByAgentId: "agent-1",
|
||
issueIds: ["issue-1", "issue-2"],
|
||
payload: {
|
||
summary: "Focus on onboarding, retention, and a first customer interview loop.",
|
||
priority: "high",
|
||
},
|
||
}),
|
||
});
|
||
|
||
const approval = await res.json();
|
||
```
|
||
|
||
<!-- tab: Python -->
|
||
|
||
```python
|
||
import requests
|
||
|
||
res = requests.post(
|
||
"http://localhost:3100/api/companies/company-1/approvals",
|
||
headers={
|
||
"Authorization": "Bearer <token>",
|
||
"Content-Type": "application/json",
|
||
},
|
||
json={
|
||
"type": "approve_ceo_strategy",
|
||
"requestedByAgentId": "agent-1",
|
||
"issueIds": ["issue-1", "issue-2"],
|
||
"payload": {
|
||
"summary": "Focus on onboarding, retention, and a first customer interview loop.",
|
||
"priority": "high",
|
||
},
|
||
},
|
||
)
|
||
|
||
approval = res.json()
|
||
```
|
||
|
||
<!-- /tabs -->
|
||
|
||
---
|
||
|
||
## Linked Issues
|
||
|
||
```http
|
||
GET /api/approvals/{approvalId}/issues
|
||
```
|
||
|
||
Returns the issues linked to a specific approval.
|
||
|
||
This is useful when:
|
||
|
||
- you want to show which tasks belong to a strategy approval
|
||
- you want to trace a board decision back to the work it governs
|
||
- you created the approval with `issueIds` and want to verify the links
|
||
|
||
Important caveats:
|
||
|
||
- linked issues must belong to the same company as the approval
|
||
- linking a missing issue returns `404`
|
||
- linking a cross-company issue returns `422`
|
||
|
||
---
|
||
|
||
## Review A Decision
|
||
|
||
The decision endpoints are board-only:
|
||
|
||
- `POST /api/approvals/{approvalId}/approve`
|
||
- `POST /api/approvals/{approvalId}/reject`
|
||
- `POST /api/approvals/{approvalId}/request-revision`
|
||
|
||
Only approvals in `pending` or `revision_requested` can be approved or rejected.
|
||
Only approvals in `pending` can be sent to revision.
|
||
|
||
When you approve a `hire_agent` approval:
|
||
|
||
- an existing pending agent is activated, or a new agent is created from the approval payload
|
||
- a monthly budget policy may be created for the new agent when the payload includes a positive budget
|
||
- the requesting agent may be woken up so it can continue after approval
|
||
|
||
When you reject a `hire_agent` approval:
|
||
|
||
- if the approval already points at a draft agent, that agent is terminated
|
||
|
||
When you request revision:
|
||
|
||
- the status changes to `revision_requested`
|
||
- the `decisionNote` is stored so the requester can use it as guidance
|
||
|
||
<!-- tabs: cURL, JavaScript, Python -->
|
||
|
||
<!-- tab: cURL -->
|
||
|
||
```bash
|
||
curl -X POST "http://localhost:3100/api/approvals/approval-1/request-revision" \
|
||
-H "Authorization: Bearer <board-token>" \
|
||
-H "Content-Type: application/json" \
|
||
-d '{
|
||
"decisionNote": "Please reduce the budget and make the role description more specific."
|
||
}'
|
||
```
|
||
|
||
<!-- tab: JavaScript -->
|
||
|
||
```js
|
||
const res = await fetch("http://localhost:3100/api/approvals/approval-1/request-revision", {
|
||
method: "POST",
|
||
headers: {
|
||
Authorization: "Bearer <board-token>",
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
decisionNote: "Please reduce the budget and make the role description more specific.",
|
||
}),
|
||
});
|
||
|
||
const approval = await res.json();
|
||
```
|
||
|
||
<!-- tab: Python -->
|
||
|
||
```python
|
||
import requests
|
||
|
||
res = requests.post(
|
||
"http://localhost:3100/api/approvals/approval-1/request-revision",
|
||
headers={
|
||
"Authorization": "Bearer <board-token>",
|
||
"Content-Type": "application/json",
|
||
},
|
||
json={
|
||
"decisionNote": "Please reduce the budget and make the role description more specific.",
|
||
},
|
||
)
|
||
|
||
approval = res.json()
|
||
```
|
||
|
||
<!-- /tabs -->
|
||
|
||
You can use the same pattern for `approve` and `reject` by changing the path to `/approve` or `/reject`.
|
||
|
||
---
|
||
|
||
## Comments
|
||
|
||
```http
|
||
GET /api/approvals/{approvalId}/comments
|
||
POST /api/approvals/{approvalId}/comments
|
||
Content-Type: application/json
|
||
```
|
||
|
||
Body for `POST`:
|
||
|
||
| Field | Required | Notes |
|
||
|---|---|---|
|
||
| `body` | Yes | Comment text in markdown. |
|
||
|
||
Comments are the lightweight review thread for an approval. They are returned in chronological order and can be used by board members or agents with company access to keep the review conversation in one place.
|
||
|
||
---
|
||
|
||
## Resubmit
|
||
|
||
```http
|
||
POST /api/approvals/{approvalId}/resubmit
|
||
Content-Type: application/json
|
||
```
|
||
|
||
Body:
|
||
|
||
| Field | Required | Notes |
|
||
|---|---|---|
|
||
| `payload` | No | Optional replacement payload. If omitted, the existing payload stays in place. |
|
||
|
||
Resubmit is only valid when the approval is in `revision_requested`.
|
||
|
||
Requester caveats:
|
||
|
||
- if the caller is an agent, it must be the same agent that originally requested the approval
|
||
- board users can resubmit revision-requested approvals as well
|
||
- sending a new payload replaces the old payload
|
||
|
||
<!-- tabs: cURL, JavaScript, Python -->
|
||
|
||
<!-- tab: cURL -->
|
||
|
||
```bash
|
||
curl -X POST "http://localhost:3100/api/approvals/approval-1/resubmit" \
|
||
-H "Authorization: Bearer <token>" \
|
||
-H "Content-Type: application/json" \
|
||
-d '{
|
||
"payload": {
|
||
"summary": "Revised strategy with smaller scope and lower cost."
|
||
}
|
||
}'
|
||
```
|
||
|
||
<!-- tab: JavaScript -->
|
||
|
||
```js
|
||
const res = await fetch("http://localhost:3100/api/approvals/approval-1/resubmit", {
|
||
method: "POST",
|
||
headers: {
|
||
Authorization: "Bearer <token>",
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
payload: {
|
||
summary: "Revised strategy with smaller scope and lower cost.",
|
||
},
|
||
}),
|
||
});
|
||
|
||
const approval = await res.json();
|
||
```
|
||
|
||
<!-- tab: Python -->
|
||
|
||
```python
|
||
import requests
|
||
|
||
res = requests.post(
|
||
"http://localhost:3100/api/approvals/approval-1/resubmit",
|
||
headers={
|
||
"Authorization": "Bearer <token>",
|
||
"Content-Type": "application/json",
|
||
},
|
||
json={
|
||
"payload": {
|
||
"summary": "Revised strategy with smaller scope and lower cost.",
|
||
}
|
||
},
|
||
)
|
||
|
||
approval = res.json()
|
||
```
|
||
|
||
<!-- /tabs -->
|
||
|
||
---
|
||
|
||
## Comments
|
||
|
||
```http
|
||
GET /api/approvals/{approvalId}/comments
|
||
POST /api/approvals/{approvalId}/comments
|
||
Content-Type: application/json
|
||
```
|
||
|
||
Body for `POST`:
|
||
|
||
| Field | Required | Notes |
|
||
|---|---|---|
|
||
| `body` | Yes | Comment text in markdown. |
|
||
|
||
Comments are a lightweight review thread attached to the approval. Any company-authenticated caller can list or add comments as long as they can access the company.
|
||
|
||
<!-- tabs: cURL, JavaScript, Python -->
|
||
|
||
<!-- tab: cURL -->
|
||
|
||
```bash
|
||
curl -X POST "http://localhost:3100/api/approvals/approval-1/comments" \
|
||
-H "Authorization: Bearer <token>" \
|
||
-H "Content-Type: application/json" \
|
||
-d '{
|
||
"body": "Looks good overall, but please shorten the timeline."
|
||
}'
|
||
```
|
||
|
||
<!-- tab: JavaScript -->
|
||
|
||
```js
|
||
const res = await fetch("http://localhost:3100/api/approvals/approval-1/comments", {
|
||
method: "POST",
|
||
headers: {
|
||
Authorization: "Bearer <token>",
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
body: "Looks good overall, but please shorten the timeline.",
|
||
}),
|
||
});
|
||
|
||
const comment = await res.json();
|
||
```
|
||
|
||
<!-- tab: Python -->
|
||
|
||
```python
|
||
import requests
|
||
|
||
res = requests.post(
|
||
"http://localhost:3100/api/approvals/approval-1/comments",
|
||
headers={
|
||
"Authorization": "Bearer <token>",
|
||
"Content-Type": "application/json",
|
||
},
|
||
json={
|
||
"body": "Looks good overall, but please shorten the timeline.",
|
||
},
|
||
)
|
||
|
||
comment = res.json()
|
||
```
|
||
|
||
<!-- /tabs -->
|
||
|
||
---
|
||
|
||
## Lifecycle
|
||
|
||
```
|
||
pending -> approved
|
||
-> rejected
|
||
-> revision_requested -> pending
|
||
```
|
||
|
||
Practical rules:
|
||
|
||
- `approve` and `reject` work only on `pending` and `revision_requested`
|
||
- `request-revision` works only on `pending`
|
||
- `resubmit` works only on `revision_requested`
|
||
- `approve`, `reject`, and `request-revision` require board access
|
||
- `resubmit` can be done by the requesting agent, or by the board
|
||
|
||
---
|
||
|
||
## Common Errors
|
||
|
||
| Code | Meaning | When it happens |
|
||
|---|---|---|
|
||
| `403` | Forbidden | A board-only action was called without board access, or an agent tried to resubmit someone else’s approval. |
|
||
| `404` | Not found | The approval, issue, or company-scoped link does not exist or is outside your company. |
|
||
| `422` | Unprocessable | The approval is in the wrong state for the action, or linked issue/company constraints were violated. |
|