---
title: Sidecars
description: "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"
canonical: https://giglabo.com/heretic/docs/heretic-cli/configuration/sidecars
locale: en
---

# Sidecars

> Markdown twin of https://giglabo.com/heretic/docs/heretic-cli/configuration/sidecars
> Fetch this instead of the HTML page: same content, a fraction of the bytes.
> Site structure and the full page list for agents: https://giglabo.com/llms.txt

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](https://giglabo.com/heretic/docs/heretic-cli/configuration/ssh) for the remote-host variant.

## Quick start

```bash
# 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:

```yaml
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
```

> **Note: 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

```yaml
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-empty`
- `tool_backends.sidecars[i].port must be between 1 and 65535`
- `tool_backends.ready_timeout must be a positive number of seconds`
- `tool_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

```bash
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 to `heretic-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-sidecars` wins over `--sidecar`.
- All three work in the `heretic-cli <profile>` shorthand form.

## Building builder images

```bash
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`](https://giglabo.com/heretic/docs/heretic-cli/commands/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:

1. **A private network** `heretic-net-<agent>-<session>-<hash8>` (bridge), labelled `heretic.managed=true`, `heretic.role=build-network`. An existing network with that name is reused, so a crashed previous run does not block start-up.
2. **One builder per sidecar**, named `heretic-builder-<runtime>-<session>-<hash8>` with the network alias `builder-<runtime>`:
   - the **same** workspace bind as the agent
   - command `exec-server -port <p> -cwd <workspace_target>` unless overridden
   - environment: your `env`, `EXEC_SERVER_PORT`, and `HOME` under the workspace when running as the caller uid
   - `User: <uid>:<gid>` of the calling user (`run_as_caller_uid: false` disables this)
   - `CapDrop: ALL`, `no-new-privileges`, no restart policy, **no published ports**
   - labels including `heretic.role=build-sidecar`, `heretic.runtime`, `heretic.network`
   - any `cache_volumes` as additional binds

   A stale container with the same name is force-removed first.
3. **A health gate** — the builder's health status is polled every 2 s until `healthy`, up to `ready_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`
4. **Agent wiring** — `BUILD_SIDECARS` and `SIDECAR_ENV_PASSTHROUGH` in the environment, the shared network as the agent's network, and the `heretic.network` label.

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

```json
{"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`.

> **Warning: 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.

```bash
# 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's `builder` user, 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.

> **Warning: /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

```yaml
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:

```bash
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](https://giglabo.com/heretic/docs/heretic-cli/commands/image) — building agent and builder images
- [SSH Host Access](https://giglabo.com/heretic/docs/heretic-cli/configuration/ssh) — the remote-host backend
- [Runners](https://giglabo.com/heretic/docs/heretic-cli/configuration/runners) — orchestration differences
- [run](https://giglabo.com/heretic/docs/heretic-cli/commands/run) — the per-run flags

## Related

- HTML version of this page: https://giglabo.com/heretic/docs/heretic-cli/configuration/sidecars
- Site map for agents: https://giglabo.com/llms.txt
