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(orpodman) and Compose v2, and root on it viasudo. Minimum 1 CPU, 512 MB RAM. No GPU. Without a container runtime, deploy on bare metal instead (see Step 1). erectlinstalled on that host and configured against your router with an API key carrying themanagementscope -- see erectl. Undersudoit is root's configuration that is read, so either configure root or pass--base-urland--api-keyexplicitly.- A project. The agent joins the project your
base_urlis 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.
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:
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:
[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.
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:
erectl agents
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:
erectl exec tools
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):
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
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:
erectl workspaces create --name prod-k8s
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:
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
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 eemrefuses it before the host is touched; the router refuses the enrollment too. Drop--join-key-fileand 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-nameif the name is the problem.conflict_pillstate: the registration name is claimed by another CURVE fingerprint. Runerectl 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.jsonbeside its keys in the config directory. Remove that file to enroll again from a fresh key. queue_write_failed: the/data/erebine-eem/statevolume 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 -fon 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.
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.
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.
# 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.
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.
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:
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:
environment:
# ... the keys already there ...
EREBINE_EEM_WORKSPACES_FILE: /var/lib/eem/.config/erebine/workspaces.yaml
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.