Tapes
Tapes is agent telemetry for LLM interactions. It transparently captures provider calls, keeps their original request and response data in an append-only log, and derives a browsable model of sessions, traces, and spans.
Use Tapes to inspect agent work, measure token use and cost, search previous work by meaning, export sessions, and turn successful sessions into reusable skills.
Quickstart
Tapes ships as two binaries: tapes, the server, and
tapesctl, the client that
captures sessions and reads them back. Install both:
curl -fsSL https://download.tapes.dev/install | bash
curl -sSfL https://download.tapes.dev/tapesctl/install | bash
Docker is required for the bundled local dependencies. Start PostgreSQL with pgvector and Ollama with the default embeddinggemma embedding model:
tapes local up
tapes local up writes the resulting PostgreSQL, pgvector, and Ollama settings to the active .tapes/config.toml. If native Ollama is already installed, Tapes uses it rather than starting the Ollama container.
Start the all-in-one runtime in one terminal:
tapes serve
This runs the capture proxy on http://localhost:8080, read API on http://localhost:8081, private ingest API on http://localhost:8082, derive pipeline, and span-embedding pipeline. Seed data and browse it from another terminal:
tapesctl seed --tapes-url http://localhost:8081
tapesctl sessions list --tapes-url http://localhost:8081
tapesctl sessions list
Search individual spans:
tapesctl search "explain the retry logic"
When ready to capture real work, launch a supported agent through the client:
tapesctl start claude --tapes-url http://localhost:8081
# or
tapesctl start opencode --tapes-url http://localhost:8081
tapesctl start launches the agent under a just-in-time capture proxy, routes its provider traffic through it, and ships the captured turns to the server. See Agent integrations for manual and generic-proxy setups.
Next steps
- Understand the
raw_turns→ sessions → traces → spans model. - Review configuration precedence and supported keys.
- Learn how to inspect and export captured work.
- Connect an agent to the MCP search tool.
Installation and local setup
Install a release
tapes is the server — it runs the services and owns the database:
curl -fsSL https://download.tapes.dev/install | bash
tapes version
tapesctl is the client. It
captures sessions and reads them back, and is what you use day to day:
curl -sSfL https://download.tapes.dev/tapesctl/install | bash
tapesctl version
Bootstrap local dependencies
Tapes uses PostgreSQL as its storage backend and pgvector for semantic search. The bundled bootstrap requires Docker and provisions:
- PostgreSQL with pgvector and pg_duckdb;
- Ollama for local embeddings. It reuses a running native server when available, or starts an Ollama container when Ollama is not installed. If native Ollama is installed but stopped, the command tells you to start it and pull the model.
tapes local up
tapes local status
The default PostgreSQL port is 5432, Ollama port is 11434, and embedding model is embeddinggemma. To force Ollama into Docker:
tapes local up --docker-ollama
The PostgreSQL data directory lives under the active .tapes/ directory. Stopping containers preserves it:
tapes local down
Delete both containers and captured PostgreSQL data only when a reset is intended:
tapes local down --wipe
--wipepermanently removes locally captured sessions.
Start Tapes
tapes serve
Defaults are proxy :8080, read API :8081, private ingest API :8082, Ollama upstream http://localhost:11434, and background span embedding enabled. Verify the read API and configuration:
curl http://localhost:8081/ping
tapes status
Seed representative capture data through the normal ingest and derive path:
tapesctl seed --tapes-url http://localhost:8081
tapesctl sessions list
OpenAI embeddings
Local PostgreSQL is still required, but Ollama need not be used for embeddings:
tapes auth openai
tapes config set embedding.provider openai
tapes serve
OPENAI_API_KEY may be used instead of tapes auth openai. The configured model and dimensions must match the provider’s output.
For source builds and contributor dependencies, see Local development.
Configuration
Tapes stores configuration and credentials in a .tapes/ directory. Resolution order is:
--config-dir <directory>;.tapes/in the current directory;~/.tapes/;- built-in defaults if none exists.
Create a project-local directory with defaults or a provider preset:
tapes init
tapes init --preset anthropic
# presets: openai, anthropic, ollama
A project-local .tapes/ takes precedence over the home directory. This is useful for per-project provider and database settings, but is also the first place to check when an expected global setting appears ignored.
Precedence
For commands that bind a setting, precedence is:
- CLI flag;
TAPES_...environment variable;config.tomlvalue;- built-in default.
Dots become underscores in environment names, for example TAPES_PROXY_LISTEN and TAPES_STORAGE_POSTGRES_DSN.
Manage settings
tapes config list
tapes config get proxy.provider
tapes config set proxy.provider anthropic
tapes config set proxy.upstream https://api.anthropic.com
Useful supported keys include:
| Key | Purpose | Default |
|---|---|---|
storage.postgres_dsn | Capture and derived PostgreSQL database | unset |
proxy.provider | anthropic, openai, or ollama | ollama |
proxy.upstream | Upstream provider base URL | http://localhost:11434 |
proxy.listen | Proxy listen address | :8080 |
proxy.project | Session project tag | auto-detected from Git when unset |
api.listen | Read API listen address | :8081 |
api.web_ui | Minimal browser UI at / | false |
ingest.listen | Private ingest listen address | :8082 |
client.proxy_target | Proxy URL used by clients | http://localhost:8080 |
client.api_target | API URL used by clients | http://localhost:8081 |
vector_store.target | pgvector PostgreSQL DSN | primary PostgreSQL DSN when unset |
embedding.provider | ollama or openai | ollama |
embedding.target | Embedding service URL | http://localhost:11434 |
embedding.model | Embedding model | embeddinggemma |
embedding.dimensions | Vector dimensions | 768 |
opencode.provider / opencode.model | Saved OpenCode choice | unset |
telemetry.disabled | Disable CLI usage telemetry | false |
update.disabled | Disable update checks | false |
cassettes = ["https://host/openapi"] is a top-level array for operator-managed cassette OpenAPI URLs; it is not a dotted config set field. See Cassettes for the manifest, deployment responsibilities, and runtime behavior.
Example
version = 0
[storage]
postgres_dsn = "postgres://tapes:tapes@localhost:5432/tapes?sslmode=disable"
[proxy]
provider = "anthropic"
upstream = "https://api.anthropic.com"
listen = ":8080"
[api]
listen = ":8081"
[client]
proxy_target = "http://localhost:8080"
api_target = "http://localhost:8081"
[vector_store]
target = "postgres://tapes:tapes@localhost:5432/tapes?sslmode=disable"
[embedding]
provider = "ollama"
target = "http://localhost:11434"
model = "embeddinggemma"
dimensions = 768
Store provider secrets with tapes auth openai or tapes auth anthropic, or use the provider’s environment variable. Do not put API keys in config.toml.
Agent integrations
The proxy is transparent: point a supported provider client at it and Tapes forwards the request while recording the completed turn. For supported coding agents, launching through tapesctl is safer than editing configuration by hand — it wires the agent to a just-in-time capture proxy that dies with the process.
Start the server and its local dependencies before these examples:
tapes local up
tapes serve
Then install the client:
curl -sSfL https://download.tapes.dev/tapesctl/install | bash
Claude Code
tapes auth anthropic # optional when ANTHROPIC_API_KEY is already set
tapesctl start claude --tapes-url http://localhost:8081
tapesctl starts a loopback capture proxy, sets Claude Code’s ANTHROPIC_BASE_URL to it, launches claude, and ships the captured turns to the server. Pass Claude flags after --:
tapesctl start claude -- --worktree
For a manually managed, fixed-port proxy:
tapes serve --provider anthropic --upstream https://api.anthropic.com
ANTHROPIC_BASE_URL=http://localhost:8080 claude
OpenCode
tapesctl start opencode --tapes-url http://localhost:8081
Switching provider or model inside OpenCode is not captured by the configured route; start a separate capture session instead.
Ollama and generic clients
With default configuration, Tapes forwards Ollama-compatible traffic to http://localhost:11434:
tapes serve
curl http://localhost:8080/api/chat \
-H 'Content-Type: application/json' \
-d '{"model":"qwen3-coder:30b","messages":[{"role":"user","content":"hello"}],"stream":false}'
Pull a chat model separately; tapes local up pulls the embedding model, not every completion model:
ollama pull qwen3-coder:30b
For another Anthropic-, OpenAI-, or Ollama-compatible application, configure its base URL as http://localhost:8080 and run tapes serve with the matching --provider and --upstream. Preserve the path convention expected by the client and provider. Verify capture with:
tapes status
tapesctl sessions list --tapes-url http://localhost:8081
OpenClaw
Tapes has no OpenClaw-specific launcher or configuration code. OpenClaw can only be treated as a generic supported-provider client: if the installed OpenClaw version offers a provider base-URL setting, point that setting at http://localhost:8080 while Tapes runs with the matching provider/upstream. Consult the OpenClaw documentation for the setting’s current name and shape rather than copying old openclaw.json examples.
This limited integration claim is intentional: device pairing, channels, and OpenClaw deployment behavior are outside Tapes and are not configured by this repository.
Verify and stop
The read API health endpoint is separate from the proxy:
curl http://localhost:8081/ping
tapesctl sessions list --tapes-url http://localhost:8081
Stop the foreground tapes serve process with Ctrl-C. tapes local down removes bootstrap containers but keeps PostgreSQL data unless --wipe is supplied.
Architecture
Tapes separates immutable capture from derived, query-oriented data.
agent or application
|
v
proxy :8080 --------------------> upstream LLM provider
|
| append completed request/response turn
v
PostgreSQL raw_turns
|
| idempotent derivation
v
sessions -> traces -> spans -----> pgvector span embeddings
| |
+----------> API :8081 <-------+
|
CLI / Deck / MCP clients
Capture: raw_turns
The transparent proxy supports Anthropic, OpenAI, and Ollama traffic. It forwards a request to the configured upstream and appends the completed interaction to PostgreSQL’s immutable raw_turns capture log. The private ingest service is an alternative write path for an external gateway.
Capture is append-only. Replayed turns deduplicate by capture identity instead of rewriting prior capture.
A captured turn is stored twice over: raw_response holds the upstream bytes verbatim, and response holds the reduced turn an adapter produced from them. Keeping the bytes is what makes the reduction reproducible and auditable rather than authoritative — a future data-model change becomes a re-derive over existing rows instead of a re-capture. Reducing server-side from those bytes is the end state, reached through a proven ratchet: see Proving the capture ratchet.
Derivation: sessions, traces, and spans
The deriver projects raw turns into the read model:
- Session: one harness or agent session, with folded model, token, cost, duration, status, and turn-count data.
- Trace: a turn in that session.
- Span: the work within a trace, including the main LLM conversation and attached tool activity. Span links preserve relationships where needed.
Derived IDs are deterministic. Re-deriving unchanged raw input reproduces the same projection, and the deriver prunes derived records no longer present in the projection. This makes derivation idempotent and safe to run repeatedly.
Search
The embedding worker embeds main-conversation LLM spans into PostgreSQL using pgvector. Search is span-only: /v1/search/spans, tapesctl search, and the MCP search tool all return individual span hits with session, trace, and turn context. They do not search session objects or a conversation DAG.
Content addressing
Merkle content addressing remains an internal, in-memory derivation mechanism for content identity and deduplication. Tapes does not persist a user-facing Merkle node graph, and there is no checkout, branching, or Merkle browsing workflow.
Runtime forms
tapes serve is the convenient all-in-one runtime. It runs the proxy, read API, private ingest API, derive worker, and—by default—the embed worker in one process. Operators can instead run tapes serve proxy, api, ingest, derive-worker, or embed-worker as separate processes. In a split deployment, derivation and embedding are deliberately independent failure domains.
Cassettes
Alpha/POC: the current cassette contract is
cassette/v1alpha1. It is suitable for experiments and integrations, but its manifest and runtime behavior are not yet a stable compatibility promise.
A cassette is an independently deployed HTTP service that extends the Tapes read API. Tapes fetches the service’s OpenAPI document, admits the manifest embedded in that document, rewrites the cassette’s paths into the Tapes namespace, and reverse-proxies client requests to the cassette.
A cassette is not a plugin loaded into the Tapes process. It can use any language or HTTP framework and does not have to import Tapes. The deployment, not Tapes, starts it and supplies its credentials and configuration.
The complete runnable example is in
pkg/cassette/examples/hello-world.
It includes an HTTP service, OpenAPI generation, cassette.toml, a container,
PostgreSQL provisioning, and a Compose deployment. A smaller
mcp-tool example
advertises one ping tool and returns pong.
Running a cassette locally walks the hello-world
example end to end, including driving the discovered surface with tapesctl.
What a cassette must provide
A cassette has three kinds of endpoint on its own listener:
- a health anchor,
/pingby default; - an OpenAPI anchor,
/openapiby default; and - its API below a declared local prefix.
For a cassette named summary with the default prefix_path = "api", its own
listener might serve:
GET /ping
GET /openapi
GET /api/summary/reports
Tapes republishes only the cassette API:
GET /v1/cassettes/summary/reports
The health and OpenAPI anchors describe the process itself. Do not include those root paths as operations in the cassette OpenAPI document. Every path in the document must be below the cassette’s local API prefix, or Tapes refuses the whole document.
The OpenAPI document must carry an x-tapes-cassette root extension containing
the manifest. Tapes uses the configured document URL to both fetch the contract
and determine the origin to which API requests are proxied.
Minimum manifest
The current manifest kind is cassette/v1alpha1. The authored TOML form can be
as small as:
kind = "cassette/v1alpha1"
[cassette]
name = "summary"
version = "0.1.0"
[depends]
core = "v1"
Omitted API anchors default to:
[api]
health = "/ping"
openapi = "/openapi"
prefix_path = "api"
The same logical manifest is required in the OpenAPI document as JSON:
{
"openapi": "3.1.0",
"info": {"title": "Summary cassette", "version": "0.1.0"},
"x-tapes-cassette": {
"kind": "cassette/v1alpha1",
"cassette": {"name": "summary", "version": "0.1.0"},
"depends": {"core": "v1"},
"api": {
"health": "/ping",
"openapi": "/openapi",
"prefix_path": "api"
}
},
"paths": {
"/api/summary/reports": {
"get": {
"operationId": "listReports",
"responses": {"200": {"description": "Reports"}}
}
}
}
}
Use any OpenAPI library that can add a root extension. The hello-world example
uses pkg/tapesoapi, but that package is a convenience rather than part of the
wire protocol.
The two published forms
A cassette normally publishes the same declaration in two places:
cassette.tomlis read before the process starts by a registry, installer, or orchestrator. It describes the image, port, database access, and configuration that deployment tooling may need. Tapes does not read this file.x-tapes-cassettein OpenAPI is read from the running service by Tapes. This copy is required for admission.
They are two encodings of one schema, not independent manifests. For the same installation identity, keep them in sync and test that they produce the same canonical manifest digest. Defaults are applied before canonicalization, and set-like fields are sorted, so an explicit default and an omitted default have the same identity.
The Go parser is strict: duplicate keys, unknown fields, trailing JSON values,
and an unsupported kind are errors. Parsing applies defaults but does not run
semantic validation; callers of the package must also call Validate:
package main
import (
"fmt"
"os"
"github.com/papercomputeco/tapes/pkg/cassette"
"github.com/papercomputeco/tapes/pkg/cassette/manifest"
)
func main() {
declared, err := manifest.Load("cassette.toml")
if err != nil {
panic(err)
}
if err := declared.Validate([]cassette.ContractVersion{"v1"}); err != nil {
panic(err)
}
digest, err := declared.Digest()
if err != nil {
panic(err)
}
fmt.Fprintln(os.Stdout, digest)
}
There is not yet a dedicated tapes cassette validate command.
cassette/v1alpha1 field reference
Identity
| Field | Required | Rules and purpose |
|---|---|---|
kind | yes | Must be exactly cassette/v1alpha1. |
cassette.name | yes | Two to 32 lowercase letters, digits, or interior dashes; must start with a letter and end with a letter or digit. public, tapes, and names beginning pg_ are reserved. |
cassette.version | yes | Non-empty release identifier. The alpha schema does not require semantic version syntax. |
cassette.display_name | no | Human-readable name. |
cassette.description | no | Human-readable summary. |
cassette.license | no | License identifier or prose. |
cassette.homepage | no | Absolute http or https URL. |
cassette.image | no | Image reference for deployment tooling, without leading or trailing whitespace. If set, port is required. Tapes does not pull or run it. |
cassette.port | no | Listener port from 1 through 65535. If set, image is required. |
x-source-digest | no | Optional source provenance in sha256:<64 lowercase hex characters> form. Tapes checks the shape but does not fetch or verify a source artifact. |
The name is shared across several namespaces:
public route /v1/cassettes/<name>
Postgres schema <name>
Postgres role cassette_<name>
A valid name may contain a dash, so quote derived PostgreSQL identifiers rather than interpolating them as bare SQL identifiers.
Tapes dependency
[depends]
core = "v1"
views = ["sessions", "spans"]
depends.core names a major Tapes contract (v1, v2, and so on), not a Tapes
binary release. A running core admits the cassette only if it serves that
contract. The current default contract is v1.
Each depends.views entry must be a unique lowercase PostgreSQL identifier of
at most 63 bytes. raw_turns is explicitly forbidden: it is an internal capture
log, not a cassette contract view. The manifest derives requested grants as
tapes_<core>.<view>, for example tapes_v1.spans.
This is a declaration only. Tapes does not check that a named view exists, apply grants, create roles, or give the cassette a database credential. Deployment tooling owns those actions.
API anchors and path mapping
[api]
health = "/ping"
openapi = "/openapi"
prefix_path = "api"
health and openapi must be absolute paths without a host, query, fragment,
or ./.. segment. The current POC records both anchors, but Tapes fetches the
exact OpenAPI URL configured by the operator and does not currently probe the
health anchor.
prefix_path is the path before the cassette name on the cassette’s own
listener. Each slash-separated segment must begin with a lowercase letter or
digit; the rest may contain only lowercase letters, digits, dashes, or
underscores. Prefer slash-free outer edges, such as api or extensions/v2.
Tapes normalizes surrounding slashes. Set it to / to mount directly below the
name:
prefix_path | Cassette-local path | Public path |
|---|---|---|
omitted or api | /api/summary/reports | /v1/cassettes/summary/reports |
extensions/v2 | /extensions/v2/summary/reports | /v1/cassettes/summary/reports |
/ | /summary/reports | /v1/cassettes/summary/reports |
Every documented OpenAPI path must be contained by the local path in the middle column. Tapes rewrites that prefix in both the cached per-cassette document and the aggregate document.
Owned tables
Declare tables the cassette owns in its own schema:
[[tables]]
name = "daily_summary"
Names must be unique lowercase PostgreSQL identifiers of at most 63 bytes.
Discovery publishes the qualified name, such as summary.daily_summary.
Again, this is desired deployment state. The cassette owns its migrations; Tapes does not create the schema or tables.
Configuration schema
A manifest can describe values that the deployment supplies to the cassette:
[[config]]
key = "llm.model"
type = "string"
required = true
enum = ["claude", "other"]
description = "Model used to create summaries."
[[config]]
key = "batch_size"
type = "int"
default = 50
min = 1
max = 500
[[config]]
key = "llm.api_key"
type = "string"
required = true
secret = true
Keys consist of dotted lowercase snake-case segments. They must be unique both as keys and after conversion to the conventional environment name:
llm.model -> CASSETTE_LLM_MODEL
batch_size -> CASSETTE_BATCH_SIZE
Supported types are:
| Type | Default value rules | Extra constraints |
|---|---|---|
string | TOML/JSON string | enum is allowed only here, and its values must be unique. |
int | Integer | Optional inclusive min and max; min must not exceed max. |
bool | Boolean | — |
duration | String accepted by Go’s duration parser, such as 30s or 5m | — |
json | A string whose contents are valid JSON | The manifest value is a string, not an inline TOML object. |
A secret setting cannot have a default. Tapes publishes this schema, never
runtime values, and does not inject environment variables. The CASSETTE_...
name is a convention for the deployment and cassette to implement.
The current discovery response projects each setting’s key, type, required and
secret flags, default, and description. Constraints such as enum, min, and
max remain in the manifest but are not projected into discovery, so deployment
tooling that needs the full configuration schema should read the manifest.
OpenAPI admission rules
Before publishing a cassette, Tapes:
- fetches the configured URL with
GET, a ten-second default timeout, and an 8 MiB response limit; an initial or changed document must return HTTP 200, while a conditional refresh may return 304; - refuses redirects by default;
- parses the OpenAPI document and its required root manifest extension;
- validates the manifest against the contracts this core serves;
- verifies that every path is below the declared local prefix;
- rewrites paths to
/v1/cassettes/<name>; and - compiles the rewritten document to ensure it can be published.
Every operation must declare at least one response. If an operation supplies an
operationId, it must be unique within that cassette; Tapes synthesizes an ID
for anonymous operations in the aggregate. Component names and operation IDs
are namespaced by cassette name in the aggregate /openapi document, so
independently authored cassettes can use the same local names. A cassette’s own
cached document remains available at
/v1/cassettes/<name>/openapi.json.
The configured source must be a full http or https URL with a host and no
userinfo or fragment. Tapes uses only its origin (scheme, host, and port) as the
reverse-proxy target, so the service API must be reachable on the same origin as
the OpenAPI document. Do not change the manifest name served by an already
resolved source URL; the source is pinned to its first admitted identity.
Cassette requests receive X-Tapes-Cassette: <name> and standard forwarded
headers. The current proxy buffers complete requests and responses. Treat the
POC surface as JSON request/response APIs; streaming is not currently supported.
MCP tool advertisement
A cassette can expose an operation through the Tapes MCP endpoint by adding
x-tapes-mcp to that operation:
{
"post": {
"operationId": "summarizeSession",
"summary": "Summarize a session",
"x-tapes-mcp": {
"name": "summarize_session",
"annotations": {
"readOnlyHint": true,
"idempotentHint": true,
"openWorldHint": false
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {"session_id": {"type": "string"}},
"required": ["session_id"]
}
}
}
},
"responses": {"200": {
"description": "Summary",
"content": {"application/json": {"schema": {
"type": "object",
"properties": {"summary": {"type": "string"}}
}}}
}}
}
}
For a cassette named summary, this registers the MCP tool
summary.summarize_session. The operation summary and description become the
tool title and description. Annotations use the MCP field names
readOnlyHint, destructiveHint, idempotentHint, and openWorldHint; they
are client hints, not authorization rules.
The initial bridge is deliberately narrow. An advertised operation must:
- be declared in an OpenAPI 3.1 document;
- use
POSTwith no path, query, header, or cookie parameters; - have an inline, required
application/jsonrequest body whose schema resolves to an object; and - return a JSON object on success; and
- advertise no more than 128 tools per cassette.
Put every tool argument in the JSON body. Local
#/components/schemas/... references are supported and are bundled into the
standalone JSON Schema published through MCP. Remote references and a
request-body $ref are not supported. A cassette needing other HTTP semantics
should expose a small JSON-body POST facade rather than relying on Tapes to act
as a general OpenAPI client.
Malformed advertised tools refuse the refreshed cassette document. Unknown
extension fields are ignored so newer declarations remain compatible with older
Tapes servers. If a later refresh fails, Tapes retains the previously admitted
document and tools just as it retains the cassette’s stale HTTP surface. Tool calls use the admitted
cassette origin, forward the caller’s end-to-end headers, set
X-Tapes-Cassette, refuse redirects, and return non-2xx responses as MCP tool
errors.
The tool declaration lives on the operation rather than inside
x-tapes-cassette, so adding or changing a tool changes the OpenAPI ETag but not
the cassette manifest digest. Admitting a cassette also trusts its operation and
schema prose: MCP clients may place that text directly in an agent’s context.
Run and register a cassette
Tapes does not start cassette processes. Start the cassette through your normal process manager, then give the Tapes API server the exact URL of its OpenAPI document:
# .tapes/config.toml
cassettes = ["http://127.0.0.1:9999/openapi"]
Equivalent CLI configuration is:
tapes serve --cassettes=http://127.0.0.1:9999/openapi
# or: tapes serve api --cassettes=http://127.0.0.1:9999/openapi
# or: TAPES_CASSETTES=http://127.0.0.1:9999/openapi tapes serve
Tapes retries unresolved sources during startup and refreshes documents every
30 seconds by default. Change that interval with --cassette-refresh. A
cassette being unavailable does not prevent Tapes from starting.
Inspect the installed surface with:
curl http://localhost:8081/v1/cassettes
curl http://localhost:8081/v1/cassettes/summary/openapi.json
curl http://localhost:8081/openapi
curl http://localhost:8081/v1/cassettes/summary/reports
Discovery reports admitted cassettes, their manifest digests, OpenAPI status, and rejected source problems. After a successful admission, a later refresh failure marks the cached document stale rather than deleting it. Removing the source from configuration withdraws the cassette.
The manifest_digest in discovery identifies canonical manifest metadata. The
ETag on a cached cassette OpenAPI response identifies the complete republished
OpenAPI document; these digests answer different questions and need not match.
Database and deployment responsibilities
For a manifest named summary, depending on v1 views sessions and spans,
and declaring table daily_summary, the derived grant plan is:
role cassette_summary
own schema summary
SELECT tapes_v1.sessions
SELECT tapes_v1.spans
owned table summary.daily_summary
The deployment should:
- create and manage the cassette role and credentials;
- grant only the declared contract views;
- allow or create the cassette’s schema as appropriate;
- supply database and manifest-declared configuration values directly to the process; and
- let the cassette run its own schema migrations.
Tapes publishes the declaration but deliberately performs none of those steps.
It also does not pull cassette.image, expose cassette.port, or manage the
cassette lifecycle.
Builder checklist
Before handing a cassette to an operator:
- serve a 200 health response at the declared health anchor;
- serve one valid OpenAPI JSON document with HTTP 200 at the declared OpenAPI anchor;
- embed the exact
cassette/v1alpha1manifest atx-tapes-cassette; - keep every OpenAPI operation under
/<prefix_path>/<name>(or/<name>whenprefix_path = "/"); - make any supplied operation IDs unique and declare responses for every operation;
- validate both TOML and embedded copies, and compare their manifest digests for the same installation identity;
- keep deployment metadata, listener port, runtime name, and documented paths consistent;
- provision database access outside Tapes and run cassette-owned migrations;
- test direct health/OpenAPI access, discovery, the cached spec, the aggregate spec, and at least one proxied request; and
- avoid streaming endpoints until the proxy gains streaming support.
Running a cassette locally
This walkthrough proves the cassette path end to end on one machine, with
nothing but this repository, Docker, and tapesctl: a standalone Tapes admits
the bundled hello-world cassette, republishes its API, and tapesctl turns the
discovered surface into commands. No orchestrator or platform deployment is
involved — the registration mechanism is ordinary operator configuration
(--cassettes), so everything here works against any Tapes you run yourself.
For the cassette contract itself — the manifest schema, admission rules, and deployment responsibilities — see Cassettes. This page is the follow-along companion to it.
What you need
curl -sSfL https://download.tapes.dev/tapesctl/install | bash
Start the stack
The runnable example at pkg/cassette/examples/hello-world ships its own
deployment, because Tapes does not start cassettes — something else always has
to. Its compose.yaml runs three services:
- postgres, provisioning the cassette’s role at first initialization;
- hello-world, the cassette, published on
127.0.0.1:9999; - tapes, the API server on
127.0.0.1:8081, started with--cassettes http://hello-world:9999/openapi.
cd pkg/cassette/examples/hello-world
docker compose up --build -d
Both images build from source for your machine’s native architecture, which is
what you want. Do not force --platform linux/amd64 on an Apple Silicon host:
the Go toolchain is unreliable under QEMU emulation and the build can crash.
Cross-building, when actually needed, belongs in a builder that compiles
natively and targets GOOS/GOARCH — not in this walkthrough.
Tapes retries the cassette source through startup and on every refresh, so the
ordering of the three containers does not matter; give it a few seconds after
up returns.
Verify admission
Discovery lists the admitted cassette, its manifest digest, and any rejected sources:
curl -s localhost:8081/v1/cassettes | jq
{
"contract_version": "v1",
"cassettes": [
{
"name": "hello-world",
"version": "0.0.1",
"route_prefix": "/v1/cassettes/hello-world",
"openapi_path": "/v1/cassettes/hello-world/openapi.json",
"openapi_status": "fresh",
"manifest_digest": "sha256:8171d476..."
}
],
"problems": []
}
The cached per-cassette document and the aggregate document both republish the cassette’s paths under the Tapes namespace:
curl -s localhost:8081/v1/cassettes/hello-world/openapi.json | jq '.paths | keys'
# ["/v1/cassettes/hello-world/hello"]
curl -s localhost:8081/openapi | jq '.paths | keys | map(select(startswith("/v1/cassettes")))'
# ["/v1/cassettes", "/v1/cassettes/hello-world/hello"]
And the proxied API round-trips — the cassette serves /api/hello-world/hello
on its own listener, but clients only ever see the rewritten public path:
curl -s -X POST localhost:8081/v1/cassettes/hello-world/hello
# {"id":1,"hello":"hello","world":"world","created_at":"..."}
curl -s localhost:8081/v1/cassettes/hello-world/hello
# {"cassette":"hello-world","greeting":"Hello","message":"Hello world",
# "rows":[{"id":1,...}],"store":"postgres"}
"store": "postgres" confirms the deployment-supplied credential worked; run
the compose file with an empty HELLO_WORLD_DATABASE_URL to watch the same
cassette fall back to memory and say so.
Drive it with tapesctl
tapesctl reads the same discovery surface and generates a subcommand per
cassette, with a method per OpenAPI operation. Because the nouns have to exist
before the command line is parsed, point discovery at the server with the
TAPES_URL environment variable (the --tapes-url flag also works, on any
subcommand):
export TAPES_URL=http://localhost:8081
tapesctl --help # a `hello-world` command has appeared
tapesctl hello-world --help
Commands:
create-hello Write one row to the hello table
get-hello Greet, and read back every stored row
Those names are the cassette’s own operationIds, kebab-cased, taken from the
document Tapes cached — this binary has never heard of hello-world:
tapesctl hello-world create-hello
tapesctl hello-world get-hello
The discovered surface is cached per server and revalidated with ETag, so
--help stays instant and works offline once seen.
Optional: capture a session beside the cassette
The example composes only the API server, because none of the cassette surface needs anything else. But the stack it runs is a complete standalone Tapes: add the ingest server and the derive worker — from the image the compose build already produced — and it captures real sessions too.
docker run -d --name hw-ingest --network tapes-hello-world_default \
-p 127.0.0.1:8082:8082 tapes-hello-world-tapes:latest \
serve ingest --listen 0.0.0.0:8082 \
--postgres 'postgres://tapes:tapes@postgres:5432/tapes?sslmode=disable'
docker run -d --name hw-derive --network tapes-hello-world_default \
tapes-hello-world-tapes:latest \
serve derive-worker \
--postgres 'postgres://tapes:tapes@postgres:5432/tapes?sslmode=disable'
Run a harness under capture, pointed at the ingest server:
tapesctl start --tapes-url http://localhost:8082 claude -- -p "Reply with exactly: ok"
After the derive worker’s debounce (about twenty seconds), the session, its trace, and its spans are readable from the same API that serves the cassette:
tapesctl sessions list # TAPES_URL still points at :8081
curl -s "localhost:8081/v1/sessions?limit=5" | jq '.items[].display_title'
Tear it down
docker rm -f hw-ingest hw-derive # only if you ran the optional step
docker compose down -v
The -v removes the Postgres volume, so the cassette role is provisioned again
on the next up.
Where to go from here
Everything the example does — the manifest, the OpenAPI extension, the
provisioning, the admission rules it satisfies — is specified in
Cassettes. Start from the example’s source in
pkg/cassette/examples/hello-world
when building your own.
Search
Tapes performs semantic search over the embedded span projection. Each result is an individual main-conversation LLM span, with its session ID, trace ID, span ID, turn prompt, model, timestamp, similarity score, and text snippet.
Search does not return sessions as its search unit and does not search internal Merkle content.
Local setup
The quickstart provides everything required:
tapes local up
tapes serve
tapes local up configures PostgreSQL/pgvector and the Ollama embeddinggemma model. tapes serve derives captures and embeds eligible spans in the background by default.
After capturing or seeding data:
tapesctl search "how was authentication fixed?"
tapesctl search "logging configuration" --top 10
Use quiet output to return unique session IDs in score order:
tapesctl search "Charm CLI patterns" --quiet --top 3
That output composes with skill generation:
tapesctl skill generate $(tapesctl search "Charm CLI" --quiet --top 1) \
--name charm-patterns
API
The equivalent read endpoint is:
curl --get http://localhost:8081/v1/search/spans \
--data-urlencode 'query=how was authentication fixed?' \
--data-urlencode 'top_k=5'
There is no /v1/search endpoint. See HTTP APIs for tenant headers and contracts.
Separate workers
In a split deployment, run derivation and embedding independently:
tapes serve derive-worker --postgres "$TAPES_STORAGE_POSTGRES_DSN"
tapes serve embed-worker --postgres "$TAPES_STORAGE_POSTGRES_DSN"
tapes serve api --postgres "$TAPES_STORAGE_POSTGRES_DSN"
The embed worker runs a bounded pass at startup and periodically thereafter. Its embedding model and dimensions must match the pgvector table. Failures leave a span unembedded for a later retry rather than blocking derivation.
Troubleshooting
- Confirm the API is reachable with
tapes status. - Confirm sessions and derived spans exist with
tapesctl sessions listandtapesctl sessions list. - Confirm the embedding service is running; for Ollama, use
curl http://localhost:11434/api/tags. - Confirm
embedding.modelandembedding.dimensionsmatch. - In a split deployment, verify the embed worker is running. A configured but uninitialized search surface returns HTTP
503.
Skills
Tapes can extract reusable patterns from derived session transcripts with an LLM, store them under ~/.tapes/skills/, and sync them into agent skill directories.
A generated transcript follows the main conversation spine: turn-level prompts and responses from traces/spans, excluding harness shadow calls such as permission checks, title generation, and injected context.
Generate
A name in kebab-case is required:
tapesctl skill generate <session-id> --name debug-react-hooks
Use multiple session IDs, or find matching sessions through span search:
tapesctl skill generate <session-a> <session-b> --name retry-patterns
tapesctl skill generate --search "gum glow charm" \
--search-top 3 --name charm-cli-patterns
Other useful controls are:
tapesctl skill generate <session-id> --name morning-work \
--since 2026-02-17 --until 2026-02-17T17:00:00Z \
--type workflow --preview
Skill types are workflow, domain-knowledge, and prompt-template. Generation defaults to the OpenAI provider; select --provider openai|anthropic|ollama, plus --model or --api-key when required. It may connect to the API with --api-target or use --postgres for a local in-process API.
List
tapesctl skill list
tapesctl skill list --type workflow
Sync
Authoring a skill reads session data, so generate and list live in tapes.
Installing one into an agent’s skills directory is a local file copy with no
server involved, so it lives in the client,
tapesctl.
By default, sync writes to the global, agent-neutral ~/.agents/skills/:
tapesctl skill sync debug-react-hooks
Choose project-local or Claude Code paths explicitly:
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
Generated skills are files you can inspect and version like other agent instructions. Preview generation and use --dry-run before syncing when the source session contains project-specific assumptions.
Inspecting and exporting data
Check the active setup
tapes status
This reports the selected .tapes/ directory, provider and upstream, read API reachability, and a brief capture summary.
List sessions
Reading the data model is a client operation, so it lives in tapesctl:
tapesctl sessions list --tapes-url http://localhost:8081
tapesctl sessions list --limit 20
tapesctl sessions get <session-id>
tapesctl sessions traces <session-id>
Each prints the server’s JSON verbatim, so it composes with jq. --tapes-url falls back to TAPES_URL. A running read API is required; start one with tapes serve.
Browse interactively
tapesctl sessions list
tapesctl sessions get <session-id>
Deck shows session aggregates and drills into traces and spans. See Deck.
Export JSONL
tapesctl export is a thin client for GET /v1/sessions/{id}/export. It streams the API’s projection rather than maintaining a separate renderer or state store.
tapesctl export <session-id> -o session.jsonl
tapesctl export <session-id> --detail traces
tapesctl export <session-id> --tapes-url http://localhost:8081
Detail modes:
spans(default): trace records with full span trees and links;traces: turn headers without spans or links.
A running read API is required. For a multi-session export, the API also provides GET /v1/sessions/export; consult its current OpenAPI parameters.
Inspect over HTTP
Common read operations include:
curl http://localhost:8081/v1/sessions
curl http://localhost:8081/v1/sessions/<session-uuid>
curl http://localhost:8081/v1/sessions/<session-uuid>/traces
curl http://localhost:8081/v1/sessions/<session-uuid>/raw_turns
curl http://localhost:8081/v1/traces/<trace-uuid>
curl http://localhost:8081/v1/traces/<trace-uuid>/spans/<span-uuid>
curl http://localhost:8081/v1/stats
Session IDs and trace/span IDs are UUIDs, not content hashes. GET /v1/sessions/{id} returns session metadata; conversation content is on the trace/span endpoints. Raw-turn retrieval preserves the original capture separately from the derived model.
Browse the live contract at http://localhost:8081/swagger, or fetch it from http://localhost:8081/openapi. See HTTP APIs for the surface and trust boundary.
CLI reference
tapes is the server. It runs the services, owns the database, and carries the operator tooling around them. Capturing a session and reading one back are client concerns and live in tapesctl — see The client CLI below.
Run tapes <command> --help for the complete, version-matched flag list.
| Command | Use |
|---|---|
tapes init [--preset ...] | Create a local .tapes/ configuration directory. |
| `tapes local [up | status |
tapes serve | Run proxy, read API, private ingest API, derive worker, and optional embed worker together. |
tapes status | Show active config, provider/upstream, API reachability, and capture summary. |
tapes auth | Store OpenAI or Anthropic credentials in .tapes/credentials.toml. |
| `tapes config get | set |
tapes raw equivalence | Prove stored capture bytes re-reduce to the stored reduction. See Proving the capture ratchet. |
tapes version | Print version information. |
Running services
The common local command is:
tapes serve
It accepts provider/upstream, PostgreSQL, listening, embedding, and project flags. Useful examples:
tapes serve --provider anthropic --upstream https://api.anthropic.com
tapes serve --api-web-ui
tapes serve --embed-spans=false
For split deployments, service subcommands are available:
tapes serve proxy
tapes serve api
tapes serve derive-worker
tapes serve embed-worker
tapes serve ingest
The last three are operator-oriented: the derive worker projects dirty sessions, the independent embed worker populates search vectors, and the private ingest sidecar receives completed turns from a trusted gateway. See HTTP APIs before exposing any endpoint.
The client CLI
Launching an agent under capture, listing sessions, exporting one, and seeding demo data are all client operations against a running server. They live in tapesctl:
curl -sSfL https://download.tapes.dev/tapesctl/install | bash
tapesctl start claude --tapes-url http://localhost:8081
tapesctl sessions list --tapes-url http://localhost:8081
tapesctl export <session-id> --detail spans -o session.jsonl
tapesctl seed --tapes-url http://localhost:8081
tapesctl skill sync <name> --claude
Every tapesctl command takes --tapes-url, falling back to TAPES_URL. Arguments after -- go directly to the agent. See Agent integrations and the tapesctl README for the full surface.
Proving the capture ratchet
raw_turns keeps two views of the same upstream response: raw_response, the bytes exactly as they arrived, and response, the reduced turn a capture adapter produced from them. The reduction is lossy, and while a second reducer runs inside the capture adapter, two capture paths can reduce the same traffic differently.
The fix is to have exactly one reducer, server-side. Getting there is a deliberate three-step ratchet, configured on the capture adapter:
| Mode | What the adapter sends | Stored fidelity |
|---|---|---|
off | its reduction only | reduced |
dual | its reduction and the verbatim bytes | raw |
raw | verbatim bytes only; tapes reduces | raw |
dual exists to make the middle step provable. It changes nothing an operator sees — ingest keeps the adapter’s reduction — while putting the bytes in the database next to it. That makes the question “would raw have produced this same row?” answerable offline, over real traffic:
tapes raw equivalence --since 24h --limit 5000
For each wire turn in the window that has both halves, the command decodes the stored bytes, re-reduces them through the same server-side path raw would run, and compares the result against the stored reduction. It exits non-zero if anything diverged or failed to reduce, so it can gate the step in CI.
Run it inside the cluster against a tenant’s database:
kubectl exec -n <tenant-ns> deploy/tapes-api -- \
tapes raw equivalence --since 24h --limit 5000
or locally against a forwarded database, with --json for machine consumption:
tapes raw equivalence \
--postgres "postgres://user:pass@127.0.0.1:15432/tapes" \
--since 24h --json
The comparison is read-only, and it never prints response content — a difference is reported as a JSON path plus the shape of what differs, because these are real prompts.
Reading the result
Every examined turn lands in exactly one class. equivalent is the one that supports a ratchet step. divergent, undecodable, unreducible and no_reducer all block it: the last three are worse than a divergence, because under raw those rows would carry no reduction at all. The skipped_* classes describe turns the flip does not affect — no bytes were captured, the bytes were withheld or dropped over a limit, or the turn was already captured raw-only.
Two fields are excluded from the comparison, and the report always prints them:
created_at— reducers stamp it at reduction time, so two reductions of identical bytes taken at different instants differ by construction.usage.total_duration_ns— the wall clock from request to fully-assembled response. Only the party that watched the stream can measure it; a reduction of stored bytes cannot.
Everything else is compared strictly, so a third difference is reported rather than absorbed.
Because both excluded fields are ones raw restores from the capture adapter’s meta block rather than from the bytes, the report also counts which stamps would actually have been available. A window can be perfectly equivalent and still lose data on the flip: if usage.total_duration_ns shows fallback, those turns carry no usable meta.elapsed_seconds, and under raw their duration — and the derived span’s duration_ns — would land empty. Check that line before ratcheting, not just the verdict.
Commands not intended as everyday workflow
backfill is for replaying existing capture artifacts into a deployment. dev contains developer maintenance utilities. Consult their --help only when operating those workflows.
Tapes no longer provides chat or checkout commands. It captures external agents; it does not host a chat client or expose history branching.
The start, export, seed, sessions, and skill sync commands have moved to tapesctl; tapes keeps the server and the operator tooling.
MCP
The read API mounts a stateless, streamable HTTP Model Context Protocol endpoint at:
http://localhost:8081/v1/mcp
Configure that URL as a streamable HTTP server in an MCP client. The transport supports POST for JSON-RPC invocation, GET for the stream, and DELETE for session termination semantics.
Cassette tools
The MCP server aggregates tools advertised by installed cassettes. A cassette
marks an OpenAPI operation with x-tapes-mcp; Tapes validates its JSON-body
contract and publishes it under a cassette-qualified name such as
summary.summarize_session. tools/list reads the current cassette registry,
so successfully refreshed cassettes appear without restarting Tapes and removed
cassettes disappear.
Tool arguments are sent as the cassette operation’s JSON request body. A successful JSON object is returned as both MCP structured content and JSON text; non-2xx responses and unavailable cassettes are tool errors. Calls use the same admitted cassette origin and caller identity headers as the cassette HTTP proxy. See Cassettes for the extension and its initial POST-only constraints.
The transport is stateless, so it cannot push reliable out-of-band tool-list
change notifications. A client connected while the cassette fleet changes may
need to reconnect or issue tools/list again.
Legacy core search tool
While search is being extracted to its own cassette, configuring span search and the embedder still registers the core tool below:
| Field | Value |
|---|---|
| Name | search |
| Required input | query string |
| Optional input | top_k integer; defaults to 5 |
The tool embeds the query and uses the same span search implementation as GET /v1/search/spans. Its structured results contain session_id, trace_id, span_id, score, user_prompt, snippet, model, and started_at for each matched main-conversation LLM span.
Example tool arguments:
{
"query": "how was logging configured?",
"top_k": 3
}
Enable it locally
tapes local up
tapes serve
The bundled local setup configures PostgreSQL/pgvector and Ollama embeddings, and tapes serve embeds spans by default. Capture or seed data before searching:
tapesctl seed --tapes-url http://localhost:8081
If search dependencies are not configured, the legacy core search tool is omitted; cassette tools remain available. If storage is configured but the span embedding projection is not initialized, core search reports an error until the embed worker populates it.
Scope
Header-less core MCP search uses the same nil-org tenant bucket as header-less HTTP search. Cassette tools can expose whatever behavior their admitted POST operation implements; MCP annotations are descriptive hints and do not replace gateway or cassette authorization.
HTTP APIs
Tapes publishes two separate contracts because reading derived telemetry and ingesting trusted captures have different trust models.
Read API
The default read API listens on :8081. It serves health, derived data, search, skills, operator maintenance, MCP, and its own OpenAPI contract.
| Area | Routes |
|---|---|
| Health and contract | GET /ping, GET /openapi, /swagger |
| Sessions | /v1/sessions, /v1/sessions/{id}, /v1/sessions/{id}/traces, /v1/sessions/{id}/raw_turns, /v1/sessions/{id}/export |
| Traces and spans | /v1/traces, /v1/traces/{trace_id}, /v1/traces/{trace_id}/spans/{span_id} |
| Search and aggregates | GET /v1/search/spans, GET /v1/stats |
| Skills | /v1/skills and session skill routes |
| MCP | /v1/mcp |
| Operator actions | /v1/admin/derive/run, /v1/admin/seed/demo |
| Cassettes | GET /v1/cassettes, GET /v1/cassettes/{name}/openapi.json, /v1/cassettes/{name}, /v1/cassettes/{name}/* |
The authoritative parameters, schemas, and methods are compiled from route registrations and served by the running API at GET /openapi; no generated contract is checked in. The aggregate includes admitted cassette operations. See Cassettes for their manifest and proxy contract. Notable current behavior:
- session listing is cursor-paginated;
- session and trace/span paths use UUID IDs;
- session content is read through traces and spans;
- semantic search exists only at
/v1/search/spans; - raw turns remain available at
/v1/sessions/{id}/raw_turns.
There is no /v1/search, /v1/sessions/summary, or hash-based session route.
Private ingest API
The private ingest API defaults to :8082 and serves its separate contract at GET /openapi. The all-in-one tapes serve stack starts it alongside the proxy and read API; tapes serve ingest runs it as a standalone sidecar. Its write routes are:
POST /v1/ingest— append one completed conversation turn;POST /v1/ingest/transcript— append transcript capture data;GET /ping— health.
Run the standalone form only for sidecar/gateway capture:
tapes serve ingest --postgres "$TAPES_STORAGE_POSTGRES_DSN"
The ingest server appends to immutable raw_turns; it does not provide the read API. Treat it as a private trusted write surface, not as a public application endpoint. Authentication, network policy, and gateway grants are deployment responsibilities.
Provider proxy
The capture proxy defaults to :8080. It exposes provider-compatible request paths, not the Tapes read contract. Clients send LLM traffic to the proxy; they send inspection/search requests to :8081.
CORS and exposure
Do not infer a production security boundary from local listen defaults or generated OpenAPI. Choose network exposure, TLS, authentication, tenant headers, and access control for the deployment environment. Tapes documentation intentionally does not prescribe a hosting redirect or public deployment topology.
Telemetry
Release builds can send CLI usage events to PostHog when built with a PostHog project key. A source build without that injected key creates no PostHog client.
The implementation uses a random persistent UUID as the PostHog distinct ID. It stores that UUID and a first-run timestamp in telemetry.json under the resolved .tapes/ directory. Common event properties include CLI version, operating system, architecture, and $lib = tapes-cli.
Event-specific properties in the current client include command name, init preset, provider for session creation, search result count, server mode, MCP tool name, and error command/type. The code also defines install and sync events without additional properties.
These are implementation-level event fields, not a claim that the surrounding network or PostHog service cannot observe other transport metadata. Review the code and your PostHog deployment requirements before relying on telemetry privacy properties.
Disable telemetry
Use any one of:
tapes --disable-telemetry status
export TAPES_TELEMETRY_DISABLED=true
[telemetry]
disabled = true
The CLI also disables telemetry when it detects common CI environment variables, including CI, GITHUB_ACTIONS, GITLAB_CI, CIRCLECI, TRAVIS, JENKINS_URL, BUILDKITE, and CODEBUILD_BUILD_ID.
The global flag must precede the subcommand, as in tapes --disable-telemetry serve.
Local development
The repository’s supported workflow is Make-based. Run make help before invoking lower-level tools directly.
Prerequisites
- Go 1.26 or newer;
- Docker for Dagger checks and the pinned PostgreSQL test service;
- Nix with flakes (recommended) or equivalent pinned tools;
- PostgreSQL with pgvector and pg_duckdb for runtime and DB-backed tests;
- Ollama when exercising default local embeddings.
The Nix development shell supplies Go, Dagger, sqlc, hurl, mdBook, and GOEXPERIMENT=jsonv2:
git clone https://github.com/papercomputeco/tapes.git
cd tapes
nix develop
make help
With direnv, direnv allow activates the same shell automatically.
Build and run
make build-local
./build/tapes local up
./build/tapes serve
make build-local sets CGO_ENABLED=0 and GOEXPERIMENT=jsonv2 and writes ./build/tapes. Install it to Go’s binary directory with:
make install
Tests and checks
Run the test suite through Dagger so DB-backed suites receive the pinned PostgreSQL service with pgvector and pg_duckdb:
make test
make check
make format
Do not start an arbitrary stock PostgreSQL container for tests; missing extensions can look like application failures.
Documentation
make docs-build
make docs-serve
The mdBook source is docs/src/, configuration is docs/book.toml, and generated HTML is docs/book/ (ignored by Git). docs-serve rebuilds and serves the book locally.
OpenAPI contracts
Read and ingest routes register their OpenAPI descriptions through the oasfiber wrapper and each running server compiles its contract at GET /openapi. There is no generated contract to update or check in. From a checkout, tapes dev openapi [api|ingest] emits a contract with field prose when a consumer needs bytes on disk.