Run Claude Code or pi, selected through KDL profiles
A profile is a named bundle of agent + provider plumbing (env vars,
extra mounts), defined in KDL and merged by name across layers with
last-writer-takes-the-definition. The binary ships only the trivial
`pi` and `claude` profiles so ramekin runs with zero config; selection
is a bare `profile "name"` node, with precedence binary default < user
< project < project-local < -p. Profile selection subsumes agent
selection — there is no separate --agent. The active profile's env and
mounts form a layer below all file layers, so any layer can adjust one
variable without redefining the profile.

Claude gets the opposite persistence policy from pi, chosen by failure
mode: ~/.claude and ~/.claude.json persist by default from
$XDG_DATA_HOME/ramekin/agents/ (shared across repos; the per-slug
workspace mount partitions the cwd-keyed projects map), with fresh
session-scoped dirs bound over the known ephemeral junk (statsig,
todos, shell-snapshots, debug). Host ~/.claude config (CLAUDE.md,
settings.json, skills/, agents/, commands/) mounts read-only via the
same binary-layer allowlist mechanism as pi's.

Base images build to per-agent tags (ramekin-pi, ramekin-claude); a
project .ramekin/Dockerfile declares ARG BASE / FROM ${BASE} and gets
the active agent's tag, so one project Dockerfile serves both, with
repo- and agent-specific project tags.

Harvested from the claude-code branch: Dockerfile.claude with yolo
mode expressed through managed settings + IS_SANDBOX, the
--append-system-prompt-file distinction (Claude's plain flag would
append the literal path string), and the GitHub-token BuildKit secret
for rate-limited API calls during image builds. The rendered prompt
moves to a neutral /root/.ramekin/ path serving both agents.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayyqfpg98CeZPmtMPXQEFo
change
commit e2a02fcea7a8df1355708234be7ac8c12608f01e
author Claude <noreply@anthropic.com>
date
parent 72f26cd6
diff --git a/.ramekin/Dockerfile b/.ramekin/Dockerfile
index 2e13613..2cc7e82 100644
--- a/.ramekin/Dockerfile
+++ b/.ramekin/Dockerfile
@@ -1,4 +1,5 @@
-FROM ramekin-agent
+ARG BASE
+FROM ${BASE}
 
 ENV RANGER_DEFAULT_BACKLOG="ramekin"
 
diff --git a/assets/Dockerfile b/assets/Dockerfile
index 745167f..cce9b4f 100644
--- a/assets/Dockerfile
+++ b/assets/Dockerfile
@@ -22,7 +22,13 @@ RUN --mount=type=cache,target=/tmp/downloads \
     && curl -fsSL "https://github.com/kejadlen/ranger/releases/latest/download/ranger-aarch64-unknown-linux-gnu.tar.gz" \
       | tar xz -C /usr/local/bin ranger
 
-RUN JJ_VERSION=$(curl -s https://api.github.com/repos/jj-vcs/jj/releases/latest | jq -r .tag_name) && \
+RUN --mount=type=secret,id=github-token,target=/run/secrets/github-token \
+    if [ -s /run/secrets/github-token ]; then \
+      JJ_VERSION=$(curl -s -H "Authorization: Bearer $(cat /run/secrets/github-token)" \
+        https://api.github.com/repos/jj-vcs/jj/releases/latest | jq -r .tag_name); \
+    else \
+      JJ_VERSION=$(curl -s https://api.github.com/repos/jj-vcs/jj/releases/latest | jq -r .tag_name); \
+    fi && \
     curl -fsSL "https://github.com/jj-vcs/jj/releases/download/${JJ_VERSION}/jj-${JJ_VERSION}-aarch64-unknown-linux-musl.tar.gz" \
       | tar xz --strip-components=0 -C /usr/local/bin ./jj
 
diff --git a/assets/Dockerfile.claude b/assets/Dockerfile.claude
new file mode 100644
index 0000000..f1303b0
--- /dev/null
+++ b/assets/Dockerfile.claude
@@ -0,0 +1,57 @@
+FROM node:24-trixie-slim
+
+RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
+    --mount=type=cache,target=/var/lib/apt,sharing=locked \
+    rm -f /etc/apt/apt.conf.d/docker-clean \
+    && apt-get update && apt-get install -y --no-install-recommends \
+    build-essential \
+    ca-certificates \
+    curl \
+    fd-find \
+    git \
+    jq \
+    just \
+    ripgrep \
+    && ln -sf /usr/bin/fdfind /usr/bin/fd
+
+RUN --mount=type=cache,target=/tmp/downloads \
+    curl -fsSL "https://github.com/Wilfred/difftastic/releases/latest/download/difft-aarch64-unknown-linux-gnu.tar.gz" \
+      | tar xz -C /usr/local/bin difft \
+    && curl -fsSL "https://github.com/facebook/dotslash/releases/latest/download/dotslash-linux-musl.aarch64.tar.gz" \
+      | tar xz -C /usr/local/bin dotslash \
+    && curl -fsSL "https://github.com/kejadlen/ranger/releases/latest/download/ranger-aarch64-unknown-linux-gnu.tar.gz" \
+      | tar xz -C /usr/local/bin ranger
+
+RUN --mount=type=secret,id=github-token,target=/run/secrets/github-token \
+    if [ -s /run/secrets/github-token ]; then \
+      JJ_VERSION=$(curl -s -H "Authorization: Bearer $(cat /run/secrets/github-token)" \
+        https://api.github.com/repos/jj-vcs/jj/releases/latest | jq -r .tag_name); \
+    else \
+      JJ_VERSION=$(curl -s https://api.github.com/repos/jj-vcs/jj/releases/latest | jq -r .tag_name); \
+    fi && \
+    curl -fsSL "https://github.com/jj-vcs/jj/releases/download/${JJ_VERSION}/jj-${JJ_VERSION}-aarch64-unknown-linux-musl.tar.gz" \
+      | tar xz --strip-components=0 -C /usr/local/bin ./jj
+
+RUN --mount=type=cache,target=/root/.npm \
+    npm install -g @anthropic-ai/claude-code
+
+# Claude Code refuses --dangerously-skip-permissions when running as root
+# unless IS_SANDBOX=1 acknowledges the sandbox. The container is the sandbox.
+ENV IS_SANDBOX=1
+
+# Run Claude in bypass-permissions mode and skip the bypass dialog. Managed
+# settings is the highest tier, so this overrides whatever's in the user's
+# mounted ~/.claude. defaultMode replaces the --dangerously-skip-permissions
+# CLI flag; skipDangerousModePermissionPrompt suppresses the once-per-machine
+# acceptance dialog. The container is already a sandbox (see IS_SANDBOX), so
+# prompting is redundant.
+RUN mkdir -p /etc/claude-code && cat > /etc/claude-code/managed-settings.json <<'EOF'
+{
+  "permissions": { "defaultMode": "bypassPermissions" },
+  "skipDangerousModePermissionPrompt": true
+}
+EOF
+
+WORKDIR /workspace
+
+ENTRYPOINT ["claude"]
diff --git a/src/config.rs b/src/config.rs
index 6acb2b2..f6c70cc 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -8,29 +8,125 @@ use miette::{Context, IntoDiagnostic, Result, bail, miette};
 /// Configuration scope, ordered from lowest to highest precedence.
 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
 pub enum Scope {
-    /// Compiled into the binary: staples and host agent-config mounts.
+    /// Compiled into the binary: staples, host agent-config mounts, and the
+    /// trivial profiles.
     Binary,
+    /// The active profile's own env and mounts, overlaid by every file layer.
+    Profile,
     /// The user layer: every `*.kdl` in `~/.config/ramekin/`, merged.
     User,
     /// Project-level `<workspace>/.ramekin/config.kdl`, committed.
     Project,
     /// Project-local `<workspace>/.ramekin/config.local.kdl`, gitignored.
     ProjectLocal,
+    /// Command-line arguments (currently just `-p`).
+    Cli,
 }
 
 impl fmt::Display for Scope {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         match self {
             Self::Binary => write!(f, "binary"),
+            Self::Profile => write!(f, "profile"),
             Self::User => write!(f, "user"),
             Self::Project => write!(f, "project"),
             Self::ProjectLocal => write!(f, "project-local"),
+            Self::Cli => write!(f, "cli"),
         }
     }
 }
 
+/// The coding agent a session runs.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Agent {
+    Pi,
+    Claude,
+}
+
+impl Agent {
+    pub fn parse(s: &str) -> Result<Self> {
+        match s {
+            "pi" => Ok(Self::Pi),
+            "claude" => Ok(Self::Claude),
+            other => bail!("unknown agent `{other}` (expected `pi` or `claude`)"),
+        }
+    }
+
+    pub fn name(&self) -> &'static str {
+        match self {
+            Self::Pi => "pi",
+            Self::Claude => "claude",
+        }
+    }
+
+    /// Host directory where this agent keeps its config (and, mixed in with
+    /// it, runtime state that must not enter the container).
+    pub fn host_config_dir(&self) -> &'static str {
+        match self {
+            Self::Pi => "~/.pi/agent",
+            Self::Claude => "~/.claude",
+        }
+    }
+
+    /// The config-shaped entries of the host agent dir. Only these mount
+    /// into the container (read-only); the rest is host runtime state —
+    /// credentials, transcripts, caches. Skip-if-missing makes
+    /// over-inclusion cheap.
+    pub fn config_allowlist(&self) -> &'static [&'static str] {
+        match self {
+            Self::Pi => &["AGENTS.md", "skills"],
+            Self::Claude => &["CLAUDE.md", "settings.json", "skills", "agents", "commands"],
+        }
+    }
+
+    /// The agent's config dir inside the container.
+    pub fn container_config_dir(&self) -> &'static str {
+        match self {
+            Self::Pi => PI_AGENT_DIR,
+            Self::Claude => "/root/.claude",
+        }
+    }
+}
+
+impl fmt::Display for Agent {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "{}", self.name())
+    }
+}
+
+/// A named bundle of agent + provider plumbing: env vars and extra mounts.
+/// Profiles merge by name across layers, last writer takes the whole
+/// definition; fine-grained tweaks go through the ordinary layered `env`.
+#[derive(Debug, Clone, PartialEq)]
+pub struct Profile {
+    pub name: String,
+    pub agent: Agent,
+    pub env: Vec<EnvVar>,
+    pub mounts: Vec<Mount>,
+}
+
+impl Profile {
+    /// The trivial profiles shipped in the binary: bare agents with no
+    /// provider plumbing, so ramekin runs with zero config. Everything
+    /// richer is defined in KDL.
+    fn builtin() -> Vec<Self> {
+        [Agent::Pi, Agent::Claude]
+            .into_iter()
+            .map(|agent| Self {
+                name: agent.name().to_string(),
+                agent,
+                env: Vec::new(),
+                mounts: Vec::new(),
+            })
+            .collect()
+    }
+}
+
+/// The profile selected in the binary when no layer or flag picks one.
+const DEFAULT_PROFILE: &str = "pi";
+
 /// A mount as written in config: unexpanded paths, optional target.
-#[derive(Debug, PartialEq)]
+#[derive(Debug, PartialEq, Clone)]
 pub struct Mount {
     pub source: String,
     pub target: Option<String>,
@@ -82,10 +178,24 @@ pub struct ScopedValue<T> {
     pub value: T,
 }
 
-/// All configuration layers, ordered from lowest to highest precedence.
+/// All configuration layers, ordered from lowest to highest precedence,
+/// plus the resolved profile.
 #[derive(Debug)]
 pub struct ScopedConfig {
     pub layers: Vec<ConfigLayer>,
+    /// Every known profile (builtin trivial ones plus KDL definitions),
+    /// merged by name — later layers take the whole definition.
+    pub profiles: BTreeMap<String, ScopedValue<Profile>>,
+    /// The active profile's name and the scope that selected it.
+    pub selection: ScopedValue<String>,
+    /// The active profile.
+    pub profile: Profile,
+}
+
+impl ScopedConfig {
+    pub fn agent(&self) -> Agent {
+        self.profile.agent
+    }
 }
 
 /// Mask source: a mount whose source is `/dev/null` *removes* an inherited
@@ -101,20 +211,23 @@ impl ScopedConfig {
     /// relative mount targets resolve against it.
     ///
     /// Layers are returned in precedence order (lowest first):
-    /// 1. Binary (staples and host agent-config mounts)
-    /// 2. User (every `*.kdl` in `~/.config/ramekin/`, merged as one layer)
-    /// 3. Project (`<workspace>/.ramekin/config.kdl`)
-    /// 4. Project-local (`<workspace>/.ramekin/config.local.kdl`)
+    /// 1. Binary (staples, host agent-config mounts, trivial profiles)
+    /// 2. Profile (the active profile's env and mounts)
+    /// 3. User (every `*.kdl` in `~/.config/ramekin/`, merged as one layer)
+    /// 4. Project (`<workspace>/.ramekin/config.kdl`)
+    /// 5. Project-local (`<workspace>/.ramekin/config.local.kdl`)
     ///
-    /// Returns an error if a config file can't be parsed, or if two files
-    /// within the user layer define the same key.
-    pub fn load(workspace: &Path, workspace_target: &str) -> Result<Self> {
-        let mut layers = vec![ConfigLayer {
-            scope: Scope::Binary,
-            path: None,
-            mounts: binary_mounts(),
-            env: Vec::new(),
-        }];
+    /// `cli_profile` is the `-p` selection, which beats any layer's.
+    ///
+    /// Returns an error if a config file can't be parsed, if two files
+    /// within the user layer define the same key, or if the selected
+    /// profile isn't defined anywhere.
+    pub fn load(
+        workspace: &Path,
+        workspace_target: &str,
+        cli_profile: Option<&str>,
+    ) -> Result<Self> {
+        let mut builders = Vec::new();
 
         // User layer: every *.kdl in the config dir, sorted by name for
         // deterministic merging. Which files exist (and which are symlinks
@@ -132,7 +245,7 @@ impl ScopedConfig {
                 for file in &files {
                     builder.add_file(file, workspace_target)?;
                 }
-                layers.push(builder.build());
+                builders.push(builder);
             }
         }
 
@@ -145,11 +258,91 @@ impl ScopedConfig {
             if path.exists() {
                 let mut builder = LayerBuilder::new(scope, Some(path.clone()));
                 builder.add_file(&path, workspace_target)?;
-                layers.push(builder.build());
+                builders.push(builder);
             }
         }
 
-        Ok(Self { layers })
+        // Profiles merge by name across layers, last writer takes the whole
+        // definition. Selection: highest layer wins, CLI beats all, binary
+        // default when nothing selects.
+        let mut profiles: BTreeMap<String, ScopedValue<Profile>> = Profile::builtin()
+            .into_iter()
+            .map(|p| {
+                (
+                    p.name.clone(),
+                    ScopedValue {
+                        scope: Scope::Binary,
+                        value: p,
+                    },
+                )
+            })
+            .collect();
+        let mut selection = ScopedValue {
+            scope: Scope::Binary,
+            value: DEFAULT_PROFILE.to_string(),
+        };
+        for builder in &builders {
+            for profile in &builder.profiles {
+                profiles.insert(
+                    profile.name.clone(),
+                    ScopedValue {
+                        scope: builder.scope,
+                        value: profile.clone(),
+                    },
+                );
+            }
+            if let Some(name) = &builder.selection {
+                selection = ScopedValue {
+                    scope: builder.scope,
+                    value: name.clone(),
+                };
+            }
+        }
+        if let Some(name) = cli_profile {
+            selection = ScopedValue {
+                scope: Scope::Cli,
+                value: name.to_string(),
+            };
+        }
+
+        let profile = profiles
+            .get(&selection.value)
+            .map(|sv| sv.value.clone())
+            .ok_or_else(|| {
+                let known = profiles.keys().cloned().collect::<Vec<_>>().join(", ");
+                miette!(
+                    "profile `{}` (selected by {}) is not defined; known profiles: {known}",
+                    selection.value,
+                    selection.scope,
+                )
+            })?;
+
+        // The binary layer's agent-config mounts depend on the resolved
+        // agent, so the layer is assembled only now.
+        let mut layers = vec![ConfigLayer {
+            scope: Scope::Binary,
+            path: None,
+            mounts: binary_mounts(profile.agent),
+            env: Vec::new(),
+        }];
+        layers.push(ConfigLayer {
+            scope: Scope::Profile,
+            path: None,
+            mounts: profile
+                .mounts
+                .iter()
+                .filter_map(|m| m.resolve(workspace_target))
+                .collect(),
+            env: profile.env.clone(),
+        });
+        layers.extend(builders.into_iter().map(LayerBuilder::build));
+
+        Ok(Self {
+            layers,
+            profiles,
+            selection,
+            profile,
+        })
     }
 
     /// Return merged mounts from all layers, de-duplicated by container target.
@@ -220,6 +413,9 @@ struct LayerBuilder {
     mount_targets: BTreeSet<String>,
     env: Vec<EnvVar>,
     env_names: BTreeSet<String>,
+    profiles: Vec<Profile>,
+    profile_names: BTreeSet<String>,
+    selection: Option<String>,
 }
 
 impl LayerBuilder {
@@ -231,6 +427,9 @@ impl LayerBuilder {
             mount_targets: BTreeSet::new(),
             env: Vec::new(),
             env_names: BTreeSet::new(),
+            profiles: Vec::new(),
+            profile_names: BTreeSet::new(),
+            selection: None,
         }
     }
 
@@ -267,6 +466,27 @@ impl LayerBuilder {
             }
             self.env.push(var);
         }
+        for profile in raw.profiles {
+            if !self.profile_names.insert(profile.name.clone()) {
+                bail!(
+                    "{}: profile `{}` is defined twice in the {} layer",
+                    file.display(),
+                    profile.name,
+                    self.scope,
+                );
+            }
+            self.profiles.push(profile);
+        }
+        for name in raw.selections {
+            if self.selection.is_some() {
+                bail!(
+                    "{}: the {} layer selects a profile twice",
+                    file.display(),
+                    self.scope,
+                );
+            }
+            self.selection = Some(name);
+        }
         Ok(())
     }
 
@@ -289,6 +509,8 @@ impl LayerBuilder {
 struct RawConfig {
     mounts: Vec<Mount>,
     env: Vec<EnvVar>,
+    profiles: Vec<Profile>,
+    selections: Vec<String>,
 }
 
 fn parse_file(path: &Path) -> Result<RawConfig> {
@@ -305,12 +527,57 @@ fn parse_config(content: &str) -> Result<RawConfig> {
         match node.name().value() {
             "mounts" => raw.mounts.push(parse_mount(node)?),
             "env" => raw.env.extend(parse_env(node)?),
+            // `profile "name" { ... }` defines; `profile "name"` selects.
+            "profile" => match parse_profile(node)? {
+                ProfileNode::Definition(profile) => raw.profiles.push(profile),
+                ProfileNode::Selection(name) => raw.selections.push(name),
+            },
             other => bail!("unknown config node `{other}`"),
         }
     }
     Ok(raw)
 }
 
+enum ProfileNode {
+    Definition(Profile),
+    Selection(String),
+}
+
+fn parse_profile(node: &KdlNode) -> Result<ProfileNode> {
+    let name = match node.entries() {
+        [entry] if entry.name().is_none() => entry
+            .value()
+            .as_string()
+            .ok_or_else(|| miette!("`profile` takes a string name, got {}", entry.value()))?
+            .to_string(),
+        _ => bail!("`profile` takes exactly one string name"),
+    };
+
+    let Some(children) = node.children() else {
+        return Ok(ProfileNode::Selection(name));
+    };
+
+    let mut agent = None;
+    let mut env = Vec::new();
+    let mut mounts = Vec::new();
+    for child in children.nodes() {
+        match child.name().value() {
+            "agent" => agent = Some(Agent::parse(&single_string_arg(child)?)?),
+            "env" => env.extend(parse_env(child)?),
+            "mounts" => mounts.push(parse_mount(child)?),
+            other => bail!("unknown `profile` field `{other}`"),
+        }
+    }
+
+    Ok(ProfileNode::Definition(Profile {
+        agent: agent
+            .ok_or_else(|| miette!("profile `{name}` is missing `agent` (`pi` or `claude`)"))?,
+        name,
+        env,
+        mounts,
+    }))
+}
+
 fn parse_mount(node: &KdlNode) -> Result<Mount> {
     if !node.entries().is_empty() {
         bail!("`mounts` takes a block of child nodes, not inline values");
@@ -409,30 +676,23 @@ fn bool_flag(node: &KdlNode) -> Result<bool> {
 /// is "true on every machine".
 const STAPLES: &[&str] = &["~/.config/git", "~/.config/jj"];
 
-/// Host directory where pi keeps its agent config and state.
-const HOST_PI_AGENT_DIR: &str = "~/.pi/agent";
-
-/// The config-shaped entries of the host's pi agent dir. Only these mount
-/// into the container (read-only); the rest of the dir is runtime state —
-/// credentials, session history — which must not leak in.
-pub const PI_AGENT_CONFIG: &[&str] = &["AGENTS.md", "skills"];
-
 /// Pi's agent dir inside the container.
 pub const PI_AGENT_DIR: &str = "/root/.pi/agent";
 
-/// Mounts compiled into the binary: staples plus the host's pi agent config.
+/// Mounts compiled into the binary: staples plus the host's agent config for
+/// the active agent.
 ///
 /// Sources are canonicalized because agent dirs and staples commonly symlink
 /// into dotfiles, and bind sources need real paths. Missing entries are
 /// skipped.
-fn binary_mounts() -> Vec<ResolvedMount> {
+fn binary_mounts(agent: Agent) -> Vec<ResolvedMount> {
     let staples = STAPLES
         .iter()
         .map(|source| ((*source).to_string(), resolve_container_target(source, "")));
-    let agent_config = PI_AGENT_CONFIG.iter().map(|entry| {
+    let agent_config = agent.config_allowlist().iter().map(move |entry| {
         (
-            format!("{HOST_PI_AGENT_DIR}/{entry}"),
-            format!("{PI_AGENT_DIR}/{entry}"),
+            format!("{}/{entry}", agent.host_config_dir()),
+            format!("{}/{entry}", agent.container_config_dir()),
         )
     });
 
@@ -502,6 +762,25 @@ mod tests {
 
     const WS: &str = "/workspace/test-slug";
 
+    /// A ScopedConfig with the given layers and an inert trivial profile,
+    /// for tests that only exercise merging.
+    fn scoped(layers: Vec<ConfigLayer>) -> ScopedConfig {
+        ScopedConfig {
+            layers,
+            profiles: BTreeMap::new(),
+            selection: ScopedValue {
+                scope: Scope::Binary,
+                value: DEFAULT_PROFILE.into(),
+            },
+            profile: Profile {
+                name: DEFAULT_PROFILE.into(),
+                agent: Agent::Pi,
+                env: Vec::new(),
+                mounts: Vec::new(),
+            },
+        }
+    }
+
     #[test]
     fn parse_mounts() {
         let raw = parse_config(
@@ -720,13 +999,11 @@ mod tests {
 
     #[test]
     fn merged_mounts_accumulates_across_layers() {
-        let config = ScopedConfig {
-            layers: vec![
-                layer(Scope::Binary, vec![mount("/a", "/a", false)]),
-                layer(Scope::User, vec![mount("/b", "/b", true)]),
-                layer(Scope::Project, vec![mount("/c", "/c", true)]),
-            ],
-        };
+        let config = scoped(vec![
+            layer(Scope::Binary, vec![mount("/a", "/a", false)]),
+            layer(Scope::User, vec![mount("/b", "/b", true)]),
+            layer(Scope::Project, vec![mount("/c", "/c", true)]),
+        ]);
         let merged = config.merged_mounts();
         assert_eq!(merged.len(), 3);
         let a = merged.iter().find(|sv| sv.value.target == "/a").unwrap();
@@ -739,22 +1016,20 @@ mod tests {
 
     #[test]
     fn merged_mounts_deduplicates_by_target() {
-        let config = ScopedConfig {
-            layers: vec![
-                layer(
-                    Scope::User,
-                    vec![
-                        mount("/user/git", "/root/.config/git", false),
-                        mount("/user/jj", "/root/.config/jj", false),
-                    ],
-                ),
-                layer(
-                    Scope::Project,
-                    // Override the user git mount with a different source
-                    vec![mount("/project/git", "/root/.config/git", true)],
-                ),
-            ],
-        };
+        let config = scoped(vec![
+            layer(
+                Scope::User,
+                vec![
+                    mount("/user/git", "/root/.config/git", false),
+                    mount("/user/jj", "/root/.config/jj", false),
+                ],
+            ),
+            layer(
+                Scope::Project,
+                // Override the user git mount with a different source
+                vec![mount("/project/git", "/root/.config/git", true)],
+            ),
+        ]);
         let merged = config.merged_mounts();
         // /root/.config/git appears in both layers; project layer wins
         assert_eq!(merged.len(), 2);
@@ -774,18 +1049,16 @@ mod tests {
 
     #[test]
     fn merged_mounts_orders_parents_before_children() {
-        let config = ScopedConfig {
-            layers: vec![
-                layer(
-                    Scope::Binary,
-                    vec![mount("/host/agents-md", "/root/.pi/agent/AGENTS.md", false)],
-                ),
-                layer(
-                    Scope::User,
-                    vec![mount("/host/agent-dir", "/root/.pi/agent", true)],
-                ),
-            ],
-        };
+        let config = scoped(vec![
+            layer(
+                Scope::Binary,
+                vec![mount("/host/agents-md", "/root/.pi/agent/AGENTS.md", false)],
+            ),
+            layer(
+                Scope::User,
+                vec![mount("/host/agent-dir", "/root/.pi/agent", true)],
+            ),
+        ]);
         let merged = config.merged_mounts();
         let targets: Vec<&str> = merged.iter().map(|sv| sv.value.target.as_str()).collect();
         assert_eq!(
@@ -796,30 +1069,26 @@ mod tests {
 
     #[test]
     fn dev_null_mask_removes_inherited_mount() {
-        let config = ScopedConfig {
-            layers: vec![
-                layer(
-                    Scope::Binary,
-                    vec![mount("/host/skills", "/root/.pi/agent/skills", false)],
-                ),
-                layer(
-                    Scope::Project,
-                    vec![mount("/dev/null", "/root/.pi/agent/skills", false)],
-                ),
-            ],
-        };
+        let config = scoped(vec![
+            layer(
+                Scope::Binary,
+                vec![mount("/host/skills", "/root/.pi/agent/skills", false)],
+            ),
+            layer(
+                Scope::Project,
+                vec![mount("/dev/null", "/root/.pi/agent/skills", false)],
+            ),
+        ]);
         let merged = config.merged_mounts();
         assert!(merged.is_empty(), "mask should remove the inherited mount");
     }
 
     #[test]
     fn dev_null_without_inherited_mount_stays_a_bind() {
-        let config = ScopedConfig {
-            layers: vec![layer(
-                Scope::Project,
-                vec![mount("/dev/null", "/workspace/test-slug/.envrc", false)],
-            )],
-        };
+        let config = scoped(vec![layer(
+            Scope::Project,
+            vec![mount("/dev/null", "/workspace/test-slug/.envrc", false)],
+        )]);
         let merged = config.merged_mounts();
         assert_eq!(merged.len(), 1);
         assert_eq!(merged[0].value.source, PathBuf::from("/dev/null"));
@@ -827,13 +1096,11 @@ mod tests {
 
     #[test]
     fn mount_overriding_mask_survives() {
-        let config = ScopedConfig {
-            layers: vec![
-                layer(Scope::Binary, vec![mount("/host/skills", "/s", false)]),
-                layer(Scope::User, vec![mount("/dev/null", "/s", false)]),
-                layer(Scope::Project, vec![mount("/project/skills", "/s", false)]),
-            ],
-        };
+        let config = scoped(vec![
+            layer(Scope::Binary, vec![mount("/host/skills", "/s", false)]),
+            layer(Scope::User, vec![mount("/dev/null", "/s", false)]),
+            layer(Scope::Project, vec![mount("/project/skills", "/s", false)]),
+        ]);
         let merged = config.merged_mounts();
         assert_eq!(merged.len(), 1);
         assert_eq!(merged[0].value.source, PathBuf::from("/project/skills"));
@@ -843,7 +1110,7 @@ mod tests {
     #[test]
     fn load_with_no_project_config() {
         // Use a workspace with no .ramekin/ config files
-        let config = ScopedConfig::load(Path::new("/tmp"), WS).unwrap();
+        let config = ScopedConfig::load(Path::new("/tmp"), WS, None).unwrap();
         // Binary layer is always first (lowest precedence)
         assert_eq!(config.layers.first().unwrap().scope, Scope::Binary);
         // No project layers
@@ -867,7 +1134,7 @@ mod tests {
         )
         .unwrap();
 
-        let config = ScopedConfig::load(dir.path(), WS).unwrap();
+        let config = ScopedConfig::load(dir.path(), WS, None).unwrap();
 
         let project = config
             .layers
@@ -912,9 +1179,7 @@ mod tests {
                 value: Some("project".into()),
             }],
         };
-        let config = ScopedConfig {
-            layers: vec![user, project],
-        };
+        let config = scoped(vec![user, project]);
         let merged = config.merged_env();
         assert_eq!(merged.len(), 2);
         let foo = merged.iter().find(|sv| sv.value.name == "FOO").unwrap();
@@ -923,4 +1188,188 @@ mod tests {
         let bar = merged.iter().find(|sv| sv.value.name == "BAR").unwrap();
         assert_eq!(bar.scope, Scope::User);
     }
+
+    #[test]
+    fn parse_profile_definition() {
+        let raw = parse_config(
+            r#"
+            profile "claude-bedrock" {
+                agent "claude"
+                env {
+                    CLAUDE_CODE_USE_BEDROCK "1"
+                    AWS_PROFILE
+                }
+                mounts { source "~/.aws" }
+            }
+            "#,
+        )
+        .unwrap();
+        assert!(raw.selections.is_empty());
+        assert_eq!(raw.profiles.len(), 1);
+        let p = &raw.profiles[0];
+        assert_eq!(p.name, "claude-bedrock");
+        assert_eq!(p.agent, Agent::Claude);
+        assert_eq!(p.env.len(), 2);
+        assert_eq!(p.env[1].name, "AWS_PROFILE");
+        assert_eq!(p.env[1].value, None);
+        assert_eq!(p.mounts.len(), 1);
+        assert_eq!(p.mounts[0].source, "~/.aws");
+    }
+
+    #[test]
+    fn parse_profile_selection() {
+        let raw = parse_config(r#"profile "pi-glm""#).unwrap();
+        assert!(raw.profiles.is_empty());
+        assert_eq!(raw.selections, vec!["pi-glm".to_string()]);
+    }
+
+    #[test]
+    fn profile_without_agent_is_rejected() {
+        let result = parse_config(r#"profile "x" { env { FOO "1" } }"#);
+        assert!(result.is_err());
+    }
+
+    #[test]
+    fn profile_with_unknown_agent_is_rejected() {
+        let result = parse_config(r#"profile "x" { agent "glm" }"#);
+        assert!(result.is_err());
+    }
+
+    #[test]
+    fn builtin_trivial_profile_is_the_default() {
+        let dir = tempfile::tempdir().unwrap();
+        let config = ScopedConfig::load(dir.path(), WS, None).unwrap();
+        assert_eq!(config.selection.value, "pi");
+        assert_eq!(config.selection.scope, Scope::Binary);
+        assert_eq!(config.agent(), Agent::Pi);
+    }
+
+    #[test]
+    fn project_selection_beats_binary_default() {
+        let dir = tempfile::tempdir().unwrap();
+        let ramekin_dir = dir.path().join(".ramekin");
+        fs_err::create_dir_all(&ramekin_dir).unwrap();
+        fs_err::write(ramekin_dir.join("config.kdl"), "profile \"claude\"\n").unwrap();
+
+        let config = ScopedConfig::load(dir.path(), WS, None).unwrap();
+        assert_eq!(config.selection.value, "claude");
+        assert_eq!(config.selection.scope, Scope::Project);
+        assert_eq!(config.agent(), Agent::Claude);
+    }
+
+    #[test]
+    fn cli_selection_beats_layers() {
+        let dir = tempfile::tempdir().unwrap();
+        let ramekin_dir = dir.path().join(".ramekin");
+        fs_err::create_dir_all(&ramekin_dir).unwrap();
+        fs_err::write(ramekin_dir.join("config.kdl"), "profile \"claude\"\n").unwrap();
+
+        let config = ScopedConfig::load(dir.path(), WS, Some("pi")).unwrap();
+        assert_eq!(config.selection.value, "pi");
+        assert_eq!(config.selection.scope, Scope::Cli);
+        assert_eq!(config.agent(), Agent::Pi);
+    }
+
+    #[test]
+    fn unknown_profile_selection_is_an_error() {
+        let dir = tempfile::tempdir().unwrap();
+        let err = ScopedConfig::load(dir.path(), WS, Some("bogus")).unwrap_err();
+        assert!(err.to_string().contains("bogus"), "{err}");
+    }
+
+    #[test]
+    fn profile_env_and_mounts_form_a_layer_below_project() {
+        let dir = tempfile::tempdir().unwrap();
+        let ramekin_dir = dir.path().join(".ramekin");
+        fs_err::create_dir_all(&ramekin_dir).unwrap();
+        fs_err::write(
+            ramekin_dir.join("config.kdl"),
+            r#"
+            profile "pi-glm" {
+                agent "pi"
+                env {
+                    ANTHROPIC_BASE_URL "https://open.bigmodel.cn/api/anthropic"
+                    ZHIPU_API_KEY
+                }
+                mounts {
+                    source "/tmp"
+                    target "/root/extra"
+                }
+            }
+            profile "pi-glm"
+            env {
+                ANTHROPIC_BASE_URL "https://elsewhere.example"
+            }
+            "#,
+        )
+        .unwrap();
+
+        let config = ScopedConfig::load(dir.path(), WS, None).unwrap();
+        assert_eq!(config.profile.name, "pi-glm");
+
+        // Layered env overlays the profile's env per variable.
+        let merged = config.merged_env();
+        let base_url = merged
+            .iter()
+            .find(|sv| sv.value.name == "ANTHROPIC_BASE_URL")
+            .unwrap();
+        assert_eq!(base_url.scope, Scope::Project);
+        assert_eq!(
+            base_url.value.value.as_deref(),
+            Some("https://elsewhere.example")
+        );
+        let key = merged
+            .iter()
+            .find(|sv| sv.value.name == "ZHIPU_API_KEY")
+            .unwrap();
+        assert_eq!(key.scope, Scope::Profile);
+        assert_eq!(key.value.value, None);
+
+        // Profile mounts join the merge at profile scope.
+        let mounts = config.merged_mounts();
+        let extra = mounts
+            .iter()
+            .find(|sv| sv.value.target == "/root/extra")
+            .unwrap();
+        assert_eq!(extra.scope, Scope::Profile);
+    }
+
+    #[test]
+    fn later_layer_redefines_profile_wholesale() {
+        let dir = tempfile::tempdir().unwrap();
+        let ramekin_dir = dir.path().join(".ramekin");
+        fs_err::create_dir_all(&ramekin_dir).unwrap();
+        fs_err::write(
+            ramekin_dir.join("config.kdl"),
+            r#"
+            profile "custom" {
+                agent "pi"
+                env { FOO "project" }
+            }
+            profile "custom"
+            "#,
+        )
+        .unwrap();
+        fs_err::write(
+            ramekin_dir.join("config.local.kdl"),
+            r#"
+            profile "custom" {
+                agent "claude"
+            }
+            "#,
+        )
+        .unwrap();
+
+        let config = ScopedConfig::load(dir.path(), WS, None).unwrap();
+        // Local layer's definition wins wholesale: agent changes, env gone.
+        assert_eq!(config.agent(), Agent::Claude);
+        assert!(config.profile.env.is_empty());
+    }
+
+    #[test]
+    fn agent_allowlists() {
+        assert!(Agent::Pi.config_allowlist().contains(&"AGENTS.md"));
+        assert!(Agent::Claude.config_allowlist().contains(&"CLAUDE.md"));
+        assert_eq!(Agent::Claude.container_config_dir(), "/root/.claude");
+    }
 }
diff --git a/src/main.rs b/src/main.rs
index 4717d73..ef2f679 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,6 +1,7 @@
 mod config;
 
 use std::collections::{BTreeMap, BTreeSet};
+use std::io::Write;
 use std::path::{Path, PathBuf};
 use std::process::{Command, Stdio};
 
@@ -11,29 +12,43 @@ use serde::Serialize;
 use tracing::{error, info, warn};
 use tracing_subscriber::{EnvFilter, fmt, prelude::*};
 
-const DOCKERFILE: &str = include_str!("../assets/Dockerfile");
+const PI_DOCKERFILE: &str = include_str!("../assets/Dockerfile");
+const CLAUDE_DOCKERFILE: &str = include_str!("../assets/Dockerfile.claude");
 const RAMEKIN_PROMPT: &str = include_str!("../assets/ramekin-prompt.md");
 
 const VERSION: &str = env!("RAMEKIN_VERSION");
 
+/// Container path of the rendered per-session system prompt.
+const PROMPT_TARGET: &str = "/root/.ramekin/ramekin-prompt.md";
+
+/// The `~/.claude` subdirectories that are caches and scratch, bound to
+/// fresh session-scoped dirs so they don't accumulate in the persistent
+/// claude state. A best-current-guess denylist; everything else persists
+/// (worst case: rot) rather than vanishing (worst case: lost auth).
+const CLAUDE_EPHEMERAL: &[&str] = &["statsig", "todos", "shell-snapshots", "debug"];
+
 #[derive(Parser)]
-#[command(about = "Run a pi coding agent in a containerized environment", version = VERSION)]
+#[command(about = "Run a coding agent (pi or Claude Code) in a containerized environment", version = VERSION)]
 struct Cli {
     /// Workspace directory to mount (defaults to current directory)
     #[arg(global = true, default_value = ".")]
     workspace: PathBuf,
 
+    /// Profile to run (a named agent + provider bundle)
+    #[arg(short, long, global = true)]
+    profile: Option<String>,
+
     #[command(subcommand)]
     command: Option<Cmd>,
 
-    /// Extra arguments forwarded to pi inside the container (after --)
+    /// Extra arguments forwarded to the agent inside the container (after --)
     #[arg(last = true, global = true)]
-    pi_args: Vec<String>,
+    agent_args: Vec<String>,
 }
 
 #[derive(Subcommand)]
 enum Cmd {
-    /// Start a containerized pi agent session
+    /// Start a containerized agent session
     Run {
         /// Force a full image rebuild (ignores Docker layer cache)
         #[arg(long)]
@@ -71,15 +86,206 @@ fn main() -> Result<()> {
         return Ok(());
     }
 
-    let ramekin = Ramekin::resolve(cli.workspace)?;
+    let ramekin = Ramekin::resolve(cli.workspace, cli.profile.as_deref())?;
 
     match command {
-        Cmd::Run { rebuild } => ramekin.run(rebuild, &cli.pi_args),
+        Cmd::Run { rebuild } => ramekin.run(rebuild, &cli.agent_args),
         Cmd::Config => ramekin.config(),
         Cmd::Completions { .. } => unreachable!(),
     }
 }
 
+// ---------------------------------------------------------------------------
+// AgentState
+// ---------------------------------------------------------------------------
+
+/// Host-side persistent state for the active agent, and how it mounts.
+///
+/// The two agents get opposite persistence policies, chosen by failure mode:
+/// pi is ephemeral by default with an allowlist of what persists (its
+/// persistent surface is small and stable); claude persists by default with
+/// a denylist of known junk (an unclassified new state file should rot, not
+/// vanish along with auth or onboarding state).
+enum AgentState {
+    Pi {
+        /// `$XDG_DATA_HOME/ramekin/agents/pi/`; holds `auth.json`, the one
+        /// global file that survives across sessions.
+        state_dir: PathBuf,
+        /// `$XDG_DATA_HOME/ramekin/repos/<slug>/sessions/`.
+        repo_sessions_dir: PathBuf,
+    },
+    Claude {
+        /// `$XDG_DATA_HOME/ramekin/agents/claude/` → `/root/.claude`.
+        /// Global across repos so OAuth tokens, account identity, and
+        /// onboarding state survive switching workspaces.
+        data_dir: PathBuf,
+        /// `$XDG_DATA_HOME/ramekin/agents/claude.json` → `/root/.claude.json`
+        /// (sibling to `~/.claude/`, not inside it). Its cwd-keyed `projects`
+        /// map partitions per repo via the `/workspace/<slug>` mount, so the
+        /// file itself stays global.
+        state_file: PathBuf,
+    },
+}
+
+impl AgentState {
+    fn for_agent(agent: config::Agent, data_home: &Path, repo_slug: &str) -> Self {
+        match agent {
+            config::Agent::Pi => Self::Pi {
+                state_dir: data_home.join("agents/pi"),
+                repo_sessions_dir: data_home.join(format!("repos/{repo_slug}/sessions")),
+            },
+            config::Agent::Claude => Self::Claude {
+                data_dir: data_home.join("agents/claude"),
+                state_file: data_home.join("agents/claude.json"),
+            },
+        }
+    }
+
+    /// Materialize persistent host-side state. Idempotent.
+    fn prepare(&self, xdg: &xdg::BaseDirectories) -> Result<()> {
+        match self {
+            Self::Pi {
+                state_dir,
+                repo_sessions_dir,
+            } => {
+                fs_err::create_dir_all(state_dir).into_diagnostic()?;
+                fs_err::create_dir_all(repo_sessions_dir).into_diagnostic()?;
+
+                // auth.json bind-mounts as a file, so it has to exist before
+                // the container starts. Migrate from the pre-redesign
+                // location (~/.config/ramekin/agent/auth.json) or start it
+                // empty.
+                let auth_file = state_dir.join("auth.json");
+                if !auth_file.exists() {
+                    let old_auth = xdg
+                        .get_config_home()
+                        .map(|config_home| config_home.join("agent/auth.json"))
+                        .filter(|p| p.exists());
+                    match old_auth {
+                        Some(old) => {
+                            info!(from = %old.display(), to = %auth_file.display(), "migrating pi auth");
+                            fs_err::copy(&old, &auth_file).into_diagnostic()?;
+                        }
+                        None => init_json_file(&auth_file)?,
+                    }
+                }
+            }
+            Self::Claude {
+                data_dir,
+                state_file,
+            } => {
+                fs_err::create_dir_all(data_dir).into_diagnostic()?;
+                init_json_file(state_file)?;
+            }
+        }
+        Ok(())
+    }
+
+    /// Create the session-scoped directories this agent's mounts need.
+    fn prepare_session(&self, session_dir: &Path) -> Result<()> {
+        match self {
+            Self::Pi { .. } => {
+                fs_err::create_dir_all(session_dir.join("agent")).into_diagnostic()?;
+            }
+            Self::Claude { .. } => {
+                for name in CLAUDE_EPHEMERAL {
+                    fs_err::create_dir_all(session_dir.join("claude").join(name))
+                        .into_diagnostic()?;
+                }
+            }
+        }
+        Ok(())
+    }
+
+    /// Agent-state mounts for one session. Read-only host-config mounts
+    /// from the binary layer sit above these.
+    fn mounts(&self, session_dir: &Path) -> Vec<config::ResolvedMount> {
+        let rw = |source: PathBuf, target: String| config::ResolvedMount {
+            source,
+            target,
+            writable: true,
+        };
+        match self {
+            // Fresh empty writable dir per session, with the allowlisted
+            // persistent pieces (auth.json, per-repo sessions/) bound on top.
+            Self::Pi {
+                state_dir,
+                repo_sessions_dir,
+            } => vec![
+                rw(session_dir.join("agent"), config::PI_AGENT_DIR.into()),
+                rw(
+                    state_dir.join("auth.json"),
+                    format!("{}/auth.json", config::PI_AGENT_DIR),
+                ),
+                rw(
+                    repo_sessions_dir.clone(),
+                    format!("{}/sessions", config::PI_AGENT_DIR),
+                ),
+            ],
+            // Persistent state dir and state file, with fresh session-scoped
+            // dirs bound over the known ephemeral subdirs.
+            Self::Claude {
+                data_dir,
+                state_file,
+            } => {
+                let mut mounts = vec![
+                    rw(data_dir.clone(), "/root/.claude".into()),
+                    rw(state_file.clone(), "/root/.claude.json".into()),
+                ];
+                mounts.extend(CLAUDE_EPHEMERAL.iter().map(|name| {
+                    rw(
+                        session_dir.join("claude").join(name),
+                        format!("/root/.claude/{name}"),
+                    )
+                }));
+                mounts
+            }
+        }
+    }
+
+    /// The session-scoped dir whose discarded writes the teardown report
+    /// inspects. Only pi has one: its whole agent dir is ephemeral, so a
+    /// novel write there is a candidate for the persistent allowlist.
+    /// Claude's session-scoped dirs are the ephemeral denylist — already
+    /// classified junk, not worth reporting every run.
+    fn report_dir(&self, session_dir: &Path) -> Option<PathBuf> {
+        match self {
+            Self::Pi { .. } => Some(session_dir.join("agent")),
+            Self::Claude { .. } => None,
+        }
+    }
+
+    /// Labelled host paths for `ramekin config` output.
+    fn state_labels(&self) -> Vec<(&'static str, &Path)> {
+        match self {
+            Self::Pi {
+                state_dir,
+                repo_sessions_dir,
+            } => vec![("pi state", state_dir), ("sessions", repo_sessions_dir)],
+            Self::Claude {
+                data_dir,
+                state_file,
+            } => vec![("claude  ", data_dir), ("state   ", state_file)],
+        }
+    }
+}
+
+/// Create a file containing `{}\n` unless it already exists. `create_new`
+/// is race-safe: two concurrent first runs can't clobber each other, and
+/// losing the race is fine — the file exists.
+fn init_json_file(file: &Path) -> Result<()> {
+    match fs_err::OpenOptions::new()
+        .write(true)
+        .create_new(true)
+        .open(file)
+    {
+        Ok(mut f) => f.write_all(b"{}\n").into_diagnostic()?,
+        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
+        Err(e) => return Err(e).into_diagnostic(),
+    }
+    Ok(())
+}
+
 // ---------------------------------------------------------------------------
 // Ramekin
 // ---------------------------------------------------------------------------
@@ -87,26 +293,23 @@ fn main() -> Result<()> {
 struct Ramekin {
     workspace: PathBuf,
     /// Container path of the workspace mount: `/workspace/<slug>`. Per-repo
-    /// so anything the agent keys by cwd (pi's session grouping) gets a
-    /// distinct path per repo instead of every repo looking like the same
-    /// `/workspace` project.
+    /// so anything the agent keys by cwd (pi's session grouping, claude's
+    /// `projects` map and transcripts) gets a distinct path per repo instead
+    /// of every repo looking like the same `/workspace` project.
     workspace_target: String,
+    repo_slug: String,
     xdg: xdg::BaseDirectories,
-    /// Persistent pi state: `$XDG_DATA_HOME/ramekin/agents/pi/`. Holds
-    /// `auth.json`, the one global file that survives across sessions.
-    pi_state_dir: PathBuf,
-    /// Per-repo session history: `$XDG_DATA_HOME/ramekin/repos/<slug>/sessions/`.
-    repo_sessions_dir: PathBuf,
     cache_dir: PathBuf,
     custom_dockerfile: Option<PathBuf>,
     config: config::ScopedConfig,
+    agent_state: AgentState,
 }
 
 impl Ramekin {
     /// Resolve all paths and load config layers. Side-effect free: nothing
     /// is created or written until `run` calls `prepare`, so `ramekin
     /// config` can inspect state without mutating it.
-    fn resolve(workspace_arg: PathBuf) -> Result<Self> {
+    fn resolve(workspace_arg: PathBuf, cli_profile: Option<&str>) -> Result<Self> {
         let workspace = workspace_arg
             .canonicalize()
             .into_diagnostic()
@@ -124,106 +327,44 @@ impl Ramekin {
 
         let repo_slug = repo_slug(&workspace);
         let workspace_target = format!("/workspace/{repo_slug}");
-        let pi_state_dir = data_home.join("agents/pi");
-        let repo_sessions_dir = data_home.join(format!("repos/{repo_slug}/sessions"));
 
         let custom_dockerfile_path = workspace.join(".ramekin/Dockerfile");
         let custom_dockerfile = custom_dockerfile_path
             .is_file()
             .then_some(custom_dockerfile_path);
 
-        let config = config::ScopedConfig::load(&workspace, &workspace_target)
+        let config = config::ScopedConfig::load(&workspace, &workspace_target, cli_profile)
             .wrap_err("failed to load ramekin configuration")?;
 
+        let agent_state = AgentState::for_agent(config.agent(), &data_home, &repo_slug);
+
         Ok(Self {
             workspace,
             workspace_target,
+            repo_slug,
             xdg,
-            pi_state_dir,
-            repo_sessions_dir,
             cache_dir,
             custom_dockerfile,
             config,
+            agent_state,
         })
     }
 
-    /// Materialize host-side state for a run: XDG directories and the
-    /// persistent pi auth file.
-    fn prepare(&self) -> Result<()> {
-        fs_err::create_dir_all(&self.pi_state_dir).into_diagnostic()?;
-        fs_err::create_dir_all(&self.repo_sessions_dir).into_diagnostic()?;
-        fs_err::create_dir_all(&self.cache_dir).into_diagnostic()?;
-
-        // auth.json bind-mounts as a file, so it has to exist before the
-        // container starts. Migrate from the pre-redesign location
-        // (~/.config/ramekin/agent/auth.json) or start it empty.
-        let auth_file = self.pi_state_dir.join("auth.json");
-        if !auth_file.exists() {
-            let old_auth = self
-                .xdg
-                .get_config_home()
-                .map(|config_home| config_home.join("agent/auth.json"));
-            match old_auth.filter(|p| p.exists()) {
-                Some(old) => {
-                    info!(from = %old.display(), to = %auth_file.display(), "migrating pi auth");
-                    fs_err::copy(&old, &auth_file).into_diagnostic()?;
-                }
-                None => {
-                    // create_new so two concurrent first runs can't clobber
-                    // each other; losing the race is fine, the file exists.
-                    use std::io::Write;
-                    match fs_err::OpenOptions::new()
-                        .write(true)
-                        .create_new(true)
-                        .open(&auth_file)
-                    {
-                        Ok(mut f) => f.write_all(b"{}").into_diagnostic()?,
-                        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
-                        Err(e) => return Err(e).into_diagnostic(),
-                    }
-                }
-            }
-        }
-
-        Ok(())
-    }
-
-    /// Session plumbing and agent-state mounts. Not a config layer: these
-    /// are forced, overriding any config mount at the same target.
-    ///
-    /// Pi state is ephemeral by default — a fresh empty writable dir per
-    /// session at the agent dir, with the allowlisted persistent pieces
-    /// (`auth.json`, per-repo `sessions/`) bind-mounted on top. Read-only
-    /// host-config mounts from the binary layer sit above that.
+    /// Session plumbing mounts shared by both agents: the rendered prompt
+    /// and the workspace.
     fn session_mounts(&self, session_dir: &Path) -> Vec<config::ResolvedMount> {
-        let agent = |rest: &str| format!("{}/{rest}", config::PI_AGENT_DIR);
-        vec![
-            config::ResolvedMount {
-                source: session_dir.join("agent"),
-                target: config::PI_AGENT_DIR.into(),
-                writable: true,
-            },
-            config::ResolvedMount {
-                source: self.pi_state_dir.join("auth.json"),
-                target: agent("auth.json"),
-                writable: true,
-            },
-            config::ResolvedMount {
-                source: self.repo_sessions_dir.clone(),
-                target: agent("sessions"),
-                writable: true,
-            },
-            config::ResolvedMount {
-                source: session_dir.join("ramekin-prompt.md"),
-                target: agent("ramekin-prompt.md"),
-                writable: false,
-            },
-            config::ResolvedMount {
-                source: self.workspace.clone(),
-                target: self.workspace_target.clone(),
-                writable: true,
-            },
-        ]
+        let mut mounts = self.agent_state.mounts(session_dir);
+        mounts.push(config::ResolvedMount {
+            source: session_dir.join("ramekin-prompt.md"),
+            target: PROMPT_TARGET.into(),
+            writable: false,
+        });
+        mounts.push(config::ResolvedMount {
+            source: self.workspace.clone(),
+            target: self.workspace_target.clone(),
+            writable: true,
+        });
+        mounts
     }
 
     /// Merge config mounts with the forced session mounts, ordered
@@ -244,20 +385,46 @@ impl Ramekin {
         by_target.into_values().collect()
     }
 
+    /// Tag of the active agent's base image.
+    fn base_image(&self) -> String {
+        format!("ramekin-{}", self.config.agent())
+    }
+
     fn config(&self) -> Result<()> {
         println!("Workspace");
         println!("  {} → {}", self.workspace.display(), self.workspace_target);
 
+        println!();
+        println!("Profile");
+        println!(
+            "  {} (agent {}, selected by {})",
+            self.config.profile.name,
+            self.config.agent(),
+            self.config.selection.scope,
+        );
+        for (name, sv) in &self.config.profiles {
+            let marker = if *name == self.config.profile.name {
+                "*"
+            } else {
+                " "
+            };
+            println!("  {marker} {name} ({}, agent {})", sv.scope, sv.value.agent);
+        }
+
         println!();
         println!("Ramekin directories");
-        println!("  pi state {}", self.pi_state_dir.display());
-        println!("  sessions {}", self.repo_sessions_dir.display());
+        for (label, path) in self.agent_state.state_labels() {
+            println!("  {label} {}", path.display());
+        }
         println!("  cache    {}", self.cache_dir.display());
 
         let merged_mounts = self.config.merged_mounts();
         let merged_env = self.config.merged_env();
 
         let scope_label = |scope: config::Scope| -> String {
+            if scope == config::Scope::Profile {
+                return format!("profile ({})", self.config.profile.name);
+            }
             self.config
                 .layers
                 .iter()
@@ -315,9 +482,9 @@ impl Ramekin {
         println!();
         println!("Dockerfile");
         match &self.custom_dockerfile {
-            Some(path) => println!("  ✓ {}", path.display()),
+            Some(path) => println!("  ✓ {} (BASE={})", path.display(), self.base_image()),
             None => {
-                println!("  embedded (default)");
+                println!("  embedded ({})", self.base_image());
                 println!(
                     "  ✗ {} (not found)",
                     self.workspace.join(".ramekin/Dockerfile").display()
@@ -328,16 +495,36 @@ impl Ramekin {
         Ok(())
     }
 
-    fn run(&self, rebuild: bool, pi_args: &[String]) -> Result<()> {
-        info!(workspace = %self.workspace.display(), target = %self.workspace_target, "starting agent");
+    fn run(&self, rebuild: bool, agent_args: &[String]) -> Result<()> {
+        let agent = self.config.agent();
+        info!(
+            profile = %self.config.profile.name,
+            agent = %agent,
+            workspace = %self.workspace.display(),
+            target = %self.workspace_target,
+            "starting agent"
+        );
 
-        self.prepare()?;
+        fs_err::create_dir_all(&self.cache_dir).into_diagnostic()?;
+        self.agent_state.prepare(&self.xdg)?;
 
-        // Write the embedded Dockerfile to the cache directory
-        let base_dockerfile = self.cache_dir.join("Dockerfile");
-        fs_err::write(&base_dockerfile, DOCKERFILE).into_diagnostic()?;
+        // Write the embedded Dockerfile to the cache directory, one file per
+        // agent so concurrent sessions of different agents don't race.
+        let dockerfile_source = match agent {
+            config::Agent::Pi => PI_DOCKERFILE,
+            config::Agent::Claude => CLAUDE_DOCKERFILE,
+        };
+        let base_dockerfile = self.cache_dir.join(format!("Dockerfile.{agent}"));
+        fs_err::write(&base_dockerfile, dockerfile_source).into_diagnostic()?;
+
+        // The base image fetches release metadata from the GitHub API at
+        // build time. Pass a host token so the build doesn't get rate-limited.
+        let gh_token = host_github_token();
+        if gh_token.is_some() {
+            info!("authenticated GitHub API for image build");
+        }
 
-        // Build the base image
+        let base_image = self.base_image();
         if rebuild {
             info!("rebuilding base image (no cache)");
         } else {
@@ -345,8 +532,13 @@ impl Ramekin {
         }
         let mut build_cmd = Command::new("docker");
         build_cmd
-            .args(["build", "-t", "ramekin-agent", "-f"])
+            .args(["build", "-t", &base_image, "-f"])
             .arg(&base_dockerfile);
+        if let Some(token) = &gh_token {
+            build_cmd
+                .env("RAMEKIN_GH_TOKEN", token)
+                .args(["--secret", "id=github-token,env=RAMEKIN_GH_TOKEN"]);
+        }
         if rebuild {
             build_cmd.args(["--no-cache", "--pull"]);
         }
@@ -360,27 +552,29 @@ impl Ramekin {
         }
 
         // Determine the final dockerfile, build context, and image tag. A
-        // custom Dockerfile gets a repo-specific tag so it doesn't collide with
-        // the base `ramekin-agent` image it builds `FROM`; sharing the tag would
-        // make `docker compose up` reuse the base instead of the project layer.
-        let (dockerfile, build_context, image) = match &self.custom_dockerfile {
+        // custom Dockerfile declares `ARG BASE` / `FROM ${BASE}` and gets the
+        // active agent's base tag passed in, plus a repo- and agent-specific
+        // image tag so the two never collide.
+        let (dockerfile, build_context, image, build_args) = match &self.custom_dockerfile {
             Some(custom) => {
                 info!("building project image from .ramekin/Dockerfile");
                 (
                     custom.clone(),
                     self.workspace.clone(),
-                    project_image_name(&self.workspace),
+                    project_image_name(&self.repo_slug, agent),
+                    BTreeMap::from([("BASE", base_image.clone())]),
                 )
             }
             None => (
                 base_dockerfile,
                 self.cache_dir.clone(),
-                "ramekin-agent".to_string(),
+                base_image.clone(),
+                BTreeMap::new(),
             ),
         };
 
-        // Session-scoped: compose file, rendered prompt, and a fresh empty
-        // agent dir, all under a random session id so concurrent runs don't
+        // Session-scoped: compose file, rendered prompt, and fresh agent
+        // dirs, all under a random session id so concurrent runs don't
         // interfere.
         let session_id = session_id();
         let session_dir = self
@@ -388,8 +582,7 @@ impl Ramekin {
             .create_cache_directory(format!("sessions/{session_id}"))
             .into_diagnostic()
             .wrap_err("failed to create session directory")?;
-        let session_agent_dir = session_dir.join("agent");
-        fs_err::create_dir_all(&session_agent_dir).into_diagnostic()?;
+        self.agent_state.prepare_session(&session_dir)?;
 
         let prompt = RAMEKIN_PROMPT.replace("{{WORKSPACE_PATH}}", &self.workspace_target);
         fs_err::write(session_dir.join("ramekin-prompt.md"), prompt).into_diagnostic()?;
@@ -397,15 +590,23 @@ impl Ramekin {
         let session_mounts = self.session_mounts(&session_dir);
         let all_mounts = self.final_mounts(&session_mounts);
         let env_vars = self.config.merged_env();
-        let compose = generate_compose(
-            &dockerfile,
-            &build_context,
-            &all_mounts,
-            &env_vars,
-            &image,
-            &self.workspace_target,
-            pi_args,
-        );
+        let compose = generate_compose(ComposeParams {
+            dockerfile: &dockerfile,
+            build_context: &build_context,
+            build_args,
+            mounts: &all_mounts,
+            env_vars: &env_vars,
+            image: &image,
+            working_dir: &self.workspace_target,
+            prompt_flag: match agent {
+                // Pi's --append-system-prompt accepts a file path; Claude's
+                // takes a literal string, so it needs the -file variant to
+                // read the file rather than append the literal path.
+                config::Agent::Pi => "--append-system-prompt",
+                config::Agent::Claude => "--append-system-prompt-file",
+            },
+            agent_args,
+        });
         let compose_file = session_dir.join("compose.yml");
         fs_err::write(&compose_file, &compose).into_diagnostic()?;
 
@@ -477,13 +678,15 @@ impl Ramekin {
         // Anything the agent wrote to its session-scoped dir is about to be
         // discarded; log it so a path that deserves persistence gets noticed
         // instead of silently vanishing.
-        match discarded_writes(&session_agent_dir, &agent_dir_mountpoints) {
-            Ok(paths) => {
-                for path in paths {
-                    warn!(path = %path.display(), "discarding session-scoped agent write");
+        if let Some(report_dir) = self.agent_state.report_dir(&session_dir) {
+            match discarded_writes(&report_dir, &agent_dir_mountpoints) {
+                Ok(paths) => {
+                    for path in paths {
+                        warn!(path = %path.display(), "discarding session-scoped agent write");
+                    }
                 }
+                Err(e) => error!("failed to inspect session agent dir: {e}"),
             }
-            Err(e) => error!("failed to inspect session agent dir: {e}"),
         }
 
         if let Err(e) = fs_err::remove_dir_all(&session_dir) {
@@ -543,6 +746,26 @@ fn discarded_writes(agent_dir: &Path, mountpoints: &BTreeSet<PathBuf>) -> Result
     Ok(found)
 }
 
+/// Look up a GitHub token from the host environment for build-time API calls.
+///
+/// Tries env vars first, then falls back to `gh auth token`. Returns `None`
+/// if no token is available; the build degrades to anonymous API access.
+fn host_github_token() -> Option<String> {
+    for var in ["RAMEKIN_GH_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"] {
+        if let Ok(v) = std::env::var(var)
+            && !v.is_empty()
+        {
+            return Some(v);
+        }
+    }
+    let output = Command::new("gh").args(["auth", "token"]).output().ok()?;
+    if !output.status.success() {
+        return None;
+    }
+    let token = String::from_utf8(output.stdout).ok()?.trim().to_string();
+    (!token.is_empty()).then_some(token)
+}
+
 /// Generate a random session ID for scoping the compose project and cache dir.
 fn session_id() -> String {
     format!("{:08x}", fastrand::u32(..))
@@ -571,12 +794,11 @@ fn repo_slug(workspace: &Path) -> String {
 }
 
 /// Docker image tag for a workspace's project image, built from its
-/// `.ramekin/Dockerfile`. Kept distinct from the base `ramekin-agent` tag so
-/// `docker compose up` builds the project layer instead of reusing the base
-/// image that shares the tag. Lowercased because Docker repository names must
-/// be lowercase.
-fn project_image_name(workspace: &Path) -> String {
-    format!("ramekin-{}", repo_slug(workspace)).to_lowercase()
+/// `.ramekin/Dockerfile`. Repo- and agent-specific so it neither collides
+/// with the per-agent base tags nor lets one agent's project layer shadow
+/// the other's. Lowercased because Docker repository names must be lowercase.
+fn project_image_name(repo_slug: &str, agent: config::Agent) -> String {
+    format!("ramekin-{repo_slug}-{agent}").to_lowercase()
 }
 
 #[derive(Serialize)]
@@ -605,6 +827,8 @@ struct AgentService {
 struct BuildConfig {
     context: String,
     dockerfile: String,
+    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
+    args: BTreeMap<&'static str, String>,
 }
 
 /// Long-form compose bind mount. Avoids the `source:target[:ro]` short form,
@@ -618,16 +842,35 @@ struct VolumeBind {
     read_only: bool,
 }
 
+/// Inputs for [`generate_compose`], grouped so the container command,
+/// mounts, environment, and build context travel together instead of as a
+/// long positional argument list.
+struct ComposeParams<'a> {
+    dockerfile: &'a Path,
+    build_context: &'a Path,
+    build_args: BTreeMap<&'static str, String>,
+    mounts: &'a [&'a config::ResolvedMount],
+    env_vars: &'a [config::ScopedValue<&'a config::EnvVar>],
+    image: &'a str,
+    working_dir: &'a str,
+    prompt_flag: &'a str,
+    agent_args: &'a [String],
+}
+
 /// Generate a Docker Compose config with all volume mounts.
-fn generate_compose(
-    dockerfile: &Path,
-    build_context: &Path,
-    mounts: &[&config::ResolvedMount],
-    env_vars: &[config::ScopedValue<&config::EnvVar>],
-    image: &str,
-    working_dir: &str,
-    pi_args: &[String],
-) -> String {
+fn generate_compose(params: ComposeParams) -> String {
+    let ComposeParams {
+        dockerfile,
+        build_context,
+        build_args,
+        mounts,
+        env_vars,
+        image,
+        working_dir,
+        prompt_flag,
+        agent_args,
+    } = params;
+
     let volumes: Vec<VolumeBind> = mounts
         .iter()
         .map(|m| VolumeBind {
@@ -649,12 +892,11 @@ fn generate_compose(
         })
         .collect();
 
-    // Always pass --append-system-prompt for the ramekin container context.
-    // The rendered prompt is mounted read-only into the agent dir.
-    let prompt_path = format!("{}/ramekin-prompt.md", config::PI_AGENT_DIR);
-    let command: Vec<String> = ["--append-system-prompt".to_string(), prompt_path]
+    // Always pass the prompt flag for the ramekin container context.
+    // User-supplied agent args come last so they can override.
+    let command: Vec<String> = [prompt_flag.to_string(), PROMPT_TARGET.to_string()]
         .into_iter()
-        .chain(pi_args.iter().cloned())
+        .chain(agent_args.iter().cloned())
         .collect();
 
     let config = ComposeConfig {
@@ -663,6 +905,7 @@ fn generate_compose(
                 build: BuildConfig {
                     context: build_context.display().to_string(),
                     dockerfile: dockerfile.display().to_string(),
+                    args: build_args,
                 },
                 image: image.to_string(),
                 stdin_open: true,
@@ -683,42 +926,35 @@ mod tests {
     use super::*;
 
     #[test]
-    fn project_image_name_is_repo_specific_and_distinct_from_base() {
-        let name = project_image_name(Path::new("/Users/alpha/src/lit-rs"));
-        // Must not collide with the base image tag, or `docker compose up`
-        // reuses the base instead of building the project Dockerfile.
-        assert_ne!(name, "ramekin-agent");
-        assert!(name.contains("lit-rs"), "got: {name}");
+    fn project_image_name_is_repo_and_agent_specific() {
+        let pi = project_image_name("lit-rs-deadbeef", config::Agent::Pi);
+        let claude = project_image_name("lit-rs-deadbeef", config::Agent::Claude);
+        // Must not collide with the per-agent base tags, or `docker compose
+        // up` reuses the base instead of building the project Dockerfile.
+        assert_ne!(pi, "ramekin-pi");
+        assert_ne!(claude, "ramekin-claude");
+        // One project Dockerfile serves both agents; the tags must differ or
+        // one agent's project layer shadows the other's.
+        assert_ne!(pi, claude);
         // Docker repository names must be lowercase.
-        assert_eq!(name, name.to_lowercase(), "got: {name}");
-    }
-
-    #[test]
-    fn project_image_name_differs_per_workspace() {
-        let a = project_image_name(Path::new("/Users/alpha/src/lit-rs"));
-        let b = project_image_name(Path::new("/Users/alpha/src/ramekin"));
-        assert_ne!(a, b);
+        assert_eq!(pi, pi.to_lowercase(), "got: {pi}");
     }
 
-    #[test]
-    fn generate_compose_uses_supplied_image_tag() {
-        let yaml = generate_compose(
-            Path::new("/ws/.ramekin/Dockerfile"),
-            Path::new("/ws"),
-            &[],
-            &[],
-            "ramekin-agent-lit-rs-deadbeef",
-            "/workspace/lit-rs-deadbeef",
-            &[],
-        );
-        assert!(
-            yaml.contains("image: ramekin-agent-lit-rs-deadbeef"),
-            "compose did not carry the supplied image tag:\n{yaml}"
-        );
-        assert!(
-            yaml.contains("working_dir: /workspace/lit-rs-deadbeef"),
-            "compose did not set working_dir:\n{yaml}"
-        );
+    fn compose_params<'a>(
+        mounts: &'a [&'a config::ResolvedMount],
+        env_vars: &'a [config::ScopedValue<&'a config::EnvVar>],
+    ) -> ComposeParams<'a> {
+        ComposeParams {
+            dockerfile: Path::new("/cache/Dockerfile.pi"),
+            build_context: Path::new("/cache"),
+            build_args: BTreeMap::new(),
+            mounts,
+            env_vars,
+            image: "ramekin-pi",
+            working_dir: "/workspace/x-1",
+            prompt_flag: "--append-system-prompt",
+            agent_args: &[],
+        }
     }
 
     #[test]
@@ -728,19 +964,53 @@ mod tests {
             target: "/root/.config/git".into(),
             writable: false,
         };
-        let yaml = generate_compose(
-            Path::new("/cache/Dockerfile"),
-            Path::new("/cache"),
-            &[&mount],
-            &[],
-            "ramekin-agent",
-            "/workspace/x-1",
-            &[],
-        );
+        let yaml = generate_compose(compose_params(&[&mount], &[]));
         assert!(yaml.contains("type: bind"), "{yaml}");
         assert!(yaml.contains("source: /host/.config/git"), "{yaml}");
         assert!(yaml.contains("target: /root/.config/git"), "{yaml}");
         assert!(yaml.contains("read_only: true"), "{yaml}");
+        assert!(yaml.contains("working_dir: /workspace/x-1"), "{yaml}");
+        // No build args → no args key at all.
+        assert!(!yaml.contains("args:"), "{yaml}");
+    }
+
+    #[test]
+    fn generate_compose_env_passthrough_is_a_bare_name() {
+        let with_value = config::EnvVar {
+            name: "FOO".into(),
+            value: Some("bar".into()),
+        };
+        let passthrough = config::EnvVar {
+            name: "GITHUB_TOKEN".into(),
+            value: None,
+        };
+        let env = [
+            config::ScopedValue {
+                scope: config::Scope::User,
+                value: &with_value,
+            },
+            config::ScopedValue {
+                scope: config::Scope::Profile,
+                value: &passthrough,
+            },
+        ];
+        let yaml = generate_compose(compose_params(&[], &env));
+        assert!(yaml.contains("- FOO=bar"), "{yaml}");
+        assert!(yaml.contains("- GITHUB_TOKEN\n"), "{yaml}");
+        assert!(!yaml.contains("GITHUB_TOKEN="), "{yaml}");
+    }
+
+    #[test]
+    fn generate_compose_carries_base_build_arg() {
+        let mut params = compose_params(&[], &[]);
+        params.build_args = BTreeMap::from([("BASE", "ramekin-claude".to_string())]);
+        params.prompt_flag = "--append-system-prompt-file";
+        let yaml = generate_compose(params);
+        assert!(yaml.contains("BASE: ramekin-claude"), "{yaml}");
+        // Claude needs the -file variant: the plain flag would append the
+        // literal path string instead of the prompt contents.
+        assert!(yaml.contains("--append-system-prompt-file"), "{yaml}");
+        assert!(yaml.contains(PROMPT_TARGET), "{yaml}");
     }
 
     #[test]
@@ -771,4 +1041,48 @@ mod tests {
         let found = discarded_writes(dir.path(), &BTreeSet::new()).unwrap();
         assert!(found.is_empty());
     }
+
+    #[test]
+    fn claude_state_mounts_partition_persistent_and_ephemeral() {
+        let state = AgentState::Claude {
+            data_dir: PathBuf::from("/data/agents/claude"),
+            state_file: PathBuf::from("/data/agents/claude.json"),
+        };
+        let mounts = state.mounts(Path::new("/cache/sessions/abc"));
+        let target = |t: &str| mounts.iter().find(|m| m.target == t);
+
+        let data = target("/root/.claude").expect("claude data dir mount");
+        assert_eq!(data.source, PathBuf::from("/data/agents/claude"));
+        assert!(data.writable);
+
+        let state_file = target("/root/.claude.json").expect("claude state file mount");
+        assert_eq!(state_file.source, PathBuf::from("/data/agents/claude.json"));
+
+        // Ephemeral denylist dirs bind session-scoped dirs over the junk.
+        for name in CLAUDE_EPHEMERAL {
+            let m = target(&format!("/root/.claude/{name}"))
+                .unwrap_or_else(|| panic!("missing ephemeral mount for {name}"));
+            assert_eq!(m.source, Path::new("/cache/sessions/abc/claude").join(name));
+            assert!(m.writable);
+        }
+    }
+
+    #[test]
+    fn pi_state_mounts_allowlist_persistence() {
+        let state = AgentState::Pi {
+            state_dir: PathBuf::from("/data/agents/pi"),
+            repo_sessions_dir: PathBuf::from("/data/repos/x-1/sessions"),
+        };
+        let mounts = state.mounts(Path::new("/cache/sessions/abc"));
+        let target = |t: &str| mounts.iter().find(|m| m.target == t);
+
+        let agent_dir = target("/root/.pi/agent").expect("session agent dir mount");
+        assert_eq!(agent_dir.source, PathBuf::from("/cache/sessions/abc/agent"));
+
+        let auth = target("/root/.pi/agent/auth.json").expect("auth mount");
+        assert_eq!(auth.source, PathBuf::from("/data/agents/pi/auth.json"));
+
+        let sessions = target("/root/.pi/agent/sessions").expect("sessions mount");
+        assert_eq!(sessions.source, PathBuf::from("/data/repos/x-1/sessions"));
+    }
 }