docs
// EEM Guides

Deploy Your First EEM

One command from a blank Linux host to an EEM agent enrolled with your router and serving an operational workspace. No GPU. erectl deploy agent eem creates the directories, mints an execution join key, generates and starts the stack, and verifies enrollment before it reports success. Stops at the first successful tool call.

Prerequisites

  • A Linux host with docker (or podman) and Compose v2, and root on it via sudo. Minimum 1 CPU, 512 MB RAM. No GPU. Without a container runtime, deploy on bare metal instead (see Step 1).
  • erectl installed on that host and configured against your router with an API key carrying the management scope -- see erectl. Under sudo it is root's configuration that is read, so either configure root or pass --base-url and --api-key explicitly.
  • A project. The agent joins the project your base_url is scoped to unless you name another with --project.
  • Outbound HTTPS to the router URL (for enrollment) and outbound TCP to the router's CurveZMQ port (for the data plane).
  • One of: a Kubernetes cluster, OpenStack, Docker daemon, or another target infrastructure the EEM should manage.

Step 1: Deploy the Agent

One command. It mints the join key itself, so there is no token to copy between machines and nothing to paste into a file.

bash
sudo erectl deploy agent eem --service --region us-east

--service installs the systemd unit so the agent comes back after a reboot. The region is what the minted key records; add --registration-name to name the agent something other than the default eem-<hostname>.

The command plans first and asks before it changes anything:

text
Erebine EEM Agent Deploy Plan ============================= 1. Target shape EEM execution agent (no GPU, no kernel tuning), runtime docker registration name eem-fleet-01; project the base URL's project (read at step 1; override with --project) compose: docker compose image: ghcr.io/erebine/container-agents/eem:latest 2. Host mutations data dirs: /data/erebine-eem/{credentials,state,config} (chown 5153) artifacts: /etc/erebine/deploy-eem/{eem.compose.yaml 644, eem.env 600, eem.local.env 600 (seeded once), deploy-state.json 640} units: erebine-eem.service 3. Join key mint after pull (execution key; keys live at most 1h; expiry starts then) maxEnrollments 10; role execution; no tiers router url resolved at step 5 (no --enroll-endpoint; the token's htp claim, else https://router.example.com from config base_url) 4. Start and verify first enrollment (agent-enrollment.json absent; one join-key slot) gates: enrollment delta, liveness, router health + capability manifest timeout: 120s 5. Boot-restart truth systemd starts the stack at boot 6. Listening surface bridge network: the execution agent listens on nothing host-side 7. Warnings (none) Proceed? (y/N)

Answer y and the seven steps run in order:

text
[1/7] Creating data directories under /data/erebine-eem (uid 5153) ... [2/7] Writing compose, env, state, pointer ... [3/7] Installing the systemd unit ... [4/7] Pulling ghcr.io/erebine/container-agents/eem:latest ... [5/7] Resolving the join key ... minted execution join key jk_01HX... (prefix ere-join..., expires in <= 1h) [6/7] Starting the agent ... [7/7] Verifying ... gate 1: 1 enrollment(s) confirmed gate 2: all 1 container(s) running gate 3: execution agent us-east:agent-3f9c1d2b8a04 connected, heartbeating, capability manifest published Deploy verified. Recommended next step: revoke the join key now that enrollment is complete: erectl agents join-keys jk_01HX... --revoke

Run the revoke command it prints. The agent redeems its join key once and keeps the enrollment beside its keys under /data/erebine-eem/config, so restarts and re-deploys need no key at all.

Preview first: erectl deploy agent eem --dry-run --region us-east prints the same plan and every command it would run, touching nothing. It is the one mode that does not need root.

No container runtime on the host? Add --runtime baremetal. The agent binary comes from the host's package manager, Homebrew or the release, verified against the release's sha256, and runs as erebine-eem.service under its own erebine-eem account, from agent release 2.4.0 on. The data directory, the join key, the gates and --down work as above; the credentials directory stays root's, read-only to the agent.

bash
sudo erectl deploy agent eem --runtime baremetal --service --region us-east

The agent needs libzmq, libsodium, libzstd and libcurl; the packages pull them in, and for the release binary the deploy names any that are missing. Details: bare metal.

Step 2: Verify the Enrollment

The deploy already proved enrollment, liveness and the published manifest. From your workstation, the same facts read back through the API:

bash
erectl agents
text
ID NAME WORKER_ID STATUS REGION PRESEED LAST_HEARTBEAT agt_01HX.. us-east:agent-3f9c1d2b8a04 us-east:agent-3f9c1d2b8a04 active us-east no 2026-04-22T03:31:12Z

Enrollment assigns the worker id and mirrors it into NAME, so both columns carry the same <region>:agent-<id> string -- not the --registration-name the deploy passed, which is not stored. Pick your agent out by the worker id gate 3 printed. Then list the tools the agent published in its capability manifest -- this is the catalogue callers dispatch against:

bash
erectl exec tools
text
Name Description kubectl_get Read Kubernetes resources kubectl_drain Drain a node (requires approval)

An empty list means the agent advertises no tool. The tools are built into the agent, which leaves out a tool whose command, such as kubectl, is not installed where it runs, and every tool in a domain EREBINE_EEM_DOMAINS or EREBINE_EEM_DISABLE_DOMAINS turns off.

Step 3: Declare a Workspace

The agent cannot learn which workspaces it serves: enrollment carries keys, addresses and a token, never a workspace list. You declare them in a workspaces.yaml the agent reads, and the name of each declaration must equal the router-side workspace name exactly -- that string is the only axis the router matches on.

Write the file into the config directory the agent mounts, and point the agent at it through the operator-owned environment file (the deploy seeds that file once and never rewrites it):

/data/erebine-eem/config/workspaces.yaml
workspaces: - name: prod-k8s # MUST equal the router-side workspace name type: kubernetes description: Production cluster domains: [kubernetes, observability] scope: namespaces: [default, kube-system] rate_per_minute: 10
bash
echo 'EREBINE_EEM_WORKSPACES_FILE=/var/lib/eem/.config/erebine/workspaces.yaml' \ | sudo tee -a /etc/erebine/deploy-eem/eem.local.env sudo chown 5153:5153 /data/erebine-eem/config/workspaces.yaml sudo systemctl restart erebine-eem

Later edits to the declarations need no restart: the dispatch path rebuilds the manifest on every invocation, so narrowing a scope binds the very next tool the agent runs, and the router picks the change up within about one lease interval.

Create the matching workspace on the router with the same name. The ID it prints is the workspace external id the bind below and Step 4 both take; erectl workspaces list prints it again for a workspace that already exists:

bash
erectl workspaces create --name prod-k8s
text
Workspace 'ws_01HX...' created successfully. Workspace: ID: ws_01HX... Project ID: proj_01HX... Name: prod-k8s Kind: chat Default: no Inference routing: default Created: 2026-04-22T03:29:44Z Updated: 2026-04-22T03:29:44Z

Then bind this agent to it. Without a binding, dispatch falls back to the first agent in the project that advertises the tool:

bash
erectl workspaces bind-agent ws_01HX... agt_01HX...

Both arguments are positional: the workspace external id (ws_xxx) and the agent id from erectl agents. List the bindings back with erectl workspaces agents ws_01HX....

Step 4: Run a Tool Call

bash
erectl exec invoke \ --workspace ws_01HX... \ --tool kubectl_get \ --args '{"resource": "pods", "namespace": "default"}'

If the tool is read-only and the approval policy allows, the invocation runs immediately and returns the pod list. If approval is required, the CLI blocks with an invocation ID you can approve via erectl approvals approve.

Congratulations, your first EEM is serving an operational workspace. Next, author a chat template to bake in a repeatable procedure: Author a Chat Template.

Troubleshooting

See EEM Troubleshooting for the full error-code matrix. Common first-deploy issues:

  • The join key was minted for an inference agent: a key carries the role it was minted for, and erectl deploy agent eem refuses it before the host is touched; the router refuses the enrollment too. Drop --join-key-file and let the deploy mint an execution key, or mint one from the execution-agent tab of the agents dashboard.
  • enrollment_rejected: the join key expired or the registration name collides with another agent. Re-run the deploy -- it mints a fresh key -- and pass --registration-name if the name is the problem.
  • conflict_pill state: the registration name is claimed by another CURVE fingerprint. Run erectl agents revoke-credentials <agent-id> to force a clean re-enrollment.
  • The agent will not enroll again: it redeems a join key once and keeps the enrollment in agent-enrollment.json beside its keys in the config directory. Remove that file to enroll again from a fresh key.
  • queue_write_failed: the /data/erebine-eem/state volume is not persistent, or the disk is full.
  • No log output: the generated stack pins the journald log driver, so the container's output is in journalctl -u erebine-eem -f on docker as well as podman.

To take the agent down, keeping its data, keys and enrollment: sudo erectl deploy agent eem --down. To re-render the deployment with a newer erectl and refresh the image: sudo erectl deploy agent eem --update, which restarts only when something actually moved.

Manual Deployment (compose)

For hosts erectl cannot reach, the same stack runs by hand from the public compose file. Mint the join key from the execution-agent tab of the agents dashboard first.

Clone the erebine/container-agents repository on the EEM host (or copy just the compose/ directory) so the compose file referenced below is available locally.

bash
git clone https://github.com/erebine/container-agents.git cd container-agents/compose

Create the host directories the agent mounts for workspace credentials, runtime state, and its persistent signing key. The container runs as UID:GID 5153:5153.

bash
sudo mkdir -p /data/erebine-eem/credentials \ /data/erebine-eem/state /data/erebine-eem/config sudo chown -R 5153:5153 /data/erebine-eem

Create a .env file in the compose/ directory. Docker Compose reads it automatically for variable substitution. The agent refuses to start without the three required values.

compose/.env
# Required EREBINE_AGENT_JOIN_KEY=eyJhbGciOi... EREBINE_ROUTER_URL=https://router.example.com EREBINE_AGENT_REGISTRATION_NAME=eem-fleet-01 # Image (pulls the published container image) EEM_IMAGE=ghcr.io/erebine/container-agents/eem:latest # Optional EEM_AGENT_LOG_LEVEL=info EEM_AGENT_MAX_CONCURRENT=20

Treat this file as a secret: the join token is a bearer credential. Set chmod 600 .env; never commit it to a repository and never copy it onto an operator workstation.

bash
docker compose -f compose.agent-eem.yaml up -d docker compose -f compose.agent-eem.yaml logs -f eem-agent

Expected messages on a first start, in order. Each line also carries a timestamp, the level and its metadata; Enrollment active carries the assigned worker_id and source=join_key.

text
EEM Agent starting Enrolling with router Enrollment completed Enrollment active Announced on the control plane EEM Agent running

A later start restores the saved enrollment instead: it logs Restored persisted enrollment in place of Enrolling with router and Enrollment completed, and Enrollment active carries source=persisted.

The registration name must be unique per project. The agent generates a persistent Ed25519 signing key on first run and writes it under the mounted config volume so CURVE rotation acks survive restarts; the router address and CURVE public key arrive in the enrollment response. The lease duration is owned by the router server-side. The join key is consumed once, and the enrollment is persisted beside the keys: do not re-use the key even if the enrollment seems to have failed.

Declaring a workspace on the manual path

Step 3's first two commands belong to the erectl path: they write eem.local.env and restart erebine-eem.service, and a hand-run stack has neither. The canonical compose file also declares no env_file, so a name in compose/.env is only substituted into the ${...} references already in the file and never reaches the container by itself. Declaring a workspace here therefore takes one edit to the compose file.

Write the declarations into the config directory the stack mounts -- the same file shown in Step 3, and the same rule that name must equal the router-side workspace name exactly:

bash
sudo tee /data/erebine-eem/config/workspaces.yaml >/dev/null <<'YAML' workspaces: - name: prod-k8s # MUST equal the router-side workspace name type: kubernetes description: Production cluster domains: [kubernetes, observability] scope: namespaces: [default, kube-system] rate_per_minute: 10 YAML sudo chown 5153:5153 /data/erebine-eem/config/workspaces.yaml

The agent looks for the file at /etc/erebine/workspaces.yaml unless told otherwise, and this stack does not mount that path. Add one key to the environment: block of compose.agent-eem.yaml pointing it at the config mount instead:

compose.agent-eem.yaml
environment: # ... the keys already there ... EREBINE_EEM_WORKSPACES_FILE: /var/lib/eem/.config/erebine/workspaces.yaml
bash
docker compose -f compose.agent-eem.yaml up -d --force-recreate

Later edits to the declarations need no recreate: the dispatch path rebuilds the manifest on every invocation. Adding or removing the key does, because it changes the container's environment.

The router half is identical on both paths and runs from any workstation with erectl configured, not on the agent host: create the workspace and bind the agent with the erectl workspaces create and erectl workspaces bind-agent commands in Step 3, then run a tool call as in Step 4.