Files
paperclip-docs/docs/reference/cli/output-and-scripting.md
T
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


paperclip_version: v2026.609.0 seo_title: CLI Output and Scripting seo_description: Wiring paperclipai into a script, a CI step, or an AI operator loop: the two output modes, exit codes, and how to parse results reliably.

Output & Scripting

Use this page when you are wiring paperclipai into a script, a CI step, or an AI operator loop rather than reading its output by eye. It explains the two output modes (--json vs. the human renderer), how the CLI signals success and failure through exit codes and stderr, and the practical patterns for capturing IDs, piping to jq, and running every command non-interactively.

Every behavior described here comes from the shared client layer in cli/src/commands/client/common.ts, which all control-plane commands route through. The rules are uniform: if a command takes --json, it renders the same way as every other command, and it fails the same way too.


Two output modes

Each client command prints its result through a single shared renderer. The renderer has exactly two modes, selected by the --json flag.

Mode Flag What you get
Human (default) A compact, line-oriented rendering tuned for reading in a terminal
Machine --json The raw API result, pretty-printed as JSON with two-space indentation

Tip: When you are scripting against the CLI or piping into another tool, always pass --json. The human format is for humans and is not a stable contract — column order and truncation can change between releases. The JSON is the API payload and is what you should parse.

What --json prints

With --json, the CLI runs JSON.stringify(data, null, 2) on the API result and writes nothing else to stdout. There are no banners, no labels, no color codes — just the JSON document, ready to pipe.

paperclipai issue get PAP-39 --json
{
  "id": "244c0c2c-8416-43b6-84c9-ec183c074cc1",
  "identifier": "PAP-39",
  "title": "Implement caching layer",
  "status": "in_progress",
  "priority": "high"
}

What human mode prints

Without --json, the renderer adapts to the shape of the result:

  • Arrays print one item per line. Each object item is flattened into a key=value line; the renderer leads with identifier, id, name, status, priority, title, and action (in that order, when present), then appends the remaining scalar fields. Nested objects are omitted from this line and long strings are truncated to ~90 characters. An empty array prints (empty).
  • Single objects print as pretty JSON — the same two-space format as --json. So for a single-record get, human mode and --json look nearly identical.
  • Scalars (a string, number, or boolean) print as themselves. null/undefined prints (null).
paperclipai issue list --company-id <company-id> --status todo,in_progress
identifier=PAP-39 id=244c0c2c... status=in_progress priority=high title=Implement caching layer
identifier=PAP-41 id=9b1f0a2c... status=todo priority=medium title=Add request tracing

That key=value listing is convenient to skim, but it drops nested fields and truncates strings — never parse it. The moment you need a value out of a list, switch to --json and a JSON parser.


Exit codes and error behavior

The CLI follows the standard shell convention: a command that succeeds exits 0; a command that fails exits non-zero and writes its error to stderr.

Outcome Exit code Stream Shape
Success 0 stdout The rendered result (JSON with --json)
API error 1 stderr API error <status>: <message>[ details=<json>]
Other error 1 stderr The error message text

All errors are routed through one handler, so the contract is consistent across commands. An API error (an HTTP error response from the Paperclip server) is printed as API error <status>: <message>, with a details=<json> suffix when the server returned a structured detail payload — for example API error 409: ... for a checkout conflict, or API error 401: ... for an auth failure. Any other failure (a bad flag, a missing company ID, a connection refused) prints its plain message. Both exit 1.

Because results go to stdout and errors go to stderr, you can separate them cleanly:

# Capture JSON output, let errors flow to the terminal
result=$(paperclipai issue get PAP-39 --json) || exit 1

# Discard human chatter, keep only errors
paperclipai issue list --company-id <company-id> --json 2>/tmp/pc-errors.log

Note: A non-zero exit always means the command did not complete. Check it. In a pipeline like paperclipai ... --json | jq ..., the exit status you see is jq's, not the CLI's — set set -o pipefail (bash) so an upstream failure fails the whole pipeline.

Connection errors

If the CLI cannot reach the API, the error message includes the URL it tried and a hint to check GET /api/health at that base. When you see this, the fix is almost always the API base resolution (below) pointing somewhere the server is not listening. See Common Options for the full resolution order.


Running non-interactively

For a script or an autonomous agent, the cardinal rule is: never let a command block on a prompt. Two things can trigger interactivity, and both are avoidable.

1. Interactive board re-authentication. When a command hits a 401, or a 403 that needs board or instance-admin access, the CLI may try to recover by launching an interactive board login. It only does this when both stdin and stdout are TTYs and no explicit --api-key was supplied. In a script — where output is piped or redirected, so stdout is not a TTY — this recovery is skipped and the command simply fails with the API error. To guarantee non-interactive behavior regardless of environment, always pass credentials explicitly:

paperclipai issue list \
  --company-id <company-id> \
  --api-base https://paperclip.example.com \
  --api-key "$PAPERCLIP_API_KEY" \
  --json

Passing --api-key (or setting PAPERCLIP_API_KEY) disables the interactive recovery path entirely, so a credential problem fails fast instead of hanging.

2. Confirmation prompts. Destructive commands prompt for confirmation in a TTY and require an explicit flag to proceed without one. Pass --yes where a command supports it (for example skills remove, skills reset), and supply the full confirmation flags where they are mandated — company delete requires both --yes and --confirm <id> and is additionally gated server-side. See Company for that flow.

Credential resolution for scripts

The API key is resolved in this order: --api-key, then PAPERCLIP_API_KEY, then the env var named by your context profile's apiKeyEnvVarName. For automation, prefer one of the first two so the run does not depend on a profile being present on the machine. The company ID resolves from --company-id (alias -C on commands like run), then PAPERCLIP_COMPANY_ID, then the profile default; if a company-scoped command finds none, it fails with a clear message telling you to pass --company-id or set the env var.

A clean, profile-independent invocation for CI looks like:

export PAPERCLIP_API_URL="https://paperclip.example.com"
export PAPERCLIP_API_KEY="<token>"
export PAPERCLIP_COMPANY_ID="<company-id>"

paperclipai issue list --status todo --json

Tip: Use --data-dir <path> to isolate all local state away from ~/.paperclip when you run the CLI in CI or on a shared host. It keeps context, config, and credentials from leaking between jobs. See Common Options.


Capturing IDs and piping to jq

Most scripting comes down to: run a command, pull an ID or status out of the JSON, feed it to the next command. --json plus jq covers nearly all of it.

Pull a single field

# Create an issue and capture its ID for the next step
issue_id=$(paperclipai issue create \
  --company-id <company-id> \
  --title "Ship the export pipeline" \
  --status todo --priority high \
  --json | jq -r '.id')

paperclipai issue comment "$issue_id" --body "Kicking this off." --json

The -r flag makes jq emit the raw string without quotes — exactly what you want for shell variables and URLs.

Filter and reshape a list

# IDs of every todo issue, one per line
paperclipai issue list --company-id <company-id> --status todo --json \
  | jq -r '.[].id'

# Just the identifier and title, as TSV
paperclipai issue list --company-id <company-id> --json \
  | jq -r '.[] | [.identifier, .title] | @tsv'

# Count issues by status
paperclipai issue list --company-id <company-id> --json \
  | jq 'group_by(.status) | map({status: .[0].status, count: length})'

Drive a loop over results

# Release every issue currently assigned to a given agent
paperclipai issue list --company-id <company-id> \
  --assignee-agent-id <agent-id> --status in_progress --json \
  | jq -r '.[].id' \
  | while read -r id; do
      paperclipai issue release "$id" --json
    done

Sending JSON payloads in

The flow runs both ways. Commands that map to broad server schemas take a JSON payload via --payload-json, and the body-bearing skills command reads from stdin:

# Build a payload with jq, pass it straight in
paperclipai agent create --company-id <company-id> \
  --payload-json "$(jq -nc '{name:"Builder", adapterType:"codex_local"}')"

# Author a skill body from stdin
cat house-style.md | paperclipai skills create \
  --name "House Style" --slug house-style --body-file -

Warning: When you shell-interpolate a payload, quote it. An unquoted --payload-json with spaces or shell metacharacters will be split or mangled before the CLI ever sees it. Single-quote literals, or build the JSON with jq -nc and double-quote the substitution as shown above.


A complete scripting pattern

Putting it together, a robust script invocation: fails fast on errors, never prompts, parses only JSON, and separates output from diagnostics.

#!/usr/bin/env bash
set -euo pipefail

export PAPERCLIP_API_URL="https://paperclip.example.com"
export PAPERCLIP_API_KEY="<token>"
export PAPERCLIP_COMPANY_ID="<company-id>"

# Find the highest-priority open issue, or exit cleanly if none.
issue_id=$(paperclipai issue list --status todo --json \
  | jq -r 'sort_by(.priority) | reverse | .[0].id // empty')

if [ -z "$issue_id" ]; then
  echo "No open work." >&2
  exit 0
fi

paperclipai issue comment "$issue_id" \
  --body "Picked up by the nightly script." --json >/dev/null

echo "Commented on $issue_id"

set -euo pipefail makes the script abort on the first failed command (including inside the jq pipeline), so a 401 or a 409 stops the run instead of silently continuing with empty data. Every CLI call passes --json, so nothing depends on the human renderer. That is the whole discipline: explicit credentials, --json everywhere, exit codes checked.


See also

  • Common Options — the full flag set, API base resolution order, and context profiles
  • Authentication — board tokens vs. agent keys, and how --api-key resolves
  • Issue — the command surface most scripts drive
  • Run — inspecting and controlling heartbeat runs from the terminal