Ramekin

Containerized harness for running coding agents: the pi coding agent or Claude Code.

Quick start

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.

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

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:

// 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 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.

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:

PropertyDescription
targetContainer 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:

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:

// <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.

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:

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.

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.

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.

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

Development

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