The manifest, admission rules, and deployment responsibilities for a service that extends the Tapes read API.
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
Section titled “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 /pingGET /openapiGET /api/summary/reportsTapes republishes only the cassette API:
GET /v1/cassettes/summary/reportsThe 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
Section titled “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
Section titled “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
Section titled “cassette/v1alpha1 field reference”Identity
Section titled “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. |
cassette.audience |
no | Client names that should offer this cassette, each a lowercase name of at most 63 bytes. Omitted means every client. See below. |
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. |
Audience
Section titled “Audience”cassette.audience names the clients that should offer a cassette to a person:
[cassette]audience = ["console", "paperctl"]The known client names are console, paperctl, and tapesctl, but the set is
not closed — a cassette must be able to name a client that shipped after the
Tapes release it validates against, so Tapes checks the shape of each name and
not its membership. The cost of that choice is that a misspelled name reads as
an unknown client rather than an error.
Two things it is not:
- It is not authorization. Tapes routes every installed cassette for anyone who can reach it. A client that ignores the field is not bypassing a control, and a cassette that needs a caller kept out must enforce that itself.
- It is not a default. Omitting
audiencemeans every client, because that is what every manifest written before the field existed meant. Declare an audience only to narrow the set.
Discovery publishes the value as audience on each entry, empty for a cassette
that declares none, so a client can filter its own menu without carrying a list
of which cassettes belong in it.
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
Section titled “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.
The v1 contract publishes four views, created by core’s migrations in the
tapes_v1 schema:
| View | Fronts |
|---|---|
tapes_v1.sessions |
the sessions table |
tapes_v1.spans |
the current span projection generation |
tapes_v1.span_turns |
the current span-turn projection generation |
tapes_v1.span_links |
the current span-link projection generation |
The views are the stable names. The physical projection tables behind them are
date-versioned and rotate when a new projection generation lands; the views are
repointed in the same migration, so a cassette granted the views never notices.
Point queries and grants at tapes_v1.*, never at a physical table name.
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
Section titled “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
Section titled “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
Section titled “Configuration schema”A manifest can describe values that the deployment supplies to the cassette:
[[config]]key = "llm.model"type = "string"required = trueenum = ["claude", "other"]description = "Model used to create summaries."
[[config]]key = "batch_size"type = "int"default = 50min = 1max = 500
[[config]]key = "llm.api_key"type = "string"required = truesecret = trueKeys 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_MODELbatch_size -> CASSETTE_BATCH_SIZESupported 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.
Published views and filter claims
Section titled “Published views and filter claims”A cassette can also contribute back to core’s read surface. The publishes
section declares views the cassette creates and maintains for others to join,
and filter query params it claims on core endpoints:
[publishes]views = ["notes_v1.attachments"]
[[publishes.filters]]param = "note" # claimed query paramsurface = "sessions" # core surface it extends (GET /v1/sessions)view = "notes_v1.attachments" # published view the filter probesmatch = { primitive_type = "session", value_column = "value" }normalize = ["trim", "nfc", "casefold"]While the claim is admitted, GET /v1/sessions?note=x executes as an SQL
EXISTS probe against the published view inside core’s own paginated list
query: rows whose primitive_type is session, whose primitive_id equals
the session id (as text), and whose declared match.value_column equals the
supplied value after the declared normalization verbs run in order. Repeating
the param ANDs the predicates, and params claimed by different cassettes
compose the same way: every supplied claimed param filters, ANDed. Core
applies exactly the declared normalization (trim,
nfc, casefold) and never cassette-specific validation, so a value that
could never match returns an honest empty page rather than an error.
Rules, checked at admission with no database access:
- View names are strict identifiers:
schema.view, lowercase snake, at most 63 bytes per segment. Thepublic,tapes*, andpg_*schemas are reserved. A claim’sviewmust be declared inpublishes.views. surfacemust be a surface this core accepts claims on (currentlysessions), andparammust not collide with a core-owned query param on that surface. The reserved set is derived from core’s own route table, so it tracks the actual contract rather than a hand-maintained list.- First claim wins. A second cassette claiming an already-held param has its whole document refused, exactly like any other admission violation, and a violating refresh keeps the holder’s previously admitted claims.
Admission alone does not put a claim into effect. Core arms each admitted
claim by probing the published view with its own database role — one
WHERE FALSE round trip verifying that the view exists, that core may
SELECT from it, and that it carries the contract columns plus the
claim-declared match.value_column. The probe reruns on every refresh pass,
including passes where the cassette’s document is unchanged, so arming
self-heals: create the view or fix the grant, and the claim arms on the next
pass with no re-admission required. Only an answer from the database moves
arming state: a probe that never reaches it (a timeout, a refused
connection) changes nothing — an armed claim stays armed, an un-armed claim
keeps its recorded reason, and arming itself always waits for a definitive
success. If core genuinely cannot reach the database, the filtered queries
fail loudly on their own; a blip must never silently flip filtering
semantics.
Degradation is contract, not accident:
- Unclaimed → invisible. When no admitted cassette claims a param, core ignores it entirely: the response is byte-identical to the same request without the param, indistinguishable from any unknown parameter.
- Admitted but un-armed → invisible, and reported. Until its probe
succeeds, a claimed param behaves exactly as if it were unclaimed — the
response stays byte-identical to the same request without the param — and
the claim is reported under
problems[]inGET /v1/cassettes, filed per claim so an operator can see which param is inert and why. The claim still counts for first-claim-wins: arming gates execution, never ownership. - Armed-then-broken → loud. When an armed claim cannot be evaluated at query time (the view dropped or the grant revoked since the last refresh pass), the filtered request fails with a 500. Core never silently returns unfiltered results as if the filter had been applied.
Like depends.views, publishes is a declaration only: core never touches
grants. The deployment owns granting core’s read role SELECT on the published
view; the derived grant plan carries those views under core_selects. What
core does verify — with its own role, at arming time rather than admission
time — is that the granted view is actually readable, because that is
precisely what the claim asks core to execute. The consumer direction also
exists in the contract: a cassette with a genuinely static, same-license
dependency on another cassette’s published view can declare it under depends.published
(schema-qualified names), which lands in its own selects. A consumer that
must stay decoupled from any particular publisher should take its view names
as deployment configuration instead.
The grant surface around a published schema carries its own discipline, owned by the publisher and the deployment:
- Views only. A published schema exists to be read through schema-wide grants, so only views belong in it: a base table created there would be exposed by the same grants. The publisher enforces this — core admits declarations, it does not inspect the schema’s contents.
- Definer-rights views, deliberately. A published view executes with its
owner’s rights. That is the mechanism that lets a consumer read the
contract without holding any rights on the publisher’s private schema —
so
security_invokermust never be applied to a published view: it would re-evaluate the view with each consumer’s rights and break every consumer. Because the body runs as its owner, keep it narrow: enumerate columns explicitly (neverSELECT *, which would also drift the promised column list) and avoid function calls in the view body. - The read-access trust boundary. Consumers reach published schemas through a deployment-provided readers group; membership is a deployment decision, not a manifest one. Today any platform component in that group may read any published schema — a documented platform-internal trust decision. Per-schema reader groups are the planned least-privilege tightening.
- Publisher grant obligations. The schema owner grants
USAGEon the schema andSELECTon its views to the deployment’s reader roles, with matchingALTER DEFAULT PRIVILEGESso later-created views are covered too. Guard the grants so an absent role is skipped rather than fatal, and keep them idempotent so they can run at every boot.
Entity advertisement
Section titled “Entity advertisement”A cassette that offers entities other cassettes may reference declares them:
[[entities]]type = "note"id_kind = "uuid"display_name = "Note"# optional declared relations — metadata for future aggregation views:# relations = [{ to = "session", kind = "attached_to" }]Core represents its own primitives (session) in the same shape and
publishes the aggregate — core-native plus every admitted cassette’s
declarations — as the discovery document’s entities list. The set updates
as cassettes are admitted and withdrawn, and a re-registration with a changed
manifest replaces that cassette’s declarations. Entity declarations are
digest-relevant manifest content: changing one changes the manifest digest.
The registry is an advisory catalog, never a write gate. A consumer that learns entity types from discovery should accept any shape-valid type whether or not it is currently listed, so a just-registered cassette’s entities work immediately rather than after the next catalog refresh.
Registry-change hooks
Section titled “Registry-change hooks”A cassette that consumes discovery can declare a hook endpoint:
[hooks]registry_changed = "/hooks/registry-changed"Whenever the admitted entity/claim set changes — a cassette admitted, withdrawn, or re-admitted with a changed manifest — core POSTs each admitted cassette’s declared endpoint on that cassette’s own listener, with an empty body. Delivery is best-effort: failures are logged and never affect admission. Treat the hook as a hint to re-crawl discovery immediately, and keep conditional-GET polling as the freshness backstop for missed hooks.
OpenAPI admission rules
Section titled “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.
Request bodies are read whole; response bodies are streamed. A cassette may hold
a response open for as long as it needs to, and each write reaches the client as
the cassette makes it rather than when the response ends — so server-sent event
streams and other long-lived responses work through /v1/cassettes/<name>. A
response that declares a Content-Length keeps it and is framed as one sized
body; anything else is sent chunked.
Two consequences are worth knowing:
- Ask for an event stream by name. Send
Accept: text/event-streamwhen reading one. Tapes compresses responses by default, and compressing a stream re-buffers it; that header is what tells Tapes to leave the stream alone, and it must come from the client because the decision is made before the cassette has answered. - Backpressure belongs to the client. A client that reads slowly slows the cassette rather than accumulating in the Tapes process, so a cassette that streams should expect writes to block.
MCP tool advertisement
Section titled “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
Section titled “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:
cassettes = ["http://127.0.0.1:9999/openapi"]Equivalent CLI configuration is:
tapes serve --cassettes=http://127.0.0.1:9999/openapi# or: TAPES_CASSETTES=http://127.0.0.1:9999/openapi tapes serveTapes 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/cassettescurl http://localhost:8081/v1/cassettes/summary/openapi.jsoncurl http://localhost:8081/openapicurl http://localhost:8081/v1/cassettes/summary/reportsDiscovery 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
Section titled “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_summaryown schema summarySELECT tapes_v1.sessionsSELECT tapes_v1.spansowned table summary.daily_summaryThe 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
Section titled “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
- describe a streaming endpoint with its real media type (
text/event-stream), and document that its clients must send a matchingAcceptheader.