Hermes Conduit Notifier is the open-source Hermes plugin that delivers lifecycle notifications to the Hermes Conduit iOS app. It observes normal Hermes hooks and sends small HTTPS events to the Conduit push relay.
The plugin does not contain an Apple Push Notification service key, dashboard credentials, or access to your Hermes gateway. (Its only dashboard routes hand Conduit short-lived Gemini Live tokens, start GPT-Live sessions on the host's ChatGPT subscription, relay Grok Live calls with the host's SuperGrok sign-in, run its voice web lookups, read memory and personality for voice, save live voice transcripts to this host's session history, ending each call's session there so session hooks and memory see it, and open short-lived grants that let an Apple Watch call run lookups and Hermes jobs through the relay; see below.) Apple credentials remain on the central push relay, so self-hosted users never need to copy a shared signing key onto their gateway.
Run these commands on the machine where Hermes is installed:
hermes plugins install kaishi00/hermes-conduit-notifier --enable
hermes gateway restartIn Hermes Conduit, open Settings > Notifications, enable notifications, and create a pairing code. Claim it from the matching Hermes profile:
hermes conduit-push pair XXXXX-XXXXXPair additional profiles independently:
hermes -p coder plugins enable conduit_push
hermes -p coder conduit-push pair YYYYY-YYYYYPairing codes expire after ten minutes and can only be claimed once.
With plugin 0.24+ Conduit pairs the active dashboard's profile by itself: it
creates the code and hands it to POST /api/plugins/conduit_push/pair?profile=
over your signed-in dashboard connection (see Pairing from Conduit).
# Show status without printing the credential
hermes conduit-push status
# Send a local test event through the relay
hermes conduit-push test
# Redact chat text from pushes (see Privacy and security)
hermes conduit-push redact on
# Revoke this profile's credential
hermes conduit-push unpairUpdate the plugin and restart Hermes:
hermes plugins update conduit_push
hermes gateway restartThe dashboard imports the plugin's routes once, so restart the dashboard as well (or let Conduit do it: Settings > Conduit plugin > Update runs Hermes' own plugin update, the gateway restart and the dashboard restart).
The plugin currently emits notifications for:
- approval needed
- clarification or other input needed
- response ready, once the turn has ended: a turn that failed sends only its failure notice, not Hermes' error text as a reply (plugin 0.35+)
- failed turns
- completed background tasks: a delegated task that ends while the turn that started it is still running is part of that turn's reply and sends nothing of its own (plugin 0.35+)
- calendar and reminder changes Hermes asks your phone to make (see Calendar, Reminders, Focus and phone status): a notification to approve, or a silent wake for adds you let Hermes make without asking
- call requests: a job you asked to be called about has ended, Hermes asked to call you, or (alert calls) Hermes has waited a minute on your approval or answer, or a turn failed (see Hermes calls you)
An exact [Silent] assistant response does not emit a completion notification. Reply and task bodies are sent as plain text: Markdown fences, emphasis, headings, link targets and table rules are dropped (plugin 0.35+).
A cron run notifies like any other turn, with two exceptions:
- A run whose reply is Hermes' cron silence marker doesn't notify, by the same rule Hermes uses to skip delivering it:
[SILENT](orSILENT/NO_REPLY) as the whole reply, on its own first or last line, or opening the reply ([SILENT] No changes since yesterday). A marker mid-sentence is real content and still notifies. - You can turn a job's notifications off in Conduit's Cron tab. Its runs then send no reply or failure notification (and no automatic alert call; a call you asked for on the job still rings). The muted job ids live in the profile's
conduit-cron.json;GET /api/plugins/conduit_push/cron/notificationslists them ({"ok": true, "muted": [...]}) andPUTwith{"job_id": "...", "notify": false}changes one. Capabilitycron-notifications.
The plugin tells a cron run by its task id (cron:<job id>:…) or its classic session id (cron_<job id>_<date>_<time>).
Beside notifications, the plugin keeps a Live Activity up to date while a turn you sent from Conduit runs (see Lock Screen progress).
Each notification is titled with the session's title when it has one (for example a Telegram topic name), so you can tell which chat it came from: a reply shows the session title, and the others prefix it (Approval needed · Infra). A Conduit Bot Chat, whose title is always Bot Chat, is titled with its bot's profile name instead, so you can tell which bot replied. Untitled sessions keep the generic copy. With previews off the title is not shown; redact on or end-to-end encryption keeps it from reaching the relay.
The iOS app controls which categories are enabled, whether notification previews are shown, whether completion sounds play, and whether approval and input-needed notifications play a sound (on by default).
- Pairing creates a revocable, profile-scoped credential.
- The credential is stored in the profile-aware Hermes home as
conduit-push.jsonwith mode0600on supported systems. - Authorization credentials are never written to logs or printed by
status. - Hook callbacks enqueue bounded events; HTTPS delivery runs on a background worker and does not block the agent loop.
- Event titles and bodies are length-limited before delivery.
- Lock Screen previews are disabled by default in Hermes Conduit.
- End-to-end encryption (plugin 0.5+, relay 0.5+, a Conduit build with encrypted notifications): Conduit creates a secret for each pairing on the phone and hands it to this profile over your Hermes dashboard connection, so it never passes through the relay. From then on every notification's title, body, profile, session and decision card is sealed with it (ChaCha20-Poly1305, keys derived with HKDF-SHA256), the phone decrypts it in a Notification Service Extension, and clarify answers come back sealed for this profile. The relay and APNs see only routing data: the event type, opaque ids, a keyed thread token and timing. A pairing that has a key never sends plaintext content again, and only accepts clarify answers sealed by the phone.
hermes conduit-push statusshows whether it's on; Conduit turns it on by itself when it connects to this host. Runhermes conduit-push test --corruptto check that the phone shows only generic text for a push it can't verify. The secret is only as private in transit as your dashboard connection (HTTPS or a private tunnel is recommended). hermes conduit-push redact onkeeps chat content from leaving the gateway: events carry no title or body text, approval cards say "Hermes needs your approval. Open Conduit for details.", and clarify cards replace question text with generic copy. The fields needed to answer stay (session key or request id, question ids, choice labels), so cards raised while the app is backgrounded remain recoverable and answerable. Clarify choice labels still transit the relay because the answer is one of them.statusshows the current setting;redact offrestores full content, including for events still waiting in the local delivery queue.
The public relay URL is part of the client protocol. APNs signing material (the .p8 key) is never committed — see the relay directory below for how to deploy your own.
The push relay is the server component that receives events from Hermes gateways and delivers them to iOS devices via APNs. The source lives in relay/.
Architecture: Hermes plugin → HTTPS → relay → APNs → Conduit app
The relay handles device registration, pairing codes, per-installation preferences, rate limiting, and idempotent event delivery. It uses only the Node.js standard library (no npm dependencies).
A self-hosted relay only works with a Conduit build you sign yourself. APNs only accepts notifications for the App Store app (com.milim.relay) when they are signed with a key from its developer team, so a relay running anyone else's key can't reach the App Store build, even if you enter its address under Settings > Notifications > Push relay. To self-host end to end you need your own Apple Developer account, a build of Hermes Conduit under your own bundle id, and an APNs key for that bundle id in the relay below (APNS_TOPIC must be your bundle id). If you only want the relay operator to stop seeing your content, you don't need to self-host: with end-to-end encryption on (see Privacy and security) the shared relay only sees routing data.
cd relay/deploy
# 1. Copy and fill in environment
cp .env.example .env
# Edit .env with your PUBLIC_URL and Apple developer credentials
# 2. Place your APNs signing key
mkdir -p secrets
cp /path/to/AuthKey_XXXXXX.p8 secrets/apns-production.p8
# 3. Build and run
docker compose up -dThe relay listens on port 9120. Put it behind an HTTPS reverse proxy (the relay validates that PUBLIC_URL uses HTTPS). If the proxy sends X-Forwarded-For, set TRUST_PROXY=1.
The plugin sends relay credentials only over HTTPS with a trusted certificate (plain HTTP only to the same machine), and never follows a redirect. You can pair over plain HTTP against a LAN or Tailscale address, for example hermes conduit-push pair XXXXX-XXXXX --relay-url http://100.x.y.z:9120: the relay answers with its HTTPS PUBLIC_URL, and the plugin saves and uses that from then on. Pair that way only on a network you trust: whatever answers on that address chooses the HTTPS relay this host uses from then on.
What stays secret:
| File | Contents | Gitignored |
|---|---|---|
.env |
APNs key ID, team ID, topic, public URL | Yes |
secrets/*.p8 |
Apple Push Notification signing key | Yes |
data/relay.json |
Installation records, gateway credentials | Yes |
See relay/deploy/.env.example for all required environment variables.
Check retained installation capacity without changing the data file. From the repository root, pass the mounted JSON path directly:
node relay/src/storage-status.mjs relay/deploy/data/relay.jsonOr, from relay/deploy, inspect the running Compose service:
docker compose exec conduit-push npm run --silent storage:statusThe command prints only total, active, inactive, and maximum installation
counts. Inactive installations remain in the store and still consume slots:
automatic APNs deactivation and repeated uninstall/reinstall cycles count
toward the installation limit (50,000 by default, RELAY_MAX_INSTALLATIONS),
which the printed maximum reflects. Review counts before scheduling offline data
maintenance; the relay never deletes installation records or their
credentials automatically. DELETE /v1/installations/:id deactivates an
installation and does not free its slot.
For offline reclamation, storage:prune-inactive previews the number of
eligible records by default. It removes only installations that have been
inactive and unchanged for at least 30 days, have no gateway credentials,
and have no pairing or pending-decision references. Active, recent, legacy
gateway-bound, and referenced records are preserved; other store sections
are left unchanged. Malformed state causes a generic failure without writes.
From relay/deploy, stop every relay writer before previewing or applying:
docker compose stop conduit-push
docker compose run --rm --no-deps conduit-push npm run --silent storage:prune-inactiveReview the counts, then explicitly apply and restart:
docker compose run --rm --no-deps conduit-push npm run --silent storage:prune-inactive -- --apply --relay-stopped
docker compose start conduit-pushThe command creates an exclusive, exact-byte relay.json.backup-* beside
the data file before atomically replacing it. Backups contain credentials
and private messages: protect them like the original data file. The
--relay-stopped flag acknowledges the offline requirement; it does not
stop running writers. A byte comparison detects changes before replacement,
but cannot make concurrent maintenance safe. Preview and zero-eligible runs
create no backup and change no files. For a direct Node invocation, use
node relay/src/storage-prune-inactive.mjs PATH_TO_RELAY_JSON and the same
explicit flags when applying. Reclamation is operator-run, never automatic;
gateway-bound inactive records still require separately reviewed maintenance.
| Method | Path | Purpose |
|---|---|---|
| GET | /healthz |
Health check |
| POST | /v1/installations |
Register a device |
| PUT | /v1/installations/:id |
Update device token, PushKit token (voip_token, null clears it), the paired Apple Watch's PushKit token (watch_voip_token, 0.10+), location push token (location_token, 0.12+) / preferences |
| DELETE | /v1/installations/:id |
Deactivate a device |
| POST | /v1/installations/:id/pairings |
Create a pairing code |
| POST | /v1/pairings/claim |
Claim a pairing code (gateway side) |
| POST | /v1/events |
Deliver a notification event |
| DELETE | /v1/gateways/current |
Revoke a gateway credential |
| PUT / DELETE | /v1/installations/:id/live-activities |
Register (push_token, session_ids) or remove (push_token) the Live Activity the phone started for a chat (0.15+) |
| POST | /v1/live-activity |
Report a running turn's progress (session_id, state, optional end); answers tracked (gateway side, 0.15+) |
| POST | /v1/watch-tools/grants |
Open a Watch tool grant for one call (gateway side) |
| GET | /v1/watch-tools/grants/:id/calls |
Long-poll for the grant's Watch tool calls (gateway side) |
| POST | /v1/watch-tools/grants/:id/results/:rid |
Answer one Watch tool call (gateway side) |
| POST | /v1/watch-tools/grants/:id/calls |
A Watch tool call, held open until the gateway answers (Watch side, the grant's relay key) |
| DELETE | /v1/watch-tools/grants/:id |
Close a grant (either side; always 204, so a wrong key learns nothing) |
| GET (WebSocket) | /v1/watch-audio/:id/host |
The gateway's side of a Watch call's audio (a grant opened with audio: true) |
| GET (WebSocket) | /v1/watch-audio/:id/watch |
The Watch's side of it (the grant's relay key) |
| POST | /v1/rings/:id/settled |
The phone or the Watch answered or declined a call that rings on both (0.10+; the ring's token from the call push) |
| PUT | /v1/rings/:id/session |
The phone stores the sealed session of a call the Watch answered (0.11+; the ring's token) |
| GET | /v1/rings/:id/session |
The Watch fetches it, waiting up to ?wait=20 seconds (0.11+; the ring's token as the bearer) |
Watch tool grants carry an Apple Watch call's lookups while the Watch can't
reach Conduit on the iPhone. Calls and answers are sealed with a per-call key
the relay never sees (ChaCha20-Poly1305, bound to the grant, call id and
direction), so the relay only forwards ciphertext. Grants live in memory,
last at most 30 minutes and 120 calls, and a restart drops them; the Watch
then falls back to the iPhone. A relay holding its maximum of grants answers a
new one with 503 watch_grant_capacity, and that call's lookups go through
the iPhone.
A grant opened with audio: true (relay 0.7+) can also carry the call's live
audio for GPT-Live and Grok on the Watch: the gateway and the Watch each open
a WebSocket, and the relay copies binary messages between them. They are
sealed end to end like the tool calls; the relay reads only a message's first
byte, which is never 0 (its own two-byte notices to the gateway: Watch
connected, Watch gone). Limits per socket: 64 KB a message, 96 KB/s averaged
over 5 s, 60 s of silence; bridges are capped per gateway and per relay (see
below).
A call rings on the Apple Watch too (relay 0.10+) when the phone registered the
Watch's own PushKit token: iOS doesn't pass a calling app's call to the Watch,
so the relay sends the call push to both, with topic APNS_WATCH_TOPIC
(default <APNS_TOPIC>.watchkitapp, then .voip). Both pushes carry the same
ring: an id, a token that only those pushes hold, and the url to settle it
at, since the Watch knows no relay address of its own. The device that answers
or declines posts {token, by: "phone"|"watch", outcome: "answered"|"declined"}
to /v1/rings/:id/settled, and the relay sends the other device a VoIP push
{conduit: {ring: {id, settled, by}}} that expires after a minute, so it stops
ringing. The first settle wins; a later one gets 409 with how the ring was
settled, and an unknown ring or wrong token gets 404. Rings live in memory for
10 minutes, and a restart drops them: the other device then rings out. A Watch
token APNs refuses as gone is forgotten, and the phone still rings. A Watch
push refused with DeviceTokenNotForTopic keeps the token and logs
check APNS_WATCH_TOPIC: the setting is wrong, not the token. The .p8 key
must be allowed to push to the Watch app's bundle id as well as the iPhone
app's: a team-scoped key is, but a topic-specific key that lists only the
iPhone app gets TopicDisallowed for every Watch push. The relay then keeps
the token and logs apns key not allowed for this topic. A Watch push that
APNs accepts logs watch voip sent.
A stop push that doesn't land isn't sent again: the other device rings out on
its own. The settle answer's notified says only that the other device has a
PushKit token at the relay, so a stop push went to it, not that it arrived.
A call answered on the Watch starts through the relay too (0.11+): the Watch's
link to the iPhone stays down under the system call screen, so it can't ask
Conduit there for the call's session. Its settle carries a sealed start,
which the relay forwards untouched in the phone's stop push (ring.start).
Conduit builds the session as for any Watch call and stores it, sealed, with
PUT /v1/rings/:id/session {token, sealed}; the Watch fetches it with
GET /v1/rings/:id/session?wait=20 and the ring's token as the bearer (200
with sealed, or 204 when nothing came in time). Both are sealed with a key
only the phone and the Watch hold, so the relay sees their size and nothing
else. A session can be stored only for a ring the Watch answered (409
otherwise), lasts 2 minutes in memory, and is at most 96 KB; the relay holds
32 MB of them at most, dropping the oldest first, and a ring takes two waiting
fetches at a time. An older relay ignores start, and the Watch then starts
the call over its link to the iPhone, as before.
The defaults are sized for the shared public relay with thousands of installations. Each bound is a backstop against floods, not a quota ordinary traffic reaches. Every one can be changed with the environment variable next to it: unset or empty keeps the default, and anything other than a positive integer stops the relay at boot.
| Bound | Default | Variable |
|---|---|---|
| Installations, inactive ones included | 50,000 | RELAY_MAX_INSTALLATIONS |
| Gateways per installation | 64 | RELAY_MAX_GATEWAYS_PER_INSTALLATION |
| Event IDs remembered per installation | 5,000 | RELAY_MAX_EVENT_IDS_PER_INSTALLATION |
| Event IDs remembered across the relay | 250,000 | RELAY_MAX_EVENT_IDS |
| Active clarify decisions across the relay | 1,024 | RELAY_MAX_ACTIVE_DECISIONS |
| Retained clarify decisions across the relay | 4,096 | RELAY_MAX_RETAINED_DECISIONS |
| Registrations per minute | 24 | RELAY_REGISTRATIONS_PER_MINUTE |
| Events per minute | 6,000 | RELAY_EVENTS_PER_MINUTE |
| Device updates that change state, pairing creations, and valid claims per minute | 240 | RELAY_DEVICE_CHANGES_PER_MINUTE |
| Decision answers and releases that change state per minute | 600 | RELAY_DECISION_ACTIONS_PER_MINUTE |
| Installation deactivations and gateway revocations per minute | 24 | RELAY_REVOCATIONS_PER_MINUTE |
| Live Watch tool grants across the relay | 2,048 | RELAY_MAX_WATCH_GRANTS |
| Live Watch tool grants per gateway (a new one closes the oldest) | 4 | RELAY_MAX_WATCH_GRANTS_PER_GATEWAY |
| Live Watch audio bridges across the relay (GPT-Live and Grok on the Watch) | 200 | RELAY_MAX_WATCH_AUDIO_BRIDGES |
| Live Watch audio bridges per gateway | 2 | RELAY_MAX_WATCH_AUDIO_BRIDGES_PER_GATEWAY |
| Watch tool calls per minute | 1,200 | RELAY_WATCH_CALLS_PER_MINUTE |
| Hermes call requests per installation per day (each new request counts, even one that rings no device) | 60 | RELAY_CALLS_PER_INSTALLATION_PER_DAY |
| Live Activities held in memory, relay-wide / per phone (the oldest is forgotten past either) | 50,000 / 8 | RELAY_MAX_LIVE_ACTIVITIES / RELAY_MAX_LIVE_ACTIVITIES_PER_INSTALLATION |
| Live Activity registrations per minute, relay-wide | 3,000 | RELAY_LIVE_ACTIVITY_REGISTRATIONS_PER_MINUTE |
| Live Activity reports for a watched turn per minute, relay-wide | 30,000 | RELAY_LIVE_ACTIVITY_UPDATES_PER_MINUTE |
| Live Activity registrations and removals per phone / per IP address, per minute (fixed) | 60 / 120 |
Event IDs only prevent a repeated delivery of the same event within 24 hours, so the relay keeps them in memory and never writes them to the data file. A restart forgets them, which costs at most one duplicate push; replaying an event after a restart needs that gateway's credential, which can send new events anyway. An installation at its event-ID bound forgets its own oldest ID, and at the relay-wide bound the installation holding the most IDs forgets its oldest, so neither bound rejects an event. A gateway's last-seen time and plugin version are written at most every five seconds, and on shutdown, instead of on every event. Each of those writes is still a full rewrite of the data file, so a busy relay spends one whole-file write per five seconds on them rather than two per event.
Installations, gateways, and clarify decisions are persistent. A new record is rejected with HTTP 429 when its bound is full; the relay never evicts existing credentials. A store already above a limit when upgraded keeps every record, but cannot add records of that kind until the operator raises the limit or removes records while the relay is stopped. Re-pairing a profile adds a gateway without revoking the previous one, which is why the gateway bound is generous.
The per-minute budgets are process-wide and separate for each class of action, so a flood of one kind cannot starve the others. Validation, capacity rejection, and per-client limits run before a budget is charged. Unchanged device updates, already-accepted events without plugin metadata, non-mutating decision responses, and repeated cancellations use no budget and do not rewrite the store; health checks and decision polling use none either. The per-installation limits of 30 events and 30 device updates per minute still apply.
Run exactly one relay process/replica per DATA_PATH. The relay caches its
state and rewrites the whole JSON file on every persistent change; atomic
replacement does not coordinate multiple writers, and sharing the file
between processes can lose updates. The budgets are per process. A hostile
flood can still saturate a budget or a bound, and installation IDs alone
cannot establish fairness between actors.
When you send a message from Conduit, it starts a Live Activity on the Lock Screen and in the Dynamic Island: what Hermes is doing (thinking, running a kind of tool, waiting for your approval or answer), how long the turn has run, how many tools it used and how full its context is. Conduit updates it itself while it is open; once you leave it, the plugin keeps it current through the relay (relay 0.15+), and the end of the turn ends it.
- The phone registers the activity's push token with the relay under the
chat's session ids. The plugin reports each turn it runs: phase changes,
tool starts, approvals and clarify questions, and each model request's
context use (
pre_api_request/post_api_request, which older Hermes versions may not have; the rest still works without them). - Reports are coalesced: at most one every 5 seconds per watched turn, the end at once. The relay answers whether the phone is watching; a turn nobody watches is asked about again only every 15 seconds, and a relay without the route turns reporting off for half an hour.
- Live Activity payloads are rendered by iOS itself, so they can't be sealed
end to end like notifications. What reaches the relay and APNs is coarse on
purpose: the session id, a phase, a tool category from a fixed list
(
terminal,files,web,browser,connector…, never a tool's name, arguments or output), a tool count and a context percentage. The chat's title stays on the phone. Withredact onthe tool category is left out too. - The relay keeps registrations in memory only (at most 8 hours, the longest iOS runs an activity). A relay restart stops updates; the activity then shows as out of date on the phone and Conduit ends it when you open it.
Current Hermes lets one clarify call ask several questions
(questions: [{qid, question, choices, multi_select}]). The plugin relays
the FULL batch to Conduit instead of collapsing it to the first question:
- The pushed decision carries
questions[]with the gateway qids, choices, andmulti_selectflags. The old collapsedquestion/choicessummary still rides along, so pre-0.3 Conduit builds keep rendering an answerable first-question card. - The relay stores per-question answers with first-answer-wins per qid: two devices answering the same qid resolve to one lock (the loser gets a 409), and the decision completes only when every qid is locked.
- Devices answer per question with
POST /v1/decisions/:id/respond {"question_id": "q…", "answer": "…"}and receive the remaining open qids back (POST /v1/decisions/:idis kept as a backward-compatible alias running the same handler). The legacy whole-decision body ({"answer": "…"}) still works for single-question cards, and on a batch it counts as the collapsed first question only. - Duplicate qid answers and released decisions are distinct outcomes:
409 already_answeredsettles only that qid as answered elsewhere, while410 decision_released(the native Desktop/TUI path resolved the whole clarify) tells Conduit to retire the entire pushed card. An unknown qid on a live decision is400 invalid_question_id, not a missing decision. - The structured decision and the notification are independent: when the
card cannot be delivered (decision_cards disabled, or an oversized batch
stripped by the APNs size guard, or APNs rejecting the send), the
ordinary input.needed banner is still delivered and the parked decision
is marked
deliverable=false, so the plugin immediately falls back to Hermes' native clarify path. - Bounds: the plugin and relay accept at most 8 questions × 8 choices per
batch (validation mirrored on both sides). That is the protocol/store
ceiling, NOT a guarantee that every valid batch fits an answerable card:
answerable-card capacity is bounded by Apple's ~4 KB APNs payload limit,
and therefore by the actual byte size of the question and choice text. A
valid batch whose serialized decision exceeds the payload budget is
never truncated or split — Hermes is still waiting on every qid, so a
partial card would collect answers for a batch that can never complete —
instead the WHOLE structured decision is dropped from the push (plain
input.needed banner, decision parked
deliverable=false) and the plugin falls back to Hermes' native clarify path. - The plugin returns the batch to Hermes exactly in the built-in tool's
result shape (
{"responses": [...]}, multi-select answers parsed back to lists). Protocol provenance comes from the original invocation: a one-entryquestions[]call is batch protocol even though it has a single question, while legacy scalar calls keep the scalar result shape. - Releasing a decision (native path won) is fired off the answer's critical path on a daemon thread, so a slow or unreachable relay can never delay the user's native answer.
Known limitation: the relay cannot see answers made natively (Hermes
Desktop/TUI answer through the gateway), and the gateway cannot see relay
answers. Whichever surface completes the WHOLE batch first wins the tool
call; the plugin then releases the parked decision (DELETE /v1/decisions/:id) so late device answers are rejected rather than
reported as accepted. A batch answered partly natively and partly by relay
stays open until the gateway's configured clarify timeout bounds it.
The relay allows up to 32 active unresolved decisions per installation and
1,024 across the relay (RELAY_MAX_ACTIVE_DECISIONS). A scalar decision is active until answered or cancelled;
a batch remains active until every question is answered or the decision is
cancelled. Completed and cancelled decisions preserve their answers and locks
for the two-hour decision TTL, within separate retained-record caps of 128 per
installation and 4,096 across the relay (RELAY_MAX_RETAINED_DECISIONS).
Active decisions count toward both retained caps. Since settled records stay
for two hours, those retained caps also impose a maximum admission throughput
of 64 records per installation per hour and 2,048 records per hour across the
relay, averaged over a full retention window. Decisions are stored in the data
file and a full batch record can reach about 13 KB, so the relay-wide caps
also bound how large a flood can make that file.
When any limit is full, a new clarify event receives HTTP 429
decision_capacity_exceeded. The relay sends no push and does not consume the
event ID. Retrying is caller-chosen: the shipped plugin logs the rejection and
does not retry automatically; a caller may explicitly retry the same event ID
after capacity becomes available and before the event is accepted. Hermes'
native clarify path remains available, and existing decisions remain
answerable, pollable, and cancellable.
The global limits apply across every installation. Because installation self-registration is open, one actor can create multiple installations, occupy the global active and retained pools, and keep refreshing them as records expire, denying new relay clarifies indefinitely. The relay cannot identify a shared actor or enforce fairness from installation IDs alone. Existing answers and Hermes' native clarify path remain available during saturation; the global caps are a bounded storage tradeoff, not a tenant fairness boundary.
Conduit's Gemini Live voice mode talks to Google directly from the phone, but the Gemini API key stays on your Hermes host. The plugin adds two routes to the Hermes dashboard, behind the dashboard's normal auth:
| Method | Path | Returns |
|---|---|---|
| GET | /api/plugins/conduit_push/gemini-live/status |
{ok, available, reason?, model} |
| POST | /api/plugins/conduit_push/gemini-live/token |
{ok, token, expires_at, new_session_expires_at, model, websocket_url} |
Set the key in the profile's .env (the same key Hermes' Gemini TTS uses):
GEMINI_API_KEY=... # or GOOGLE_API_KEY
CONDUIT_GEMINI_LIVE_MODEL=gemini-3.8-live # optional override
CONDUIT_GEMINI_LIVE_API_VERSION=v1alpha # optional; ephemeral tokens are v1alpha todayEach token is a Google ephemeral token: one use, locked to that model, valid
for 30 minutes, and it must open its Live session within a minute. Conduit
asks for a fresh token for every connection, capped at 20 per minute per
profile. The API key itself is never returned or logged. ?profile=<name> resolves another profile's key, as the
/api/audio/* routes do, and fails with 503 rather than falling back to the
default profile if it can't. Restart the dashboard (hermes gateway restart)
after updating the plugin so the routes mount.
Gemini Live can answer quick questions (weather, news, facts) with a web
search. Instead of Google Search, which Google meters separately, Conduit can
run those lookups on this host's own web search backend: whatever
hermes tools set up (SearXNG, Firecrawl, Tavily, and so on). Nothing extra
needs configuring on the plugin side.
| Method | Path | Returns |
|---|---|---|
| GET | /api/plugins/conduit_push/web-search/status |
{ok, available, reason?, backend?} |
| POST | /api/plugins/conduit_push/web-search with {query, limit?} |
{ok, query, results: [{title, url, snippet}]} |
Results are titles, URLs and snippets only (at most 5), and searches are capped
at 30 per minute per profile. A search that takes more than 20 seconds returns 504, and a backend's own error text stays in the Hermes log: Conduit only sees Hermes' configuration messages (such as "No web search provider configured") or a generic failure. ?profile=<name> searches with that profile's
backend and keys, as the Gemini Live routes do.
Gemini Live can use what this host's Hermes remembers, whichever memory setup
it runs, without Conduit knowing the backend. Both routes are read-only in the
sense that the plugin issues no write calls: no sync_turn, no memory tool. A
provider may still keep its own caches or bookkeeping while it recalls. A
saved call is written to memory once it ends instead, as a chat is (see
Call end).
| Method | Path | Returns |
|---|---|---|
| GET | /api/plugins/conduit_push/memory/context |
{ok, available, reason?, provider, recall, context} |
| POST | /api/plugins/conduit_push/memory/recall with {query} |
{ok, available, results} |
context is the built-in MEMORY.md / USER.md snapshot Hermes puts in its
own system prompt (respecting memory.memory_enabled and
memory.user_profile_enabled), capped at 8,000 characters. An external
provider named by memory.provider (Honcho, Mem0, Holographic, and so on) is
reached through recall instead: its own prompt block mostly describes tools the
voice model can't call. provider is
"builtin", the external provider's name, or null. When there is nothing to
use, available is false with reason "unsupported" (a Hermes without the
memory modules) or "disabled" (memory off, or nothing stored yet).
recall is true only with an external provider: the built-in store is already
all in context. Then POST /memory/recall returns the provider's recall for
query (at most 500 characters) as results, capped at 4,000 characters.
Without a provider it returns available: false and empty results, not an
error. Recalls and context reads are each capped at 30 per minute per
profile, time out after 10 seconds (504), and a provider's own error text stays
in the Hermes log. The provider is started once per profile (as the
conduit_voice platform, session conduit-voice) and restarted when
memory.provider changes. Calls into it run one at a time. If one hangs, that
profile's memory routes answer 503 at once until it returns, and other profiles
are unaffected. ?profile=<name>
reads that profile's memory, as the other routes do.
Conduit's voice modes can keep the agent's personality without reading stage directions or emoji aloud.
| Method | Path | Returns |
|---|---|---|
| GET | /api/plugins/conduit_push/personality |
{ok, available, text} |
text is the profile's SOUL.md as Hermes itself loads it (load_soul_md,
with its injection scan and legacy-protocol stripping), capped at 8,000
characters. available is false with empty text when there is no
SOUL.md, it is empty, or the Hermes is too old to have load_soul_md.
Reads are capped at 30 per minute per profile, unexpected errors are reported
by type only, and ?profile=<name> reads that profile's SOUL.md.
When Conduit delegates a spoken turn to Hermes, the plugin's pre_llm_call
hook sees Hermes' voice-live note on that turn and adds one line to the user
message: keep the usual personality through word choice and tone, but write
only words meant to be spoken, with no stage directions or narrated actions,
no sound effects, no emoji, and no markdown. Other turns are left alone.
This is the host half of hermes-agent#108940,
shipped in the plugin so Conduit doesn't wait on the upstream merge. It lets a
GPT-Live voice session bill your ChatGPT/Codex subscription instead of an OpenAI
API key. First sign in on the Hermes host with hermes auth and choose OpenAI Codex.
| Method | Path | Returns |
|---|---|---|
| GET | /api/plugins/conduit_push/gpt-live/status |
{ok, auth, available, reason, model, voice, source, api} |
| POST | /api/plugins/conduit_push/gpt-live/session |
{ok, auth, session: {id}, transport: {type, sdp}, source, voice, briefing_applied, greeting_applied} (plus model on the API path) |
The session route takes {"sdp": "<WebRTC offer>", "history": [...], "voice": "cove", "briefing": "..."} (history
optional, last 40 items kept; voice optional and, when given, used instead of subscription_voice; briefing optional, up to 32 KB, and added to the session instructions so the model has Conduit's rules before it speaks; greeting optional: absent, the model stays silent until the user speaks; "" or text, it greets the user first, saying the text when given, up to 200 characters; call_opening optional (0.17.1+): true for a call Hermes placed, so the model speaks first with the opening Conduit's briefing gives (without a briefing it only says it's calling, never guessing why) and any greeting is ignored; greeting_applied is then true too, as it means the model was told to speak first) and returns the SDP answer plus the voice it used. The plugin posts the
offer to the Codex voice service with the host's Codex OAuth token and ChatGPT
account id; neither is ever returned to Conduit or logged (a failure in Hermes' own exchange logs only its exception type). Status only checks that a
sign-in exists; the account's voice entitlement is checked when a call starts.
A sign-in, quota or connection failure is an error: it never falls back to
API billing. Sessions are capped at 10 per minute per profile.
Optional settings in the profile's config.yaml, the same keys upstream uses:
voice:
gpt_live:
subscription_model: gpt-live-1-codex
subscription_voice: cove
instructions: "" # extra persona sentencesWhen the host's Hermes ships the same exchange, both routes hand the request to
Hermes (source: "hermes") and keep serving the same URLs, so Conduit needs no
change. voice.gpt_live.auth is Hermes desktop's setting; these routes never
read it. A session request is billed to the subscription unless it names the
API key itself (below).
Conduit can also start GPT-Live on the host's OpenAI API key, through the
public GPT-Live API (POST /v1/live/sessions, the same one Hermes desktop's
voice_chat_mode: gpt-live uses). OpenAI bills that per minute of session
time (see OpenAI's pricing for gpt-live-1); the ChatGPT subscription is not
used. Background jobs still run on Hermes' own model, as they do on the
subscription.
The two paths are kept apart so nobody is billed for the one they didn't choose:
- Conduit has to ask for it by name. The session route takes
"billing": "api"; a request withoutbilling(every Conduit before this feature) or with"billing": "subscription"is a subscription call. Any other value is a 400. - The profile has to allow it.
voice.gpt_live.conduit_api_billing: trueturns it on; anything else (missing,false,"true",1) keeps it off and the request is a 403. An OpenAI key that is set for speech-to-text or TTS is not enough on its own. - No fallback, either way. A failed subscription call never becomes an API call, and a failed API call never becomes a subscription call. Every API-path error says the subscription was not used.
authsays which path ran. The session answer carries"auth": "api"or"auth": "subscription". Conduit drops the call before any audio when it doesn't match what it asked for.- A daily cap. A profile may start
voice.gpt_live.conduit_api_daily_callsAPI-billed calls per day (default 20;0turns API calls off, a 403). A call OpenAI refuses or never answers (a rejected key, an outage) gives its slot back; once OpenAI has said yes, the slot stays counted even if its answer turns out unusable, since a billed session may have started. The count is kept in memory, per dashboard process: a restart starts it again, and a dashboard run with several worker processes allows the cap in each. - What the host can't cap. The cap counts calls started, not minutes. Once a call is set up, its audio runs between Conduit and OpenAI without passing through this host, so the host can't end a call or see how many are open. Call length is limited by Conduit (a per-call time limit, 20 minutes by default), which is a client-side control: set a spending limit on the OpenAI key itself if you need a hard ceiling.
voice:
gpt_live:
conduit_api_billing: true # allow API-billed calls from Conduit
conduit_api_daily_calls: 20 # per profile per day
model: gpt-live-1 # the API path's model and voice
voice: marin # marin, cedar, quartz, ripple, vesper, willow, ...
# api_key: sk-... # else VOICE_TOOLS_OPENAI_KEY / OPENAI_API_KEY
# base_url: https://api.openai.com/v1The key is resolved the way Hermes desktop resolves it
(voice.gpt_live.api_key, then VOICE_TOOLS_OPENAI_KEY, then
OPENAI_API_KEY) and is never returned to Conduit or logged. base_url must
be an https URL with a host and no query or fragment. The shared keys only
ever go to api.openai.com on the standard port: a base_url on any other host needs its own
voice.gpt_live.api_key. Only OpenAI's GPT-Live contract is supported; other
services behind base_url (Azure Foundry, say) are untested and may expect a
different auth header.
OpenAI's answer doesn't echo the session, so on the API path
briefing_applied, greeting_applied and voice say what the plugin sent,
not what OpenAI confirmed. A 401 from OpenAI is reported as a rejected key and
a 429 is passed on as a 429; neither carries OpenAI's response body. The status route reports the API path under its own key, leaving
the top-level fields the subscription's:
{"ok": true, "auth": "subscription", "available": true, "...": "...",
"api": {"auth": "api", "enabled": true, "available": true, "reason": null,
"model": "gpt-live-1", "voice": "marin", "daily_limit": 20, "host": "api.openai.com"}}The Apple Watch's GPT-Live bridge always uses the subscription.
Grok Live is xAI's realtime voice model. Conduit reaches it through a socket on
this plugin: the plugin opens wss://api.x.ai/v1/realtime with the host's xAI
credential and relays frames both ways without reading or changing them. The
credential is the host's SuperGrok sign-in (hermes auth add xai-oauth) or, without one, XAI_API_KEY. That's the same order Hermes uses for its
other xAI endpoints, and neither credential is ever sent to Conduit or logged.
| Method | Path | Returns |
|---|---|---|
| GET | /api/plugins/conduit_push/grok-live/status |
{ok, available, reason, auth, model, voice, transport} |
| WebSocket | /api/plugins/conduit_push/grok-live/socket |
xAI realtime events, relayed |
The socket takes the dashboard's own WebSocket credential (?ticket=, as
/api/audio/speak-stream does) and an optional ?profile=. auth in the status
says which credential would be used: subscription or api_key. When the
plugin or xAI refuses a call, the socket closes with a reason Conduit shows:
| Code | Meaning |
|---|---|
| 4401 | Dashboard auth failed (past 30 refusals a minute on the host, refused upgrades get a bare HTTP 403 instead) |
| 4503 | No xAI credential on the host, or its SuperGrok sign-in couldn't be read |
| 4400 | xAI refused the credential or the call, or closed it with one of its own 3000-4999 codes (named in the reason) |
| 4429 | Too many connections (10 per minute or 3 open per profile, 8 open on the host), or xAI rate limiting |
| 4502 | xAI unreachable, the connection to xAI dropped, or the host timed out reading its xAI sign-in (retryable) |
| 4500 | Grok Live failed on the host, e.g. it can't scope the profile (see the dashboard log) |
Frames from Conduit over 256 KiB close the socket with 1009. The dashboard's
server reads a whole frame before the plugin sees it, so its own WebSocket size
limit (uvicorn's ws_max_size, 16 MiB by default) is what bounds memory per
frame; only a signed-in dashboard client can send one.
Optional settings in the profile's config.yaml:
voice:
grok_live:
model: grok-voice-latest # or CONDUIT_GROK_LIVE_MODEL in .env
voice: eveConduit's Classic voice can listen through a speech server you run yourself,
so your voice is recognized on your own machines. Any server that speaks the
OpenAI Realtime WebSocket protocol works, for example Hugging Face
speech-to-speech
(speech-to-speech serve) or the one Hermes Live Voice's local mode starts.
It can run on another machine (an LXC, a GPU box, a Mac on your network).
Conduit never connects to it directly. This plugin relays Conduit's realtime
events to the server and back, adding only Authorization: Bearer <api_key>
when a key is set. The phone and Watch keep talking to Hermes alone, so the
speech server never needs to be exposed outside your network.
Set it per profile in config.yaml (Conduit's Voice settings write the same keys):
voice:
speech_server:
url: ws://192.168.1.20:8765/v1/realtime # http(s) works too; a bare host gets /v1/realtime
api_key: "" # optional, for a server behind an auth proxyPlain ws:// is only allowed for loopback, private, link-local and Tailscale
addresses or LAN-style names (localhost, single-label names, .local, .lan,
.internal, .home.arpa, .ts.net). A public host needs wss://. Names are
judged by their shape and not resolved, so this is a guard against mistakes,
not a security boundary.
speech-to-speech has no authentication of its own, so keep it on your network.
| Method | Path | Returns |
|---|---|---|
| GET | /api/plugins/conduit_push/speech-server/status |
{ok, configured, available, url, has_key, reason} |
| POST | /api/plugins/conduit_push/speech-server/test |
{ok, connected, url, latency_ms, event, reason}: dials the server from the host and waits for its first event |
| WebSocket | /api/plugins/conduit_push/speech-server/socket |
OpenAI Realtime events, relayed |
The socket takes the dashboard's WebSocket credential (?ticket=) and an
optional ?profile=. Close codes: 4503 not configured or invalid URL, 4401
dashboard sign-in required, 4429 rate limited, 4400 the server refused, 4502
unreachable or dropped (retryable), 4500 the relay failed on the host, 1009 a
frame over 256 KB. A socket that sends nothing for 60 seconds is closed.
The same speech server can run whole live calls. speech-to-speech listens, decides when you've finished, and speaks the reply, asking an OpenAI-compatible "model address" for the words. Point that model address at this plugin and Hermes answers each turn in the chat the call belongs to, with that chat's profile, tools and memory. The call's audio goes through the relay above.
- Set the speech server above (Conduit's Voice settings do it).
- In Conduit's Local Live settings, copy the model address and key, or read
them from
GET /local-live/status. The model address is your dashboard's address plus/api/plugins/conduit_push/local-live/v1, so the voice server has to reach the dashboard the way your phone does (LAN, Tailscale or a public domain). - Start speech-to-speech with the chat-completions backend:
speech-to-speech serve --llm_backend chat-completions \
--responses_api_base_url https://<your dashboard>/api/plugins/conduit_push/local-live/v1 \
--responses_api_api_key <key from Conduit> \
--model_name conduit --stream_batch_sentences 1The model routes take that key as a bearer token instead of the dashboard
sign-in, through Hermes' token-route seam
(hermes_cli.dashboard_auth.token_auth). The key works on those two routes
only. A Hermes without the seam reports Local Live unavailable, and so does a
public dashboard with no other sign-in provider. A key is per profile and
names its profile. Rotating it stops the old one at once in the dashboard's process (other processes accept it for up to 30 seconds, until their cache expires).
The model name is ignored, and only the newest user message is used, since Hermes keeps the history. One live call per profile at a time: a turn goes to the profile's newest Local Live socket. With no call open, the routes answer a fixed line, so the server's warmup request succeeds.
| Method | Path | Returns |
|---|---|---|
| GET | /api/plugins/conduit_push/local-live/status |
{ok, available, reason, model_path, model_name, api_key, speech_server, in_call, last_request_at} |
| POST | /api/plugins/conduit_push/local-live/key |
{ok, api_key}: a new key |
| WebSocket | /api/plugins/conduit_push/local-live/socket |
the speech server relay, plus turn frames |
| POST | /api/plugins/conduit_push/local-live/v1/chat/completions |
OpenAI chat completions (streaming or not), key auth |
| GET | /api/plugins/conduit_push/local-live/v1/models |
{object: "list", data: [{id: "conduit"}]}, key auth |
Turn frames on the socket, beside the relayed realtime events:
- plugin to Conduit:
{"type": "conduit.turn", "turn_id", "text"}, and{"type": "conduit.turn.cancel", "turn_id"}when the server stops listening (you talked over the reply) or a newer turn starts. - Conduit to plugin, never forwarded:
{"type": "conduit.turn.delta", "turn_id", "text"}, thenconduit.turn.doneorconduit.turn.error. A turn with no frame for 90 seconds ends.
/local-live/socket?mode=speak is Classic voice's reply voice: Conduit sends
each reply sentence as a user conversation.item.create plus
response.create, and the model route reads that text back unchanged, so
the server speaks Classic's replies in its own voice. Classic then uses one
server for listening and speaking. While the reply plays, Classic may open
its listening socket too, so start the server with --num_pipelines 2.
Gemini Live, GPT-Live and Grok Live calls never run a Hermes turn, so nothing records them. These routes let Conduit save a call's transcript as an ordinary session in this profile's history, written straight into the session store. They never start the agent or call a model. A resumed call appends to the same session.
| Method | Route | Returns |
|---|---|---|
| POST | /api/plugins/conduit_push/voice/sessions with {call_id, engine, session_id?, chat_id?, title?, turns: [{index, role, text, at?}]} |
{ok, session_id, chat_id?, written, appended, created} |
| GET | /api/plugins/conduit_push/voice/chat-calls?chat_id=[&call_id=] |
{ok, chat_id, calls: [{call_id, engine, turns?: [{role, text, at}]}]} |
| GET | /api/plugins/conduit_push/voice/tags |
{ok, tags: {session_id: {kind, engine?, parent_id?, parent_title?}}} |
| POST | /api/plugins/conduit_push/voice/tags with {session_id, kind: classic or job, parent_id?, parent_title?} |
{ok, session_id, kind, ...} |
| GET | /api/plugins/conduit_push/voice/summary?session_id= |
{ok, available, text, covers} |
| POST | /api/plugins/conduit_push/voice/summary with {session_id, text, covers} |
{ok, session_id, covers} |
- Saved sessions keep source
desktopand no model. Hermes uses a session's source as the agent platform and restores the stored model on resume, so typing into a saved call behaves exactly like any Conduit chat. - Each turn's
indexcounts from 0 within itscall_id(at most 100,000), with no gaps, and a turn is sent only once it's final: it must have text, and an index never changes content afterwards. A row is append-only, so the host keeps each call's highest written index (for a session's last 500 calls): indices at or below it are replays of a retried save and are reported asskipped, and new turns must continue right after it. A gap gets 400 instead of being stored out of order or lost.writtenis one past that highest index, where the next save starts. One caveat: the append and that record are two separate writes, so a failure between them can repeat a line on retry (it never loses one). - A call attached to a chat (started in it, or a Hermes call from it) sends
chat_id, the chat's stored session id, and nosession_id. Its turns are kept with that chat instate_metainstead of a session of its own, under the same index rules, and the chat's own messages are never touched;session_idcomes back null andcreatedfalse (no session is made).voice/chat-callslists them for the chat; turns come only for the one call named bycall_id. A chat keeps its latest 50 calls of at most 2000 turns each. When the call ends it is written to the memory provider under the chat's session, but no session-end hooks fire: the chat goes on. A chat that no longer exists gets 422, and Conduit then saves the call on its own. Older plugins ignorechat_idand save a session as before. - A create is idempotent per
call_id: a retried first save whose response was lost continues the row it made instead of starting a second one. - Appends and summaries go only to rows saved as voice calls; any other session id gets 422. A gone row's call record and summary are cleared.
- Conduit's labels (voice call, classic voice chat, voice job) and the resume
summaries live in Hermes'
state_metatable, which the agent never reads. - The routes return 501 on a Hermes without a session store, 409 while the session is being compacted, and 422 when the session was deleted or compaction has closed it (Conduit then saves the call as a new session). Writes are capped at 120 a minute and reads at 600 for the whole dashboard (429 past that). Writes assume one dashboard process, as Hermes runs it. Each request gets 20 seconds; a store call that hangs past that keeps its worker busy until Hermes' own SQLite timeout releases it, and may still complete, so a 504 doesn't mean the write didn't land (a retried save skips what did). Writes are serialized per profile, so a slow store in one profile doesn't hold up another. A 404 only ever means the plugin is too old to have the route.
A live call runs no Hermes turn, so on its own Hermes never learns that it is over. Conduit saves a call only once it has ended (hung up, dropped or timed out), so a save that stores new turns ends that call's session, the same way Hermes ends a Desktop chat:
- The
on_session_endhook fires withsession_id,completed: true,interrupted: false,model(the voice engine, such asgemini-live),platform: "desktop"andreason: "voice_call_ended". - The memory provider named by
memory.provider(Honcho, Mem0, …) gets the call: it is started for that session (with the session's title, as Hermes does), sees each exchange throughsync_turn, thenon_session_endwith the call's turns, and is shut down. A turn missing either side (a greeting before you spoke, a question the call ended on) isn't synced, as in Hermes. - The
on_session_finalizehook fires withsession_id,platformandreason, through Hermes' ownfinalize_session.
Only the call's new turns go to memory: a resumed call's earlier turns went there when their own call ended. A retried save that stores nothing new ends nothing. This runs in the background, one call at a time, so a slow provider never holds up a save. A step that fails is logged and the rest still run. A call whose end takes more than two minutes is left to finish on its own while the next call ends; once three are stuck like that in a profile, that profile's calls end without these steps until one finishes, and past 20 calls waiting a call ends without them too (the log says so each time). A call that isn't saved (saving turned off in Conduit, or no speech from you) ends nothing.
Hermes lets one app own a chat at a time. Hermes Desktop claims a chat on its
first turn and keeps the claim until the chat is closed there, so sending to
the same chat from Conduit is refused with "This chat is open in another Hermes
window/terminal". This route is what Conduit's Take over does: it drops the
other app's claim in runtime/active_sessions.json, under Hermes' own registry
lock, and Conduit's next send claims the chat.
| Method | Route | Returns |
|---|---|---|
| POST | /api/plugins/conduit_push/sessions/takeover with {session_ids: [..]} |
{ok, status, surface?} |
statusistaken_over(send again),free(nobody else holds it),busy(the other app is running a turn on it, or it's a live worker or gateway whose turns can't be seen; ask again shortly, nothing is interrupted; a marker that can't be read counts as running, and only a marker whose writer is provably dead, or a gone owner's marker that names no writer, is ignored) orsame_host(this dashboard process still holds it, for example Conduit on another device; that claim is left alone).- The other app writes its turn marker without the registry lock, so a turn it starts in the instant between the check and the takeover still loses its claim. That turn keeps running there; nothing is interrupted.
- Only Desktop and TUI claims are dropped while their owner is alive: they are
the surfaces that write turn markers. A live claim from a CLI or kanban
worker, a gateway, or with no surface answers
busy, since a running turn there can't be seen. A claim whose owner process is gone is dropped whatever its surface, as long as no live claim of that kind also holds the chat. - Only another process's claim is dropped; a claim without a valid pid is kept, since it can't be attributed. The other app isn't told: if you go back and send from Hermes Desktop, it still believes it owns the chat and doesn't see what you sent from Conduit until you reopen the chat there.
session_idstakes 1 to 4 ids of one chat (its stored id and its live id), never ids of different chats. 400 for a bad body, 501 on a Hermes without the ownership registry or turn markers, 503 when the registry can't be read (ownership is never guessed), 429 past 60 requests a minute, 504 if the takeover doesn't finish within 20 seconds (ask again: nothing is written after that, short of a write already under way).
Conduit's Unread filter can count a chat as read when you have it open in
Hermes Desktop or the web dashboard on the same host. Hermes has no hook for
a client opening a chat, so the plugin wraps the gateway's session.activate
and session.resume handlers in the dashboard process. Per chat it keeps the
newest open (opened_at) and how long that chat then stayed the one its
Desktop connection had selected (seen_through), in
<hermes home>/conduit-desktop-views.json (at most 2,000 chats). It never
writes Hermes' own read flag, so Desktop's unread dots don't change.
| Method | Route | Returns |
|---|---|---|
| GET | /api/plugins/conduit_push/sessions/desktop-views?profile=&since= |
{ok, observing, reason, views: {stored id: {opened_at, seen_through, client, open?}}} |
- An open counts when it comes over the gateway's WebSocket from a browser
engine (Desktop's renderer or the web dashboard: a
Mozilla/User-Agent);clientisdesktopwhen it also saysElectron/, elsebrowser. Conduit's own socket, the TUI and hosted rooms never count. The User-Agent is the client's own claim, so this is best-effort sorting among clients that already hold a dashboard login. - A connection's selection moves with its next open and ends when its socket
closes (noticed within 5 seconds).
open: truemarks a chat that is selected right now; itsseen_throughis the moment of the read. The host can't tell whether anyone is looking at the window, and Desktop also re-attaches chats after a reconnect. observingis false, with areason, while the gateway hasn't loaded (gateway-not-in-process, also whatplugins.isolation: hostreports) or when a Hermes update moved those handlers or the socket's headers (gateway-unsupported). The wrapper never fails a call and always returns Hermes' own response.sincereturns only chats seen after that time (seconds since the epoch); a value that isn't a finite number returns every chat. 429 past 60 reads a minute per profile.- An open is skipped when the gateway can't say which profile its chat belongs to, rather than guessed onto the default profile.
A reply that comes back within 2 minutes in a chat Hermes Desktop (or the web dashboard) has selected on this host sends no "response ready" push: you most likely watched it arrive there. A longer turn still pushes, since you have likely walked away, and every other notification stays as it was: other chats, background jobs, approvals, questions, failed turns and calls. There is nothing to set up.
- The selection is the one Chats opened on Desktop follows: each Desktop connection's selected chat, ended when it switches chats or its socket closes. The host can't see whether the window is in front, so a short reply in a chat left selected on an unattended Mac stays quiet on the phone (Desktop's own notification still shows on the Mac when its window is in the background).
- The dashboard publishes the selected stored chat ids, per Hermes home, in
<hermes home>/conduit-desktop-selected.json(private to the user) when they change, and renews the file every 30 seconds while anything is selected. The turn hooks trust it for 45 seconds, so a stopped dashboard stops quieting anything. - A turn's 2 minutes run from its
pre_llm_callto itspost_llm_call. A turn with no start time, a file that can't be read, orplugins.isolation: host(where Desktop views aren't observed) all push as before.
If you also talk to Hermes in Desktop, a messaging app or the terminal, you already see those replies there. In Conduit's Settings > Notifications you choose, per profile, which sources send a "response ready" push: Conduit, Hermes Desktop and the web dashboard, messaging apps (Telegram, Discord, Slack, plugin platforms…), and the terminal. Everything is on until you turn it off. Approvals, questions, failed turns, background tasks, cron runs (they have their own switch) and calls don't follow this.
- The source is where the message that started the reply's turn was sent, not where the chat began: a Telegram chat you continue in Conduit is a Conduit reply. Hermes binds each turn to the connection that submitted last before it started, so a message queued meanwhile belongs to the next turn, not to the reply already running.
- Conduit's socket carries
X-Conduit-Client(Watch jobs count as Conduit); another browser-engine socket is Desktop or the web dashboard. A turn with no app connection is placed by its Hermes platform:cliis the terminal, a Hermes messaging platform is a messaging app. - A turn that can't be placed (an app connection run under
plugins.isolation: host, the stdio TUI, a Hermes that moved these) pushes as before. - The choice lives in the profile's
conduit-notifications.json.GET /api/plugins/conduit_push/notifications/reply-sourcesreads it ({"ok": true, "sources": {"conduit": true, "desktop": false, …}}) andPUTwith{"source": "desktop", "notify": false}changes one. Capabilityreply-sources.
A group chat room isn't a session, so Hermes' attach RPCs can't take a file
for it. Conduit uploads photos and files picked in a room's composer with
POST /api/plugins/conduit_push/room-files ({"name": "photo.jpg", "data_base64": "…"}, up to 50 MB) and puts the @file: reference it gets
back ({"ok": true, "path": "…", "ref_text": "@file:…"}) on its own line
above the message, as Hermes Desktop does for a chat. The room's members run
on this host and read the file from there. Uploads go to
<hermes home>/conduit/room-files/<id>/ and are deleted a month later, or
sooner, oldest first, once they pass 2 GB together (checked hourly). An
upload that would leave under 1 GB free on the host is refused (507).
Capability room-files.
Hermes' gateway-hosted group rooms carry no picture, so Conduit keeps a
room's custom picture here. GET /api/plugins/conduit_push/room-images
returns each room's revision ({"ok": true, "revisions": {"<room id>": "<rev>"}}), so Conduit fetches only pictures it hasn't cached with
GET /room-images/<room id> ({"rev": "…", "mime": "image/jpeg", "data_base64": "…"}). PUT /room-images/<room id> with {"data_base64": "…"} sets a JPEG or PNG of up to 512 KB; DELETE clears it. Records live in
<hermes home>/conduit/room-images/ (a small record plus the picture per
room, so listing never reads picture bytes), up to 500 rooms (the
oldest go first). Capability room-images.
Conduit's Artifacts screens list what a profile's chats produced:
GET /api/plugins/conduit_push/artifacts reads the profile's state.db
(read only) newest message first and returns
{"ok": true, "items": [...], "next_before": 1234}. Each item has a kind
(image, video, audio, page, document, file or link), a source
(delivered for a MEDIA: file, preview for a ::preview page, written
for a document or media file a tool wrote, attached for a file the user sent
with @file:, link for a web link in a reply), a name, the chat's
session_id and session_title, the message_id it came from and its
timestamp; a file has its path, whether it still exists and its size,
a link its url and host. Within a page one path or URL is listed once, at
its newest mention (a later page can list it again at an older one). Query: session_id (one chat; repeat it for each id the chat is
known by, and the sessions it continues from or spawned are read too), kind (images, pages, files or
links), q (words in the name, chat title, path or URL), limit (up to
200) and before (the previous page's next_before; null once the history
is read to its start). A request reads a bounded window of rows, so a page can
come back short with a next_before to keep going. Capability artifacts.
In a Live Voice call you can hand Hermes a long job and say "call me when it's done". Conduit asks this profile right away to watch the job's own Hermes session, held while the call goes on. The first time a turn of that session ends after you hang up, the plugin sends a call request through the relay instead of the usual "Response ready" or "Turn failed" push: a "Hermes wants to talk" notification whose Talk button opens Live Voice in the job's chat, where Hermes opens with how the job went. With relay 0.9+ and a Conduit build that rings, it arrives as a native incoming call (CallKit) instead, and the notification is the fallback. With relay 0.10+ and a Watch app that rings, the Apple Watch rings too, and answering on either stops the other (see Relay API).
While the call goes on, Conduit renews the hold every minute and releases it when you hang up. A job that ends during the hold gets its usual push and the call waits: Conduit tells you in the call (and removes the watch), or, if you hang up first, tells you itself when it releases the hold. If the phone goes away mid-call (crash, no signal), the hold runs out and the plugin calls on its own, so the call never depends on the phone reaching this host after the call.
Calls are off until you turn them on in Conduit's Settings, under Calls from
Hermes. Settings and watches live per profile in
<hermes home>/conduit-calls.json (mode 0600), shared by the turn-end hooks
and the dashboard routes under one file lock, so they survive restarts:
| Setting | Default | Range |
|---|---|---|
enabled (Hermes can call me) |
off | |
when_asked (call when I explicitly ask) |
on | |
decides (Hermes decides when to call, 0.14+) |
off | |
alerts (approval, question and failure calls, 0.14+) |
off | |
min_gap_s (time between calls) |
120 s | 30–3600 s |
per_hour |
6 | 1–30 |
per_day |
20 | 1–60 |
rules (what's worth a call, 0.16+) |
empty | up to 500 characters |
| Method | Route | Body | Returns |
|---|---|---|---|
| GET | /api/plugins/conduit_push/calls?profile= |
{ok, paired, settings, bounds, rules_max, watches} |
|
| PUT | /api/plugins/conduit_push/calls?profile= |
{settings: {...}} (any subset) |
{ok, settings} |
| POST | /api/plugins/conduit_push/calls/watches?profile= |
{session_ids: [...], title, hold_s?, ended_within_s?} |
{ok, status: "watching", id} or {ok, status: "ended", outcome} |
| PUT | /api/plugins/conduit_push/calls/watches/{id}?profile= |
{hold_s} (0 releases) |
{ok, status: "watching"}, {ok, status: "ended", outcome} or {ok, status: "gone"} |
| DELETE | /api/plugins/conduit_push/calls/watches/{id}?profile= |
{ok, removed} |
|
| PUT | /api/plugins/conduit_push/calls/presence?profile= (0.14+) |
{hold_s} (0 releases) |
{ok, status: "present"} or {ok, status: "away"} |
| POST | /api/plugins/conduit_push/calls/outcomes?profile= (0.15+) |
{call_id, session_ids: [...], outcome: "declined" or "missed", kind?, title?, reason?, age_s?} |
{ok, status: "recorded"} |
- A watch names up to 4 session ids (the job's runtime and stored ids) and a
title of up to 120 characters. It fires once, on the first turn end of any
of them:
done(the turn finished),failed, orstopped(interrupted). A replayed hook finds nothing left, so a job calls at most once. Watches expire after 24 hours; a profile holds at most 20 (429 past that). hold_s(0–600) holds the watch while the call goes on. A job that ends during the hold keeps its outcome; releasing the hold (hold_s: 0) then answersendedand consumes the watch, and a hold that runs out calls. The waiting runs in the agent process, and every turn end also sweeps for overdue calls, so a gateway restart doesn't lose one.endedfrom POST means one of the job's sessions ended within the lastended_within_sseconds (how long ago the job's request went out, on the phone's clock, so an earlier turn of the same chat doesn't count; the plugin remembers turn ends for 30 minutes while calls are on); Conduit then tells you itself.- 409 when the profile isn't paired with Conduit, or calls (or "call when I ask") are off. 400 for an unknown setting or a value outside its range. 429 past 30 requests a minute per profile.
- A call held back by your limits, or calls turned off after the watch was
made, consumes the watch and sends the usual push instead. So does a relay
that refuses the call request (relays before 0.8 don't know it; 0.8 caps
call requests at 60 a day per installation,
call_limit), one that took the request but couldn't reach Apple, or one that stays unreachable or keeps failing (5xx) after three tries (2 s and 4 s apart, same event id). One malformed session id drops the job details from the request, on the host and on the relay alike; it still opens the chat. - Presence (0.14+): while you're in a Live Voice call, Conduit holds
presence (
hold_sup to 600, renewed during the call, 0 at hang-up). Nothing rings meanwhile: a call that would go out sends the usual push. - Declined and missed calls (0.15+): when you decline a call, or it
rings out, reaches a phone that was offline or is silenced by Do Not
Disturb, Conduit reports it here with the call's id, session ids, kind,
title and reason, and
age_s(how long ago, on the phone's clock). The next turn of one of those sessions gets a one-line note through thepre_llm_callhook, for example "the user declined the phone call you placed 12 minutes ago", and takes it, so Hermes hears it once, even if a late retry of the report arrives after. Nothing rings again. A retried report keeps the first; a profile keeps at most 20 waiting, each for a day. 409 when the profile isn't paired, 400 for anything malformed. - Conduit first (0.36+): every turn of a main conversation also gets one
line naming what the phone shares with this profile (calendar and
reminders, health, location, phone status, contacts) and saying to use
the Conduit tool before other tools, skills or CLIs for the same data,
such as gog. Each of those tools' descriptions ends the same way. Hermes
drops
pre_llm_callcontext after the turn, so the line never piles up; what's shared is looked up at most once a minute. Subagents don't get it. - The call request carries the job's id, outcome, title and session ids so
Conduit can open the right chat. With end-to-end encryption they are sealed
like any other content; with
redact onthe title is dropped. - Ringing (relay 0.9+): a phone that registered a PushKit token
(
voip_token) gets the call request as a VoIP push on the app's.voiptopic, with no alert, the same routing or sealed envelope, andsent_at, so it rings as a native call. Like a notification, APNs tries to keep it for a phone that's offline (for up to a day; best effort, and only the newest one per app), and Conduit shows one that arrives late as a missed call rather than ringing. Anything that stops it ringing (APNs refusing or unreachable, a call with no chat to open) sends the usual "Hermes wants to talk" notification instead, and a PushKit token APNs calls gone is forgotten until the phone registers a new one. The relay answersrang: truewhen APNs took the VoIP push. Calls carry areasonof up to 200 characters (dropped with previews off, like the title), and the kindsapprovalandquestionbesides how a job ended.
The plugin gives Hermes a conduit_call_user tool (toolset conduit) and a
bundled skill, conduit_push:calling-the-user (plugin skills aren't listed in
the skill index, so the tool's description points to it). Hermes calls it
with a reason (one spoken sentence, up to 200 characters) and
asked_by_user:
- The call rings when the asking turn ends, so it opens on the finished result: the turn's final reply is in the chat the call opens, and the reason is said first. This works from any chat Hermes runs in (typed chats in Conduit or Desktop, other platforms, cron jobs), not only Live Voice.
asked_by_user: trueneeds "call when I ask";falseneeds "Hermes decides". Either way the limits apply, presence holds it, and a session already watched keeps one watch (it takes the reason).- What's worth a call (0.16+): with
ruleswritten (the user's own words, such as "only if production is down"), a call Hermes decides on first answerscheck_ruleswith them. It goes only when Hermes calls again withfits_rules: truein the same chat, within 10 minutes, while the rules are unchanged. Calls the user asked for skip the rules, and Hermes can read them but not change them. The rules are checked when Hermes confirms the call, not when it rings, and they steer Hermes rather than bind it:asked_by_useris Hermes' own word (0.14+), so a call it marks as asked for skips them. - Call watches (0.17+): the rules only filter the calls Hermes makes;
they don't watch anything. When the user asks to be called if something
happens ("call me if the homelab is down for more than 10 minutes"),
Hermes uses
conduit_call_watchand only describes the watch:what,howa run checks it,for_minutesit must last, and optionalhours(HH:MM-HH:MM) andunless. The plugin writes the cron job through Hermes's owncronjobfunction: named "Call watch: ", checked every few minutes (picked fromfor_minutes), with a fixed run prompt. The samewhatis the same watch, so asking again changes it instead of adding another.synctakes every watch the What's worth a call note describes (Conduit's "Have Hermes watch for this"): it changes and adds watches to match and lists the ones the note no longer mentions, which Hermes asks about before removing. - A watch's runs: each run checks and calls
conduit_watch_reportwith whether it's happening. The plugin keeps the incident per job inconduit-call-watches.json, waits outfor_minutesand the allowed hours, calls once per incident with the run's sentence as the reason, and tells the run what to reply ([SILENT]while there's nothing to send).conduit_call_userrefuses in such a run. Watches set up by 0.16.x (jobs that load the skill and keep their own state) keep running as written, and setting one again by name or id moves it onto the report. - A call the user asked for while calls are off answers
calls_offwith aconduit://settings/callslink for Hermes' reply, which opens Calls from Hermes in Conduit. - The tool shows only while this profile is paired and one of those is on.
Subagents can't call; their parent can.
conduit_call_watchshows while the profile is paired on a Hermes with scheduled jobs, andconduit_watch_reportwhile it's paired (it only works in a watch's run). Whenconduitis off for cron inhermes tools, each watch's job gets its own toolset list (cron's, plusconduit) so its runs can report. - If
hermes toolslists theconduittoolset as off for a platform, turn it on there.
With alerts on, Hermes also calls when it waits on you:
- Approval: an approval request still unanswered after a minute rings
(kind
approval, the request's description as the reason), beside its usual answerable notification. Answering it anywhere first (post_approval_response) or the turn ending cancels the call. - Question: the same for a clarify question (kind
question), answered through Conduit's card, Desktop or the CLI. Needs the clarify middleware (Hermes withregister_middleware). - Failed turn: a turn that fails calls in place of its "Turn failed"
push (kind
failed).
Alert calls count against the same limits. In the call, Conduit can read the approval out and answer it by voice.
Ask Hermes to set Conduit up ("make live calls more relaxed", "use Gemini
Live with my memory") and it can, through the conduit_settings tool, within
what Conduit allows:
- Conduit decides what Hermes sees. Each Conduit uploads the settings it
shares (key, plain-language title and meaning, choices, value and tier) and
the user's Let Hermes change settings mode to
PUT /api/plugins/conduit_push/conduit-settingswhen it connects, comes to the foreground, changes a shared setting, or a turn used the tool. Anything it doesn't list (keys, wake phrase setup, Calls from Hermes, Watch access, the mode itself) is out of Hermes' reach. - Hermes only asks.
getlists the settings;changekeeps a request with the value Hermes saw and a reason;canceltakes back one Conduit hasn't picked up. Requests wait in the profile'sconduit-settings.jsonfor a day. - Conduit applies or asks. It checks every request against its own list
again. With the default mode, Ask me every time, each change waits for
the user's Apply. With Small changes without asking, the small tier
(how calls sound, voices) applies with an Undo and the rest still asks.
Off refuses everything. A setting the user changed after Hermes read it
keeps the user's value. Conduit reports each outcome (applied, dismissed,
conflict, refused, undone) on its next sync, and
getshows it to Hermes. - Speech providers and their keys,
voice.gpt_liveand models are Hermes settings, not Conduit's: the tool tells Hermes to change those in its own config.
The tool shows only once a Conduit has synced with the profile. Limits: 20 requested changes per device per hour, 10 per call, 8 devices per profile.
Before an action you should OK (sending a message for you, buying, deleting),
Hermes can ask on your phone with the conduit_present tool:
- Tool.
confirmkeeps a prompt (title up to 80 characters, a Markdown preview up to 2,000, a button label up to 24, up to 5spoken_yesphrases,ttl_minutes30 by default and at most 1,440) and wakes the phone with aphone.changespush (present:<id>).wait_seconds(up to 120) waits for the answer.statusreturns waiting prompts and answers Hermes hasn't seen yet (or one byid);canceltakes one back. States:waiting,confirmed,declined,edit,expired,cancelled. Onlyconfirmedmeans go ahead; onedityou send the corrected request yourself. - Conduit answers. It shows a panel over everything, calls included, and
you confirm by tap or, in a call, by voice. It reports answers and fetches
waiting prompts with
POST /api/plugins/conduit_push/present/exchange?profile=:{"installation_id", "device", "app_version", "results": [{"id", "outcome": "confirmed|declined|edit", "edit_text"?, "via": "tap|voice"}]}answers{"ok": true, "prompts": [{"id", "title", "preview", "action_label", "spoken_yes", "session_id", "created_at", "expires_at"}]}. The first answer wins; answers for unknown or finished prompts are ignored.
The tool shows only once a Conduit with present-v1 has exchanged with the
profile; subagents can't use it. Prompts live in the profile's
conduit-present.json (answers are kept a week). Limits: 20 prompts an
hour, 20 waiting at once, 60 exchanges a minute per profile.
Conduit can share Apple Health data with a Hermes profile so the agent can answer "how did I sleep?" or "how active was I this week?". It's off until the user turns it on in Conduit, per category (activity, heart, sleep, workouts, body, vitals, fitness), with how far back to start.
- Straight to this host. The phone posts to
POST /api/plugins/conduit_push/health/ingestover your dashboard connection. Health data never passes through the push relay or any Conduit server. - Sealed end to end. Each phone agrees a key with the profile once
(
POST /health/key, X25519: only public keys cross the wire, so watching that exchange doesn't reveal the key). Every batch is sealed with ChaCha20-Poly1305 under an HKDF-SHA256 key from it, on top of any TLS, so even a dashboard on plain http in your LAN never carries health data in the clear. Like any plain-http service, someone actively tampering with the connection while the key is set up could still intercept it: use HTTPS or a private tunnel when you can. - Who can use these routes. Anyone signed in to your dashboard, as for every other dashboard route; push pairing isn't needed. A signed-in client could register a new key under a phone's device id and send data in its name (the phone notices on its next sync and sets up a new key), so treat dashboard access as access to your health data. A new device key for a device id drops what the earlier key sent (the phone sends it all again), so a re-key can't be used to read a phone's data back; the same device key again keeps it, since only that phone can use it.
- Summaries first. Conduit sends the hourly and daily totals Apple Health computes on the phone (iPhone and Watch already merged), a summary per night of sleep, sleep stage segments and workouts. Raw readings go only for categories where the user turns on detailed readings.
- Stored here only. The profile's
conduit-health.sqlite3, mode0600. A newer computation of a window replaces the older one; a deleted workout or reading stays deleted.GET /health/statusreports counts and dates, never values;POST /health/wipedeletes one device's data and key (or everything with{"all": true}), and Conduit's "Delete health data on this host" calls it. Only the voice read below returns health data. - Read-only tool.
conduit_health(actionsoverview,daily,hourly,sleep,workouts,samples; at most 92 days per call) with its skillconduit_push:reading-health-data. It shows only once a phone has synced data, and subagents can't use it. What it returns goes to your agent's model like any tool result, which Conduit tells the user before they turn sharing on.
Limits: 4 devices per profile, 6,000 items and 4 MiB per batch, 120 uploads a minute per profile.
Gemini Live, Grok Live and GPT-Live run at Google, xAI and OpenAI, driven by
Conduit, so they can't call Hermes' tools. When the user lets a voice mode read
their health data in Conduit's Settings > Health (off by default and asked per
mode, naming the voice provider, since that company may not be the one Hermes
uses), Conduit offers the voice model conduit_health with Hermes' own
description and parameters (minus device: a voice read covers only the
asking phone), and runs each call here. That switch lives in Conduit; the
host's boundary is the seal:
| Method | Route | Returns |
|---|---|---|
| GET | /api/plugins/conduit_push/voice-tools |
Tools this profile can answer for voice (name, description, parameters, which key seals them); empty until a phone has synced |
| POST | /api/plugins/conduit_push/voice-tools/call |
One sealed read: {v: 1, tool, device_id, kid, call_id, nonce, ct} in, {v: 1, nonce, ct} out |
- Sealed both ways with the device's own health key, with its own HKDF
directions (
conduit-health-v1 voice device-to-host/host-to-device) and associated data binding the tool, key, device and call id. Dashboard access alone opens nothing. - Only the asking device's data, matched by id (never by name), so another device or a newly registered key reads nothing.
- Read actions only (the same handler as
conduit_health), 60 reads a minute per profile. Conduit keeps the log of what each call read. Shared vector:tests/health_vector.json(voice).
Calendar and phone status (plugin 0.29+): once a phone shares them,
conduit_calendar (read actions only: overview, calendars, events, free_busy,
next, reminders) and conduit_phone (status) join the list with key: "phone". Their call is the phone feature's sealed exchange with op: "voice" ({v: 1, device_id, kid, call_id, op: "voice", nonce, ct}, the
tool's name inside the seal: {call_id, tool, args, provider} in, {call_id, result} out), answered for the asking phone only. Conduit asks for this per
voice mode in Settings > Calendar & phone. Adding an event or reminder in a
call is the phone's own tool, with spoken confirmation, so no write is offered
here. With plugin 0.30, conduit_phone also offers alarms (the alarms and
timers Hermes set) and conduit_contacts (people) joins the list, each once
a phone shares it; setting an alarm or timer in a call is the phone's own tool
too.
Conduit can tell a Hermes profile where the user is. It's a building block rather than one feature: the agent can answer "how far am I from work?", follow place rules ("call me with my brief when I leave home on weekdays"), use it from any scheduled job or skill the user teaches it ("check I'm on my way before each appointment"), and Calls can skip places the user picked. Two separate opt-ins in Conduit:
- While talking (When In Use): one fix when the user sends a message or starts a voice call, and again when they've moved over 200 m during a call. Precise by default.
- In the background (Always): arriving at and leaving saved places (up to 20), visits and significant moves. Area by default.
Each layer has its own precision: places (saved place names only), area
(about 1 km: neighbourhood and town, coordinates rounded to 2 decimals) or
precise. The phone applies it before sending and the host applies it again.
Saved places live on the phone: this host gets their names and kinds, never
their coordinates, and each fix carries distances to them instead. A place
marked private is reported only as "at a private place".
- Straight to this host, sealed. Same scheme as health with its own key
and labels (
POST /location/key,POST /location/ingest): X25519, then ChaCha20-Poly1305 under an HKDF-SHA256 key. Location never passes through the push relay; the relay only carries the on-demand request (below). - Stored here only. The profile's
conduit-location.sqlite3, mode0600. History is kept as long as the user picks in Conduit (latest only, 1, 7 by default, 30 or 365 days).GET /location/statusreports counts, never locations;GET /location/readslists when the agent read location (kept 30 days, without the locations);POST /location/wipedeletes one device's data and key, or everything with{"all": true}. - Tool.
conduit_location(actionsnow,locate,places,history,distance_to,rules,set_rule,remove_rule) with its skillconduit_push:using-location. It answers "paused" or "off" when the user has paused sharing, logs every read, and subagents can't use it. - Voice reads (plugin 0.28): when the user lets a voice mode read their
location in Conduit's Settings > Location (off by default, per mode,
naming the voice provider),
/voice-toolsalso offersconduit_locationwith onlynow,places,historyanddistance_to(keylocation), sealed as health's voice reads are but with the device's location key (conduit-location-v1 voice device-to-host/host-to-device). It reads only the asking device's fix (never its coordinates: area, saved place and distances only), places and history, matched by id, and each read is logged as "Gemini Live call" (or Grok Live, GPT-Live). Shared vector:tests/location_vector.json(voice). - On-demand locate.
locateasks the phone for a fresh fix through the relay as a silent location push (Apple's Location Push Service Extension), which the phone answers only while background sharing is on and not paused. At most one every 5 minutes and 30 a day per profile. - Place rules.
set_rulekeeps a Hermes cron job namedPlace rule: <what>that never runs on its own schedule; an arrive or leave event that matches the rule's place (one saved place or any), move (arrive, leave or either), days and hours triggers it with a line naming the place and move (GET /location/rules,DELETE /location/rules/<id>). The task is anything a cron run can do. A rule fires once a day, or every time at least 30 minutes apart (repeat: "every"), never for an event over 15 minutes old, and never while sharing is paused. - No calls at places. Calls settings
avoid_places(place ids) andavoid_unknown_place(also hold calls when there's no recent location, or sharing is paused or off): a call that would ring while the user is at an avoided place goes out as a notification (statuslimited, reasonplace). The latest fix counts for 24 hours with background sharing on, otherwise 1 hour.
Limits: 4 devices per profile, 20 places, 256 KiB per batch.
conduit_maps gives Hermes Apple Maps: search (places or categories near
the user, a place or an address), lookup (one place or address),
reverse_geocode, directions (routes with traffic, tolls, steps, by car,
on foot, by transit or bike, and leave_by for arrive_by) and
travel_times (up to 10 destinations at once). Places can be "me", a saved
place's name, an address or a name, place_id:<id> from an earlier answer,
or lat,lon. It's a building block like location: crons, place rules and
skills can use it.
navigate (plugin 0.37+) opens Apple Maps on the iPhone with directions to
a place. It's a confirm prompt (conduit_present's store and exchange)
carrying maps: {to, mode}: Conduit opens Maps at once when the user is in
that chat with Conduit open, and otherwise shows a Go button and a
notification, answered by tap or by saying "go" in a call. Conduit says it
can do this with features: ["maps-navigate"] in its present exchange;
without it the tool returns an Apple Maps link for a text chat.
The host has no map, so the user's iPhone answers. The tool stores the
lookup sealed for that one phone (location's key, its own HKDF labels) and
wakes it with the same location push as locate; the relay sees only a
request id inside the sealed push envelope. Conduit's Location Push
extension fetches the lookup (GET /location/maps/{id}), answers it with
MapKit and posts the sealed answer (POST /location/maps/{id}). The user's
own position stays on the phone: "from me" answers carry durations,
distances and places at the detail they chose for background sharing
("places only" refuses "me"). It needs background location sharing and a
Conduit that says it answers maps lookups (sharing.features), and the
tool is hidden until then. Lookups are logged in the location read log
(action and chat, never the query); budgets are 30 wakes an hour and 120 a
day, a repeat within 2 minutes is answered from the last answer, and when
the phone doesn't answer, a trip from "me" to a saved place gets a labelled
straight-line guess. Contract: designs/maps-v1-contract.md in the project.
Lookups that don't involve the user (or when the phone can't answer) go to
Apple Maps from this host instead, without waking the phone: the relay hands
paired hosts a short-lived Apple Maps Server API token (POST /v1/maps/token; one cached token for every host, so it doesn't identify
one) and the plugin calls maps-api.apple.com itself, so the relay never
sees a lookup. Each host uses at most 200 Apple calls a day. Saved places
(their coordinates live on the phone) and transit routes need the phone;
"me" on the host is the last shared fix at its detail. A user who turns on
"Keep maps lookups on this iPhone" in Conduit turns the host engine off for
that profile. A relay without a Maps key answers 404 and the host stops
asking for an hour. Self-hosted relays enable it with a key that has "Maps"
enabled for a Maps ID: put it at deploy/secrets/maps/maps.p8 and set
MAPS_KEY_ID in .env.
Conduit can share the user's iPhone calendars (every account on the phone), Reminders, which Focus is on and a snapshot of the phone (battery, charging, Low Power Mode, CarPlay, audio route, network, motion) with a Hermes profile, so the agent can answer "what's on today?", find a free slot, add the dentist on Thursday, remind them to call mum when they get home, or keep it short when the battery is low. Each kind is off until the user turns it on in Conduit, with which calendars and lists, how far back and ahead, and whether notes and attendees are included (off by default).
-
Straight to this host, sealed end to end. The phone posts to
POST /api/plugins/conduit_push/phone/ingestover your dashboard connection, never through the push relay. It agrees its own key with the profile (POST /phone/key, X25519, separate from health and location, so each can be on alone and wiping one never touches another), and every body is sealed with ChaCha20-Poly1305 under HKDF-SHA256 keys from it, on top of any TLS. The same caveats as health apply: use HTTPS or a private tunnel when you can, and treat dashboard access as access to this data. -
What the user chose, enforced here too. The phone says what it shares in every batch and the store holds it to that: no notes, attendees or links without "Include notes and attendees", no attendee emails without that switch, no events at all when the calendar is "add only" (write-only) or off, and status or Focus forgotten as soon as either is turned off. Only the shared window is kept (past days to future days), status and Focus are the latest value only.
-
Writes are changes the phone applies. Hermes can't touch the phone's calendar, so
conduit_calendar's write actions queue a pending change. The plugin nudges the phone with aphone.changesrelay event (sealed like any notification when the pairing has end-to-end encryption): a visible notification for a change the user approves on the phone (mode: "alert"), or a silent background push for an add the user lets Hermes make without asking (mode: "background"); updates, deletes and completions always ask, and adds to shared calendars or lists always ask. The phone picks the queue up and reports outcomes (applied, edited, dismissed, failed, expired) with one sealedPOST /phone/exchange. The notification carries no calendar data beyond the sealed title and body: the change itself comes from this host. Pending changes expire after 7 days, at most 50 per profile. A relay withoutphone.changesrefuses the nudge; the phone still finds the change on its next exchange. -
Stored here only. The profile's
conduit-phone.sqlite3, mode0600.GET /phone/statusreports counts and dates, never content;POST /phone/wipedeletes one device's data, key and pending changes (or everything with{"all": true}). -
Tools.
conduit_calendar(readsoverview,calendars,events,free_busy,next,reminders; writesadd_event,update_event,delete_event,add_reminder,update_reminder,complete_reminder;pendingfor how changes went) andconduit_phone(status), with the skillconduit_push:using-calendar. Each shows only once a phone shares what it reads; subagents can't use them. Reads say how fresh the copy is ("as of" the last sync). What they return goes to your agent's model like any tool result, which Conduit tells the user before they turn sharing on. -
Alarms and timers (plugin 0.30+). When the user lets Hermes set them (AlarmKit, iOS 26),
conduit_phonegainsalarms(the alarms and timers Hermes set that are still on: iOS shows an app only its own),set_alarm(one-offat, ortimeHH:MM withrepeatsdays),set_timer(seconds) andcancel_alarm, as pending changes like calendar writes. They ring through Silent and Focus. Setting one goes without asking when the user lets Hermes add without asking; cancelling always asks. -
Contacts (plugin 0.30+). Only the people the user picks in Conduit (no address book permission): name, nickname, a relation the user types ("wife"), phone numbers, emails, birthday and organization, read with
conduit_contacts(people, with an optionalquery). Turned off, they are deleted here.
Limits: 4 devices per profile, 5,000 items and 4 MiB per batch, 50 alarms and 200 contacts per device, 120 uploads and 60 exchanges a minute per profile, 64 KiB per exchange.
With the wrist down, a Conduit Watch call can't reach Conduit on the iPhone, but it can reach the push relay over its own internet. For each call Conduit asks this profile for a grant; the plugin opens it on the relay (see Relay API above), long-polls the relay for the Watch's sealed calls, runs them here and seals the answers back. This host opens no inbound route of its own.
| Method | Route | Returns |
|---|---|---|
| POST | /api/plugins/conduit_push/watch-tools/grant with {tools, max_jobs?, job_options?, job_profiles?, carry_jobs_from?, audio?} |
{ok, grant_id, relay_url, key, watch_key, expires_at, tools, max_calls, max_jobs, job_profiles?, jobs_carried_from?, audio?} |
| POST | /api/plugins/conduit_push/watch-tools/revoke with {grant_id} |
{ok, revoked} |
toolsnames what the call may run:web_searchandrecall_memory(the same code as the voice lookup routes), and with plugin 0.7start_job,list_jobsandcancel_job(granted with the Watch app's ownjob_newsandanswer_approval). A grant lasts at most 30 minutes, holds one profile, and ends when the call does.- Jobs start an ordinary Hermes chat on the grant's profile through
Hermes' own session API in the dashboard process, filed under Voice Jobs in
Conduit like a job started from the phone;
job_optionscarries the phone's voice-job model, provider and reasoning effort.max_jobsis the user's per-call limit (default 5, at most 20, 0 for none), counting the jobs that started; three run at once. A grant with jobs allows 120 calls, since the Watch also asks for job news (finished jobs and approval requests) while they run. A start Hermes takes longer than 18 s to accept is answeredaccepted, inside the relay's wait, and a start that then fails comes as job news. News whose answer doesn't reach the relay is sent again with the next call. A renewal names the call's previous grant incarry_jobs_from: while that grant is open and on the same profile, its jobs move to the new one (jobs_carried_from), so a call keeps hearing about, listing and cancelling them past a renewal, and the job limit counts per call. A renewal that leaves outmax_jobs,job_optionsorjob_profileskeeps the call's. - Jobs on another profile (plugin 0.11):
job_profileslists the user's other profiles (at most 32 names) a call's jobs may run on, as the phone's "for Fam, …" jobs do. Astart_jobwithprofilenaming one of them runs there, on that profile's own model (job_optionsstays with the grant's profile) and filed under that profile's Voice Jobs; a name not listed is answerednot_startedand never guessed at. A grant with jobs answersjob_profiles(the listed names, possibly none); an older plugin's answer leaves it out, and the Watch then sends such jobs through the iPhone. - Follow-ups (plugin 0.10): a grant that can start or cancel jobs also
carries
interrupt_job({job_id, message}), as does one that names it. It puts the user's words into a job Hermes is still working on, through Hermes'session.redirect(a steer on a Hermes without it), as Conduit's phone calls do: Hermes keeps the work so far and the job's result still comes as news. The answer is{ok, outcome, title?, error?}, the outcome beinginterrupted,queued(taken right after the step Hermes is finishing),finished,failedorunknown_job; follow-ups to one job go one at a time. - Gemini Live tokens (plugin 0.8): with
live_tokenintools, and only where this profile has a Gemini key, the Watch can ask for a fresh single-use Gemini Live token, minted as/gemini-live/tokenmints one and answered with the same fields. It's for a call whose Gemini session broke and can't be resumed while the iPhone is out of reach. It travels sealed like every answer, so the relay never sees it, and the Gemini key never leaves this host. A grant mints at most 6, and a renewal, which only the iPhone can ask for, counts its own; a mint that fails, or whose answer never reaches the relay, doesn't count. A request forlive_tokenalone on a profile without a key is refused (503). - Chats (plugin 0.18):
find_chat({query}) andread_chat({number}) let a call find one of the user's earlier chats on the grant's profile and talk about it, as a phone call does.find_chatruns Hermes' ownGET /api/sessions/searchin process (a query that matches nothing as a whole is tried a word at a time) and answers{count, chats}, the numbered matches as fenced text for the voice model.read_chatreads only a chat the grant's newest search returned, by its number, through Hermes' ownGET /api/sessions/{id}/messages(newest 500 rows, user and Hermes text only) and answers{chat}({chat: "", title}for a chat with no messages): word for word when short, else a summary of the older part (a saved voice call's stored one, or one made once per call with Hermes'compressionmodel) plus the latest turns;{unknown: true}for a number the search didn't return. A Hermes without the search route leaves both out of the grant, so the Watch asks the iPhone instead. Capabilitywatch-chats. - Approvals follow the profile's own Hermes approval settings. A command that needs one shows on the Watch, which can only approve it once or deny it, never for the session or always. Hermes denies it after its own approval timeout.
- Jobs need the dashboard to serve Hermes' chats in the same process (not
plugins.isolation: host). Where it doesn't, the grant leaves jobs out and the Watch's jobs go through the iPhone. A job still running when its call ends keeps running in Hermes, and its chat is closed here once it finishes. - 400 for a bad body, 409 when this profile isn't paired with Conduit
notifications, 501 without the
cryptographypackage or a relay that predates Watch tools, 502/503 when the relay can't open the grant (the call then uses the iPhone).
Gemini Live runs on the Watch itself. GPT-Live on a ChatGPT subscription
speaks only WebRTC, which the Watch can't run, and Grok runs on this host's
xAI sign-in, which never leaves it. So for those two the host holds the
provider session and the Watch streams its audio to it through the push
relay. A Watch grant opened with audio: true returns
audio: {url, version, engines}. The plugin then dials the relay's gateway
side for that grant and waits, and the Watch dials url with the grant's
relay key. The host still opens no inbound route.
| Method | Route | Returns |
|---|---|---|
| GET | /api/plugins/conduit_push/watch-audio/status |
{ok, version, engines: {gpt_live: {runtime, source, reason}, grok: {...}}} |
| POST | /api/plugins/conduit_push/watch-audio/prepare |
{ok, version, engines: {gpt_live: {runtime, source, reason}}} |
- Messages are sealed per Watch connection with a key from the grant's
root, so the relay can neither read nor forge one. The Watch speaks each
engine's own events, as the phone does, and its tools and jobs go through
the grant's lookups. The wire format, keys and control messages are
described where they're built (
dashboard/plugin_api.py, "Watch audio"). - GPT-Live runs WebRTC in a small helper process
(
dashboard/watch_audio_helper.py) under a Python that hasaiortc. The plugin does the SDP exchange with the Codex sign-in itself, so the sign-in never enters the helper. On any Hermes install,preparemakes the plugin's own environment for it:python -m venvfrom the Python Hermes runs, oruvwhere that Python has no venv or pip, thenpip install aiortc==1.15.0(about 150 MB). It goes in~/.hermes/conduit_push/watch-audio-env(underHERMES_HOMEwhen that's set, orCONDUIT_WATCH_AUDIO_ENV). Nothing is installed into Hermes' own Python; if that Python already hasaiortc, it's used as it is.runtimereadsready,preparing,failed(with the reason) ormissing, andenginesin a grant listsgpt_liveonly once it's ready. - Grok connects to xAI the way the phone's Grok relay does. Grok sends a reply's audio faster than it plays, so the plugin paces it to the Watch at real time with a little lead, and drops what's left when the user speaks over it.
- A host holds at most 4 Watch calls at once, one per grant. 501 when the
relay predates Watch audio (the grant is closed again) or this Python has
no
websockets. probes/watch_audio_client.pyis a command-line stand-in for the Watch: it opens a grant with audio, calls through the real relay, plays a WAV question and records the answer.--preparemakes the GPT-Live runtime first.
GET /api/plugins/conduit_push/capabilities returns the plugin version and
the Conduit features its routes serve:
{"ok": true, "version": "0.24.1", "loaded_version": "0.24.1", "restart_needed": false, "boot_id": "9f2c41d07a3b5e68", "capabilities": ["gemini-live", "web-search", "...", "pairing", "self-restart"]}Conduit reads it on connect. When a feature it uses is missing, or the route
itself is (a plugin older than 0.4), Settings asks you to run
hermes plugins update conduit_push and restart the gateway. Every new route
family adds its name here.
POST /api/plugins/conduit_push/pair?profile= with
{"pairing_code": "ABCDE-FGHJK", "relay_url": "https://push.milim.dev"}
makes the same claim as hermes conduit-push pair for that profile and saves
the credential in its conduit-push.json. relay_url is optional (the
profile's current relay, else push.milim.dev) and must be a bare http(s) URL;
the claim carries only the code and the gateway name, never follows redirects,
and the answer never includes the credential:
{ok, paired, installation_id, gateway_id, gateway_name, relay_url}. A
re-pair keeps the profile's redaction choice; it keeps the encryption key when
the same phone and gateway pair again, and otherwise drops it (Conduit
provisions a new one). 400 for a malformed code or URL, 410 when the
code expired or was used, 429 past 10 requests a minute, 502 when the relay
can't be reached.
Conduit updates the plugin with Hermes' own dashboard routes
(POST /api/dashboard/agent-plugins/install and
POST /api/dashboard/agent-plugins/conduit_push/update), then restarts the
gateway (POST /api/gateway/restart), then this dashboard:
GET /capabilitiesalso reportsloaded_version(the version this dashboard process imported),restart_needed(the version on disk is different) andboot_id(new with every dashboard process).POST /api/plugins/conduit_push/self/restartanswers{ok, restarting, boot_id, loaded_version, version}and a second later re-executes the dashboard with the command line it started with, keeping its pid, so systemd, launchd and terminal launches carry on. 409 while a chat turn this process runs is still going (from Hermes' turn markers); 501 on Windows, inside a plugin host, and for Hermes Desktop's own backend, where you restart Hermes yourself.
The first update to 0.24 still runs the old code, which has no restart route: restart the dashboard by hand once.
The repository also hosts the public Hermes Conduit support and privacy pages:
The static site source lives in docs/.
The runtime uses only the Python standard library plus Hermes APIs. Run the pure event tests with:
python -m pytest --rootdir=tests testsMIT