# The Construct Image (https://jackin.tailrocks.com/developing/construct-image/)



<Aside type="note">
  This page has two readers. **Role authors** (user-facing) read the
  top half — &#x2A;what is in the `construct`*, *what tools come for free*,
  &#x2A;how to extend it from a role's `Dockerfile`*. **Contributors**
  (internals-facing) read the lower half — *how to build, validate,
  and publish the `construct` image itself* (`mise run construct-build-local`,
  CI workflow, advanced publish rehearsal). Operators using jackin❯
  as a product do not need to read either half; `jackin load` pulls
  and updates the `construct` automatically.
</Aside>

## What is the construct? [#what-is-the-construct]

`projectjackin/construct:trixie` is the shared base Docker image for every agent — the foundation layer that every role extends. It provides system tools, shell environment, and container infrastructure, kept in one image so every agent inherits the same baseline.

Every agent Dockerfile starts from the construct:

```dockerfile
FROM projectjackin/construct:0.4-trixie
```

## What's inside [#whats-inside]

The construct is built on **Debian Trixie** and includes:

### System tools [#system-tools]

| Package           | Purpose                                                                                                                                                                                                                                                                                                          |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `bash`            | Default shell for scripts                                                                                                                                                                                                                                                                                        |
| `zsh` + Oh My Zsh | Default interactive shell for the `agent` user. Oh My Zsh runs with `ZSH_THEME=""` so the `git` plugin, autosuggestions plugin, and OSC 0/2 + OSC 7 auto-title hooks are active without overriding the Starship prompt.                                                                                          |
| `fish`            | Opt-in alternative shell. Pre-configured `~/.config/fish/config.fish` initialises Starship and emits the same OSC 0/2 + OSC 7 pane title escapes as zsh, so pane border titles render identically. Enter with `fish` from a default zsh pane, or set as the login shell with `sudo chsh -s /usr/bin/fish agent`. |
| `git` + Git LFS   | Source control with large file support                                                                                                                                                                                                                                                                           |
| `curl`            | HTTP client                                                                                                                                                                                                                                                                                                      |
| `jq` + `yq`       | JSON and YAML processors                                                                                                                                                                                                                                                                                         |
| `openssh-client`  | SSH for git operations                                                                                                                                                                                                                                                                                           |
| `sudo`            | Privilege escalation package. Passwordless access is provisioned only when the active Docker profile or grant enables sudo.                                                                                                                                                                                      |
| `tree`            | Directory visualization                                                                                                                                                                                                                                                                                          |

### Search tools [#search-tools]

| Package          | Purpose                                |
| ---------------- | -------------------------------------- |
| `ripgrep` (`rg`) | Fast regex search                      |
| `fd-find` (`fd`) | Fast file finder                       |
| `fzf`            | Fuzzy finder for interactive selection |

### Development infrastructure [#development-infrastructure]

| Package              | Purpose                              |
| -------------------- | ------------------------------------ |
| `mise`               | Polyglot language version manager    |
| Docker CLI + Compose | Container operations via DinD        |
| GitHub CLI (`gh`)    | Repository, PR, and issue operations |
| Starship prompt      | Informative terminal prompt          |

### User environment [#user-environment]

The construct creates an `agent` user with:

* Home directory at `/home/agent`
* Zsh as the default shell, with fish available as an opt-in alternative (`fish` from zsh, or `sudo chsh -s /usr/bin/fish agent` to change the login shell)
* Profile-gated sudo access: `compat` enables it by default, stricter profiles require an explicit sudo grant
* Oh My Zsh sourced with `ZSH_THEME=""` so the git plugin, autosuggestions plugin, and OSC 0/2 + OSC 7 title hooks are active without competing with Starship for the prompt
* Starship prompt configured for both zsh and fish so the prompt surface stays the same when switching shells
* Pane border title (`user@host:cwd`) emitted via OSC 0/2 on every prompt — the jackin-capsule multiplexer reads it and renders it as the pane title (the same mechanism zellij uses)
* mise shims in `$PATH` for both shells

## How it's built [#how-its-built]

The construct source starts at <RepoFile path="docker/construct/Dockerfile">docker/construct/Dockerfile</RepoFile> in the jackin❯ repository. The declarative build definition lives in <RepoFile path="docker-bake.hcl">docker-bake.hcl</RepoFile> at the repo root, and the supported command surface is the `construct-*` mise tasks defined in <RepoFile path="mise.toml">mise.toml</RepoFile>, backed by the <RepoFile path="crates/jackin-xtask/src/construct.rs">crates/jackin-xtask/src/construct.rs</RepoFile> Rust task runner.

<RepoFile path="docker/construct/versions.env">docker/construct/versions.env</RepoFile> remains the source of truth for the pinned `tirith`, `shellfirm`, and `MISE_VERSION` build args used by <RepoFile path="docker/construct/Dockerfile">docker/construct/Dockerfile</RepoFile>. `mise` is installed with the official standalone installer during the Docker build, which downloads the pinned version's release asset from GitHub — where every version stays available, so the pin never goes stale. The pinned version gives Buildx a stable cache key, and Renovate bumps invalidate the mise install layer when upstream publishes a new release.

The pinned `shellfirm` binary is prepared before Docker Buildx runs and staged in the construct context as `prebuilt/shellfirm`. CI builds or restores that native binary per architecture from the GitHub Actions cache, then the Dockerfile copies it into the image. This keeps the heavy Rust compile out of the Docker build while upstream still lacks a Linux arm64 release artifact.

## Build workflow [#build-workflow]

Use the `construct-*` mise tasks for day-to-day construct work. Docker Bake is the underlying build engine, but contributors should treat `mise run` as the supported command surface.

### Local validation [#local-validation]

Before opening a pull request for construct changes, validate the image locally:

```bash
mise run construct-init-buildx
mise run construct-build-local
```

By default, local validation loads the image into your Docker daemon as `jackin-local/construct:trixie`, so it does not silently replace the canonical `projectjackin/construct:trixie` base image that normal `jackin` workflows consume.

To test `jackin load` against your local construct build, set `JACKIN_CONSTRUCT_IMAGE` after building:

```bash
mise run construct-init-buildx
mise run construct-build-local
export JACKIN_CONSTRUCT_IMAGE="jackin-local/construct:trixie"
```

With `JACKIN_CONSTRUCT_IMAGE` set, jackin❯ validates role Dockerfiles against the canonical image name as usual (role repos reference a versioned construct tag such as `projectjackin/construct:0.4-trixie`), but substitutes your local image at derived-build time so `docker build` uses `jackin-local/construct:trixie` as the actual base.

Use `mise run construct-inspect` to print the fully resolved Bake config without building the image.

If builder state gets stale or confusing, run `mise run construct-doctor-buildx` to inspect it or `mise run construct-reset-buildx` to recreate it. Set `BUILDX_BUILDER` before running the task if you want to isolate this workflow under a different local builder name.

### Platform-specific debugging [#platform-specific-debugging]

To force a specific target architecture during local debugging:

```bash
mise run construct-build-platform amd64
mise run construct-build-platform arm64
```

These commands require a native host for the requested platform because the pinned `shellfirm` binary is staged before Docker Buildx runs.

### Advanced publish rehearsal [#advanced-publish-rehearsal]

If you need to rehearse the release path against a temporary registry, point `REGISTRY_IMAGE` at your own namespace instead of the canonical `projectjackin/construct` repository:

```bash
REGISTRY_IMAGE=ttl.sh/jackin-construct-$USER mise run construct-push-platform amd64
REGISTRY_IMAGE=ttl.sh/jackin-construct-$USER mise run construct-push-platform arm64
REGISTRY_IMAGE=ttl.sh/jackin-construct-$USER mise run construct-publish-manifest
```

<Aside type="caution">
  `mise run construct-push-platform` and `mise run construct-publish-manifest` intentionally refuse to publish to `projectjackin/construct` unless they are running in CI.
</Aside>

### CI behavior [#ci-behavior]

GitHub Actions reuses the same `mise run` commands on native `ubuntu-24.04` and `ubuntu-24.04-arm` runners. Each construct build first restores or builds the pinned `shellfirm` binary for the runner architecture and places it in the construct context. Construct CI validates broad build plumbing when changes touch `docker/construct/**`, <RepoFile path="docker-bake.hcl">docker-bake.hcl</RepoFile>, the `crates/jackin-xtask/` task runner, or <RepoFile path=".github/workflows/construct.yml">.github/workflows/construct.yml</RepoFile>. It only treats a run as a construct-image publish candidate when changes touch inputs baked into the image itself: <RepoFile path="docker-bake.hcl">docker-bake.hcl</RepoFile>, <RepoFile path="docker/construct/Dockerfile">docker/construct/Dockerfile</RepoFile>, <RepoFile path="docker/construct/versions.env">docker/construct/versions.env</RepoFile>, <RepoFile path="docker/construct/zshrc">docker/construct/zshrc</RepoFile>, or <RepoFile path="docker/construct/fish-config.fish">docker/construct/fish-config.fish</RepoFile>.

Pull requests, plus manual workflow runs against non-`main` branches, build both architectures natively without pushing images. Those validation jobs use the GitHub Actions cache backend; the cache key includes the pinned `MISE_VERSION`, so a Renovate mise bump invalidates the mise install layer while unrelated earlier layers can still be reused. They still do not receive Docker Hub credentials.

Pushes to `main`, plus manual workflow runs against `main`, publish each platform by digest first, then assemble the final multi-platform manifest from those digests when the run is a construct-image publish candidate. Build-plumbing-only changes still validate the construct workflow, but they skip the immutable-version guard and registry publish path because no baked image input changed. The registry cache uses the same pinned `MISE_VERSION` keying behavior as PR builds. That keeps the public tag surface limited to the canonical release tags instead of leaving permanent public `-amd64` and `-arm64` tags behind.

Published images also carry explicit BuildKit provenance and SBOM attestations.

The published tags are:

* `projectjackin/construct:trixie` — the stable tag
* `projectjackin/construct:trixie-<sha>` — commit-specific tag

## The derived image layer [#the-derived-image-layer]

When you load an agent, jackin❯ doesn't use the construct directly. It generates a **derived Dockerfile** that adds:

1. **Agent installation** — copies jackin❯ cached binaries for every supported runtime into the derived image
2. **Plugin installation** — any Claude plugins declared in the role manifest
3. **Default-home snapshot** — captures each baked agent home under `/jackin/default-home` for first-launch seeding
4. **Volatile runtime payload** — copies jackin❯ owned runtime files, including the entrypoint, hook shims, agent-status helper, and `jackin-capsule`, after the heavier agent and default-home layers
5. **Runtime finalization** — creates the `/jackin/` runtime/state/run paths and appends shell-title integration after the runtime shims exist

The volatile runtime payload is intentionally late in the Dockerfile. A jackin❯ version bump changes `jackin-capsule`, so placing that COPY block after agent installs, plugin installs, and the default-home snapshot means a jackin❯ upgrade rebuilds only the cheap tail layers instead of invalidating the expensive agent setup layers.

<Aside type="note">
  jackin❯ tags derived images per role commit. Warm launches inspect recipe labels first; if the role source, construct/base image, runtime recipe, supported-agent set, `jackin-capsule`, hooks, Claude plugin recipe, host identity strategy, and host UID still match, launch reuses the local image and skips binary prep, build-context creation, GitHub build-token lookup, `docker build`, and the selected-agent version probe. Published-image lag falls back to a local role-base build while preserving Docker layer cache; passing `--rebuild` is the explicit full-refresh path.
</Aside>

## Extending the construct [#extending-the-construct]

Role repos add their tools on top of the construct. The construct provides the foundation — agents provide the specialization:

```
┌─────────────────────────────────┐
│  Derived Layer (jackin-managed) │  Selected agent CLI, entrypoint, /jackin state
├─────────────────────────────────┤
│  Agent Layer (your Dockerfile)  │  Rust, Node, Python, custom tools
├─────────────────────────────────┤
│  Construct (shared base)        │  Debian, git, Docker CLI, mise, zsh
└─────────────────────────────────┘
```

This layered approach means:

* Agent authors focus on their tools, not infrastructure
* The construct can be updated independently (security patches, new tools)
* Docker layer caching makes builds fast
