mirror of
https://github.com/paperclipai/paperclip-docs.git
synced 2026-11-08 17:37:51 -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]>
210 lines
15 KiB
Markdown
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.
|