## 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]>
13 KiB
paperclip_version, seo_title, seo_description
| paperclip_version | seo_title | seo_description |
|---|---|---|
| v2026.720.0 | Summary Slots API | Places in the UI that always hold a short, current summary of what is happening on a project or workspace, written by agents rather than by hand. |
Summary Slots
A summary slot is a place in the UI that always holds a short, current summary of what is going on — at the top of a project, a project workspace, or the workspaces overview. You do not write these summaries yourself. You ask for one, Paperclip's built-in Summarizer agent goes and writes it, and the slot holds the result along with its revision history.
Think of a slot as a labelled shelf rather than a document. The shelf is identified by what it describes (a project, say) and where on the page it sits (the header). Whatever the Summarizer last wrote sits on that shelf, and each new generation appends a revision instead of overwriting the old one.
Summary slots are company-scoped, and company access is enforced 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.
Experimental — off by default. Summary slots are gated behind an instance experimental setting. Until an operator turns on
enableSummaries, every endpoint on this page responds404 Not Foundwith{ "error": "Summaries are not enabled" }. Because the feature is still experimental, treat the request and response shapes below as subject to change between releases.
Enabling summaries
Every route here starts by asking the instance whether summaries are on. The server reads the instance's experimental settings, and if enableSummaries is not true it stops immediately with:
{ "error": "Summaries are not enabled" }
returned as 404 Not Found. Turn on summaries in the instance experimental settings before calling anything on this page — it is an instance-wide switch, not a per-company one.
You also need the Summarizer built-in agent provisioned and ready for the company. Generation refuses to start otherwise; see Asking for a summary below and the Built-in Agents page for how to provision one.
How a slot is identified
A slot is addressed by three things, which together make up the path and query of every request:
| Part | Where it goes | Meaning |
|---|---|---|
scopeKind |
Path | What kind of thing the summary is about. |
scopeId |
Query string (or request body on writes) | Which specific thing, as a UUID. |
slotKey |
Path | Which slot on that thing. |
Scope kinds
scopeKind |
Describes | Needs scopeId? |
|---|---|---|
project |
A single project. | Yes — the project's UUID. |
project_workspace |
A single project workspace. | Yes — the workspace's UUID. |
workspaces_overview |
The company-wide workspaces overview. | No. |
The rule is strict in both directions. A project or project_workspace slot without a scopeId is rejected, and a workspaces_overview slot that does include one is rejected too — the message is workspaces_overview summary slots must not include scopeId. Either way you get 422 Unprocessable Entity with { "error": "Invalid summary slot selector" } and the validation details.
The scope target also has to live inside your company. Pointing a project or project_workspace slot at something belonging to another company comes back as 404 Not Found with { "error": "Summary target not found" }.
Slot keys
Today there is exactly one slot key: header — the summary that appears at the top of the page. The field exists so more slots can be added later without changing the shape of these routes.
Slot fields
Reading a slot gives you the slot record itself plus the document it points at. The slot carries:
| Field | Meaning |
|---|---|
id |
The slot's UUID. |
companyId, scopeKind, scopeId, slotKey |
The identity described above. |
documentId |
The document holding the summary text, or null before the first write. |
status |
idle, generating, or failed. |
failureReason |
Plain-language explanation when status is failed, otherwise null. |
generatingIssueId |
The generation task currently in flight, or null. |
lastGeneratedAt |
When the last summary was written. |
lastGeneratedByAgentId |
The agent that wrote it. |
lastModel |
The model the Summarizer reported using for that write. |
createdAt, updatedAt |
Timestamps. |
Status values
| Status | What it means |
|---|---|
idle |
Nothing is running. Either there is a summary to read, or none has been generated yet. |
generating |
A generation task is in flight. |
failed |
The generation task ended without producing a summary. failureReason explains why. |
A slot lands in failed when its generation task reaches done or cancelled without a summary ever being written. The server sets failureReason for you — either "Summary generation task {label} was cancelled before writing a summary." or "Summary generation task {label} finished without writing a summary." — so a stuck slot never sits on generating forever.
Read a Summary Slot
GET /api/companies/{companyId}/summary-slots/{scopeKind}/{slotKey}
Return the current state of a slot. Pass ?scopeId={uuid} for project and project_workspace scopes; omit it for workspaces_overview.
The response has three parts:
slot— the slot record described above, ornullif nobody has ever touched this slot.document— the summary document, including itsbody(the Markdown you render),title,latestRevisionId, andlatestRevisionNumber.nullwhen no summary has been written yet.generatingIssue— a compact view of the in-flight generation task (id,identifier,title,status,assigneeAgentId), ornullwhen nothing is running.
A brand-new slot is not an error. You get { "slot": null, "document": null, "generatingIssue": null } and can render an empty state with a "generate" button.
Example
curl -sS \
-H "Authorization: Bearer {token}" \
"https://paperclip.example.com/api/companies/{companyId}/summary-slots/project/header?scopeId={projectId}"
Revision History
GET /api/companies/{companyId}/summary-slots/{scopeKind}/{slotKey}/revisions
Return the slot's past summaries, newest first, up to 20 revisions. Each revision carries revisionNumber, title, body, changeSummary, createdAt, and the createdByAgentId / createdByRunId behind it, so you can show how the summary has moved over time.
If the slot exists but has no document yet, you get the slot back with an empty revisions array. If the slot has never existed, slot is null and revisions is empty.
Asking for a summary
POST /api/companies/{companyId}/summary-slots/{scopeKind}/{slotKey}/generate
This is the "refresh this summary" button. It does not write anything itself — it creates a task, assigns it to the Summarizer, and wakes the agent up. The summary appears a little later, when the Summarizer writes it back.
Who can call it. Generating is deliberately a human action: only board operators may do it, and agents cannot trigger it at all. An agent caller gets 403 Forbidden with { "error": "Only board operators can generate summaries." }. A board operator needs the tasks:assign permission (implicit local board users and instance admins skip the check); without it the response is 403 Forbidden with { "error": "Missing permission: tasks:assign" }.
The Summarizer must be ready. If the summarizer built-in agent is not provisioned and ready for the company, the request fails with 422 Unprocessable Entity, { "error": "Summarizer built-in agent is not configured" }, and a details object carrying code: "summarizer_not_configured" plus the built-in's current status.
Request body. Optional, and the only accepted field is scopeId — a convenient alternative to the query string. Unknown fields are rejected.
Response.
| Field | Meaning |
|---|---|
slot |
The slot, now with status: "generating" and its generatingIssueId set. |
generatingIssue |
The task that was created or found (id, identifier, title, status, assigneeAgentId). |
alreadyGenerating |
true when a generation was already in flight and this request joined it rather than starting a new one. |
Status codes. A newly started generation responds 202 Accepted. When the request was deduplicated onto an existing run, it responds 200 OK — so pressing "refresh" twice does not queue two summaries.
Behind the scenes the server prebuilds a snapshot of the scope's blocked, in review, in progress, and recently done issues and hands it to the Summarizer in the task description, so the agent works from a bounded, company-scoped view rather than crawling the issue list.
Example
curl -sS -X POST \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
"https://paperclip.example.com/api/companies/{companyId}/summary-slots/project/header/generate" \
-d '{ "scopeId": "{projectId}" }'
Write a Summary
PUT /api/companies/{companyId}/summary-slots/{scopeKind}/{slotKey}
This is the endpoint the Summarizer calls at the end of its run. It is documented here so you can understand what happens to the slot — not because you will normally call it yourself. Only the Summarizer built-in agent may write summaries, and only from the run of the generation task that was created for this exact slot.
Request body:
| Field | Required | Notes |
|---|---|---|
markdown |
yes | The summary body, 1–200,000 characters. Stored with format markdown. |
title |
no | Up to 200 characters, or null. |
changeSummary |
no | Note attached to the new revision, up to 1,000 characters, or null. |
baseRevisionId |
no | The revision this write is based on, for conflict detection. |
generationIssueId |
no in the schema, required in practice | The generation task this write belongs to. |
model |
no | The model the agent actually used, up to 200 characters. Stored on the slot as lastModel. |
scopeId |
no | Alternative to the query-string scopeId. |
Unknown fields are rejected.
The rules the write has to satisfy
Every one of these failures returns 403 Forbidden:
- The caller must be an agent carrying the
summarizerbuilt-in marker for this company — otherwise{ "error": "Only the Summarizer built-in agent may write summaries" }. - The write must name its task: a missing
generationIssueIdgives{ "error": "Summary writes must identify the active generation task" }. - That task must be the one the slot is currently waiting on —
{ "error": "Summary write does not match the active generation task" }if not, and{ "error": "Linked generation task not found" }if the task cannot be loaded. - The task must target this exact slot —
{ "error": "Generation task does not target this summary slot" }. - The task must be assigned to the calling agent —
{ "error": "Generation task is not assigned to this agent" }. - The write must come from that task's run —
{ "error": "Summary write must run from the linked generation task" }.
Two conflicts round it out, both 409 Conflict:
- If a newer generation has taken over the slot in the meantime:
{ "error": "Summary generation was superseded by a newer task" }. - If
baseRevisionIdis supplied but is no longer the latest revision:{ "error": "Summary was updated by someone else" }, along with thecurrentRevisionIdso the writer can re-anchor.
What a successful write does
The write appends a new revision rather than replacing the old text, then settles the slot: status returns to idle, failureReason and generatingIssueId are cleared, and lastGeneratedAt, lastGeneratedByAgentId, and lastModel are stamped. The response contains the updated slot, the document, and the new revision.
Activity logging
Both mutating routes record an entry on the company activity log against entityType: "summary_slot":
| Action | Recorded when |
|---|---|
summary_slot.generate_requested |
Someone asks for a summary. Details include the scope, slot key, generatingIssueId, and alreadyGenerating. |
summary_slot.write |
The Summarizer writes a revision. Details include the scope, slot key, documentId, revisionId, and revisionNumber. |
See the Activity page for how to read the log.
Where to go next
- Built-in Agents — how to provision the Summarizer, and what its lifecycle states mean.
- Issues — the generation tasks created by
POST .../generateare ordinary issues. - Goals and Projects — the projects and workspaces a summary slot can be scoped to.
- API Overview — base URL, authentication, company scoping, and the shared error-code table.