Skip to content

Architecture

runtime topology
jackin CLI operator interface
Docker Engine
jackin-agent-smith-net per-agent network

Agent Container

Claude Code · mounted dirs

DinD Sidecar

docker:dind · TLS certs volume


~/.jackin/data/ runtime state per instance
~/.jackin/agents/ cached agent repos
~/.config/jackin/ operator config

jackin’ deliberately uses your existing Docker engine as the operator control plane. For host-side Docker API calls, jackin’ honors DOCKER_HOST when the operator sets it to a non-empty value (matching Docker CLI’s own treatment of an empty DOCKER_HOST= as unset). Otherwise it inspects the active Docker CLI context — including any per-shell DOCKER_CONTEXT override — and connects to that context’s Docker host when the context is a local socket or plain HTTP/TCP endpoint, so API calls such as network creation land on the same daemon that later docker run commands use. SSH and TLS Docker contexts are rejected for now because the current Bollard dependency build cannot reproduce the Docker CLI’s SSH transport or context TLS material.

That creates an important split:

  • build time happens on the host Docker engine
  • runtime orchestration creates the role container, DinD sidecar, certs volume, and per-instance network on that same selected 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.

jackin’ 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.
  1. Derived layer

    jackin-managed
    • UID/GID remapping
    • Claude Code installation
    • Pre-launch hook
    • Runtime entrypoint
    • Plugin bootstrap
  2. Agent layer

    agent repo
    • Language runtimes
    • Development tools
    • Custom configuration
  3. Construct base

    shared
    • Debian Trixie
    • Docker CLI + Compose
    • Git, GitHub CLI
    • mise, ripgrep, fd, fzf
    • zsh + Oh My Zsh

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 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 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 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 src/repo.rs and src/manifest/migrations.rs with the desktop path.

  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. Write Capsule launch config — serialize the role key, workspace workdir, ordered supported-agent list, and manifest model overrides to ~/.jackin/sockets/<container>/agent.toml, which is bind-mounted at /jackin/run/agent.toml.
  9. Start the agent container detached (docker run -d ... <image> <agent>) with jackin-capsule as PID 1. The trailing <agent> argv selects only the initial tab; it is not a container-level environment variable.
  10. Attach the operator terminal via docker exec -it <container> /jackin/runtime/jackin-capsule. The client connects to the PID 1 daemon over /jackin/run/jackin.sock and renders the in-container multiplexer.

The detached-start + exec split decouples the container’s lifetime from the foreground attach: closing the operator’s terminal detaches from the client, while the PID 1 daemon and live PTYs keep running. jackin hardline reconnects to the daemon or asks it to spawn a new session. 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.

jackin hardline inspects the target container. If it’s running, it attaches with docker exec ... /jackin/runtime/jackin-capsule; hardline --new --agent <agent> uses jackin-capsule new <agent> so agent selection travels as argv. 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.

Every role container runs the jackin’ Capsule (crates/jackin-capsule/src/main.rs) as PID 1. In daemon mode it reaps children, manages PTYs, renders the multiplexer, and serves the socket API. SIGTERM / SIGINT trigger orderly shutdown, and the daemon exits when the last live session ends so Docker reports a clean container exit. The full Capsule design is documented in jackin’ Capsule.

Sessions are daemon-owned PTYs inside the running container. All agent sessions are equal — there is no “primary” or “secondary” distinction.

Session typeIdentityCreated byReconnectable
Agent sessiondaemon session id plus agent slugjackin load, jackin hardline, or console a/AYes — jackin hardline attaches to the daemon or creates a new session
Shelldaemon session id with no agent slughardline --shell or console x/XNo — intended as an ephemeral zsh pane

When an agent session exits cleanly (the operator types /exit or the agent finishes), the daemon removes that pane. If no live sessions remain, PID 1 exits and the host cleanup path tears down the role container, DinD sidecar, certs volume, and network.

JACKIN_AGENT is set per agent PTY by crates/jackin-capsule/src/session.rs, immediately before /jackin/runtime/entrypoint.sh runs. The daemon’s own process does not carry JACKIN_AGENT, so shell panes and future mixed-agent sessions do not inherit a misleading container-wide agent value.

Role identity, workspace workdir, supported-agent order, and model overrides are not duplicated into container-wide environment variables. The daemon reads those values from /jackin/run/agent.toml, keeping all jackin-owned in-container runtime state under /jackin/.

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

Implementation: crates/jackin-capsule/src/daemon.rs, src/runtime/launch.rs (launch_role_runtime), src/runtime/attach.rs (reconnect_or_create_session, spawn_agent_session, spawn_shell_session).

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.

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.

Pane PTYs run with a stable TERM=xterm-256color baseline. jackin’ does not copy the host terminal’s $TERM into the long-lived container environment, because a preserved instance can be reattached later from a different terminal app. A pane that started while the operator used Ghostty must still be safe to reattach from Kitty, iTerm, Warp, SSH, or a plain xterm-compatible terminal.

Terminal-specific enhancements are attached-client state instead. Every foreground attach (jackin load, jackin hardline, and console attach) runs a short-lived Capsule client through docker exec; that client reports its current TERM, TERM_PROGRAM, and COLORTERM in the attach handshake. The PID 1 daemon stores that identity only for the active client and refreshes it on every takeover, so jackin-owned output enhancements follow the terminal the operator is using now.

Ghostty, Kitty, and iTerm get native enhancements through that active-client capability path. Features that are raw terminal protocols, such as extended-key sequences, OSC 52 clipboard writes, OSC 8 hyperlinks, OSC 9 notifications, and focused-pane title updates, continue to travel over the attach stream when allowed. Enhancements that require known outer-terminal support, such as OSC 22 pointer-shape feedback, are enabled only when the active client advertises a compatible identity.

The container no longer runs tmux for agent sessions. The Capsule owns the PTYs directly, so terminal behavior that used to come from tmux options is implemented at the Capsule/client boundary:

Former tmux concernCapsule behavior
Modified keysClient-side input parsing preserves xterm/Kitty/CSI-u key sequences and forwards pane input unmodified unless it is a jackin’-owned palette or prefix command.
Terminal feature advertisementPane PTYs run with TERM=xterm-256color; outer-terminal identity is reported per attach and used only for client-side enhancements.
Focus eventsThe attach client enables focus reporting on the outer terminal and forwards focus-in/focus-out only to panes that requested it.
OSC passthroughFocused-pane OSC 0/1/2 titles, OSC 8 hyperlinks, OSC 9 notifications, and OSC 52 clipboard writes are forwarded with per-family opt-outs. OSC 7 is captured for pane titles and not forwarded to the host.
Escape timingThe input parser treats Escape as data for the pane except when a jackin’ dialog is open; dialogs receive normalized legacy keys even after an embedded TUI enables progressive keyboard reporting.
MouseThe client keeps the outer terminal in SGR any-event mode. The daemon handles clicks on jackin’ chrome and forwards pane mouse events only when the pane’s program opted into mouse reporting.

Implementation: crates/jackin-capsule/src/client.rs, crates/jackin-capsule/src/input.rs, crates/jackin-capsule/src/session.rs, and crates/jackin-capsule/src/daemon.rs.

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 operator guide.

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:

ShapeOriginOwns
WorkspaceConfig~/.config/jackin/config.tomlPersisted operator intent — raw src/dst/readonly/isolation fields per mount.
ResolvedWorkspaceresolve_load_workspaceExpanded host paths plus the merged composition of workspace, ad-hoc, and global mounts. Still independent of the actual container instance.
MaterializedWorkspacepost-name-claim materializerFinal 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: worktree mode creates a scratch branch from host HEAD, clone mode runs a local clone of the host repo’s current branch, and both modes record 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.

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:

FieldPurpose
workspaceWorkspace name at materialization time
mount_dstContainer destination path (the mount identity)
original_srcResolved host src recorded for source-drift detection
isolationMode (worktree or clone)
worktree_pathMaterialized worktree or clone path on the host
scratch_branchWorktree scratch branch name (jackin/scratch/<container>); empty for clone records
base_commitHost HEAD at first materialization
selector_keyAgent selector key
container_nameFinal container name
statusactive | preserved_dirty | preserved_unpushed

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

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 foreground docker exec ... jackin-capsule attach returns, jackin’ inspects the container and decides:

  • Container is still running → preserve everything for jackin hardline to reconnect later. No isolated-checkout action.
  • Stopped, non-zero exit or OOM → preserve the container, DinD, and isolated checkouts so jackin hardline can restart in place.
  • Stopped, exit code 0 (clean) → attempt safe isolated-checkout cleanup. A checkout is safe to delete only when it has no uncommitted changes and every local branch in the checkout is safe — agents routinely rename the worktree scratch branch to feature/* before opening a PR, and clone mode starts on the host branch directly, 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 checkouts).
  • 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 checkout 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 checkout 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.

~/.jackin/
├── roles/ # Cached role repos
│ ├── agent-smith/ # Flat role (no namespace)
│ └── chainargos/ # Namespace directory
│ └── agent-brown/ # Namespaced role
├── cache/ # Shared caches (version checks and other rebuildable data)
├── 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.

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.

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

jackin’ is built in Rust. The runtime dependencies, grouped by what they own, match Cargo.toml:

ConcernCrates
CLI parsing & manpagesclap, clap_mangen
Terminal UIratatui, crossterm, ratatui-textarea, tui-widget-list, dialoguer
Table outputtabled
Configuration (parse + edit)serde, serde_json, toml, toml_edit
XDG paths and process lockingdirectories, fs2
Dockerfile parsingdockerfile-parser-rs
Error handlinganyhow, thiserror
Colored outputowo-colors
Filesystem helperstempfile
Open external URLsopen
Test helpers (dev)assert_cmd, predicates

The project targets Rust edition 2024 with a current MSRV of 1.94 (see rust-version in 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 Cargo.toml for the full lint table.

When this list drifts from Cargo.toml, Cargo.toml wins. Update both in the same PR.