Docs / Hotcell / Egress gateway

Egress and the LLM gateway

Keep provider keys on the host with per-sandbox gateway tokens, spend caps, model allowlists, rate limits and default-deny egress.

The egress gateway is a single chokepoint for outbound traffic from sandboxes. You give the daemon your provider keys; each sandbox gets a per-sandbox token instead. The sandbox points its SDK at the gateway, the gateway swaps the token for the real key, forwards the call and meters it. A leaked token can be revoked without touching the real key or any other sandbox.

The gateway listens on HOTCELL_EGRESS_PORT (default 4752; 0 disables it). It rewrites base URLs and injects keys. It is not a TLS-intercepting proxy, so you don't install a CA in any sandbox.

Add provider keys

hotcell keys add openrouter                      # hidden prompt
hotcell keys add github --value "$(gh auth token)"
echo "$KEY" | hotcell keys add openai --stdin
hotcell keys ls
hotcell keys rm openai

Keys are stored on the host: in the macOS keychain, otherwise in ~/.hotcell/keys.json with mode 600. Set HOTCELL_KEYSTORE=file to force the file backend. In CI you can set HOTCELL_PROVIDER_KEY_<NAME> instead (for example HOTCELL_PROVIDER_KEY_OPENROUTER). The daemon reloads keys after hotcell keys changes without a restart.

A provider's gateway route exists only while its key is present.

Wire a sandbox

hotcell run --egress --keep "printenv OPENROUTER_BASE_URL"
# http://host.docker.internal:4752/openrouter

With --egress (REST egress: true), the daemon mints a token and injects base-URL and key variables for every configured provider:

Provider Base URL variable Key variable
openai OPENAI_BASE_URL OPENAI_API_KEY
anthropic ANTHROPIC_BASE_URL ANTHROPIC_API_KEY
openrouter OPENROUTER_BASE_URL OPENROUTER_API_KEY
google GOOGLE_BASE_URL GOOGLE_API_KEY
gemini GEMINI_BASE_URL GEMINI_API_KEY
Custom <name> <NAME>_BASE_URL <NAME>_API_KEY

The key variable holds the token. On microVM drivers the base URL points at 127.0.0.1, because the guest reaches the gateway through an in-guest relay over vsock.

You can also mint tokens for an existing sandbox:

hotcell egress <id>                 # mint; prints export lines for each provider
hotcell egress <id> --list          # tokens, policies and spend
hotcell egress <id> --revoke <token>

Destroying a sandbox revokes all its tokens.

Import a .env

hotcell keys import .env      # review screen: choose for every variable, then confirm
hotcell keys review           # change those decisions later

You choose one of three dispositions for each variable:

Disposition What the sandbox gets
gateway <PROVIDER>_BASE_URL pointing at the gateway and <PROVIDER>_API_KEY set to a per-sandbox token. The real key stays on the host.
inject The real value, verbatim. For credentials the gateway can't stand in front of, and for plain config such as NODE_ENV.
skip Nothing.

Hotcell doesn't guess. It preselects gateway only for names it already routes (OPENAI_API_KEY, ANTHROPIC_API_KEY, OPENROUTER_API_KEY, GOOGLE_API_KEY, GEMINI_API_KEY, GITHUB_API_KEY), and you can change those. The confirm step lists every value that will enter a sandbox before anything is stored.

Choosing gateway for an unknown provider asks for its base URL and auth header once. That shape is saved to .hotcell/env.json, which holds no secret values and is meant to be committed, so a teammate's clone inherits every decision. inject variables are added to every sandbox the project creates; an explicit --env overrides them.

For scripts, state each decision explicitly. There is no implicit default:

hotcell keys import .env --set OPENAI_API_KEY=gateway --set DATABASE_URL=inject \
                         --set NODE_ENV=inject --set SENTRY_DSN=skip
hotcell keys import .env --default-unknown=skip

A gateway variable reaches the sandbox under its route's name. If you import GH_TOKEN as the github route, the sandbox sees GITHUB_API_KEY.

What the gateway can and can't protect

Protected: any API whose credential travels in an HTTP request header. OpenAI, Anthropic, OpenRouter, GitHub, Stripe, Slack, Cloudflare and most REST APIs work the same way.

Not protected. If you choose inject for these, the real value enters the sandbox:

  • Non-HTTP protocols such as Postgres, Redis and MongoDB. The credential is inside the wire handshake, so there is no header to substitute. The base-URL prompt rejects postgres://.
  • Request-signing schemes such as AWS SigV4. The key signs the request locally and is never transmitted.
  • mTLS and client certificates, and OAuth refresh-token exchanges.

Protected, but not drop-in: an SDK that hardcodes its host ignores the injected <PROVIDER>_BASE_URL and calls the provider directly. LLM SDKs read the variable; many other SDKs need one line of code to pass the host through. Under HOTCELL_EGRESS_ENFORCE those direct calls are denied.

Per-token policy

hotcell egress <id> --spend-cap 5 --models 'gpt-4o,claude-3-5*' \
    --rate-calls 60 --rate-window 1m --ttl 24h
Flag Policy field Effect when exceeded
--spend-cap <usd> spendCapUsd 402 for any call whose worst-case cost no longer fits
--models <csv> models 403 for a model not on the list (prefix globs allowed)
--providers <csv> providers 403 for other providers
--rate-calls <n> rateLimit.calls 429
--rate-tokens <n> rateLimit.tokens 429
--rate-window <dur> rateLimit.windowMs Sliding window; defaults to 1m when a rate cap is set
--ttl <dur> ttlMs / expiresAt 403 after expiry

Durations take forms such as 30m, 24h or 7d. Through the TypeScript SDK you can bind a policy at create time:

const sandbox = await client.getSandbox(undefined, {
  egress: { spendCapUsd: 5, models: ["gpt-4o"], ttlMs: 24 * 3600_000 },
  egressSpendCapUsd: 10,
});

Per-sandbox spend ceiling

A hard cap across all of a sandbox's tokens, so a token that is abused before you revoke it still can't exceed it:

hotcell run --egress --egress-spend-cap 2 "..."

HOTCELL_EGRESS_SPEND_CAP sets the daemon default (0 is unlimited). The per-create egressSpendCapUsd overrides it.

How spend caps are enforced

A cap is checked before a call is forwarded, against the call's worst-case cost, so neither one large call nor a burst of concurrent calls can run past it.

  • Every call reserves its worst case. Worst case is the request-body bytes counted as input tokens, plus the call's output limit, times the number of completions requested (n, best_of, candidateCount), at the model's price. The call is admitted only if spent plus in-flight reservations plus this reservation stays within each cap; otherwise 402. When the call finishes, the reservation is replaced by its actual cost.
  • The output limit is always bounded. An explicit output limit above 128,000 is clamped to 128,000. A missing one is set to HOTCELL_EGRESS_MAX_OUTPUT_TOKENS (default 8192). On routes the gateway doesn't recognise, nothing is rewritten and 128,000 output tokens are reserved. Embeddings reserve input only.
  • Unpriced models are refused (403) under a cap. Add them to HOTCELL_MODEL_PRICES. Without a cap they run, metered as $0 with a warning.
  • Usage is never silently free. OpenAI chat streams get stream_options.include_usage: true, so the client sees one extra final chunk with no choices. A successful response whose usage can't be read, or a stream that dies midway, is charged its full reservation.

Without a cap, requests are forwarded byte-for-byte and only metered. The --models check on a Gemini URL-path model applies with or without a cap.

Limits. The bound covers what is in the request. Content the provider pulls in by reference (an image URL, a Gemini fileData URI, an OpenAI previous_response_id, a stored prompt) is billed at its real usage after the call and isn't covered by the reservation. Async jobs (Batch, fine-tuning, Assistants runs) aren't metered. A price-table prefix match can underprice a more expensive variant (for example o3 matching o3-pro), and long-context price tiers aren't modelled. Reservations live in daemon memory. Combine caps with --models and --providers to cover these cases.

Cost

OpenRouter reports USD in its responses. For OpenAI, Anthropic and Google, the gateway computes cost from the reported token usage (including Anthropic cache tokens and Gemini usageMetadata) and a built-in price table. Override the table with a JSON file in HOTCELL_MODEL_PRICES. Per-sandbox LLM cost appears in hotcell stats, the metrics API and the dashboard.

Custom providers

Built-in providers are openai, anthropic, openrouter, google (also gemini) and github. Add others, such as a Cloudflare AI Gateway, Azure OpenAI or a self-hosted endpoint, with environment variables:

export HOTCELL_PROVIDER_CFOPENAI_BASEURL="https://gateway.ai.cloudflare.com/v1/<acct>/<gw>/openai"
export HOTCELL_PROVIDER_CFOPENAI_AUTHHEADER=authorization
export HOTCELL_PROVIDER_CFOPENAI_FORMAT="Bearer {key}"
export HOTCELL_PROVIDER_KEY_CFOPENAI=sk-...
# sandboxes reach it at http://<gateway>/cfopenai/...

{key} in _FORMAT is replaced with the real key. The same variables can repoint a built-in provider.

GitHub through the gateway

hotcell keys add github --value "$(gh auth token)"
  • API: call $GITHUB_BASE_URL/... with Authorization: Bearer $GITHUB_API_KEY. The gateway swaps in your token for api.github.com.
  • git clone, fetch and push: create a sandbox with --egress and a GitHub --repo while a github key is registered, and the daemon points the clone's origin through the gateway. git push then works with no GitHub credential in the sandbox. For any other remote, use http://x-access-token:$GITHUB_API_KEY@<gateway>/github-git/<owner>/<repo>.git.

The examples/agents.sh script shows this end to end, including opening a pull request without a token in the sandbox.

Default-deny egress

With HOTCELL_EGRESS_ENFORCE=true, a container sandbox on Linux can reach only the gateway and a DNS resolver. Everything else is dropped by host DOCKER-USER iptables rules on a dedicated bridge (HOTCELL_EGRESS_NETWORK, default hotcell-egress, subnet HOTCELL_EGRESS_SUBNET, default 10.200.0.0/24).

HOTCELL_EGRESS_ENFORCE=true hotcell start     # the daemon needs CAP_NET_ADMIN

Under enforcement, every sandbox gets a token and HTTP_PROXY / HTTPS_PROXY pointing at the gateway. The gateway forwards non-LLM traffic (pip, npm, git, apt) to an allowlist of hosts and denies the rest, logging every denial. Direct calls to LLM providers are denied too, so traffic can't skip key injection.

Allowlist

  • Matching is by domain or TLS SNI name, not IP. *.example.com matches the apex and any subdomain. A denylist wins over the allowlist, and blocks DNS-over-HTTPS endpoints by default.
  • The defaults include package registries (pypi, npm, crates, go, rubygems, maven, packagist, apt and apk) and a source-control tier (GitHub, GitLab, Bitbucket).
  • HOTCELL_ALLOWLIST_EXTRA adds hosts (comma-separated). HOTCELL_ALLOW_SOURCE_CONTROL=false removes the source-control tier, since a writable git host is also an exfiltration channel. HOTCELL_ALLOWLIST_FILE replaces the defaults with a JSON file of allow and deny lists.
  • HOTCELL_EGRESS_DNS pins a DNS resolver IP into sandboxes. The default is Docker's embedded resolver.

Enforcement by configuration

Configuration Enforcement Mechanism
Container with HOTCELL_EGRESS_ENFORCE (Linux) Kernel-enforced Host DOCKER-USER iptables, default-drop, on a dedicated bridge. Needs CAP_NET_ADMIN; degrades to advisory if the daemon can't install rules.
MicroVM, no network device (default) Enforced by construction No network device exists; the vsock gateway is the only route out.
Firecracker with networked: true Advisory Proxy env points tools at the gateway; a raw socket bypasses it, unlogged.
Container on macOS (Docker Desktop) Advisory The host firewall can't be installed because the bridge lives in a VM; proxy env is still set.

On a Mac, use the applevz driver for kernel-enforced egress. The gateway, policies, caps, cost tracking, providers and allowlist all work on macOS with any driver.

On Linux, blocked flows are logged before they are dropped and recorded as audit events. The rate is limited to 20 records per second. Reading kernel records uses /dev/kmsg, falling back to journalctl; without either, enforcement still works but those drop events aren't recorded.

Linux reachability

Sandboxes reach the gateway at host.docker.internal, which the daemon maps to the bridge gateway. The gateway binds to 127.0.0.1 by default, which the bridge can't reach on native Linux dockerd. Set HOTCELL_EGRESS_HOST=0.0.0.0 when you use --egress on Linux, and keep HOTCELL_EGRESS_ADVERTISE_HOST=host.docker.internal under enforcement. See Self-hosting.

REST

Method Path Purpose
POST /sandboxes/:id/egress-tokens Mint a token. Body is an optional policy. Returns { token, policy, providers }.
GET /sandboxes/:id/egress-tokens List tokens with policy, spendUsd and spendRemaining, plus providers.
DELETE /sandboxes/:id/egress-tokens/:token Revoke a token.

Full source documentation: docs/egress.md.