Update docs for the completed config redesign
README covers profiles, per-agent persistence policies, the five-layer
mount merge, the single env syntax with passthrough, and the outbox.
AGENTS.md architecture notes match the implementation; the design doc
status moves to implemented, with its open questions still standing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayyqfpg98CeZPmtMPXQEFo
diff --git a/AGENTS.md b/AGENTS.md
index 624c61a..08e0f1b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,19 +2,21 @@
## Project
-Ramekin is a containerized harness for running the [pi coding agent](https://github.com/badlogic/pi-mono). A Rust CLI builds a Docker image, generates a compose config at runtime, and attaches the user's terminal to the agent container. Network restriction via a firewall sidecar is planned but not yet implemented.
+Ramekin is a containerized harness for running coding agents — the [pi coding agent](https://github.com/badlogic/pi-mono) or Claude Code, selected via profiles. A Rust CLI builds per-agent Docker images, generates a compose config at runtime, and attaches the user's terminal to the agent container. Network restriction via a firewall sidecar is planned but not yet implemented.
## Repository layout
```
Cargo.toml # Single-crate workspace
src/
- main.rs # CLI: builds image, generates compose, starts container, attaches
- config.rs # KDL config layers, mount resolution and merging
+ main.rs # CLI: agent state, image builds, compose generation, outbox commands
+ config.rs # KDL parsing, config layers, profiles, mount resolution and merging
+ outbox.rs # Pending config proposals: scan, map to host sources, apply/discard
build.rs # Sets RAMEKIN_VERSION from env or git rev
assets/
- Dockerfile # Agent container image (Node.js + pi + jj + Rust)
- ramekin-prompt.md # System prompt appended inside the container
+ Dockerfile # Pi base image (Node.js + pi + jj)
+ Dockerfile.claude # Claude Code base image (+ managed settings, IS_SANDBOX)
+ ramekin-prompt.md # System prompt template appended inside the container
clippy.toml # Disallows std::fs in favor of fs-err
justfile # Local dev tasks (check, fmt, clippy, test, install)
.github/workflows/
@@ -42,19 +44,22 @@ just # All four
## Architecture notes
-- The config redesign is documented in `docs/config-redesign.md`; the session model, layer structure, and persistence policy below implement its step 1 (pi only).
-- Docker compose config is generated at runtime via `serde_yaml` over a typed `ComposeConfig` struct, not a static file. Volume mounts use the long-form bind syntax (`{type: bind, source, target, read_only}`), which sidesteps the colon-delimited `source:target[:ro]` format and its quoting hazards. Mounts are ordered lexicographically by target so parents precede children.
-- Config merges three layers, lowest precedence first: binary (compiled-in staples `~/.config/git`/`~/.config/jj` plus the pi agent-config allowlist `~/.pi/agent/{AGENTS.md,skills}`, mounted read-only, canonicalized, skip-if-missing), user (`~/.config/ramekin/config.kdl`), and project (`<workspace>/.ramekin/config.kdl`). A `/dev/null` source masks (removes) a mount inherited from a lower layer.
-- Pi state is ephemeral by default: each session mounts a fresh empty dir at `/root/.pi/agent`, with persistent pieces bound on top — `auth.json` from `$XDG_DATA_HOME/ramekin/agents/pi/`, per-repo `sessions/` from `$XDG_DATA_HOME/ramekin/repos/<slug>/sessions/` (slug is `<dirname>-<hash>`). On teardown, anything else the agent wrote to the session dir is logged before being discarded.
-- Each workspace mounts at `/workspace/<slug>` (never a shared `/workspace`) so cwd-keyed agent state stays distinct per repo; compose's `working_dir` puts the agent there on start.
-- `Ramekin::resolve` is side-effect free (so `ramekin config` never mutates state); `Ramekin::prepare`, called from `run`, creates directories and initializes/migrates `auth.json`.
-- If the workspace contains `.ramekin/Dockerfile`, the CLI builds it on top of `ramekin-agent` instead of using the base image directly.
-- The `ramekin-prompt.md` template is rendered per session (`{{WORKSPACE_PATH}}` → the workspace target), mounted read-only into the agent dir, and passed to pi via `--append-system-prompt`.
+- The config redesign is documented in `docs/config-redesign.md`; all four steps of its sequencing are implemented.
+- Config files are parsed directly with the `kdl` crate (`parse_config` in `config.rs`), not serde: the grammar is `mounts` blocks, one `env` block syntax (bare child = host passthrough), and `profile` nodes (with children = definition, bare = selection). Unknown nodes fail loudly.
+- Config merges layers, lowest precedence first: binary (staples `~/.config/git`/`~/.config/jj` plus the active agent's config allowlist, mounted read-only, canonicalized, skip-if-missing), profile (the active profile's env/mounts), user (every `*.kdl` in `~/.config/ramekin/`, merged as one layer, duplicate keys within the layer are errors), project (`.ramekin/config.kdl`), and project-local (`.ramekin/config.local.kdl`). A `/dev/null` source masks (removes) a mount inherited from a lower layer. `env` merges per variable; profiles merge by name, last writer takes the whole definition.
+- Profiles subsume agent selection: the binary ships trivial `pi`/`claude` profiles, selection precedence is binary < user < project < project-local < `-p`. `Agent` (in `config.rs`) carries each agent's host config dir, allowlist, and container config dir; `AgentState` (in `main.rs`) carries its persistent host paths and session mounts.
+- Persistence is per-agent, opposite policies: pi is ephemeral-by-default (fresh session dir at `/root/.pi/agent`; allowlisted `auth.json` from `$XDG_DATA_HOME/ramekin/agents/pi/` and per-repo `sessions/` bound on top; teardown logs discarded writes). Claude is persist-by-default (`~/.claude` + `~/.claude.json` from `$XDG_DATA_HOME/ramekin/agents/`, shared across repos; session-scoped dirs bound over the `CLAUDE_EPHEMERAL` denylist).
+- Each workspace mounts at `/workspace/<slug>` (slug is `<dirname>-<hash>`, never a shared `/workspace`) so cwd-keyed agent state stays distinct per repo; compose's `working_dir` puts the agent there on start.
+- Docker compose config is generated at runtime via `serde_yaml` over a typed `ComposeConfig` struct, not a static file. Volume mounts use the long-form bind syntax (`{type: bind, source, target, read_only}`), ordered lexicographically by target so parents precede children. Passthrough env vars render as bare names in the environment list.
+- Base images build to per-agent tags (`ramekin-pi`, `ramekin-claude`). A project `.ramekin/Dockerfile` declares `ARG BASE` / `FROM ${BASE}`; ramekin passes the active agent's tag, and project image tags are repo- and agent-specific. Image builds forward a host GitHub token (env vars or `gh auth token`) as a BuildKit secret for API calls.
+- The outbox (`src/outbox.rs`) is the only write path for shared config: each session mounts a fresh dir at `/root/.ramekin/outbox`; proposals map back to host sources via the agent allowlist plus an `.agent` sidecar written outside the mount; `ramekin outbox list|diff|apply|discard` reviews them.
+- `Ramekin::resolve` is side-effect free (so `ramekin config` never mutates state); materialization happens in `run` via `AgentState::prepare`/`prepare_session`.
+- The `ramekin-prompt.md` template is rendered per session (`{{WORKSPACE_PATH}}` → the workspace target), mounted read-only at `/root/.ramekin/ramekin-prompt.md`, and passed via `--append-system-prompt` (pi) / `--append-system-prompt-file` (Claude — its plain flag takes a literal string).
- Version is set at build time via the `RAMEKIN_VERSION` env var (used by CI) or falls back to `dev+<short-sha>`.
## Dependencies
-- Production dependencies use `*` (unpinned) versions, except for pre-release crates which pin the exact version (e.g. `serde-kdl2 = "0.1.1-alpha.6"`).
+- Production dependencies use `*` (unpinned) versions, except for pre-release crates which pin the exact version.
- Dev dependencies also use `*`. Do not pin to the version `cargo add` resolves.
- `Cargo.lock` is committed.
diff --git a/README.md b/README.md
index f8db01c..57661aa 100644
--- a/README.md
+++ b/README.md
@@ -1,35 +1,38 @@
# Ramekin
-Containerized harness for running the [pi coding agent](https://github.com/badlogic/pi-mono).
+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 pi against the current directory
-ramekin /some/path # mount a specific workspace
+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 pi and its dependencies, starts it via Docker Compose, and attaches your terminal. Auth tokens and session history persist across runs.
+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. Creates XDG directories for persistent state
-2. Writes the embedded Dockerfile to `$XDG_CACHE_HOME/ramekin/`
-3. Generates a compose config, renders the system prompt, and creates a fresh agent dir, all in a session-scoped cache directory
-4. Builds the agent image (and a project-specific layer, if one exists)
-5. 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)
-6. Attaches interactively, then tears down on exit — logging any state the agent wrote to its session-scoped dir before discarding it
+1. Resolves the active profile (which picks the agent) and merges config layers
+2. Builds the agent's base image (`ramekin-pi` or `ramekin-claude`), 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 pi session. Pass `--rebuild` to ignore Docker layer cache and pull fresh base images.
+`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 resolved paths, volume mounts, and Dockerfile status without starting anything — useful for debugging mount issues.
+`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:
@@ -38,45 +41,80 @@ 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 { source "~/.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 → project-local 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
-Pi's config comes from the host's own agent dir: the config-shaped entries of `~/.pi/agent/` (`AGENTS.md`, `skills/`) mount read-only at their normal paths inside the container. 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 `~/.pi/agent/` is host runtime state (credentials, session history) and never enters the container.
+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`, `skills/`
+- claude: `~/.claude/` — `CLAUDE.md`, `settings.json`, `skills/`, `agents/`, `commands/`
-Config is immutable from inside the container by design: in-container edits to it fail loudly instead of silently disappearing.
+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
-Pi's container state is ephemeral by default: each session gets a fresh, empty writable dir at `/root/.pi/agent`, discarded on teardown. What persists is allowlisted and bind-mounted on top:
+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)
- `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 three layers, lowest to highest precedence:
+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 pi agent-config mounts described above
-2. **User** — `$XDG_CONFIG_HOME/ramekin/config.kdl`
-3. **Project** — `<workspace>/.ramekin/config.kdl`
+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
+5. **Project-local** — `<workspace>/.ramekin/config.local.kdl`, gitignored
Additional host paths can be mounted into the container via the KDL layers. Directories, files, and devices (such as `/dev/null`) all work. Mounts whose source doesn't exist on the host are silently skipped.
-**User config** — `$XDG_CONFIG_HOME/ramekin/config.kdl`
-
```kdl
// Mount ranger database (writable)
mounts {
source "~/.local/share/ranger"
writable
}
-```
-
-**Project config** — `<workspace>/.ramekin/config.kdl`
-```kdl
-// Mount extra data into the container
+// Mount extra data at an explicit path
mounts {
source "~/datasets"
target "/root/datasets"
@@ -107,20 +145,47 @@ mounts {
}
```
-Session mounts (the workspace, the agent dir plumbing, `auth.json`, `sessions/`, the rendered prompt) are forced and cannot be overridden from config.
+Session mounts (the workspace, agent state, the rendered prompt, the outbox) are forced and cannot be overridden from config.
+
+### 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 into the agent dir, and passed to pi via `--append-system-prompt`. It tells the agent about the container environment — the workspace mount, ephemeral filesystem, read-only config, and networking. AGENTS.md remains fully available for user customization.
+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 includes Node.js, pi, git, jj, ripgrep, fd, just, jq, difftastic, ranger, and Rust tooling.
+Place a `Dockerfile` at `.ramekin/Dockerfile` in your workspace to extend the base agent image. Declare `ARG BASE` / `FROM ${BASE}` — ramekin passes the active agent's base tag, so one project Dockerfile serves both agents. The base images include Node.js, the agent, 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
+ARG BASE
+FROM ${BASE}
RUN apt-get update && apt-get install -y ruby && rm -rf /var/lib/apt/lists/*
```
diff --git a/docs/config-redesign.md b/docs/config-redesign.md
index 2395589..ae86915 100644
--- a/docs/config-redesign.md
+++ b/docs/config-redesign.md
@@ -1,7 +1,8 @@
# Config redesign
-Status: in progress (2026-07) — step 1 of the sequencing (session model on
-pi) is implemented. Ramekin is single-user; this design leans on that
+Status: implemented (2026-07) — all four steps of the sequencing have
+landed; the open questions below remain to verify against the real agents.
+Ramekin is single-user; this design leans on that
hard. The `claude-code` branch is a prototype to learn from, not a baseline
to preserve.