## 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]>
11 KiB
paperclip_version, seo_title, seo_description
| paperclip_version | seo_title | seo_description |
|---|---|---|
| v2026.720.0 | Company Skill Policy API | The guardrail on what agents may do to skills — create, import, install, edit, or remove — instead of leaving the whole library open to every agent. |
Company Skill Policy
Skills are how your agents pick up new abilities — and left unguarded, an agent can create, import, install, edit, or remove them at will. The company skill policy is the guardrail. It is a single per-company document that says which agents may perform which skill actions, on which skills, and from which sources.
The policy is one document per company, with a revision number for safe concurrent edits. You read it, replace it wholesale, reset it back to open, and — usefully — ask the server to explain a decision before you make a change.
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 open default
A brand-new company has no stored policy at all. Reading the policy in that state gives you an open default:
{
"schemaVersion": 1,
"revision": 0,
"defaultEffect": "allow",
"rules": [],
"materialized": false
}
The materialized flag is the one to watch. When it is false, no policy row exists — nothing has been written yet, and every skill action is allowed. Once you save a policy it becomes true and revision starts counting up from 1.
That distinction also shows up in evaluation. An unmaterialized policy short-circuits to "allowed" with the reason no_policy_default, without walking any rules.
What a policy document looks like
A policy has three parts: a schema version, a default effect, and an ordered set of rules.
| Field | Notes |
|---|---|
schemaVersion |
Must be 1. |
defaultEffect |
allow or deny. Applied when no rule matches. |
rules |
Array of rule objects, up to 1,000. Rule id values must be unique. |
Documents are strict — unknown fields are rejected.
Rules
| Field | Required | Notes |
|---|---|---|
id |
yes | 1–128 characters, matching ^[a-zA-Z0-9][a-zA-Z0-9._:-]*$. |
priority |
yes | Integer between -1000000 and 1000000. Lower numbers are considered first. |
effect |
yes | allow or deny. |
subject |
yes | Who the rule applies to — see below. |
actions |
yes | At least one skill action, no duplicates. |
resources |
no | Narrows the rule to particular skills or sources. Omit it to match every resource. |
Subjects
The subject object is a tagged union on type:
type |
Shape | Matches |
|---|---|---|
all_agents |
{ "type": "all_agents" } |
Any agent principal. |
agents |
{ "type": "agents", "agentIds": [...] } |
The listed agents. 1–500 unique UUIDs. |
roles |
{ "type": "roles", "roles": [...] } |
Any principal whose role matches, compared case-insensitively after trimming. 1–500 unique strings. |
all_agents and agents only ever match agent principals. roles matches on the principal's role string, so a board principal — which evaluates with the role board — can be targeted with a roles rule.
Actions
| Action | Covers |
|---|---|
skills.create |
Creating a new skill. |
skills.import |
Importing a skill. |
skills.install |
Installing a skill. |
skills.edit |
Editing an existing skill. |
skills.update |
Updating a skill from its source. |
skills.test |
Running a skill test. |
skills.reset |
Resetting a skill. |
skills.remove |
Removing a skill. |
Resource selectors
resources narrows a rule. At least one of these keys must be present, and unknown keys are rejected:
| Key | Notes |
|---|---|
skillIds |
1–500 unique skill UUIDs. |
skillKeys |
1–500 unique strings, each 1–512 characters. |
sourceTypes |
One or more of workspace, catalog, git, external_package, generated, unknown. Must be unique. |
sourceLocators |
1–500 unique locator strings, each up to 2,048 characters. |
Every listed selector has to match for the rule to apply. A rule that names skillKeys will never match an evaluation that doesn't carry a skillKey.
Source locators are normalized. Both the values you store in a rule and the values you pass at evaluation time are canonicalized the same way — repo-style https URLs get a lowercased host (www.github.com becomes github.com), a lowercased owner and repo, and a stripped .git suffix. That matters: without matching normalization on both sides, a deny rule could silently never fire.
Locators are also screened for embedded credentials. A locator with a username or password in the URL, or with a query or fragment parameter that looks like token, secret, password, api_key, or authorization, is rejected with the message "Source locators must not contain credentials or secret query or fragment parameters".
Who can read and who can change
Reading the policy — and evaluating against it for yourself — needs nothing more than access to the company.
Changing it (PUT, DELETE, or evaluating on behalf of another agent) requires skill 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 comes back as 403 Forbidden with code skill_policy_admin_required and the remediation "Ask a company administrator to manage the skill policy."
Two more boundaries apply to every route here:
- An unauthenticated caller gets
401with codeskill_authentication_required. - An agent key scoped to a different company gets
403with codeskill_company_boundary_denied.
Get the Policy
GET /api/companies/{companyId}/skill-policy
Return the company's effective policy — either the stored document (with materialized: true and its current revision) or the open default described above.
Replace the Policy
PUT /api/companies/{companyId}/skill-policy
Policies are replaced wholesale, never patched. Send the complete document plus the revision you believe is current:
| Field | Required | Notes |
|---|---|---|
schemaVersion |
yes | 1. |
defaultEffect |
yes | allow or deny. |
rules |
yes | The full rule set. Send [] for none. |
expectedRevision |
yes | Non-negative integer. Use 0 when no policy exists yet. |
On success the server bumps the revision by one and returns the saved document with materialized: true.
If expectedRevision doesn't match what is stored, the write is rejected with 409 Conflict, code skill_policy_revision_conflict, and the details carry expectedRevision and currentRevision so you can re-read, re-apply your change, and retry.
A document that fails validation returns 422 Unprocessable Entity with { "error": "Invalid skill policy document" }, code skill_policy_validation_failed, and an issues array describing what went wrong.
A successful replace records a company.skill_policy_replaced activity entry against entity type company_skill_policy, with the previous and new revision, the default effect, and the rule count.
Example
curl -sS -X PUT \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
"https://paperclip.example.com/api/companies/{companyId}/skill-policy" \
-d '{
"schemaVersion": 1,
"defaultEffect": "allow",
"expectedRevision": 0,
"rules": [
{
"id": "no-external-installs",
"priority": 10,
"effect": "deny",
"subject": { "type": "all_agents" },
"actions": ["skills.install", "skills.import"],
"resources": { "sourceTypes": ["external_package", "git"] }
}
]
}'
Reset the Policy
DELETE /api/companies/{companyId}/skill-policy
Delete the stored policy and return to the open default. The response is the open default document (defaultEffect: "allow", no rules, materialized: false).
Resetting a company that had a policy records a company.skill_policy_reset activity entry with the previous revision and a new revision of 0. Resetting a company that never had one is a no-op and still returns the default.
Evaluate a Decision
POST /api/companies/{companyId}/skill-policy/evaluate
Ask the policy engine what it would decide, without performing the action. This is the endpoint to reach for when you want to explain a denial, or to test a rule set before relying on it.
Request body:
| Field | Required | Notes |
|---|---|---|
action |
yes | One of the eight skill actions. |
resource |
no | skillId, skillKey, sourceType, sourceLocator — any subset. Defaults to {}. |
principal |
no | { "agentId": "<uuid>" }. Evaluate as that agent instead of yourself. |
Without principal, the evaluation runs as you: an agent caller evaluates as its own agent (with that agent's role), and a board caller evaluates as a board principal with the role board.
With principal, you are asking about someone else — so the route requires skill policy administration authority, exactly like a write. The named agent must exist (404 Agent not found) and must belong to this company (403, code skill_company_boundary_denied).
How a decision is reached
- If the policy isn't materialized, the answer is allow, reason
no_policy_default. - Otherwise rules are sorted by
priorityascending, ties broken byidin lexicographic order, and the first rule whose actions, subject, and resource selectors all match wins. The decision follows that rule'seffect, reasonexplicit_rule, with the rule's id inmatchedRuleId. - If nothing matched and
defaultEffectisdeny, one compatibility check runs: a principal holding askills:createorskills:suggest-changespermission grant in the company is allowed with reasonlegacy_compatibility. Those grants historically authorized the whole skill mutation surface, and this preserves that scope only as a default-deny fallback — explicit rules still take precedence. - Otherwise the answer follows
defaultEffect, reasonpolicy_default.
The decision object
{
"allowed": false,
"action": "skills.install",
"reason": "explicit_rule",
"policyRevision": 4,
"matchedRuleId": "no-external-installs",
"remediation": "Contact a company administrator to change the skill policy."
}
remediation is null when the action is allowed.
reason |
Meaning |
|---|---|
no_policy_default |
No policy is stored, so the action is allowed. |
explicit_rule |
A rule matched; matchedRuleId names it. |
policy_default |
No rule matched; defaultEffect decided it. |
legacy_compatibility |
Default-deny, but a legacy skills:create / skills:suggest-changes grant allowed it. |
platform_invariant |
A platform-level rule the skill routes enforce ahead of the policy. |
Where to go next
- Agents — the agents you name in
subject.agentIdsand inprincipal.agentId. - Activity — where
company.skill_policy_replacedandcompany.skill_policy_resetland. - API Overview — base URL, authentication, company scoping, and the shared error-code table.