# Hermes callback adapter

Suzo's native Hermes bridge owns a documented Hermes TUI gateway over JSON-RPC stdio. Each managed conversation has one gateway process, one private Hermes home and one MCP inventory. It preserves that conversation's native context, tools and manual approval routing. It never resumes a saved transcript in another process or attaches to an unrelated CLI/Desktop session.

Use `existing_session` for a task explicitly opened in this bridge. It remains the same live task for subsequent incoming requests. Use phone-approved `new_session_per_call` for a fixed profile that creates a separate conversation and child credential per incoming request. An already-running external Hermes process is not attachable through this managed stdio adapter; keep that owner unavailable rather than substituting it. Hermes's official [programmatic integration](https://hermes-agent.nousresearch.com/docs/developer-guide/programmatic-integration) describes this native gateway; [MCP configuration](https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp) describes the process/profile-wide tool inventory.

## Private configuration

Build the server with `npm ci`, `npm run check`, `npm test`, `npm run build`. Use an installed Hermes Python environment with `tui_gateway.entry`; never use `hermes -z`, `--oneshot` or `--yolo`. The oneshot path bypasses approvals.

Create an owner-only directory and `0600` JSON files. JSON is valid YAML, so the fixed profile is written directly to each isolated home's `config.yaml`. Supply the actual installed provider/model, tool configuration and explicit `approvals.mode:"manual"`. A provider name or saved credential does not prove the selected model remains supported. No model fallback is configured by this adapter.

```json
{
  "model": {"default": "your-supported-model", "provider": "your-provider"},
  "approvals": {"mode": "manual"},
  "tools": {"cli": ["terminal", "file"]},
  "max_iterations": 40
}
```

The private bridge configuration:

```json
{
  "version": 1,
  "mode": "new_session_per_call",
  "profileId": "hermes-incoming-profile",
  "python": "/absolute/hermes-agent/venv/bin/python",
  "sourceDirectory": "/absolute/hermes-agent",
  "workspace": "/absolute/task-workspace",
  "stateDirectory": "/absolute/private/hermes-bridge/state",
  "profileConfigFile": "/absolute/private/hermes-bridge/profile.json",
  "providerFiles": [
    {"source": "/absolute/private/hermes-provider-auth.json", "name": "auth.json"}
  ],
  "bridgeTokenFile": "/absolute/private/hermes-bridge/bridge-token",
  "operatorTokenFile": "/absolute/private/hermes-bridge/operator-token",
  "listen": {"host": "127.0.0.1", "port": 8799},
  "timeoutMs": 25000
}
```

Generate distinct random bridge and operator tokens with at least 20 non-whitespace characters. Provider files are explicit copies of only `auth.json` and/or `.env`; they may contain provider credentials. Do not copy memory, transcripts or another task's scoped bearer. Existing Hermes global settings and sessions are untouched. A profile may explicitly include other MCP tools; `suzo_call` is reserved for this conversation's scoped connection. The gateway environment excludes inherited Hermes flags, task settings and MCP secrets.

Start the bridge:

```sh
node server/dist/hermes-bridge-cli.js serve /absolute/private/hermes-bridge/config.json
```

Startup opens no conversation or model turn. Save the printed `runtime_version` fingerprint and actual listener port. The fingerprint pins the fixed workspace, model/tool/approval configuration, executable paths and provider-file locations. Changed configuration requires a new state directory and deliberate receiver registration/phone approval. Updating the installed Hermes runtime also requires an operator compatibility check; the fingerprint does not hash its source tree.

## Register the shared receiver

Use the [shared receiver](runtime-adapters.md) with this owner. Its mode must match the bridge configuration:

```json
{
  "kind": "bridge",
  "runtimeSessionId": "hermes-incoming-profile",
  "runtimeVersion": "hermes-native-v1:copy-the-printed-fingerprint",
  "workspace": "/absolute/task-workspace",
  "transport": {
    "type": "http",
    "url": "http://127.0.0.1:8799/",
    "tokenFile": "/absolute/private/hermes-bridge/bridge-token"
  },
  "timeoutMs": 30000
}
```

Register using a Hermes contact credential. Keep the returned profile/session credential in the receiver state directory. Approve the exact owner on the phone; fresh mode additionally requires **Allow new conversations**. Publish only the shared callback receiver through an authenticated HTTPS destination, authorize that destination on Suzo, and configure/check protocol version 3. Keep the Hermes bridge listener local. A successful probe never opens a conversation; acceptance means queued work. The complete receiver configuration and destination policy are in [runtime adapters](runtime-adapters.md) and [runtime webhooks](runtime-webhooks.md).

For a fresh call, the receiver persists a random child bearer before creation. The bridge creates a new home and native session with the static [call MCP proxy](../server/src/call-mcp-proxy.ts), so schema discovery needs no remote authorization before child binding. Suzo provisions that actual native session, then the bridge verifies connected `suzo_call` tools in the native agent inventory before admission. Each child has a distinct gateway, home, native ID and bearer. The approved profile credential never enters a child.

## Existing managed task

Set bridge/receiver mode to `existing_session`. Explicitly create the original task once:

```sh
node server/dist/hermes-bridge-cli.js operator /absolute/private/config.json open
```

Use the returned `runtime_session_id` as the receiver's `runtimeSessionId`, then register and approve that exact task. Bind only its returned session token through a `0600` payload file:

```json
{"runtime_session_id":"actual-native-stored-session-id","serverURL":"https://your-suzo.example","token":"approved-session-token"}
```

```sh
node server/dist/hermes-bridge-cli.js operator /absolute/private/config.json bind /absolute/private/bind.json
```

This installs/reloads the MCP connection only inside this task's dedicated gateway. It rejects replacement with another bearer. The original task can receive explicitly authorized operator prompts through `operator ... submit /private/prompt.json`, whose payload is `runtime_session_id`, a unique `request_id`, and `text`. Receiver admission still accepts only the fixed Suzo instruction and original request UUID.

## Human approvals

The callback bridge credential cannot read private task output, submit arbitrary prompts or answer approval requests. The separate operator credential controls only the local `/operator/` channel:

```sh
node server/dist/hermes-bridge-cli.js operator /absolute/private/config.json status
node server/dist/hermes-bridge-cli.js operator /absolute/private/config.json respond /absolute/private/response.json
```

Status shows native pending approval/clarification requests and bounded task output. A response file names `runtime_session_id`, `method` and `params`. Supported methods are `approval.respond`, `clarify.respond`, `sudo.respond`, `secret.respond` and `session.interrupt`. For example, the human can deny a pending command with `{"runtime_session_id":"actual-id","method":"approval.respond","params":{"choice":"deny"}}`. Preserve Hermes's native response fields for other request types. The adapter never automatically answers, escalates or approves. Private provider diagnostics stay in each home's bounded `native-stderr.log`.

## Admission and recovery

Inspection uses only `session.active_list` against the live native ID/key; it never creates, resumes, attaches, submits or answers approvals. Admission checks the exact owner, workspace, native tool inventory and manual policy, then saves `dispatching` before `prompt.submit`. The native `status:"streaming"` acknowledgment alone is insufficient. The bridge waits for `message.start`, reads the native live turn through `session.activate`, and verifies the exact submitted instruction and session/workspace before saving `admitted`.

Hermes does not return an autonomous turn UUID. Suzo's receipt combines the actual native session ID and the unique JSON-RPC submit ID, backed by the observed native start and exact-input readback. It proves admission/start, not successful model work, tool completion, provider media or physical ringing.

Exact retries return the saved receipt without another submit. Restarted or uncertain create/dispatch entries never create a replacement or replay a turn. A bridge restart loses its live native processes and reports those original owners unavailable; inspecting a profile does not restore them. Retain the journal/private home, reconcile original work, and deliberately establish/register a new owner when necessary. Do not delete state merely to retry an uncertain call. Shut down the bridge and receiver together; no automatic transcript resume or unattended approval recovery is attempted.

## Verification

Isolated tests exercise probe purity, exact matching, credential/process isolation, tool rejection, acknowledgment uncertainty, restart deduplication and operator authorization. Run the opt-in real Hermes/model/MCP check after building:

```sh
server/node_modules/.bin/tsx scripts/hermes-native-smoke.mts /absolute/private/hermes-config.json
```

It uses the selected real Hermes/provider model with a new private home and actual Suzo MCP tools, against an isolated Suzo service and synthetic phone/media fixture. It never contacts a real phone and does not establish physical calling. Physical verification separately requires phone approval, a reachable receiver, native call admission, audible provider speech, interruption and hangup evidence.

## Existing canonical gateway conversations

For a conversation already owned by Hermes 0.22's canonical gateway, use the separate [native session plugin](../hermes/suzo-native-session/__init__.py). This path preserves an existing messaging conversation, including its Telegram identity, context, tools and permission policy. It does not move that conversation into the managed bridge described above. It requires the supported `hermes-gateway-v1`, `durable-admission-v1`, explicit endpoint discovery and the installed MCP 2/httpx2 client API. Check the installed Hermes version and these capabilities before installation.

The plugin registers `suzo_` native tools from the actual Suzo MCP schemas. Every invocation verifies the trusted runtime's profile home, native session ID, route key, working directory and explicitly selected approval policy before reading a scoped credential or opening a private MCP connection. Scope fields in prompt text or tool arguments confer no authority. Other conversations may see the wrapper names in a shared toolset, but their calls fail closed before credential access/network dispatch. The older shared MCP bearer is never overwritten. After strict enrollment, that older contact bearer cannot consume the scoped call.

The expected approval mode is configurable: `manual` by default, or explicitly selected `smart`/`off` to preserve an existing Hermes policy. The adapter neither changes that policy nor sends approval responses. A changed policy fails closed until the destination is reviewed and registered again. The managed fresh-conversation bridge currently supports manual policy only; this is its implementation boundary, not a general requirement that Suzo replace a harness's permissions.

Example owner-only plugin configuration:

```json
{
  "version": 1,
  "agent_id": "existing-contact-UUID",
  "profile_home": "/absolute/hermes-home",
  "runtime_session_id": "actual-original-native-session-ID",
  "session_key": "agent:profile:telegram:dm:chosen-chat-ID",
  "workspace": "/absolute/existing-workspace",
  "expected_approval_mode": "manual",
  "server_url": "https://your-allowed-suzo-origin.example",
  "contact_token_file": "/absolute/private/existing-contact-token",
  "session_credential_file": "/absolute/private/receiver-state/session.json",
  "bridge_token_file": "/absolute/private/native-bridge-token",
  "operator_token_file": "/absolute/private/native-operator-token",
  "state_directory": "/absolute/private/native-session-state",
  "port": 8647,
  "toolset": "mcp-prism"
}
```

The toolset must be one intentionally enabled for this task; `mcp-prism` is only an example for an existing Hermes MCP server named `prism`. Do not grant a broader toolset merely to expose the wrappers. The contact-token file is used only for read-only schema discovery. The session credential is the shared receiver's saved owner file; the plugin verifies its contact, exact native owner, workspace and existing-session mode on every invocation. It may be registered before approval, but Suzo still enforces explicit phone approval for call operations.

Reviewable migration sequence:

1. Run `python3 hermes/test_native_session.py`. Read the original runtime's canonical discovery, route and session information without resuming or submitting. Verify the exact current native ID, route key, workspace, provider/model and existing approval policy. Keep the existing contact and legacy receiver intact.
2. Prepare a new private plugin/state configuration with that exact owner and deliberately chosen policy. Review the concrete **Call destination** with the human. An old hostname may allow ordinary REST while failing MCP deployment-host validation; verify initialize, schemas and read-only status against the actual allowed origin without changing global credentials.
3. Copy `hermes/suzo-native-session/` into the existing profile's supported plugin directory. Set only this plugin's `settings.configuration_file` to the private configuration file and enable/reload it through Hermes's supported plugin workflow. The plugin has no tool-override capability and starts no model turn. Use a separate local port so the legacy receiver can coexist during review. A gateway restart, if required by the installed version, is a separately scheduled operation after its existing work is idle.
4. Point a shared `existing_session` receiver at this loopback HTTP bridge, using the actual original native session ID and workspace. Inspect it and pin the printed `runtime_version` fingerprint. Register using the existing contact credential. This proposes the original task; it does not approve or replace the contact's binding.
5. After the human explicitly approves the reviewed destination on the phone, the saved receiver session credential becomes usable by its guarded native tools. Start the shared receiver, authorize its reachable HTTPS callback destination and register/check it as version 3. Preserve the previous receiver/configuration for rollback. No probe calls the phone or wakes a session.
6. Only during a separately authorized physical test, verify the original conversation's actual native admission, scoped tools, audio, interruption, cancellation, hangup and transcript receipt. A new plugin file or successful read-only MCP check does not establish this result.

The plugin bridge uses only `session.info` and `config.get` plus read-only route/ledger inspection on probes. Admission explicitly attaches the existing owner with canonical `session.resume`, submits the same request UUID as native `submission_id`, and persists the returned real native admission ID, authority epoch, sequence and execution generation. It waits until the durable receipt is `started` or `terminal` with an execution generation; a merely queued receipt is not reported as started. If the Suzo request stops waiting while input is still queued, it requests native `prompt.cancel`. A start racing cancellation remains uncertain and must be reconciled against the original request; it never creates a replacement call.

The local journal pins the complete configuration. Exact admitted retries reuse the real native receipt. Interrupted dispatch is retained as uncertain and never automatically resubmitted, even though the native gateway also deduplicates submission IDs. Inspect the original native admission through `prompt.receipt` and the private `/operator/status` journal before deliberate reconciliation. The operator token can read this status; it cannot authorize a new owner, replace a credential, submit arbitrary model prompts or answer native approvals through this plugin. Plugin unload stops the listener and cancels still-queued admission where possible; it does not terminate the existing Hermes gateway or another conversation.

## Proposed follow-current-chat destination policy

The shipped strict plugin pins one exact session. If `/new` changes the chosen Telegram chat's route to a new native session, it reports `native_binding_changed`; it does not silently redirect calls. A user may reasonably prefer a call to follow the current session in one chosen Telegram chat. That needs an explicit server/phone policy, not a runtime adapter guessing which transcript was used most recently.

The proposed policy would approve a stable native route descriptor: harness and runtime/profile identity, messaging platform, chosen chat/thread ID, fixed workspace/tool profile and expected approval policy. At each incoming request, the native authority would resolve that route's current session and return its exact ID plus a route revision. Suzo would snapshot that ID/revision and mint a new call-scoped bearer for that call. The original runtime would retain that session's existing context; it would not create a fresh conversation merely because the phone rang.

This is distinct from the current `new_session_per_call` factory, whose unique-conversation rule forbids reusing a conversation on another call. A follow-route implementation must support repeated calls to an existing conversation with fresh credentials, fence route changes during admission, stop when the route is suspended/unavailable, preserve approvals and reconcile uncertain native admissions. Its approval screen must say which chat it follows and that `/new` changes future call destinations. It must never use global recency across Telegram chats, projects, harnesses or profiles. Exact-session remains the default; follow-current-chat and create-new-per-call are explicit choices. No such follow-route enrollment or automatic destination change is implemented by this patch.
