Skip to main content

Hermes adapter

protagine-hermes is one wheel with two Hermes plugins:

  • protagine (general): turn capture, the guard, /mind, and the self, people, opinions, memory and reminder tools
  • protagine-memory (memory provider): per-turn recall through /v1/host/context/assemble, turn sync when the general plugin is absent, and the owner's commitment and affect writes

Both run on stock Hermes (hermes-agent >= 0.21.3, < 0.22) with no patches.

Install

pipx install protagine && protagine init && hermes gateway restart

protagine init installs protagine-hermes into the Python of the hermes executable, writes the config keys below and creates the protagine-act worker profile. protagine upgrade repeats the adapter install when the version changed.

plugins:
  enabled: [protagine]
  hook_callback_timeout: 0          # callbacks run inline; overlapping fires are not dropped
  protagine:
    sidecar_url: http://127.0.0.1:7777
    key_file: ~/.protagine/api.key  # one line, mode 600
memory:
  provider: protagine-memory
kanban:
  dispatch_in_gateway: true         # the plugin's heartbeat is the dispatch tick
skills:
  external_dirs: [~/.protagine/skills]   # Protagine's own skills (with faculties.skills on); nothing else writes there
security:
  protected_instruction_extra_patterns: [protagine.yaml, identity.yaml, api.key]

Every request to the sidecar carries Authorization: Bearer <key>. The key is bound to the contacts and channels it may write as (CREDENTIALS.md): it writes a turn as the owner only from the platforms protagine.yaml credentials.api-key.owner lists (by default WhatsApp, SMS, RCS and voice).

Seams used

Need Stock seam
Who is speaking pre_llm_call (session_id, platform, sender_id, user_message), kept in a bounded session map
Turn capture post_llm_call enqueues one row in <hermes_home>/state/protagine-turn-outbox.sqlite3; the body thread delivers it to POST /v1/host/turns/sync
Recall memory provider prefetch
Guard pre_tool_call; a block directive fails closed, a floor match becomes an approve directive (Hermes' human-approval gate, which fails closed in a worker)
Heartbeat on_kanban_dispatch_tick wakes the body thread; stock fires it from dispatch_once in the one process holding the dispatcher lock, so it also marks this process as the board's writer
Pause agent.estop.is_engaged() (hermes pause): the body creates and sends nothing while it holds
DM lookup the stock session rows (hermes_state_registry, list_gateway_sessions): the DM chat the gateway has had with a sender, for handles that name a user rather than a chat (Discord)
Durable work hermes_cli.kanban_db.create_task(idempotency_key="mind:<id>", assignee="protagine-act", ...); an existing non-archived task with the key is returned, never duplicated
Messages tools.send_message_tool.send_message_tool({"action": "send", "target", "message"}), text verbatim
Task outcomes the mind:* task's status plus its latest task_runs row (kanban_db.latest_run)
Owner commands register_command("mind"): read-only in chat plus off; pre_gateway_dispatch drops a /mind whose sender is not the owner, since the command handler gets no sender
Tools register_tool; handlers receive session_id, which the session map resolves to a sender
Reminders stock cron: a one-shot job whose id is the only thing the plugin keeps
Skill loads on_skill_lifecycle: a loaded fact for a protagine-* skill is queued in the turn outbox as a skill_use row, which the body delivers to POST /v1/mind/skills/used; every other skill and action is Hermes' own
Skills index skills.external_dirs lists <instance>/skills; when /v1/mind/state reports a new skills.generation, the body calls agent.prompt_builder.clear_skills_system_prompt_cache() so the next session lists the change (stock caches the index per process with no file times in its key); without that function a change shows after a restart
Secrets a second pre_gateway_dispatch seals a gateway message's secrets (and the quoted message and backfill that come with it) before Hermes sees them; register_middleware("tool_request") serves the owner's sealed values to his own calls (into a send's or a write's words, or a command's or code's environment for that call) or hides them in a worker's; a second pre_tool_call holds a call whose value waits for the owner or cannot be used there; post_tool_call removes a call's private value files unless the program that took one still runs; register_redaction_patterns and agent.redact.register_vault_redaction_value keep used values out of model-facing output; protagine_secret (see Secrets)

Owner and guest

The owner is the sender whose handle is listed in identity.yaml (owner.handles.<platform>) or whose resolved contact is owner.contact_id. Turns without a sender on the CLI, cron or API lanes are the owner's own for the plugin's tools; a cron run (platform: cron) is a stored prompt rather than the owner typing, so it cannot answer an ask, rate, change a permission or forget. In memory a turn is the owner's only when it arrives on a platform the key is granted the owner on: every turn names its platform (the sender's, the lane itself: cli, cron, api, or the platform the memory provider was initialized on when no session binds one). A turn the sidecar refuses (owner_not_granted) is never sent again as another contact: the adapter logs it and counts it as a failed capture (the outbox row keeps the refusal and /mind status counts it; the memory provider's diagnostics count attribution_refusals). Grant the CLI with protagine init --owner-platform ... or protagine credentials set api-key --owner-platform ... to keep a CLI conversation as the owner's. Any other sender is a guest: session_search is blocked for guests, delivering cron jobs need a recipient whose may_contact is not never, every mutation through protagine_self, protagine_people and protagine_memory_forget is refused, protagine_people who without a name is refused (a guest may ask who one named person is, never list the contacts), and the plugin drops a guest's /mind at pre_gateway_dispatch, because Hermes hands the command itself no sender.

Mind-originated runs

A kanban worker spawned on the protagine-act profile is a mind-originated run (the dispatcher sets HERMES_KANBAN_TASK and HERMES_PROFILE). In it:

  • kanban_create only with assignee: protagine-act, no mind: key, no project override and no workspace outside HERMES_KANBAN_WORKSPACE
  • write_file and patch only inside HERMES_KANBAN_WORKSPACE, every V4A header target included
  • mind.deny.tools and mind.deny.text from protagine.yaml block; floor patterns (money, irreversible deletion, credentials, bulk messaging) ask
  • messaging tools and delivering cron jobs need a permitted recipient: the plugin resolves a job's effective deliver and failure_deliver contacts (a stored job's when an update does not resend them) and hands them to the sidecar as recipients, which applies may_contact and the message budgets to each of them exactly as it does to a send_message recipient
  • with mind.enabled: false or autonomy: off, every effectful tool is blocked

The guard asks POST /v1/mind/guard {tool, args, session, run, task_id, owner, contact_id, platform, sender_id, recipients} with a 2 s timeout when the sidecar serves the mind routes and reads {allow, reason} (ask: true becomes Hermes' approval gate in the owner's and the mind's runs); silence blocks effectful tools, a 404 falls back to the rules above. Read-only tools never wait on the sidecar.

In a contact's (non-owner) session nothing becomes Hermes' approval gate: in a gateway that gate posts its prompt to the session's own chat and waits for /approve or a bare "yes" from it, so the contact would be the one approving. There an ask verdict is refused, and every refusal is the plugin's one final answer (retry: false), worded for a reply the contact reads. The final response is delivered to the session's own chat and nowhere else: the chat the gateway bound for the turn (a group's chat in a group), or, with no gateway, the sender's direct chat. A message to that chat is answered with "your final response is delivered to this conversation as your reply": the reply already is that message. Anything else, another handle of the sender's and a direct message to the sender from a group included, is a recipient for the sidecar's verdict; a target no contact is known at is answered "nothing was sent: no contact is known at that target".

The body: the mind's effects on Hermes

The body thread runs in every Hermes process, but only the one that owns the kanban dispatcher writes to the board: stock fires on_kanban_dispatch_tick from dispatch_once in the process holding the singleton dispatcher lock, and the body runs steps 2 to 5 only after it has seen that tick. A CLI session, a gateway that lost the lock and a kanban worker drain the turn outbox only; the first two also read GET /v1/mind/state on every tick for the skills generation (step 1), since Hermes caches the skills index per process. While hermes pause holds (the stock ESTOP sentinel) steps 2 and 3 wait as well. Once the sidecar serves /v1/mind/*, the body does this on every tick (about every 60 s, and at once when the dispatch tick or a captured turn wakes it):

  1. GET /v1/mind/state. With enabled: false there, or mind.enabled: false in protagine.yaml, steps 2 and 3 are skipped and every unstarted mind:* task is archived; running workers end on their own. None of this needs a model endpoint. When skills.generation moved since the last tick (the sidecar wrote or removed one of its skills), Hermes' skills prompt cache is cleared; the first value a process sees is only recorded.
  2. GET /v1/mind/dispatch → for each kind: task intention one kanban_db.create_task(title, body, assignee="protagine-act", idempotency_key=dedup_key or "mind:<id>", workspace_kind="scratch", created_by="protagine", priority, max_runtime_seconds, max_retries, goal_mode, goal_max_turns), then POST /v1/mind/dispatch/{id}/bound {hermes_ref, hermes_kind: "kanban", status, bound_at}. A lost ack or a restart creates no second task: the key returns the existing one, and a task with the key that was archived in between (stock's lookup skips archived tasks) is bound and settled as cancelled rather than recreated. Messages and notices never travel through dispatch. Runtime and retries are the task kind's (mind.budgets.task_types, else task_max_runtime_s / task_max_retries), and fall back to that flat pair when a payload carries none.
  3. GET /v1/mind/outbox → for each message, in order: skip it if the body's own ledger has begun it before; POST /v1/mind/outbox/{id}/sending {target, at} (any non-2xx: not ours); record sending locally; send verbatim with send_message_tool; record the result; POST /v1/mind/outbox/{id}/sent {result: sent|failed|uncertain, error, hermes_ref, at}. success from the tool is sent; an error raised before the platform call (unknown target, unconfigured platform, no home channel) is failed; Send failed: ... or an exception is uncertain. A process that stops between sending and sent reports that message uncertain at the next start and never sends it, whatever the sidecar lists. The target is the message's target (platform:chat_id), else recipient.platform + recipient.chat_id|address|handle, else, for the owner (recipient_is_owner, the owner contact or no recipient), the first handle in identity.yaml owner.handles (the [{platform, id}] list protagine init writes, or a {platform: [ids]} mapping), else the first of the sidecar's recipient_handles ({gateway, address}, primary first). A handle names a sender; where that is not a chat (a Discord user id) the target is the DM session stock has recorded with that user, and the handle itself when none has been seen yet. The sidecar answers sending with 409 for a message it already handed out, and marks a claim nobody settled uncertain after ten minutes. A claim that names no target (target: null, the body resolved no handle) is also 409 (no_target) and is not a claim: the message stays ready, never failed, and the next pull offers it again with recipient_handles read afresh, so a handle added later lets it go out.
  4. Reconciliation: for every mind:* task, GET /v1/mind/why/{id}. A 404, or a lifecycle status in proposed | asked | denied | expired | cancelled | dropped, means no dispatched intention owns the task and it is archived (the ask backstop of architecture 7.7). Otherwise, when the task is done | blocked | review | archived or its latest run has ended, one POST /v1/mind/outcome {id, hermes_ref, hermes_kind, status, outcome: done|blocked|failed|cancelled|uncertain, final, summary, error, verified: "hermes_failure"|null (the sidecar honours the claim only for a failure with a reason, and never takes ownerorcheck from the body), run: {id, outcome, status, profile, started_at, ended_at}, block_kind, consecutive_failures, completed_at, observed_at} per state change (a failed run that Hermes requeues is one failed, final: false outcome; a task parked blocked with its failed run once consecutive_failures reached max_retries is failed, final: true; a kanban_block is blocked; a 404 archives the task). The body remembers what it posted in <hermes_home>/state/protagine-body.sqlite3 so an outcome is posted once.
  5. POST /v1/mind/observations {observed_at, board, body: {pid, profile, started_at, interval_s, ticks, mind_ticks, last_tick_at, last_mind_tick_at, last_pull_at, stale}, counts: {status: n}, stale_tasks, blocked_tasks, goals, mind_tasks} when the board changed and at least every 5 minutes. Task entries carry id, title, status, assignee, created_by, created_at, started_at, age_s, idle_s, block_kind, goal; a stale task is an open non-mind task idle for mind.stale_task_hours (default 72); goals are kanban goal-mode tasks; mind_tasks add intention_id and the latest run.

Every GET /v1/mind/dispatch is the body's heartbeat: a sidecar whose last pull is older than 5 minutes stops deliberating (architecture 3.1).

protagine_hermes.tick() runs one full tick on the caller's thread: POST /v1/mind/tick (the sidecar forms intentions now) and then the body pass above. The paired benchmark's body tick calls it before Hermes cron and kanban dispatch, with PROTAGINE_BODY_THREAD=0 so the body's own thread stays parked and nothing lands between two observed ticks; a gateway never needs either.

Asks

An ask lives only in the sidecar and creates nothing in Hermes until approved. The owner answers with protagine mind yes|no <code>, or in chat by typing the code ("yes K7F"): the model calls protagine_self yes/no and the plugin sends POST /v1/mind/decide {code, answer, session_id, contact_id, message} only when the session's sender is the owner, the turn is not a kanban worker, and the owner's own message for that turn contains the code as a whole word; the sidecar checks the contact and the message again and answers {ok, id, status, ...} (404: no open ask with that code, 403: not the owner). A code the owner's message does not carry is never a guess away: with no ask open, or none typed, the answer is final; a miscopied code on a message that carries an open ask's code names that code. A guest, a worker whose task body quotes the code, or a page injected into an owner session cannot approve. protagine_self rate {id, verdict} posts POST /v1/mind/rate for the owner only. log and why are the owner's too, and a guest's state says only whether the mind is on and at what level: the log and the asks name what the owner asked about other people.

People

protagine_people is the model's view of the people store over /v1/mind/people (the sidecar side is sidecar/protagine/api/routers/people.py):

Operation Who Route
who {contact_id} everyone (listing: the owner) GET /v1/mind/people?q= (a name, handle or id; empty lists the newest, for the owner only)
inspect {contact_id} everyone GET /v1/mind/people/{who}
propose_link {contact_id, handle: gateway:address} everyone POST /v1/mind/people/link: a candidate the owner confirms as an ask
set_permission {contact_id, permission} owner POST /v1/mind/people/{who}/permission {may_contact: never|ask|auto}
set_cadence {contact_id, minutes} owner POST /v1/mind/people/{who}/cadence {minutes} (0 clears)
merge {contact_id, drop} owner POST /v1/mind/people/merge {keep, drop}: drop folds into contact_id; handles, sources, comms and affect follow the person

A guest sees who someone is (contact_id, display_name, trust_tier) and nothing else: the plugin names the guest as the viewer (contact_id) on its reads and the sidecar answers with only those fields, and only for the one person the reference names exactly (no listing, no partial matches, no 404 candidates), so a guest cannot browse the contact list. The owner also sees may_contact, cadence_minutes, last_interaction_at, the per-contact digest and the handles. The three mutations are refused in the plugin outside the owner's own interactive session (a guest, a kanban worker, a cron run) and are sent with the owner as contact_id, which the sidecar checks again (403 not_owner). protagine people who|inspect|permit|cadence|merge|link|proposals is the same interface from the CLI. may_contact is raised nowhere else; a contact's opt-out ("STOP", "don't text me", ...) only lowers it to never.

protagine_self also carries the agent's recorded opinions (one tool, not a separate one, so the tool schemas stay within their budget): opinions with an optional query and why <number> read GET /v1/mind/opinions with the session's contact, so a guest sees only everyone-audience views (an intention id is never all digits, so why on any other id is still the owner's record); withdraw and reconsider <number> with the owner's reason post POST /v1/mind/opinions/{id}/withdraw|reconsider from the owner's own interactive session only, never a guest, a kanban worker or a cron run (docs/OPINIONS.md). The guard treats the tool as read-only.

Secrets

A secret that arrives in conversation is sealed into the sidecar's store and a reference stands in its place (docs/SECRETS.md). The plugin's half (secrets.py):

  • pre_gateway_dispatch, after the /mind gate: when the plugin's copy of the detector (byte for byte the sidecar's) finds a value in a gateway message, it posts POST /v1/secrets/seal {text, owner | contact_id} and returns {"action": "rewrite", "text"}; Hermes, its transcript and the model see the reference. The owner is named as such (the sidecar scopes the value to its owner), anyone else by their resolved contact or, when no contact has the handle, handle:<platform>:<id> (never created). The event's other words that Hermes puts in front of the model are sealed in place the same way: the quoted message (reply_to_text, in its author's scope) and a channel's backfill (channel_context, in nobody's). Without the sidecar each value is withheld; a value the sidecar returns as it came passes only in a span its answer marks as released. secrets.capture.ingress: false turns this off; a message with no detected value is never sent there. The section is validated as the sidecar validates it: a malformed one leaves every rule on.
  • tool_request middleware: in the owner's own session a call carrying a reference has the value served (POST /v1/secrets/resolve {purpose: fill, lane: owner, own_refs, action, destination}, 32 references a request, more in turns) when the reference was sealed from the owner's own words in his latest message in that same chat (own_refs: what the gateway hook sealed from his text, never from a quote, a backfill, an expanded file or another chat); otherwise the sidecar forms a one-tap ask bound to the digest of the complete call, the plugin puts it to the owner in his chat directly, and the second pre_tool_call hook holds the call with its words. A value goes as text only into a send's or a write's words (message, content, new_string and the like) and into a waiting process's input as the whole of it. A terminal command or Python code never carries a value: it names $PROTAGINE_SECRET_<id> (the value) or $PROTAGINE_SECRET_<id>_FILE (a private 0600 file holding it), and the middleware hands the command to a subshell that sets both from the file (bind_shell) or runs the code with both in the interpreter's environment until it ends (bind_python); the command or code is never read or changed (a command follows the lines that set its values as it was written, so Hermes still reads its words, a sudo included; code goes as one string literal). A reference written into a command or code is refused with the variable to name, and a command or code naming a variable the plugin did not set for it is held. Values reach commands and code on the local backend only. The program that takes a file records its process and removes the file when it ends; post_tool_call removes a call's files when no program holds them, and a sweep removes those whose program is gone or that none took within an hour. write_file and patch in his session are served without an ask; a reference where no value goes (a patch's old_string) holds the call. In a mind run a worker's message to anyone but the owner has every detected value and reference replaced by "(a private value, withheld)".
  • The body resolves an outbox message's references just before it claims and sends it (purpose: send, in batches of 32); the sidecar allows only the owner's own values, to the owner or in his dictated words, and the body hides any other for a contact. When any reference is not settled for now (the sidecar unreachable or without an answer for it, the key unavailable) the message waits for a later tick: nothing is claimed or sent.
  • protagine_secret (list, use, reveal, release, forget) works in the owner's own direct session only; a guest, a cron run and a worker are refused. reveal sends the value to the owner's own chat itself (Hermes would mask it in a reply) and tells the model only that it did; in a session with no chat it writes it to a private file (0600, removed within half an hour) and tells the model only where it is.
  • A delivered turn-outbox row keeps its turn id and envelope digest and drops its payload; a rejected one (last_error "rejected: ...") keeps it in the private outbox file, the turn's only copy, for repair.

Memory provider

prefetch assembles context for the turn's participant. A guest request sets audience: viewer, and the sidecar returns a guest only that contact's scoped sections, never an owner-only one. A kanban worker's prefetch names its task (context.metadata.kanban_task): its task body already carries its lessons, so the sidecar adds no turn lesson and logs no lesson use for it. A scheduled job's prefetch (Hermes builds its agent with platform cron) says so (context.metadata.run: cron): a job's prompt is not the owner's turn, so the sidecar leaves the owner's status block out of it. Hermes waits for the whole prefetch, so each one has one deadline from its start (Hermes' external prefetch bound less the provider's 0.5 s margin): the participant's resolution, the assembly and the Current Time refresh all end by it, each bounded in wall-clock time as a whole (an HTTP client's timeout bounds each read, so an answer arriving in slow chunks would outlast it): past the deadline the provider stops waiting and closes that request's connection. The assembly request names what is left (wait_ms), the sidecar's recall spends only that (docs/RECALL-HYBRID.md, Recall latency), and the Current Time refresh gets what the assembly leaves (every refresh while sections are held back included); with nothing left the turn carries the provider's own clock line. The scoping fails closed: anyone the sidecar cannot show to be the owner, a caller without the key included, gets the contact-scoped set. Recall includes this session's own earlier turns: Hermes compacts sessions in place, rebuilds agents after an idle eviction or a restart and compacts on detached agents, so no provider instance knows whether the model still reads them verbatim. The provider's direct tools are offered on the owner's own lane only: a guest session or a channel with no sender binding gets none of them, and a call that still arrives, like protagine_memory_search for a turn with no resolved participant, is answered once with {"unavailable": true, "retry": false, "reason": ...} rather than an error the model retries. sync_turn is active only when the general plugin is not enabled; otherwise the outbox owns capture. on_pre_compress writes a checkpoint through the same outbox before Hermes compresses a session.

Hermes appends each turn's recalled context to that turn's user message and replays it byte for byte on every later turn (the api_content sidecar). The provider keeps a fingerprint of each context section it showed, per session, and a section unchanged since the session's last shown turn is one line in the new turn's context: unchanged since your last turn: <section>. Only the new turn changes; earlier turns are never touched, so the cached prompt prefix holds. Current Time is always sent. A prefetch counts as shown only once its turn completed (sync_turn, called whoever writes the turn), only when Hermes waited for it (its 8 s external prefetch bound) and only when it went inline whole (under Hermes' spill cap). The fingerprints reset on every session switch (including /undo), before compression (on_pre_compress) and on restart; the gateway's detached compaction evicts the live agent, whose provider starts empty.

The context never outgrows Hermes' inline cap (hooks.output_spill.max_chars, 10,000 by default), which would replace the whole of it with a head, a tail and a file path. The provider names its inline allowance (inline_chars, the cap less 256 for the clock it adds) and the sections it will send as one line (shown, their fingerprints); the sidecar's per-turn selection fits the context to that allowance, and the turn's resident blocks are fitted to the same budget: a hard limit per block, fresh state admitted first, whole lines kept and a marker for what was held back, and a Held back this turn section naming any block that did not fit (sidecar/protagine/memory/blocks.py, architecture 4.1). Against a sidecar that fits nothing (1.10) the provider holds whole sections back itself, fresh state last, and names each one. With context.blocks: false the sidecar reports {"blocks": false} and the provider holds nothing back either: the owner chose the 1.10 loader.

Tests

tests/hermes_adapter runs against stock Hermes installed in the same environment plus a fake sidecar; see tests/hermes_adapter/conftest.py.

Metadata

Release files for protagine-hermes 1.11.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for protagine-hermes 1.11.0
File Size Uploaded
protagine_hermes-1.11.0.tar.gz 142.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for protagine-hermes 1.11.0
File Interpreter ABI Platform
protagine_hermes-1.11.0-py3-none-any.whl Python 3 none any Details

Total release size: 266.6 kB

Release files / protagine_hermes-1.11.0.tar.gz

Download URL protagine_hermes-1.11.0.tar.gz
Size 142.7 kB
Tags Source
SHA-256 checksum
How to use checksums
6a913757fe567a12fd94c16b5f92bf944109a0e4bd7a2473d6d9674f3336202f
BLAKE2b-256 checksum
How to use checksums
bc73922c9e6a6c80bbf70f24b95f9f17f18d214835295bc0646e97339a2a2f55
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.3

Release files / protagine_hermes-1.11.0-py3-none-any.whl

Download URL protagine_hermes-1.11.0-py3-none-any.whl
Size 123.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
a1f0a5fa61827a8eaffd03737effb19bf432d78daac5ba398c221b84f618c2e9
BLAKE2b-256 checksum
How to use checksums
eea5c447b66c311f989ad71634a55cf2896728086c7066b06bb3f73ae287a23a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.3

Release history Release notifications | RSS feed

This release

1.11.0 This release

2 release files

1.9.1

2 release files

1.9.0

2 release files

1.8.6

2 release files

1.8.5

2 release files

1.8.4

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page