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:

  1. OpenShell gateway runs as a Podman Quadlet systemd service. It manages sandbox lifecycle, mTLS, JWT auth, provider credentials, and network policy.
  2. 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.
  3. 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.target

A few things worth calling out:

  • SecurityLabelDisable=true is needed because SELinux blocks the container from accessing the Podman socket, even with :Z relabeling. Since this is rootless Podman, running as User=0 inside the container is safe (it maps to your unprivileged UID on the host).
  • XDG_STATE_HOME is 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=openshell line puts the gateway on a named bridge network. Sandbox containers join the same network, so they reach the gateway at openshell-gateway:8080 by 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.13

The 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.yaml

No 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-src

Config 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 /sandbox

The 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-start

The 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-src

That’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-verify

This 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-start

This 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 hermes

Then 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/null must be in the filesystem policy’s read_write list. 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>, not openrouter: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 just gateway.discord.gg. Discord uses regional gateways like gateway-us-east1-c.discord.gg for 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.yaml and .env at build time and verifies them at startup. If you modify the config in a layered Containerfile, the hashes won’t match and nemoclaw-start will 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 set after 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 exec enters the container’s root namespace, not the sandbox’s network namespace. Commands like hermes pairing approve need to talk to the running gateway process, which is inside the restricted namespace. Use openshell ssh-proxy to 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.