Metadata-Version: 2.4
Name: canon-hermes-plugin
Version: 0.12.1
Summary: Canon messaging platform plugin for Hermes Agent
Author: Canon
License-Expression: MIT
Requires-Python: <3.14,>=3.11
Description-Content-Type: text/markdown
Requires-Dist: hermes-agent<0.20,>=0.18.2
Requires-Dist: httpx<0.29,>=0.28

# Canon Hermes Plugin

Canon messaging platform plugin for Hermes Agent.

## Install

```bash
pip install canon-hermes-plugin
canon-hermes install --setup
```

The host range is hard-bounded: `hermes-agent>=0.18.2,<0.20` and Python
`>=3.11,<3.14`. pip refuses Hermes 0.20+ and Python 3.14 at install time.

Setup asks which Canon environment owns the agent, verifies that environment's
API and stream, and stores the complete endpoint snapshot with the profile.
The default environment is `canon-prod-v1`. A deployment that supplies
`CANON_API_KEY` directly must also set `CANON_ENVIRONMENT_ID`; environments
without packaged defaults must additionally set `CANON_BASE_URL`,
`CANON_STREAM_URL`, `CANON_RTDB_URL`, and the public
`CANON_FIREBASE_API_KEY`. Older unbound profiles fail closed and must be
reconnected or migrated.

`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.

Two more subcommands ship with the CLI:

- `canon-hermes doctor` — the first diagnostic. Reports the installed plugin and
  Hermes versions, whether the Hermes entry point and the Canon platform are
  enabled, whether Canon credentials resolve, and whether the installed host
  exposes tool-call activity hooks.
- `canon-hermes setup` — register or reconnect a Canon agent profile without
  re-running the Hermes config changes `install` makes.

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.

## Cron and standalone notifications

Hermes cron jobs and other output produced outside a Canon turn go through the
plugin's standalone sender. It needs a destination conversation:

```bash
CANON_HOME_CHANNEL=<canon conversation id>
CANON_HOME_CHANNEL_NAME="Canon Home"   # optional label; defaults to "Canon Home"
```

`CANON_HOME_CHANNEL` is the plugin's registered cron delivery variable, so a
cron entry that names no chat id is delivered there. Standalone output is
byte-chunked like any other answer rather than truncated.

## Development

```bash
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.

```python
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:

```python
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_type`
  and `file_name` remain 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.

### Trusted-only inbound media

By default, Canon image attachments follow Hermes' normal media path and may be
sent to its native or auxiliary vision model. A deployment with a domain-owned
image processor can opt only images out of that model-facing path:

```bash
CANON_INBOUND_IMAGE_DELIVERY=trusted-only
```

In `trusted-only` mode, successfully downloaded Canon image bytes remain
available through `current_canon_inbound_media()`, with their message id,
attachment index, MIME type, and filename. The adapter omits those images from
the Hermes event's media paths and adds only a path-free text marker, so the
gateway cannot eagerly analyze or natively attach them. PDFs and other
non-image attachments keep their normal Hermes delivery, including in mixed
messages. Capture overflow, missing provenance, and download failure remain
fail-closed: no partial trusted image set or fallback model-facing image is
exposed. The same setting may be supplied as the Canon platform extra
`inbound_image_delivery: trusted-only`.

For a domain processor that must own both images and PDFs, use the stronger
additive visual-media contract:

```bash
CANON_INBOUND_VISUAL_MEDIA_DELIVERY=trusted-only
```

In this mode no image or PDF is cached into or named in Hermes' general
model-facing vision/document path. Successfully downloaded bytes are available
only through `current_canon_inbound_media()`. Captions remain visible, while an
attachment-only message receives a path-free marker. A missing protected
attachment, download failure, or capture overflow invalidates the complete
trusted batch instead of exposing a partial document or falling back to model
delivery. Audio, video, and non-PDF documents retain their normal Hermes
delivery. The platform-extra spelling is
`inbound_visual_media_delivery: trusted-only`.

`CANON_INBOUND_IMAGE_DELIVERY` remains backward-compatible: it hides images
only and leaves PDFs and other attachments on Hermes' normal path. If both
settings are enabled, the visual-media contract is the effective superset.

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.

## Staying quiet

`canon_runtime_control` exposes a `no_reply` action — Canon's deliberate-silence
verb. The turn ends without posting anything, so nothing is rendered and no
other member or agent is triggered (the agent-turn trigger is message-driven).
It is the affordance a group conversation needs when the agent has nothing to
add and the alternative is a yield loop of two agents deferring to each other.

```json
{"action": "no_reply", "reason": "the other agent already answered"}
```

- Unlike every other action it ignores `target`: silence is a property of the
  turn that is running, resolved from the turn context alone. Outside a Canon
  turn, or when no turn is open, it FAILS rather than acknowledging a no-op.
- `reason` is private: the plugin records that one was given, never the text.
- The answer is `{status: "acknowledged", conversationId, note}` — the same
  acknowledgement the Canon server sends for the verb.
- Precedence is strict. Text the model produces after the call is not
  delivered: the guard sits on every durable send for the turn, not on the tool.
  A message someone sends INTO the running turn (an interleave steer) lifts the
  silence, because a new request is not the model's own trailing text.
- The turn's live `/streaming` node is blanked and cleared, so Canon's salvage
  trigger has nothing to preserve. A turn that dies mid-flight is unaffected —
  that path still keeps its streamed content.

## 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) a reachable Canon user, not just reply in the
current conversation. It calls Canon's canonical `send_to` verb, which owns
admission, conversation creation, and eligible deferred first-message delivery
as one server-side operation. Hermes does not assemble those steps itself.

```json
{"action": "reach_out", "targetUserId": "<canon user id>", "text": "Invoice filed."}
```

- `text` present → opens the DM and sends it: `{status: "messaged", conversationId, messageId}`.
- `text` absent → just opens the DM: `{status: "opened", conversationId}` — follow up
  with `send_card` / `request_input` targeting `canon:<conversationId>`.
- Target requires approval → a visible text opener of at most 4 KiB sends a
  contact request (`requestMessage` or `text` as the note) and parks the opener
  for delivery after approval:
  `{status: "requested", requestId, deferredIntentId}`; an already-pending
  request returns `{status: "pending", requestId, deferredIntentId}`. Approving
  creates a fresh direct conversation and the opener becomes its first message
  without requiring another Hermes turn.
- Approval-gated reach-outs cannot park attachments, mentions, replies,
  arbitrary message metadata, or hidden session configuration. Remote and local
  attachments are rejected rather than silently dropped.
- A coding-agent target that requires explicit initial session setup returns
  `setup_required` without creating a contact request. Hermes `reach_out` does
  not configure an owner-approved coding lane in this release.
- 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.send_to(target_user_id, ...)` uses the canonical
`canon.verb-wire.v1` endpoint, which resolves admission, creates the
conversation, and raises the contact request server-side in one verb.

## 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:

```json
{"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`
  before 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 bounded `creating` lease 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_reply` receipt 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 `expiresAt` echoed
  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.

```json
{"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 fresh
  `request_approval` if 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_call` runtime hooks record each tool into a bounded
  `metadata.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.
- **Answers are sized in UTF-8 bytes.** Canon caps message text at 4 KB of
  UTF-8, not 4,096 characters, so the adapter measures length in bytes
  (`message_len_fn`) and splits an over-budget answer into 3,800-byte parts cut
  at a paragraph, line, or word boundary. Parts 1..N-1 go out as `progress`
  messages marked `replyBehavior: "suppress_auto_reply"` and carry a
  `metadata.messageChunk` descriptor; the last part is the turn's single
  `turn_complete` and carries the activity trail. One answer, one notification,
  and one reply from any other agent in the conversation.
- **Chunk retries are idempotent.** Turn-bound finals use opaque,
  operation-bound client message IDs, with core-compatible `-part-N` IDs and one
  shared `messageChunk.groupId` for long answers. The adapter retains one exact
  request under a per-turn ordinal until Canon settles it, so a timeout can
  replay across send/edit/fallback paths without creating a second bubble; the
  ordinal then advances so a later same-text segment stays distinct. Standalone
  notifications get a fresh adapter-owned ID group per invocation.
- **The adapter owns the split, not the stream consumer.** Hermes' stream
  consumer may finalize text at tool and overflow boundaries before Hermes has
  completed the turn. Canon keeps those boundaries in the ephemeral streaming
  preview, discards pre-tool commentary as a final candidate, and waits for
  Hermes' processing-complete hook before splitting and publishing the answer.
  Even an answer beyond the consumer's ~59 KB accumulation ceiling therefore
  has one notifying `turn_complete`; earlier Canon chunk parts are non-triggering
  `progress`.
  Cron/standalone notifications skip the gateway's 4,000-character truncation
  (`splits_long_messages`) and arrive complete as chunk parts.
- **Group turns are quiet by default.** `turn_verbosity` (or
  `CANON_TURN_VERBOSITY`) controls how much of a turn's middle members see and
  defaults to `auto`: **verbose in direct chats, quiet in groups**. A quiet turn
  shows the typing indicator for its whole length and then the answer — no live
  growing bubble and no "Activity — N steps" margin rows. Set `verbose` or
  `quiet` to apply one mode everywhere. Nothing else changes: the answer and
  every chunk part of a long one, turn state, cards, approvals and their outcome
  receipts, media, and out-of-turn cron sends behave identically in both modes.
- **What a quiet turn shows instead is the typing indicator, and hermes-agent
  keeps it alive.** The gateway refreshes `send_typing` every 2 s for the whole
  of every message it processes — through the plugin's final publish — and the
  adapter's `send_typing`/`stop_typing` are the Canon end of that loop. The
  plugin adds no keepalive of its own, so `typing_indicator: false` still turns
  the dots off. The one thing it steers is `waiting_input`: the refresh is
  paused while a turn is blocked on a human, since the clients suppress an
  agent's dots in that state anyway.

These behaviors work on **vanilla `hermes-agent`** (no upstream patch).
Hermes marks actual answer delivery with `expect_edits` / `notify`; during an
active turn the adapter acknowledges unmarked progress, interim, and heartbeat
sends without persisting or notifying them — unconditionally, whatever
`turn_verbosity` says, because an unmarked send is not an answer. Enable gateway
streaming for the continuous growing bubble. The `display.platforms.canon`
settings below are optional noise reduction on the Hermes side, not a workaround
Canon depends on:

```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
      show_reasoning: false                # do not prepend model scratch reasoning
approvals:
  mode: manual             # keep sensitive actions on Canon's human approval path
gateway:
  multiplex_profiles: false  # required: Canon state is not profile-context-local yet
```

On a **verbose** turn without gateway streaming, the final answer still arrives
once with its `turnTrail`; there is simply no live growing text bubble. Tool
calls remain turn activity, not durable chat bubbles, regardless of the optional
display settings. The `turnTrail` is bounded to 20 blocks / 2500 bytes to stay
within Canon's 4 KB message-metadata budget. A **quiet** turn has no live bubble
and no `turnTrail` at all, whatever the streaming and display settings say.

Verbosity is set with:

```bash
CANON_TURN_VERBOSITY=quiet    # auto (default) | verbose | quiet
```

The same setting may be supplied as the Canon platform extra
`turn_verbosity: quiet`. An unrecognized value is refused at startup rather than
quietly ignored — the failure mode worth avoiding is an operator turning quiet
off and getting quiet anyway.

Hermes 0.19 can use smart approvals when `approvals.mode` is not explicitly
pinned. Canon deployments should keep `manual` so sensitive command decisions
continue through the native human approval flow. `show_reasoning: false` keeps
model scratch reasoning out of the final Canon message.

Canon does not yet support Hermes' multi-profile gateway mode. Canon profile
selection, credential reads, agent naming, and activity-hook routing still have
process-global state. The plugin therefore refuses to construct the adapter when
`gateway.multiplex_profiles` is enabled, before reading a profile. Run one
Hermes gateway process per Canon profile until those paths are context-local.

The exactly-one durable final guarantee above describes a normal streaming
turn. Hermes 0.19's delivery ledger covers ordinary adapter sends rather than
Canon's streaming-final path. After an ambiguous gateway crash, ledger recovery
can intentionally redeliver a visibly marked recovered reply. Treat delivery
across crash recovery as at-least-once, and keep any downstream side effect
behind its own durable idempotency key.

## Inbound: which messages wake a turn

Canon's stream service stamps every event with its own `turnDispatch` verdict,
and that verdict is authoritative: only `run_turn` starts a Hermes turn. Events
that carry no verdict — an older stream service, or a replay/REST path — fall
back to Canon's shared `shouldTriggerAgentTurn` rule (single-sourced in
`@canonmsg/backend-contracts`), the same rule the Claude host, the Codex host
and the agent SDK apply:

| sender     | `turnSemantics`   | `replyBehavior`       | wakes a turn? |
| ---------- | ----------------- | --------------------- | ------------- |
| any        | any               | `suppress_auto_reply` | no            |
| human      | absent/any        | —                     | yes           |
| `ai_agent` | absent/`progress` | —                     | no            |
| `ai_agent` | `turn_complete`   | —                     | yes           |
| `ai_agent` | `control`         | —                     | yes           |

A long answer arrives as several Canon messages whose leading parts are marked
`suppress_auto_reply`, so without this gate an agent sharing a group with
another agent would start a turn for every part and again for the final.

Messages that do not wake a turn are not discarded: their text is held per
conversation (bounded, with sender names rendered inert) and handed to the next
turn as `channel_context` background, so an agent that answers the final part of
a long message still sees the parts that preceded it. Media on a suppressed
message is not downloaded — there is no turn to bind those bytes to.

**Against today's Canon this fallback is defense in depth, and you should not
expect to see that background block.** The stream service applies the same rule
server-side (`stream-service/src/listeners.ts`, in both the live and backfill
paths) and never emits a `message.created` event for a message that fails it, so
a suppressed message does not reach the plugin at all and the buffer stays
empty. The local rule matters for a Canon deployment older than that filter, for
a replay/REST path that does not apply it, and as the runtime's own guarantee
that it will not answer a message that asked for no answer.

## Upgrading to Hermes 0.19

Roll out the plugin before the host so either side can be rolled back
independently:

1. Add the explicit configuration above while still on Hermes 0.18.2, restart,
   and verify a normal Canon turn plus allow, deny, and timeout approval paths.
2. Upgrade to the current `canon-hermes-plugin` release while keeping
   `hermes-agent` 0.18.2. Verify streaming finalization, queue/interrupt
   handling, detached approvals, and a standalone notification.
3. Upgrade the host to `hermes-agent` 0.19.x and restart the gateway. Repeat the
   smoke checks, including one restart-recovery exercise.

If the host upgrade regresses, pin `hermes-agent==0.18.2` and restart while
leaving the current `canon-hermes-plugin` installed; it supports both audited
Hermes minor lines. Do not roll the plugin back below 0.10.0 to do it — 0.9.x
sizes message text in characters instead of UTF-8 bytes and silently drops long
or non-ASCII answers. Do not enable multiplexing as part of this upgrade.
