---
title: Configuration
description: How tapesctl resolves a server URL, the config.toml schema, and every file and directory it reads or writes.
editUrl: https://github.com/papercomputeco/tapesctl/edit/main/docs/configuration.md
sourceRepo: papercomputeco/tapesctl
sourcePath: docs/configuration.md
lastUpdated: 2026-08-19
---

`tapesctl` has separate read-API and ingest-API settings in one file. This
page covers how they resolve, what else lives beside them, and where diagnostics
go.

## Resolving server URLs

Both endpoints have local defaults:

| purpose | default | flag | environment | config key |
|---|---|---|---|---|
| read API | `http://localhost:8081` | `--api-url` | `TAPES_API_URL` | `api-url` |
| ingest API | `http://localhost:8082` | `--ingest-url` | `TAPES_INGEST_URL` | `ingest-url` |

For either endpoint, an explicit flag beats its environment variable, which
beats its configured value, which beats the localhost default.

**There is no project-local layer.** No `.tapesrc`, no directory walk, no
per-repository override. One user-level file, one variable, one flag.

Three mechanics that are easy to get wrong:

- **Leaf position beats global position.** `tapesctl --api-url A sessions
  list --api-url B` uses `B`.
- **The configured value is installed as the flag's default**, rather than
  resolved by hand. So precedence is the argument parser's own, and a default
  does not count as a user-supplied argument — which is why a bare `tapesctl`
  still prints help on a configured machine instead of complaining about a
  missing subcommand.
- **The global `--api-url` deliberately carries no environment binding.**
  The parser counts an environment-sourced value as user-supplied, so binding
  `TAPES_API_URL` at the top level would make a bare `tapesctl` answer `error:
  requires a subcommand` on any machine with the variable exported. The
  per-command declarations carry the binding instead, so the fallback still
  works everywhere it matters.

Cassette discovery resolves the same three sources itself, because it runs
before arguments are parsed. See [Cassettes](/docs/tapesctl/cassettes/).

Configure a remote deployment once when its ports differ from the defaults:

```bash
tapesctl config set api-url https://tapes.example/api
tapesctl config set ingest-url https://tapes.example/ingest
```

See [The two ports](/docs/tapesctl/introduction/#the-two-ports) for which command is on
which side.

## config.toml

The path is `~/.tapes/config.toml`, resolved once and nowhere else. Ask for it
rather than assuming:

```bash
tapesctl config path
```

```
/Users/you/.tapes/config.toml
```

`config path` prints the path whether or not the file exists.

The schema has one key per endpoint:

```toml
# ~/.tapes/config.toml
api-url = "http://localhost:8081"
ingest-url = "http://localhost:8082"
```

| key | type | meaning | validation |
|---|---|---|---|
| `api-url` | string | read API | must parse as a URL **and** use scheme `http` or `https` |
| `ingest-url` | string | ingest API | must parse as a URL **and** use scheme `http` or `https` |

Setting either:

```bash
tapesctl config set ingest-url http://localhost:8082
```

Validation happens at write time rather than on every command afterwards, and a
rejected value writes nothing:

```
tapesctl: unknown config key "tapes-erl" (known keys: api-url)
tapesctl: invalid tapes URL
tapesctl: api-url must be an http or https URL; "ftp" is not a scheme this client can call
```

**The file is deliberately not under `$XDG_CONFIG_HOME`.** It sits beside
`~/.tapes/logs`, `~/.tapes/skills`, and `~/.tapes/codex-app` so there is one
directory to inspect, back up, or delete.

### Rules that are invisible from the help text

- **Unknown keys are preserved, not refused.** Reading ignores them, and
  writing edits the TOML document in place rather than re-serializing it — so
  comments, ordering, your formatting, and keys a newer `tapesctl` wrote all
  survive a `config set`.
- **A malformed file fails only the `config` commands.** They surface a parse
  error; every other command loads with a fallback, warns at `-v`, and
  continues with an empty configuration.
- **`config set` never reads before it writes**, so it can repair a known key
  holding a wrong-typed value. Structurally broken TOML is still refused rather
  than clobbered.
- **`config get` can print nothing from a file that is not empty.** Only keys
  that are both *known* and *set* are listed. A file containing only a key this
  build has never heard of produces empty output — the forward-compatibility
  rule working as designed, and indistinguishable from an empty file.
- **A known-but-unset key prints nothing and exits `0`**, so
  `$(tapesctl config get api-url)` is empty rather than an error a script has
  to special-case.

## Logging

One rule governs everything here: **while a harness holds the terminal, nothing
may reach stdout or stderr.** A stray log line lands in the middle of a TUI
frame.

So diagnostics go to a file when, and only when, the command hands over the
terminal *and* verbosity is at its default. In practice that is `start` without
`-v`. Every other command — `sync`, `capture`, the read commands — logs to
stderr as usual.

```
~/.tapes/logs/start-YYYYMMDD-HHMMSS-<pid>.log
```

Files are created `0600` and appended to, never truncated. The path is printed
before the harness launches and again when it exits.

Pass `-v` to `start` to stream to stderr instead of a file, accepting what that
does to the display. That is the documented way to watch a capture live.

Level precedence is `RUST_LOG`, then the `-v` count, then `info`. A set-but-empty
`RUST_LOG` is treated as unset. An unparseable one prints
`tapesctl: ignoring invalid RUST_LOG <directive> (<err>)` to stderr and falls
back.

**There is no stderr fallback when the log file cannot be opened.** The run
prints `tapesctl: diagnostics disabled — no log file (<err>)` once, then
discards events. A corrupted TUI is judged more costly than a lost debugging
session.

## Files and directories

What `tapesctl` writes:

| path | written by |
|---|---|
| `~/.tapes/config.toml` | `config set` |
| `~/.tapes/logs/start-*.log` | `start`, at default verbosity |
| `~/.tapes/codex-app/handoff.json` | `plugin install codex-app` |
| `~/.tapes/codex-app/plugin/` | `plugin install codex-app` |
| `~/.pi/agent/extensions/tapes-gateway.ts` | `plugin install pi` |
| `~/.config/opencode/plugins/tapes-gateway.ts` | `plugin install opencode` |
| `~/.codex/config.toml` (or `$CODEX_HOME/config.toml`) | `plugin install`/`uninstall codex-app`, patched in place |
| `<platform cache>/tapesctl/cassettes/<key>.json` | `cassettes`, `help`, and bare invocations — the only shapes that run cassette discovery |

Skill documents, log files, and installed plugin files are written `0600`.

What it reads but never writes:

| path | read by |
|---|---|
| `~/.claude/projects/` | the transcript tailer and `sync` |
| `~/.claude/sessions/<pid>.json` | Claude attribution |
| `$CODEX_HOME/sessions`, or `~/.codex/sessions` | Codex attribution |

## Environment variables

| variable | read by |
|---|---|
| `TAPES_API_URL` | read commands and cassette discovery |
| `TAPES_INGEST_URL` | `start`, `capture`, `sync` |
| `TAPES_UPSTREAM` | `start`, `capture` |
| `TAPES_WEB_URL` | `start`, `capture` |
| `TAPES_ORG_ID` | `start`, `capture` |
| `TAPES_AUTH_SUBJECT` | `start`, `capture`, `sync` |
| `RUST_LOG` | logging, all commands |
| `TAPESCTL_CACHE_DIR` | the cassette surface cache |
| `CODEX_HOME` | `plugin install`/`uninstall codex-app`, `capture codex-app`, `start codex` |
| `USER`, then `USERNAME` | the default `--auth-subject`: `local:<user>`, else `local:unknown` |
| `OPENAI_API_KEY` | `start codex` upstream selection |

There is no telemetry variable, because there is no telemetry. `tapesctl`
reports nothing about you anywhere.

**The parent environment is inherited wholesale by a launched harness.**
`start` clears nothing, so a variable set in your shell reaches the harness
unchanged.

## Commands that ignore `--api-url`

Because the global flag propagates into every leaf's help, `--api-url` is
rendered for commands that never make an HTTP call: `config set`, `config get`,
`config path`, `version`, and `plugin uninstall`.
It is inert in all of them.

The reverse is worth stating too: `plugin install` and `plugin uninstall` write
local files from bytes the binary already carries and fetch nothing.