I’ve been running AI agents on my workstation for a while now. First it was Ollama under rootless Podman, then Hermes Agent with Vertex AI. Recently I wanted to run both OpenClaw and Hermes Agent as Discord bots, but I didn’t want to just run them as bare containers. If an AI agent is going to have network access and API credentials, I want something between it and the outside world.
NVIDIA OpenShell provides exactly that: sandboxed runtimes for AI agents with kernel-level isolation, policy-enforced network egress, and credential injection. The agent never sees your real API keys. The proxy intercepts every outbound connection and evaluates it against a YAML policy before anything leaves the box.
The catch is that OpenShell’s local deployment path (openshell gateway start) spins up a full k3s cluster inside a container, which doesn’t work on Fedora Atomic with rootless Podman (kubelet needs /dev/kmsg, which rootless containers can’t access). So I set up the gateway as a plain container on a Podman bridge network instead.
I haven’t seen anyone else running this exact combination, so I thought I would share how it all fits together.
The architecture
There are three pieces:
- OpenShell gateway runs as a Podman Quadlet systemd service. It manages sandbox lifecycle, mTLS, JWT auth, provider credentials, and network policy.
- Agent sandboxes are created by the gateway via the Podman driver. Each runs inside an OpenShell supervisor with a network namespace, transparent proxy, Landlock filesystem restrictions, and credential injection.
- NemoClaw images are the container images. They’re built from NVIDIA’s NemoClaw repo (which bundles agents with security hardening and proxy preload scripts), then layered with Discord config.
The gateway and sandboxes sit on the same Podman bridge network (openshell) so they can communicate directly. The gateway is published on 0.0.0.0:8080 so I can reach it from other machines on my Tailscale network.
Setting up the gateway
The gateway runs as a containerized service via a Podman Quadlet unit file:
[Unit]
Description=OpenShell Gateway
After=network-online.target
Wants=network-online.target
[Container]
Image=ghcr.io/nvidia/openshell/gateway:latest
ContainerName=openshell-gateway
PublishPort=0.0.0.0:8080:8080
Network=openshell
Volume=openshell-state:/var/openshell:Z
Volume=%t/podman/podman.sock:/var/run/podman.sock
Volume=%h/.config/openshell/gateway.toml:/etc/openshell/gateway.toml:ro
Volume=%h/.local/state/openshell/tls:/etc/openshell-tls:ro
Volume=%h/.local/state/openshell/tls/jwt:/etc/openshell-jwt:ro
Volume=%h/.local/state:%h/.local/state:Z
Environment=OPENSHELL_DRIVERS=podman
Environment=OPENSHELL_PODMAN_SOCKET=/var/run/podman.sock
Environment=OPENSHELL_DB_URL=sqlite:/var/openshell/openshell.db
Environment=OPENSHELL_GATEWAY_CONFIG=/etc/openshell/gateway.toml
Environment=XDG_STATE_HOME=%h/.local/state
User=0
SecurityLabelDisable=true
[Service]
Restart=on-failure
RestartSec=5
TimeoutStartSec=60
[Install]
WantedBy=default.targetA few things worth calling out:
SecurityLabelDisable=trueis needed because SELinux blocks the container from accessing the Podman socket, even with:Zrelabeling. Since this is rootless Podman, running asUser=0inside the container is safe (it maps to your unprivileged UID on the host).XDG_STATE_HOMEis set to the host path so the gateway writes sandbox JWT tokens where the host Podman daemon can find them. Without this, the gateway writes tokens inside its own container filesystem, and Podman can’t bind-mount them into sandbox containers.- The
Network=openshellline puts the gateway on a named bridge network. Sandbox containers join the same network, so they reach the gateway atopenshell-gateway:8080by container name.
TLS and authentication
The gateway needs mTLS certificates and JWT signing keys. OpenShell ships a generate-certs command for this:
mkdir -p ~/.local/state/openshell/tls
podman run --rm --user 0 \
-v "$HOME/.local/state/openshell:/output:Z" \
-v "$HOME/.config/openshell:/config:Z" \
--security-opt label=disable \
ghcr.io/nvidia/openshell/gateway:latest \
generate-certs \
--output-dir /output/tls \
--server-san openshell-gateway \
--server-san $(hostname)The --server-san flags add Subject Alternative Names to the server certificate. I added my Tailscale hostname so I can reach the gateway from other machines. The client certs go in ~/.config/openshell/gateways/ for the CLI to pick up automatically.
The gateway TOML config references these certs and tells the Podman driver where to find the client TLS material on the host filesystem (not inside the gateway container):
[openshell]
version = 1
[openshell.gateway]
bind_address = "0.0.0.0:8080"
[openshell.gateway.tls]
cert_path = "/etc/openshell-tls/server/tls.crt"
key_path = "/etc/openshell-tls/server/tls.key"
client_ca_path = "/etc/openshell-tls/ca.crt"
[openshell.gateway.gateway_jwt]
signing_key_path = "/etc/openshell-jwt/signing.pem"
public_key_path = "/etc/openshell-jwt/public.pem"
kid_path = "/etc/openshell-jwt/kid"
gateway_id = "openshell"
ttl_secs = 3600
[openshell.drivers.podman]
grpc_endpoint = "https://openshell-gateway:8080"
guest_tls_ca = "/home/shanemcd/.local/state/openshell/tls/ca.crt"
guest_tls_cert = "/home/shanemcd/.local/state/openshell/tls/client/tls.crt"
guest_tls_key = "/home/shanemcd/.local/state/openshell/tls/client/tls.key"The guest_tls_* paths are host filesystem paths because the Podman driver bind-mounts them into sandbox containers. If you put container-internal paths here, the host Podman daemon won’t find the files.
Creating providers
OpenShell providers hold credentials and inject them into sandboxes as opaque placeholders. The agent process never sees the real values. The proxy resolves placeholders at egress time.
openshell provider create --name openrouter --type openai \
--credential "OPENAI_API_KEY=$(secret-tool lookup service openshell key openrouter-api-key)" \
--credential "OPENROUTER_API_KEY=$(secret-tool lookup service openshell key openrouter-api-key)" \
--config "OPENAI_BASE_URL=https://openrouter.ai/api/v1"
openshell provider create --name discord --type generic \
--credential "DISCORD_BOT_TOKEN=$(secret-tool lookup service openshell key discord-bot-token)"The OpenRouter provider needs both OPENAI_API_KEY and OPENROUTER_API_KEY because different agents read different env vars. OpenClaw uses OPENROUTER_API_KEY. Hermes uses the OpenAI SDK which reads OPENAI_API_KEY. Having both means the same provider works for either agent.
The network policy
This is where OpenShell earns its keep. Every outbound connection from the sandbox goes through the transparent proxy and is evaluated against the policy. If there’s no matching rule, the connection is denied with a 403.
version: 1
filesystem_policy:
read_only: [/usr, /lib, /etc]
read_write: [/sandbox, /tmp, /dev/null, /dev/urandom]
landlock:
compatibility: best_effort
process:
run_as_user: sandbox
run_as_group: sandbox
network_policies:
discord_gateway:
name: discord-gateway
endpoints:
- host: "*.discord.gg"
port: 443
protocol: websocket
enforcement: enforce
access: full
websocket_credential_rewrite: true
binaries:
- path: /usr/local/bin/node
- path: /usr/bin/python3.13
discord_api:
name: discord-api
endpoints:
- host: discord.com
port: 443
protocol: rest
enforcement: enforce
access: full
- host: discordapp.com
port: 443
protocol: rest
enforcement: enforce
access: full
binaries:
- path: /usr/local/bin/node
- path: /usr/bin/python3.13
openrouter:
name: openrouter
endpoints:
- host: openrouter.ai
port: 443
protocol: rest
enforcement: enforce
access: full
binaries:
- path: /usr/local/bin/node
- path: /usr/bin/python3.13The Discord WebSocket endpoint needs websocket_credential_rewrite: true so the proxy can resolve the bot token placeholder in the WebSocket IDENTIFY payload. Without this, Discord rejects the connection with error code 4004 (authentication failed) because it receives the placeholder string instead of the real token.
I’ve included both node and python3.13 in the binary allowlists so the same policy works for OpenClaw (Node.js) and Hermes (Python). You could split these into separate policy files per agent if you want tighter binary restrictions.
The policy is hot-reloadable. If you need to add a new endpoint, you update the YAML and run:
openshell policy set <sandbox-name> --policy policy.yamlNo sandbox restart needed. The proxy picks up the new rules within seconds.
OpenClaw
OpenClaw is a Node.js agent. The image is a two-layer build: the NemoClaw base from source, then a config layer with the Discord plugin.
Building the NemoClaw base
git clone --depth 1 https://github.com/NVIDIA/NemoClaw.git /tmp/nemoclaw-src
podman build -t nemoclaw-discord:latest \
--build-arg NEMOCLAW_MODEL=openrouter/anthropic/claude-sonnet-5 \
--build-arg NEMOCLAW_PROVIDER_KEY=openrouter \
--build-arg NEMOCLAW_UPSTREAM_PROVIDER=openrouter \
--build-arg NEMOCLAW_PRIMARY_MODEL_REF=openrouter/anthropic/claude-sonnet-5 \
--build-arg NEMOCLAW_INFERENCE_BASE_URL=https://openrouter.ai/api/v1 \
--build-arg NEMOCLAW_INFERENCE_API=openai-completions \
-f /tmp/nemoclaw-src/Dockerfile \
/tmp/nemoclaw-srcConfig layer
FROM localhost/nemoclaw-discord:latest
USER root
ENV HOME=/sandbox
# Disable managed proxy during build (points at sandbox proxy that doesn't exist yet)
RUN node -e ' \
const fs = require("fs"); \
const p = "/sandbox/.openclaw/openclaw.json"; \
const c = JSON.parse(fs.readFileSync(p, "utf8")); \
c.proxy.enabled = false; \
fs.writeFileSync(p, JSON.stringify(c, null, 2)); \
' && \
NPM_CONFIG_OFFLINE=false openclaw plugins install npm:@openclaw/discord@2026.6.10 && \
node -e ' \
const fs = require("fs"); \
const p = "/sandbox/.openclaw/openclaw.json"; \
const c = JSON.parse(fs.readFileSync(p, "utf8")); \
c.proxy.enabled = true; \
fs.writeFileSync(p, JSON.stringify(c, null, 2)); \
'
RUN openclaw doctor --fix 2>/dev/null; \
openclaw config set gateway.mode local && \
openclaw models set openrouter/anthropic/claude-sonnet-5 && \
openclaw config set agents.defaults.memorySearch.enabled false
# Pre-configure Discord channel and owner
RUN node -e ' \
const fs = require("fs"); \
const p = "/sandbox/.openclaw/openclaw.json"; \
const c = JSON.parse(fs.readFileSync(p, "utf8")); \
c.channels = c.channels || {}; \
c.channels.discord = { \
enabled: true, \
accounts: { \
default: { \
enabled: true, \
dmPolicy: "allowlist", \
allowFrom: ["YOUR_DISCORD_USER_ID"], \
healthMonitor: { enabled: false } \
} \
} \
}; \
c.commands = c.commands || {}; \
c.commands.ownerAllowFrom = ["discord:YOUR_DISCORD_USER_ID"]; \
c.plugins = c.plugins || {}; \
c.plugins.entries = c.plugins.entries || {}; \
c.plugins.entries.discord = { enabled: true }; \
c.skills = c.skills || {}; \
c.skills.entries = c.skills.entries || {}; \
c.skills.entries.discord = { enabled: true }; \
fs.writeFileSync(p, JSON.stringify(c, null, 2)); \
'
RUN chown -R sandbox:sandbox /sandbox/.openclaw
USER sandbox
WORKDIR /sandboxThe proxy toggle during the plugin install is necessary because the NemoClaw base image bakes in a managed proxy config pointing at 10.200.0.1:3128 (the OpenShell sandbox proxy). That proxy doesn’t exist at build time, so npm would hang trying to route through it.
The owner and allowed users are baked into the image because once the sandbox is running, Landlock filesystem restrictions prevent the OpenClaw CLI from writing to its SQLite database.
Creating the sandbox
openshell sandbox create \
--name clankr \
--from localhost/nemoclaw-discord-configured:latest \
--provider openrouter \
--provider discord \
--policy policy.yaml \
--no-tty \
-- /usr/local/bin/nemoclaw-startThe nemoclaw-start entrypoint is important for OpenClaw. It handles privilege separation, gateway auth token generation, and most critically, it loads a Node.js preload script (http-proxy-fix.js) that patches https.request() to properly route WebSocket CONNECT tunnels through the sandbox proxy. Without this preload, Node.js tries to resolve DNS directly (which is blocked by nftables in the sandbox network namespace) and you get EAI_AGAIN errors on every connection.
Hermes Agent
Hermes is a Python agent, and the setup turned out to be cleaner than OpenClaw. The key insight: use the unmodified NemoClaw Hermes base image and let OpenShell’s inference routing and provider injection handle everything. No custom Containerfile needed.
Building the image
Just build the NemoClaw Hermes Dockerfile from source:
podman build -t nemoclaw-hermes:latest \
-f /tmp/nemoclaw-src/agents/hermes/Dockerfile \
/tmp/nemoclaw-srcThat’s it. No config layer. The base image ships with model.base_url: https://inference.local/v1 and api_key: sk-OPENSHELL-PROXY-REWRITE, which is exactly what the OpenShell proxy expects. The proxy intercepts requests to inference.local, routes them to the configured provider (OpenRouter), and resolves the credential placeholder to the real API key at egress time.
I spent hours trying to write a custom Containerfile that modified the Hermes config. Every approach broke NemoClaw’s config integrity checks, which hash the config files at build time and verify them at startup. The security model is designed so that nemoclaw-start is the only thing that can modify config at runtime. Once I stopped fighting it and used the image as-is, everything just worked.
Setting up inference routing
Tell the OpenShell gateway to route inference.local to OpenRouter:
openshell inference set \
--provider openrouter \
--model anthropic/claude-sonnet-5 \
--no-verifyThis is a gateway-level setting, not per-sandbox. The sandbox’s supervisor fetches the route bundle at startup and configures the local proxy to forward inference.local requests to OpenRouter with the right credentials.
Creating providers
Hermes needs a few more providers than OpenClaw because the NemoClaw entrypoint validates env vars:
# Inference credentials (both env var names needed)
openshell provider create --name openrouter --type openai \
--credential "OPENAI_API_KEY=$(secret-tool lookup service openshell key openrouter-api-key)" \
--credential "OPENROUTER_API_KEY=$(secret-tool lookup service openshell key openrouter-api-key)" \
--config "OPENAI_BASE_URL=https://openrouter.ai/api/v1"
# Discord bot token
openshell provider create --name discord --type generic \
--credential "DISCORD_BOT_TOKEN=$(secret-tool lookup service openshell key discord-bot-token)"
# Dashboard auth key (NemoClaw refuses to start without one)
openshell provider create --name hermes-dashboard --type generic \
--credential "API_SERVER_KEY=hermes-local-dashboard"
# Discord user allowlist (passed as env var, not baked into the image)
openshell provider create --name hermes-discord-config --type generic \
--credential "DISCORD_ALLOWED_USERS=YOUR_DISCORD_USER_ID" \
--credential "GATEWAY_ALLOW_ALL_USERS=false"Creating the sandbox
openshell sandbox create \
--name hermes \
--from localhost/nemoclaw-hermes:latest \
--provider openrouter \
--provider discord \
--provider hermes-dashboard \
--provider hermes-discord-config \
--policy policy.yaml \
--no-tty \
-- /usr/local/bin/nemoclaw-startThis runs the full NemoClaw entrypoint: privilege separation, config integrity verification, gateway auth token minting, the Hermes gateway, the dashboard with socat forwarders, and the Discord bridge. No shortcuts, no hacks.
Pairing on Discord
DM the bot on Discord. It will respond with a pairing code. Approve it by SSH-ing into the sandbox:
ssh -o ProxyCommand="openshell ssh-proxy --gateway-name tot --name hermes" \
-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \
sandbox@openshell-hermes \
"hermes pairing approve discord <CODE>"Regular podman exec won’t work because it enters the container’s root namespace, not the sandbox’s network namespace where the Hermes gateway is running. The OpenShell SSH relay goes through the supervisor, which puts you in the right namespace.
Dashboard
Forward the dashboard port:
openshell forward start -d 18789 hermesThen open http://127.0.0.1:18789/ in your browser.
What it looks like running
After about 30 seconds, the bot comes online on Discord. You can verify from the proxy logs:
openshell logs <sandbox-name>NET:OPEN [INFO] ALLOWED /usr/local/bin/node(428) -> discord.com:443 [policy:discord_api engine:opa]
HTTP:GET [INFO] ALLOWED GET http://discord.com:443/api/v10/users/@me [policy:discord_api engine:l7]
NET:UPGRADE [INFO] gateway-us-east1-c.discord.gg:443
NET:OTHER [INFO] ALLOWED gateway-us-east1-c.discord.gg:443 [policy:discord_gateway engine:l7-websocket]
HTTP:POST [INFO] ALLOWED POST http://openrouter.ai:443/api/v1/chat/completions [policy:openrouter engine:l7]
HTTP:POST [INFO] ALLOWED POST http://discord.com:443/api/v10/channels/.../messages [policy:discord_api engine:l7]
Every connection is logged with the policy rule that allowed it, the engine that evaluated it (OPA for L4, l7 for HTTP, l7-websocket for WebSocket), and the binary that initiated it. Anything not in the policy gets a 403 and an OCSF log entry.
Gotchas
A few things I ran into that aren’t obvious:
- The containerized gateway can’t bind-mount its own internal files into sibling containers. The Podman driver calls the host Podman daemon to create sandbox containers, so any paths the driver references (client TLS certs, JWT tokens) must exist on the host filesystem, not inside the gateway container.
/dev/nullmust be in the filesystem policy’sread_writelist. The NemoClaw startup script redirects to/dev/null, and Landlock blocks it by default. Without this, every shell initialization line fails with “Permission denied.”- OpenRouter models in OpenClaw use the format
openrouter/<author>/<slug>, notopenrouter:author/slug. The colon-separated format causes OpenClaw to treat the model name as a filesystem path and crash with a “Bundled plugin dirName must be a single directory” error. - After regenerating TLS certs, you must recreate sandboxes. Running sandboxes have the old certs mounted and lose connectivity to the gateway. The supervisor can’t fetch policy updates, so hot-reload stops working.
- Wildcard the Discord gateway hostname. The policy needs
*.discord.gg, not justgateway.discord.gg. Discord uses regional gateways likegateway-us-east1-c.discord.ggfor reconnections, and the bot goes offline when the proxy denies the regional hostname. - Don’t customize the Hermes config at build time. NemoClaw hashes
config.yamland.envat build time and verifies them at startup. If you modify the config in a layered Containerfile, the hashes won’t match andnemoclaw-startwill refuse to run. Use the unmodified base image and configure everything through OpenShell providers, inference routing, and env var injection. - Set inference routing before creating the sandbox. The sandbox fetches the inference route bundle once at startup. If you set
openshell inference setafter the sandbox is already running, it won’t pick up the route. Recreate the sandbox after changing inference config. - Use the SSH relay for in-sandbox CLI commands, not
podman exec.podman execenters the container’s root namespace, not the sandbox’s network namespace. Commands likehermes pairing approveneed to talk to the running gateway process, which is inside the restricted namespace. Useopenshell ssh-proxyto get into the right namespace. - One Discord bot token, one consumer. If you run both agents with the same bot token, only the last one to connect will receive messages. Use a separate bot token per agent if you want both online simultaneously.
Source
Everything is in my clankr repo under agents/openclaw/ and agents/hermes/. The gateway quadlet and config live in my dotfiles.