byre -- architecture
August 25, 2026 · View on GitHub
How byre works, as built. This file is the mechanics reference; it
describes current state only. Its sibling documents own the other lanes:
GLOSSARY.md (canonical vocabulary -- this file uses it), PRINCIPLES.md
(standing commitments), adr/ (point-in-time decisions and their
rationale), TODO.md at the repo root (open work), marketing/
(positioning and launch copy).
byre is a small Go binary that runs an AI coding agent in a throwaway,
project-scoped container. cd ~/project && byre develop drops the agent
into a box that sees this project and what you explicitly grant -- not
your home dir, keys, or the rest of your machine.
Name: Scots/Northern-English for a cowshed (pronounced like buyer) -- the enclosure you keep the thing in so it doesn't wander off.
The shape
byre is a transparent templating layer for running agents in containers. It is a convenience for the common case; it never stops you writing Docker (PRINCIPLES.md #3). The split that defines the project (PRINCIPLES.md #2):
- Core owns the chassis -- the agent-runtime scaffolding everyone reinvents (host UID/GID baked into the image, git identity, the launcher, credential persistence). Core ships no opinions.
- Skills own the opinions. The workflow is a skill; the firewall is a
skill; the agent itself is a skill (ADR 0005) -- byre ships agent
skills for Claude, Codex, Gemini, Grok, and OpenCode, and
agentselects which one launches. You compose your baseline from skills.
Image-building is solved -- Docker does it well and everyone knows the syntax. byre owns the frame around your Docker, not the Docker.
Security contract
What byre actually guarantees -- stated plainly so it isn't mistaken for more:
- Isolated: the host filesystem, environment, and credentials. The
agent sees only what you explicitly mount or pass -- not your home
dir, SSH/cloud keys, env, or the rest of the machine. This is byre's
core promise. (Narrow, named exceptions: the core
env_from_hostlayer passes through gituser.name/user.emailfor commit attribution, plusTERMandTZ-- see The chassis. Anything more crosses only as an explicitenv_from_hostgrant, ADR 0026/0031.) - Not isolated -- by design: the network (open by default, see
below) and the mounted project itself (
/workspaceis read-write so the agent can edit and commit your code). With the network too, an agent can exfiltrate the project it's working on. The mount alone is enough for something else: the project is a directory your host tools execute, so an agent can leave code behind -- git hooks, git config -- that runs on your machine later, as you, and some of it never appears ingit diff. The box keeps the agent out of host state you didn't grant it, for as long as the box is running; it does not make what the agent leaves in the project safe for your host to run afterwards. - Opt-in holes: anything that widens the boundary is a grant you
choose, not a default -- a host-socket mount, extra host mounts, ports.
byre makes every grant legible via
byre status(PRINCIPLES.md #4); it does not block them. Core bundles none of these -- core ships empty.
The doctrine behind every line above -- the threat model is the agent, never the user; legibility instead of gates; degrade claims, never refuse -- is PRINCIPLES.md #1 (the footgun doctrine). It is normative and lives there, not here.
Network
Open to the world by default. No firewall, no NET_ADMIN/NET_RAW added
by core; because byre makes no network-containment claim by default, the
container's stock capability set is not a security surface byre reasons
about.
Network restriction is the built-in firewall skill (opt-in): it flips a box's posture to deny-by-default egress with an allowlist. How it works:
developstarts the box normally; the launcher sees the gate file the skill baked into the image and waits at the launch gate -- at the very top, before first-run hooks -- for a ready signal (ADR 0011).- Concurrently, byre (host-side) runs the netns helper: a
run-to-completion container sharing only the box's network namespace,
root +
NET_ADMIN, targeted by a per-invocation nonce label. It resolves the egress allowlist, installs port-scoped per-IP ACCEPT rules plus a default-DROP OUTPUT policy (v4 + v6), and self-verifies with a deny probe (ADR 0010). The probe covers v4 always and v6 where the netns has a global v6 address to probe from -- otherwise a probe that cannot leave would be indistinguishable from one that was blocked, so the helper reports the v6 side as applied-but-unverified rather than counting it. - The helper listens once on loopback; the launcher's poll-connect
succeeds and the agent execs behind the wall. Any failure -- helper
death, DNS failure,
docker restartrecreating the netns -- means no signal, timeout, and the box dies closed (ADR 0011).
The allowlist is derived, and minimal by ruling (ADR 0020): every
enabled skill declares the [runtime] egress = ["host[:port]"] it NEEDS
to function (agents carry their API endpoints -- enabling the agent is
the intent), unioned with the user's egress config key (ADR 0012, key
per ADR 0019 -- it cascades like every other list), minus the config's
closures -- !host[:port] entries, which survive the cascade and
subtract from the derived union LAST, skill-declared entries included
(ADR 0030; portless closes every port). Nothing else opens: convenience
endpoints (git hosting, apt, language registries) ship as
egress_offered -- declared-but-closed doors the config UI opens with
one press, writing the entry into the user's own config. Empty is legal
-- a maximally-locked box. byre status prints the posture under
honesty rules (skill contributions are trusted and attributed, unless
they DISPLACE byre's own machinery -- a reserved BYRE_ knob degrades
the claims it can skew, a mount over a byre-managed path gets a
containment line; project-level raw blocks degrade the claim -- ADR
0010's annotation, ADR 0050, ADR 0052) and shows the
resolved allowlist as an Egress section attributed per source (each
skill, and config for the key's entries), closures as Closed: rows.
The firewall-open skill (ADR 0030) is the same mechanism with the
opposite default -- the open-denylist posture: the netns helper leaves
the OUTPUT policy at ACCEPT and drops only the closures' resolved IPs.
Best-effort by design (an IP snapshot aimed at well-behaved telemetry
clients; the deny-by-default wall is the containment posture), but
fail-closed all the same: any helper failure -- including a closure
whose host doesn't resolve, which would otherwise stay silently
reachable -- kills the launch. The two enforcement siblings are mutually
exclusive (both declare a network_posture; resolution rejects two).
Image generation
byre's job is to generate a Dockerfile from config; Docker's job is to build and cache it (ADR 0001 -- byre owns no caching layer).
At every launch:
- Resolve config -> generate Dockerfile text + build context.
- Write it to
~/.byre/projects/<project_id>/context/Dockerfile.generated. docker build --build-arg BYRE_UID=<uid> --build-arg BYRE_GID=<gid> -t byre-<project_id>-u<uid>-g<gid> <context>(the UID/GID are baked in, so the tag carries them -- ADR 0008).docker run --rm -it --label byre.project=<project_id> byre-<project_id>-u<uid>-g<gid>(foreground; see Box lifecycle).
On an unchanged config every instruction is a Docker cache hit, so the
build is a near-no-op and launch is effectively instant. A change
rebuilds only the layers from the changed instruction onward. Freshness
is deliberate staleness: byre rebuild (--no-cache) is the valve for
pulling fresh upstream packages; byre guarantees Dockerfile determinism,
not upstream-artifact reproducibility (ADR 0001).
Instruction ordering is load-bearing -- not for byre's own caching (it has none) but because it determines how well Docker's layer cache shares work across projects. The generator emits blocks in a stable order, expensive-and-shared first, cheap-and-project-specific last:
FROM <base> # from template config
<template block> # shared across all projects on this template ┐ Docker
<core block> # constant: build-only gosu, baked dev user │ layer-
<skill apt (hoisted)> # every skill's apt RUN, ahead of all blocks │ cached
<skill blocks> # enabled skills: bundled, installed, local │ across
<project block> # this project only │ projects
<security guard> # re-COPY chassis paths (launcher, gate, fw) │
USER dev / ENTRYPOINT ... # constant: drop to the baked user, then exec ┘
Skill blocks emit in provenance order -- bundled, then installed, then local, stable within each class (ADR 0041). Provenance is a volatility proxy: bundled skills change only with the byre binary, installed packages on install events, local packages whenever their working tree is edited. Docker invalidates every layer after the first changed one, so stable-before-volatile means editing an installed skill's payload no longer re-runs the bundled installers (agent, codex, grok) behind it. Enable order is unchanged everywhere the agent sees it (context composition, status); only image layers move.
Skill apt hoists above the skill blocks (ADR 0042): every skill's apt
RUN emits, one per skill in the same provenance order, in its own section
between the core block and the skill blocks. Within a block apt already ran
before the skill's own COPYs and raw lines, so no declarative apt list can
depend on a raw line; hoisting preserves that order while putting the only
skill layers with a network dependency on mutable external state (apt-get update) where payload and raw-line churn can't invalidate them.
The security guard re-COPYs byre's own copy of the security-critical
files -- the launcher (the ENTRYPOINT's content), and, when a network-posture
skill is enabled, its launch gate and netns enforcement script -- after the
project block. Those files are installed early (core/skill blocks), so without
this a project files entry targeting their paths would be a
later COPY and win in the built image: a one-line clobber could empty the launch
gate or stub the firewall while byre status still read deny-by-default. Same
posture as the USER/ENTRYPOINT/HEALTHCHECK NONE tail: byre forces its
security-critical instructions last wherever it controls the order. A files
destination that collides with a guarded path draws a note at develop and
byre dockerfile (byre's copy takes precedence; the override isn't silent).
The guard reaches only the image. A runtime mount or volume over a
byre-managed path -- the launcher, a netns hook, or anything under
/etc/byre (the launch gate and the baked delivery artifacts) -- is applied
by the engine over the finished image, where byre has nothing left to
re-assert with. So that case is disclosed instead of guarded: one loud line
at develop, in status's Containment register, and in a preset's grant review,
covering the project's own mounts and any a skill declares, attributed
(ADR 0052).
The core block precedes skills: it's constant, so placing it ahead of
the varying skill blocks keeps it cache-shared across all projects on a
base, and it means the dev user and gosu exist when skills build --
a skill can install as the dev user (e.g. gosu dev in a RUN) rather
than as root.
Ten projects on one node template share the early layers via Docker's
layer store; only the project tail diverges. byre never parses inside a
raw block, so it can't dedupe across them -- keep expensive shared
installs in the template block, not per-project blocks. That's the only
caching discipline required.
The generated Dockerfile is printed by byre dockerfile. byre shows its
work and you can always read or eject from it.
Container engine
byre targets Docker and Podman through a thin runner abstraction over
the operations it needs -- build, run, volume/image/container ops --
shelling out to the engine CLI, never the SDK (ADR 0002). engine = "auto" (default) picks docker if present, else podman.
Detection resolves the CLI to an ABSOLUTE path and every engine call
runs that path, pinned for the invocation -- as do byre's other
host-side spawns (git, ssh, the shell behind $EDITOR, the
clipboard helpers), all through internal/hostexec. A binary PATH
resolves out of a directory this project's box can write -- the work
tree, the main tree, the common git dir, byre's store for the project --
is declined rather than run, naming the tool, the path and the
directory. develop refuses outright on its engine; the session-end
probes degrade and disclose instead, since a session end must not be
blockable by the thing it reports on (ADR 0047). A declined engine is
never read as an ABSENT one: develop's single-session check treats it
as uncheckable rather than as one engine fewer, and the commands that
speak in totals (forget, reset, rehome) refuse rather than claim
"completely removed" over an engine byre never reached.
Rootless Podman is a first-class path with its own ownership math
(ADR 0032): the chassis bakes a GENERIC dev uid (1000) instead of the
host's, and the box -- plus every helper container that fills its volumes
-- runs under --userns=keep-id:uid=1000,gid=1000, mapping the invoking
user onto the baked id, so files land correctly owned just like the
rootful bake (ADR 0008). The netns-init helper joins the box's own userns
(--userns=container:<box>) instead, since NET_ADMIN over a netns exists
only inside the userns that owns it. Rootless Podman OLDER than 4.3
(no explicit keep-id mapping form) keeps the old detect-and-refuse;
BYRE_ALLOW_ROOTLESS_PODMAN=1 overrides with the warning retained, the
same shape as the root-host refusal (BYRE_ALLOW_ROOT=1).
Box lifecycle
The container is throwaway; the volumes and image are not. byre develop runs docker run --rm -it in the foreground: a fresh container
each session, removed on exit. What persists across sessions is the
project-scoped image and the named volumes (state + cache) -- agent auth,
shell history, and caches survive; no long-lived container accretes
cruft.
A running session is identified by labels (byre.project +
byre.workdir), not an assumed name (ADR 0004); that's how byre status
finds "is a session running for this directory?".
Every container also carries a launch record (byre.launch=<sha256>,
ADR 0053). Under the setup lock, immediately before create, byre writes
what it is about to tell the engine -- binds, ports, volumes, env KEYS
(never values), network posture and resolved egress, the image tag AND
digest, run_args verbatim, the skill identities -- to
~/.byre/projects/<id>/launches/<sha256>.toml, named by the sha256 of its
own bytes, and stamps that hash on the container. It records what byre TOLD
THE ENGINE, deliberately not the config that produced it: the record is one
step closer to reality than config, never a second copy of it.
byre status reads it back and VERIFIES it by re-hashing (the store is
box-writable under --self-edit, so the address is checked, not trusted;
the record only ever informs a human reading status and drives no host
action). While a box runs, that box is status's subject: the grant rows
come from its record and the Container row says so, and a Next launch
section lists only what differs in the current config. A record byre cannot
read or verify degrades the page with one qualifier instead of a guess.
Records are reaped opportunistically at the next create, when nothing
points at them.
develop is single-session per directory (ADR 0004): if a session is
already running here, it reports that session (and how to re-attach to
its terminal, stop it, or get a shell via byre shell) rather than
spawning a parallel one -- two boxes
on one directory would race the shared state volumes. For two agents on
one codebase, use worktrees: each worktree is its own workdir with its
own session, deliberately sharing the project's config, volumes, and
image (ADR 0009). A volume that cannot take two concurrent holders says
so with sharing = "exclusive", and develop then refuses the second
box rather than corrupt it (ADR 0054).
The configuration a session launches is read under the setup lock. The
config editor's save takes that same lock, so a save landing while develop
waits for it is the one that launches -- develop's earlier read only decides
what must happen before the lock (whether to onboard, which engine to
detect, which host tools to pin), and everything the lock guards -- generate,
build, seed, create, and the exposure banner that describes the result -- comes
from the read taken under it. rebuild and the worktree create step read
under the lock for the same reason. The one thing the later read cannot honor
is a changed engine: the runner, the identity mode (ADR 0032) and the image
tag are already fixed by the earlier detection, so all three refuse by name
rather than build or launch on the engine the config just stopped naming.
Config
(The user-facing reference -- every key, with usage guidance -- is the
site's key reference, site/content/docs/configuration-reference.md;
the editor walkthrough is configuration.md. This section is the
design view: the same facts with their rationale and ADRs.)
Cascade, config-only (image steps are compiled output, never hand-written twice):
~/.byre/default.config your personal baseline
~/.byre/templates/<name>/ template.config (+ optional files)
~/.byre/layers/<name>/ layer.config (named layers, chained)
~/.byre/projects/<id>/byre.config project config (the HOST-SIDE store)
Resolution: default ⊕ template ⊕ chain(root … parent) ⊕ project.
Named layers (ADR 0035) are user-authored shared baselines (an
employer config, a personal toolkit) slotted between the template and
the project. The project config -- or any layer -- names at most ONE
parent via extends = "<name>"; byre walks the pointers to the root
and merges root-first. Chains are linear (no lists, no diamonds);
cycles and dangling parents are hard errors naming the loop / the exact
path to create. Layers are plain files, not packages (no version, no
install verbs -- ADR 0029's boundary), carry the full config vocabulary
except template, and resolve LIVE at every develop: editing a layer
changes every extending project's next box, with no ceremony -- the
same trust position as editing default.config. Attribution names the
layer everywhere (status's Extends: chain, the config UI's
layer:<name> tags, preset-review grant rows); layer files sit outside
the --self-edit writable set, so a boxed agent can never edit a file
that propagates into other projects' sandboxes. Manage with byre layer new|list|validate and edit with byre config --layer <name>.
The project layer lives host-side, NOT in the project tree (ADR
0003): a config inside the rw-mounted project would let the boxed agent
rewrite its own sandbox. A repo can ship a preset -- conventionally
byre.preset, a complete config proposal (ADR 0029; the retired
in-repo byre.config spelling is refused with the rename remedy) --
but cloning
gives you a file, not a prompt: nothing takes effect until an explicit
byre preset apply, which chauffeurs installs for missing packages,
shows the composed box's full grant review (with a diff against the
current store config -- applying replaces the whole file), and writes
the store byre.config on confirm, recording an applied marker. Drift
from the applied version is a passive develop/status note, never a
question. --self-edit is the one announced exception: the
session opens with a loud escalation warning and closes by reporting what
changed in the project store -- byre.config as a content diff (it applies
on the next develop), every other file listed as added/changed/deleted.
- Scalars override -- last layer wins,
seed_prefsincluded: an explicitseed_prefs = falsein a later layer turns an inherited opt-in off (tri-state, ADR 0045); unset inherits. - Lists union, identity replaces -- plain string lists (
skills,apt,egress) accumulate across layers; an entry with an identity replaces the accumulated entry of that identity (mounts by target, volumes and[[mcp]]/[[claude_skills]]/[[context]]by name, ports by container port -- ADR 0018's 2026-07-28 amendment). - Removal markers -- a later layer drops something an earlier layer
added:
!namewhere the entry's identity is a string (skills, apt, volumes, mounts by target),remove = truewhere it's structured (ports, keyed by container port alone). ADR 0018. Env has no unset (override the value instead); raw blocks are unnamed lines: append-only union, no per-line removal.
Vocabulary is deliberately minimal -- the convenient 90%:
engine = "auto" # auto | docker | podman
template = "node" # which ~/.byre/templates/<name> to layer on (optional)
agent = "claude" # which agent skill launches: claude | codex | gemini | grok | opencode
seed_prefs = true # one-time curated prefs seed (ADR 0013); off by default
base = "node:22"
apt = ["build-essential"]
env = { FOO = "bar" } # literals, baked into the image (not a grant)
files = { "./seed" = "/opt/..." } # copied into image, read-only
skills = ["pjlsergeant/devlog", "firewall"] # bundled names bare; installed qualified
mounts = [ ... ] # host-bind mounts (see Mounts & volumes)
ports = [{ container = 3000 }] # published ports; binds 127.0.0.1 unless
# interface says otherwise, host defaults to container
volumes = [ ... ] # ad-hoc named volumes; skills usually supply these
dockerfile_pre = ["RUN ..."] # raw BUILD block, before the core block
dockerfile_post = ["RUN ..."] # raw BUILD block, project tail
run_args = ["--cap-add=SYS_PTRACE"] # raw RUNTIME block: docker-run passthrough
[[mcp]] # MCP servers: wiring, not grants (ADR 0033)
name = "github" # `!name` in a later layer closes one, even
command = ["github-mcp-server", "stdio"] # a skill-declared one
env = ["GITHUB_TOKEN"] # var NAMES the server consumes, never values
[[claude_skills]] # Claude Skills: wiring, not grants (ADR 0039)
name = "tdd-loop" # same `!name` closure semantics as [[mcp]]
path = "~/claude-skills/tdd-loop" # host dir whose root holds SKILL.md
[[context]] # standing agent instructions (ADR 0043)
name = "house-rules" # layers replace by name; `!name` removes
text = "Run the linter before committing." # inline -- or file = "~/notes/agent.md"
Raw blocks are symmetric across both layers byre controls (PRINCIPLES.md #3):
| layer | nice primitives | raw block |
|---|---|---|
| build | base, apt, files, env | dockerfile_pre, dockerfile_post |
| runtime | mounts, volumes, env | run_args |
byre never parses inside a raw block -- byre status counts the raw build
lines and flags them as not-introspected, and byre status --full shows
them verbatim.
run_args is last-wins (ADR 0006): byre's own flags first, run_args
appended last, so a raw flag can override byre's -- except the labels byre
puts on the container (identity, client pid, netns nonce, launch-record
address), all re-asserted after it.
There is no full-Dockerfile opt-out: byre either generates the build or isn't involved (ADR 0014). A whole hand-written Dockerfile means raw Docker, not byre.
Skills
A skill is a portable bundle that can contribute to any layer byre controls:
- build -- Dockerfile block(s) + files
- runtime -- mounts, env, egress declarations, network posture
- agent context -- a snippet appended to the agent's instructions
- state -- named volume(s) it needs
Agents are skills (ADR 0005). An agent skill contributes its CLI
(build), its launch command + autonomy flag, and its auth state volume
(.claude / .codex / .gemini / .grok / .opencode). The chassis ENTRYPOINT is a
constant
launcher that execs the selected agent skill's recorded command; the
agent scalar picks which, and implicitly enables that skill. More
than one agent skill can be enabled; agent decides the default command.
A skill declares its contributions in skill.toml. Minimal shape (an
agent skill):
# ~/.byre/skills/claude/skill.toml
[build]
[agent] # marks this as an agent skill
command = "claude --dangerously-skip-permissions" # what the launcher execs
state = ".claude" # name of its state volume
[runtime]
egress = ["api.anthropic.com"] # what the firewall opens for it (ADR 0012)
[[volumes]]
name = ".claude"
role = "state"
target = "/home/dev/.claude"
# (no seed: the agent logs in IN THE BOX -- ADR 0007)
[context]
file = "agent-context.md" # appended to agent instructions
A non-agent skill simply omits [agent]. Skills are template-independent:
they drop onto any supported base identically ("supported" = the
Debian-derived family of The chassis: a skill may assume apt, a POSIX
shell, and root at build time, nothing more specific). Each skill gets its
own insertion slot and its own Docker layers. Skills and templates are
packages (ADR 0029) with three provenances: bundled live inside
the byre binary, immutable, under byre/* ids (a display mirror sits at
~/.byre/bundled/, never loaded from); local are editable
directories under ~/.byre/skills|templates/; installed are
content-addressed, hash-verified snapshots acquired with
byre skill install <manifest-url> and inert until a config enables
them. To edit an immutable package under a NEW id, byre skill fork it into
a local one; to make a checkout the live source for the SAME id,
byre skill adopt it. docs/SKILLS.md is the user guide. The store carries its own guide
for host-side coding agents at ~/.byre/AGENTS.md -- byre-owned,
regenerated whenever it differs from the running binary's copy: the map,
per-entry write rules (the consent-document rule for
projects/<id>/byre.config above all), and the version-control and
sharing conventions.
Enabling a skill is trusting it (PRINCIPLES.md #2): skill content is
validated for legibility, not as a trust boundary. A skill's grants (a
mounted host socket, a network posture) are named by byre status, never
hidden -- and never blocked.
MCP provisioning
[[mcp]] blocks (config layers and skill.toml alike) declare MCP servers
for the box -- wiring, not grants (ADR 0033, GLOSSARY): the
declarations list as configuration, while the egress a remote url
implies (plus declared extras) and the env NAMES a server consumes render
as attributed grants (mcp:<name>). The effective set -- config cascade
(later layer replaces by name) ∪ skill contributions, minus !name
closures (ADR 0030 semantics: a closure reaches a skill-declared server)
-- bakes deterministically to /etc/byre/mcp.json in every image,
empty set included; the path and format are a stable contract for
anything that wants the set. Delivery is injection, per-agent, vouched
by [agent] mcp = "inject": claude's command carries --mcp-config;
codex's is a skill-owned wrapper deriving per-invocation -c overrides
from the same file; opencode's is the same wrapper shape building an
OPENCODE_CONFIG_CONTENT env layer (deep-merged by opencode, so byre's
servers compose with user config; live-verified 2026-07-17). byre never
writes an agent's MCP state -- ADR 0033
walked the state-writing registrar back. An agent skill without an
adapter degrades honestly -- byre status shows
declared-but-NOT-delivered plus the baked path. byre mcp add|remove|list is the CLI sugar (remove is closure-smart; --global
targets default.config), and the config UI has the full editor screen.
Tokens never enter byre files: env = ["GITHUB_TOKEN"] names what the
server consumes, values arrive via env_from_host/[env], and a box's
stdio servers inherit the box env. Remote OAuth is agent-owned on the
project state volume (claude mcp login --no-browser works headless via
URL paste-back).
Claude Skills delivery
[[claude_skills]] blocks declare Claude Skills (Anthropic's
agent-skill format: a directory whose root holds a SKILL.md) for the box
-- the same wiring-not-grants model and merge taxonomy as [[mcp]] (ADR
0039): config layers replace by name, skill.toml contributions union
after, !name closures reach skill-declared entries, duplicates
hard-reject. Config declares a host path (~/… or absolute); a
skill.toml contributes a package-relative from (containment-checked
like [build].files). Each declaration is validated as a Claude Skill at
bake (root SKILL.md, YAML frontmatter name/description, name match,
64-file/8 MiB bounds, no symlinks) and the merged set bakes to
/etc/byre/claude-skills/.claude/skills/<name>/ in every image,
empty set included -- claude's native discovery layout, so skills load
BARE (/name). Delivery is injection, vouched by [agent] claude_skills = "inject": the claude skill's command carries --add-dir /etc/byre/claude-skills, and that flag is the whole adapter (no state
writes; a same-name skill the in-box agent authored into its own
~/.claude/skills shadows the delivered one -- box state wins).
Adapter-less agents degrade honestly in status. byre claude-skill add|remove|list is the CLI sugar; the config UI has the editor screen.
Standing instructions
[[context]] blocks declare standing agent instructions -- prose the
operator wants in front of the agent in every box the declaring layer
reaches (ADR 0043). The cascade is the scoping: default.config reaches
every box on the machine, a template or named layer its stack, the
project config one project. Config-only vocabulary (no skill.toml twin --
a skill's prose is its own [context] table): layers replace by name,
!name removes an inherited snippet. Each declaration carries inline
text (the portable form for templates) or a host file (~/… or
absolute), read at bake under the skill-context size cap -- a missing
file fails the develop, attributed to its declaration. The merged prose
joins the baked agent context AFTER the skill snippets, in cascade order,
and is INJECTED into the agent's session through that agent's own
vendor channel (ADR 0046: claude's --append-system-prompt-file,
codex's developer_instructions, grok's --append-system-prompt,
gemini's include-dir memory, opencode's instructions config -- each
vouched per skill with [agent] context = "inject"; no vouch = not
delivered, and status says so). byre never writes an agent-owned file
to deliver prose -- the retired context_target placement rewrote each
agent's own instruction file every launch, and ADR 0046 buried it.
Edited in the Instructions section of byre config (prose via the
$EDITOR handoff) or byre context add|remove|list (add with no flags
opens $EDITOR, the git-commit shape). Three voices, three channels: the skill author's
([context] in skill.toml), the project's (in-tree agent memory,
committed and agent-writable), the operator's ([[context]], host-side
and out of the box's reach -- short of a --self-edit session's
explicit grant over the project layer).
Mounts & volumes
Two mount species:
- host-bind -- a host path into the box, declared in
mounts(defaultro). The project itself is the implicit one (/workspace, read-write by design).:roprotects another codebase from modification, not from being read. Plain Docker binds -- anything fancier drops torun_args. A mount can be disabled (disabled = true): it stays in the config and inbyre status(marked), but produces no bind -- a switch for long-lived entries, distinct from!targetremoval.modesurvives the off state, and a disabled mount's host path may be absent without blocking develop. - named volume -- Docker-managed, project-scoped
(
byre-<project_id>-<name>), survives rebuilds. Usually contributed by a skill; a project can declare ad-hoc ones viavolumes. Carries:- role --
cache(disposable, regenerable) orstate(precious: agent auth + history). Drives lifecycle:byre resetwarns and wipes; rebuild leaves volumes alone. - scope --
project(default) ormachine(ADR 0017). A machine-scoped volume is one per user per machine (byre-machine-u<uid>-<name>) and mounts identically in every project's box -- the shared-auth companion skills' identity volumes are the canonical use.byre statuslists them on their own "Shared vols" row;reset/forgetnever touch them and say so (delete one deliberately viabyre config-> Volume data -> clear, which refuses while ANY byre session runs).seedis invalid on a machine-scoped volume. (Worktree sharing remains an identity question, not a volume one -- ADR 0009.) - sharing --
shared(default) orexclusive(ADR 0054). Scope says which boxes may see a volume; sharing says how many may hold it at once.exclusivedeclares single-writer data:developreads the launch records of this project's live boxes and refuses (exit 3) rather than mount a volume one of them is holding, and refuses equally when it cannot establish that none is. Project scope only -- byre can only see this project's boxes. - seed (state only, optional) -- initialize a fresh volume from a host path or config literal (non-secrets only), once. A copy, not a shared mount; nothing flows back.
- role --
Credentials are not seeded (ADR 0007 -- a "not now", not doctrine:
copy-semantics breaks rotating OAuth tokens): agents log in once in the
box and the state volume persists the login per-project. seed_prefs
(ADR 0013) is the curated, non-secret exception for agent prefs. The
shared-auth companion skills (claude-shared-auth,
codex-shared-auth, gemini-shared-auth, opencode-shared-auth;
ADR 0017) make one login
serve every project WITHOUT host copying: the credential lives in a
machine-scoped identity volume and byre reads nothing from the host --
Codex, Gemini, and OpenCode log in once in any box (the credential
lands in the shared volume through symlinks; Gemini's API-key path is
verified and its two-box OAuth check is gate-pending; OpenCode is
vouched for API-key logins -- two-box gate passed 2026-07-17, OAuth
entries unsupported and warned -- see the skills' own skill.toml
gate records); Grok's v1 file-sharing was retired in the field
(ADR 0023) and its v2 auth broker awaits its rollover field gate
(ADR 0036);
Claude uses a user-minted claude setup-token pasted at a
first-run prompt and exported to the agent process by a launch env
hook
(/etc/byre/env.d/*.sh, sourced by the launcher after firstrun hooks,
immediately before exec -- the chassis mechanism for skills that must
put env into the agent process). A companion whose mechanism is ready
declares shared_auth_for = "<agent>" in its skill.toml, and the
first-run picker then asks, at every box's onboarding, whether to opt
that box in -- yes puts the companion in the project's byre.config
skills, the only grant the answer makes. Save-as-default stores the
answer as a favourite (the picker-owned shared_auth list) that
prefills the next box's offer; the offer is skipped under exactly two
suppressions (ADR 0025): the companion already granted machine-wide in
default.config skills (a key the picker never writes -- the answer
could not matter), or defaults.skip_questions, the standing
instruction to configure new projects from the stored answers unasked
(which DOES apply the grant, and says so at the switch and at develop
time). Gemini (two-box
OAuth check pending) and grok (broker rollover gate pending, ADR 0036)
deliberately don't declare it yet; opencode's gate passed 2026-07-17
and it now does. The gate-pending pair still declare companion_for --
the pairing fact, which nests a companion under its agent's row in the
config UI without putting anything in front of onboarding; readiness
gates the offer, never the display (ADR 0034).
The chassis
Core's constant provision to every box. Supported base:
Debian-derived images -- the core block assumes apt, a POSIX shell,
glibc, and root at build time. Alpine/distroless/non-glibc bases are
unsupported; for those, use Docker directly (ADR 0014).
Build-time (the core block, identical everywhere, always cached):
- Bake the host UID/GID into the image (ADR 0008): the
devuser is created at that UID/GID,/home/dev+ the volume mount points are chowned to it, so a fresh volume inherits correct ownership. No runtime chown. - Run unprivileged:
USER devafter all root build steps; the agent never runs as root and there is no runtimegosu(it stays installed as a build-only helper for skill installs). - Strip inherited
HEALTHCHECKs (a base's probe could do network I/O before a firewall gate lands -- ADR 0011). Because the lastHEALTHCHECKin a Dockerfile wins,HEALTHCHECK NONEis re-asserted at the tail alongsideUSER/ENTRYPOINT, so a raw skill ordockerfile_postline can't reintroduce one. - Install the launcher as the constant ENTRYPOINT.
Runtime constants:
- Pass named host values through via
env_from_host-- narrowly, per key: the shipped core layer is git identity (user.name/user.emailinjected asGIT_AUTHOR_*/GIT_COMMITTER_*-- not your.gitconfig, not git credentials),TERM(the launching terminal's), andTZ(the host timezone: the TZ var if set, else the/etc/localtimesymlink's IANA name). Sources are a closed scheme set (git:<key>,env:<HOST_VAR>,tz:, and the two credential kindsencrypted:/encrypted-file:); each host-sourced entry is a grant -- attributed in status, counted in exposure, disable-able per layer (KEY = ""). A credential row asks the host for nothing: it carries an age ciphertext this config file's own[credentials]identity opens, and it is delivered over the credential channel onto the session tmpfs rather than as-e(ADR 0057). Host env is otherwise isolated (ADR 0026). - The launcher: wait at the launch gate if a network-posture skill is
enabled (ADR 0011), export the per-session context additions as
BYRE_SESSION_CONTEXT(under an allowlist posture, the session's enforced egress allowlist -- the sameBYRE_EGRESSstring the netns helper applied, so announcement and enforcement share one source; plus the self-edit note when that grant is live), run first-run hooks as the user (agent login flows live here), then exec the selected agent's command in autonomous mode. The agent command itself INJECTS the baked agent context (the chassis facts, the base image, a one-line inventory of config-provisionedaptpackages when any exist -- the agent shouldn't discover tools by probing -- then skill snippets in enable order, then the operator's[[context]]declarations in cascade order) plus the session var, through its own vendor channel (ADR 0046); the launcher writes no agent file. The box is the safety boundary.
Project identity
project_id is derived from the canonicalized absolute path of the
project dir: a readable slug of the last two path components plus a short
hash of the full path, e.g. /Users/me/dev/byre -> byre-dev-0877d7. It is
a naming device, not a caching one (ADR 0001): it names the image tag
(byre-<project_id>-u<uid>-g<gid> -- written byre-<project_id>
elsewhere in this doc for brevity), the container, the named volumes
(byre-<project_id>-<name>), the labels, and
~/.byre/projects/<project_id>/. A collision is not silent: byre records
each id's canonical path and fails loudly if a different path maps to an
existing id.
For a linked git worktree, identity anchors at the main worktree's
path (ADR 0009): config, volumes, image, and the setup lock come from the
project; the container name, byre.workdir label, and /workspace mount
stay per-worktree. byre worktree creation runs the repository's git
inside containers, never on the host -- registration in a short-lived,
network-less creation container, checkout at first launch (ADR 0009) --
so the hooks and filters a checkout runs stay contained.
Consequence: moving or renaming the folder yields a new id -> fresh image
and fresh volumes; the old volumes persist, orphaned, under the old
id. For cache that's a shrug; for state it strands the agent login.
byre rehome re-points identity after a move (refusing from a worktree,
which re-resolves automatically once git's own pointers are repaired).
Commands
Commands fall into lifecycle, inspection and ejection, package and
wiring management, transfer (deliver and grab, below), and the
self-describers. byre --help is the authoritative list, and the site's
commands page is generated from the command tree; the reference below
covers the ones with architectural weight rather than every verb.
No command mutates config behind your back -- config content
changes only where you asked for the write: files you edit, the byre config editor, the declaration verbs (byre mcp add/remove,
byre claude-skill add/remove, byre context add/remove),
byre preset apply (after its review), and onboarding's initial write. rehome and forget move or
delete the store wholesale, never editing what's inside; everything
else -- develop and friends included -- only reads it and acts.
byre develop Set up if needed (generate, build-on-cache-miss) and run the
box in the FOREGROUND. The main entry point.
byre shell Open a shell (as dev, with the agent's env) in the running
session.
byre worktree Create a git worktree and start a session in it -- a parallel
agent inheriting this project's config, volumes, and image.
byre reset Wipe ALL of this project's named volumes (not the image).
Warns and names what dies. Refuses while a session is live.
byre rebuild Rebuild the image with the cache disabled (--no-cache) -- the
deliberate-staleness valve (ADR 0001).
byre status The legibility surface (PRINCIPLES.md #4): resolved config,
every grant and who granted it, network posture + egress,
volumes, raw blocks flagged as not introspected, session
state.
Project id: repo-abc123
Agent: byre/claude
Template: byre/go bundled v1.3.1
Engine: docker
Project: /repo -> /workspace (rw)
Network: open
Ports: none
Host mounts: none
Skills: byre/claude bundled v1.3.1
pjlsergeant/devlog installed 1.0.0
(--full for package digests)
State vols: .claude
Cache vols: none
Host env: 2 keys from host: GIT_AUTHOR_EMAIL,
GIT_AUTHOR_NAME (env_from_host; --full for sources)
Raw run args: --cap-add=SYS_PTRACE (passed through; not introspected)
Container: not running
Three tiers. The DEFAULT page above shows every row that
exists -- a grant, mount, volume, skill, port or reserved
key is never elided -- and truncates long values, marking
each truncation with a count and a `--full` pointer. It
never folds a claim degradation or a containment
disclosure: those rows are the point, and they are short.
`--full` is the same page untruncated (raw build lines
verbatim, passthrough sources, package digests, the full
delivery sentences). `--data` is the same content as JSON,
carrying a `version` field; it is versioned but not frozen,
nothing consumes it yet, and it is not a scripting
interface until it says so.
Layout is byre's, not the terminal's: the row funnel wraps
a long value itself, hanging to the value column and
breaking at the row grammar's separators rather than
through a path. Width is measured in terminal CELLS, so a
CJK path or byre's own two-cell 🛑 marker does not overrun
and get re-wrapped at column zero. It lays out to the
terminal's width when printing to one -- clamped to
[48, 160], and BELOW the floor it lays out at 48 rather
than pretending to 80 -- and to a fixed 80 otherwise, so
redirected output is the same wherever it is produced.
byre dockerfile Print the generated Dockerfile for this directory.
byre dockerrun Print the exact `docker run` command for this directory --
with `dockerfile`, the whole exit (docs/EJECTING.md).
byre ejectfirewall Print the firewall's outside-the-box step as a standalone
script -- one of the two things dockerfile+dockerrun don't
carry (the other is credential delivery, which needs the
passphrase and so stays `byre develop`'s; both leave the
ejected box gated to fail closed -- docs/EJECTING.md).
byre config Interactive editor for this project's host-side config
(--global for the baseline).
byre rehome Re-point a moved directory's identity onto its new
path-derived id: migrate volumes and the stored config,
then retire the old id's store and image.
byre forget Remove all of byre's host-side state for this directory --
volumes, image, ~/.byre/projects/<id>/. Never touches the
project tree.
byre skill ... list / inspect <id|url> / install / uninstall / fork /
init / adopt / validate / pack -- the package verbs (same
set on `byre template ...`). See docs/SKILLS.md.
byre preset ... apply / inspect -- review and apply a config preset
(byre.preset, a path, or an https URI).
byre mcp ... add / remove / list -- declare MCP servers in the project
config (wiring, not a grant; ADR 0033).
byre claude-skill ... add / remove / list -- declare Claude Skills in the
project config (wiring, not a grant; ADR 0039).
byre context ... add / remove / list -- declare standing-instruction
snippets in the project config (ADR 0043/0046).
byre deliver Stream files (or the clipboard, or stdin) from the host into
a running box's /inbox -- locally, or through another machine
via ssh://. User docs: docs/DELIVER.md.
byre grab Deliver's mirror: stream a file or directory out of a running
box onto the host (never overwriting). User docs:
docs/DELIVER.md.
byre version Print the version (also --version).
byre completion Per-shell completion scripts (bash/zsh/fish/powershell).
Deliver and grab
byre deliver and its mirror byre grab are the machine-scoped
verbs: instead of deriving a project from cwd, they discover running
boxes across every installed
engine (ps filtered on the byre.project label; each hit keeps engine
affinity for the later exec) and resolve a target through a cascade --
--box (unique prefix), cwd match walking ancestor directories against
the byre.workdir label, sole owned session (an unreachable engine
quietly counts as zero; any other failed query disables this step -- a
partial pool can't prove "exactly one"), interactive
picker (Bubble Tea on the terminal -- /dev/tty when stdin is busy
carrying a piped payload, ssh's own contract; osascript/zenity/kdialog
on a graphical launch), else an error listing the candidates. Discovery filters to
boxes whose BYRE_UID matches the caller -- an accident filter, not
confinement (--skip-uid-check reveals and permits the rest).
Transport is an exec -i per file, as the container's own
BYRE_UID:BYRE_GID (the byre shell attach model), running a POSIX-sh
script that streams stdin to a dotfile temp under set -C (noclobber)
and claims the final name with ln -- link(2) fails EEXIST atomically,
so collisions uniquify (report-2.pdf) with no overwrite window, and a
died stream leaves no half-file under a real name. Directories claim
their top-level name with an atomic mkdir and stream the tree
per-file. /inbox itself is baked by the chassis: dev-owned under
root-owned /, so the boxed agent can't replace it with a symlink.
Grab runs the same transport in reverse with the judgment moved
host-side (ADR 0040): dumb box scripts classify the path, enumerate a
directory (NUL-framed find output), and cat each file out over an
exec, while the host treats everything the box says as agent input --
writes ride an os.Root anchored at the destination, names claim via
an O_EXCL dotfile temp + hardlink (mkdir for directories) so nothing
ever overwrites a host file or writes through a planted symlink, and
enumeration records outside the grabbed root are ignored loudly.
An ssh://[user@]host[:port] first argument routes deliver
through another machine running byre (ADR 0037): the local byre asks
the remote for its box list (byre deliver --boxes --proto N -- a
frozen tab-separated line grammar on stdout; a distinct exit code
marks a partial pool, which forbids auto-picking), picks locally with
the ordinary picker, then streams every source as ONE tar archive up a
single plain-ssh exec into the remote's byre deliver --tar -. The
archive's entries feed the ordinary per-file transport, so claiming,
uniquify, and /inbox confinement are identical to a local delivery and
nothing touches the remote host's disk. --box skips enumeration (one
connection -- the deliver-app/script path); --remote-byre names the
remote binary when sshd's sparse non-interactive PATH hides it. Local
capabilities stay local: the paste beat, stdin spooling, the sending
meter, and the clipboard round-trip run on the near side (the remote's
clipboard leg is suppressed with --no-clip).
Host capabilities are probed per axis and degrade independently: the
landed paths always print to stdout (the machine contract, one per
line) and best-effort ride the host clipboard back (pbcopy / wl-copy /
xclip, or OSC 52 through SSH); the no-arg clipboard import waits for a
paste gesture on a TTY and classifies the captured paste -- text
mirroring the pasteboard is a real paste (read the pasteboard
out-of-band: file references → image → text), existing absolute host
paths are a possible drag onto the window (named and delivered only after
confirmation), anything else
is literal pasted text; graphical launches (no TTY, GUI present)
also report via OS notification. Every degraded nicety states itself
on stderr. Mechanics in internal/deliver; decisions in ADR 0021 and
(remote) ADR 0037; user behavior (and the what-works-where matrix) in
docs/DELIVER.md.
byre deliver --install-app writes the deliver app -- generated,
readable host artifacts whose only job is invoking byre deliver: an
AppleScript app assembled by the OS's own osacompile (source
shipped inside the bundle; nothing prebuilt crosses a machine boundary,
so no signing certificate is involved -- the ad-hoc codesign repairs the
signature that writing byre's script and icons into the applet stub
invalidates), a Finder Quick Action, and a Linux .desktop
entry. An ssh:// target and/or --name produce coexisting per-target
installs. Regeneration replaces only text artifacts carrying byre's
generated marker in their header (plus, for a labeled install, its
identity token — a colliding label refuses rather than clobbers); a
same-named file byre didn't write is refused. Icons are the one
non-text artifact: on Linux they're judged by content (only byre's own
bytes are ever replaced; anything else is skipped with a note), and the
icns inside a macOS bundle rides the bundle's own marker gate.
Platform note
Baking the host UID/GID to yield correctly-owned files is a Linux-host concern. On Docker Desktop (macOS/Windows) the file-sharing layer fakes ownership, so the problem mostly doesn't arise and the baked UID is harmless. Test the baked-UID ownership on a native Linux host.