463 lines
20 KiB
Markdown
463 lines
20 KiB
Markdown
# Omnigent on Modal
|
||
|
||
[Modal](https://modal.com) plays two distinct roles for Omnigent:
|
||
|
||
1. **[Server deploy target](#deploying-the-server)** — run the
|
||
Omnigent server itself on Modal as a single always-on web server
|
||
(`modal_app.py` in this directory).
|
||
2. **[Sandbox provider](#sandboxes-for-runner-hosts)** — disposable
|
||
cloud machines for running Omnigent *hosts*, so sessions execute in
|
||
the cloud instead of on your laptop.
|
||
|
||
The two are independent: you can deploy the server anywhere and still
|
||
use Modal sandboxes for hosts, or vice versa.
|
||
|
||
## Deploying the server
|
||
|
||
Run the Omnigent server on Modal as a single always-on web server.
|
||
`modal_app.py` pulls the standard server image and launches the same
|
||
Docker entrypoint every other platform uses; Modal provides the HTTPS
|
||
URL, log streaming, and a persistent Volume for the artifact store —
|
||
uploaded agent bundles survive restarts and redeploys here, unlike on
|
||
Heroku or Cloudflare.
|
||
|
||
### Prerequisites
|
||
|
||
- A Modal account and the CLI: `pip install modal && modal setup`.
|
||
No Docker needed locally — Modal's builders pull the image.
|
||
- A Postgres database. Modal has no managed Postgres — the fastest is
|
||
**Neon**: create one at [pg.new](https://pg.new) and copy the
|
||
connection string.
|
||
|
||
### Deploy
|
||
|
||
```bash
|
||
# 1. One secret bundle with the three required values. The app URL is
|
||
# deterministic: https://<workspace>--omnigent-server.modal.run
|
||
# (your workspace name is shown by `modal profile current`).
|
||
modal secret create omnigent-deploy \
|
||
DATABASE_URL='postgres://…neon.tech/…' \
|
||
OMNIGENT_ACCOUNTS_COOKIE_SECRET="$(openssl rand -hex 32)" \
|
||
OMNIGENT_ACCOUNTS_BASE_URL='https://<workspace>--omnigent-server.modal.run'
|
||
|
||
# 2. Ship it.
|
||
modal deploy deploy/modal/modal_app.py
|
||
```
|
||
|
||
`modal deploy` prints the live URL — if it differs from what you guessed
|
||
in step 1 (e.g. a non-default Modal environment adds a suffix), update
|
||
the secret and redeploy.
|
||
|
||
The first boot runs DB migrations over the network (~1 minute on Neon).
|
||
|
||
**Get the admin password:** the first boot prints it to the app log:
|
||
|
||
```bash
|
||
modal app logs omnigent
|
||
```
|
||
|
||
```
|
||
✓ Created initial admin account (accounts auth provider).
|
||
password: <generated>
|
||
```
|
||
|
||
Log in as the admin and invite teammates from **Members** in the web UI.
|
||
|
||
> To set a known admin password instead, add
|
||
> `OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD=<password>` to the
|
||
> `omnigent-deploy` secret before the first deploy.
|
||
|
||
### Modal-specific caveats
|
||
|
||
- **2 MiB WebSocket message cap.** Modal's ingress limits WebSocket
|
||
messages to 2 MiB each, well below the runner tunnel's own 100 MiB
|
||
allowance. Normal streaming traffic (events, terminal frames) is far
|
||
smaller, but a single very large tool payload over the tunnel can
|
||
fail on this platform.
|
||
- **Connections reset at the 24 h input timeout.** A proxied WebSocket
|
||
occupies one Modal function input, and inputs are capped at 24 hours
|
||
— so a tunnel lives at most a day before being cut. Runners
|
||
auto-reconnect (0.5–10 s jittered backoff).
|
||
- **One always-on container by design.** `min_containers=1` /
|
||
`max_containers=1` in `modal_app.py`: the runner registry is
|
||
in-memory, so traffic must land on a single container, and
|
||
scale-to-zero would kill live tunnels. Don't raise `max_containers`
|
||
expecting horizontal scaling.
|
||
- **No SQLite tier.** The artifact Volume is durable but is not a place
|
||
for a SQLite database (eventual-consistency semantics); use Postgres.
|
||
|
||
### Use your own IdP instead (OIDC)
|
||
|
||
Add the OIDC values to the `omnigent-deploy` secret (Modal secrets are
|
||
key-value bundles; `modal secret create` with the same name replaces it)
|
||
and redeploy:
|
||
|
||
```bash
|
||
modal secret create omnigent-deploy \
|
||
DATABASE_URL='…' \
|
||
OMNIGENT_AUTH_PROVIDER=oidc \
|
||
OMNIGENT_OIDC_ISSUER='https://github.com' \
|
||
OMNIGENT_OIDC_CLIENT_ID='…' \
|
||
OMNIGENT_OIDC_CLIENT_SECRET='…' \
|
||
OMNIGENT_OIDC_REDIRECT_URI='https://<workspace>--omnigent-server.modal.run/auth/callback' \
|
||
OMNIGENT_OIDC_COOKIE_SECRET="$(openssl rand -hex 32)"
|
||
```
|
||
|
||
The IdP registration steps (GitHub / Google / Okta callback URLs, domain
|
||
allow-listing) are identical to the other platforms — see
|
||
[`deploy/render/README.md`](../render/README.md#use-your-own-idp-instead-oidc).
|
||
|
||
### Custom domain
|
||
|
||
Pass `custom_domains=["omnigent.example.com"]` to `@modal.web_server`
|
||
in `modal_app.py` (requires a paid Modal plan), point your DNS at Modal
|
||
per the printed instructions, and update `OMNIGENT_ACCOUNTS_BASE_URL`
|
||
(or the OIDC redirect URI) to match.
|
||
|
||
### Upgrading
|
||
|
||
`modal deploy deploy/modal/modal_app.py` again — Modal re-resolves
|
||
`ghcr.io/omnigent-ai/omnigent-server:latest`, so a redeploy is an
|
||
upgrade. The rollout replaces the container; runners reconnect.
|
||
|
||
### Cost
|
||
|
||
Modal bills actual usage: memory at ~$0.008/GiB-hour and CPU by the
|
||
cycle (so an idle server's CPU line is small). An always-on 1 GiB
|
||
instance runs roughly **$6–8/month**, which fits inside the Starter
|
||
plan's **$30/month of free credits** — making this effectively free for
|
||
a lightly loaded server. Rates: [modal.com/pricing](https://modal.com/pricing).
|
||
|
||
## Sandboxes for runner hosts
|
||
|
||
Modal sandboxes give you disposable cloud machines for running
|
||
Omnigent hosts — no laptop tethered to a session, no VM to babysit.
|
||
There are two ways to use them:
|
||
|
||
1. **CLI-launched sandboxes** — you provision a sandbox from your
|
||
terminal and register it as a host with your server. Good for
|
||
development and for running your local checkout's code in the cloud.
|
||
2. **Server-managed sandboxes** — the server provisions a sandbox
|
||
automatically when a session is created with
|
||
`"host_type": "managed"`, and terminates it when the session is
|
||
deleted. Good for production deployments where users shouldn't have
|
||
to think about hosts at all.
|
||
|
||
Both boot from the official prebaked host image, so startup is seconds,
|
||
not minutes.
|
||
|
||
### Sandbox prerequisites
|
||
|
||
```bash
|
||
pip install 'omnigent[modal]' # installs the modal SDK extra
|
||
modal token new # one-time browser auth with Modal
|
||
```
|
||
|
||
`modal token new` writes `~/.modal.toml`. Anywhere Omnigent needs to
|
||
talk to Modal (your laptop for the CLI flow, the server for the managed
|
||
flow), Modal credentials must be available — either that file or the
|
||
`MODAL_TOKEN_ID` / `MODAL_TOKEN_SECRET` environment variables.
|
||
|
||
### The host image
|
||
|
||
Sandboxes boot from `ghcr.io/omnigent-ai/omnigent-host:latest`, an image
|
||
published by CI from the `host` target of
|
||
[`deploy/docker/Dockerfile`](../docker/Dockerfile) with Omnigent
|
||
and its dependencies preinstalled — including the coding-harness CLIs
|
||
(`claude`, `codex`, `pi`, `kiro-cli`), so agents on any harness run without an
|
||
in-sandbox install.
|
||
|
||
To use a different image (a fork, or extra tooling baked in), build the
|
||
same target and push it anywhere Modal can pull from:
|
||
|
||
```bash
|
||
docker build -f deploy/docker/Dockerfile --target host \
|
||
-t docker.io/<you>/omnigent-host:latest .
|
||
docker push docker.io/<you>/omnigent-host:latest
|
||
```
|
||
|
||
Then point Omnigent at it — `OMNIGENT_MODAL_HOST_IMAGE` for the CLI
|
||
flow, or `sandbox.modal.image` in the server config for the managed
|
||
flow (see below). For private registries, set
|
||
`OMNIGENT_MODAL_REGISTRY_SECRET` to the name of a
|
||
[Modal secret](https://modal.com/secrets) containing
|
||
`REGISTRY_USERNAME` / `REGISTRY_PASSWORD`.
|
||
|
||
> [!NOTE]
|
||
> Building on Apple Silicon? Pass `--platform linux/amd64` — Modal
|
||
> sandboxes run x86_64.
|
||
|
||
### CLI-launched sandboxes
|
||
|
||
Provision a sandbox and ship your local checkout into it:
|
||
|
||
```bash
|
||
omnigent sandbox create --provider modal
|
||
```
|
||
|
||
This pulls the host image, builds wheels from your local checkout, and
|
||
overlays them on top — so the sandbox runs *your* code, not whatever
|
||
the image was built from. Then register it as a host with your server:
|
||
|
||
```bash
|
||
omnigent sandbox connect --provider modal \
|
||
--sandbox-id <id-printed-by-create> \
|
||
--server https://your-host
|
||
```
|
||
|
||
`connect` runs `omnigent host` inside the sandbox and holds the
|
||
connection open in your terminal — Ctrl-C tears it down. New sessions
|
||
targeting that host now run in the sandbox.
|
||
|
||
Running multiple sandboxes against one server? Pass a unique
|
||
`--host-name <label>` to each `connect` — the server keys hosts on
|
||
(owner, name), and sandboxes that share a hostname collide.
|
||
|
||
Sandboxes are disposable. When your code changes, create a new one.
|
||
|
||
> [!NOTE]
|
||
> Modal caps sandbox lifetime at 24 hours (a platform hard limit).
|
||
> Re-run `create` + `connect` to roll the host onto a fresh sandbox.
|
||
|
||
For provider-side lifecycle (list / status / terminate), use Modal's
|
||
own tooling — the [Modal dashboard](https://modal.com/sandboxes) or the
|
||
`modal` CLI.
|
||
|
||
### Connecting to an authenticated server
|
||
|
||
`connect` runs `omnigent host` inside the sandbox, and that host must
|
||
present credentials when it dials back to a server that requires
|
||
authentication. The interactive `omnigent login` browser flow can't
|
||
run inside a sandbox, so inject the keys for the relevant server
|
||
instead: park them in a [Modal secret](https://modal.com/secrets) and
|
||
name it in `OMNIGENT_MODAL_SANDBOX_SECRETS` (comma-separated) before
|
||
running `create`:
|
||
|
||
```bash
|
||
modal secret create omnigent-server-auth \
|
||
DATABRICKS_HOST=https://example.databricks.com \
|
||
DATABRICKS_TOKEN=<your-pat>
|
||
export OMNIGENT_MODAL_SANDBOX_SECRETS=omnigent-server-auth
|
||
omnigent sandbox create --provider modal
|
||
```
|
||
|
||
The in-sandbox host mints a fresh bearer token from those credentials
|
||
on every connect and reconnect. For a server fronted by Databricks
|
||
authentication, inject `DATABRICKS_HOST` plus either
|
||
`DATABRICKS_TOKEN` (a PAT) or `DATABRICKS_CLIENT_ID` /
|
||
`DATABRICKS_CLIENT_SECRET` (an OAuth service principal — re-minting
|
||
keeps a long-lived sandbox connected past any single token's expiry).
|
||
|
||
A server with no authentication on the host tunnel needs none of this,
|
||
and neither do [server-managed sandboxes](#server-managed-sandboxes) —
|
||
those authenticate with a server-minted per-launch token automatically.
|
||
|
||
(The same env var also carries LLM / git credentials for CLI-launched
|
||
sandboxes — any secret named in `OMNIGENT_MODAL_SANDBOX_SECRETS` lands
|
||
in the sandbox environment, exactly like `sandbox.modal.secrets` does
|
||
for managed launches.)
|
||
|
||
### Server-managed sandboxes
|
||
|
||
With managed hosts, the server does all of the above per session.
|
||
Add a `sandbox:` section to the server config (`omnigent server -c
|
||
config.yaml`, or `<data_dir>/config.yaml`):
|
||
|
||
```yaml
|
||
sandbox:
|
||
provider: modal
|
||
server_url: https://your-host # public URL sandboxes dial back to
|
||
```
|
||
|
||
`server_url` must be reachable *from Modal's cloud* — a public HTTPS
|
||
URL, not `localhost`. The server itself needs Modal credentials in its
|
||
environment (`MODAL_TOKEN_ID` / `MODAL_TOKEN_SECRET`, or a mounted
|
||
`~/.modal.toml`).
|
||
|
||
Now create sessions with `host_type: "managed"`:
|
||
|
||
```bash
|
||
curl -X POST https://your-host/v1/sessions \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"agent_id": "agent_...", "host_type": "managed"}'
|
||
```
|
||
|
||
The create returns immediately; the server provisions a fresh sandbox
|
||
in the background, starts a host in it, and binds the session once the
|
||
host comes online (`host_id` / `workspace` appear on
|
||
`GET /v1/sessions/{id}` when it does). A message posted before then
|
||
waits for the launch to settle, so you can send the first prompt right
|
||
away. Deleting the session terminates the sandbox and removes the
|
||
host. Each sandbox authenticates back with a server-minted, per-launch
|
||
token — no user credentials ever enter the sandbox.
|
||
|
||
Optional `modal:` settings:
|
||
|
||
```yaml
|
||
sandbox:
|
||
provider: modal
|
||
server_url: https://your-host
|
||
modal:
|
||
image: docker.io/<you>/omnigent-host:latest # default: official image
|
||
secrets: [omnigent-llm] # Modal secrets to inject
|
||
```
|
||
|
||
### LLM credentials for managed sandboxes
|
||
|
||
A fresh sandbox has no API keys. Park your provider credentials in a
|
||
[Modal secret](https://modal.com/secrets) and list it under
|
||
`sandbox.modal.secrets` — its env vars are injected into every managed
|
||
sandbox, and the in-sandbox host forwards the standard harness
|
||
credential vars to its runners:
|
||
|
||
```bash
|
||
modal secret create omnigent-llm \
|
||
OMNIGENT_ANTHROPIC_API_KEY=sk-ant-… OPENAI_API_KEY=sk-…
|
||
```
|
||
|
||
The forwarded set covers the variables the harnesses themselves
|
||
resolve — and it reaches well beyond the first-party APIs. The
|
||
`*_BASE_URL` variables redirect each harness to *any* compatible
|
||
endpoint, so the same mechanism covers frontier providers, gateways
|
||
like [OpenRouter](https://openrouter.ai) and
|
||
[LiteLLM](https://docs.litellm.ai), and self-hosted open-source models:
|
||
|
||
| Variable | Enables |
|
||
|---|---|
|
||
| `OMNIGENT_ANTHROPIC_API_KEY` or `ANTHROPIC_API_KEY` | Claude models on the Anthropic API (claude-sdk, pi, claude-code harnesses). Prefer the `OMNIGENT_` form for Claude Code so the raw `ANTHROPIC_API_KEY` env var is not present in the CLI process. |
|
||
| `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_BASE_URL` | Anthropic-compatible gateways — point claude-code at a LiteLLM proxy, a Bedrock/Vertex bridge, or a corporate gateway |
|
||
| `CLAUDE_CODE_OAUTH_TOKEN` | claude-code with a Claude subscription (no API key) |
|
||
| `OPENAI_API_KEY` | OpenAI models on the OpenAI API (codex, openai-agents harnesses) |
|
||
| `OPENAI_BASE_URL` | Any OpenAI-compatible endpoint — the de-facto standard API of the open-model ecosystem. Gateways (OpenRouter, LiteLLM), hosted open-weights providers (Together, Fireworks, Groq), or self-hosted vLLM / Ollama — this is how Llama, Qwen, DeepSeek, and friends plug in |
|
||
| `CODEX_ACCESS_TOKEN` | codex with a ChatGPT Business/Enterprise workspace |
|
||
| `GEMINI_API_KEY` | Gemini models on the Google AI API |
|
||
|
||
Common setups:
|
||
|
||
- **Claude with an API key** — put `OMNIGENT_ANTHROPIC_API_KEY` in the secret.
|
||
Omnigent resolves it into Claude Code's `apiKeyHelper`; do not also set
|
||
`ANTHROPIC_API_KEY` unless you are okay with Claude Code detecting the raw
|
||
custom key env var.
|
||
- **Claude with a subscription** — run `claude setup-token` on your own
|
||
machine (one-time browser auth) and store the resulting long-lived
|
||
token as `CLAUDE_CODE_OAUTH_TOKEN`.
|
||
- **Codex with an API key** — put `OPENAI_API_KEY` in the secret.
|
||
- **Codex with a ChatGPT Business/Enterprise plan** — mint a
|
||
[Codex access token](https://developers.openai.com/codex/enterprise/access-tokens)
|
||
in the ChatGPT admin console (a workspace admin must grant the
|
||
permission) and store it as `CODEX_ACCESS_TOKEN`.
|
||
- **Codex with a ChatGPT Plus/Pro plan** — there is no headless token
|
||
for personal plans. Codex stores personal-plan auth in
|
||
`~/.codex/auth.json` with effectively single-use refresh tokens, so
|
||
copies of that file across machines invalidate each other — it can't
|
||
be injected into disposable sandboxes via a shared secret. Use an
|
||
API key or `codex login --device-auth` inside a long-lived sandbox
|
||
instead (device-code login must first be enabled in ChatGPT →
|
||
Settings → Security).
|
||
- **Gateways and open-source models** — set `OPENAI_BASE_URL` to the
|
||
endpoint plus its key as `OPENAI_API_KEY` (e.g.
|
||
`OPENAI_BASE_URL=https://openrouter.ai/api/v1` with an OpenRouter
|
||
key, or your own vLLM server's URL). Anthropic-side gateways work
|
||
the same way via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`.
|
||
|
||
For env vars beyond the standard set, add
|
||
`OMNIGENT_RUNNER_ENV_PASSTHROUGH=NAME1,NAME2` to the secret — the
|
||
host forwards the named extras to its runners.
|
||
|
||
To check what actually landed in a sandbox, exec into it with Modal's
|
||
CLI and inspect the environment:
|
||
|
||
```bash
|
||
modal shell <sandbox-id> # interactive shell in the sandbox
|
||
env | grep -E 'ANTHROPIC|OPENAI|GIT'
|
||
```
|
||
|
||
### Git credentials (private repositories)
|
||
|
||
Sandboxes clone repository workspaces anonymously by default, which
|
||
covers public repositories only. For private repositories — both the
|
||
clone the server runs at session create and the `git fetch` / `git
|
||
push` the agent runs later — put an HTTPS token in a Modal secret as
|
||
`GIT_TOKEN`:
|
||
|
||
```bash
|
||
modal secret create omnigent-git GIT_TOKEN=github_pat_…
|
||
```
|
||
|
||
and list the secret under `sandbox.modal.secrets` (multiple secrets
|
||
compose, so keeping git and LLM credentials in separate secrets is
|
||
fine):
|
||
|
||
```yaml
|
||
sandbox:
|
||
provider: modal
|
||
server_url: https://your-host
|
||
modal:
|
||
secrets: [omnigent-llm, omnigent-git]
|
||
```
|
||
|
||
The host image ships a git credential helper that answers HTTPS
|
||
authentication from `GIT_TOKEN`, so nothing is written to disk and no
|
||
URL ever embeds the token. Details by provider:
|
||
|
||
- **GitHub** — use a [fine-grained personal access
|
||
token](https://github.com/settings/personal-access-tokens) scoped to
|
||
the repositories the sandbox needs (Contents: read, or read/write if
|
||
the agent pushes). The default auth username (`x-access-token`)
|
||
is already correct.
|
||
- **GitLab** — create a project or personal access token with
|
||
`read_repository` / `write_repository` and add
|
||
`GIT_USERNAME=oauth2` to the secret.
|
||
- **Other HTTPS remotes** — any server accepting basic auth works;
|
||
set `GIT_USERNAME` if it requires a specific username.
|
||
|
||
Use HTTPS repository URLs (`https://github.com/org/repo`) for private
|
||
workspaces — SSH URLs (`git@github.com:…`) would need a key and
|
||
known-hosts setup inside the sandbox, which the managed flow does not
|
||
provide.
|
||
|
||
The token is forwarded host→runner (like the LLM credentials above),
|
||
so the agent's own git commands authenticate the same way the
|
||
launch-time clone did. If the agent should also create commits, bake
|
||
or configure `user.name` / `user.email` via your agent's instructions
|
||
or a custom image.
|
||
|
||
### Environment variable reference
|
||
|
||
| Variable | Where it's read | Purpose |
|
||
|---|---|---|
|
||
| `MODAL_TOKEN_ID` / `MODAL_TOKEN_SECRET` | CLI machine / server | Modal API credentials (alternative to `~/.modal.toml`) |
|
||
| `OMNIGENT_MODAL_HOST_IMAGE` | CLI machine / server | Override the host image ref (`sandbox.modal.image` takes precedence for managed) |
|
||
| `OMNIGENT_MODAL_REGISTRY_SECRET` | CLI machine / server | Modal secret name with `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` for private image pulls |
|
||
| `OMNIGENT_MODAL_SANDBOX_SECRETS` | CLI machine / server | Comma-separated Modal secret names to inject (`sandbox.modal.secrets` takes precedence for managed) |
|
||
| `OMNIGENT_RUNNER_ENV_PASSTHROUGH` | inside the sandbox (set via a Modal secret) | Extra env var names the host forwards to runners |
|
||
| `GIT_TOKEN` | inside the sandbox (set via a Modal secret) | HTTPS token for private repository clone / fetch / push |
|
||
| `GIT_USERNAME` | inside the sandbox (set via a Modal secret) | Auth username paired with `GIT_TOKEN` (default `x-access-token`; GitLab uses `oauth2`) |
|
||
|
||
All of the above are supported public configuration. The variables the
|
||
managed launcher itself sets inside the sandbox —
|
||
`OMNIGENT_HOST_TOKEN`, `OMNIGENT_HOST_ID`, `OMNIGENT_HOST_NAME` —
|
||
are internal plumbing (server-minted per launch) and are never set by
|
||
users.
|
||
|
||
### Limits and troubleshooting
|
||
|
||
- **24-hour lifetime.** Modal hard-caps sandbox lifetime at 24 hours.
|
||
CLI flow: re-run `create` + `connect`. Managed flow: nothing to do —
|
||
when the sandbox dies, the next message to the session provisions a
|
||
fresh one under the same host (the session binding survives; a
|
||
repository workspace is re-cloned). Uncommitted workspace changes
|
||
die with the sandbox, so push work you care about.
|
||
- **Resources.** Sandboxes are created with 2 CPUs and 4 GiB of
|
||
memory.
|
||
- **Managed launch hangs then fails.** The server waits up to two
|
||
minutes for the in-sandbox host to come online. If it times out,
|
||
check that `server_url` is publicly reachable from Modal, then
|
||
inspect the host log inside the sandbox: `/tmp/omnigent-host.log`.
|
||
- **Image pull failures.** Private image without
|
||
`OMNIGENT_MODAL_REGISTRY_SECRET` set, or a secret missing
|
||
`REGISTRY_USERNAME` / `REGISTRY_PASSWORD`.
|
||
- **Agent has no credentials.** Verify the Modal secret is listed in
|
||
`sandbox.modal.secrets` and its var names match the forwarded set
|
||
above (or are named in `OMNIGENT_RUNNER_ENV_PASSTHROUGH`).
|