Sidecars
Build sidecars for heretic agents — tool_backends configuration, the --sidecar flags, builder images, the BUILD_SIDECARS contract, the exec-server HTTP API, health gating, caches, security posture and troubleshooting
The problem. A slim agent image has no npm, pip, mvn, go or cargo. The solution. Keep the toolchain in a sibling container that shares the same workspace, and put thin wrappers on the agent's PATH that forward each command there over HTTP.
| Backend | Configured with | Transport | Client in the agent |
|---|---|---|---|
| HTTP build sidecar | tool_backends.sidecars | HTTP POST to http://builder-<runtime>:<port> | /opt/sidecar/sidecar-exec |
| SSH | the ssh: block | ssh user@host 'cd <cwd> && <cmd>' | /opt/sidecar/ssh-exec |
Both are driven by the same wrapper mechanism, generated by the image's entrypoint. This page covers the HTTP sidecars; see SSH Host Access for the remote-host variant.
Quick start
# 1. build a builder image for the runtime you need
heretic-cli image build-sidecar node # → heretic-builder-node:latest
# 2. run the agent with that sidecar
heretic-cli run claude --sidecar node
# 3. inside the container, npm now works — it executes in the builder, same files
agent@…:/workspace$ npm ci && npm test
Persist it in the profile instead of passing flags:
volumes:
- { source: "${CWD}", target: /workspace } # REQUIRED: builders share this exact bind
tool_backends:
sidecars:
- runtime: node
image: heretic-builder-node:latest
cache_volumes: ["heretic-npm-cache:/home/builder/.npm"]
workspace_target: /workspace
ready_timeout: 60
run_as_caller_uid: true
Works with the docker and compose runners
The docker runner orchestrates builders natively through the Docker API; the compose runner emits them as services with depends_on: service_healthy. Only runner: custom is rejected: Build sidecars require the 'docker' or 'compose' runner, but this profile uses the custom runner. No Docker socket and no privileged mode are needed.
The five runtimes
runtime must be one of node, python, java, go, rust — those are the runtimes the wrapper generator knows, and anything else fails validation.
| Runtime | Commands wrapped (only when missing from the agent image) | Builder base image | Extra tools |
|---|---|---|---|
node | npm npx pnpm yarn node | node:<v>-bookworm-slim (22) | corepack (yarn, pnpm) |
python | python python3 pip pip3 poetry pytest ruff black mypy | python:<v>-slim-bookworm (3.13) | poetry, pytest, ruff, black, mypy |
java | java javac mvn gradle | eclipse-temurin:<v>-jdk (21) | maven, gradle |
go | go gofmt | golang:<v>-bookworm (1.23) | — |
rust | cargo rustc rustfmt clippy | rust:<v>-bookworm | rustfmt, clippy |
Resolution order inside the container:
native binary on PATH > HTTP sidecar for that runtime > SSH host
A command that already exists in the agent image is never wrapped — the local tool wins.
Configuration reference
tool_backends:
sidecars:
- runtime: node # required: node | python | java | go | rust
image: heretic-builder-node:latest # required, non-empty
port: 8080 # optional, 1–65535, default 8080
command: ["exec-server", "-port", "8080", "-cwd", "/workspace"] # optional override
env: { NPM_CONFIG_FUND: "false" }
env_passthrough: ["NPM_TOKEN"] # allowlist forwarded per exec
cache_volumes: ["heretic-npm-cache:/home/builder/.npm"]
healthcheck: { test: [...] } # raw compose healthcheck override
workspace_target: /workspace # default; MUST equal one volume target
ready_timeout: 60 # seconds, ≥ 1
run_as_caller_uid: true # default true
Validation errors you may hit:
tool_backends.sidecars[i].runtime '<x>' is invalid (must be one of: node, python, java, go, rust)tool_backends.sidecars[i]: duplicate runtime '<x>'tool_backends.sidecars[i].image must be non-emptytool_backends.sidecars[i].port must be between 1 and 65535tool_backends.ready_timeout must be a positive number of secondstool_backends: no volume targets '<workspace_target>' — build sidecars need the workspace bind-mounted
Configuring both sidecars and ssh logs a warning: a single build would then span two filesystems.
Merge behaviour: scalar keys merge across layers, but the sidecars array is replaced.
CLI flags
heretic-cli run claude --sidecar node --sidecar python # replaces the profile list
heretic-cli run claude --builder-image node=ghcr.io/me/node-builder:2 # retarget one runtime
heretic-cli run claude --disable-sidecars # off for this run
--sidecar <rt>defaults each image toheretic-builder-<rt>:latest.--builder-image <rt>=<img>also overrides profile-declared sidecars; a malformed value fails with--builder-image expects <runtime>=<image>, got '<raw>'.--disable-sidecarswins over--sidecar.- All three work in the
heretic-cli <profile>shorthand form.
Building builder images
heretic-cli image build-sidecar node
heretic-cli image build-sidecar python --runtime-version 3.12
heretic-cli image build-sidecar go -r ghcr.io/acme -t 1.0.0 -a both -p
heretic-cli image build-sidecar java --dry-run # print the Dockerfile only
The generated Dockerfile has two stages: compile the vendored Go exec-server statically, then copy it onto the runtime base, install the toolchain, create a non-root builder user, expose the port and declare a port-agnostic HEALTHCHECK. Full flag list: heretic-cli image.
You can also bring your own builder image — anything that shares the workspace and speaks the exec-server HTTP contract works. Without a baked HEALTHCHECK the docker runner falls back to an exec probe, so a custom image should either declare a healthcheck or ship the exec-server binary.
What orchestration creates
With the docker runner:
-
A private network
heretic-net-<agent>-<session>-<hash8>(bridge), labelledheretic.managed=true,heretic.role=build-network. An existing network with that name is reused, so a crashed previous run does not block start-up. -
One builder per sidecar, named
heretic-builder-<runtime>-<session>-<hash8>with the network aliasbuilder-<runtime>:- the same workspace bind as the agent
- command
exec-server -port <p> -cwd <workspace_target>unless overridden - environment: your
env,EXEC_SERVER_PORT, andHOMEunder the workspace when running as the caller uid User: <uid>:<gid>of the calling user (run_as_caller_uid: falsedisables this)CapDrop: ALL,no-new-privileges, no restart policy, no published ports- labels including
heretic.role=build-sidecar,heretic.runtime,heretic.network - any
cache_volumesas additional binds
A stale container with the same name is force-removed first.
-
A health gate — the builder's health status is polled every 2 s until
healthy, up toready_timeout. Failures abort the run and tear down everything created:build sidecar '<name>' exited before becoming healthy (status: …)build sidecar '<name>' did not become healthy within <n>s
-
Agent wiring —
BUILD_SIDECARSandSIDECAR_ENV_PASSTHROUGHin the environment, the shared network as the agent's network, and theheretic.networklabel.
With the compose runner the same shape is emitted as services: one builder-<runtime> per sidecar with identical binds, cap_drop, security_opt, user, labels and a healthcheck (interval 2 s, timeout 3 s, retries derived from ready_timeout), plus depends_on: { builder-<rt>: { condition: service_healthy } } on the agent.
Teardown: in-process when the runner stops, label-driven for detached runs, and heretic-cli stop removes sibling builders plus the per-run network. Named cache volumes are never removed — that is what keeps builds fast. (The compose runner's own stop() uses docker compose down --volumes, which does delete them.)
The BUILD_SIDECARS contract
{"node":{"internal_url":"http://builder-node:8080"},
"python":{"internal_url":"http://builder-python:8080"}}
Keys are runtimes; clients read only internal_url. Both runners emit exactly this shape, and the bundled sidecar-exec extracts it with jq.
sidecar-exec — the agent-side client
Invoked by the generated wrapper as sidecar-exec <runtime> <command> [args…].
| Environment variable | Meaning |
|---|---|
BUILD_SIDECARS | required — the JSON above |
SIDECAR_TIMEOUT | seconds sent to the server (default 600); the transport deadline is that plus 30 |
SIDECAR_CWD | working directory sent (default: the current directory) |
SIDECAR_STREAM | 1 streams output over SSE instead of waiting for the blocking call |
SIDECAR_ENV_PASSTHROUGH | comma-separated allowlist of environment names forwarded per exec |
Exit codes: the command's own code, 124 on timeout, 255 on a server-side start failure, and 1 on transport or configuration errors such as no sidecar available for runtime '<rt>' (available: …), failed to connect to sidecar at <url>, or stream ended without an exit frame.
Never forward the whole environment
The agent holds ANTHROPIC_API_KEY, GH_TOKEN and friends. Only the names listed in a sidecar's env_passthrough are forwarded, e.g. env_passthrough: ["NPM_TOKEN"].
The exec-server HTTP API
A dependency-free Go server, built into the builder image.
| Endpoint | Purpose |
|---|---|
GET /health | readiness probe — always open |
GET /info | runtime, version and working directory (never the environment) |
POST /exec | run a command, return stdout, stderr and the exit code |
POST /exec/stream | the same, streamed as server-sent events with a final exit frame |
Flags -port, -cwd, -healthcheck; environment EXEC_SERVER_PORT, EXEC_SERVER_CWD, EXEC_SERVER_RUNTIME, EXEC_SERVER_TOKEN. Timeouts default to 600 s with a hard cap of 3600 s, request bodies are capped at 1 MiB, and there is deliberately no read/write timeout because real builds hold the connection for minutes. Commands run in their own process group, so a timeout kills the whole tree instead of leaving orphans.
# from inside the agent container
curl -s http://builder-node:8080/health
curl -s http://builder-node:8080/info | jq
Security posture
- Builders publish no host ports and are reachable only by network alias on a private network.
cap_drop: ALL,no-new-privileges, and non-root execution (the image'sbuilderuser, or your uid at run time).- Only allowlisted environment variables cross into builds.
run_as_caller_uid: true(the default) means build artefacts are owned by you, not root.
/exec is unauthenticated by default
Anyone who can reach the builder's network can run commands in your workspace. On a shared host set EXEC_SERVER_TOKEN through the sidecar's env — note that the bundled sidecar-exec does not send a bearer token, so enabling it means shipping your own client.
Caches
tool_backends:
sidecars:
- runtime: node
image: heretic-builder-node:latest
cache_volumes: ["heretic-npm-cache:/home/builder/.npm"]
- runtime: python
image: heretic-builder-python:latest
cache_volumes: ["heretic-pip-cache:/home/builder/.cache/pip"]
Named volumes survive heretic-cli stop and are shared by every project that names them. Inspect with docker volume ls and remove them manually when you want a cold build.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
wrappers never appear, npm: command not found | the image has no baked ENTRYPOINT (built before the feature) → rebuild with heretic-cli image build, or run with --root, which bind-mounts the current entrypoint |
FATAL: /opt/sidecar/wrappers is not writable by agent (uid 1000) | the image was built with a different --agent-uid, or extra.user changed it → rebuild with a matching uid |
sidecar-exec: BUILD_SIDECARS is not set | the wrapper exists but no sidecar is configured for this run |
no sidecar available for runtime 'x' | that runtime is not in BUILD_SIDECARS — a typo, or --disable-sidecars |
tool_backends: no volume targets '/workspace' | add a volume whose target equals workspace_target |
| builder unhealthy or timing out | docker logs heretic-builder-<rt>-<session>-<hash8>; raise ready_timeout; confirm the image really runs exec-server |
| build artefacts owned by root | run_as_caller_uid was disabled, or a custom image forces root |
| builders unreachable | extra.network pinned the agent to another network (a warning is logged) |
| every streamed build appears to succeed | an outdated sidecar-exec — regenerate with heretic-cli image generate --format sidecar-exec |
Sidecars are hidden from heretic-cli ps on purpose. Inspect them directly:
docker ps --filter label=heretic.role=build-sidecar
docker logs heretic-builder-node-default-1a2b3c4d
docker network ls --filter label=heretic.role=build-network
Next Steps
- image — building agent and builder images
- SSH Host Access — the remote-host backend
- Runners — orchestration differences
- run — the per-run flags