Skip to content
← All posts
EngineeringAugust 26, 2026

The Anatomy of a Cassette

We recently open sourced tapes and introduced cassettes, a powerful way to extend the tapes capabilities across a shared API namespace.

You can capture your AI data, write a custom cassette with bespoke capabilities, and serve that functionality as part of the tapes API on v1/cassettes/.... We use open standards and OpenAPI to power all of this making it an ideal way to integrate with your tooling and your platform. In this blog post, let’s take a deep look at how this all works, how you can build a cassette, and what capabilities this can unlock for you!

Functionality

Let’s build a very simple cassette that returns the user prompt for a given session. You can find this example in the tapes GitHub repo!

Every cassette must serve an API (which then gets re-served on tapes’s proxy API via /v1/cassettes/...). We also need to serve an OpenAPI specification on our cassette API that describes what exactly that API is and how it functions: this is the main contract with the tapes API server and allows for us to provide important metadata downstream about our cassette microservice. This is also the main mechanism for how clients (like tapesctl) discover bespoke capabilities on cassettes for a given tapes server. These mechanisms are the backbone of how functionality in tapes can be extended to fit your needs.

First, let’s implement this in an API like so:

GET /api/prompt/{session_id}

where session_id is the identifier in tapes of a captured session. The tapes session API is served at v1/sessions/ and sessions can be listed with the tapesctl sessions list command.

The Python code for this is relatively simple. We can assume we have access to the tapes server API from the cassette in order to use the v1/traces API:

response = client.get(
   f"{tapes_base_url}/v1/traces",
   params={"session_id": session_id},
)

for trace in response.json().get("items", []):
   if prompt := trace.get("user_prompt"):
       return {"session_id": session_id, "prompt": prompt}

This will query the data for that session ID, iterate the captured session turns, and returns the first user_prompt it sees.

Next, we need to describe this API using OpenAPI in a root level /openapi endpoint:

GET /openapi

There is a wealth of tooling around OpenAPI and lots of options for you to choose from given your selected framework, language, and environment. In our example here, it’s easy enough to hard code the OpenAPI JSON but for a more robust solution, I recommend picking an HTTP framework and middleware that can dynamically generate the OpenAPI specification based on your code.

There are a few important bits in the cassette’s OpenAPI spec worth calling out: first, the metadata and x-tapes-cassette extension:

{
  "openapi": "3.0.3",
  "info": {
    "title": "Prompt Cassette",
    "description": "Returns the first captured user prompt for a tapes session.",
    "version": "0.0.1"
  },
  "x-tapes-cassette": {
    "kind": "cassette/v1alpha1",
    "cassette": {
      "name": "prompt",
      "version": "0.0.1",
      "display_name": "Prompt",
      "description": "Returns the first captured user prompt for a tapes session.",
      "license": "Apache-2.0",
      "homepage": "https://github.com/papercomputeco/prompt-cassette",
      "image": "tapes/prompt-cassette:0.0.1",
      "port": 9999
    },
    "depends": {
      "core": "v1",
      "views": []
    },
    "api": {
      "health": "/ping",
      "openapi": "/openapi",
      "prefix_path": "api"
    },
    "config": [
      {
        "key": "tapes_base_url",
        "type": "string",
        "default": "http://127.0.0.1:8081",
        "description": "Base URL of the tapes core API."
      }
    ]
  }
}

The x-tapes-cassette is the cassette’s manifest data that the tapes API server will use when discovering and admitting the running cassette. It contains important information about what version of tapes the cassette needs, what tables it reads, how it runs, how it’s been configured, and what APIs it provides. Importantly, it advertises the prefix path tapes will trim when proxying this API as well as where the “healthy” endpoint is and where the OpenAPI spec is served from.

Next, in the actual API route definition, we describe the actual implementation:

"/api/prompt/{session_id}": {
  "get": {
    "operationId": "get",
    "parameters": [
      {
        "in": "path",
        "name": "session_id",
        "required": true,
        "schema": {
          "format": "uuid",
          "type": "string"
        }
      }
    ],
    "responses": {
      "200": {
        "content": {
          "application/json": {
            "schema": {
              "properties": {
                "prompt": {
                  "type": "string"
                },
                "session_id": {
                  "type": "string"
                }
              },
              "required": [
                "prompt",
                "session_id"
              ],
              "type": "object"
            }
          }
        },
        "description": "The prompt"
      }
    }
  }
}

This is a simple descriptor of how our /api/prompt/{session_id} capabilities work and will be re-served on the tapes API.

Running it

Now that we have implemented the cassette’s capabilities, we can run the stack to see it in action.

First, we need to stand up a Postgres that the tapes server can talk to and manage. We provide the necessary Postgres extensions and capabilities in a bundled image you can run with Docker:

docker run --rm --name tapes-postgres \
  -e POSTGRES_DB=tapes \
  -e POSTGRES_USER=tapes \
  -e POSTGRES_PASSWORD=tapes \
  -p 5432:5432 \
  public.ecr.aws/g4e5l3z3/papercomputeco/postgres:17.7-pgduckdb-1.1.1

We can then run our cassette via Python which serves its API on port 9999:

python main.py

Next, we can start tapes by running:

tapes serve \
  --postgres postgres://tapes:tapes@localhost:5432/tapes?sslmode=disable \
  --cassettes localhost:9999/openapi

The cassettes flag is what signals to tapes which OpenAPI spec to scrape and serve on its reverse proxy. In the logs, we can see that the prompt cassette is “admitted” via this log line:

time=2026-08-25T12:24:20.952Z level=INFO msg="admitted cassette OpenAPI source" source=http://prompt:9999/openapi cassette=prompt

End to end, we can now see that the cassette is being served on the tapes API via v1/cassettes/prompt/... and is discoverable on the tapes OpenAPI spec on /openapi and the admitted cassette manifest at /v1/cassettes.

This results in tapesctl automatically discovering and surfacing these capabilities:

tapesctl cassette prompt get 550e8400-e29b-41d4-a716-446655440000
{
  "prompt": "Describe to me how tapes cassettes work?",
  "session_id": "550e8400-e29b-41d4-a716-446655440000"
}

Running all these APIs and services is a bit cumbersome. Be sure to check out the Docker Compose files in our examples that demonstrate bringing up the whole stack locally with a single docker compose up.

MCP

The tapes API server also includes an MCP server where cassette capabilities as tools can be configured. All we need to do to add an MCP tool for our cassette is define an x-tapes-mcp extension on a new POST route. This new route can have same capabilities and internal implementation as the GET, but takes a session_id in an application/json body as part of the request body:

"/api/prompt/get": {
  "post": {
    "operationId": "getPromptTool",
    "summary": "Get a session's first user prompt",
    "tags": [
      "prompt"
    ],
    "x-tapes-mcp": {
      "name": "get_prompt",
      "annotations": {
        "readOnlyHint": true,
        "idempotentHint": true,
        "openWorldHint": false
      }
    },

    // The API schema definition for POST /api/prompt/get
    // which gets turned into a tool call args

    "requestBody": {
      "required": true,
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "additionalProperties": false,
            "required": [
              "session_id"
            ],
            "properties": {
              "session_id": {
                "type": "string",
                "format": "uuid"
              }
            }
          }
        }
      }
    }

    // etc. etc. the response body definition
  }
}

We can see this MCP tool materialize on the MCP server once we refresh the servers and tapes re-admits the cassette:

{
  "tools": [
    {
      "name": "prompt.get_prompt",
      "title": "Get a session's first user prompt",
      "description": "Get a session's first user prompt",
      "inputSchema": {
        "type": "object",
        "properties": {
          "session_id": {
            "format": "uuid",
            "type": "string"
          }
        },
        "required": [
          "session_id"
        ],
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "additionalProperties": false
      },
      "annotations": {
        "readOnlyHint": true,
        "idempotentHint": true,
        "openWorldHint": false
      }
    }
  ]
}

An agent can then automatically discover, take advantage of, and utilize these capabilities from the tapes MCP server.

Fin

Just a reminder that a cassette doesn’t run inside tapes and doesn’t need to share its implementation language or deployment model. As long as it satisfies the cassette contract, tapes can discover and expose it. That gives us room to move capabilities like search and skills out of the core without making them second-class features. This is why we’re very excited to see what people build with cassettes. Our team has already built some very interesting things that extend, modularize, and enhance AI data captured through tapes. Read more about how cassettes work and how you can build your own on our docs. The sky’s the limit with cassettes: if you can dream it up with your AI data, you can do it with cassettes.

Found this useful? Share it.
ShareY

Start with paper

Turn every session into knowledge at team scale.

Get started