Run a Weftra worker on Hermes
This guide takes an Agent Factory worker you designed and compiled in Weftra and runs one of its episodes on Hermes Agent, the open agent harness from Nous Research.
Known limit — read before you run this on anything that matters. Weftra governs everything Hermes does to Weftra: every read and write goes through the worker gateway and is checked and recorded. It does not restrict what Hermes does inside its own container with its own native tools (
terminal, file, code). The exported tool restriction and the egress allowlist both narrow what the container can reach from the outside; neither one inspects or disables Hermes's own tools. Treat the container as untrusted and run it on a host that carries nothing else. See What Hermes can and cannot do here. Not yet confirmed on a live run — see Status.
What you get
When you finish, the worker's definition still lives in Weftra (its steps, its authority, its prohibitions, its autonomy level), but a different harness does the work: an unmodified Hermes container, running on a model credential that belongs to Hermes, not to Weftra.
Weftra stays the system of record. Hermes reaches Weftra only through the worker gateway, so every tool call it makes is metered, checked against the worker's authority and written to the ledger, and so is every refusal. Weftra decides whether the episode succeeded from what the gateway saw, not from what Hermes says it did.
In short: Weftra designs and governs the worker, Hermes runs it, and Weftra observes and records everything that comes back through the gateway.
Before you start
You need:
A running Weftra instance. See the Quickstart. The Hermes container must be able to reach Weftra's container-facing listener (see Prepare Weftra for a container runtime).
Docker on the same host, plus Node.js 18 or later. The harness scripts use only built-in Node modules (
fetch/fs/child_process) — nocurl, nopython3, nothing tonpm install.A compiled Agent Factory worker in shadow mode. Follow the User Guide sections "Agent Factory — declaring and compiling a digital worker" and "Running a worker in shadow mode" in the User Guide. For a ready-made definition, use
examples/agent-factory/fixtures/agent-solution.json(a stalled-lead watcher with anobservestep calledscanand arecommendstep calledflag, at"autonomy": "shadow"), or runexamples/agent-factory/run-governance-pilot.mjs, which declares and compiles it for you. Note the source project id, the agent_solution lineage id, the deployment id, and the id of the step you want to run.Your own named admin or approver key. Exporting a package and minting an enrollment token need a named person who is admin or approver on the source project. These keys are refused with
403:- the shared
ADMIN_API_KEY(it is a role, not a person, and the export is recorded against a person), - any project key, including the worker project's own key (a worker cannot approve its own package).
Compiling a worker is stricter still: it needs a named admin.
- the shared
A model credential for Hermes. This is Hermes's own key and never Weftra's. The harness expects a file holding only the key line, for example
ANTHROPIC_API_KEY=..., at mode 0600.run-hermes.mjsreads~/.hermes-fa/.envby default, or the path inHERMES_CREDENTIAL_ENV. It defaults to the Anthropic provider (HERMES_PROVIDER, defaultanthropic), with the model taken fromHERMES_MODEL(defaultclaude-sonnet-5).bashmkdir -m 700 -p ~/.hermes-fa umask 077 printf 'ANTHROPIC_API_KEY=%s\n' 'sk-ant-...' > ~/.hermes-fa/.env chmod 600 ~/.hermes-fa/.envWeftra never reads this file.
run-hermes.mjscopies it (still 0600) into a throwaway Hermes home for the one episode and deletes the home'sconfig.yaml(the only file in it that ever carries the episode token) when the run ends, success or failure.
Who does what:
| Step | Who |
|---|---|
| Declare the worker | project key, or admin/approver |
| Compile it | named admin |
| Export a package, mint an enrollment token, read episodes and the evaluation, pause | named admin or approver on the source project |
| Redeem, call the gateway, report completion | the runtime, holding only the token it was given |
The runtime is not a Weftra user. It holds no session, no fa_ key and no admin key. The token in its Authorization: Bearer header is its only credential.
All the commands below use these variables:
export FA_BASE_URL=http://127.0.0.1:3100
export FA_ADMIN_KEY=fa_... # YOUR named admin/approver key, never ADMIN_API_KEY
export FA_PROJECT_ID=<source project id>
export FA_LINEAGE_ID=<agent_solution lineage id>
export FA_DEPLOYMENT_ID=<deployment id>
DEP="$FA_BASE_URL/api/projects/$FA_PROJECT_ID/product/agents/$FA_LINEAGE_ID/deployments/$FA_DEPLOYMENT_ID"Export the worker as a Hermes package
A package is an immutable, hashed tarball, and it never contains a credential. Before writing a package, Weftra scans every file for token shapes, Bearer values and every secret configured on both the source and worker projects. If anything matches, the export is refused.
From the dashboard: open the Workers tab, choose the source project and open the deployment's card. In its Packages section, set the shape picker to hermes (an optional preamble box then appears) and click Export package. The new row shows the shape, a short hash, who exported it (by kind) and when, along with a Download link.
From the API:
curl -s -X POST "$DEP/packages" \
-H "Authorization: Bearer $FA_ADMIN_KEY" -H 'Content-Type: application/json' \
-d '{"shape":"hermes","profile":{}}'
# -> 201 { "id": "<package id>", "shape": "hermes", "manifest_hash": "...", "package_hash": "...", ... }profile takes one optional field, system_preamble: up to 4 KB of your own text, added after Weftra's fixed rules. It cannot replace those rules, add a URL or add a tool. Any other field is rejected with 400.
Exporting the same deployment, shape and profile again returns 200 and the same row. You cannot export from a retired (promoted or demoted) deployment: that returns 409, so export from its successor instead.
The hermes package holds the generic tree (manifest.json, constitution.md, seeds, evaluation/) plus:
| File | What it is |
|---|---|
SYSTEM.md | The fixed rules, the workflow steps (id, action class, description, escalate-when), the declared prohibitions with their reasons, and any declared external tools |
config.yaml | A fragment to merge into Hermes's own config.yaml: one mcp_servers.fa-gateway entry that runs fa-mcp-shim.py, plus platform_toolsets.cli: ["fa-gateway"] — the restriction; its key and name have not been checked against Hermes source or on a live run yet |
toolset.json | Tool allowlist, denies, argument denies, capability-wide denies, external tools |
memory-policy.json | Seeds are read-only; the runtime owns a scoped state store |
schedule/*.json | One stub per declared trigger, always "enabled": false |
In config.yaml, ${FA_GATEWAY_URL} and ${FA_WORKER_EPISODE_TOKEN} are literal placeholders. The launcher substitutes them at run time, and Weftra never does. The model, the provider and any credential are deliberately left out.
Mint an enrollment token
An enrollment token is single-use and bound to one deployment. It can do exactly one thing: redeem itself for an episode.
From the dashboard: each package row has a Mint enrollment token control with a TTL input (placeholder "TTL seconds (default 24h)"). Before you click it, the row shows the level and tool set the token will carry. After you click, the token appears once in a code block with a Copy button and the line "Shown once; expires …; redeems into a level-N token." It is kept only in the page's memory, so reloading or switching projects loses it. If that happens, mint another.
From the API:
curl -s -X POST "$DEP/packages/<package id>/enroll" \
-H "Authorization: Bearer $FA_ADMIN_KEY" -H 'Content-Type: application/json' \
-d '{"ttl_seconds": 3600}'
# -> 201 { "package_id": "...", "enrollment_token": "fa_wen_...", "jti": "...", "expires_at": "..." }ttl_seconds defaults to 24 hours and is capped at 7 days (FA_PACKAGE_ENROLL_TTL_S). The ledger records only the jti, the expiry and who minted it (worker_package_enrollment_minted), never the token itself.
If you use the harness below, you can skip both steps above: deploy.mjs exports and mints for you.
Prepare Weftra for a container runtime
The Hermes container has to reach the worker gateway. On native Linux Docker, the supported way is Weftra's sandbox-facing listener. Set FA_CONTAINER_BIND to the docker0 gateway address and restart Weftra:
FA_CONTAINER_BIND=172.17.0.1 # in Weftra's environment; restart afterwardsThis listener serves only /api/internal/* and /health. The admin API, the project routes and the dashboard do not exist on it. Leave Weftra's main listener on its default loopback bind (FA_BIND unset, which means 127.0.0.1). run-hermes.mjs calls the redeem and complete routes from the host, because those routes live on the main listener, and the container talks only to the gateway.
Start the Hermes runtime
The harness lives in examples/agent-factory/hermes/. One supported, tested start path:
| File | Role |
|---|---|
deploy.mjs | Exports the package, unpacks it, mints the token, writes the one secret to a 0600 file, starts the runtime, then watches Weftra's ledger |
run-hermes.mjs | The real-run start command (default). Redeems the token, builds a throwaway Hermes home under the exported restriction, runs the official image once, then reports completion. Dependency-free Node, no curl/python3 |
hermes-home.mjs | composeHermesConfig — the pure function run-hermes.mjs uses to merge the package's own config.yaml fragment with the redeemed episode's values, changing nothing else |
fa-mcp-shim.py | A stdio MCP server, stdlib only, that forwards tools/list and tools/call to the gateway with the episode token |
stub-runtime.mjs | The same contract with no Hermes and no model. This is what CI runs instead of run-hermes.mjs |
docker-compose.yml | Starts Weftra only. There is no hermes compose service — run-hermes.mjs runs the official image directly, once per episode |
Rehearse with the stub first
The stub does the same four things a real runtime does (read the package, redeem, write evidence through the gateway, report) and needs no model key:
cd examples/agent-factory/hermes
FA_EPISODE_STEP_ID=scan \
FA_HERMES_START_CMD="node $(pwd)/stub-runtime.mjs" \
node deploy.mjs
# -> DEPLOY_RESULT {"ok":true,"package_id":"...","deployment_id":"...","evaluation":{...}}If this fails, nothing Hermes-specific is wrong yet. Fix it here first.
Run the real thing
cd examples/agent-factory/hermes
export FA_EPISODE_STEP_ID=scan # a step id from YOUR deployment's workflow
docker compose up -d fa # starts Weftra only
node deploy.mjs # FA_HERMES_START_CMD unset -> run-hermes.mjs, the defaultdeploy.mjs builds the start command's environment from nothing. It passes on only PATH, HOME, DOCKER_HOST, DOCKER_CONTEXT, FA_HOST_PORT, HERMES_AGENT_REF, HERMES_CREDENTIAL_ENV, HERMES_MODEL, HERMES_PROVIDER, FA_CONTAINER_HOST, HERMES_RUN_DIR, HERMES_DOCKER_BIN, HERMES_CONTAINER_NAME, PACKAGE_DIR and RUNTIME_ENV_PATH, so your FA_ADMIN_KEY never reaches the runtime. run-hermes.mjs reads FA_EPISODE_STEP_ID itself from RUNTIME_ENV_PATH — the file deploy.mjs wrote — never from its own environment, so there is no fallback step id to fall back to; a missing one fails the run rather than silently picking one.
What deploy.mjs needs:
| Variable | Required | Meaning |
|---|---|---|
FA_BASE_URL | yes | Where Weftra's main listener is, as seen from this host |
FA_ADMIN_KEY | yes | Your named admin/approver key |
FA_PROJECT_ID, FA_LINEAGE_ID, FA_DEPLOYMENT_ID | yes | The deployment to export |
FA_EPISODE_STEP_ID | yes | The workflow step the episode runs |
FA_HERMES_START_CMD | no | Default: run-hermes.mjs, run with the current node |
FA_PACKAGE_SHAPE | no | Default hermes |
FA_WATCH_TIMEOUT_MS, FA_WATCH_POLL_MS | no | Defaults 1800000 (30 min) and 1000 |
PACKAGE_DIR, RUNTIME_ENV_PATH | no | Default package/ and runtime/hermes.runtime.env under the harness directory |
What run-hermes.mjs reads (export these in your shell — deploy.mjs passes each one through):
| Variable | Default | Meaning |
|---|---|---|
HERMES_CREDENTIAL_ENV | ~/.hermes-fa/.env | Hermes's model key file (0600) |
HERMES_MODEL | claude-sonnet-5 | Model passed to hermes chat -m |
HERMES_PROVIDER | anthropic | Provider passed to hermes chat --provider |
FA_CONTAINER_HOST | 172.17.0.1 | Replaces localhost/127.0.0.1 in the gateway URL so the container can reach it |
HERMES_RUN_DIR | runtime/hermes-real | Logs, transcript and the throwaway home |
HERMES_DOCKER_BIN | docker | The docker binary to run |
HERMES_CONTAINER_NAME | fa-hermes-episode | The --name given to the one-shot container |
HERMES_AGENT_REF | v2026.8.31 | The nousresearch/hermes-agent tag to run |
The only secret on disk is runtime/hermes.runtime.env, mode 0600. It holds FA_BASE_URL, FA_WORKER_ENROLLMENT_TOKEN and FA_EPISODE_STEP_ID. The harness's .gitignore excludes package/* and runtime/*. If you override PACKAGE_DIR, RUNTIME_ENV_PATH or HERMES_RUN_DIR, make sure your version control ignores the new location too: that file, and the throwaway Hermes home's config.yaml while the run is in progress, each hold a live, redeemable token.
The compose file sets
FA_CONTAINER_BINDequal toFA_BIND. That means there is only one listener, and the Hermes container can reach Weftra's whole HTTP surface, admin API included, kept out only by credentials. Run this on a host that carries nothing else.
What happens during an episode
Enroll.
deploy.mjsexports, mints and writes the token file, then records a baseline of the deployment's evaluation.Redeem.
run-hermes.mjsreadsRUNTIME_ENV_PATHand callsPOST /api/worker/episodeswith the enrollment token and{"step_id": "...", "trigger_ref": "hermes-harness"}. Weftra consumes the token in the same transaction and opens an episode that is alreadyin_progress, withepisode_runtime: externaland the same tool set an internal episode at this level would get. It returnsepisode_id,episode_token(fa_wet_..., default lifetime up to an hour,FA_WORKER_EPISODE_TTL_S),gateway_urlandtool_allowlist.Cage the egress. Before Hermes starts,
run-hermes.mjscreates a per-run--internalDocker network (no route off the Docker host on its own; its name, and the proxy's, carry a random per-run suffix), starts the dependency-free allowlist proxy (egress-proxy.mjs) on it, connects the proxy — and only the proxy — to the ordinary bridge network so it alone has a route off the host, and waits until the proxy reports it is listening. The allowlist is the gateway's own address, the model provider's (api.anthropic.com:443foranthropic; any other provider needsHERMES_EGRESS_ALLOW, or the run fails before any container starts — see Environment variables), and everyHERMES_EGRESS_ALLOWentry you add. See the known limits below for what--internaldoes not stop.Act through the gateway.
run-hermes.mjswrites a throwaway Hermes home: your model key, aconfig.yamlCOMPOSED from the exported package's own fragment (hermes-home.mjs'scomposeHermesConfig— themcp_servers/platform_toolsetskeys stay exactly what Weftra exported; only the two placeholders and the shim's path are filled in), and a query built from the package'sSYSTEM.mdand the step's definition inmanifest.json. It then starts Hermes on the internal network ONLY, as an argv array with no shell:bashdocker run --rm --name "$HERMES_CONTAINER_NAME" --network "<the per-run internal network>" \ --user "<uid>:<gid>" \ -e HOME=/opt/data -e HERMES_HOME=/opt/data \ -e HTTPS_PROXY="<the egress proxy's address>" -e HTTP_PROXY="<same>" \ -v "<home>:/opt/data" \ --entrypoint hermes "nousresearch/hermes-agent:$HERMES_AGENT_REF" \ chat --query-file /opt/data/query.md --oneshot -v \ -m "$HERMES_MODEL" --provider "$HERMES_PROVIDER" --max-turns 60 --run-budget 1200No
--yolo. No-t. Nofeatureagent.instancelabel. Everytools/callgoes through the shim toPOST <gateway_url>with a body like{"server":"fa-act","method":"tools/call","params":{...}}. The gateway checks scope, step, level, prohibitions and the per-episode call budget, and records the call. Awrite_evidencecall is recorded as aworker_act_evidence_writtenevent withevidence_fidelity: "observed"andchannel: "gateway".Report.
run-hermes.mjspostsPOST /api/worker/episodes/<episode id>/completewith{"summary": "...", "claimed": []}. The response'soutcomedecides the exit code:completedexits0; anything else — including a missing field — exits1, namedreport_completion: FAILED: outcome=<value>inrun.log, sodeploy.mjsstops waiting within one poll interval instead of running out its full watch timeout. Either way,run-hermes.mjsALWAYS deletesconfig.yamlfrom the Hermes home (it is the only file in it that ever carries the episode token), saves the proxy's decision log toegress.loginHERMES_RUN_DIR, and removes the egress proxy container and its network. The summary and anyclaimeditems are Hermes's own account of what it did, recorded asevidence_fidelity: "claimed". They never count toward completion or promotion. Weftra decidescompletedorno_evidencefrom the observed evidence alone, and revokes the episode token either way. An episode that never reports is swept asfailedwhen its token expires.
Watch it
deploy.mjs polls the deployment's evaluation and logs episode_redeemed, gateway_evidence and completion_report as each one lands. You can read the same data yourself:
# Evaluation: episodes.started / episodes.completed, evidence_notes (observed only), claimed_items
curl -s "$DEP/evaluation" -H "Authorization: Bearer $FA_ADMIN_KEY"
# The deployment's episodes: step, trigger, status, outcome, call budget used
curl -s "$DEP/episodes" -H "Authorization: Bearer $FA_ADMIN_KEY"
# One episode's ledger (admin): worker_act_evidence_written, episode_completed, ...
curl -s "$FA_BASE_URL/api/features/admin/<episode id>/events" -H "Authorization: Bearer $FA_ADMIN_KEY"In the dashboard, the deployment card's Episodes list shows the external episode next to the internal ones. The evaluation grid shows Evidence notes (gateway-observed) and, beside it, Claimed items (what the runtime only said it did). If claimed items grow while evidence notes stay flat, the runtime is talking more than it is doing.
On the host, HERMES_RUN_DIR (default runtime/hermes-real/ under the harness directory) holds run.log (each step by name), egress.log (the proxy's decisions, written when the run ends) and the throwaway home/ directory. Hermes writes its own logs into that home, so you can follow a run live:
cd examples/agent-factory/hermes
node deploy.mjs 2>&1 | tee runtime/deploy.log # the harness steps, in order
tail -f runtime/hermes-real/home/logs/agent.log # model calls and every tool call Hermes makes
tail -f runtime/hermes-real/home/logs/errors.log # provider errors (an unfunded key shows here)
docker logs -f <name>-egress-proxy-<suffix> # live: one EGRESS_DECISION line per allow/deny, host:port only
cat runtime/hermes-real/egress.log # the same lines, saved when the run endsThe dashboard does not show an external episode's log (Weftra never runs Hermes's model, so the episode's log view is empty; the gateway calls are recorded, read them from the episode's events, above). It does show the runtime-reported usage line on the Workers panel: after Hermes exits, run-hermes.mjs reads Hermes's session store and sends the model, provider, token counts and cost Hermes itself recorded. That figure is a claim by the runtime. Weftra labels it as reported and never adds it to its own cost. A field Hermes did not record is simply absent, and if the store cannot be read the run log says read_session unavailable and the episode shows no usage.
The session-store reader runs once, after Hermes exits and config.yaml (which holds the episode token) has been deleted. It copies only state.db (and its -wal/-shm files) into a private temporary directory and runs one throwaway container from the same Hermes image with no network, the same hardening as Hermes and a fixed script. The Hermes home, which holds your model key, is never mounted into it. The table and column names the script reads (sessions, messages) are recalled from upstream, not confirmed against Hermes's source at the pinned tag; the script reads the schema it finds and leaves out anything it cannot match. Treat the mapping as unconfirmed until your own live run shows real values.
Tool check. The same read lists the distinct tools Hermes called. The first line of the completion summary, and the tool_check step in the run log, reads one of:
tool check: only gateway toolstool check: no tools observedtool check: non-gateway tools used: <names>(anything not namedmcp__fa_gateway__...)tool check: not performed: <reason>(the store could not be read, recorded no tool calls, or listed more than 200 names or a name over 128 characters)
When non-gateway tools were used, the completion is still reported (so the episode is not left in flight) and run-hermes.mjs then exits non-zero with step tool_check. Otherwise the exit code is unchanged. "Not performed" is never a pass. The restriction itself is still not confirmed on a live run: this check is how you find out.
Hardening. Hermes runs with --cap-drop ALL, --security-opt no-new-privileges, --read-only (the Hermes home stays a writable bind mount at /opt/data), a /tmp tmpfs, a file descriptor ulimit, and a ceiling of 2 CPUs, 4 GB of memory and 512 processes. Weftra's own agent sandboxes also use a custom seccomp profile; this harness runs outside a Weftra install and has no path to that file, so Docker's default seccomp profile applies. The run log says hardening=strict. No live Hermes run has yet been made under these flags. If Hermes fails to start under them, set HERMES_SANDBOX_HARDENING=compat in the harness's own environment: it drops the hardening flags and keeps the resource ceiling, and the log says hardening=compat. Record what you needed rather than leaving compat as a silent default.
Stopping the harness. Ctrl-C or SIGTERM on run-hermes.mjs runs docker rm -f on the Hermes container, sends no completion report, deletes config.yaml, removes the egress proxy and network, and exits non-zero. A second signal does not skip that cleanup. Stopping an episode from the Weftra side does not reach a running harness: stop it on the host.
Gateway calls appear in agent.log as mcp__fa_gateway__<tool>. Anything else in that log (terminal, read_file, search_files, ...) is Hermes using its own tools, which Weftra does not see. Hermes's transcript is in home/state.db (SQLite), and its tail is what run-hermes.mjs sends as the completion report's summary. Do not open home/.env: it is your model key.
Check that it worked
A successful run ends with:
[deploy] episode_redeemed: observed
[deploy] gateway_evidence: observed
[deploy] completion_report: observed
DEPLOY_RESULT {"ok":true,"package_id":"...","deployment_id":"...","evaluation":{...}}If something is missing, deploy.mjs exits non-zero, prints DEPLOY_FAILED step=... and names the first step that never happened: one of export_package, download_package, mint_enrollment, write_runtime_env, start_runtime, episode_redeemed, gateway_evidence or completion_report.
For reference, the latest real run (2026-10-09, run-hermes.mjs, Hermes v2026.8.31, the watch_verdicts observe step of a live worker, Claude Sonnet 5 on the operator's own key) produced:
DEPLOY_RESULT {"ok":true,...}after about 10 minutes, with Hermes exiting 0.- 18 gateway calls recorded by Weftra:
list_features2,list_escalations1,read_evidence1,get_feature8 andwrite_evidence6. Oneget_featurewas refused first because Hermes passedidinstead offeature_id(see Troubleshooting); it retried correctly. - 6
worker_act_evidence_writtenevents, every one observed by the gateway, then completionoutcome: "completed". - Alongside those, Hermes used its own
read_file,search_files,write_fileandterminaltools inside the container to parse a largelist_escalationsresponse it had saved to a file. Oneexecute_codecall was blocked by Hermes itself, because the run has no--yolo(see What Hermes can and cannot do here). - About $1.50 of model usage on Hermes's key.
The first real run (2026-09-09, the old bash script, an observe step) produced:
DEPLOY_RESULT {"ok":true,...}, with Hermes exiting 0 after about 6 minutes.- 58 allowed gateway calls (
tools/list1,list_features2,list_escalations1,get_feature5,write_evidence50) and 4 refused calls. - 50
worker_act_evidence_writtenevents, every oneevidence_fidelity: "observed",channel: "gateway". - Completion
outcome: "completed"and anepisode_completedevent withruntime: "external".
On your run, expect the same shape: at least one evidence note, episodes.completed up by one, and an episode_completed event with runtime: "external". The exact counts depend on your worker and its data.
Troubleshooting
Hermes ignores the package's MCP wiring. Hermes reads config.yaml and .env under its home (HERMES_HOME, /opt/data in the image). It does not read mcp.json, toolset.json or memory-policy.json. run-hermes.mjs merges the package's own config.yaml fragment as-is (hermes-home.mjs's composeHermesConfig) — if Hermes still does not pick it up, compare the home's config.yaml (before it is deleted — copy it out mid-run, or inspect HERMES_RUN_DIR quickly after a failure) against what upstream Hermes actually expects, and fix the renderer (src/services/agent-factory/package/shapes/hermes.ts), never the launcher's merge step.
The credential file is unreadable inside the container. The official image does not run as the user who mounts the file, so a 0600 file cannot be read. run-hermes.mjs already runs the container as your own uid (--user "<uid>:<gid>", computed automatically) with HOME=/opt/data — if this still fails, check that HERMES_CREDENTIAL_ENV itself is readable by YOUR user before the run starts.
The shim cannot reach the gateway. Check the shim's own log if you set FA_SHIM_LOG in the package's config.yaml (not set by default). Make sure FA_CONTAINER_BIND is set and Weftra was restarted, and that FA_CONTAINER_HOST matches it. run-hermes.mjs rewrites only localhost and 127.0.0.1 in the returned gateway_url. If your instance's base URL is a public name such as https://weftra.example.com, the container uses that name as it is.
deploy.mjs times out at episode_redeemed. Either the start command exited (check run.log under HERMES_RUN_DIR for the failed step and its reason — deploy.mjs itself also fails fast on a non-zero exit, well before the full timeout) or the redeem failed. 409 enrollment_consumed means the token was already used. 409 deployment_superseded means the deployment was promoted or demoted after you exported, so export and enroll again. 400 on step_id means the step id is not one of the deployment's compiled steps.
It times out at completion_report even though the run looks fine. The default watch window is 30 minutes (FA_WATCH_TIMEOUT_MS=1800000); raise it further for an unusually long episode.
run-hermes.mjs exits naming read_runtime_env. There is no default step: a missing FA_EPISODE_STEP_ID, FA_BASE_URL or FA_WORKER_ENROLLMENT_TOKEN in RUNTIME_ENV_PATH fails the run immediately, naming the missing key. Check that deploy.mjs's own FA_EPISODE_STEP_ID was set.
A get_feature call is refused feature_not_in_scope. The argument is feature_id, not id. The refusal message names it. The tool's tools/list entry does not spell out its arguments yet.
The gateway refuses with episode_call_cap_exceeded. The episode has used up its gathering budget. This is not an outage. The refusal says how many reporting calls remain (reporting_calls_remaining), and those are kept back so the runtime can still write its findings. The episode list shows budget.reads_used and budget.reporting_used.
The outcome is no_evidence. Hermes reported completion, but nothing reached the gateway. The completion summary counts for nothing on its own. Check the shim log for forwarded tools/call lines, and check home/logs/errors.log: a model key with no credit fails Hermes's first model call ("Your credit balance is too low"), yet Hermes still exits 0. run-hermes.mjs reports no_evidence and then exits 1 itself (run.log shows report_completion: FAILED: outcome=no_evidence), so deploy.mjs fails fast at gateway_evidence within one poll interval instead of waiting out the full watch window.
The run fails at egress_allowlist before any container starts. HERMES_PROVIDER names a provider other than anthropic and HERMES_EGRESS_ALLOW is unset. Set HERMES_EGRESS_ALLOW=<host>:<port>[,<host>:<port>...] to that provider's own API host(s) — see Environment variables.
What Hermes can and cannot do here
What Weftra controls:
- Tools. Hermes can use only the gateway tools the step and level allow (the
tool_allowlistfrom redeem, the same settools/listreturns). Anobservestep gets the observe set: the reads its data scopes allow,write_evidence,file_escalation, and the state tools if the worker declares memory. All of these are narrowed to the capabilities the worker declared. Arecommendstep addspropose_recommendation. - Prohibitions. Every declared prohibition is listed in
SYSTEM.mdwith its reason and intoolset.jsonas a deny. The gateway refuses a prohibited call and records it. In the example worker,worker:draft_pris prohibited outright. - One token, one episode. The enrollment token is single-use. The episode token is bound to one episode, expires, and is revoked when the episode completes or is cancelled. Any other token from the same worker project gets
401from the gateway and the completion route. - Credentials. The package contains none. The runtime gets exactly one Weftra secret, and
deploy.mjskeeps its own admin key out of the runtime's environment.
What Weftra does not control: what Hermes does inside its own container with its own tools.
- The exported tool restriction — not yet confirmed on a live run. The package's
config.yamlfragment now setsplatform_toolsets.cli: ["fa-gateway"], replacing the top-leveltoolsets: ["mcp-fa-gateway"]key the 2026-10-09 live run showed Hermes ignores. The new key and the bare MCP-server name are what Hermes'shermes_cli/tools_config.pyis understood to read for a CLI run, but that file was not checked at the pinned tag when this was built (it could not be fetched there), so neither is verified. This repo's tests prove what the package says and what the harness starts; they cannot run Hermes itself. Known limit: even once confirmed, this does not inspect or disable Hermes's own native tools (terminal, file, code) — it only changes which toolsets Hermes's own startup resolver turns on. See Check that it worked for the operator confirmation step. - No
--yolodoes help. Hermes's own approval check blocked anexecute_codecall in one-shot mode on the 2026-10-09 run. It did not blockterminal, which ranpython3scripts the model wrote. - The container's egress is caged, but only at the network layer.
run-hermes.mjsstarts Hermes on an--internalDocker network whose only route off the Docker host is a dependency-free allowlist proxy permitting the gateway's address, the model provider's, and anyHERMES_EGRESS_ALLOWentry you add. This limits where the container can connect; it does not make Hermes's native tools safe, and it does not inspect what they do with an allowed connection (a tool that can reach the model provider can still send it whatever it likes; the gateway's entry admits every path on that listener). It also does not cover the Docker host itself: on many Docker engine versions a container on an--internalnetwork can still reach a port the host listens on through the network's host-side address, without going through the proxy. Nothing in the harness blocks that, so run it on a host that listens on nothing you would not hand the container. The proxy's log —egress.loginHERMES_RUN_DIR, oneEGRESS_DECISION allow|deny <host>:<port>line per decision — shows what the container tried to reach THROUGH THE PROXY; traffic straight to the host does not appear in it. - The query is an instruction, not a control. It asks Hermes to use only the
fa-gatewaytools; the model did not always follow it.
What still holds: Hermes's only Weftra credential is the episode token, and that token works only at the gateway and at its own episode's completion route, so every read or write Hermes makes against Weftra is checked and recorded there. Completion counts only evidence the gateway observed. Treat everything outside the gateway as invisible to the ledger. The governance boundary is the gateway, not the container.
The episode token is a bearer token. Whoever holds it has that episode's authority until it expires or is revoked.
Stop it
Pause the deployment. This is the one switch that silences the worker at every level. From then on,
tools/listreturns nothing and every call is refuseddeployment_paused, mid-episode too:bashcurl -s -X POST "$DEP/pause" -H "Authorization: Bearer $FA_ADMIN_KEY" \ -H 'Content-Type: application/json' -d '{"reason":"stop the Hermes run"}' # resume later: POST "$DEP/resume"Cancel the episode. This revokes the episode token. The episode is a feature in the worker project:
bashcurl -s -X POST "$FA_BASE_URL/api/features/admin/<episode id>/cancel" -H "Authorization: Bearer $FA_ADMIN_KEY"Stop the container:
docker rm -f "${HERMES_CONTAINER_NAME:-fa-hermes-episode}".An unredeemed enrollment token has no revoke route. It expires at its TTL, so mint with a short
ttl_seconds. Retiring the deployment (promoting or demoting it) makes any redeem fail with409 deployment_superseded, and that attempt also consumes the token.
Status
The export format (SYSTEM.md with steps and prohibitions, the config.yaml fragment, the toolset files) and the harness — run-hermes.mjs, plus the egress proxy (egress-proxy.mjs) — match the current code. CI runs the stub path on every change, and separately runs run-hermes.mjs itself against a fake docker (no real container, no real Hermes): export, enroll, redeem, the composed config.yaml, the egress network/proxy provisioning and its teardown, the argv Hermes would be started with, gateway evidence and the fail-fast exit code are all exercised, and the no---yolo/exported-platform_toolsets properties are asserted directly on that argv and that config.yaml (tests/spec-474-hermes-harness-repair.test.ts, tests/spec-476-inc-1-hermes-gateway-tools- only.test.ts). The proxy's own allow/deny logic is tested with real loopback sockets, no container at all.
The last real Hermes run was on 2026-10-09, against run-hermes.mjs and the pre-restriction renderer: it redeemed, acted through the gateway, wrote observed evidence and completed (see Check that it worked). That run confirmed the no---yolo start and the merge of the package's config.yaml fragment, and also showed that the fragment's then top-level toolsets key is not what a Hermes CLI run reads. The renderer now emits platform_toolsets.cli instead — checked neither against upstream source (hermes_cli/tools_config.py at the pinned tag) nor against another live run — and the harness now starts Hermes behind the egress proxy. Neither change has been confirmed on a live Hermes run yet. After merge, the operator runs the harness once for real and checks: the run's tool list (Hermes's own state.db session, or the request dump in home/logs/) contains only mcp__fa_gateway__* tools, and egress.log in HERMES_RUN_DIR (the proxy's decisions, saved when the run ends) shows only the gateway and the provider. That log covers traffic through the proxy only — see the known limit on reaching the Docker host directly. Until that run is recorded here, read every claim above the line as "renders/starts correctly", not "Hermes obeys it."