# Generate skills

> Server skill store and /v1/skills routes, transcript spine used for generation, and how tapesctl generate/list/sync talk to this API.

- Repository: papercomputeco/tapes
- GitHub: https://github.com/papercomputeco/tapes
- Human docs: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750
- Complete Markdown: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/llms-full.txt

## Source Files

- `api/skills_handlers.go`
- `pkg/skill/generator.go`
- `pkg/skill/transcript.go`
- `pkg/skill/types.go`
- `pkg/skill/writer.go`
- `docs/skills.md`

---

---
title: "Generate skills"
description: "Server skill store and /v1/skills routes, transcript spine used for generation, and how tapesctl generate/list/sync talk to this API."
---

Skills are reusable `workflow`, `domain-knowledge`, or `prompt-template` documents extracted from derived session transcripts. The read API on `:8081` persists them in PostgreSQL under `/v1/skills`. `tapesctl skill generate` does **not** write that store: it reads the same transcript spine from `/v1/traces`, calls an extraction LLM locally, and writes `~/.tapes/skills/<name>.md`. `tapesctl skill list` and `skill sync` never call the server.

<Note>
`tapes` is the server. Skill authoring commands live in [`tapesctl`](https://github.com/papercomputeco/tapesctl). This repository owns the `/v1/skills` contract, the generator, and the `SKILL.md` renderer those commands share.
</Note>

## Two stores, one transcript

```mermaid
flowchart LR
  subgraph client ["tapesctl"]
    gen["skill generate"]
    list["skill list"]
    sync["skill sync"]
    disk["~/.tapes/skills/*.md"]
  end
  subgraph api ["Read API :8081"]
    traces["GET /v1/traces"]
    generate["POST /v1/skills/generate"]
    storeRoutes["GET/PUT/DELETE /v1/skills"]
    md["GET /v1/skills/{id}/skill.md"]
  end
  subgraph pg ["PostgreSQL"]
    skills["skills + skill_versions"]
  end
  subgraph llm ["Extraction LLM"]
    provider["openai / anthropic / ollama"]
  end
  gen --> traces
  gen --> provider
  gen --> disk
  list --> disk
  sync --> disk
  generate --> provider
  generate --> skills
  storeRoutes --> skills
  md --> skills
```

| Path | Who extracts | Where the skill lands | How you install it |
| --- | --- | --- | --- |
| Console / HTTP | Server (`POST /v1/skills/generate`) | `skills` table, keyed by opaque UUID | `GET /v1/skills/{id}/skill.md` |
| `tapesctl skill generate` | Client (`pkg/skill` + caller-supplied LLM) | `~/.tapes/skills/<name>.md` | `tapesctl skill sync` |

The two stores do not sync. A skill generated in the console does not appear in `tapesctl skill list`. A file written by `tapesctl` does not appear in `GET /v1/skills`.

## Transcript spine

`pkg/skill.BuildSessionTranscript` is the single transcript path for both the server generator and `tapesctl`. It walks the derived turn/span surface — not `raw_turns` and not a loopback HTTP self-call on the server.

For each product session (`/v1/sessions` UUID):

1. Load turn headers (`GET /v1/traces?session_id=` on the client; in-process `ListTraceSummaries` on the server).
2. Drop synthetic turns (`compaction`, resume replay). Optionally keep only turns in `--since` / `--until`.
3. For each remaining turn, emit `[user] <prompt>`, then walk that turn's spans in order.

Only the conversation spine reaches the extraction prompt:

| Span | Included? |
| --- | --- |
| `kind=llm`, `call_kind=main`, empty `thread_id`, non-empty text | `[assistant]` line |
| `kind=tool`, empty `thread_id` | Accumulated into the next `[tools] name, name ×N` line |
| `call_kind` `offshoot:…` or `injected:…` | No |
| Any span with a non-empty `thread_id` (subagent) | No |
| Thinking / non-text content blocks | No |

If a turn has no spine text, the deriver's `response_preview` stands in as `[assistant]`. Multiple sessions are joined with `\n---\n`. Combined input is capped at 30 000 characters at a session boundary; the first session is always kept even if it exceeds the cap.

```text
[user] My useEffect keeps running in an infinite loop
[assistant] Let me check the dependency array.
[tools] Read
[assistant] The issue is a new object reference on each render.
```

The extraction prompt asks for JSON only: `description`, `tags`, `content`, and (when the caller omitted a name) `name`. Up to three parse retries append “Return ONLY valid JSON”. The resulting `Skill` is always typed by the caller, versioned `0.1.0`, and stamped with every requested session ID — including sessions dropped by the char cap.

## Server skill store

Skills require the PostgreSQL driver. The in-memory node store returns **501** (`skills not supported by this backend`). Every row is scoped to the single-tenant org `00000000-0000-0000-0000-000000000000`.

Identity is an opaque UUID (`id`). `slug` is a kebab-case display label and `SKILL.md` filename derived from `name`; it is not unique and is not a route key. Content lives on the skill row. `skill_versions` is publish history only.

The wire JSON is camelCase (`sessionIds`, `isAiGenerated`, `originatingSessionIds`) — an exception to the snake_case used elsewhere on `:8081`. The list envelope still uses `next_cursor`.

### Routes

| Method | Path | Role |
| --- | --- | --- |
| `POST` | `/v1/skills/generate` | Extract from sessions and persist |
| `POST` | `/v1/skills` | Create a blank / authored skill |
| `GET` | `/v1/skills` | Keyset page + tab counts |
| `GET` | `/v1/skills/{id}` | One skill |
| `PUT` | `/v1/skills/{id}` | Partial update of the head |
| `DELETE` | `/v1/skills/{id}` | Delete skill + versions |
| `GET` | `/v1/skills/{id}/versions` | Full version history, newest first |
| `POST` | `/v1/skills/{id}/versions` | Publish an immutable snapshot |
| `POST` | `/v1/skills/{id}/duplicate` | Fork under a new id |
| `GET` | `/v1/skills/{id}/skill.md` | Download `SKILL.md` (counts a download) |
| `GET` | `/v1/sessions/{id}/skills` | Skills whose provenance includes this session |

:::endpoint POST /v1/skills/generate Extract and persist a skill
The client nominates source sessions and optional hints. The server is authoritative on the body.

The generator reads transcripts through an org-scoped in-process querier (`skillTraceQuerier`). A session UUID the tenant cannot see is **404** before any LLM call.

Honored hint fields: `name`, `type`. `hint.description` and `hint.tags` are accepted on the wire and ignored; tags and description come from the model. Empty `name` lets the model suggest a title. Default `type` is `workflow`.

Generated rows are `visibility: private`, `isAiGenerated: true`, version `0.1.0`. Author is `x-paper-auth-subject` when the gateway (or local console) stamps it.

**422** means the extraction LLM has no key: generation reuses the tenant's search/embedding credential. **500** covers LLM/parse/persist failures. OpenAPI also lists 422 for empty sources; the handler maps a missing session to **404** and other generate errors to **500**.
:::

<RequestExample>
```bash
curl -sS -X POST http://127.0.0.1:8081/v1/skills/generate \
  -H 'Content-Type: application/json' \
  -H 'x-paper-auth-subject: user_01ABC' \
  -d '{
    "sessionIds": ["11111111-1111-1111-1111-111111111111"],
    "hint": { "name": "debug-react-hooks", "type": "workflow" }
  }'
```
</RequestExample>

<ResponseExample>
```json
{
  "id": "c0ffee00-0000-4000-8000-000000000001",
  "slug": "debug-react-hooks",
  "parentId": null,
  "name": "debug-react-hooks",
  "description": "Debug React hooks infinite loops. Use when debugging useEffect.",
  "type": "workflow",
  "version": "0.1.0",
  "visibility": "private",
  "tags": ["react", "hooks"],
  "content": "## Debug React Hooks\n\n1. Check the dependency array\n",
  "isAiGenerated": true,
  "originatingSessionIds": ["11111111-1111-1111-1111-111111111111"],
  "authorId": "user_01ABC",
  "downloadCount": 0,
  "createdAt": "2026-08-18T12:00:00Z",
  "updatedAt": "2026-08-18T12:00:00Z"
}
```
</ResponseExample>

:::endpoint GET /v1/skills List skills
Keyset pagination mirrors `/v1/sessions`. Default sort is `updated_at DESC, id DESC`. `sort=downloads` switches the cursor to `download_count`. Changing sort requires dropping the cursor.

Default `limit` is 24, max 100. `q` searches name, description, and tags. `scope` is `all` (default), `mine`, or `team`, using `x-paper-auth-subject`. `counts` (`all`, `mine`, `team`) are computed over the full match, not the page.
:::

<ParamField query="limit" type="integer">Page size. Default `24`, max `100`.</ParamField>
<ParamField query="cursor" type="string">Opaque base64url JSON from the previous `next_cursor`.</ParamField>
<ParamField query="q" type="string">Search over name, description, and tags.</ParamField>
<ParamField query="scope" type="string">`all` \| `mine` \| `team`.</ParamField>
<ParamField query="sort" type="string">Omit for recency. `downloads` for most downloaded.</ParamField>

### Create, edit, publish, delete

`POST /v1/skills` is the hand-authored path: `isAiGenerated: false`, empty provenance. Missing `name` becomes `New skill`; missing `type` becomes `workflow`; version `0.1.0`, visibility `private`.

`PUT /v1/skills/{id}` is a partial update of the head. Renaming recomputes `slug`. It does not publish. `created_at` and `author_subject` stay with the original creator.

`POST /v1/skills/{id}/versions` snapshots content (body `content`, or the current head if omitted) and bumps the displayed semver: first publish `0.1.0`, then `0.1.1`, …. Concurrent publishes retry up to four times on `(skill_id, version_number)` conflict. A version that lands while the head bump fails is **500** (`version published but …`), not **201** with stale data.

`POST /v1/skills/{id}/duplicate` mints a new id, sets `parentId`, appends ` (copy)` to the display name, keeps the parent's slug, resets visibility to `private` and version to `0.1.0`, and attributes the copy to the caller.

`DELETE /v1/skills/{id}` is owner-gated: **403** if `author_subject` is set and does not match `x-paper-auth-subject`. Unattributed (empty author) skills are deletable by anyone. Success is **204**.

:::endpoint GET /v1/skills/{id}/skill.md Download a drop-in SKILL.md
Renders the same frontmatter + body as `pkg/skill.RenderSkillMD`. Frontmatter `name` is the kebab **slug**, not the human display name — Claude Code matches that string to the skill directory.

`Content-Type: text/markdown; charset=utf-8`  
`Content-Disposition: attachment; filename="<slug>.md"`

Increments `download_count` best-effort; a counter write failure does not fail the download.
:::

## Configure server-side generation

Generation reuses the search/embedding credential. The chat **model** is a separate knob because embedding models are not chat models.

| Setting | Flag / key | Used as |
| --- | --- | --- |
| Provider | `embedding.provider` (`--embedding-provider`) | `SkillLLMProvider` |
| API key | `tapes auth` / env for that provider | `SkillLLMAPIKey` |
| Base URL | `embedding.target` (`--embedding-target`) | `SkillLLMBaseURL` |
| Chat model | `--skill-model` / `skill.model` / `TAPES_SKILL_MODEL` | `SkillLLMModel` |

These fields are wired today on **`tapes serve api`**. The bundled `tapes serve` stack starts the same API process but does not copy embedding credentials onto `SkillLLM*`. In that process the handler defaults provider to `openai` and resolves the key from `OPENAI_API_KEY` (or `tapes auth` only if a caller constructed `NewLLMCaller` with a credentials manager — the HTTP handler does not).

<ParamField body="provider" type="string">`openai` (handler default when unset), `anthropic`, or `ollama`.</ParamField>
<ParamField body="model" type="string">Defaults: `gpt-4o-mini`, `claude-haiku-4-5-20251001`, `llama3.2`.</ParamField>
<ParamField body="timeout" type="duration">One 30s budget covers the HTTP call plus a single retry on 429/502/503/504.</ParamField>

Ollama needs no API key (`http://localhost:11434/api/chat`, `format=json`). OpenAI and Anthropic without a key return **422**: `skill generation requires the search/embedding feature to be enabled for this tenant`.

## tapesctl generate, list, and sync

Client commands are documented in this repo and implemented against `pkg/skill`. They talk to **different** HTTP surfaces than `/v1/skills`.

### generate

```bash
tapesctl skill generate <session-id> --name debug-react-hooks
tapesctl skill generate <session-a> <session-b> --name retry-patterns
tapesctl skill generate --search "gum glow charm" \
  --search-top 3 --name charm-cli-patterns
tapesctl skill generate <session-id> --name morning-work \
  --since 2026-02-17 --until 2026-02-17T17:00:00Z \
  --type workflow --preview
```

| Flag / input | Talks to |
| --- | --- |
| Positional session IDs | `GET /v1/traces?session_id=` then `GET /v1/traces/{trace_id}` on `--tapes-url` (read API, default `:8081`) |
| `--search` / `--search-top` | `GET /v1/search/spans`; positional IDs win if both are set |
| `--since` / `--until` | Client-side turn filter in `BuildSessionTranscript` (the server generate route does not apply a time window) |
| `--provider` / `--model` / `--api-key` | The extraction LLM — not the tapes server |
| `--preview` | Print without writing `~/.tapes/skills` |

`--tapes-url` / `TAPES_URL` is the read API that supplies transcripts. It is not ingest (`:8082`) and not `POST /v1/skills/generate`.

| Provider | Default model | Key |
| --- | --- | --- |
| `openai` (default) | `gpt-4o-mini` | `OPENAI_API_KEY` required |
| `anthropic` | `claude-haiku-4-5-20251001` | `ANTHROPIC_API_KEY` required |
| `ollama` | `llama3.2` | none |

Prefer the environment variable over `--api-key`: a flag value is visible in `ps` and shell history for the life of the process.

Compose with span search:

```bash
tapesctl skill generate $(tapesctl search "Charm CLI" --quiet --top 1) \
  --name charm-patterns
```

### list

```bash
tapesctl skill list
tapesctl skill list --type workflow
```

Reads `~/.tapes/skills/*.md`. No HTTP. `--tapes-url` may appear on `--help` because it is inherited; the command ignores it.

### sync

```bash
tapesctl skill sync debug-react-hooks
tapesctl skill sync debug-react-hooks --local             # .agents/skills/
tapesctl skill sync debug-react-hooks --claude            # ~/.claude/skills/
tapesctl skill sync debug-react-hooks --claude --local    # .claude/skills/
tapesctl skill sync debug-react-hooks --dry-run
```

Local file copy of `<name>.md`. Default target is `~/.agents/skills/`. No server involved.

To install a **server** skill into an agent directory, download `GET /v1/skills/{id}/skill.md` (frontmatter `name` = slug) and place that file yourself — `tapesctl skill sync` will not see it until a copy exists under `~/.tapes/skills/`.

## On-disk SKILL.md

```markdown
---
name: debug-react-hooks
description: Debug React hooks issues. Use when debugging useEffect loops.
version: 0.1.0
tags: [react, hooks, debugging]
type: workflow
sessions: [sess-1, sess-2]
created_at: 2026-02-17T10:00:00Z
---

## Debug React Hooks

1. Check dependency array
2. Look for stale closures
```

`Write` creates the directory at `0700`/`0755` and the file at `0600`. `List` skips unreadable or non-frontmatter files.

## Errors and verification

| Status | When |
| --- | --- |
| 400 | Empty `sessionIds`, unknown `type`, malformed cursor, invalid JSON |
| 403 | Delete by someone other than `author_subject` |
| 404 | Unknown skill id, or generate source session not in the tenant |
| 422 | Server generate: no API key for a non-Ollama provider |
| 500 | LLM call/parse/persist, or published version whose head bump failed |
| 501 | Driver is not PostgreSQL |

Valid types: `workflow`, `domain-knowledge`, `prompt-template`.

<Steps>
<Step title="Confirm the read API and a derived session">
```bash
curl -sS http://127.0.0.1:8081/ping
tapesctl sessions list --tapes-url http://localhost:8081
```
Generation needs the span projection, not raw ingest alone. See [Capture and derive](/capture-and-derive).
</Step>
<Step title="Generate locally or on the server">
```bash
tapesctl skill generate <session-id> --name debug-react-hooks --preview
# or
curl -sS -X POST http://127.0.0.1:8081/v1/skills/generate \
  -H 'Content-Type: application/json' \
  -d '{"sessionIds":["<session-id>"],"hint":{"name":"debug-react-hooks"}}'
```
</Step>
<Step title="Verify the store you used">
```bash
ls ~/.tapes/skills/debug-react-hooks.md
# or
curl -sS http://127.0.0.1:8081/v1/skills
curl -sS http://127.0.0.1:8081/v1/sessions/<session-id>/skills
```
</Step>
</Steps>

<Warning>
`POST /v1/skills/generate` on bundled `tapes serve` defaults to OpenAI unless you run `tapes serve api` (which inherits `embedding.provider`). A local Ollama embedder does not automatically make all-in-one generate call Ollama.
</Warning>

<AccordionGroup>
<Accordion title="501 on every /v1/skills route">
The API process is not using the PostgreSQL driver. Skills are unimplemented on the in-memory store.
</Accordion>
<Accordion title="422 on generate">
No chat-provider key resolved. On `tapes serve api`, store one with `tapes auth` for `embedding.provider`, or set `OPENAI_API_KEY` / `ANTHROPIC_API_KEY`. Or set `embedding.provider=ollama`.
</Accordion>
<Accordion title="404 on generate">
One of `sessionIds` is unknown or not in the single-tenant org. Confirm `GET /v1/sessions/{id}` first.
</Accordion>
<Accordion title="tapesctl list is empty after a console generate">
Expected. Download `GET /v1/skills/{id}/skill.md` or keep using the server store.
</Accordion>
<Accordion title="Generated skill is too specific">
The model is asked for the generalizable technique, not session-specific details. Preview first. Sessions that are mostly harness offshoots produce a thin spine (previews + few `[tools]` lines).
</Accordion>
</AccordionGroup>

## Next

<CardGroup>
<Card title="Search spans" href="/search-spans">
Feed `--search` / `tapesctl search --quiet` session IDs into generate.
</Card>
<Card title="Read API" href="/read-api">
Compiled `GET /openapi` for `:8081`, including the skills tag.
</Card>
<Card title="Sessions, traces, and spans" href="/sessions-traces-spans">
What `call_kind`, `thread_id`, and synthetic turns mean for the spine.
</Card>
<Card title="Configure embeddings" href="/configure-embeddings">
The credential and provider `POST /v1/skills/generate` reuses.
</Card>
<Card title="Inspect and export" href="/inspect-and-export">
Browse the same turns generate reads, or export JSONL.
</Card>
<Card title="CLI reference" href="/cli-reference">
`tapes` vs `tapesctl` ownership. Skill commands are not on this binary.
</Card>
</CardGroup>
