1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
# Ramekin

Containerized harness for running coding agents: the [pi coding agent](https://github.com/badlogic/pi-mono) or [Claude Code](https://github.com/anthropics/claude-code).

## Quick start

```sh
ramekin                # run the default profile (pi) against the current directory
ramekin -p claude      # run Claude Code instead
ramekin /some/path     # mount a specific workspace
ramekin run --rebuild  # force a full image rebuild
ramekin -- --model X   # forward extra args to the agent
```

Ramekin builds a Docker image with the agent and its dependencies, starts it via Docker Compose, and attaches your terminal. Auth tokens and session history persist across runs.

## How it works

A Rust CLI orchestrates a Docker Compose stack. On each run it:

1. Resolves the active profile (which picks the agent) and merges config layers
2. Builds the base image (`ramekin-agent`, carrying both agents), and a project-specific layer if one exists
3. Generates a compose config, renders the system prompt, and creates fresh agent dirs, all in a session-scoped cache directory
4. Starts the agent container with the workspace mounted at `/workspace/<slug>` (where `<slug>` is `<dirname>-<hash>`, so cwd-keyed agent state never collides across repos)
5. Attaches interactively, then tears down on exit — logging any state the agent wrote to its session-scoped dirs before discarding it, and keeping any config proposals the agent left in its outbox

Concurrent sessions don't interfere: everything a run touches is either read-only config, session-scoped plumbing under a random session id, or agent state the agent itself manages concurrently.

### Subcommands

`run` (default) starts a containerized agent session. Pass `--rebuild` to ignore Docker layer cache and pull fresh base images, `-p <profile>` to pick a profile for this run.

`config` prints the active profile, resolved paths, volume mounts, and Dockerfile status without starting (or mutating) anything — useful for debugging mount issues.

`outbox` reviews config changes proposed by agents — see [Outbox](#outbox).

`completions <shell>` generates shell completions for bash, zsh, fish, elvish, or powershell. Pipe the output to a file sourced by your shell:

```sh
ramekin completions zsh > ~/.zfunc/_ramekin
ramekin completions bash > ~/.local/share/bash-completion/completions/ramekin
```

### Profiles

A profile is a named bundle of agent + provider plumbing: which agent to run, env vars (with host passthrough for credentials), and extra mounts. The binary ships two trivial profiles — `pi` and `claude`, bare agents with no provider plumbing — so ramekin runs with zero config. Everything richer is defined in KDL:

```kdl
// e.g. ~/.config/ramekin/profiles.kdl — symlinked from dotfiles, shared
profile "claude-bedrock" {
    agent "claude"
    env {
        CLAUDE_CODE_USE_BEDROCK "1"
        AWS_PROFILE              // bare = pass the host's value through
    }
    mounts { "~/.aws" }
}

profile "pi-glm" {
    agent "pi"
    env {
        ANTHROPIC_BASE_URL "https://open.bigmodel.cn/api/anthropic"
        ZHIPU_API_KEY
    }
}
```

A bare `profile "name"` node (no block) *selects* a profile. Selection precedence, lowest to highest: binary default (`pi`) → user KDL (the per-machine default) → project KDL → `-p` on the command line. Profile selection subsumes agent selection; there is no separate `--agent`. Model choice within a provider stays out of profiles — that's per-run agent args after `--`.

Profiles merge by name across layers and the last writer takes the whole definition. Fine-grained tweaks (one env var) go through the ordinary layered `env`, which overlays the active profile's env per variable.

Provider credentials never appear in config: passthrough env forwards host values at run time, mounts carry their own files, and OAuth lives in the container's persistent agent state.

### Agent config

Agent config comes from the host's own dirs — the agents are also used locally, so their config already exists where they look for it. The config-shaped entries mount read-only at their normal paths inside the container:

- pi: `~/.pi/agent/` — `AGENTS.md`, `settings.json`, `models.json`, `keybindings.json`, `extensions/`, `skills/`
- claude: `~/.claude/` — `CLAUDE.md`, `settings.json`, `skills/`, `agents/`, `commands/`, `hooks/`

Ramekin keeps no parallel copy — edit the host files (or the dotfiles they symlink to) and the next session sees the changes. The rest of each host dir is runtime state (credentials, transcripts) and never enters the container. Project-level agent config (`.claude/`, `CLAUDE.md`, `AGENTS.md` in the repo) rides the workspace mount; the agents layer it themselves.

Config is immutable from inside the container by design: in-container edits fail loudly, and the [outbox](#outbox) is the write path.

### Persistence

The two agents get opposite persistence policies, chosen by failure mode.

**Pi: ephemeral by default, allowlist what persists.** Each session gets a fresh, empty writable dir at `/root/.pi/agent`, discarded on teardown, with the persistent pieces bound on top:

- `auth.json` — global, at `$XDG_DATA_HOME/ramekin/agents/pi/auth.json`, so the containerized agent keeps its own credentials (separate from the host's)
- `models-store.json` — global, alongside `auth.json`; pi's model catalog cache, rebuilt from scratch every session otherwise
- `git/` — global, alongside `auth.json`; the clones of the git packages named in pi's `settings.json`, so package fetching isn't paid on every start
- `sessions/` — per-repo, at `$XDG_DATA_HOME/ramekin/repos/<slug>/sessions/`

On teardown, ramekin logs anything else the agent wrote to its session dir before discarding it, so a path that deserves persistence gets noticed rather than silently vanishing.

**Claude: persist by default, denylist the junk.** `~/.claude` and `~/.claude.json` mount from `$XDG_DATA_HOME/ramekin/agents/`, shared across repos — auth, identity, onboarding state, and transcripts all survive. Fresh session-scoped dirs bind over the known ephemeral subdirs (`statsig/`, `todos/`, `shell-snapshots/`, `debug/`). Per-repo isolation of Claude's cwd-keyed `projects` map comes from the `/workspace/<slug>` mount, not from splitting the state file. If Claude grows an unclassified state file, it persists (worst case: rot) rather than vanishing (worst case: lost auth).

### Volume mounts

Mount configuration merges across layers, lowest to highest precedence:

1. **Binary** — compiled-in staples (`~/.config/git`, `~/.config/jj`, read-only, skipped when missing) and the agent-config mounts described above
2. **Profile** — the active profile's `mounts`
3. **User** — every `*.kdl` in `$XDG_CONFIG_HOME/ramekin/`, merged as one layer (defining the same key twice within the layer is an error; per-file symlinking into dotfiles is a dotfiles decision)
4. **Project** — `<workspace>/.ramekin/config.kdl`, committed

Additional host paths can be mounted into the container via the KDL layers. Directories, files, and devices (such as `/dev/null`) all work. A configured mount whose source doesn't exist on the host is an error, not a silent skip — a mount that quietly failed to bind looks like a bug in the container. Create the path, or hide the target with a `/dev/null` mount if it isn't wanted. Only the mount that survives merging is checked, so a higher layer's override or mask also covers a lower layer's missing source.

```kdl
mounts {
    // Ranger database, writable
    "~/.local/share/ranger" writable=#true
    // Extra data at an explicit container path
    "~/datasets" target="/root/datasets"
}
```

A `mounts` block holds one child node per mount, mirroring `env`. The node name is the host source path (`~` expands to the home directory, and a relative path resolves against the workspace), with optional properties:

| Property | Description |
|---|---|
| `target` | Container path; `~` expands to the container home, a relative path resolves against the workspace mount, and omitting it derives the target from the source |
| `writable` | `#true` allows writes (read-only by default) |

When two layers define a mount with the same container target, the higher layer wins wholesale. A `/dev/null` source follows that same rule and binds nothing readable, which hides whatever the target would otherwise hold — a mount from a lower layer, a file the image ships, or a file in your own workspace. The path still exists in the container; only its contents are gone. Hiding a directory binds a session-scoped empty directory instead, so listing the path succeeds and comes back empty:

```kdl
mounts {
    // Hide an agent-config entry this machine doesn't want
    "/dev/null" target="/root/.pi/agent/skills"
    // Hide the repo's .envrc from the agent
    "/dev/null" target=".envrc"
    // Same spelling for a directory; the agent sees it empty
    "/dev/null" target="secrets"
}
```

Precedence runs the other way too, so a project can mount back a path the user layer hides:

```kdl
// <workspace>/.ramekin/config.kdl — this repo's .envrc is fine to read
mounts {
    ".envrc"
}
```

Session mounts (the workspace, agent state, the rendered prompt, the outbox) are forced and cannot be overridden from config.

### Caches

A `cache` is a writable directory ramekin creates per repo and keeps across sessions. Build tools are the reason it exists: without one, every session pays a cold build, and pointing the tool at the workspace instead means the container and the host fight over one build directory.

```kdl
cache {
    // Cargo's build directory, shadowing the host's target/ inside the
    // workspace, so plain `cargo build` finds it
    target
    // uv's cache, at an explicit container path
    uv "~/.cache/uv"
}
```

Each child node names a directory under `$XDG_DATA_HOME/ramekin/repos/<slug>/caches/`; its argument is the container path, resolved like a mount target (`~` is the container home, a relative path resolves against the workspace mount). Omit the argument and the name serves as the path — a bare `target` is the whole declaration for a build directory the tool already looks for in the workspace. Caches merge by name across layers, so a higher layer can retarget one, and two names claiming the same container path is an error.

Unlike `mounts`, the host side isn't configurable and the directory is created when missing instead of erroring — a cache that silently failed to mount would look like nothing but slow builds. Caches are per repo because build directories can't be shared: tools take an exclusive lock on them for the duration of a build, and a toolchain change invalidates their contents wholesale. Download caches have the opposite shape — keyed by name and version, safe to share — so those belong in `mounts`:

```kdl
mounts {
    "~/.cache/ramekin-rust/registry" target="/root/.cargo/registry" writable=#true
}
```

### Environment variables

`env` has exactly one syntax: a block with one child node per variable. The single argument is the value; omit it to pass the host's value through at run time (the value never lands in config or the generated compose file). `env` merges per variable across layers, overlaying the active profile's env.

```kdl
env {
    RUST_BACKTRACE "1"
    GITHUB_TOKEN            // forwarded from the host environment
}
```

### Outbox

Shared config is read-only in the container, so the outbox is the one reviewed path for changing it. Each session mounts a fresh, empty dir at `/root/.ramekin/outbox` (host: `$XDG_DATA_HOME/ramekin/repos/<slug>/outbox/<session-id>/`), and the system prompt tells the agent to write complete updated files there, mirroring the agent config layout. Empty outboxes vanish at teardown; anything left becomes a pending proposal.

```sh
ramekin outbox list                          # pending proposals across sessions
ramekin outbox diff [<slug>/<session>/<path>] # diff against the host source
ramekin outbox apply <slug>/<session>/<path>  # copy over the host source, after confirmation
ramekin outbox discard <slug>/<session>       # drop proposals
```

`apply` shows the diff, asks for confirmation, and writes through dotfiles symlinks so the change lands in the working copy. Proposals that don't map back to an allowlisted agent-config entry need an explicit `--to` destination.

### Container environment context

A built-in system prompt (`ramekin-prompt.md`) is rendered per session, mounted read-only at `/root/.ramekin/ramekin-prompt.md`, and passed to the agent (`--append-system-prompt` for pi, `--append-system-prompt-file` for Claude). It tells the agent about the container environment — the workspace mount, ephemeral filesystem, read-only config, the outbox, and networking. `AGENTS.md`/`CLAUDE.md` remain fully available for user customization.

For Claude, the base image bakes in yolo mode: managed settings set `permissions.defaultMode = bypassPermissions` and `IS_SANDBOX=1` acknowledges the container as the sandbox.

### Custom Dockerfile

Place a `Dockerfile` at `.ramekin/Dockerfile` in your workspace to extend the base agent image. Use `FROM ramekin-agent` to layer on top — the base image carries both agents (the compose config picks the entrypoint per session) plus Node.js, git, jj, ripgrep, fd, just, jq, difftastic, dotslash, and ranger.

The workspace is used as the build context, so `COPY` instructions work relative to the project root.

```dockerfile
FROM ramekin-agent
RUN apt-get update && apt-get install -y ruby && rm -rf /var/lib/apt/lists/*
```

## Development

```sh
cargo check    # type-check
cargo fmt      # format
cargo clippy   # lint
cargo test     # run tests
just           # all four
just install   # cargo install from local source
```