# Inspect and export

> List sessions, browse traces and raw_turns, enable the optional API web UI, and stream GET /v1/sessions/{id}/export as JSONL.

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

## Source Files

- `api/sessions_handlers.go`
- `api/trace_browse_handlers.go`
- `api/web_ui.go`
- `api/openapi_routes.go`
- `docs/data.md`
- `cmd/tapes/status/status.go`

---

---
title: "Inspect and export"
description: "List sessions, browse traces and raw_turns, enable the optional API web UI, and stream GET /v1/sessions/{id}/export as JSONL."
---

The read API on `:8081` is the inspect surface. `tapesctl` prints that JSON verbatim; `GET /` is an optional same-origin browser that hits the same routes; `GET /v1/sessions/{id}/export` streams the derived session → traces → spans projection as `application/x-ndjson`. Conversation content lives on traces and spans, not on `GET /v1/sessions/{id}`. Session, trace, and span IDs are UUIDs.

<Info>
`tapes` owns the database and serves the API. Listing, browsing, and exporting are client operations against a running read API. Capture and ingest stay on `:8082`.
</Info>

```mermaid
flowchart LR
  subgraph Clients
    CTL["tapesctl sessions / export"]
    UI["GET / optional web UI"]
    HTTP["curl / HTTP"]
  end
  subgraph API[":8081 read API"]
    SESS["/v1/sessions"]
    TR["/v1/sessions/{id}/traces<br/>/v1/traces/{trace_id}"]
    RAW["/v1/sessions/{id}/raw_turns"]
    EXP["/v1/sessions/{id}/export<br/>/v1/sessions/export"]
  end
  subgraph Store[Postgres]
    ROLL["sessions rollups"]
    SPAN["traces / spans / links"]
    LOG["raw_turns headers"]
  end
  CTL --> API
  UI --> SESS
  UI --> TR
  HTTP --> API
  SESS --> ROLL
  TR --> SPAN
  EXP --> SPAN
  EXP --> ROLL
  RAW --> LOG
```

## Prerequisites

<Steps>
<Step title="Start the stack">
```bash
tapes local up
tapes serve
```

Default listen addresses: proxy `:8080`, read API `:8081`, ingest `:8082`.
</Step>
<Step title="Point the client at the read API">
```bash
tapesctl config set tapes-url http://localhost:8081
```

`--tapes-url` overrides that value. If neither is set, `tapesctl` falls back to `TAPES_URL`.
</Step>
<Step title="Confirm the API is reachable">
```bash
tapes status
```

`tapes status` prints the resolved `.tapes/` directory, provider → upstream, whether `storage.postgres_dsn` is set, and the configured `client.api_target` (default `http://localhost:8081`). It probes `GET /v1/stats` with a 3s timeout. A live API prints `N sessions · M turns · $X.XXXX captured`. An unreachable target tells you to run `tapes local up` then `tapes serve`.
</Step>
</Steps>

<Note>
Use the Tapes session UUID from `tapesctl sessions list` / `GET /v1/sessions`. That is not the harness session id printed when `tapesctl start` exits. To resolve a harness id, pass `harness_session_id` (optionally with `harness_id`) on `GET /v1/sessions`.
</Note>

## List sessions

### tapesctl

```bash
tapesctl sessions list --tapes-url http://localhost:8081
tapesctl sessions list --limit 20
tapesctl sessions get <session-id>
```

Each command prints the server JSON as-is, so it composes with `jq`.

### GET /v1/sessions

:::endpoint GET /v1/sessions Cursor-paginated session list
Returns one `SessionItem` per harness session from the sessions table. Default order is `last_active` (`last_seen_at`) descending. Default `limit` is 50; the server clamps anything above 200.

Query parameters:

| Param | Type | Notes |
| --- | --- | --- |
| `limit` | integer ≥ 1 | Default 50, max 200 |
| `cursor` | string | Opaque keyset cursor from the previous page; must match the current `sort`/`direction` |
| `sort` | string | `last_active` (default), `started_at`, `turn_count`, `total_cost_usd`, `total_tokens`, `duration_ns`, `derived_status`, `auth_subject` |
| `direction` | `asc` \| `desc` | Default `desc` |
| `since` / `until` | RFC3339 | Activity window (turn started at); same window semantics as `GET /v1/stats` |
| `auth_subject` | string | Exact match on the gateway-stamped JWT subject stored at ingest. Filter only — not an identity claim |
| `harness_session_id` | string | Exact-match lookup; skips pagination. Alone, matches across harnesses (at most one row per harness) |
| `harness_id` | string | Only valid with `harness_session_id`. Alone is `400` |

Harness-filter requests reject `cursor`, `sort`, `direction`, `since`, and `until` (`400`). `limit` is ignored on that path. No match is `{ "items": [] }`, not `404`.

A `501` means the storage driver does not implement the sessions table (Postgres does).
:::

Each list item splits capture identity from the deriver rollup:

<ResponseField name="id" type="string">Tapes session UUID.</ResponseField>
<ResponseField name="harness_id" type="string">Capture harness (`claude`, `codex`, …).</ResponseField>
<ResponseField name="harness_session_id" type="string">Harness-native session id.</ResponseField>
<ResponseField name="display_title" type="string">Server-resolved label. Precedence: `display_name` → `rollup.title` → non-JSON `rollup.preview` → `name` → 12-char `harness_session_id` slice → session id. Never empty. Render this, not `name`.</ResponseField>
<ResponseField name="display_name" type="string">User rename from `PATCH /v1/sessions/{id}`. Survives ingest re-sending the harness slug.</ResponseField>
<ResponseField name="name" type="string">Harness slug / folded identity-row label. Ingest re-sends it every turn.</ResponseField>
<ResponseField name="live" type="boolean">`true` when `ended_at` is null and `last_seen_at` is within 5 minutes. Not derived from `rollup.status`.</ResponseField>
<ResponseField name="rollup" type="object">Deriver-owned status, title, preview, `turn_count`, dominant `model`, `model_usage`, `kind_counts`, `tasks`, and `usage` (`input_tokens`, `output_tokens`, `cost_usd`). Zero/empty until the session first derives.</ResponseField>
<ResponseField name="next_cursor" type="string">Present when another page exists.</ResponseField>

<RequestExample>
```bash
curl -sS 'http://localhost:8081/v1/sessions?limit=20'
curl -sS 'http://localhost:8081/v1/sessions?harness_session_id=<harness-session-id>'
```
</RequestExample>

<ResponseExample>
```json
{
  "items": [
    {
      "id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
      "harness_id": "claude",
      "harness_session_id": "sess_01ABC",
      "display_title": "Retry backoff in the proxy",
      "live": false,
      "rollup": {
        "status": "completed",
        "turn_count": 4,
        "model": "claude-sonnet-4-5",
        "kind_counts": { "llm": 6, "tool": 3 },
        "tasks": [],
        "usage": { "input_tokens": 12000, "output_tokens": 800, "cost_usd": 0.042 }
      }
    }
  ],
  "next_cursor": "..."
}
```
</ResponseExample>

`GET /v1/sessions/{id}` returns `{ "session": SessionItem }` only. A malformed id is `400` (`id must be a valid UUID`); unknown id is `404`.

`PATCH /v1/sessions/{id}` updates `display_name` (max 200 characters after trim). Null or empty clears the rename so `display_title` falls back to the derived title. `DELETE /v1/sessions/{id}` removes the session and cascading derived traces/spans (including child subagent sessions) and leaves the immutable `raw_turns` log intact.

## Browse traces and spans

Initial paint is O(turns), not O(session). Load turn headers, then expand one trace, then drill into one span.

| Route | Returns |
| --- | --- |
| `GET /v1/sessions/{id}/traces` | Composite: `schema`, `session`, `traces[]` (`trace` + `spans`), session-scoped `links` |
| `GET /v1/traces?session_id=` | Turn summaries only (`TraceListResponse`). `session_id` is required and must be a UUID |
| `GET /v1/traces/{trace_id}` | One turn: spans nested by `parent_span_id`, plus links that touch this trace |
| `GET /v1/traces/{trace_id}/spans/{span_id}` | One span with full `input` / `output` payloads |

`?payload=full` (default) embeds stored content blocks. `?payload=preview` truncates payload strings to 512 runes and sets `payload` so a client can fetch the span endpoint. Anything other than `preview` is treated as full.

Trace headers carry `trace_id`, `user_prompt` (always present; empty means a synthetic opener), `response_preview`, `status`, `source` (`wire` or `transcript`), timestamps, `span_count`, `usage` (all LLM spans including shadow), `main_usage` (task slice: main + subagents), and optional `synthetic` (`post-compaction`, `shadow-opener`).

Every composite and export line stamps `"schema": "2026-06-15"` — the projection generation currently served.

<CodeGroup>
```bash title="tapesctl"
tapesctl sessions traces <session-id>
```

```bash title="curl"
curl -sS "http://localhost:8081/v1/sessions/<session-id>/traces?payload=preview"
curl -sS "http://localhost:8081/v1/traces?session_id=<session-id>"
curl -sS "http://localhost:8081/v1/traces/<trace-id>?payload=full"
curl -sS "http://localhost:8081/v1/traces/<trace-id>/spans/<span-id>"
```
</CodeGroup>

<Tip>
`GET /v1/stats` is the same accounting the session and trace views use: `session_count`, `turn_count` (traces), `completed_count`, `total_cost`, `input_tokens`, `output_tokens`, `total_duration_ms` (summed agent time, not wall-clock idle), `tool_calls`. Optional `since` / `until` / `auth_subject` match the session list window.
</Tip>

## Browse raw_turns

`GET /v1/sessions/{id}/raw_turns` is the operator wire log: one header per captured call or transcript push, keyed by the session's `(harness_id, harness_session_id)`. It does not return request/response blobs.

<ResponseField name="id" type="integer">`raw_turns` row id.</ResponseField>
<ResponseField name="source" type="string">`wire` vs `transcript`.</ResponseField>
<ResponseField name="provider" type="string">Capture provider, when known.</ResponseField>
<ResponseField name="agent_name" type="string">Agent label from the capture envelope.</ResponseField>
<ResponseField name="request_id" type="string">Capture request id, when present.</ResponseField>
<ResponseField name="received_at" type="string">Ingest timestamp.</ResponseField>
<ResponseField name="meta" type="object">Envelope metadata.</ResponseField>
<ResponseField name="request_bytes" type="integer">Captured request size.</ResponseField>
<ResponseField name="response_bytes" type="integer">Captured response size.</ResponseField>

```bash
tapesctl sessions raw-turns <session-id>
curl -sS "http://localhost:8081/v1/sessions/<session-id>/raw_turns"
```

There is no public GET for a single raw-turn payload. Use this list to correlate `span.raw_turn_id` with what crossed the wire. Attribution repair (`POST /v1/admin/raw-turns/attribution-repair`) overlays a correction without rewriting `raw_turns`.

## Optional API web UI

`api.web_ui` defaults to `false`. When enabled, the API binary serves embedded HTML at `GET /` — no frontend build, no external scripts. Disabled, `GET /` is `404`. The route is HTML, not OpenAPI surface.

<Tabs>
<Tab title="All-in-one">
```bash
tapes serve --api-web-ui
```
</Tab>
<Tab title="Standalone API">
```bash
tapes serve api --web-ui --listen :8081
```
</Tab>
<Tab title="config.toml / env">
```toml
[api]
web_ui = true
```

`TAPES_API_WEB_UI=true`. Precedence: flag → `TAPES_*` → `config.toml` → default `false`.
</Tab>
</Tabs>

Open `http://localhost:8081/`. Deep-link a session with `/?session=<uuid>`. The page:

- lists `GET /v1/sessions?limit=50`
- shows `GET /v1/stats` (`session_count`, `turn_count`, `total_cost`)
- loads `GET /v1/sessions/{id}/traces?payload=preview` and prints the selected turn header as JSON
- can `POST /v1/admin/seed/demo` (operator seed)
- links to `/swagger` and `/metrics`

It does **not** search, export, or list `raw_turns`. Use `tapesctl` or the HTTP routes for those.

<Warning>
The bundled UI is a Prometheus-style operator pane, not the product console. Prefer `display_title` and `rollup.*` in any client you write; those are the current list/detail fields.
</Warning>

## Export JSONL

`tapesctl export` is a thin client of `GET /v1/sessions/{id}/export`. It does not keep a separate renderer.

```bash
tapesctl export <session-id> --tapes-url http://localhost:8081 -o session.jsonl
tapesctl export <session-id> --detail traces
```

`-o` writes the bundle to a file and the byte count to stderr so stdout stays clean. Without `-o`, the body goes to stdout.

### GET /v1/sessions/{id}/export

:::endpoint GET /v1/sessions/{id}/export One session as a single JSONL line
`detail=spans` (default, or omitted) emits the same nested object as `GET /v1/sessions/{id}/traces?payload=full`: `{ schema, session, traces: [{ trace, spans }], links }`. Spans are loaded one trace at a time so peak memory stays at one trace, not the whole session.

`detail=traces` emits `{ schema, session, traces: [{ trace }] }` with no `spans` or `links` keys (omitted, not empty). Span-derived `tasks` / `kind_counts` are omitted at this grain.

Unknown `detail` is `400` (`detail must be spans or traces`). Missing session is `404` with no attachment headers. The handler buffers the line first, then sets:

- `Content-Type: application/x-ndjson`
- `Content-Disposition: attachment; filename="session-<id>-<YYYY-MM-DD>.jsonl"`

`detail=traces` rewrites the filename to `session-<id>-<date>-traces.jsonl`.
:::

<RequestExample>
```bash
curl -sS -D - \
  "http://localhost:8081/v1/sessions/<session-id>/export" \
  -o session.jsonl

curl -sS \
  "http://localhost:8081/v1/sessions/<session-id>/export?detail=traces" \
  -o session-traces.jsonl
```
</RequestExample>

### GET /v1/sessions/export

Bulk export of every session in a window, one JSON object per line, newest-first. Same grains as the per-session route. Not bounded by the list cap of 200 — the handler pages internally at that size and flushes after each session.

| Query | Behavior |
| --- | --- |
| `since` | RFC3339 lower bound. Default and **floor**: now − 30 days. An older `since` is clamped; you cannot stream the whole history |
| `until` | RFC3339 exclusive upper bound. Must be after the effective `since` |
| `detail` | `spans` (default) or `traces` |

Default filename: `sessions-last-30-days-<date>.jsonl`. An explicit window becomes `sessions-<since>-to-<until|now>.jsonl`. Headers are committed before the body; a mid-stream failure is logged and the stream stops — it cannot become a JSON error.

<Warning>
Register `/v1/sessions/export` before `/v1/sessions/:id`. Fiber matches in order; a parameterized route would otherwise swallow the literal `export` path.
</Warning>

A bulk export is a concatenation of per-session export lines. Reads are pinned to the deployment's single tenant; a client-asserted org header does not change the lookup.

## Errors and verification

JSON errors use `{ "error": "<message>" }`.

| Status | Typical cause |
| --- | --- |
| `400` | Malformed UUID, bad `limit`/`sort`/`direction`/`cursor`, `since`/`until` not RFC3339, harness filter combined with list options, lone `harness_id`, `detail` not `spans`/`traces`, bulk `until` not after `since` |
| `404` | Unknown session or trace/span; web UI disabled (`GET /`) |
| `500` | Storage or render failure |
| `501` | Driver does not implement sessions or the span model |

<Check>
After seed or a captured session:

1. `tapes status` shows a non-zero session count.
2. `tapesctl sessions list` returns a UUID whose `display_title` is non-empty.
3. `GET /v1/sessions/{id}/traces` has `"schema":"2026-06-15"` and one item per user-visible turn.
4. `GET /v1/sessions/{id}/export` is `application/x-ndjson` and `jq -c . session.jsonl` parses one object with `session` and `traces`.
</Check>

Empty traces usually means the session has not derived yet. `POST /v1/admin/derive/run` rebuilds the projection from `raw_turns`. Seed demo data with `tapesctl seed` or `POST /v1/admin/seed/demo`.

Browse the live contract at `http://localhost:8081/swagger` or `GET /openapi`.

## Next

<CardGroup>
<Card title="Sessions, traces, and spans" href="/sessions-traces-spans">
Deterministic IDs, rollups, span kinds, and how subagent threads rejoin.
</Card>
<Card title="Read API" href="/read-api">
Compiled GET /openapi for :8081, including CONTRACT seal rules.
</Card>
<Card title="Read API vs ingest" href="/read-vs-ingest">
Why inspect stays on :8081 and capture writes stay on :8082.
</Card>
<Card title="Search spans" href="/search-spans">
Semantic search over embedded main-conversation LLM spans.
</Card>
<Card title="CLI reference" href="/cli-reference">
tapes serve, status, and the tapes vs tapesctl split.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Wrong-port capture, 413s, attribution-repair 200 vs 202.
</Card>
</CardGroup>
