| title | .claude composition | |||
|---|---|---|---|---|
| tags |
|
|||
| status | living doc |
Loopat extends Claude Code's
.claude/config model with two extra tiers for team sharing — same files, same fields, more layers. Every artifact Claude Code natively understands works in loopat without changes; you just get four more places to put them.
When a team shares an AI workspace, configuration multiplies fast: each role
needs different skills, each on-call rotation wants different MCP servers, each
person has their own credentials and habits. The standard answer is "everyone
runs their own CLI with their own ~/.claude/" — fine for one person, painful
across a team.
Loopat's answer: don't invent a new config system; just add tiers to the one
Claude Code already has. Skills, agents, plugins, MCP servers, hooks — all
live in .claude/ directories at every tier, and loopat merges them before the
SDK starts. The agent sees a single CC-native .claude/ and doesn't know there
were ever five sources.
Claude Code ships with three setting source tiers — user · project · local.
Loopat adds two more — workspace · profile. Same .claude/ shape at every
tier. The first three are merged by loopat into the SDK's user tier; the last
two are read by the SDK directly from the workdir.
| Tier | Native to | Lives at | Scope | How it reaches the SDK |
|---|---|---|---|---|
| workspace | loopat | knowledge/.loopat/.claude/ |
the whole team | merged into user tier by loopat |
| profile | loopat | knowledge/.loopat/profiles/<name>/.claude/ |
opt-in role / mode | merged into user tier by loopat |
| user (personal) | Claude Code | personal/<user>/.loopat/.claude/ |
one team member | merged into user tier by loopat |
| project | Claude Code | workdir/.claude/ |
one repo | read directly by SDK |
| local | Claude Code | workdir/.claude/*.local.* |
one local checkout | read directly by SDK |
Override order is strongest at the bottom of the table — local beats
everything, and within the merged user tier personal beats every other
loopat-managed source.
If you have ever configured Claude Code, you have already learned loopat.
- Same directory layout.
.claude/settings.json,.claude/CLAUDE.md,.claude/skills/<name>/SKILL.md,.claude/agents/<name>.md— everywhere. - Same setting fields.
enabledPlugins,mcpServers,extraKnownMarketplaces,hooks,permissions,model— they all behave exactly like Claude Code's docs describe. - Same conventions for "drop in to enable". A
SKILL.mdin any.claude/skills/<name>/works. An.mdin any.claude/agents/works. No new schemas.
Loopat invents nothing inside .claude/. The only thing loopat invents is
where additional .claude/ directories can live — namely the workspace
and profile tiers. Once merged, the output is a perfectly ordinary .claude/
that any Claude Code reader (CLI, SDK, docs example) understands.
The practical payoff: when CC adds a new field to .claude/settings.json,
loopat picks it up for free. When you onboard a teammate, they already know
half the config from prior CC experience.
A common assumption: "the SDK is a thin wrapper; it doesn't read filesystem
config." That is wrong — the Claude Agent SDK is built on the same engine
as the Claude Code CLI, and it fully understands .claude/. The
settingSources option controls which tiers it reads:
query({
options: {
settingSources: ["user", "project", "local"],
// SDK auto-loads $CLAUDE_CONFIG_DIR/* (user tier),
// <cwd>/.claude/* (project tier), and *.local.* (local tier).
}
})Loopat's job is therefore not to feed config into the SDK. Loopat's job
is to assemble the merged .claude/ directory and point CLAUDE_CONFIG_DIR
at it. The SDK then walks the directory the same way the CLI would. Two
narrow channels stay outside this filesystem path — both because the sandbox
isolates host state that would otherwise carry the data:
- MCP server credentials. The MCP server configuration (transport,
command, headers, env keys) lives in
.claude/settings.jsonlike everything else — it gets merged and lands on disk inloops/<id>/.claude/settings.json. But credentials (api keys, OAuth bearer tokens) come from the loop's selected vault at spawn time. Loopat reads the merged server list, injects the vault credentials into each server'senv/headers, and passes the augmented list via themcpServers:SDK option. Secrets never get written to settings.json on disk. See the MCP deep-dive below. - The loopat builtin plugin. Loopat ships one bundled plugin that lives
inside the loopat install directory, not in CC's plugin cache. It's
passed via the
plugins:SDK option. All other plugins are declared inenabledPluginsand resolved natively by the SDK from~/.claude/plugins/(ro-bound into the sandbox).
Everything else — skills, agents, hooks, CLAUDE.md, marketplace plugin
selection — is read by the SDK directly from the composed .claude/.
Each tier is a complete, standalone .claude/ directory. Loopat walks them
in order (workspace → profile-1 → … → profile-N → personal) and merges by
content type:
| Content | Merge rule |
|---|---|
settings.json |
Deep shallow union per top-level key. enabledPlugins, mcpServers, extraKnownMarketplaces, hooks merge by sub-key — later tier wins per key. So a personal tier can flip enabledPlugins["foo@bar"] to false and override a team default. |
CLAUDE.md |
Concatenated in tier order, with ## <tier> section headers. Each tier's doctrine layers on top of the previous. |
skills/<name>/ |
Symlink union. Same-name skill in a later tier shadows the earlier one. |
agents/<name>.md |
Symlink union, same rule. |
mise.toml / mise.lock |
TOML table-level union — [tools] and [env] sections each merge by key. |
The result is written to loops/<loop-id>/.claude/ once, when the loop
is created — and from then on the snapshot is immutable. Later admin
pushes to knowledge don't change what an existing loop sees. This is what
makes loops reproducible: spawn the same loop tomorrow and it loads the
same plugin set, the same skills, the same CLAUDE.md, with the same
contents as the day it was created. (See "Plugin version lock"
below for how the snapshot also pins specific plugin versions, not just
which plugins.)
Inside the sandbox, two .claude/ directories actually exist — and
that's by design:
loops/<id>/.claude/— the merged user tier, the loopat-composed source of truth, frozen at loop creation. Mounted at the SDK'sCLAUDE_CONFIG_DIR.<workdir>/.claude/— the repo's own.claude/, read as project tier (and local tier for*.local.*files) directly by the SDK at every spawn. Loopat does not merge this; the repo contributes whatever it contributes, and editing it takes effect on the next spawn.
There is no third .claude/ — the sandbox does not see your host
machine's ~/.claude/. The sandbox $HOME is a fresh overlay with an
empty lower layer, so any host-side CC configuration you have outside of
the workspace stays outside. This is intentional: loops are reproducible
because they don't depend on whatever happens to be in your home directory.
(There is one host-side exception: the directory ~/.claude/plugins/ is
ro-bound wholesale so the SDK can resolve marketplace plugins. Sibling
files like ~/.claude.json and ~/.claude/.credentials.json stay
invisible — see the next section.)
enabledPlugins in settings.json only carries an on/off switch — it
doesn't say which version of each plugin to load. Without a version pin,
a member running claude plugin update on the host would silently change
what an already-created loop sees on its next spawn. That violates
reproducibility.
CC already provides the right primitive: ~/.claude/plugins/installed_plugins.json.
It records, per spec, the version, the marketplace's gitCommitSha at
install time, and the local installPath. Personal CC users don't think
of it as a lockfile, but it is one — it's the only place the specific
code of each installed plugin is identified.
Loopat treats installed_plugins.json as a CC-native lockfile and
brings it into the tier merge:
knowledge/.loopat/.claude/plugins/installed_plugins.json— team lock, committed by adminpersonal/<user>/.loopat/.claude/plugins/installed_plugins.json— personal override, never pushed to team- merged per-spec, last-wins (same rule as
enabledPlugins) - snapshot written to
loops/<id>/.claude/plugins/installed_plugins.jsonat loop creation, never changes - bwrap file-binds this snapshot over the sandbox's host installed state, so the SDK reads pinned versions
| Field | Role |
|---|---|
version |
canonical identifier — used by CC to name the cache directory (~/.claude/plugins/cache/<m>/<plugin>/<version>/) and to decide "is this already installed?". |
gitCommitSha |
audit metadata — records what marketplace commit produced this install. Used for warnings and bug-triage, not lookups. |
This mirrors CC's own design intent: plugin authors are trusted to bump
the version when code changes. If they don't, two different shas can
share a version label — the second install overwrites the first in
cache. Loopat doesn't try to police this contract; if authors break it,
sha-mismatch warnings surface during spawn so users can investigate.
- Old loops never change — the snapshot at
loops/<id>/.claude/plugins/installed_plugins.jsonis immutable. Even after admin pushes a new lock or member runsclaude plugin update, an existing loop's pinned versions don't move. The sandbox bind ensures the SDK reads the snapshot'sinstallPath, which points into the host's cache (~/.claude/plugins/cache/.../<version>/). As long as that cache directory survives (which it does unless someone explicitlyclaude plugin uninstall's it), the loop runs the same code forever. - Admin gates team-wide use — without admin's commit to
knowledge/.loopat/.claude/plugins/installed_plugins.json(or tosettings.json'senabledPlugins), no member's new loop will install a new plugin. Old loops are already frozen. - Personal can override locally — a user can put their own
personal/.loopat/.claude/plugins/installed_plugins.jsonto pin a different version of any spec; their own future loops use it, the team stays unaffected.
Spawning a loop whose lock says cicd@example-skills version 0.1.0:
- Host has 0.1.0 in cache → silent fast path. SDK reads snapshot → loads cache/.../0.1.0/. ✓
- Host doesn't have 0.1.0 (member's marketplace clone has advanced;
CC's
installwould now produce a different version) → loopat runsclaude plugin install, then checks the resultingversion. If it doesn't match the pin, spawn fails with a clear message telling the user how to recover (admin bumps the team lock, or member manually restores the pinned version via marketplace clone checkout). This is fail-loud, not auto-heal — option (a) in our design discussions. - Future enhancement: option (b), where loopat performs the marketplace checkout dance automatically to install the exact sha. Not yet implemented; the manual recovery path is fine as long as version drift is rare.
~/.claude.json is Claude Code's host-side application state file —
different from ~/.claude/settings.json. It tracks:
- account / OAuth state, onboarding completion, notification history
- top-level
mcpServers— globally-registered MCP servers projects.<cwd>— per-directory state includingmcpServers(whatclaude mcp addwrites),allowedTools, trust-dialog acceptance, session usage stats
In other words, it's the file CC mutates as you use the CLI on your host
machine: every claude mcp add, every project you've ever opened, every
permissions choice. Loopat never reads or writes ~/.claude.json, and
the loopat sandbox can't see it (sandbox $HOME is an empty overlay).
What this means for your mental model:
- Adding an MCP server via
claude mcp addon your host does NOT make it available in loops. That command writes to~/.claude.json, which the sandbox doesn't see. - To use an MCP server in a loop, declare it in some
.claude/settings.jsontier (workspace / profile / personal). Loopat will merge it and the SDK will start it. - Host CC and loopat loops have disjoint MCP sets. This is a feature: loops are reproducible regardless of what host CC happens to know about.
The one thing that is shared between host and sandbox is the plugin
install cache (~/.claude/plugins/ — a directory, not the
.claude.json file). Loopat ro-binds it so the SDK inside the sandbox can
resolve enabledPlugins natively. Plugin install state is a small,
file-tree-shaped global; mixing it doesn't compromise the
reproducibility story the way MCP-on-host-CLI would.
Anything that lives inside .claude/ automatically gets the five-tier
treatment. That includes everything Claude Code natively supports, plus
small loopat-side extensions:
-
Skills —
.claude/skills/<name>/SKILL.md— reusable named procedures invoked as/<skill-name>. Drop one in any tier; it becomes available to every loop that selects that tier. -
Subagents —
.claude/agents/<name>.md— delegated agents the main Claude can hand work to. Frontmatter declaresdescription,tools,model; body is the agent's system prompt. -
MCP servers — declared in
.claude/settings.jsonundermcpServers. Loopat injects vault credentials at spawn so secrets never sit in config files. -
Plugins — declared in
.claude/settings.jsonunderenabledPluginsand (when needed)extraKnownMarketplaces. The plugin itself stays in Claude Code's standard plugin cache; loopat ensures it gets installed on the host and visible to the sandbox. -
Hooks — declared in
.claude/settings.jsonunderhooks. Scripts triggered onSessionStart,PreToolUse,PostToolUse, etc. -
CLAUDE.md— team doctrine, role expectations, system prompt fragments. Concatenated across tiers. Profile authors: lead with a one-line description so the New Loop dialog and per-loop header can surface what each profile is for. Two formats supported:--- description: ML training oncall — sls + spectrum + example CLI ready --- # ML Test 角色 ...
Or the legacy form (kept for backward compat):
# ML Test 角色 — generate and verify mock data ...Frontmatter
description:wins when present; otherwise loopat falls back to the first heading text (stripped of#). Frontmatter is the recommended form: it survives doctrine edits that rewrite the heading. -
Mise toolchain —
.claude/mise.toml+.claude/mise.lock. Loopat's own addition: pins the version of every tool the loop's shell will see. Team can pin Node, profile can add Python, personal can override a single version — same merge model. -
Other CC fields — anything else in
.claude/settings.json(permissions,model, output styles, statusline, future fields) gets the same per-key tier union for free.
The rule of thumb: want to share something across the team or selectively
across roles? Express it as a .claude/ artifact and put it in the tier
that owns it. Loopat handles the rest.
For each capability: what it is, where its definition lives, how it gets turned on, how credentials are handled (if any), how plain CC / the SDK / loopat each activate it, and where it lands inside the sandbox.
| Skill | Subagent | MCP server | Plugin | Hook | Mise toolchain | |
|---|---|---|---|---|---|---|
| What it is · when to use it | A named procedure invocable as /<name>. Stable, repeated workflows you want the human to trigger by name. |
A delegated agent with its own prompt, tool restrictions, and model. The main agent hands work off via the Task tool. |
A long-running process that exposes external tools (Jira, GitHub, internal APIs) to Claude over stdio / HTTP / SSE. | A distributable bundle of skills + agents + MCP + hooks, with marketplace metadata for cross-team sharing. | A script triggered on lifecycle events (SessionStart, PreToolUse, PostToolUse, …). |
Pinned tool versions (Node, Python, etc.) so every loop's shell sees the same toolchain. (loopat extension) |
| Where to define | .claude/skills/<name>/SKILL.md plus optional siblings in the same directory. |
.claude/agents/<name>.md — single file, frontmatter + body. |
.claude/settings.json → mcpServers.<name>: { type, command, args, headers, env, … }. |
.claude/settings.json → enabledPlugins["foo@market"]: true (+ extraKnownMarketplaces if not built in). |
.claude/settings.json → hooks.<event>: [{ matcher, hooks: […] }]. |
.claude/mise.toml (versions) + .claude/mise.lock (lockfile). |
| How to enable | Presence-based. Drop the directory in; it's enabled. | Presence-based. Drop the file in; it's enabled. | Presence-based, key by key. Listing the server under mcpServers enables it; remove the key to disable. |
Explicit flag required. enabledPlugins["foo@market"]: true. Files in ~/.claude/plugins/ alone don't enable anything. |
Presence-based per event. Listing a hook under hooks.<event> enables it. |
Presence-based. Listing a tool in [tools] enables it for the loop's shell. |
| How auth works | None — skills are just markdown. | None — agents are just markdown + prompt. | Loopat reads the selected vault and injects credentials into env / headers at spawn; the augmented config is passed via the mcpServers: SDK option. Plain CC stores OAuth in ~/.claude/.credentials.json; loopat instead manages tokens per-vault. |
Marketplace install may need git auth (SSH key, HTTPS PAT) — runs on host, uses host's git creds. Plugins themselves usually don't carry their own auth (their bundled MCPs do, see above). | None — hooks are just scripts; whatever creds they need they read themselves. | None — mise install runs in the host with whatever creds it already has (rare). |
| How plain CC activates it | CC scans <config-dir>/skills/ at session start; available immediately. |
CC scans <config-dir>/agents/; subagents listed via Task tool. |
CC reads mcpServers from each settings tier and starts each server at session init. |
Resolves spec → ~/.claude/plugins/installed_plugins.json → loads installPath. Requires the user to have run claude plugin install <spec> first. |
CC registers the hooks at session init; invokes the script when the matching event fires. | Not a CC concept. (Mise activates outside of CC.) |
| How the SDK activates it | Discovered via settingSources ('user', 'project'). Narrowing option: skills: 'all' | string[]. |
Discovered via settingSources or defined programmatically via agents: { <name>: { ... } }. |
Either via settingSources (settings.json) or directly via the mcpServers: option (loopat uses this so it can inject credentials). |
Either via settingSources (CC plugin cache resolution) or programmatically via plugins: [{type:"local", path:...}]. |
Either via settingSources or programmatically via the hooks: option. |
Not an SDK concept. |
| How loopat activates it | Drop into any tier's .claude/skills/. Merged into loops/<id>/.claude/skills/ as a symlink union; SDK discovers via settingSources: 'user'. |
Drop into any tier's .claude/agents/. Same merge mechanism. |
Add to any tier's .claude/settings.json mcpServers. Compose merges by key; loopat then reads the selected vault, injects credentials, and passes the augmented map via the mcpServers: SDK option. |
Add to any tier's .claude/settings.json enabledPlugins. Compose merges. ensureLoopPluginsInstalled runs claude plugin install on host for anything missing; bwrap ro-binds ~/.claude/plugins/ wholesale so SDK resolves natively. |
Add to any tier's .claude/settings.json hooks. Standard settingSources discovery. |
Add to any tier's .claude/mise.toml. Bwrap runs mise install + mise env --json on the merged file before sandbox spawn and injects PATH / env via --setenv. |
| Where it lands in the sandbox | loops/<id>/.claude/skills/<name>/ — a symlink to the source tier's host path. |
loops/<id>/.claude/agents/<name>.md — symlink to the source tier's host path. |
Server config lives in loops/<id>/.claude/settings.json (no creds). Augmented config (with creds) reaches the SDK in memory; the running server is a regular host process the SDK talks to. |
Plugin code is at ~/.claude/plugins/marketplaces/<m>/plugins/<n>/ (ro-bound wholesale into the sandbox). Activation is via the loop's merged enabledPlugins. |
loops/<id>/.claude/settings.json hooks field; script lives at its source tier's host path (covered by the workspace / personal binds). |
loops/<id>/.claude/mise.toml + injected env vars; tool binaries from host ~/.local/share/mise/ (also bound in). |
| Version lock | The file contents are themselves the "lock" — a skill is just markdown. compose symlinks point to a specific host path; renaming or rewriting the source file changes any loop's spawn-time view. (Frozen for the loop only if the source file itself stops changing.) | Same as skill — the .md file is the lock. |
The mcpServers entry (transport / command / args / env-keys) IS the spec; it's deep-merged into the loop's settings.json snapshot at creation, so the loop sees the merged config forever. Credentials are injected fresh from the active vault at each spawn (intentionally not pinned). |
.claude/plugins/installed_plugins.json — CC-native, same shape host writes. Merged across tiers (per-spec last-wins), snapshotted into loops/<id>/.claude/plugins/installed_plugins.json at creation, file-bound over the host's at spawn. Pins both version (used for cache resolution) and gitCommitSha (audit). |
The script path in settings.json is the "lock". As with skills, the script content is whatever is at that path at spawn time. (Hooks pointing into team-managed source dirs are effectively pinned by the source not being rewritten.) |
.claude/mise.lock — CC-extended, mise-native. Compose merges per-tool last-wins; snapshot frozen with the loop. mise install uses the lockfile to resolve identical versions across machines. |
architecture.md— sandbox / vault model, read & write paths, full bwrap layout.composition.svg— the diagram on its own.- Source of truth:
server/src/compose.tsandserver/src/profiles.ts.