Skip to main content

Running the Agent in a Container

By the end of this page you will have an agent served by OmniServe in a container that runs as a non-root user on a read-only filesystem, reads its model key from the environment, keeps everything it must remember on one volume, and reports itself healthy only when it can take work. Replace the container, and its runs, sessions, traces and scheduled tasks are still there.
Every output on this page is what the commands printed when they were run, with an omnicoreagent 0.5.0 release candidate installed from its wheel in the image (the commented lines in the Dockerfile below).
This is a deployment pattern, not a setting: nothing in OmniCoreAgent turns it on. It is also a different boundary from the sandbox, which contains the commands the agent runs while the harness (policy, memory, telemetry, your credentials) stays in your process. A container contains the whole process. The two combine; see Sandboxes from inside a container.

Build the image

An agent file, agent.py, with its memory in SQL so its runs survive:
Beside it, a Dockerfile:
  • postgres is the SQL extra: SQLAlchemy, used for SQLite too.
  • The image holds no secret. The model key arrives at run time.
  • The health check asks /ready, which is true only once the agent is initialized and its MCP servers are connected (/health only says the process is up; neither answers before startup has finished, which can take up to a minute on a busy machine, so raise --start-period on a slow host). The slim image has no curl, so it uses Python.

Run it

-e LLM_API_KEY with no =value passes the variable through from your shell, so the key is not in your shell history or on the docker run command line. It is still in the container’s environment: docker inspect shows it under Config.Env to anyone who can reach the Docker daemon. In production, inject it the same way from your platform’s secret store. LLM_API_KEY is the only model key variable the runtime reads.
The flags harden the container: a read-only root filesystem (only /tmp and the /data volume are writable), no Linux capabilities, no privilege escalation, memory and process limits. The agent needed nothing else. Ask it something as on the OmniServe page:

What is on the volume

After a run and a scheduled task:
Without the volume, all of it is lost with the container. Without OMNICOREAGENT_WORKSPACE_DIR the workspace is ./workspace, relative to the working directory (/app here, which is read-only).

Replace the container, keep the state

A task created before, then the container removed and a new one started on the same volume:
The new container’s answer, reduced to task_id, schedule.expression and schedule_state.next_due_at:
The run from before the restart was there too: GET /runs/{run_id} returned its record, and GET /runs/{run_id}/trajectory its trace (traces_missing 0). A run that was paused for an approval can be decided and resumed on the new container.

Scale beyond one container

SQLite on a volume is for one container. For several, each one a single OmniServe process, share a database server instead of the volume: MemoryRouter("sql") with a PostgreSQL DATABASE_URL, or "redis" with REDIS_URL, or "mongodb" with MONGODB_URI; and a task store to match. Each run belongs to one process at a time; a run whose process dies is picked up by another once its lease expires. Traces are written to each container’s workspace, so keep the workspace on shared storage (OMNICOREAGENT_WORKSPACE_BACKEND=s3 or r2) or export traces (Observability). Stores and scale covers this.

Sandboxes from inside a container

If the agent runs commands (the execute tool, code mode), give it a sandbox. From inside a container: A hosted provider (E2B, Modal, Daytona, Vercel, or your own http service) needs only its extra, its key in the environment, and outbound network. This is the simple choice. The Docker sandbox starts containers through a Docker engine, found as the Docker SDK finds one (DOCKER_HOST, else /var/run/docker.sock). A container has neither. With omnicoreagent[docker] in the image, the engine’s ping from inside it:
The non-root user needs the socket’s group as well. It works, but know what it means:
Whoever can use the Docker socket controls the host: it can start a privileged container that mounts /. Mounting it into the agent’s container gives the agent’s process that power (the sandboxes it starts never get the socket: the Docker sandbox refuses to mount an engine socket, system directories or credential folders into them). Prefer a hosted provider, or point DOCKER_HOST at a Docker engine dedicated to sandboxes (another VM or a Docker-in-Docker service) rather than the host’s own.
The Docker sandbox copies files into its containers rather than bind-mounting them, so it works against a remote engine. A workspace_mount, if you configure one, is a path on the engine’s host, not in the agent’s container.

Options

omniserve generate-dockerfile --file agent.py writes a starting Dockerfile (OmniServe); it installs only [serve], checks /health, and keeps the workspace in /tmp/workspace, so add a volume and the extras you use. A script instead of a server works the same way: CMD ["python", "my_job.py"], with the same variables and volume.

When things go wrong

The container was started without the key (or -e LLM_API_KEY with the variable unset in your shell). It exits:
Export LLM_API_KEY in the shell that runs docker run, or set it in your platform’s secrets.
The container is healthy, but the key is wrong:
Check which provider the key belongs to and model_config["provider"].
State was in memory or in the container’s filesystem. Mount a volume (or use a database server), set DATABASE_URL with MemoryRouter("sql"), a durable task store, and OMNICOREAGENT_WORKSPACE_DIR on the volume. If DATABASE_URL is unset, the agent does not start: MemoryRouter("sql") raises ValueError: MemoryRouter("sql") needs DATABASE_URL.
With --read-only, only /tmp and mounted volumes are writable. Point the workspace and SQLite files at the volume, as above.
FileNotFoundError(2, ...): no Docker engine is reachable from the container. PermissionError(13, ...): the socket is mounted, but the container’s user is not in its group (--group-add). Read the warning above before doing either; a hosted provider avoids both.

Next

OmniServe

Every route, the configuration, the errors.

Stores and scale

Choosing stores, and several containers sharing them.

Durable runs

What a run’s record holds, and recovery after a crash.

Background agents

Scheduled work, retries and leases.

Sandbox providers

Docker, E2B, Modal, Daytona, Vercel, your own.