Running ๐Ÿฐ Camelot AI locally with Docker runners

This guide walks you through running the BEAM on your host but launching each agent CLI inside a Docker container via the DockerEngine backend. It's the fastest way to exercise the new cluster-runner code path without standing up a Swarm.

For the multi-node Swarm story, see cluster-runners.md.

What you'll have at the end

  • ๐Ÿฐ Camelot AI running in iex -S mix phx.server on your host.
  • PostgreSQL in a container (existing docker-compose.yml).
  • Every agent CLI dispatch spawns a one-shot camelot-runner-<id> container on your local Docker daemon.
  • A working /profile page where you can add credentials and watch pool usage in real time.

Prerequisites

  • Docker Desktop running (or a working docker CLI against a local daemon).
  • Elixir 1.19 / Erlang/OTP 28 (use asdf or mise).
  • A clone of this repo with deps fetched:
    mix deps.get

1. Start the database

docker compose --profile db up -d

This brings up the db service from docker-compose.yml on port 5433. The .env in the repo root supplies credentials.

Migrate:

mix ecto.migrate

2. Build at least one runner image

For a true end-to-end test (real agent), build the base + claude images. Single-arch (matching your host) is fine for local dev:

docker build -t camelot/runner-base:dev runner-images/base
docker build \
--build-arg BASE_IMAGE=camelot/runner-base:dev \
-t camelot/runner-claude:dev runner-images/claude

For a faster smoke test that only verifies the runner-spawn / log-tail / exit-code wiring, skip the build and use a stock image:

docker pull alpine:latest

3. Start ๐Ÿฐ Camelot AI pointed at Docker

RUNNER_BACKEND=docker \
DOCKER_HOST=unix:///var/run/docker.sock \
RUNNER_PER_USER_MAX=2 \
RUNNER_GLOBAL_MAX=5 \
iex -S mix phx.server

The RUNNER_BACKEND=docker env var picks up Camelot.Runtime.Runner.DockerEngine, which talks to /var/run/docker.sock directly.

Verify:

iex> Camelot.Runtime.Runner.backend()
Camelot.Runtime.Runner.DockerEngine
iex> Camelot.Runtime.Runner.DockerApi.ping()
:ok

If ping/0 returns an error, your DOCKER_HOST is wrong or the daemon isn't reachable.

4. Sign in

Open http://localhost:4000. Sign in via magic link โ€” the email lands in the dev mailbox at http://localhost:4000/dev/mailbox.

5. Create an Agent CLI

Use the smoke Agent CLI (alpine) or the real one (claude). In an IEx shell:

iex> Ash.create!(Camelot.Agents.Agent, %{
...> slug: "alpine-echo",
...> name: "Alpine smoke test",
...> executable: "/bin/sh",
...> base_args: ["-c", "echo hello; sleep 2; echo bye"],
...> runner_image: "alpine:latest",
...> runner_resources: %{"cpu" => "0.5", "memory" => "256M"},
...> required_credential_kinds: [],
...> parser: :raw_text,
...> pr_url_pattern: "x^"
...> })

For Claude Code:

iex> Ash.create!(Camelot.Agents.Agent, %{
...> slug: "claude_code",
...> name: "Claude Code",
...> executable: "claude",
...> base_args: ["--print"],
...> prompt_flag: nil,
...> tools_flag: "--allowed-tools",
...> runner_image: "camelot/runner-claude:dev",
...> runner_resources: %{"cpu" => "1.0", "memory" => "1G"},
...> required_credential_kinds: [:claude_api_key],
...> parser: :claude_code_json,
...> pr_url_pattern: "https://github\\.com/[^\\s]+/pull/(\\d+)"
...> })

(Or use the existing /agents/new UI, then set runner_image etc. via the form.)

To pin a single project to a different image without touching the shared Agent CLI, set that project's runner_image_override (via the project's edit form, or Ash.update!/2). It takes precedence over Agent.runner_image for every task in that project; leaving it blank falls back to the Agent CLI's default.

6. Create a Project

Visit /projects/new. The DockerEngine backend reproduces the Swarm flow exactly: it always clones github_repo_url into an ephemeral /workspace tmpfs. So:

  • Set github_repo_url to a clonable URL. Public repos work without extra credentials; private repos need either a linked GitHub App installation on the project or the user's SSH key (see docs/self-hosting/github-app.md).
  • For the smoke test, leave it empty โ€” the entrypoint will skip the clone and /workspace will be an empty tmpfs.

If you want to point at code already on your disk (no clone, no container), switch the backend to LocalPort:

RUNNER_BACKEND=local iex -S mix phx.server

Then path on the project drives the BEAM's cd for the CLI.

7. Add credentials at /profile

Visit http://localhost:4000/profile:

  • Add credentials for whatever the Agent CLI's required_credential_kinds lists (e.g. :claude_api_key).
  • (Optional) set your swarm node label โ€” not used by the DockerEngine backend, but you can populate it now so the model is the same as the hosted setup.

Note: when RUNNER_BACKEND=docker, secrets are passed to the container via env vars under CAMELOT_SECRET_<KIND> โ€” Swarm secrets only kick in with RUNNER_BACKEND=swarm. The entrypoint inside the image reads from /run/secrets/... first; we'll likely add an env fallback there in a follow-up. For now this means:

  • Alpine smoke test: no credentials needed.
  • Claude test: pass the API key via the Agent CLI's env_vars map for now, e.g. env_vars: %{"ANTHROPIC_API_KEY" => "sk-..."}, until env-fallback lands.

8. Note your Agent CLI's id

There's no more per-project Agent row to wire up โ€” a task carries its own fixed agent_id, chosen from an explicit "CLI Agent" dropdown on the board's "New Task" modal at creation time. If you want the id handy for the IEx dispatch walkthrough below, grab it with:

iex> agent = Ash.read_first!(Camelot.Agents.Agent)

9. Dispatch a task and watch the container

Two terminals:

Terminal 1 โ€” watch container lifecycle:

watch -n 1 'docker ps --filter "name=camelot-runner-"'

Terminal 2 โ€” once a container appears, tail its logs:

docker logs -f $(docker ps -q --filter "name=camelot-runner-")

Then in the ๐Ÿฐ Camelot AI UI:

  1. Open / (the board), create a new task on your project โ€” pick your Agent CLI from the "CLI Agent" dropdown on the "New Task" modal.
  2. Drag it to "Todo".
  3. It gets picked up via the Oban dispatch_tasks job (runs every minute). To skip the wait, manually dispatch in IEx:
    iex> Camelot.Runtime.TaskRunnerSupervisor.start_task_runner(task.id)
    iex> Camelot.Runtime.TaskRunner.dispatch(task.id, "do something", [])

You should see:

  • A Session row appear with status: :queued.
  • A few moments later, status: :running and service_id populated.
  • A camelot-runner-<session_id> container in docker ps.
  • Output streaming into the LiveView task page in real time.
  • On exit, container disappears, session marked :completed / :failed.

Verify the pool snapshot updates at /profile:

You โ€” running: 0/2
You โ€” queued: 0
Cluster: 0/5

10. Tear-down between tries

If something goes sideways and you have stuck containers:

docker ps -a --filter "name=camelot-runner-" -q | xargs -r docker rm -f

The Reconciler sweeps orphans automatically once per minute too, so they'd be cleaned up regardless โ€” but -f is faster.

To reset queued sessions in the DB after a hard restart:

iex> import Ecto.Query
iex> Camelot.Repo.update_all(
...> from(s in Camelot.Agents.Session,
...> where: s.status in [:queued, :running]
...> ),
...> set: [status: :cancelled, finished_at: DateTime.utc_now()]
...> )

Troubleshooting

Camelot.Runtime.Runner.backend/0 returns LocalPort

You forgot RUNNER_BACKEND=docker in front of iex -S mix phx.server. Quit (Ctrl+C twice) and relaunch with the env var prefix.

DockerApi.ping() returns {:error, _}

  • DOCKER_HOST=unix:///var/run/docker.sock is the default. On Docker Desktop for Mac this socket is the host's socket, exposed at the same path. If it's not there, set it to wherever Docker Desktop exposes it (check docker context inspect).
  • For a remote daemon set DOCKER_HOST=tcp://host:2375.

Container starts then exits immediately with no logs

  • Check docker inspect <name> โ€” the State.Error field usually explains it.
  • For alpine:latest, make sure the Agent CLI's executable is something that actually exists in the image (/bin/sh, not claude).

Session never transitions out of :queued

  • RunnerPool may be at its per_user_max cap. Check /profile.
  • The pool monitors the TaskRunner pid โ€” if your IEx node restarted, the monitor fires and the slot frees, but the queued sessions may still be in the DB. Run the DB reset from step 10.

Logs not streaming into the LiveView

  • LocalPort uses Phoenix.PubSub topic task:<task_id> โ€” same as DockerEngine. Verify the LiveView subscribes there. If you wired in custom UI, double-check.

"Cannot pull camelot/runner-claude:dev"

You only built it locally โ€” there's no registry to pull from. Make sure runner_image: "camelot/runner-claude:dev" matches exactly the tag you built (case sensitive). Docker won't auto-pull tags that don't exist remotely.

Going further

When you're done with single-node local testing and want to exercise the Swarm backend:

docker swarm init
docker node update --label-add camelot-home=local $(docker node ls -q)
# Set User.swarm_node_label = "local"
RUNNER_BACKEND=swarm iex -S mix phx.server

Everything else stays the same. See cluster-runners.md for the full hosted-mode setup.