## 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]>
7.1 KiB
paperclip_version, seo_title, seo_description
| paperclip_version | seo_title | seo_description |
|---|---|---|
| v2026.720.0 | Inbox Agent Policy API | Your personal switch for what agents may do to your inbox — archiving, clearing, and keeping the attention queue tidy — and when to take it back. |
Inbox Agent Policy
Agents can act on your inbox — archiving items, clearing things out, keeping your attention queue tidy. That is helpful right up until it isn't. The inbox agent policy is your personal switch: per company, you decide whether agents may manage your inbox at all, and if so, which ones.
Each policy belongs to one user in one company. You manage your own through the me routes, and a company administrator can read or set another member's through the by-user routes.
Every route below is company-scoped, and company access is checked on every request. If you are new to the API, read the API Overview first for base URL, authentication, and error-code conventions; this page builds on those and won't repeat them.
The three modes
| Mode | What it means |
|---|---|
open |
Any agent may manage your inbox. This is the default. |
allowlist |
Only the agents you list in allowedAgentIds may manage your inbox. |
disabled |
No agent may manage your inbox. |
open is not just the starting value — it is what applies when no policy row exists at all.
The policy object
{
"companyId": "…",
"userId": "…",
"mode": "open",
"allowedAgentIds": [],
"materialized": false,
"createdAt": null,
"updatedAt": null
}
| Field | Notes |
|---|---|
mode |
open, allowlist, or disabled. |
allowedAgentIds |
Agent UUIDs. Always [] unless mode is allowlist. |
materialized |
false when nothing has been saved yet and you are seeing the default; true once a policy has been written. |
createdAt / updatedAt |
Timestamps, or null while unmaterialized. |
Your own policy
GET /api/companies/{companyId}/users/me/inbox-agent-policy
PUT /api/companies/{companyId}/users/me/inbox-agent-policy
These routes need company access and nothing more — no permission key is involved. You are always allowed to manage your own inbox policy.
They do require a board user context: the acting identity has to be a board actor carrying a user id. A caller without one — an agent API key, for instance — gets 401 with { "error": "Board user context required" }, because there is no "me" to resolve.
Example
curl -sS -X PUT \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
"https://paperclip.example.com/api/companies/{companyId}/users/me/inbox-agent-policy" \
-d '{
"mode": "allowlist",
"allowedAgentIds": ["6d2f1c8e-1f4b-4a51-9f0b-2c2a4c9c8f11"]
}'
Someone else's policy
GET /api/companies/{companyId}/users/{userId}/inbox-agent-policy
PUT /api/companies/{companyId}/users/{userId}/inbox-agent-policy
Administrators use these to inspect or set a teammate's policy — during onboarding, or to switch an inbox off while someone is away.
Both routes require inbox agent policy administration authority, which means one of:
- a board actor from the implicit local session, or an instance admin;
- a board user holding the
users:manage_permissionspermission in that company; - an agent holding the
users:manage_permissionspermission in that company.
Anything else returns 403 Forbidden with { "error": "Inbox agent policy administration authority required" } and code inbox_agent_policy_admin_required.
Note. The permission these routes check is
users:manage_permissions, notinbox:manage. Theinbox:managepermission key does exist in the permission catalog, but it governs whether an agent may act on an inbox — not whether it may edit the policy. See Who the policy actually governs below.
After the authority check, the target user must be an active member of the company. If there is no membership, or the membership isn't active, the request returns 404 with { "error": "Active company user membership not found" }.
Updating a policy
Both PUT routes take the same body:
| Field | Required | Notes |
|---|---|---|
mode |
yes | open, allowlist, or disabled. |
allowedAgentIds |
no | Array of agent UUIDs, up to 100. Defaults to []. |
Rules:
- Unknown fields are rejected.
allowedAgentIdsmust be empty unlessmodeisallowlist. Sending agents alongsideopenordisabledfails validation with "allowedAgentIds must be empty when mode is not "allowlist"".- Validation failures come back as
400 Bad Requestwith{ "error": "Validation error", "details": [...] }. - Duplicate agent ids are de-duplicated before saving.
- Every allowlisted agent must belong to this company. Any that don't return
422 Unprocessable Entitywith{ "error": "Inbox agent policy contains agents outside the company" }, codeinbox_agent_policy_invalid_agents, and aninvalidAgentIdsarray naming the offenders.
Switching away from allowlist clears the list. If you set mode to open or disabled, allowedAgentIds is stored as [] — so switching back to allowlist later means naming your agents again.
Every successful write — through either the me or the by-user route — records an inbox.agent_policy_updated activity entry against entity type user_inbox_agent_policy, with the affected userId, the previousMode, the new mode, and the resulting allowedAgentIds.
Who the policy actually governs
The policy is consulted when an agent attempts the inbox:manage action, and how it applies depends on whose inbox is being touched.
The agent's own responsible user. When an agent acts on the inbox of the user it is running on behalf of, that user's policy is the deciding factor:
disabled→ denied, reasoninbox_management_disabled.allowlistand the agent isn't on the list → denied, reasoninbox_agent_not_allowed.- Otherwise allowed, carrying the effective
inboxPolicyMode(openwhen nothing is stored).
Any other user. Reaching into a different member's inbox is a board-admin override, not something the target's policy opens up. The agent must hold an explicit inbox:manage grant in the company, and that grant's scope must cover the target user. Without the grant the denial reason is deny_missing_grant; with a grant whose scope doesn't reach that user it is deny_scope. A successful cross-user action is allowed by explicit grant and reports inboxPolicyMode: "grant_override" — the target's own policy is not applied in this path.
In both cases the acting agent must be active in the company, and the target user must be an active member.
Where to go next
- Attention — the inbox surface these policies protect.
- Agents — the agents you name in
allowedAgentIds. - Resource Memberships — company memberships and the permission grants behind
users:manage_permissionsandinbox:manage. - API Overview — base URL, authentication, company scoping, and the shared error-code table.