Skip to content

M2-33: Reference adapter for Hermes Agent (L7, L8)

Status: ready | Depends on: M2-12, M2-29, M2-32 | Milestone: M2

Goal

After this ticket an operator connects one Hermes Agent profile to the server as one agent principal with the reference adapter in plugins/hermes-adapter: a Hermes platform plugin (plugin.yaml with kind: platform, registered with ctx.register_platform() in register(ctx)) on the Python SDK of M2-29 that uses only the public plugin surfaces of the Hermes version pinned and recorded by M2-32 (09 17). The adapter accepts an invocation only after the SDK has checked the invocation token (09 4.5 points 1 to 10) with the adapter's actual billing mode, actual tool class and allow list; it stores the event with its token in its inbox before it acknowledges it, reads the run context, determines the actual billing mode again from the key type of the credential Hermes resolves (never from its value) and starts the Hermes turn only after 200 on startRun, so no Hermes turn ever runs without a matching token, and an undeterminable mode fails the run with billing_mode_mismatch (I3, I5, R-AG-07, AZ-P6, RK-13). During the turn format_tool_event creates run steps instead of Hermes's progress bubble and returns None, set_status_text and send_draft fill the live box, the answer is one message, and the run ends with finishRun and the run's costs (D-L7, R-AG-18). The server-built run context reaches Hermes as a fixed system part in which content marked unverified and every file content appear only inside marked data blocks (ADR 0054, AG-20); follow-ups follow busy_input_mode (steer or queue, AG-13); cards from send_clarify, the approval prompt, the slash confirmation and the pickers become components whose single interaction resolves the Hermes callback exactly once (AG-15); stop interrupts the turn; each thread has one Hermes session that the adapter reports; the heartbeat carries actual mode, evidence and provable modes (D-L8); an installer sets up the profile, sets busy_input_mode, switches the billing mode and writes Hermes's own allow list for personal (10 10.1, 17.7). The Fake-Hermes scenarios AG-08, AG-11, AG-13, AG-15 and AG-20 run green in make check against the pinned Hermes version in a subprocess with a fake model and the SDK's FakeServer, the pinned version is unchanged before and after (M2 exit criterion 5), and a leak test finds no key value in any request, log, output or state file of the adapter (BR-13).

Read first

  • docs/PLAN.md: section 1 "Principles" point 2 (adapter tests only against pinned public versions in throwaway folders, never against a productive runtime); section 3 "Way of working per ticket" point 5 (no real model calls without an explicit switch); section 5 "Test strategy" (row "Test doubles": Fake-Hermes M2-33); section 8 "M2 Agent platform, extensions and terminal" (Goal; Scope "reference adapters for Hermes Agent"; Out of scope "Files in agent invocations and voice messages (M3)"; Exit criteria 1, 2, 3 (AZ-P6) and 5; Test strategy "fake Hermes with a pinned version and a fake model (no costs)"; Red team "forging or reusing the invocation token; faking the reported mode" and "adapters, runtimes ... as boundaries"; Dependencies "M2-32 ... can run at any time"; Risks "Hermes Agent internals are not a promised plugin API", "fail closed on unknown", prompt injection); block "agents and extensions" rows M2-29, M2-32 (what exists), M2-33 (this ticket), M2-09 to M2-16, M2-20, M2-22 (the server halves), M2-31, M2-34 and M2-35; section 18 "Coverage" rows B6, I3, I5; section 19 P-09, P-10, P-11.
  • Dependencies. Header: M2-12 (AgentInvocationPayload.session_mode, AgentSessionReport.started and bind_failed), M2-29 (Python SDK bothy_sdk with the hand layer modules auth, gateway, invocation, adapter (AgentHandler, Adapter, RunHandle: the acceptance and run loop), inbox (SqliteInboxStore), runs, context (render_context, the run context with trust markers as marked data blocks), components, outbox, retry, heartbeat, testing with the FakeServer) and M2-32 (record of the pinned Hermes version and the answered points of 09 17.10 in plugins/hermes-adapter/tests, and the harness hermes_harness). Implied through M2-29: M2-14 (header Invocation-Token, SR9), M2-23 (sdk/python, distribution bothy-sdk, import package bothy_sdk, generated core, quality.coverage.python_min_percent), M2-10 (patchRun, path table of 09 5.3), M2-09 (startRun, resumeRun, renewInvocationToken, awaitRunInput, finishRun), M2-08, M2-07, M2-06 (token format), M2-05, M2-04, M2-02 (getServedChannelConfig) and the M1 tickets below them.
  • docs/spec/09-agents.md: 1 "Principles"; 2.2 "Features"; 2.4 "Tool class"; 3.6 "Guest agents"; 4.1 to 4.10 (all of "Adapter contract"); 5.1, 5.2 "Reasons (runs.state_reason)", 5.3 "Run document (run_document.v1)", 5.5; 7 "Follow-ups during a run"; 8 "Stop"; 9.1 "Lifecycle", 9.5 "Import binding and fail open"; 11 "Components" (11.1 to 11.6); 12 "Auto-title" points 3 to 5; 15 "Reliability"; 16.1 "Test doubles" (row Fake Hermes), 16.2 "Required scenarios" (AG-08, AG-11, AG-13, AG-15, AG-20); 17 "Reference adapter: Hermes Agent" (all of 17.1 to 17.10); 19 (R-AG-01, R-AG-04, R-AG-06, R-AG-07, R-AG-18, R-AG-19, R-AG-24, R-AG-25); 21 "Configuration values of this chapter".
  • docs/spec/10-backend-policy.md: 3 "Invariants" (I3, I5); 7 "Adapter check (I3)"; 8 "Expected and reported"; 9.2 points 3 to 5; 10.1 "Hermes Agent"; 15 "Tests" (BR-05, BR-13).
  • docs/spec/07-permissions.md: 2.11 "Tool class of an agent"; "SR9 Acting-for binding (confused deputy)"; 11.2 "Required tests" (AZ-P6).
  • docs/spec/04-sync-protocol.md (core contract, read only): 2.2 "Envelope" rule 3; 15.1 to 15.5 (agent consumers, ack, invocation and run start, follow-ups, cancel).
  • docs/spec/05-actions.md (core contract, read only): 4.10 rows run.start, run.update, run.finish, run.token.renew, run.context.read, agent.heartbeat, agent.config.read, agent.triggerers.read, agent_session.report, interaction.respond, component.update, thread.title.update, message.create; 5 (claims of the invocation token); 6 rows agent.invocation, agent.followup, interaction.created, run.cancel_requested, agent.config_changed.
  • docs/spec/03-data-model.md (core contract, read only): agents (reported_details), runs, run_steps (detail), message_components.
  • docs/spec/11-extensions.md: 11 "SDK design" (11.2 "Hand-written layer", 11.3 "Runtimes and dependencies": dependencies compatible with the Hermes environment, checked in the Fake-Hermes test).
  • docs/spec/06-api.md: 2 "Basic rules"; 5 "Errors"; 13 point 3 and 14 (row "Python SDK and Hermes adapter").
  • docs/spec/18-release.md: 2 "Components" (row hermes-adapter, rule 1: VERSION and CHANGELOG.md); 9 point 5.
  • docs/spec/19-security.md: SEC-AI-1, SEC-AI-4, SEC-AI-5, SEC-LOG-2, SEC-LOG-4. docs/spec/23-quality.md: 3 "Component contract" (row "Python project") and "Documentation in code". docs/spec/02-architecture.md: 6, list "Places that carry the product name".
  • docs/api/openapi.yaml: operations getJwks, getDiscovery, getMe, pollAgentEvents, ackAgentEvents, sendHeartbeat, getAgentTriggerers, listServedChannels, getServedChannelConfig, getRunContext, startRun, resumeRun, renewInvocationToken, awaitRunInput, patchRun, finishRun, createMessage, updateMessage, respondToInteraction, updateComponent, reportAgentSession, getThread, setThreadTitle, downloadFile; schemas EventEnvelope (invocation_token, redelivered), AgentInvocationPayload, AgentFollowupPayload, RunCancelRequestedPayload, InteractionCreatedPayload, Interaction, InteractionRespond, RunStart, RunFinish, CostReport, RunMetrics, RunPatch, RunStep, RunAwaitInput, InvocationTokenRenew, InvocationTokenGrant, RunContext, RunContextMessage, RunContextAttachment, PrincipalRef, Heartbeat, BillingEvidence, BillingMode, ToolClass, MessageCreate, ComponentCreate, ComponentUpdate, ComponentType, ComponentRestriction, Capability, AgentSessionReport, AgentChannelConfig, ChannelBehavior, AgentTriggerers, Problem with x-problem-codes. docs/api/asyncapi.yaml: operations receive.identify, receive.heartbeat, receive.ack, receive.run.update, send.dispatch.
  • ADRs 0022, 0023, 0024, 0025, 0026, 0044, 0053, 0054.
  • docs/DECISIONS.md: D-L7, D-L8, D-R-HERMES, D-AG-05, D-AG-06, D-AG-07, D-AG-17, D-AG-18, D-AG-19, D-W-15, D-W-16, D-W-44, D-W-79, D-W-100; section 10 for the format of new rows (D-M2-33-<n>).
  • docs/security/threat-model.md: 5.10 "Agent adapters and runtime"; register rows T-AI-01, T-AI-03, T-AI-08, T-TK-05. docs/RISKS.md: RK-12, RK-13. docs/OPEN-QUESTIONS.md: no item concerns this ticket.
  • docs/operations/configuration.md: hermes_adapter.platform_key, hermes_adapter.consumer, adapter.invocation_token_leeway, adapter.jwks_refresh_min_interval, sdk.http_timeout, sdk.retry_max_attempts, sdk.retry_backoff_base, sdk.retry_backoff_cap, agent.run_update_min_interval, agent.invocation_token_ttl, agent.ack_timeout, message.max_content_bytes, agent.context_message_max_bytes, component.default_ttl, component.custom_id_max_chars.
  • docs/quality/gates.md (rows lint, typecheck, test, coverage, licenses, osv), docs/quality/licenses.md ("License gate (make licenses)"), quality/licenses/allowed.txt.
  • Briefs whose names this ticket uses: docs/briefs/M2-23-generated-clients.md (layout, tool versions, coverage key, root Makefile Python lines), M2-09-run-lifecycle.md (start, resume, renew, finish), M2-10-run-document-live-box.md (note 2 path table, note 3 step values, note 5 cutting), M2-11-run-followups.md, M2-12-agent-sessions.md (notes 9 to 12), M2-13-run-context.md (rulings 3 to 11), M2-14-acting-for.md (note 1, header Invocation-Token), M2-15-components-interactions.md, M2-20-heartbeat-presence.md (note 3: closed key set and forms of agent_heartbeat.v1), M2-22-costs.md (note 3, CostReport rules).
  • Code and files on the base branch: sdk/python (README, src/bothy_sdk, the FakeServer in bothy_sdk.testing, CHANGELOG), plugins/hermes-adapter (README, the record and fixtures of M2-32 under tests/), plugins/README.md, the root Makefile (variables PY_PROJECTS, PY_ON; targets fmt-check, lint, typecheck, test, coverage, licenses, osv, bootstrap-ci), tools/ci/components.py, tools/ci/license_check.py, tools/docs/tests/test_config_values.py, sonar-project.properties, .gitignore. Read the merged state with git log before you start.

Fixed requirements that apply

Owner's fixed build rules (apply verbatim):

  1. No MVP. New from scratch and directly in the final state; stages are only the order.
  2. Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
  3. Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
  4. Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
  5. Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.

Rules for every brief:

  1. Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
  2. No en dash and no em dash in any file you write; the text-rules pre-commit hook rejects them. Use a hyphen or rephrase.
  3. White label: examples and test data use example.com and the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory.
  4. Limits, windows and thresholds only through named configuration keys listed in docs/operations/configuration.md; code comments and docs name the key, never the number.
  5. Specification first: a change to behaviour, API, event, right or configuration updates docs/spec/ or docs/api/ in the same change. The core contract (docs/spec/00-glossary.md, 03-data-model.md, 04-sync-protocol.md, 05-actions.md) is not edited by this ticket.

Product requirements implemented here:

  1. The adapter is a Hermes platform plugin that uses only public plugin surfaces and never changes Hermes; logic lives in modules without a Hermes import; configuration comes only from the platform configuration Hermes hands to the plugin, never from os.getenv or os.environ (09 17.1, 17.2; ADR 0022; B6).
  2. No Hermes turn without a matching token: the SDK checks points 1 to 10 of 09 4.5, a failed point on an event that would start or resume a run ends the run with finishRun failed and the reason of 09 4.5 (M2-29 note 9), an unknown kid is neither accepted nor acknowledged, and the turn starts only after 200 on startRun or resumeRun (09 4.5, 4.9; R-AG-07; AZ-P6; AG-08).
  3. The actual billing mode is determined before every startRun and resumeRun from the key type of the credential Hermes resolves for the turn, never from its value; an undeterminable mode counts as unequal to every target and fails the run with billing_mode_mismatch; the value is never logged, stored, sent or printed (09 17.8; 10 7, 10.1; I3; I5; AG-11; BR-05; T-AI-08; RK-13).
  4. The actual tool class is reported in the heartbeat and in tool_class_used, and a run whose token carries a lower tool_class fails with tool_class_mismatch (09 2.4, 4.5 point 10; 07 2.11; D-W-16).
  5. An event with a token is acknowledged after it is stored with its token in the adapter's inbox; a redelivery replaces the stored token of a run not yet started and is not processed twice; the start uses the newest token, renews an expired one with renewInvocationToken and resumes from the inbox after a restart (09 4.3, 4.5.1, 4.9, 15; R-AG-24; D-W-15).
  6. Live box and answer: format_tool_event creates and closes run steps of kind tool_call and returns None; set_status_text sets /status_line; send_draft sets /last_text_excerpt; the answer is one message created with a client_msg_id stored before sending; edit_message is buffered until finalize or the end of the run; typing is ignored; the run ends with finishRun and its costs (09 4.7, 4.10, 5.3, 17.3; D-L7; R-AG-18).
  7. The run context reaches Hermes as a fixed system part with author and trust per element, separate from the text of the triggering person; content with trust = unverified and every file content appear only as marked data behind a fixed note; the transcript of a voice message carries the trust of its message (09 4.6, 17.6; ADR 0054; SEC-AI-1; AG-20; T-AI-01; T-AI-03).
  8. Follow-ups follow the configured busy_input_mode: steer passes the text into the running turn and reports delivered, then absorbed; queue passes nothing; interrupt makes the adapter degraded with busy_mode_invalid and without follow-up capability (09 7, 17.5; AG-13).
  9. Cards and interactions: clarify, approval, slash confirmation and pickers are single-use components with the Hermes callback ids as custom_id; the run waits with awaitRunInput; an interaction resumes the run with its own token and resolves the Hermes callback exactly once; approval buttons follow 09 11.5 (09 11, 17.3; SEC-AI-4; AG-15).
  10. Stop: run.cancel_requested interrupts the turn and ends the run cancelled with the reason of the event; no later patch or message of that run is sent (09 8; 04 15.5; AG-13 stop part).
  11. Sessions: every source is built with build_source and the thread id, so Hermes keeps exactly one session per thread; the adapter follows session_mode (new, resume, bind), reports the Hermes session id with reportAgentSession, and falls back to a new session with bind_failed: session_missing when a bound session is missing (09 9.1, 9.5, 17.4; R-AG-04).
  12. Allow list: the adapter enforces its own access policy with the principals of getAgentTriggerers and reloads it when a token carries a newer permission_version; at personal the installer writes Hermes's own allow list with exactly the credential holder (09 4.5 point 9, 17.7; R-AG-19).
  13. Heartbeat after agent_heartbeat.v1: state, degraded reasons, runtime version, actual mode or its absence, provable modes, evidence without value, actual tool class, capabilities, active runs (09 4.8; D-L8; 10 8).
  14. Every request inside a run carries the run's current invocation token in the header Invocation-Token; requests outside a run carry none (07 SR9; M2-14).
  15. No key value, token or message content appears in a log line, error text, heartbeat, request field or state file of the adapter (BR-13; I5; SEC-LOG-2; SEC-LOG-4 applied to the adapter).
  16. Tests use the pinned Hermes version in a subprocess with a throwaway HERMES_HOME, a fake model on 127.0.0.1 and the FakeServer; the pinned version is unchanged before and after; real model calls never happen (09 16.1; PLAN 1 point 2; M2 exit criterion 5).
  17. No JSON null in any request body (ADR 0053).

Scope

All paths under plugins/hermes-adapter/ unless noted. Module and file names are proposals; where the merged SDK of M2-29 or the record of M2-32 names something differently, follow it and list the difference in the report. This ticket creates no new top-level make target, no migration and no server code.

Create:

  • src/bothy_hermes_adapter/ (doc comment on every public symbol; modules without a Hermes import unless named):
    • __init__.py (version from VERSION, held by a test), config.py (Design note 3), actual_mode.py (Design note 6), tool_class.py (Design note 7), handler.py (HermesHandler, Design notes 5, 8, 11 to 14), store.py (only resolved callback ids and the open card per run, Design note 18), runtime.py (the Runtime protocol of Design note 1), context_format.py (Design note 9), live_box.py (Design note 10), cards.py (Design note 12), heartbeat.py (Design note 15), installer.py and __main__.py (Design note 17).
    • platform.py: the only runtime module that imports Hermes: the subclass of BasePlatformAdapter implementing Runtime and the methods of 09 17.3, and register(ctx).
    • hermes_config.py: the only other module that imports Hermes: the configuration bridge of the installer over Hermes's own YAML reader and credential resolution (Design notes 6 and 17).
    • The plugin entry (plugin.yaml with kind: platform and the module that Hermes's plugin discovery loads) in the form and place the pinned version discovers, as M2-32 recorded (directory plugin or entry point), shipped as package data.
  • typings/: type stubs (.pyi) for exactly the Hermes symbols platform.py and hermes_config.py use, with the SPDX header; held equal to the pinned version by test_hermes_stubs_match_pinned_version (Design note 2).
  • tests/unit/: the unit tests below (FakeServer of M2-29 and a fake Runtime; no Hermes).
  • tests/fake_hermes/: harness (conftest.py with the fixtures of Design note 20 on M2-32's hermes_harness) and the scenario tests below (Design note 20). Reuse what M2-32 left under tests/; do not duplicate its fake model, its pin, its provisioning or its isolation.

Change:

  • pyproject.toml and uv.lock of M2-32: rename the project hermes-adapter to bothy-hermes-adapter (import package bothy_hermes_adapter in src/bothy_hermes_adapter/), remove [tool.uv] package = false, add the package src/bothy_hermes_adapter and the runtime dependency bothy-sdk (path source ../../sdk/python) and nothing that the SDK does not already pull in (the standard library covers SQLite, logging and JSON); keep M2-32's dev dependencies (including PyYAML and types-PyYAML) and its settings for tests/hermes_harness; add src/bothy_hermes_adapter to mypy and to coverage source, add mypy_path = "typings" and subprocess coverage (Design note 20), then relock. Hermes is never a dependency in this lock (Design note 2).
  • VERSION stays. CHANGELOG.md of M2-32: an entry under "Unreleased".
  • README.md of this component and plugins/README.md (English, like today): status "built", layout, installation into a Hermes profile, configuration keys, mode switch with restart note, what the tests prove and what they do not, the pin and how to update it.
  • sdk/python/src/bothy_sdk/testing and sdk/python/CHANGELOG.md, only where the FakeServer lacks an operation, event, signing variant or recording that a test below needs (for example a token with a foreign aud, an interaction.created delivery, the agent.followup delivery for a waiting_input run at queue, a served downloadFile, recorded request headers): add it after the schema in docs/api/openapi.yaml with its own SDK test; only if the SDK does not send Invocation-Token inside a run, add it to the run helper of bothy_sdk.runs with an SDK test (Design note 16); and the behaviour the SDK lacks for Design note 5 (allow-list failures) in bothy_sdk, each with an SDK test. List every SDK change in the report.
  • docs/spec/09-agents.md (English, like the chapter), spec first: 17.2 (the two Hermes-importing modules, configuration keys in PlatformConfig.extra, state directory), 17.3 (rows format_tool_event with the step detail of Design note 10, "Interaction (interaction.created)" with Design note 12, rows of Design note 21 marked as not built yet), 17.6 (the sequence after 4.3: acknowledge after the inbox, before run.context.read; the context format of Design note 9), 17.8 (row "not determinable" covers every resolution outside the three named rows, Design note 6; the change during a run), a new subsection "17.11 Tool class" (Design note 7), the handling of channel prompt and skills (Design note 9), and 21 (the new keys of Design note 3).
  • docs/spec/02-architecture.md 6, list "Places that carry the product name": a new kind "Hermes adapter: Python distribution bothy-hermes-adapter, import package bothy_hermes_adapter, default value bothy of hermes_adapter.platform_key". tools/ci/tests/test_product_name_places.py (M1-46) is in the closure: allow this kind there.
  • docs/operations/configuration.md: regenerate with make config-reference.
  • docs/security/threat-model.md: rows T-AI-08 and T-AI-03 name the tests of this ticket in the column "Tests"; the rating stays. docs/RISKS.md RK-12 and RK-13: the mitigation names the stub contract test, the credential test and the pin; no rating changes.
  • Root Makefile bootstrap-ci: uv sync --project plugins/hermes-adapter --locked --quiet when plugins/hermes-adapter/uv.lock is tracked (the pattern M2-23 used for sdk/python), and only if the merged recipe does not already cover every tracked Python project; root CHANGELOG.md under "Unreleased" for that change.
  • tools/docs/tests/test_config_values.py: the fail_under of this project equals quality.coverage.python_min_percent, only if the test does not already cover every tracked Python project.
  • docs/DECISIONS.md section 10: rows D-M2-33-1 and following (Design note 22).

Out of scope

  • Every server change: the server halves of the scenarios (token checks at start AG-08 and AG-11 in M2-09, follow-up states AG-13 in M2-11, the single-use rule AG-15 in M2-15, trust markers AG-20 in M2-13), heartbeat storage and warnings (M2-20), cost booking (M2-22), and the server side of getAgentTriggerers (no ticket names it; plan gap).
  • Registering Hermes commands and executing command.invoked and model commands (09 17.3 row "Hermes commands"; needs M2-16 and the reconciliation operation that M2-16 reports missing), reports from Hermes cron jobs without a run (09 17.9 and 14; needs M2-19), sending files, images, voice and video (rows send_image, send_document, send_voice, send_video; needs M3-03 and M3-07), and voice input with file.transcript.submit (09 13; M3-09). The adapter reports neither files_out nor voice_input and answers these Hermes calls with a failure result (Design note 21). Plan gap: no ticket builds them for this adapter.
  • Autocomplete of command options (09 10.5): the adapter registers no commands.
  • The Claude Code runner (M2-34, M2-35), the TypeScript SDK (M2-30), the conformance suite with the Fake-Agent (M2-31) and the guide (M2-37).
  • Tests against the newest Hermes version: built behind the explicit switch of Design note 20, not run here (an unpinned download).
  • Release packaging of the plugin (release-build, M7) and publishing anywhere.
  • Changes to the core contract.

Boundaries

  • Work in a dedicated git worktree on the branch feat/M2-33-hermes-adapter; one writer per worktree.
  • Conventional Commits with the component as scope (for example feat(hermes-adapter): ..., test(hermes-adapter): ..., docs(spec): ..., feat(sdk-python): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md).
  • Never push to main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request of AGENTS.md. Never publish a package.
  • Do not modify files outside the scope above, except the CHANGELOG, README and docs/DECISIONS.md entries named in this brief.
  • Follow the host rules supplied by the launcher (ports, machine rules).
  • Tests bind only 127.0.0.1 and port 0 (P-10); no PostgreSQL and no container is needed, because the server side of every test is the FakeServer of M2-29.
  • Stop only processes you started, by PID. Temporary directories only via mktemp -d or the test framework's temporary directory, removed at the end; the pinned Hermes checkout and its venv live only in the temporary root of M2-32's provision.
  • Never touch a Hermes installation of the host: no Hermes home, profile, configuration, credential store, gateway process or cron job outside the throwaway directories of a test is read, written, started or stopped. The Hermes subprocess gets an environment built from scratch (Design note 20), never the environment of the session, so no real credential and no real home directory can reach it.
  • Never read or print secrets. Test credentials are random canaries generated at test time and never committed. No network calls except package downloads for pinned tools and packages, including the pinned Hermes commit fetched by M2-32's provision; no real model call and no real third-party service in any test.
  • Never lower a threshold, disable a gate or delete a test; no # nosec, # type: ignore or noqa without a reason in the same line.
  • If the core contract lacks something this ticket needs, stop with Verdict: BLOCKED <reason> instead of building a deviation (P-11).
  • Start check: make check is green on the fresh worktree, else Verdict: BLOCKED base branch red. The record of M2-32 answers every point of 09 17.10 with evidence, else Verdict: BLOCKED 09 17.10 point open: <point>.
  • This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 or M2 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with Verdict: BLOCKED <reason>.

Design notes

  1. Layout and layering (09 17.2, rule 2). Ruling for docs/DECISIONS.md: distribution bothy-hermes-adapter, import package bothy_hermes_adapter, the same naming as bothy-sdk (M2-23 note 1), listed in 02 6. Exactly two modules import Hermes: platform.py (runtime) and hermes_config.py (installer and credential resolution over Hermes's own reader, which 10 10.1 and 17.5 require); 17.2 is aligned to say so. Every other module depends on the protocol Runtime in runtime.py, which platform.py implements and unit tests fake: start_turn(run, source, user_text, system_part), steer(run_id, text), interrupt(run_id), resolve_callback(custom_id, values, operator), switch_session(thread_id, mode, external_session_ref), current_session(thread_id), session_title(thread_id), resolved_credential() and loaded_toolsets(). The installer runs inside Hermes's interpreter (python -m bothy_hermes_adapter ... of the profile's venv), so no package module starts a subprocess. Two static tests hold this: test_only_two_modules_import_hermes (AST scan against the top-level module names of the pinned distribution) and test_no_environment_access (AST scan: no os.getenv, os.environ, os.putenv in the package).
  2. Hermes surfaces (09 17.1, 17.10; RK-12). The pinned version and its record from M2-32 decide every Hermes name used here (method signatures, the event of format_tool_event, the MessageEvent fields, the session switch path, the credential resolution, the steer and interrupt paths, the plugin discovery). The adapter uses only the surfaces 09 17 names; a surface that M2-32 found missing and adjusted in 09 17 is built as adjusted. Stubs in typings/ describe exactly the used symbols; test_hermes_stubs_match_pinned_version imports each from the pinned version and compares inspect.signature, so a pin update that changes a used surface fails at once. Hermes is not a dependency of this component (09 17.1: not part of the product): it runs only in the Fake-Hermes harness and in the operator's installation.
  3. Configuration (09 17.2, 21; rule 9). Keys arrive in PlatformConfig.extra of the platform section of the profile configuration, flat with their full names, written by the installer; config.py validates them into a frozen dataclass and fails the connect with a message naming the key. Existing keys: hermes_adapter.platform_key, hermes_adapter.consumer, adapter.invocation_token_leeway, adapter.jwks_refresh_min_interval, sdk.http_timeout, sdk.retry_max_attempts, sdk.retry_backoff_base, sdk.retry_backoff_cap. New keys, rows in 09 21 with Place "Hermes adapter" (Ruling for docs/DECISIONS.md, spec gap: 09 names no way to reach the server):
    • hermes_adapter.server_url (no default, required): base address of the instance; discovery and JWKS come from getDiscovery.
    • hermes_adapter.instance_id (no default, required): instance ID of the server and the expected iss of every token (09 4.5 point 4); connect() passes it as AdapterConfig.instance_id, and the SDK compares it with getDiscovery and stops on a difference (M2-29 note 8). The adapter's own ID, the expected aud, comes from getMe (a principal of kind agent, otherwise stop) and becomes AdapterConfig.agent_id, as in M2-34.
    • hermes_adapter.token_file (no default, required): file with the agent's API token, readable only by the profile's user; read at connect, never logged; a file is the adapter's secret provider because os.getenv is forbidden (17.2) and the token must not stand in the configuration file.
    • hermes_adapter.dpop_key_file (no default; without it no DPoP): private key the SDK auth module signs DPoP proofs with; the installer creates it with user-only permissions when asked.
    • hermes_adapter.max_message_bytes (default equal to the default of message.max_content_bytes): MAX_MESSAGE_LENGTH of 17.3; the adapter cannot read the server's value, so the README says that both change together, and test_max_message_bytes_default_matches_server holds the two defaults equal in docs/operations/configuration.md.
    • hermes_adapter.attachment_text_max_bytes (default equal to the default of agent.context_message_max_bytes): largest attachment text the adapter inlines as a data block (Design note 9).
    • State lives in the directory named like the platform key below the profile's home directory, which the adapter learns from the function the pinned version offers plugins (M2-32 record), never from the environment; mode 0700; it is the state_dir of the SDK (its inbox and outbox files, M2-29) and holds the adapter's own SQLite file of Design note 18.
  4. Connect and disconnect (09 17.3). connect(): load the configuration, the token and the DPoP key; getMe; then start the SDK loop of Design note 5, which compares getDiscovery with hermes_adapter.instance_id, loads getJwks and the allow list, opens the gateway through the SDK gateway module with identify and consumer = hermes_adapter.consumer (one process per consumer; on 4015 the SDK stops and the adapter reports the platform as disconnected without reconnecting), starts the heartbeat (Design note 15) and resumes not-started inbox entries (M2-29 note 9). No command registration (Out of scope). disconnect(): stop the SDK loop (close with 1000), stop the heartbeat; open runs are no longer listed in active_runs and the server ends them as interrupted (09 5.5). get_chat_info(chat_id) reads the thread with getThread.
  5. SDK run loop and handler (handler.py; 09 4.3, 4.5, 4.9; M2-29 notes 7 to 9). platform.py runs the SDK loop of M2-29: connect() starts Adapter(AdapterConfig(..., allowlist=True, state_dir=<state dir>), HermesHandler(runtime)) as a task and disconnect() stops it. HermesHandler(AgentHandler) (module handler.py, no Hermes import) implements actual_billing (Design note 6), actual_tool_class (Design note 7), prepare (Design note 8: context, channel config and actual mode; returns StartOptions), execute (the session handling of Design note 14, the turn and Design note 10), on_followup (Design note 11), on_interaction (Design note 12) and on_event (agent.config_changed, Design note 9). Inbox, jti store, ack rule, allow list (M2-29 note 8), token renewal, start answers, cancel and restart are the SDK's (M2-29 notes 7 to 9) and are not rebuilt. prepare raises, so the run ends failed adapter_error without a start, for an agent.invocation with trigger_kind command (the adapter registers no commands) or interaction that names no open card of this adapter. Behaviour the SDK lacks is added to bothy_sdk with an SDK test and listed in the report:

    • allow list unloadable with 404: finishRun failed adapter_error and the event is settled (the operation is absent on this server; a visible reason instead of a run that waits until run.queued_max_age);
    • allow list unloadable with a network error, 429 or 5xx: the event stays unsettled, no inbox, no ack (redelivery retries).

    Ruling for docs/DECISIONS.md: the two allow-list failure kinds as SDK additions and the refusals in prepare.

  6. Actual billing mode (actual_mode.py and hermes_config.py; 09 17.8; 10 7). Runtime.resolved_credential() (implemented with the resolution function of hermes_config.py) asks Hermes which credential it will use for the next turn (surface of the M2-32 record) and returns only provider and key_type; the bridge drops every other attribute of Hermes's answer inside the call and never logs, formats, stores or returns the value. The table, Ruling for docs/DECISIONS.md: Anthropic with a console API key gives organization, key_type: api_key; Anthropic with OAuth gives personal, key_type: oauth (OAuth wins over an API key when both are set, 17.8); another provider with an API key gives organization, key_type: api_key; every other result (no credential, cloud credentials, an OAuth login of another provider, an exception, an unknown answer) gives no mode with key_type: unknown, because 17.8 lists the determinable rows exhaustively and 10 7 counts an undeterminable mode as unequal to every target. billing_evidence is {provider, key_type, method: "resolved_credentials"} (provider omitted when unknown). The mode is computed at intake (point 8), again immediately before every startRun and resumeRun (billing_mode_used and billing_evidence in RunStart), and at every heartbeat. A change during a running turn (heartbeat computation differs from the run's billing_mode_used) interrupts the turn and ends the run failed billing_mode_mismatch (BR-05 "change during the run"). Ruling for docs/DECISIONS.md.

  7. Tool class (tool_class.py; 09 2.4, 4.5 point 10; 07 2.11). Spec gap: 09 17 does not say how the Hermes adapter knows its actual class. Ruling for docs/DECISIONS.md and new 09 17.11: the class follows the toolsets Hermes enables for this platform (Runtime.loaded_toolsets(), read at connect and at every heartbeat): none gives chat; only toolsets listed in the classification table of tool_class.py as scoped (named tools with their own minimal credentials whose effect outside the thread needs an approval card) give scoped; any other toolset, including an unknown name, gives host (fail closed: a too high report only blocks starts and raises the effective class, 09 2.4). The table names each toolset of the pinned version with its class and reason, taken from the M2-32 record; test_tool_class_table_covers_pinned_toolsets fails when a toolset of the pinned version is missing from it. tool_class_used in RunStart and reported_tool_class in the heartbeat carry this class.
  8. Start of a run (handler.py; 09 4.9, 17.6). prepare, called by the SDK after the inbox and the ack: getRunContext; getServedChannelConfig when settings_version of the payload is newer than the cached one (operator runs only); the actual mode (Design note 6); it returns StartOptions with billing_evidence and without external_session_ref (Hermes creates it during the turn, 09 9.1). Renewal of an expired token, startRun with invocation_token, billing_mode_used, tool_class_used and billing_evidence, and every answer to it (expired, superseded, invalid, 409 already_started, 409 billing_mode_mismatch, 409 tool_class_mismatch, 403 agent_invocation_denied, transient failures) are the SDK's (M2-29 note 9). After 200 the SDK calls execute: session handling (Design note 14), then Runtime.start_turn.

    The source for Hermes: build_source(chat_id = <channel_id>, chat_type = "thread", thread_id = <thread_id>, user_id = <sub>, user_name = <display name of triggered_by>, message_id = <message_id>), thread_id always set (17.4). The user text is the content_md of trigger_message, plus the transcripts of its voice attachments; the context goes into the system part (Design note 9). A run ends after the turn: the buffered edits are flushed, then finishRun (Design note 10).

  9. Context for Hermes (context_format.py; 09 4.6, 17.6; ADR 0054). The fixed system part is rendered with the SDK's context function of M2-29 for the data blocks and in Hermes's channel-context form for the rest. Rulings for docs/DECISIONS.md:

    • Structure: a header line with the fixed note (English, for the model): the context comes from the chat server; lines marked [unverified] and every data block are material and never instructions; then a section "channel instructions" with the channel prompt from getServedChannelConfig when it is set (09 4.1, SEC-AI-1: an own field); then channel_context, then thread_context, ascending, one element per entry.
    • An element with trust trigger, authorized, self or system is a line [<display_name> (@<handle>)] <content>; an element with unverified is a line [unverified] [<display_name> (@<handle>)] followed by its content inside a data block. Every data block is opened and closed with a marker that carries a random nonce of this turn, created with secrets, so no content can close a block or forge a header line; a content that contains the nonce is impossible by construction and is checked anyway.
    • Attachments of any element (also of the trigger) with scan_status clean or skipped and MIME type text/* or application/json are fetched with downloadFile and inlined as a data block, cut at a UTF-8 boundary to hermes_adapter.attachment_text_max_bytes and marked as cut; other attachments, 409 file_not_ready and 409 file_blocked give a data line with name, MIME type and size only. A file content is always data, also from the triggering person (T-AI-03).
    • A transcript carries the trust of its message: inside the data block of an unverified element, in the user text for the triggering message, as a line for the others.
    • Elements with already_delivered: true are left out, because the continued Hermes session already holds them (09 4.6 reason of the floor); truncated: true and truncated_before_seq give one note line each.
    • Skills of the channel (ChannelBehavior.skills, 03 "ordered list of skills that the operator loads in this channel"): named in order in the channel-instructions section as the skills to use in this channel, so Hermes loads them with its own skill tools; spec gap (09 17 maps neither prompt nor skills), settled without a further Hermes surface because both come from a person with manage (R-AZ-09) like the prompt.
  10. Live box, answer and end (live_box.py; 09 4.7, 5.3, 17.3). All patches go through the SDK runs helper (patchRun or the gateway op run.update, throttled to agent.run_update_min_interval, base_version kept, rebuilt after 409 base_version_conflict).
    • format_tool_event(event): a start event adds /steps/<id> with a new UUIDv7, kind: tool_call, name the tool name, title the tool name, status: running; the matching end event replaces /status (completed or failed) and /finished_at. Pairing by Hermes's call id when the event has one, otherwise first-in first-out per tool name. detail is {"preview": <the preview text Hermes would have shown>} and nothing else (never tool results). The method returns None so Hermes sends no progress bubble. Ruling for docs/DECISIONS.md (03 run_step_detail.v1 names no fields; spec gap).
    • supports_status_text is true; set_status_text replaces /status_line. supports_draft_streaming is true; send_draft replaces /last_text_excerpt with the draft text. render_message_event keeps Hermes's default and also sets /last_text_excerpt from text parts. send_typing and stop_typing do nothing. on_processing_start does nothing.
    • send(chat_id, content, reply_to, metadata): createMessage in the thread of the run with run_id, reply_to_message_id when reply_to is a message id, and a client_msg_id written to the outbox before the first attempt (SDK outbox with its store in the state directory, M2-29 note 13). splits_long_messages = False; MAX_MESSAGE_LENGTH from hermes_adapter.max_message_bytes; a longer answer is cut at a UTF-8 boundary and the cut is logged with the run id only. A send for a thread without an active run of this adapter returns a failure result and sends nothing.
    • edit_message(..., finalize) on an own answer: buffer; updateMessage once at finalize=True or at the end of the run.
    • on_processing_complete(event, outcome): completed, failed with runtime_error (Hermes reported an error) or adapter_error (the adapter failed), or cancelled after Design note 13; finishRun after every createMessage and updateMessage of the run, with metrics (token counts) and costs per model from Hermes's usage report (provider, model, token counts, amount_micros when Hermes estimates one, currency). Ruling for docs/DECISIONS.md: currency is the code Hermes's usage report states; when it states none, USD, the currency of the prices the pinned version ships (verify in the M2-32 record). No live /metrics or /cost patches: 17.3 names no Hermes surface for per-call usage.
    • Title: after the first completed run of a session with session_mode = new of an operator run, getThread and setThreadTitle with If-Match and the Hermes session title (Runtime.session_title) when Hermes has one; 409 title_manual, 412 version_conflict and 403 end the attempt without retry; guest runs never set titles (09 12 points 3 to 5).
  11. Follow-ups (09 7, 17.5). Capability followups is steer or queue from the configured display.platforms.<platform_key>.busy_input_mode, absent at interrupt (then degraded with busy_mode_invalid). An agent.followup reaches on_followup (Design note 5). For a running run at steer: read its content from getRunContext (message_id of the payload), Runtime.steer, patch /followups/<message_id>/state to delivered, and to absorbed when Hermes reports the text taken into the turn (surface of the M2-32 record; without one, at the end of the turn that received it). For a run in waiting_input it is the answer to the open card: the SDK resumes the run with the follow-up's token (resumeRun, M2-29 note 9), then on_followup disables the active components of the open card with updateComponent (state: disabled) and passes the text as the answer (09 11.6). At queue the server sends agent.followup only for a run in waiting_input (04 15.4 point 5; M2-11 D-M2-11-2). In both modes that event is the answer to the open card as described above. A follow-up for a running run at queue is acknowledged and not passed to Hermes. Ruling for docs/DECISIONS.md: the absorbed signal, the queue case and the answer to a waiting card in both modes.
  12. Cards and interactions (cards.py; 09 11, 17.3). Ruling for docs/DECISIONS.md: every card of the adapter is a createMessage with kind = card, run_id and single_use components, followed by awaitRunInput with the question's first line as status_line:
    • send_clarify: buttons or a select with custom_id cl:<id>:<idx>, restricted_to: run_starter.
    • _send_exec_approval_prompt: the four buttons of 09 11.5 with custom_id appr:<id>:<choice>, values once, session, always, deny, required_capability: approve_actions except deny, restricted_to run_starter (owner for always).
    • send_slash_confirm: buttons "Once", "Always", "Cancel" with custom_id sc:<choice>:<id>, the "Always" button restricted to owner like always (R-AG-12), the others to run_starter.
    • send_model_picker and send_choice_picker: one select.
    • An interaction.created for an open card: the SDK resumes the run with the interaction's token (resumeRun, M2-29 note 9), then on_interaction calls Runtime.resolve_callback(custom_id, values, principal_id) exactly once per callback id (the store records resolved ids, so a redelivery resolves nothing), then respondToInteraction with state: acknowledged and response.text the chosen label. No updateComponent after an interaction: the server disables the other single-use components in the same transaction (09 11.3); 17.3 "update the card" is aligned to this. A custom_id longer than component.custom_id_max_chars is a server 400 validation_failed, which ends the run failed adapter_error.
  13. Stop (09 8). run.cancel_requested is the SDK's (M2-29 note 9): it cancels the run's execute and then sends finishRun cancelled with the event's reason. The handler's part: on that cancellation execute calls Runtime.interrupt and drops every later Hermes callback of that run (no patch, message or card); follow-ups not absorbed are never reported absorbed. If Hermes reports no interruption (17.10 point on on_processing_complete), the run still ends through the SDK. A cancel for a run that is still in the inbox (not started) only closes the entry (M2-29 note 9; the server cancels a queued run itself), and no turn starts; a 409 invalid_state_transition answer (the server already ended it) ends the matter.
  14. Sessions (09 9.1, 9.5, 17.4; M2-12). Before start_turn: operator runs follow session_mode. new: Runtime.switch_session(thread, new) when Hermes already holds a session for the thread, after the turn created it reportAgentSession with external_session_ref (Hermes session id), started: true, and If-Match only when the payload carried agent_session. resume: continue; if Hermes's session id differs from agent_session.external_session_ref, switch to the server's reference like bind; after a switch, and after any turn in which Hermes's session id for the thread changed (compression), report the id in use with If-Match (17.4; M2-12 note 11). bind: switch to agent_session.external_session_ref; after Hermes took the session over, reportAgentSession with If-Match = agent_session.version and external_session_ref = the Hermes session id actually in use (the living end of a compression chain), without bind_failed (09 9.1, 9.5 point 3). If Hermes does not know it, start a new session and report it with If-Match and bind_failed: session_missing (fail open). Guest runs (no session_mode): a fresh Hermes session for the run and no report, because a guest run has no session (R-AG-04) and its context already holds the own earlier messages (09 4.6). 412 version_conflict and 409 conflict on a report are logged with run id and reason only; the run continues.
  15. Heartbeat (heartbeat.py; 09 4.8; D-L8; M2-20 note 3). Built with the SDK heartbeat module, sent at the interval the server announces. Ruling for docs/DECISIONS.md, inside the closed key set and forms of M2-20 note 3: state online, or degraded with details.degraded_reasons from busy_mode_invalid and billing_evidence_unknown; runtime_version the Hermes version; reported_billing_mode the actual mode and billing_modes_supported exactly that mode, both absent when unknown (Hermes proves one mode per profile process; D-L8 is for runtimes with both); billing_evidence of Design note 6; reported_tool_class; details.capabilities components: true, title: true and followups per Design note 11; details.active_runs (runs in running or waiting_input); details.adapter {name: "hermes-adapter", version}; details.runtime {name: "hermes-agent"}; details.protocol_version. Never rate_limit (M6-14), never project_readiness (runner only), never a value, path or content.
  16. SR9 header (07 SR9; M2-14 note 1). Every request the adapter makes for a run (getRunContext, downloadFile, patchRun, createMessage, updateMessage, respondToInteraction, updateComponent, reportAgentSession, getThread, setThreadTitle, getServedChannelConfig) carries Invocation-Token with the run's current token; startRun, resumeRun and renewInvocationToken carry the token in the body; heartbeat, discovery, JWKS, allow list and ack carry none. Through the SDK's run helper; Scope says when the SDK gets it.
  17. Installer (installer.py, hermes_config.py; 09 17.2, 17.5, 17.7; 10 10.1). Commands, each reading the configuration with Hermes's own reader before and after the change and printing a summary without values:
    • install --profile-home <dir> --server-url <url> --instance-id <id> --token-file <file> [--dpop-key-file <file>] --busy-input-mode steer|queue: places the plugin where the pinned version discovers it, writes the platform section with the keys of Design note 3 and display.platforms.<platform_key>.busy_input_mode explicitly; refuses interrupt; refuses to change an existing hermes_adapter.platform_key (D-AG-19).
    • switch-mode organization|personal [--credential-holder <principal id>]: organization requires the API-key variable of the profile environment to be set (checked by name, never read) and empties the OAuth variables; personal empties the API-key variable and writes Hermes's platform allow list (allowed_users_env) with exactly the credential holder; organization empties that list (17.7 layer 3). Variable names come from the M2-32 record. Afterwards it prints the actual mode Design note 6 now predicts and whether the gateway process serves several profiles; it never restarts a process and names the restart the operator must do (17.8). Ruling for docs/DECISIONS.md: no restart by the installer; values never read.
    • check: the same reads and prediction without changes.
  18. Persistence and restart (store.py; 09 4.3, 4.9, 15). Inbox, jti store and outbox are the SDK's files in the state directory (M2-29 notes 8, 9 and 13), and the restart rules are the SDK's: not-started runs are resumed (renewing expired tokens), runs that were running are not (the server ends them as interrupted, R-AG-17). store.py keeps only the resolved callback ids and the open card per run, in one SQLite file through the standard library; a write is durable before the Hermes callback or the send it guards.
  19. Logs (SEC-LOG-2, SEC-LOG-4 applied; BR-13). Logger bothy_hermes_adapter through Python logging; records carry run, thread and event ids, reasons and codes only: never a token, a key value, a DPoP proof, a message or context content, a display name, a file name or a transcript. Exceptions from the SDK are logged by type and problem code, never by their text.
  20. Fake-Hermes harness (tests/fake_hermes/; 09 16.1; exit criterion 5).
    • Pin, provisioning and isolation are M2-32's (hermes_harness): load_pin("tests/hermes_pin.toml") and provision(pin, root) with root from the test framework's temporary directory (outside the repository). Install sdk/python and this plugin into HermesInstall.venv. test_pinned_hermes_unchanged uses snapshot_checkout, installed_digest and assert_unchanged after provisioning and after the last scenario. Start the subprocess with HermesGateway, ThrowawayHomes and build_environment, adding only the adapter's canaries and COVERAGE_PROCESS_START, behind EgressRecorder and on macOS sandbox.py; started per test module and stopped by HermesGateway at teardown. Copy the plugin to the discovery place recorded in observations.json. Without the pinned Hermes the tests fail, never skip (P-09 applied).
    • Fake model: an HTTP server on 127.0.0.1 port 0 that speaks the provider protocol M2-32 used, answers from scripts (a tool call of a harmless toolset that the profile enables and that reads only inside the throwaway home, then text), records every request with the credential header kind and which canary it carried; no cost, no network.
    • Server: the FakeServer of M2-29 on 127.0.0.1 port 0 with the world of the tests below, signing tokens with its own key, recording every request and frame.
    • Coverage: the subprocess runs with COVERAGE_PROCESS_START pointing at this project's coverage configuration (coverage installed into the venv at the pinned version of the dev group) and the data is combined, so platform.py and hermes_config.py count towards fail_under.
    • Newest version: a pytest option --hermes-version=latest runs the same scenarios against the newest release (17.1); default is the pin; it needs an unpinned download, so it is never part of make check and not run in this ticket.
  21. Not built Hermes surfaces (Out of scope). send_image, send_document, send_voice and send_video return Hermes's failure result with the text "not supported by this server adapter yet" and send nothing; cron delivery is not registered; 09 17.3 and 17.9 mark these rows as "not built yet (follow-up ticket)".
  22. Rulings for docs/DECISIONS.md section 10 (one row each with date, decision, reason and place): D-M2-33-1 names, the two Hermes-importing modules, Runtime (note 1); D-M2-33-2 the new configuration keys (with hermes_adapter.instance_id as the expected iss and the agent ID from getMe as the expected aud), the token file and the state directory (note 3); D-M2-33-3 the SDK additions for the two allow-list failure kinds and the refusals in prepare (note 5); D-M2-33-4 the actual-mode table, everything else unknown, evidence method, the change during a run (note 6); D-M2-33-5 the tool class from toolsets, unknown is host (note 7); D-M2-33-6 the context format with nonce blocks, attachments, transcripts, left-out already_delivered, channel prompt and skills (note 9); D-M2-33-7 step detail, currency, no live usage patches, title rule (note 10); D-M2-33-8 follow-up signals, the queue case and the answer to a waiting card in both modes (note 11); D-M2-33-9 cards, restrictions of the slash confirmation, no updateComponent after an interaction (note 12); D-M2-33-10 session handling, the report after a bind and after a compression, and guest runs (note 14); D-M2-33-11 heartbeat content with one provable mode (note 15); D-M2-33-12 installer without restart and without reading values (note 17).

Tests (write them first)

Unit (tests/unit/, pytest, FakeServer of M2-29 on 127.0.0.1 port 0, fake Runtime, test clock where the SDK offers one; world: space Support with channel Allgemein whose operator is the agent Lotse (this adapter), Alex and Sam with trigger, a person writer with write only, Robin owner and credential holder for personal cases, the agent Texter, thread T in Allgemein):

  • test_only_two_modules_import_hermes, test_no_environment_access, test_version_matches_version_file.
  • test_config_requires_server_url_instance_id_and_token_file, test_discovery_instance_mismatch_stops (a getDiscovery with another instance_id stops the SDK loop before any ack; getMe of a non-agent principal stops connect()), test_config_rejects_unknown_key, test_token_file_value_never_in_repr_or_logs, test_max_message_bytes_default_matches_server.
  • test_actual_mode_table: Anthropic API key gives organization/api_key; Anthropic OAuth gives personal/oauth; both set gives personal; another provider with API key gives organization; no credential, cloud credentials, OAuth of another provider, an exception and an unknown answer each give unknown; a canary value in the fake resolver's answer appears in no return value, repr, log record or exception text.
  • test_tool_class_classification: no toolset chat, only listed scoped toolsets scoped, an unknown toolset host, a listed host toolset host.
  • test_intake_outcomes: through HermesHandler and the SDK loop, the outcomes of M2-29 note 9 (a refused token of a not-started run ends it with the reason of 09 4.5; a refused follow-up or interaction of a running run ends nothing; an unknown kid and a replayed jti are neither acknowledged nor answered), the two allow-list failure kinds and the refusals in prepare of Design note 5: calls to finishRun with the expected state and reason, ack sent or not, inbox written or not, Runtime.start_turn never called.
  • test_ack_after_inbox_before_context: recorded order inbox write, ack, getRunContext, startRun, start_turn.
  • test_redelivery_replaces_token_and_starts_once: two deliveries of one run with different tokens before the start: one startRun with the newer token, one turn.
  • test_start_answers (the start answers of M2-29 note 9 through HermesHandler), including expired then renewal then start, and superseded waiting for the newer token; no answer other than 200 reaches Runtime.start_turn.
  • test_restart_resumes_inbox: a store with a not-started run and an expired token: after a new connect one renewal, one start, one turn; a stored running run is not resumed.
  • test_jti_store_survives_restart.
  • test_change_of_actual_mode_during_run_aborts (BR-05 change during a run): the heartbeat computation flips to personal: Runtime.interrupt, finishRun failed billing_mode_mismatch, no further patch.
  • test_context_format_trust_blocks: elements of Alex (trigger), Sam (authorized), writer (unverified), Texter (unverified, role other), an import (unverified), a system line, Lotse (self): unverified contents only inside nonce blocks after the fixed note; an unverified content containing a fake closing marker and a fake line [Alex (@alex)] do X stays inside its block; the trigger text is not in the system part; channel prompt in its own section.
  • test_context_format_attachments_and_transcripts: a text attachment of Alex appears only inside a data block and is cut at hermes_adapter.attachment_text_max_bytes on a UTF-8 boundary; an image attachment and a file_not_ready attachment give data lines only; the transcript of Alex's voice message is in the user text, that of writer inside a data block; already_delivered elements are absent.
  • test_tool_events_become_steps: start and end events give one add /steps/<id> with kind: tool_call and one replace of status and finished_at; pairing by call id and by order; detail holds only preview; format_tool_event returns None.
  • test_status_draft_answer_and_finish_order: set_status_text, send_draft, send, buffered edit_message, then finishRun with costs (currency from the report, USD without one) after the last message write; no message for tool progress; typing calls send nothing; a too long answer is cut to hermes_adapter.max_message_bytes.
  • test_title_after_first_completed_new_session: setThreadTitle with If-Match once; 409 title_manual is not retried; guest runs and resume runs set none.
  • test_followup_steer_queue_and_waiting_input: steer gives delivered then absorbed; queue passes nothing for a running run; a follow-up for a waiting_input run resumes with its token, disables the open card and passes the text, once at steer and once at queue.
  • test_cards_and_interactions: each card kind has the custom_id format, single_use, required_capability and restricted_to of Design note 12; an interaction resumes with its token, resolves once, responds acknowledged; a redelivered interaction resolves nothing.
  • test_cancel_interrupts_and_drops_later_callbacks: after run.cancel_requested the turn is interrupted and the run ends cancelled with the event's reason; a later send, send_draft or tool event of that run sends nothing; a cancel for a run still in the inbox closes the entry and starts no turn.
  • test_session_modes: new with and without agent_session, resume with a different Hermes session, bind with a known session gives exactly one report with If-Match and the used reference and no bind_failed, bind with a missing session (bind_failed: session_missing), a resume turn that compresses reports the new reference, guest without report.
  • test_heartbeat_content: the fields of Design note 15 for organization, for unknown (degraded, billing_evidence_unknown, no mode, no supported modes) and for interrupt (busy_mode_invalid, no followups); names, versions and codes in the forms of M2-20 note 3; validates against Heartbeat of docs/api/openapi.yaml; no JSON null.
  • test_invocation_token_header_inside_runs_only: every run request of Design note 16 carries Invocation-Token, heartbeat, ack, JWKS and allow list do not.
  • test_logs_carry_no_content: a full run over content, display names, file names and transcripts with distinct canaries leaves none of them in the captured log records.

Fake-Hermes (tests/fake_hermes/, pinned Hermes in a subprocess, fake model, FakeServer; same world; every scenario asserts that the fake model received no request when no turn may start):

  • test_ag08_invalid_tokens_start_nothing (AG-08, adapter side): deliveries with a missing token, a wrong signature, alg other than EdDSA, a foreign aud and a different typ: for each finishRun failed invalid_invocation_token, ack, no startRun, no renewInvocationToken, no handle_message and no request at the fake model.
  • test_azp6_reference_adapter_never_starts_runtime (AZ-P6): additionally an expired token whose renewal the FakeServer refuses with 403 agent_invocation_denied, a repeated jti, a billing_mode unequal to the actual mode and a tool_class below the loaded class: no request at the fake model in any case.
  • test_ag11_actual_mode_unequal_target (AG-11, BR-05 adapter side): token personal while the profile resolves an Anthropic API key, and token organization while an OAuth canary is set next to an API key: finishRun failed billing_mode_mismatch, no turn.
  • test_unknown_actual_mode_gives_mismatch (plan: "unknown reported mode yields billing_mode_mismatch"): a profile without any credential and one with cloud credentials: finishRun failed billing_mode_mismatch, no turn, the heartbeat is degraded with billing_evidence_unknown and key_type: unknown and has no reported_billing_mode.
  • test_actual_mode_matches_used_credential (T-AI-08, RK-13): for API key only, OAuth only and both set, the mode the adapter sent in startRun equals the credential kind the fake model then received from Hermes for that turn.
  • test_happy_path_steps_drafts_answer (D-L7, R-AG-18): one invocation by Alex: tool step added and closed through format_tool_event, status_line and last_text_excerpt patched, exactly one createMessage with run_id, no message for tool progress, finishRun completed with costs, ack before startRun, and reportAgentSession with the Hermes session id and started: true.
  • test_ag13_followups_and_stop (AG-13, adapter side): with busy_input_mode steer a follow-up of Sam reaches the running turn (the fake model's next request contains it) and is reported delivered then absorbed; with queue the adapter reports followups: queue and passes nothing; a stop with an open follow-up interrupts the turn, ends the run cancelled user_cancelled and never reports the open follow-up as absorbed. If the M2-32 record shows no steer for platforms and 09 17.5 was adjusted to queue only, the steer half asserts that the adapter never reports steer and passes an arriving agent.followup to nobody; name this in the report.
  • test_ag15_concurrent_clicks_resolve_once (AG-15, adapter side): the fake model triggers a clarify; the card has two single-use buttons; the FakeServer delivers the interaction of Alex and answers Sam's concurrent click with 409 component_used, then redelivers Alex's interaction with a new token: resumeRun once with the interaction's token, the Hermes callback resolved exactly once with Alex's choice, one respondToInteraction, the turn continues and the run ends completed.
  • test_ag20_context_marks_unverified_content (AG-20, adapter side): the context of Design note 9's world plus a text attachment of Alex and a voice message of Alex with transcript: in the request the fake model receives, the texts of writer, Texter and the import appear only inside nonce data blocks after the fixed note, the attachment content of Alex appears only inside a data block, the transcript of Alex appears with the trigger's text, and the trigger text is separate from the system part.
  • test_br13_no_key_value_anywhere (BR-13): with an API-key canary in one throwaway profile and an OAuth canary in a second one, a full run, a heartbeat, an AG-11 mismatch and install, switch-mode and check of the installer: neither canary appears in any path, query, header or body of a request or in any WebSocket frame the FakeServer recorded, in any record of the logger bothy_hermes_adapter, in the installer's output or in any file of the adapter's state directory (the SDK's inbox and outbox and the adapter's store).
  • test_installer_busy_mode_and_switch: install writes steer and queue explicitly and refuses interrupt; Hermes's own reader reads both back; switch-mode personal writes the allow list with exactly Robin's principal id and empties the API-key variable; switch-mode organization empties the OAuth variables and the list; the printed prediction equals the mode Hermes then uses; no process is restarted.
  • test_plugin_found_by_real_discovery: Hermes loads the plugin through its plugin discovery from the throwaway profile, connect() sends identify with consumer equal to hermes_adapter.consumer.
  • test_hermes_stubs_match_pinned_version, test_tool_class_table_covers_pinned_toolsets, test_pinned_hermes_unchanged (exit criterion 5).

Test IDs of the plan row and related: AG-08, AG-11, AG-13, AG-15 and AG-20 (adapter sides, 09 16.2) through the Fake-Hermes tests named after them; "unknown reported mode yields billing_mode_mismatch" through test_unknown_actual_mode_gives_mismatch; BR-13 (10 15) through test_br13_no_key_value_anywhere; related: AZ-P6 (07 11.2) for the reference adapter, BR-05 adapter side (unequal, unknown, change during a run), T-AI-08 and RK-13 through test_actual_mode_matches_used_credential. Every scenario was red before its implementation; keep the red output for the report (M2-31 asks for the proof).

Commands that must pass, from the repository root:

  • cd plugins/hermes-adapter && uv run --locked pytest
  • make fmt-check, make lint, make typecheck, make test, make coverage, make licenses, make osv, make api-clients, make config-reference (no diff afterwards), make docs, make linkcheck
  • make check

Acceptance criteria

  • The plugin is found by the real discovery of the pinned Hermes version, connects as the agent with hermes_adapter.consumer, uses only public surfaces, and only platform.py and hermes_config.py import Hermes; no module reads the environment.
  • AG-08 and AZ-P6: no token, a bad token, a repeated jti, a wrong mode or a too low tool class never start a Hermes turn; the run fails with the reason of 09 4.5.
  • AG-11 and BR-05 (adapter side): an actual mode unequal to the target, an unknown mode and a change during the run end the run with billing_mode_mismatch; the predicted mode equals the credential Hermes really uses.
  • Events are acknowledged after the inbox, redeliveries replace the token, expired tokens are renewed, and a restart resumes not-started runs exactly once.
  • D-L7: tool events become run steps and format_tool_event returns None; status and drafts fill the live box; the answer is one message; the run ends with finishRun and costs.
  • AG-20: unverified content and every file content reach Hermes only inside nonce-marked data blocks after the fixed note; transcripts carry the trust of their message.
  • AG-13 and AG-15: follow-ups follow busy_input_mode, a stop interrupts and ends cancelled, and concurrent clicks resolve the Hermes callback exactly once.
  • Sessions follow session_mode and are reported; the heartbeat carries the fields of Design note 15; every run request carries Invocation-Token.
  • BR-13: no key value appears in any request, log, output or state file of the adapter.
  • The installer sets busy_input_mode, switches the mode and writes Hermes's allow list for personal, without reading a value and without restarting a process.
  • The pinned Hermes version is unchanged before and after the tests (exit criterion 5).
  • 09 17 and 21, 02 6, configuration.md, the threat model, RISKS.md, the READMEs and docs/DECISIONS.md describe what was built.
  • make check is green.

Definition of done

  • Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
  • Every new source file and stub carries the SPDX header tools/ci/license_check.py demands.
  • README of plugins/hermes-adapter and plugins/README.md updated (English): layout, installation, keys, mode switch and restart, inbox and restart behaviour, what the Fake-Hermes tests prove, the pin and its update path.
  • plugins/hermes-adapter/CHANGELOG.md entry under "Unreleased"; sdk/python/CHANGELOG.md if the SDK changed; root CHANGELOG.md if the root Makefile changed.
  • Rulings of Design note 22 recorded in docs/DECISIONS.md (one row each with reason); 09, 02 and the generated configuration reference changed in the same change.
  • An ADR only if you take an architecture decision beyond this brief (MADR 4 as in docs/adr/0000-template-madr-4.md, headings and text in English, next free number, make adr-index).
  • make check green.
  • The red team also checks the M2 points "forging or reusing the invocation token" and "faking the reported mode" with the adapter and its runtime as a boundary.
  • An independent code review over the diff, then the red team gate (docs/quality/red-team-gate.md, verdict JA or NEIN); on NEIN fix with a test per finding and have the delta checked again. Record both verdicts.
  • Land the change as AGENTS.md ("How work lands on main") describes: a pull request from this branch, squash-merged only when review, red team and make check, ci-ok and the review threads are green; then git worktree remove.

Report

Write to {{REPORT_PATH}}:

  1. First line exactly Verdict: DONE or Verdict: BLOCKED <reason>.
  2. Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of make check. Also the red output of every scenario before its implementation, and the pinned Hermes version with its digest before and after the tests.
  3. Review and red team verdicts and the findings fixed.
  4. What is not proven (for example the newest Hermes version, the adapter against the real server instead of the FakeServer, a credential resolution other than the tested ones, the effect of the markers on a real model).
  5. Follow-ups that could not be done in this scope, each with its reason (at least the not built rows of Design note 21 and the server side of getAgentTriggerers), every SDK change made, every name that differs from this brief, and spec gaps found.

Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.