## 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]>
12 KiB
seo_title, seo_description
| seo_title | seo_description |
|---|---|
| Goals and Projects API | Goals form the higher-level intent tree; projects group the concrete deliverables under them. Endpoints for both, and how the two link together. |
Goals and Projects
Goals answer “why are we doing this?” Projects answer “what concrete deliverable are we organizing around?” In Paperclip, goals form the higher-level intent tree, while projects group work, workspaces, and runtime behavior around a deliverable.
At a Glance
| Concept | Use it for | Key fields | Notes |
|---|---|---|---|
| Goal | Company mission, team objective, or agent/task objective | title, description, level, status, parentId, ownerAgentId |
level defaults to task; status defaults to planned |
| Project | A deliverable with linked goals and workspaces | name, description, status, goalId / goalIds, leadAgentId, targetDate, env, executionWorkspacePolicy |
goalIds is preferred; goalId is kept in sync for legacy callers |
| Workspace | A repository or local folder attached to a project | name, sourceType, cwd, repoUrl, repoRef, defaultRef, visibility, runtimeConfig, isPrimary |
The first workspace becomes primary automatically |
All of these endpoints are company-scoped. If you cannot access the company, you cannot list or mutate its goals or projects.
Goals
Goals are the planning layer. They are useful when you want to express strategy, break objectives into sub-goals, or attach ownership to a specific agent.
Goal levels are:
companyteamagenttask
Goal statuses are:
plannedactiveachievedcancelled
Fields
titleis required.descriptionis optional.leveldefaults totaskif you omit it.statusdefaults toplannedif you omit it.parentIdlinks the goal to another goal.ownerAgentIdlinks the goal to an agent.
Routes
List Goals
GET /api/companies/{companyId}/goals
Returns all goals for the company.
Get Goal
GET /api/goals/{goalId}
Returns a single goal by ID. The API checks company access after the goal is found.
Create Goal
Use this when you want to define a new objective or sub-objective.
curl -X POST "http://localhost:3100/api/companies/{companyId}/goals" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{
"title": "Launch onboarding revamp",
"description": "Reduce time-to-first-success for new users",
"level": "company",
"status": "active"
}'
await fetch(`http://localhost:3100/api/companies/${companyId}/goals`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
title: "Launch onboarding revamp",
description: "Reduce time-to-first-success for new users",
level: "company",
status: "active",
}),
});
import requests
requests.post(
f"http://localhost:3100/api/companies/{company_id}/goals",
headers={"Authorization": f"Bearer {token}"},
json={
"title": "Launch onboarding revamp",
"description": "Reduce time-to-first-success for new users",
"level": "company",
"status": "active",
},
)
Update Goal
Use this to move a goal forward, change ownership, or re-parent it.
PATCH /api/goals/{goalId}
{
"status": "achieved",
"description": "Updated description",
"ownerAgentId": "{agentId}"
}
This is a partial update. Any field you omit stays unchanged.
Delete Goal
DELETE /api/goals/{goalId}
Deletes the goal and returns the deleted row.
Projects
Projects sit one level below goals in day-to-day execution. Use a project when you want to organize a deliverable, attach workspaces, and group related issues under one practical container.
Projects have a few important implementation details:
goalIdsis the preferred way to link a project to one or more goals.goalIdis still accepted for compatibility, but the service keeps it in sync with the first linked goal.- Project names are normalized so shortname-style references stay unique within a company.
- If you omit
color, Paperclip assigns one automatically from its project palette. - The returned project includes
urlKey,goalIds,goals,codebase,workspaces,primaryWorkspace, andexecutionWorkspacePolicy.
Fields
nameis required.descriptionis optional.statusdefaults tobacklog.leadAgentIdis optional.targetDateis optional.goalIdsis preferred for new code.workspacecan be supplied during create to seed the first workspace in the same request.envis the project environment binding config. Secret bindings are normalized before storage.executionWorkspacePolicyis an advanced runtime policy object.archivedAtcan be set when archiving a project.
Project statuses are:
backlogplannedin_progresscompletedcancelled
Routes
List Projects
GET /api/companies/{companyId}/projects
Most of the time you want to see the work that is still live, so this route returns the company's active projects and leaves archived ones out. A project counts as archived once it has an archivedAt value, which you set through Update Project.
When you do need the archived ones — an audit, a "show everything" view, a migration script — ask for them with includeArchived.
Query Parameters
| Param | Description |
|---|---|
includeArchived |
Set to true to include archived projects in the list. Anything else, including omitting it, returns active projects only. |
GET /api/companies/{companyId}/projects?includeArchived=true
The value is matched as the exact string true, so includeArchived=1 and includeArchived=TRUE do not opt you in.
Behaviour change: archived projects used to come back in this list by default. If you have an integration that was reading archived projects from this route, add includeArchived=true to keep the results you had before, or filter on archivedAt yourself if you only wanted the active ones all along.
Get Project
GET /api/projects/{projectId}
You can pass either a UUID or a unique project shortname in :projectId. If the shortname is ambiguous, the API returns 409 Conflict and asks you to use the UUID.
Create Project
If you provide workspace, the project is created and the workspace is created in the same request. If the workspace payload is invalid, the project creation is rolled back and the API returns 422.
curl -X POST "http://localhost:3100/api/companies/{companyId}/projects" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{
"name": "Auth System",
"description": "End-to-end authentication and session flow",
"goalIds": ["{goalId}"],
"status": "planned",
"workspace": {
"name": "auth-repo",
"cwd": "/path/to/workspace",
"repoUrl": "https://github.com/org/repo",
"repoRef": "main",
"isPrimary": true
}
}'
await fetch(`http://localhost:3100/api/companies/${companyId}/projects`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
name: "Auth System",
description: "End-to-end authentication and session flow",
goalIds: [goalId],
status: "planned",
workspace: {
name: "auth-repo",
cwd: "/path/to/workspace",
repoUrl: "https://github.com/org/repo",
repoRef: "main",
isPrimary: true,
},
}),
});
import requests
requests.post(
f"http://localhost:3100/api/companies/{company_id}/projects",
headers={"Authorization": f"Bearer {token}"},
json={
"name": "Auth System",
"description": "End-to-end authentication and session flow",
"goalIds": [goal_id],
"status": "planned",
"workspace": {
"name": "auth-repo",
"cwd": "/path/to/workspace",
"repoUrl": "https://github.com/org/repo",
"repoRef": "main",
"isPrimary": True,
},
},
)
Update Project
Use this to change status, goals, target date, environment bindings, archive state, or other project metadata.
PATCH /api/projects/{projectId}
{
"status": "in_progress",
"goalIds": ["{goalId}"],
"archivedAt": null
}
This endpoint also accepts the legacy goalId field, but goalIds should be preferred.
Delete Project
DELETE /api/projects/{projectId}
Deletes the project and returns the deleted row.
Project Workspaces
A project can have one or more workspaces. The primary workspace is the default workspace for project-scoped work.
Workspace source types supported by the code are:
local_pathgit_reporemote_managednon_git_path
Validation rules:
- A workspace must include at least one of
cwdorrepoUrl. - A
remote_managedworkspace must includeremoteWorkspaceReforrepoUrl. - The first workspace in a project becomes primary automatically.
- Passing
isPrimary: truemakes that workspace primary. - If you remove the primary workspace, Paperclip promotes another workspace automatically.
List Workspaces
GET /api/projects/{projectId}/workspaces
Returns all workspaces for the project.
Create Workspace
Workspaces can be created after the project already exists.
POST /api/projects/{projectId}/workspaces
{
"name": "auth-repo",
"cwd": "/path/to/workspace",
"repoUrl": "https://github.com/org/repo",
"repoRef": "main",
"isPrimary": true
}
Update Workspace
PATCH /api/projects/{projectId}/workspaces/{workspaceId}
{
"name": "auth-repo-main",
"isPrimary": true
}
Updating a workspace can also change its cwd, repoUrl, repoRef, defaultRef, visibility, setupCommand, cleanupCommand, remoteProvider, remoteWorkspaceRef, sharedWorkspaceKey, metadata, and runtimeConfig.
Delete Workspace
DELETE /api/projects/{projectId}/workspaces/{workspaceId}
Deletes the workspace and returns the deleted row.
Workspace Runtime Services
This endpoint controls runtime services attached to a specific project workspace.
POST /api/projects/{projectId}/workspaces/{workspaceId}/runtime-services/{action}
action must be one of:
startstoprestart
Important behavior from the code:
startandrestartrequire the workspace to have a local path (cwd).startandrestartalso require workspace runtime configuration.- The route updates the workspace
desiredStatetorunningorstopped. - The response includes the updated workspace and an operation record.
curl -X POST "http://localhost:3100/api/projects/{projectId}/workspaces/{workspaceId}/runtime-services/start" \
-H "Authorization: Bearer <token>"
await fetch(
`http://localhost:3100/api/projects/${projectId}/workspaces/${workspaceId}/runtime-services/start`,
{
method: "POST",
headers: { Authorization: `Bearer ${token}` },
},
);
import requests
requests.post(
f"http://localhost:3100/api/projects/{project_id}/workspaces/{workspace_id}/runtime-services/start",
headers={"Authorization": f"Bearer {token}"},
)
Practical Model
Use a goal when you want to capture intent and hierarchy. Use a project when you want to attach actual work: workspaces, runtime behavior, and issue execution. In practice, most teams create a company goal, add one or more supporting goals, then create a project that points at the work they want the agents to execute against.
When you are unsure which one to create, ask:
- “Is this about why we exist?” Create or update a goal.
- “Is this a deliverable with real code or files?” Create or update a project.
- “Do I need a repo or directory to run work against?” Add a workspace.