Sidecars
Route build commands to language-specific sidecar containers — keep the agent image slim while supporting Node.js, Python, Go, Java, and Rust toolchains
Sidecars are companion containers that run alongside the main agent, providing runtime tools (Node.js, Python, Go, etc.) without bloating the agent image. Transparent shell wrappers at /opt/sidecar/wrappers/ intercept tool calls and forward them to the sidecar container via docker exec.
Why Sidecars
- Slim agent image — the agent ships with only the AI CLI and minimal tooling. No multi-runtime bloat.
- Version flexibility — each project can use different runtime versions by swapping sidecar images.
- No privileged mode — unlike Docker-in-Docker, sidecars don't need privileged access.
- Transparent — the agent never knows the tools are remote.
npm installjust works.
How Wrappers Work
On startup, entrypoint.sh calls setup_tool_wrappers(), which:
- Reads
BUILD_SIDECARS— a JSON object mapping runtime names to compose service names. - Detects whether
SSH_HOSTis set. - For each known runtime and command:
- If the command is already installed natively → skip (native wins).
- Else if the runtime has a sidecar configured → create a wrapper calling
sidecar-exec. - Else if SSH is configured → create a wrapper calling
ssh-exec.
- Wrappers live in
/opt/sidecar/wrappers/, which is prepended toPATH.
Tool Resolution Priority
native binary in $PATH > BUILD_SIDECARS sidecar > SSH (ssh-exec)
If a command exists natively in the agent image, no wrapper is created. To force a command through the sidecar, remove the tool from the agent image.
Runner Requirement
You must use runner: compose to use sidecars. The docker runner starts exactly one container and has no mechanism to manage companion services. SSH host access works with both runners.
BUILD_SIDECARS Environment Variable
Set BUILD_SIDECARS in the profile env: block. The value is a JSON string mapping runtime names to compose service names:
env:
BUILD_SIDECARS: '{"node":"node-sidecar","python":"python-sidecar"}'
Runtime Keys and Wrapped Commands
| Runtime Key | Wrapped Commands |
|---|---|
node | npm, npx, pnpm, yarn, node |
python | python, python3, pip, pip3, poetry, pytest, ruff, black, mypy |
java | java, javac, mvn, gradle |
go | go, gofmt |
rust | cargo, rustc, rustfmt, clippy |
The values must exactly match the service names in compose.services.
sidecar-exec Binary
sidecar-exec is installed at /opt/sidecar/sidecar-exec in the agent image. When a wrapper invokes it:
- Reads
BUILD_SIDECARSto look up the Docker service name for the requested runtime. - Determines the compose project name:
heretic-<agent>-<session>. - Finds the running container via Docker labels (
com.docker.compose.projectandcom.docker.compose.service). - Runs
docker exec <container-id> <command> [args...]. - Forwards stdout/stderr and exit code.
Because it uses docker exec, the agent container needs access to the Docker socket.
dind: true Requirement
Set dind: true in your profile so sidecar-exec can run docker exec:
dind: true
This automatically mounts the Docker socket for the current platform:
| Platform | Socket path |
|---|---|
| Linux | /var/run/docker.sock |
| macOS | ~/.docker/run/docker.sock |
| Windows | //./pipe/docker_engine |
Or mount manually:
volumes:
- source: /var/run/docker.sock
target: /var/run/docker.sock
Sidecar Networking
Docker Compose creates a default network for the project. All services (agent and sidecars) can reach each other by service name. The sidecar must share the same workspace volume at the same path:
compose:
services:
node-sidecar:
image: node:22-alpine
volumes:
- /workspace:/workspace
command: ["/bin/sh", "-c", "sleep infinity"]
Full Example: Node.js Sidecar
Goal: Agent image has no Node.js; npm install and npx should run on a node:22 sidecar.
Profile (~/.heretic/agents/claude-node.yaml):
name: claude-node
image: ghcr.io/your-org/heretic-agent:claude-only
runner: compose
agent_type: claude
provider: anthropic
description: Claude with Node.js delegated to sidecar
secrets:
ANTHROPIC_API_KEY: ~/.heretic/get-anthropic-key.sh
volumes:
- source: ${CWD}
target: /workspace
workdir: /workspace
interactive: true
tty: true
dind: true # allows sidecar-exec to run docker exec
env:
BUILD_SIDECARS: '{"node":"node-sidecar"}'
compose:
services:
node-sidecar:
image: node:22-alpine
working_dir: /workspace
volumes:
- type: bind
source: ${CWD}
target: /workspace
command: ["/bin/sh", "-c", "sleep infinity"]
labels:
heretic.role: sidecar
Run it:
heretic-cli run claude-node
Inside the container:
# These commands run on the node-sidecar container transparently:
npm install
npx tsc --version
node -e "console.log('hello from sidecar')"
Full Example: Python + Go Sidecars
Profile (~/.heretic/agents/claude-polyglot.yaml):
name: claude-polyglot
image: ghcr.io/your-org/heretic-agent:claude-only
runner: compose
agent_type: claude
provider: anthropic
description: Claude with Python and Go sidecars
secrets:
ANTHROPIC_API_KEY: ~/.heretic/get-anthropic-key.sh
volumes:
- source: ${CWD}
target: /workspace
workdir: /workspace
interactive: true
tty: true
dind: true
env:
BUILD_SIDECARS: '{"python":"python-sidecar","go":"go-sidecar"}'
compose:
services:
python-sidecar:
image: python:3.12-slim
working_dir: /workspace
volumes:
- type: bind
source: ${CWD}
target: /workspace
command: ["/bin/sh", "-c", "pip install poetry && sleep infinity"]
go-sidecar:
image: golang:1.23-alpine
working_dir: /workspace
volumes:
- type: bind
source: ${CWD}
target: /workspace
environment:
GOPATH: /go
GOCACHE: /root/.cache/go-build
command: ["/bin/sh", "-c", "sleep infinity"]
Inside the container:
python3 -m pytest tests/
go build ./...
go test ./...
pip install requests
poetry run mypy src/
All commands execute on the appropriate sidecar.
Custom Sidecar Image
Use a custom Dockerfile for a sidecar with extra tools:
compose:
services:
node-sidecar:
build:
context: .heretic/sidecars/node
dockerfile: Dockerfile
working_dir: /workspace
volumes:
- type: bind
source: ${CWD}
target: /workspace
command: ["/bin/sh", "-c", "sleep infinity"]
.heretic/sidecars/node/Dockerfile:
FROM node:22-alpine
RUN npm install -g typescript @types/node ts-node
RUN apk add --no-cache git
Resource Limits for Sidecars
Use native docker-compose deploy.resources syntax:
compose:
services:
heavy-sidecar:
image: python:3.12
deploy:
resources:
limits:
memory: 2g
cpus: "1.5"
command: ["/bin/sh", "-c", "sleep infinity"]
Local Overrides
Add sidecars to a specific project without modifying the global profile. Create .heretic/cli/<profile>.yaml:
extends: claude-dev
runner: compose
dind: true
env:
BUILD_SIDECARS: '{"node":"node-sidecar"}'
compose:
services:
node-sidecar:
image: node:20-alpine
working_dir: /workspace
volumes:
- type: bind
source: ${CWD}
target: /workspace
command: ["/bin/sh", "-c", "sleep infinity"]
Merge rules for compose/env fields:
compose.services— deep merge: local services merge with global servicesenv:— shallow merge: local keys win (includingBUILD_SIDECARS)
See Local Overrides and Merge Rules for details.
Debugging
Check Which Wrappers Were Created
ls -la /opt/sidecar/wrappers/
Print the BUILD_SIDECARS Env Var
echo "$BUILD_SIDECARS"
Manually Test sidecar-exec
/opt/sidecar/sidecar-exec node npm --version
/opt/sidecar/sidecar-exec python python3 --version
Inspect Compose Services
# From outside the container
docker compose -f .heretic/sessions/default/compose.yaml ps
Inspect Generated Compose File
cat .heretic/sessions/default/compose.yaml
Enable Debug Logging
HERETIC_LOG_LEVEL=debug heretic-cli run my-profile
Container Not Finding Sidecar
Check that:
BUILD_SIDECARSkeys exactly match the runtime names (node,python,go,java,rust).BUILD_SIDECARSvalues exactly matchcompose.serviceskeys.- The sidecar container is actually running:
docker ps | grep sidecar. dind: trueis set (or docker.sock is mounted) sosidecar-execcan rundocker exec.
Reference
BUILD_SIDECARS Format
env:
BUILD_SIDECARS: '{"<runtime>":"<service-name>"[,...]}'
| Runtime Key | Wrapped Commands |
|---|---|
node | npm npx pnpm yarn node |
python | python python3 pip pip3 poetry pytest ruff black mypy |
java | java javac mvn gradle |
go | go gofmt |
rust | cargo rustc rustfmt clippy |
Compose Service Requirements
- The command being called must be installed in the sidecar image (e.g.,
npminnode:22). - The sidecar must be running (Docker Compose brings up all services before the agent starts).
- The workspace volume must be mounted at the same path in both containers.
- The sidecar container typically runs
sleep infinityas its command.
Docker-in-Docker (dind: true)
dind: true # Automatically mounts the Docker socket for the current platform:
# Linux: /var/run/docker.sock → /var/run/docker.sock
# macOS: ~/.docker/run/docker.sock → /var/run/docker.sock
# Windows: //./pipe/docker_engine → /var/run/docker.sock
Next Steps
- SSH Host Access — Route commands to a remote host via SSH
- Runners — Choose between docker, compose, and custom runners
- Docker Images — Build custom agent and sidecar images
- Local Overrides — Per-project sidecar configuration