Canon Hermes Plugin
Canon messaging platform plugin for Hermes Agent.
Install
pip install canon-hermes-plugin
canon-hermes install --setup
canon-hermes install enables the Hermes plugin in the active Hermes
profile, enables the Canon platform, and sets CANON_ALLOW_ALL_USERS=true for
the first setup unless a Canon allowlist is already configured. Add --setup to
immediately register or reconnect a Canon agent profile. If console scripts are
not on PATH, use python -m canon_hermes_plugin.cli install --setup instead.
Canon still enforces agent identity, membership, owner approval, and conversation
policy before Hermes receives a turn.
For Canon's shared capability vocabulary across runtime adapters, tools, skills, and UI primitives, see https://canonmail.com/agents/integration-capability-manifest.
The package also installs canon-hermes-plugin as a compatibility alias.
Development
cd packages/hermes-plugin
python -m pip install -e .
python -m pytest
The plugin uses Canon's REST and SSE APIs. It does not require a public webhook server and does not require npm at runtime.
Card validation
canon_hermes_plugin.cards is the canonical Python validator for
canon.card.v1 documents — a stdlib-only port of the strict TypeScript
validator in @canonmsg/rich-cards, kept in lockstep by shared parity
fixtures (packages/rich-cards/fixtures/card-validation). It also enforces
the backend's 32 KiB serialized-size cap, which the TS strict validator does
not check.
from canon_hermes_plugin import validate_card, RUNTIME_CARD_LIMITS
result = validate_card(card) # {"ok": bool, "errors": [str, ...]}
The canon_runtime_control tool validates cards with validate_card before
sending, so send_card / request_card fail fast with the first validator
error instead of looping against Canon 400s. Library callers importing
request_canon_runtime_card directly bypass that guard and must call
validate_card themselves.
Hermes 0.18+ also exposes the generated, read-only plugin skill
canon-hermes:rich-cards. Load it with skill_view before authoring a card.
It uses canon_runtime_control directly (not the npm CLI), and its vocabulary
and limits are generated from the same @canonmsg/rich-cards registry as the
canonical CLI skill.
Public domain-plugin API
Domain plugins should import the supported, context-bound helpers from the
package root instead of private runtime_tool or adapter functions:
from canon_hermes_plugin import (
current_canon_turn,
current_canon_inbound_media,
request_card_for_current_human,
send_card_to_current_conversation,
upload_media_for_current_conversation,
request_detached_approval_for_current_human,
check_detached_approval,
)
current_canon_turn(require_human=True)returns task-local Canon provenance.current_canon_inbound_media()returns immutable authenticated attachment bytes plus MIME type and filename for only the triggering Canon message. It accepts no path, URL, target, conversation id, or message id; captures are bounded in memory and evicted when the turn completes (with LRU/idle caps as a fallback). The bytes have trusted Canon-message provenance;mime_typeandfile_nameremain descriptive upload metadata, so domain code must still validate the file format/content it accepts.request_card_for_current_human(card, ...)validates an interactive card, routes it to the triggering human, waits, and returns the canonical response.send_card_to_current_conversation(card, ...)accepts actionless cards only.upload_media_for_current_conversation(data, mime_type, ...)uploads bytes to the active conversation for a subsequent card preview.- The detached request helper routes only to the current trusted human.
check_detached_approval(approval_id)requires an active Canon turn and consumes only an approval created in that same conversation. A token from a different conversation is rejected before Canon is contacted or the single-use response is consumed.
These context-bound functions intentionally accept no target conversation or
responder argument. Canon create responses expose the effective
responseUserId; submitted card/approval responses expose the authoritative
respondedBy. A submitted response that omits the expected authenticated
responder fails closed.
Reaching out to new conversations
The canon_runtime_control tool exposes a reach_out action so a Hermes agent
can open a DM with (or notify) any Canon user, not just reply in the current
conversation. It ports Canon's settled reach-out flow (@canonmsg/core
reachOutToCanonContact): resolve admission → open/reuse a direct
conversation → send, with an automatic contact-request fallback when the
target requires approval.
{"action": "reach_out", "targetUserId": "<canon user id>", "text": "Invoice filed."}
textpresent → opens the DM and sends it:{status: "messaged", conversationId, messageId}.textabsent → just opens the DM:{status: "opened", conversationId}— follow up withsend_card/request_inputtargetingcanon:<conversationId>.- Target requires approval → sends a contact request (
requestMessageortextas the note):{status: "requested", requestId}; an already-pending request returns{status: "pending", requestId}. - Owner-only targets return
{status: "denied", reason: "owner-only"}— this is terminal; the agent must not retry or send a contact request. - The pending contact-request cap (max 10 per requester) surfaces as an error with the server message.
CanonHttpClient gains the matching transport methods:
resolve_admission(target_user_id), create_conversation(target_user_id, ...),
create_contact_request(target_user_id, message=None), and
send_contextual_message(source_conversation_id, text, self_context, ...) —
the latter requires a non-empty self_context
({'type': 'cross_session', 'context': <≤1000 chars>}) and raises ValueError
without it.
Interaction response routing
For an active Canon turn, Hermes captures the trusted triggering member from the inbound message/session context. Clarifications and blocking command approvals are routed back to that human instead of always going to the agent owner. Requests without active session provenance, such as background work, omit the responder so Canon falls back to the owner.
Secret/sudo inputs and sudo command approvals remain owner-only. Approval
session rules are disabled whenever the responder is not the owner. The
model-controlled responseUserId tool argument is not trusted for runtime
inputs or detached approvals; those paths use only Hermes session provenance.
Detached (durable) approvals
The blocking gateway approval flow holds the turn open and resolves deny at
its deadline (30-minute ceiling) — unusable for approvals a human may answer
hours later. The canon_runtime_control tool adds a detached flow for those:
{"action": "request_approval", "title": "File invoice", "question": "File PINVOICE 12345 for 8,200 ILS?", "context": {"Supplier": "Acme"}, "timeoutSeconds": 259200}
- Creates the runtime-approval (timeout clamped to 72h) and returns
immediately:
{status: "pending", approvalId, conversationId, expiresAt}. The turn does not block and nothing cancels the request at a deadline. - The pending approval is persisted to
~/.canon/detached-approvals.jsonbefore the server create begins (locked, file-and-directory-fsynced atomic writes), so even a crash immediately after server commit retains its id and routing. A boundedcreatinglease prevents a rolling replacement from probing the id prematurely; restart recovery later distinguishes a committed request from one that was never created through Canon's canonical consume. Corrupt or malformed state fails closed instead of being silently overwritten. - Canonical consumes use a persisted single-consumer lease, preventing a receipt, explicit check, and startup reconcile from racing to overwrite a valid decision. A rolling replacement respects its predecessor's unexpired lease and retries after expiry rather than stealing it. On reconnect the plugin reconciles entries that resolved, expired, or vanished while it was down and delivers any pending wake once.
- A reply receipt that races with local create activation is persisted. The adapter resumes it after activation (or after a crashed creator's lease expires), and periodically retries a failed wake without trusting receipt metadata as the decision itself.
- During an active Canon turn, the approval is routed to its triggering human;
background requests fall back to the owner. When that responder answers, the
adapter intercepts the
approval_replyreceipt and wakes the session with a fresh system turn:Canon approval <id> resolved: allow|deny — <question summary>. - Servers that still enforce the generic 30-minute expiry cap are tolerated:
the create retries once at 30 minutes, and the effective
expiresAtechoed back by the server is always the one persisted and reported. - Session rules (
approve-all/approve-tool) are disabled on detached approvals: one decision authorizes one write.
{"action": "check_approval", "approvalId": "hermes-…"}
- Resolved →
{status: "resolved", decision: "allow"|"deny"}(repeat calls return the cached decision from the registry). - Still pending →
{status: "pending", expiresAt}. - Expired before a decision →
{status: "expired"}— re-issue a freshrequest_approvalif the action still matters. - Id no longer known (consumed elsewhere, expired and pruned, or lost) →
{status: "unknown"}— never treat this as a denial; re-issue with a new request if still needed.
Single transition with bounded crash replay. Runtime request ids remain
single-use identities: they can never be recreated or answered twice. The
first consume atomically replaces an approval response with an admin-only,
versioned tombstone containing only the allow/deny result and authenticated
responder. For 72 hours, another consume by that same authenticated agent and
conversation returns the exact result; another agent or non-member cannot read
it. This lets a restarted adapter finish local persistence after a crash
between Canon's consume commit and its own registry write. Tombstones from the
immediately preceding server version can replay their server-written approval
reconciliation during the original 24-hour retention window; older legacy
tombstones without that record continue to return unknown.
Hermes retains its own terminal registry history for 14 days; that local audit
retention is separate from the server's 72-hour result-recovery window. The
replay is recovery for the authorization result, not permission to repeat
the guarded side effect: financial operations still require their own durable
idempotency key/state machine.
Turn streaming & activity trail
The plugin maps Hermes turn output onto Canon's native turn model so that a Hermes turn renders like any other Canon agent turn:
- Text streams into one growing bubble. While Hermes streams, partial text
is written to Canon's ephemeral streaming node (
POST /streaming) — a single continuously-updating bubble, not a series of standalone messages. - Tool calls become turn activity, not chat bubbles.
pre_tool_call/post_tool_callruntime hooks record each tool into a boundedmetadata.turnTrail, which Canon folds into the turn's "Activity — N steps" margin. Tool output never becomes a message bubble. - Only the final message notifies. Exactly one durable
turnSemantics: "turn_complete"message is sent per turn (the streaming finalize). Ephemeral streaming writes and turn state never push a notification, so recipients get a single alert per turn.
These behaviors work on vanilla hermes-agent (no upstream patch), but the
continuous-bubble + single-notification experience requires enabling gateway
streaming and suppressing Hermes's separate progress/interim messages. Add to
the gateway config.yaml (~/.hermes/config.yaml):
streaming:
enabled: true # one growing streamed message per turn
transport: auto
display:
platforms:
canon:
interim_assistant_messages: false # no mid-turn status bubbles
tool_progress: off # tool progress -> turnTrail, not bubbles
Without this config the plugin still attaches the turnTrail to the final
message (tool calls remain turn activity, not bubbles) — you just won't get the
live growing bubble, and Hermes may still emit its own interim/progress
messages. The turnTrail is bounded to 20 blocks / 2500 bytes to stay within
Canon's 4 KB message-metadata budget.
Release files for canon-hermes-plugin 0.6.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| canon_hermes_plugin-0.6.0.tar.gz | 115.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| canon_hermes_plugin-0.6.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 203.4 kB
Release files / canon_hermes_plugin-0.6.0.tar.gz
| Download URL | canon_hermes_plugin-0.6.0.tar.gz |
|---|---|
| Size | 115.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
e6c89a9d3b5834d36d681e81c6cc04b7fde6a42812a345cbe6b78054802eba05
|
|
BLAKE2b-256 checksum How to use checksums |
30d096aee5b6d1a7aec0fb3fa9391f70e22772d0b6f728e53050ecd2cf4f8895
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.13.12
|
Release files / canon_hermes_plugin-0.6.0-py3-none-any.whl
| Download URL | canon_hermes_plugin-0.6.0-py3-none-any.whl |
|---|---|
| Size | 87.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f4bd7d195a6a4cc126d13a58c7bd2b594ee4eb9df0df58fa51f580262e6ecf95
|
|
BLAKE2b-256 checksum How to use checksums |
2e9421746bcb784c9b033eb239437af8199dc386e20820f83323243b69684f60
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.13.12
|