## 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]>
5.3 KiB
paperclip_version, seo_title, seo_description
| paperclip_version | seo_title | seo_description |
|---|---|---|
| v2026.609.0 | CLI Asset Commands | Push image assets into a company from the terminal — a logo, a documentation image, or any binary blob you want to reference later. |
Asset Commands
Use these commands when you need to push image assets into a company from the terminal — a company logo, a documentation image, or any binary blob you later want to fetch back. The CLI uploads the file to the Paperclip API as multipart/form-data and prints the stored asset record; asset content is the inverse, streaming an asset's bytes back out by id. Reach for these when you are scripting company branding or bootstrapping content without opening the UI.
All three subcommands accept the common client options (--data-dir, --api-base, --api-key, --context, --profile, --json). The two upload commands are company-scoped and take -C, --company-id <id>; asset content works on a global asset id and is not company-scoped.
Upload an image
paperclipai asset image:upload \
--company-id <company-id> \
--file ./diagram.png \
--namespace docs \
--alt "Architecture diagram" \
--title "System overview"
image:upload posts the file to /api/companies/<company-id>/assets/images and returns the stored asset record (an asset id you can later pass to asset content).
| Flag | Use |
|---|---|
--file <path> |
Required. Path to the image file on disk. The CLI reads the bytes, infers the content type from the file extension, and sends the original filename. |
-C, --company-id <id> |
The company that owns the asset. Required unless resolved from your selected context/profile. |
--namespace <value> |
Optional namespace suffix that groups the asset under a logical bucket (for example docs). |
--alt <text> |
Optional alt-text metadata stored with the asset. |
--title <text> |
Optional title metadata stored with the asset. |
Tip: The content type is inferred from the file path, so keep a correct extension (
.png,.svg,.jpg) on the file you upload. A misnamed file will be stored with the wrong type.
Upload a logo
paperclipai asset logo:upload \
--company-id <company-id> \
--file ./logo.svg
logo:upload posts the file to /api/companies/<company-id>/logo and sets the company's logo. It is the same multipart upload as image:upload, but it does not accept --namespace, --alt, or --title — a logo is a single, well-known slot on the company, so there is no metadata to attach.
| Flag | Use |
|---|---|
--file <path> |
Required. Path to the logo file. SVG and standard raster formats both work; the content type is inferred from the extension. |
-C, --company-id <id> |
The company whose logo you are setting. Required unless resolved from context/profile. |
Note: This replaces whatever logo the company currently has. There is no separate "remove logo" subcommand here — upload a new file to change it.
Download asset content
# Write the bytes to a file
paperclipai asset content <asset-id> --out ./asset.bin
# Or stream the bytes to stdout (pipe or redirect them)
paperclipai asset content <asset-id> > ./asset.bin
asset content <assetId> fetches the raw bytes from /api/assets/<asset-id>/content. By default it writes those bytes straight to stdout, which lets you pipe or redirect them. Pass --out <path> to have the CLI write the file for you instead — in that mode it prints a small confirmation object ({ ok, out, bytes }) rather than the binary.
| Flag | Use |
|---|---|
--out <path> |
Write the downloaded content to this file instead of stdout. When set, the command prints a confirmation summary including the byte count. |
Warning: Without
--out, the command writes raw binary to stdout. Always redirect or pipe it (> fileor| tool) — printing binary directly into a terminal will garble your session. The--jsonflag has no effect in stdout-streaming mode; it only shapes the confirmation object emitted when you use--out.
Scripting
Both uploads honor --json, so you can capture the returned asset id and feed it back into a download or another command:
ASSET_ID=$(paperclipai asset image:upload \
--company-id <company-id> \
--file ./diagram.png \
--json | jq -r '.id')
paperclipai asset content "$ASSET_ID" --out ./roundtrip.png
API base resolution follows the standard order (--api-base, then PAPERCLIP_API_URL, then your selected context profile, then the local config port, then http://localhost:3100). If a connection fails, the error names the URL it tried so you can confirm you are pointed at the right instance — see common options and output and scripting for the full conventions.
See also
- Common Options — the flags every client command shares, including
--company-idand API base resolution - Output and Scripting — how
--jsonshapes results and how to consume them - Skills Commands — managing a company's skill library, another company-scoped file workflow
- Adapter Commands — configuring the runtime adapters that execute agent work
- Company Commands — inspecting and exporting the company that owns these assets