Stdlib client the agent runtime calls around its tool loop; fails closed for side effects, fails open for telemetry, never carries raw arguments or outputs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
5.1 KiB
hux_hook: wiring notes for the agent runtime patch
hux_hook is a stdlib-only library the Hermes agent process imports. HUX
(hermes-hux-foundation, loopback 127.0.0.1:8790 in the same pod) is the
source of truth; the agent never decides an approval, never reads the tenant
ledger directly and never persists raw tool arguments or output.
Environment the agent process needs
| Variable | Purpose |
|---|---|
HUX_BASE_URL |
default http://127.0.0.1:8790; the service is loopback-only |
HUX_TENANT_SLOT |
slot-N, same value the service was started with (HUX_TENANT_SLOT on the service side) |
HUX_SUBJECT |
usr_<hash> of the slot owner; the router derives it, the pod env carries it |
HUX_WORKER_KEY |
shared key mounted read-only under /runtime-access; sent as X-Hux-Relay-Key |
HUX_SURFACE / HUX_TRUST |
default worker / worker; the agent hook is never a human surface (SO-35) |
HUX_TIMEOUT_SECONDS |
default 5 |
Headers the client sends (exactly hux/identity.py): X-Hermes-Tenant-Identity,
X-Hux-Subject, X-Hux-Surface, X-Hux-Trust, X-Hux-Relay-Key, plus
Idempotency-Key on creates and If-Match on revisioned PUTs.
Construction
from hux_hook import HuxClient
client = HuxClient(os.environ.get("HUX_BASE_URL", "http://127.0.0.1:8790"),
{"tenant_slot": os.environ["HUX_TENANT_SLOT"], "subject": os.environ["HUX_SUBJECT"],
"surface": "worker", "trust": "worker"},
key=os.environ.get("HUX_WORKER_KEY"), timeout=float(os.environ.get("HUX_TIMEOUT_SECONDS", "5")))
One client per process. client.capabilities() is cached; call
client.forget_capabilities() on SIGHUP or when the WebUI reports a flag change.
Where each call goes in the tool loop
- Run start:
emit(client, conversation_id, "run.started", "...", run_id=run_id). - Immediately before executing any tool that is not read-only:
d = before_tool(client, run_id, conversation_id, tool_name, arguments, capability, external, risk).d.proceed is True: execute now, with exactly theargumentsobject that was hashed. Re-serialising or "normalising" arguments after the gate breaks SO-37.d.reason == "approval_required": do not execute. Surfaced.approval_idto the UI (chat postsPOST /hux/v1/approvals/{id}withonce|session|always|denyfrom a human surface), park the turn, and callbefore_toolagain with the same arguments after the decision. The idempotent replay reaches the gate; aonceapproval releases exactly once.- Any other reason (
approval_denied,budget_exhausted,hux_unavailable,autonomy_off,service_error:*, a gate reason): refuse the tool and tell the model why. Never fall back to the upstream gateway approval prompt whilehux.autonomyis on. Capability mapping is the runtime's job:read_files,write_files,shell,network,web_search,send_message,memory_write,artifact_write,spend_tokens,delegate,deploy,external_side_effect(seehux/rules.py).external=Truefor anything that leaves the tenant (messages, network, deploy).
- After every tool:
after_tool(client, run_id, conversation_id, tool_name, ok, bytes_out, turn, argument_hash=canonical_argument_hash(tool_name, arguments), duration_ms=..., exit_code=...)thenrecord_spend(client, run_id, conversation_id, tool_calls=1, tokens=<delta>). Both are best-effort: they returnNoneon failure and never raise. - Delegation:
record_spend(..., delegations=1)/subagents=1when spawning; the child run uses its ownrun_idand the sameconversation_id. - Memory: before proposing a memory write call
memory_gate(client, conversation_id);Falsemeans do not even propose. The write itself still goes throughPOST /hux/v1/memory, which enforces forget, disable and topic rules server-side. - Stop: when the user cancels, kill the tool processes, then
receipt = on_stop(client, run_id, conversation_id, process_registry_empty=<real registry check>, side_effects=[{"description": ..., "reverted": bool}, ...]).Nonemeans no receipt exists and the stop is NOT done: retry, and never report "cancelled" to the user without a receipt (SO-41).process_registry_emptymust come from the gateway's process registry, not from a timer. - Run end:
emit(..., "run.completed" | "run.failed", ...).
What must never happen
- Raw arguments, file contents, command output, prompts or secrets in any
summary,detailorevidence. The library only ever sends tool name, capability, hash, byte counts, status. - Deciding an approval with the worker identity (the service answers 403; do not retry as
chat). - Executing a tool after
proceed=Falsefor any reason, including HUX being unreachable. - Logging
HuxServiceErrorbodies with request payloads: exceptions carry status/code/message only.
Routes used
GET /hux/v1/capabilities, POST /hux/v1/approvals, POST /hux/v1/runs/{id}/gate,
POST /hux/v1/runs/{id}/budget, POST /hux/v1/runs/{id}/stop,
POST /hux/v1/conversations/{id}/events, GET /hux/v1/privacy/policy,
GET /hux/v1/conversations/{id}.