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.
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.
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.json(atomic writes), so it survives container restarts; on reconnect the plugin reconciles entries that resolved, expired, or vanished while it was down. - When the owner 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.
At-most-once semantics, deliberately. Runtime request ids are single-use: consuming a decision tombstones it server-side. If the process crashes between the consume and the guarded write it authorized, the allow is orphaned — there is no replay; the only recovery is a full re-issue with a fresh approval id and a fresh human decision. For financial writes this is the intended failure mode (losing an approval is recoverable; double-filing an invoice is not).
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.4.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.4.0.tar.gz | 81.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| canon_hermes_plugin-0.4.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 147.8 kB
Release files / canon_hermes_plugin-0.4.0.tar.gz
| Download URL | canon_hermes_plugin-0.4.0.tar.gz |
|---|---|
| Size | 81.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
813bfb27b1db845d05fa719a383f252990ef36944eaa618bf0cf17ea48f0fbeb
|
|
BLAKE2b-256 checksum How to use checksums |
6a868f0482a57ed274b21a9b03960bfd94bb5d6d6d522c6c9db4bc2466531692
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.10.9 {"installer":{"name":"uv","version":"0.10.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / canon_hermes_plugin-0.4.0-py3-none-any.whl
| Download URL | canon_hermes_plugin-0.4.0-py3-none-any.whl |
|---|---|
| Size | 66.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
cbd53f661aa83239912807c578e286b2e6b17a4f1b21db33283ceba050c93cbf
|
|
BLAKE2b-256 checksum How to use checksums |
637e1220cd503dddf556f4bd8d983570b8aaf2a5507f730191da56e1fbd2d414
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.10.9 {"installer":{"name":"uv","version":"0.10.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|