---
title: Architecture
description: How jackin' orchestrates containers, networks, and persisted state
---

import ArchitectureDiagram from '../../../components/diagrams/ArchitectureDiagram.astro'
import ImageLayers from '../../../components/diagrams/ImageLayers.astro'
import RepoFile from '../../../components/RepoFile.astro'
import { Aside } from '@astrojs/starlight/components'

<Aside type="note">
This page is **for contributors** — anyone who wants to understand or
work on jackin' itself. Operators do not need to read it to use
jackin'; the [Operator Guide](/guides/workspaces/) and
[Concepts](/getting-started/concepts/) cover everything an operator
needs.
</Aside>

## High-level architecture

<ArchitectureDiagram />

jackin' deliberately uses your existing Docker engine as the operator control plane.

That creates an important split:

- **build time** happens on the host Docker engine
- **runtime Docker commands issued by the agent** go to the per-instance DinD sidecar

So jackin' gets host-side cache reuse for building agent images, while still keeping the agent away from your host Docker daemon during runtime.

## Design constraints

jackin's architecture is shaped by a few explicit decisions:

1. **Local development first.** The first target is a developer machine that already knows how to run Docker.
2. **Environment as source code.** Roles should be normal git repos reviewed like normal software.
3. **Workspace boundary separate from tool profile.** File visibility and installed tooling are different control planes on purpose.
4. **Operator-owned infrastructure, not black-box runtime.** The implementation should stay inspectable and patchable.
5. **Container isolation is acceptable for the first target.** jackin' does not claim a microVM boundary today.

## Image layers

<ImageLayers />

The role repo owns the environment layer. jackin' owns the derived agent layer.

That means a role defines the tools and conventions, while jackin' injects the agent-specific pieces such as Claude, Codex, Amp, Kimi, or OpenCode installation, the entrypoint, and UID/GID remapping.

## Role repository tooling

Role repositories are normal source repositories, so jackin keeps role maintenance split between two surfaces. The main operator CLI owns desktop ergonomics: its role-authoring dispatch validates checked-out role repositories, applies manifest migrations to local copies, and writes small starter repositories for authors. That path lives in <RepoFile path="src/role_authoring.rs" /> and reuses the same manifest migration registry and repository contract checks used by runtime launch.

Automation stays on the small role-focused binary in <RepoFile path="src/bin/role.rs" />. CI workflows and future Renovate-style migration jobs should not depend on the full operator CLI just to validate a role repo or restamp a manifest; they call the standalone validator, which shares <RepoFile path="src/repo.rs" /> and <RepoFile path="src/manifest/migrations.rs" /> with the desktop path.

## Container lifecycle

### Loading an agent

1. **Resolve role** — map the selector to a repo, clone or update it, and reject dirty cached checkouts
2. **Validate the repo contract** — require `jackin.role.toml` and a valid Dockerfile path, validate manifest strictly
3. **Resolve environment variables** — prompt the user for interactive env vars declared in the manifest
4. **Generate a derived build context** — copy the repo, inject agent assets (entrypoint + runtime hooks), and render a derived Dockerfile
5. **Build the image on the host Docker engine** — reusing host-side Docker cache where possible
6. **Create a per-agent Docker network**
7. **Start a privileged `docker:dind` sidecar** with TLS enabled and a shared certificate volume
8. **Start the agent container detached** (`docker run -d`) with `supervisor.sh` as the entrypoint override — a bash wait loop that becomes PID 1 and keeps the container alive while agent sessions run via `docker exec`
9. **Start the first agent session** via `docker exec -e TMUX= -it <container> tmux new-session -e JACKIN_AGENT=<agent> -s jackin-<agent>-<id> -- /jackin/runtime/entrypoint.sh [model-flags]`. `TMUX=` prevents nested-session warnings when the operator's terminal is itself inside tmux. `JACKIN_AGENT` is passed via `tmux new-session -e` rather than as a container-level env var so each session can specify its own agent runtime independently.

The detached-start + exec split decouples the container's lifetime from the foreground session: closing the terminal tab detaches from the tmux session inside the container, but the supervisor (PID 1) and the tmux server keep running. `jackin hardline` reattaches to the most recently used session or creates a new one when no sessions are running. Cleanup distinguishes three post-session states:

- **running** → terminal was closed; preserve the container for `jackin hardline` to reconnect
- **stopped with exit 0** → the user exited cleanly; tear down container, DinD, network, and certs volume
- **stopped with non-zero exit or OOM** → crash; preserve the container and DinD so `jackin hardline` can restart in place

Every fresh launch gets a unique DNS-safe instance identity. Existing running or stopped instances are not fresh-launch blockers; they remain discoverable through `hardline` and the console. Missing recoverable state is the case that can prompt or block before a fresh load. The detailed contributor contract lives in [Runtime Instance Model](/reference/runtime-instance-model/).

### Reconnecting

`jackin hardline` inspects the target container. If it's running, it attaches to the most recently used tmux session via `docker exec ... tmux attach-session`, or creates a fresh `jackin-<agent>-<id>` session when none are running. If it's stopped with a non-zero exit (crash / OOM), it restores the required DinD sidecar/network/certs when needed, restarts the role container, and attaches. If the role container itself is missing but indexed recoverable state exists, hardline can rebuild the runtime around jackin-managed local state. Clean exits (exit code 0) are treated as completed sessions — use `jackin load` to start a fresh instance.

### Container supervisor and session model

Every role container runs <RepoFile path="docker/runtime/supervisor.sh" /> as PID 1. The supervisor is a bash wait loop (`while true; do sleep 3600 & wait $! || true; done`) that traps SIGTERM/SIGINT and exits cleanly on `docker stop`. Its sole job is to keep the container alive while agent sessions run via `docker exec`.

Sessions are tmux sessions inside the running container. All agent sessions are equal — there is no "primary" or "secondary" distinction.

| Session type | tmux name | Created by | Reconnectable |
|---|---|---|---|
| Agent session | `jackin-<agent>-<id>` | `jackin load`, `jackin hardline`, or console `a`/`A` | Yes — `jackin hardline` attaches to the most recent session or creates a new one |
| Shell | (no session) | `hardline --shell` or console `x`/`X` | No — ephemeral `docker exec /bin/zsh` |

When an agent session exits cleanly (the operator types `/exit` or the agent finishes), the tmux session is removed but the container and supervisor keep running. The container only stops when the operator explicitly stops it, or when `jackin load` teardown runs after the foreground session ends cleanly (exit code 0).

Agent model flags (`--model`) are passed as arguments to the tmux session exec, not as CMD args to `docker run`. This means model overrides from `jackin load --model` apply to the initial session only. Additional sessions started via `hardline --new` use the role's default agent configuration unless the operator selects a different runtime.

Session state is not yet reconciled against the live container on console refresh — Phase 4 of the [Console agent session control](/reference/roadmap/console-agent-session-control/) roadmap covers this work. The `sessions` field in the instance manifest is written but not populated until reconciliation lands.

Implementation: <RepoFile path="docker/runtime/supervisor.sh" />, <RepoFile path="src/runtime/launch.rs" /> (`launch_role_runtime`), <RepoFile path="src/runtime/attach.rs" /> (`reconnect_or_create_session`, `spawn_agent_session`, `spawn_shell_session`).

### Stopping

`jackin eject` removes:

1. the agent container
2. the DinD sidecar
3. the TLS certs volume (`{container}-dind-certs`)
4. the per-agent network

Persisted state remains unless you also purge it.

## Networking

Each agent gets an isolated Docker network:

- network name is derived from the agent container name
- the agent container and DinD sidecar share that network
- the agent reaches the daemon through `DOCKER_HOST=tcp://{dind}:2376` with TLS mutual authentication
- the agent reaches published services launched via DinD through `JACKIN_DIND_HOSTNAME={dind}`
- different agents do not share a network by default

The DinD sidecar auto-generates TLS certificates at startup into a shared Docker volume. The agent container mounts the client certificates read-only and sets `DOCKER_TLS_VERIFY=1` so all Docker CLI commands are authenticated.

## Terminal forwarding

jackin' forwards the host's `$TERM` environment variable into the agent container so that terminal capabilities (colors, keyboard protocols, mouse support) work correctly.

Standard terminal types like `xterm-256color`, `screen-256color`, and `tmux-256color` ship with Debian's `ncurses-base` and are forwarded as-is.

Modern terminals — Ghostty (`xterm-ghostty`), Kitty (`xterm-kitty`), WezTerm, and others — define custom `$TERM` values whose terminfo entries are not included in the container's base image. For these terminals, jackin' automatically:

1. Exports the host's terminfo entry using `infocmp -x`
2. Compiles it into a cache directory (`~/.jackin/cache/terminfo/`) using `tic -x`
3. Mounts the compiled terminfo read-only at `/home/agent/.terminfo` inside the container
4. Forwards the original `$TERM` value

This means terminal-specific features (such as Ghostty's Kitty keyboard protocol or extended color support) work inside the container without any manual setup. The compiled terminfo is cached and reused across agent launches.

If the export fails (e.g. `infocmp` is not installed or the terminfo entry is missing), jackin' falls back to `xterm-256color`.

## In-session tmux configuration

Beyond `$TERM` forwarding, the entrypoint applies five tmux session options immediately before exec-ing the agent. The construct image ships Debian trixie tmux (3.5+), so all options are applied unconditionally — no version guard or error suppression.

| Option | Why |
|---|---|
| `extended-keys always` | Agents don't emit the per-app activation escape that `on` waits for, so `on` silently fails to forward Shift+Enter. `always` bypasses the opt-in. |
| `terminal-features 'xterm*:extkeys'` | Paired with `extended-keys`: advertises `modifyOtherKeys` capability to panes so their terminfo queries confirm support. |
| `focus-events on` | Emits `FocusIn`/`FocusOut` sequences so editors (Neovim, Helix, etc.) reload agent-written files on pane focus return. |
| `allow-passthrough on` | Container panes cannot reach the outer terminal's OSC surface without this. tmux-aware tools (Claude Code, etc.) wrap their OSC sequences in DCS passthrough when `$TMUX` is set; this option forwards them to the outer terminal. Tools that emit raw OSC without wrapping need `allow-passthrough all` (tmux 3.4+). |
| `escape-time 0` | Removes the Escape disambiguation delay that misfires in agent TUI vi-mode navigation. |

Implementation: <RepoFile path="docker/runtime/entrypoint.sh" /> (lines at the bottom of the script, before `exec`).

Operators running `jackin console` from inside their own tmux session need matching host-side configuration so that modified keys are forwarded through the host tmux before they reach the container. See the [Running inside tmux](/guides/tmux/) operator guide.

## Workspace materialization

Workspace mounts pass through three distinct shapes between operator intent and a Docker bind-mount. Keeping these separate is what lets per-mount isolation slot in cleanly:

| Shape | Origin | Owns |
|---|---|---|
| `WorkspaceConfig` | `~/.config/jackin/config.toml` | Persisted operator intent — raw `src`/`dst`/`readonly`/`isolation` fields per mount. |
| `ResolvedWorkspace` | `resolve_load_workspace` | Expanded host paths plus the merged composition of workspace, ad-hoc, and global mounts. Still independent of the actual container instance. |
| `MaterializedWorkspace` | post-name-claim materializer | Final host paths Docker should bind-mount for *this* container. Isolated checkouts have been created on disk; isolated `src` values point at the per-container worktree or clone path. |

Materialization happens **after** jackin claims the final unique container name and the per-container state directory exists. For each non-`shared` mount, materialization is idempotent: first load creates the scratch branch from host `HEAD` and records `base_commit`; later loads to the same container reuse the existing checkout as-is — no reset, no rebase, no fast-forward.

On first worktree creation for a host repo, jackin' enables `extensions.worktreeConfig` (bumping `core.repositoryformatversion` to 1 if needed) so per-worktree config writes via `git config --worktree` don't leak into the shared host `.git/config`.

### `isolation.json`

Materialized isolated mounts are recorded at:

```
<data_dir>/<container>/.jackin/isolation.json
```

The file lives next to the per-container agent state and `.jackin/` metadata and is the source of truth for `purge` after the operator has changed (or removed) the workspace config. Each record contains at least:

| Field | Purpose |
|---|---|
| `workspace` | Workspace name at materialization time |
| `mount_dst` | Container destination path (the mount identity) |
| `original_src` | Resolved host `src` recorded for source-drift detection |
| `isolation` | Mode (`worktree` or `clone`) |
| `worktree_path` | Materialized worktree path on the host |
| `scratch_branch` | Scratch branch name (`jackin/scratch/<container>`) |
| `base_commit` | Host `HEAD` at first materialization |
| `selector_key` | Agent selector key |
| `container_name` | Final container name |
| `status` | `active` \| `preserved_dirty` \| `preserved_unpushed` |

Successful cleanup removes the corresponding record rather than keeping a historical `cleaned` entry.

### Foreground session finalization

Every command that opens or reopens an agent foreground session — `jackin load`, `jackin hardline`, the operator console — funnels through the same post-session finalizer. After the `docker exec tmux new-session` call returns, jackin' inspects the container and decides:

- **Container is still running** → preserve everything for `jackin hardline` to reconnect later. No isolated-worktree action.
- **Stopped, non-zero exit or OOM** → preserve the container, DinD, and isolated worktrees so `jackin hardline` can restart in place.
- **Stopped, exit code 0 (clean)** → attempt safe isolated-worktree cleanup. A worktree is safe to delete only when it has no uncommitted changes and **every** local branch in the worktree is safe — agents routinely rename the scratch branch to `feature/*` before opening a PR, so the safety check enumerates `git for-each-ref refs/heads/` rather than inspecting only the recorded scratch branch. A branch is safe when its tip is at `base_commit`, when its upstream is configured and `git rev-list <upstream>..<branch>` is empty, or when its upstream tracking is `[gone]` (the squash-merged-and-pruned heuristic — without it, the GitHub-default merge-and-delete-branch flow leaves a graveyard of preserved worktrees).
- **Clean exit with unsafe cleanup**, interactive stdin → ask the operator whether to return to the agent (restart + re-attach), preserve recovery state, or force-delete. The prompt wording branches on whether the worktree is dirty (`uncommitted changes`) or clean-but-unpushed (`unpushed commits on a local branch`).
- **Clean exit with unsafe cleanup**, non-interactive → preserve the recovery state and print the worktree path plus the per-reason phrasing and how to return, inspect, or discard.

The same finalizer runs after `hardline` attaches, so the cleanup decision is independent of how the foreground session was opened.

## State management

```
~/.jackin/
├── roles/                         # Cached role repos
│   ├── agent-smith/               # Flat role (no namespace)
│   └── chainargos/                # Namespace directory
│       └── agent-brown/           # Namespaced role
├── cache/                         # Shared caches (terminfo, version checks)
│   └── terminfo/
├── data/
│   ├── instances.json             # Rebuildable index of all known instances
│   └── <container-base>/          # Persisted state per instance
│       ├── claude/                # Claude auth handoff (provisioned when Claude is a supported agent)
│       │   ├── credentials.json   # Forwarded OAuth credentials under auth_forward=sync
│       │   └── account.json       # Forwarded Claude Code account metadata under sync/token modes
│       ├── codex/                 # Codex auth handoff (provisioned when Codex is a supported agent)
│       │   └── auth.json          # ChatGPT OAuth tokens (synced from host ~/.codex/auth.json under auth_forward=sync)
│       ├── amp/                   # Amp auth handoff (provisioned when Amp is a supported agent)
│       │   └── secrets.json       # Amp API key or OAuth token (synced from host ~/.local/share/amp/secrets.json under auth_forward=sync)
│       ├── kimi/                  # Kimi auth handoff (provisioned when Kimi is a supported agent)
│       │   ├── config.toml        # Kimi config (synced from host ~/.kimi/config.toml under auth_forward=sync)
│       │   ├── credentials/       # Kimi credentials (synced from host ~/.kimi/credentials under auth_forward=sync)
│       │   └── device_id          # Kimi device id (synced from host ~/.kimi/device_id under auth_forward=sync)
│       ├── opencode/              # OpenCode auth handoff (provisioned when OpenCode is a supported agent)
│       │   └── auth.json          # OpenCode provider credentials (synced from host ~/.local/share/opencode/auth.json under auth_forward=sync)
│       ├── home/                  # Durable agent home state mounted into the container
│       │   ├── .claude/           # Claude Code conversation history, runtime-local plugins, settings
│       │   ├── .claude.json       # Claude Code account metadata inside the durable home
│       │   ├── .codex/            # Codex conversation history, runtime-local config, login state
│       │   ├── .local/share/amp/  # Amp conversation history, runtime-local config, login state
│       │   ├── .kimi/             # Kimi runtime-local config and login state
│       │   └── .local/share/opencode/  # OpenCode provider credentials, model preferences
│       ├── state/                 # jackin runtime markers mounted at /jackin/state
│       ├── .config/gh/            # GitHub CLI authentication (agent-neutral)
│       └── .jackin/               # jackin runtime metadata (agent-neutral)
│           ├── instance.json      # Per-instance manifest (identity, role, agent, mounts, status)
│           └── isolation.json     # Per-mount isolation records (when applicable)
```

This is what persists across sessions:

- Claude/Codex/Amp/Kimi/OpenCode history, runtime-local settings, plugins, and authentication state stored under the per-instance durable home
- jackin' plugin metadata
- `gh` state acquired inside the agent container

This is what does **not** persist:

- packages installed interactively into the agent container
- ad-hoc root filesystem changes outside mounted paths
- DinD images, containers, and cache

That means jackin' restores only host-backed state: workspace mounts, isolated worktrees/clones, and the per-instance agent home under `~/.jackin/data/<container>/home/`. Anything installed or edited only in the container writable layer is intentionally disposable and will not be restored after Docker deletes the container.

Jackin' is biased toward **reproducible roles** rather than **mutable long-lived sandboxes**.

If a tool should always exist, put it in the role repo Dockerfile instead of installing it ad hoc during a session.

## Why Docker instead of microVMs?

Because jackin' is solving a slightly different problem.

Using plain Docker lets jackin':

- reuse a toolchain developers already have
- keep the implementation smaller and easier to inspect
- build agent images with normal Docker workflows
- share host-side build cache across roles
- stay local-first without introducing a separate VM product layer

The cost of that choice is straightforward:

- weaker isolation than a microVM
- shared host kernel
- privileged DinD sidecars
- no hypervisor boundary

If the main thing you need is the strongest local boundary for autonomous agents on a laptop, microVM-based products such as Docker Sandboxes are ahead.

## Current technical debt and limitations

- **DinD transport uses auto-generated TLS certificates.** The certificates are generated per-session and not pinned to a CA chain you control.
- **Claude installation is not fully pinned.** The derived image installs Claude Code from the upstream installer.
- **Derived build context rejects symlinks.** Role repos with symlink-heavy layouts are not supported yet.
- **Cached role repos must stay clean.** jackin' refuses to build from a dirty or origin-mismatched cache.
- **Container mutability is intentionally thin.** That keeps sessions cleaner, but makes ad-hoc package installs non-persistent.
- **Isolation-mode edits are guarded only when preserved state already records the previous mode.** Source drift is checked before workspace edits are saved; changing only `mounts[].isolation` can still take effect on the next launch without a pre-save warning when there is no conflicting preserved record.

## Technology stack

jackin' is built in Rust. The runtime dependencies, grouped by what they own, match <RepoFile path="Cargo.toml" />:

| Concern | Crates |
|---|---|
| CLI parsing & manpages | `clap`, `clap_mangen` |
| Terminal UI | `ratatui`, `crossterm`, `ratatui-textarea`, `tui-widget-list`, `dialoguer` |
| Table output | `tabled` |
| Configuration (parse + edit) | `serde`, `serde_json`, `toml`, `toml_edit` |
| XDG paths and process locking | `directories`, `fs2` |
| Dockerfile parsing | `dockerfile-parser-rs` |
| Error handling | `anyhow`, `thiserror` |
| Colored output | `owo-colors` |
| Filesystem helpers | `tempfile` |
| Open external URLs | `open` |
| Test helpers (dev) | `assert_cmd`, `predicates` |

The project targets **Rust edition 2024** with a current MSRV of **1.94** (see `rust-version` in <RepoFile path="Cargo.toml" />) and `unsafe_code = "forbid"` at the crate root. Clippy lints are configured with `correctness` and `suspicious` set to `deny` and `complexity`/`style`/`perf`/`pedantic`/`nursery` set to `warn` — see <RepoFile path="Cargo.toml" /> for the full lint table.

When this list drifts from <RepoFile path="Cargo.toml" />, <RepoFile path="Cargo.toml" /> wins. Update both in the same PR.
