Files
paperclip-docs/docs/reference/api/attention.md
Aron PrinsandClaude Opus 5 325c9eb819 seo: hand-write titles and descriptions for all 192 pages (#115)
## 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]>
2026-08-25 22:51:42 +02:00

210 lines
15 KiB
Markdown

---
paperclip_version: v2026.824.0
seo_title: Attention API
seo_description: Read the board's ranked decision queue — the feed behind the Decisions screen, covering approvals, agent questions, and blocked work in one call.
---
# Attention
Read the board's current decision queue. This is the feed behind the **Decisions** screen: a single, ranked view of work that needs a person to act, from approvals and questions to blocked work, failed runs, budget alerts, and agent errors.
Use this API when you are building an operator view or need to give a board user a concise, current list of what needs their attention. It is a read-only feed; the action a person takes still belongs to the underlying approval, interaction, issue, or alert.
If you have not read it yet, start with the [API Overview](./overview.md) for the base URL, authentication, and company-scoping rules.
---
## Read the queue
```
GET /api/companies/{companyId}/attention
```
The route requires company access and board access. A board actor also needs a user context; otherwise it returns `403` with `"Board user context required"`.
By default, the feed leaves out items that the current user has dismissed or snoozed. Add `includeDismissed=true` when you are rendering those hidden rows as well.
```bash
curl -s \
"http://localhost:3100/api/companies/{companyId}/attention?includeDismissed=true" \
-H "Authorization: Bearer <token>"
```
### Query Parameters
| Param | Description |
|---|---|
| `includeDismissed` | `true` to include rows the current user has dismissed or snoozed. |
| `activitySince` / `activityUntil` | ISO timestamps bounding each item's `activityAt`. |
| `queue` | Only items belonging to the [decision queue](./decisions.md) with this key. |
| `sort` | `activity` (default) or `decide`. |
| `cursor` | Continue from a previous page's `nextCursor`. |
| `limit` | Integer between 1 and 100. Defaults to 50. |
A bad boundary returns `400 Bad Request` with `"activitySince must be an ISO timestamp"` (or the same for `activityUntil`), and a reversed pair returns `"activitySince must be before or equal to activityUntil"`. Other validation messages are `"sort must be 'activity' or 'decide'"`, `"limit must be an integer between 1 and 100"`, and — for a cursor that is malformed or was minted under a different sort — `"Invalid attention cursor"`.
The response is an `AttentionFeed` with these top-level fields:
| Field | What you get |
|---|---|
| `companyId` | The company that owns the feed. |
| `generatedAt` | When Paperclip generated this view. |
| `totalCount` | The number of items in the whole filtered feed, before paging. |
| `decideNowCount` | How many of those are due today, by their decide-by deadline. This is the number behind the sidebar badge. |
| `nextCursor` | Pass this back as `cursor` for the next page, or `null` when you have reached the end. |
| `countsBySourceKind` | Counts grouped by each attention source. |
| `items` | The ranked `AttentionItem` records for this page. |
Each item tells you what needs attention (`whyNow`), the subject to open (`subject`), suggested actions (`decisionVerbs`), its `severity`, and its `activityAt` time. It also carries an `entryRule` and `exitRule` so an operator can understand why it appeared and what clears it. When available, `relatedIssue`, `project`, `workspace`, and `detail` add context without another lookup.
Items also carry their sidecar state and a little provenance:
| Field | What you get |
|---|---|
| `rank` | The item's 1-based position in the full ranked feed. |
| `queues` | The decision queues this item belongs to, each `{ key, title }`. |
| `decideBy` | `today`, `this_week`, `whenever`, a `YYYY-MM-DD` date, or `null`. |
| `decideByAttribution` | Who set that deadline, or `null`. |
| `snoozedUntil` | When a triage snooze lifts, or `null`. |
| `expiresAt` | When the underlying work ages out, or `null`. |
| `ruleKey` | The rule a proposed decision was raised under, or `null`. |
| `originAgentName` | The agent behind the item, when there is one. |
| `inlineResolvable` | Whether the row can be resolved in place rather than by opening the subject. |
| `trainingExampleId` | Set when you have already kept this item as a [decision training](./decision-training.md) example. |
### Sources and severity
`sourceKind` is one of:
- `approval`
- `decision`
- `issue_thread_interaction`
- `join_request`
- `recovery_action`
- `productivity_review`
- `blocker_attention`
- `review`
- `failed_run`
- `budget_alert`
- `agent_error_alert`
`severity` is one of `critical`, `high`, `medium`, or `low`.
Paperclip deduplicates the underlying signals, then ranks the feed. With the default `sort=activity` that means recent activity first, then severity, then source priority, then a stable deduplication key. With `sort=decide` the feed leads with items that have a real deadline — soonest first — then items marked `whenever`, then everything untriaged, breaking ties on `expiresAt`, severity, and the activity order above.
Treat it as a decision queue, not as a complete history of every event in your company.
### Decisions in the feed
A `decision` item is an agent's open proposal: a question with a short list of options and the exact changes each one would make. Those rows are resolvable in place, and `expiresAt` tells you when the proposal lapses on its own. The full record, and the routes to answer it, live in the [Decisions API](./decisions.md).
---
## Agent-addressed issue-thread interactions
An `issue_thread_interaction` item is a card an agent raised on an issue thread — a question, a confirmation, a set of verdicts. Historically every such card waited for the board. Cards can now be **addressed to a specific agent** and resolved by an **eligible agent under company governance**, which changes both who can answer and whether the card shows up in this feed at all.
The full interaction lifecycle (create, respond, withdraw, expiry) lives in the [Issues API](./issues.md#interactions); this section covers the addressee and resolver-policy surface that decides how a card is routed.
### Resolver policy
Every card carries a resolver policy that names its audience — who is allowed to answer it. The canonical values are:
| Value | Audience |
|---|---|
| `anyone` | Anyone in the company can respond — the board or any agent, including the one that asked. |
| `not_creator` | Anyone in the company except the agent that created the card, and its creating run. |
| `human_only` | Only a person on the board can respond. Agents are turned away. |
Two older values, `board_or_agents` and `board_only`, are **deprecated compatibility aliases** kept writable for one migration window. On write they normalize to canonical values — `board_or_agents` → `anyone`, `board_only` → `human_only` — and every record you read back reports the canonical value. For back-compatibility each record also carries `legacyResolverPolicyAliases` with the alias form of the requested and effective policies (`anyone` and `human_only` map back to `board_or_agents` and `board_only`; `not_creator` has no alias and reports `null`).
Two fields set on create decide routing:
| Field | Values | Meaning |
|---|---|---|
| `resolverPolicy` | `anyone`, `not_creator`, `human_only` (or a deprecated alias) | The policy requested when the card was created. Optional on create. |
| `addresseeAgentId` | agent UUID or `null` | Optional. Addresses the card to one specific agent, which is woken to resolve it. Must reference an invokable agent in the same company. |
The server resolves the requested policy against **company governance** and stores the result on the interaction:
| Field | Meaning |
|---|---|
| `resolverPolicy` | Deprecated mirror of `requestedResolverPolicy`, kept for API compatibility. |
| `requestedResolverPolicy` | The policy asked for, canonicalized: the create request's `resolverPolicy`, else the company's per-kind `defaultPolicy`, else the built-in per-kind default — which is `anyone` for every interaction kind (`suggest_tasks`, `ask_user_questions`, `request_confirmation`, `request_checkbox_confirmation`, `request_item_verdicts`). A card created without an explicit policy is open by default. |
| `effectiveResolverPolicy` | What is actually enforced. Equals `requestedResolverPolicy` unless a rule tightens it (see below). |
| `resolverPolicyProvenance` | How the requested policy was chosen: `explicit` (the request named one) or `inherited` (it fell back to governance or the built-in default). A pre-migration row whose restriction was inherited under the old model reports `legacy_inherited_restriction`. |
| `effectiveResolverPolicySource` | Why the effective policy is what it is: `requested`, `company_cap`, or `governed_action`. |
Tightening is one-directional along the order `anyone` → `not_creator` → `human_only` (least to most restrictive). Two rules can tighten:
- **Governed action** — a card that carries a tool action is forced to `human_only` (`effectiveResolverPolicySource` `governed_action`), whatever audience was requested.
- **Company cap** — if the company's per-kind governance sets a `cap`, and that cap is more restrictive than the requested policy, the card is tightened to the cap (`effectiveResolverPolicySource` `company_cap`). A cap can only narrow; it never widens a card.
Company governance lives on the company setting `interactionResolverGovernance` — a per-kind map of `{ defaultPolicy?, cap? }`, each value being one of the canonical policies (or a deprecated alias). `defaultPolicy` sets the fallback audience when a create request omits `resolverPolicy`; `cap` is a ceiling that can only tighten. This map is **narrowing-only**: it can lower the audience but never raise it above what a card asked for.
### Who may resolve
A board user (a human) may always resolve a card — subject only to `not_creator`, which excludes the human who created it. An **agent** may resolve one only when every check passes, evaluated in this order:
- The call carries an authenticated agent run (an agent id and a non-empty run id) — otherwise `422` `interaction_run_attribution_required` (`A valid authenticated agent run is required to resolve this issue-thread interaction`).
- The card is not bound to a governed action — otherwise `403` `interaction_governed_action_denied` (`This interaction is bound to a governed action that requires independent authorization`).
- `effectiveResolverPolicy` is not `human_only` — otherwise `403` `interaction_human_only` (`This issue-thread interaction is human-only`).
- If `addresseeAgentId` is set, the calling agent must be that addressee — otherwise `403` `interaction_addressee_mismatch` (`Only the addressed agent or an authorized human may resolve this issue-thread interaction`).
- Under `not_creator`, the caller is neither the creating agent (`createdByAgentId`) nor the creating run (`sourceRunId`) — otherwise `403` `interaction_creator_excluded` (`This issue-thread interaction requires a resolver other than its creator or creating run`). For a human caller under `not_creator`, the parallel denial is `This issue-thread interaction requires a resolver other than its creator`.
Governed-action confirmations are always human-only — the agent path is refused before the audience check, and even a human resolves them only through the action's own independent authorization. Task-watchdog and other system runs are treated as `system` actors and are always allowed.
The full denial-code catalog for resolution is: `interaction_not_found`, `interaction_run_attribution_required`, `interaction_scope_denied`, `interaction_human_only`, `interaction_creator_excluded`, `interaction_addressee_mismatch`, `interaction_stale_target`, `interaction_superseded`, `interaction_already_resolved`, `interaction_issue_closed`, `interaction_governed_action_denied`, and `review_policy_denied`. Each denial response carries the offending `effectiveResolverPolicy` in its details.
### Attention-feed filtering
The board feed is deliberately quiet about cards that a governed agent is expected to handle. An `issue_thread_interaction` item is **dropped from this feed** when its `addresseeAgentId` points at an agent that is currently **invokable** — that card is the addressed agent's to resolve, not the board's. A card stays in the feed when:
- `addresseeAgentId` is `null` (it was raised for the board), or
- the addressed agent is no longer invokable — the card falls back to the board so it never gets stranded.
### Withdrawal and terminal expiry
Addressed cards settle through the same administrative endings as any other interaction, plus one that is specific to addressees:
- **Withdrawal** — the creator, the current issue assignee, or a board user can withdraw a pending card; its `result.outcome` becomes `withdrawn`. See [withdraw vs. cancel](./issues.md#withdraw-vs-cancel).
- **Issue closed** — when the issue reaches `done` or `cancelled`, every still-pending card on it expires with `result.outcome` `issue_closed`.
- **Addressee deleted** — when the addressed agent is deleted, its pending cards are cancelled with `result.outcome` `addressee_deleted` (`Cancelled because the addressed agent was deleted`).
Treat `withdrawn`, `issue_closed`, and `addressee_deleted` as administrative endings, not answers — no decision was made.
---
## Dismiss or snooze an item
The attention endpoint does not mutate the queue. Dismissal state belongs to the current board user and uses the inbox-dismissal routes instead.
```
GET /api/companies/{companyId}/inbox-dismissals
POST /api/companies/{companyId}/inbox-dismissals
DELETE /api/companies/{companyId}/inbox-dismissals/{itemKey}
```
For an attention item, pass its `dismissalKey`, which begins with `attention:`. A `POST` body has `itemKey`, an optional `kind` of `dismiss` or `snooze`, and `snoozedUntil` when you snooze. `snoozedUntil` must be a future ISO timestamp; it must be absent for a dismissal. A successful create returns `201`, and deleting the same item key restores it with `204`.
```json
{
"itemKey": "attention:<attention-dismissal-key>",
"kind": "snooze",
"snoozedUntil": "2026-07-13T09:00:00.000Z"
}
```
These routes require board authentication and a board user context. Invalid keys return `"Unsupported inbox item key"`; an invalid snooze time returns `"snoozedUntil must be an ISO timestamp"` or `"snoozedUntil must be in the future"`.
There is a second, different snooze. An inbox dismissal is yours alone and shows up on the item as `dismissal`. A **triage** snooze is set on the source itself, applies to everyone reading the feed, and shows up as `snoozedUntil`. Reach for triage when the whole company should stop seeing something until a date; reach for a dismissal when you personally want your own queue tidier. Triage lives on the [Decisions API](./decisions.md).
---
## Where to go next
- [Decisions](../../guides/day-to-day/decisions.md) — use the built-in operator workflow.
- [Decisions API](./decisions.md) — decision records, queues, and triage.
- [Approvals](./approvals.md) — act on approval records in the queue.
- [Issues](./issues.md) — work with issue-thread interactions and the issues behind many attention items.