Files
paperclip-docs/docs/reference/api/company-skill-policy.md
Aron PrinsandClaude Opus 5 325c9eb819 seo: hand-write titles and descriptions for all 192 pages (#115)
## 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]>
2026-08-25 22:51:42 +02:00

11 KiB
Raw Permalink Blame History

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_permissions permission in that company;
  • an agent holding the users:manage_permissions permission 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 401 with code skill_authentication_required.
  • An agent key scoped to a different company gets 403 with code skill_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

  1. If the policy isn't materialized, the answer is allow, reason no_policy_default.
  2. Otherwise rules are sorted by priority ascending, ties broken by id in lexicographic order, and the first rule whose actions, subject, and resource selectors all match wins. The decision follows that rule's effect, reason explicit_rule, with the rule's id in matchedRuleId.
  3. If nothing matched and defaultEffect is deny, one compatibility check runs: a principal holding a skills:create or skills:suggest-changes permission grant in the company is allowed with reason legacy_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.
  4. Otherwise the answer follows defaultEffect, reason policy_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.agentIds and in principal.agentId.
  • Activity — where company.skill_policy_replaced and company.skill_policy_reset land.
  • API Overview — base URL, authentication, company scoping, and the shared error-code table.