## 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.2 KiB
paperclip_version, seo_title, seo_description
| paperclip_version | seo_title | seo_description |
|---|---|---|
| v2026.529.0 | Resource Memberships API | How a signed-in board user curates their own sidebar by joining or leaving projects and agents — and why leaving is not the same as losing access. |
Resource Memberships
Resource memberships are how a signed-in board user curates their own sidebar. Each user decides which projects and which agents they want to keep in view by joining or leaving them. Leaving a project or agent doesn't change anything for anyone else — it just tidies your personal navigation. By default everything is treated as joined until you choose to leave it.
Documents work a little differently. You don't join or leave a document — you star it, so the ones you care about are easy to find again. Stars are personal too: starring a document changes nothing for your colleagues.
These routes always act on the current user (the me segment in the path). You cannot read or change someone else's memberships through this API.
All routes are company-scoped and require board (user) authentication. Agent tokens are rejected with
403.
List Your Memberships
GET /api/companies/{companyId}/resource-memberships/me
Return the calling user's membership state for every project and agent they have explicitly joined or left in the company, plus the documents they have starred.
The response groups state by resource type. Each entry maps a resource id to either joined or left. Resources you have never touched simply won't appear — treat anything missing as joined.
{
"projectMemberships": {
"1f3c…": "joined",
"8ad2…": "left"
},
"agentMemberships": {
"b91e…": "left"
},
"starredDocumentIds": ["7c4a…", "2e08…"],
"documentStarredAt": {
"7c4a…": "2026-05-26T13:40:02.000Z",
"2e08…": "2026-05-24T09:12:44.000Z"
},
"updatedAt": "2026-05-26T13:41:23.000Z"
}
Two fields cover your document stars:
| Field | Type | Notes |
|---|---|---|
starredDocumentIds |
array of ids | The documents you have starred, most recently starred first. Always present — an empty array if you haven't starred anything. |
documentStarredAt |
object | Maps each starred document id to the ISO-8601 timestamp string of when you starred it. Always present. |
The project and agent equivalents — starredProjectIds, starredAgentIds, projectStarredAt, and agentStarredAt — are optional in the response, so don't rely on them being there. The two document fields above are not optional.
updatedAt is the most recent change across all of your memberships, including document stars, or null if you have never changed one.
Example
curl -sS \
-H "Authorization: Bearer {token}" \
"https://paperclip.example.com/api/companies/{companyId}/resource-memberships/me"
Join or Leave a Project
PUT /api/companies/{companyId}/resource-memberships/me/projects/{projectId}
Set whether the current user keeps a project in their sidebar.
Request body:
| Field | Type | Notes |
|---|---|---|
state |
enum, required | joined to keep the project in your sidebar, left to hide it. |
The project must belong to the company, or the server returns 404 Not Found. Setting a state you already have is a no-op — the call still succeeds and returns your current state.
Response:
{
"resourceType": "project",
"resourceId": "{projectId}",
"state": "left",
"updatedAt": "2026-05-26T13:41:23.000Z"
}
When the state actually changes, the server records a resource_membership.joined or resource_membership.left entry in the activity log so the change is auditable.
Example
curl -sS -X PUT \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
"https://paperclip.example.com/api/companies/{companyId}/resource-memberships/me/projects/{projectId}" \
-d '{ "state": "left" }'
Join or Leave an Agent
PUT /api/companies/{companyId}/resource-memberships/me/agents/{agentId}
The agent equivalent of the project route above. Set whether the current user keeps an agent in their sidebar.
Request body:
| Field | Type | Notes |
|---|---|---|
state |
enum, required | joined to keep the agent in your sidebar, left to hide it. |
The agent must belong to the company, or the server returns 404 Not Found. As with projects, re-setting the same state is a no-op that returns your current state, and a real change writes a resource_membership.joined / resource_membership.left activity entry.
Response:
{
"resourceType": "agent",
"resourceId": "{agentId}",
"state": "joined",
"updatedAt": "2026-05-26T13:41:23.000Z"
}
Example
curl -sS -X PUT \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
"https://paperclip.example.com/api/companies/{companyId}/resource-memberships/me/agents/{agentId}" \
-d '{ "state": "joined" }'
Star or Unstar a Document
PUT /api/companies/{companyId}/resource-memberships/me/documents/{documentId}
Star a document so you can find it again quickly, or take the star off. There is no join or leave for documents — the star is the only thing you set.
Request body:
| Field | Type | Notes |
|---|---|---|
starred |
boolean, required | true to star the document, false to remove the star. |
The body is strict: starred is the only field accepted, and sending anything else is rejected. The document must belong to the company, or the server returns 404 Not Found. Starring a document you have already starred — or unstarring one you never starred — is a no-op that still succeeds and returns your current state.
Response:
{
"resourceType": "document",
"resourceId": "{documentId}",
"state": "joined",
"starredAt": "2026-05-26T13:41:23.000Z",
"updatedAt": "2026-05-26T13:41:23.000Z"
}
state is always joined for documents, because there is nothing to leave. starredAt is the moment you starred the document, and becomes null once you unstar it. When the star actually changes, the server records a resource_membership.starred or resource_membership.unstarred entry in the activity log so the change is auditable.
To browse the documents you have starred, call the company artifacts list with starred=true — see Companies.
Example
curl -sS -X PUT \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
"https://paperclip.example.com/api/companies/{companyId}/resource-memberships/me/documents/{documentId}" \
-d '{ "starred": true }'
Notes
- Self-service only. A user may only read and update their own memberships. Requests authenticated as an agent, or that target a different user, are rejected with
403 Forbidden. - Active company access required. Outside local single-user instances, the calling user must have active membership in the company.
- Default is joined. A resource with no stored row is treated as
joined. Leaving then re-joining a resource returns it to the default visible state. - Documents are star-only. The document route takes
starredrather thanstate, and always reportsstate: "joined". Unstarring simply removes your star; the document itself is untouched.