Provision a Mupot deployment or attach Hermes as a restricted, agent-bound operator. Version 0.3 separates these trust zones by profile:
provisionermode: human-controlled Cloudflare setup;operatormode: a narrow Mupot task/evidence/approval-request surface, with an optional squad-agent manager extension.
Do not combine the modes in one Hermes profile.
The native Mupot receiver and human-channel routing are owned by this repository
under mupot_gateway/. Installing the operator plugin is sufficient; do not also
install a separate platforms/mupot copy. See
examples/native-gateway-config.yaml.
Enable settings.operator.native_gateway_enabled: true to register the Mupot
platform alongside the existing restricted operator tools. It is mutually exclusive
with inbox_watch_enabled; enabling both is rejected before registration. Existing
CLI/SSE-stream configurations remain opt-in and unchanged when native mode is off.
The native receiver uses Mupot's server-authoritative inbox_lease attempt receipts and
attempt-bound inbox_lease_ack. Its current attempt-v3 state requires server migration
0153_inbox_lease_attempt_reconciliation.sql, applied after
0152_telegram_project_onboarding.sql. It first reads the strict tenant/agent/effective-seat
consumer scope, durably records that scope with one random attempt ID and the owning
profile's non-secret immutable fingerprint, and reuses the ID for the single proven-safe
transport retry. An ambiguous or restarted attempt is resolved through
inbox_lease_reconcile only from the same profile owner. Token rotation inside that profile
remains valid; a different profile presenting the same server scope stays fenced. Only an
exact scope-matching
leased tuple is processed, and only an exact acked/consumed:true attempt receipt permits
local commit and marker clearing. Terminal non-consumed or mismatched receipts remain fenced.
Before any source ACK, peer reply and Routine custody records also persist the originating
attempt, strict scope, and profile owner. Restart replay revalidates those facts and remains
on inbox_lease_ack; an expired attempt can never fall through and consume a newer lease.
Before replaying a prepared outbound final, the adapter performs that owner and strict-scope
preflight read-only and sends nothing on any mismatch or ambiguous status response. The
successful order remains send receipt, durable human-notice custody, then exact attempt ACK.
Only records durably identified as non-attempt work use inbox_ack; ambiguous older outbox
records and older reconciliation markers stay fenced for manual recovery. The receiver also
verifies the operator's expected agent/tenant, preserves
message correlation, and consumes a source only after successful handling. Terminal ACKs
are preserved without generating another peer reply. It does not start an SOS connection.
Off by default. When true, the adapter also holds Mupot's peek-only
GET /api/inbox/stream open with the same agent bearer (URL derived from the configured
MCP URL, token read from the owning profile scope on every connect) and treats each new
message/initial frame purely as a wake: the poll loop runs its unchanged
inbox_lease -> process -> reply -> inbox_lease_ack cycle immediately. The stream never
leases, acks, or reads the inbox, so every guarantee above holds and a spurious wake costs
one empty lease. Reconnects use capped exponential backoff with jitter and resume with
since=<last seen seq>; : ping comments count as liveness and sse_idle_timeout
(default 45s) of silence forces a reconnect. While the stream is healthy the timed poll
drops to sse_safety_poll_interval (default 60s); after a lease that found work, or while
the stream is down, poll_interval applies. The Hermes e-stop still gates every lease.
mupot_gateway_status reports sse_wake health. It needs no consumer-mode change
(bearer_only is fine) and no org-admin capability.
Any failed lease cycle still quarantines the loop behind its durable attempt marker. Only
when the failure happened BEFORE any turn started for that attempt -- the inbox_lease
call itself failed, or its result failed validation, so the lease was never handed to
processing -- is the same bounded reconcile connect() runs at startup
(execute_leased=False: a still-leased attempt stays fenced and no turn is re-executed)
retried in-process with capped backoff (lease_self_heal_interval, default 60s, up to
lease_self_heal_cap), so one lease timeout no longer silences receive until the next
restart. A failure after processing began (for example a turn that produced no reply)
keeps the durable quarantine with mupot_inbox_reconciliation_required and no in-process
heal: re-leasing it would run the same turn again, so a human reconciles. If the heal
succeeds but the reconnect fails, status shows the retryable
mupot_inbox_heal_reconnect_failed and the reconnect keeps retrying with the same backoff.
Enable mupot.routine_events_enabled: true for the dedicated authenticated
routine.human-wait/v1 receive path. This opt-in does not add mupot-routines to
allowed_agents: Routine events never start a peer model turn or send to their synthetic
source. Their human notice becomes eligible for private-session activation only after
durable custody, an exact scope-bound attempt ACK, and the local processed marker.
For human updates, configure mupot.notification_recipients with the immutable user
ID for each linked platform. Only matching active private conversations are eligible.
With mupot.notification_activate: true and
plugins.entries.mupot.allow_gateway_injection: true, Hermes's native plugin API
starts a normal turn in the selected conversation. The native gateway rechecks
authorization and prevents the injected event from executing human slash approvals.
The previous split deployment verified Telegram; other platforms require their own
identity binding and end-to-end verification.
Keep each receipt at its own boundary: server Routine custody proves the human wait exists;
the scope-bound attempt ACK proves only that the reconciled leased envelope was consumed;
activation_queued proves
only that Hermes accepted private-session scheduling; a channel receipt plus conversation
mirror readback proves channel delivery; a Telegram webhook receipt plus Routine answer or
task verdict proves the human decision; and terminal Routine/task evidence proves domain
completion. None substitutes for another. Interrupted or ambiguous sends are retained for
reconciliation rather than blindly replayed. This integration does not grant the agent
human decision authority.
activating and activation_unknown are durable no-replay states. If Hermes accepts
scheduling but persisting activation_queued fails, the receiver retains the earlier
uncertain state and requires operator reconciliation. Routine activation is authorized by
the exact durable processed Routine receipt, not by the bounded recent processed list.
For the broader project-onboarding and human-control scope, see
docs/human-project-control.md.
The optional deterministic Telegram control surface relays exactly /start, /needs,
/answer, /approve, and /reject from a private, unforwarded chat to Mupot. These
commands do not start an LLM turn: Mupot resolves the immutable Telegram identity,
project visibility, role, pending decision, conflicts, and current authorization. Ordinary
Telegram text remains owned by Hermes.
Use one native Mupot receiver and one Telegram bot per Hermes profile. Do not install the legacy split Mupot platform beside this plugin, register a second receiver, or attach a second bot to the same profile. The production profile settings are:
plugins:
enabled: [mupot]
disabled: [platforms/mupot, mupot-platform]
entries:
mupot:
allow_gateway_injection: true
settings:
mode: operator
operator:
native_gateway_enabled: true
inbox_watch_enabled: false
base_url: https://mupot.mumega.com
telegram_control_enabled: true
telegram_control_webhook_secret_env: IM_WEBHOOK_SECRET
mupot:
enabled: true
routine_events_enabled: true
allowed_agents: [<ALLOWED_PEER_AGENT_ID>]allowed_agents accepts either a list of agent names or a single comma-separated
string; each entry is lowercased and has one leading agent: prefix stripped (so
agent:Kasra and kasra are the same allowlist entry — defensive normalization for
hand-typed config, not evidence that Mupot itself ever emits a prefixed sender). An
explicit empty value ([], "", or a list of only blank/whitespace entries) means
deny all peers and is honored as written. Only a genuinely absent key falls
back to the default four-agent roster (hadi-codex,hadi-codex-cli,kasra,hermes) — do
not rely on that default; set allowed_agents explicitly for any real deployment. A
non-string, non-list value (a bare number or boolean, for example) is rejected at
construction with a clear config error rather than an unrelated TypeError later.
Keep IM_WEBHOOK_SECRET, the Telegram bot token, and MUPOT_AGENT_TOKEN in the
profile's protected environment; never put values in YAML. Enabling Telegram control is a
local relay configuration, not a capability grant.
The participant receives a one-time pairing code through an approved out-of-band channel
and sends /start <pairing-code> in the approved bot's private chat. That response confirms
the project; it is not authoritative role evidence. An operator must separately read back
the active member and exact squad capability before /needs is treated as role-scoped.
The participant then submits one exact /answer <run-id> <choice> or, only when
independently authorized by the existing gate, /approve <task-id> or
/reject <task-id> <reason>. Mupot records the decision, continues the Routine, and the
native receiver returns the resulting update to the same private conversation automatically.
Duplicate transport updates replay the stored response without a second effect. Stale or terminal decisions, invalid choices, unauthorized actions, and conflicting reuse of an update ID are refused without creating a new decision. Suspension and capability revocation are rechecked on every later command. Restart or timeout is an uncertain state: reconcile the Mupot receipt/domain state before issuing another decision; do not bypass the durable fence with a new command or delete receipt state.
Onboarding grants no merge, deploy, publish, spending, organization-admin, token, or independent gate authority. See the Telegram onboarding runbook for setup, verification, retry, revocation, and rollback steps. A live pilot remains gated on independent review, exact deployment proof, migration readback, and protected webhook configuration.
This deterministic command relay is a fallback, not the primary path. It exists for
participants who are not bound to an owned Hermes agent seat. For an owned member, plain
natural language through their own agent is the primary, always-live decision channel — see
"Human-origin attestation" below. The relay also has a known gap: Hermes's own Telegram
polling-connection rebuild (a transient reconnect after a network hiccup) re-registers only
Hermes's core message/command handlers, never a plugin's own CommandHandlers
(plugins/platforms/telegram/adapter.py's _register_handlers, called from
_initialize_app_with_retries on every rebuilt polling Application) — so after such a
rebuild, /start /needs /answer /approve /reject can silently stop responding until
the whole gateway process restarts, with nothing in this plugin able to detect or repair it
(there is no hook back into that rebuild path). register_telegram_control logs a WARNING
naming this limitation whenever telegram_control_enabled: true is configured.
The primary decision channel for an owned member is not a deterministic command at all: they
talk to their own agent in plain natural language, and the harness — never the LLM —
stamps the triggering message's origin onto the agent's task_verdict calls. Mupot resolves
that human_origin object to the member and runs the call under the human's own identity
instead of the agent seat; without it (a CLI turn, a cron turn, a subagent turn, or a turn on a
platform this plugin doesn't yet support), the call runs under the agent seat exactly as
before this feature existed. task_verdict is the only tool this ever stamps: mupot's server
side resolves human_origin per-tool, wired individually, not via shared middleware applied
across every tool — there is currently no other tool it is safe to stamp.
This is implemented as four decision-path Hermes lifecycle hooks (pre_gateway_dispatch,
pre_llm_call, pre_tool_call, post_tool_call) plus two session-boundary hooks in
mupot_gateway/human_origin.py, registered FIRST (before the platform adapter or any tool)
from mupot_gateway/adapter.py's register(), only when native_gateway_enabled: true.
Registration fails closed: a Hermes runtime whose PluginContext cannot register_hook
(or whose hook registration itself raises) gets no native-gateway registration at all — there
is no other choke point in this plugin able to keep a model-supplied human_origin from
reaching mupot verbatim. Round 7 (kasra-review round-6 item 5) extends the same fail-closed
posture one step earlier: when the runtime's own hermes_cli.plugins.VALID_HOOKS is
introspectable, register() also refuses outright if it is missing pre_llm_call or
post_tool_call — an older runtime would otherwise accept register_hook("post_tool_call", ...) as a silent, callable-shaped no-op that is simply never invoked, quietly reverting round
6's reserve/resolve fix back to round 5's premature-consumption defect with no signal at all.
(When VALID_HOOKS cannot be introspected at all — not a real runtime shape, only this
module's own test doubles — this extra check is skipped; the register_hook-callable check
above is still the primary gate.)
pre_gateway_dispatchfires once per inbound message, straight off the platform adapter, before Hermes's own sender-authorization check runs. It is therefore this module's own trust fence, not a convenience filter: a message is captured only when it is a private, non-forwarded, self chat (chat_type == "dm"anduser_id == chat_id— Telegram's own DM invariant, mirroringtelegram_control.py's own private/unforwarded gate). A captured record — including a SHA-256 of the message's own text — is pending, not yet bound to any turn, for up to 2 minutes: a backstop for a session that never reachespre_llm_callat all, not the mechanism that bounds a live record's lifetime (that's the next hook).pre_llm_callfires once per turn, before the tool loop. On EVERY call it drains every pending record for that turn's session: the first one whose sender and exact SHA-256 text hash match this turn's own is bound to the currentturn_id; every other one — mismatched or a later duplicate-content match — is burned right there (dropped, never re-queued) and logged at WARNING with its message id and a reason. A turn that binds nothing still burns whatever was pending: a pending record cannot outlive the nextpre_llm_callthat QUALIFIES for its session (Telegram platform, a string inbound message, a resolvable session key, not a delegated child — each of those is its own guard that returns before the drain). A turn that doesn't qualify neither binds nor burns anything; the 2-minute TTL backstop still caps whatever is left pending in that case. Earlier designs (a session-keyed slot, a per-session queue, then a content-matched bind that re-queued failures) all left a window where a record that failed to bind stayed spendable by whatever turn asked next — this closes that window at its root rather than narrowing it again. An internal/plugin-injected turn is handed the human's own sender id and platform (Hermes resolves both from the turn's session source, a copy of the human's stored origin), so text is the only discriminator: today's in-plugin injectors all prepend a fixed, non-removable preamble, so their prompt never equals the human's own message and they only ever burn a pending record, never bind it — but an injector able to emit an unwrapped string equal to the human's own recent words would bind it with the human's identity (see "Documented residual" below; this is a property of the fixed preamble, not of the binding logic itself). A delegated subagent is refused outright regardless of its text, checked directly against Hermes's delegated-child-context marker.pre_tool_callfires once per tool dispatch and only ever reserves whatpre_llm_callalready bound to that exact turn — it never claims or burns anything itself, and it never fully consumes on its own (round 6, kasra-review round-5 P1-1). Earlier (round 5) the read here was one-shot: it consumed the bound record the momentpre_tool_callran, which is before Hermes's own block gate, a guardrail, or a denied human-approval escalation can still kill the call (hermes_cli/plugins.pyresolves a hook'smodifydirective before the block/approve gate runs) — so a vetoed call spent the human's one credit on a dispatch that never reached mupot at all, leaving a same-turn retry unattested. Round 6 splits this in two:pre_tool_callmarks the bound record reserved by that call'stool_call_id(available for stamping, but not to any other concurrent call) without removing it, and onlypost_tool_call(below) decides whether the reservation is actually spent. One human message still authenticates AT MOST onetask_verdictcall that actually dispatches — a call that never reaches mupot no longer burns the credit. Round 7 (kasra-review round-6 P1): reservation is not trusted totool_call_ididentity across repeat calls — round 6 briefly treated a repeat call carrying the identicaltool_call_idas an idempotent retry and handed the same record out again, buttool_call_idrides in on the model's own tool-call output; Hermes does not mint or verify it. A model steered to emit two DISTINCTtask_verdictcalls in one turn could simply copy the same id onto both and get the human's identity stamped twice. Now, once a record is reserved by anyone — same id or not — every further reservation attempt for that turn is stripped untilpost_tool_call(below) explicitly releases it. A model-suppliedhuman_originis stripped on every tool that looks like it belongs to mupot at all (anymcp__<configured mupot server>__*wire name once the server name is resolved — sanitized the same way Hermes sanitizes one with punctuation in it — plus a small named fallback for a handful of other decision-adjacent mupot tools while the server name is still unresolved, and the server name itself is scoped per Hermes profile so two multiplexed profiles with differentmcp_servervalues can't clobber each other's resolution), and only ever replaced with the bound origin for the one-tool stamp allowlist on an exact wire match with a turn that actually bound something and a call carrying atool_call_id.post_tool_call(round 6) fires once per tool dispatch outcome and resolves whatever reservation that call'stool_call_idholds:status == "blocked"(Hermes's own block gate, a guardrail, or a denied/erroring human-approval escalation — anything that stopped the call before it ever reached mupot) releases the reservation, so the model's retry in the same turn — a newtool_call_id— can reserve and stamp it again; any other status (ok,error,cancelled, …) is a genuine dispatch attempt and permanently consumes it — mupot has seen the field (or would have) either way. A non-governed tool, or a turn/call that never reserved anything, is a no-op. Known limitation (kasra-review round-6 item 4): Hermes's owninvoke_hookskips a callback invocation entirely while a PRIOR invocation of that SAME callback is still running (its own timeout/re-entrancy protection) — a slowfinalize_tool_callfor onetool_call_idcan therefore cause a concurrentpost_tool_callfor a DIFFERENTtool_call_idto never fire at all, leaving that reservation permanently outstanding on its own. This module does not solve that (it cannot, from inside one of the callbacks Hermes might skip) — instead, the NEXTpre_llm_callon that session (round 7) scans for any reservation left over from an earlier turn, drops it, and logs it at WARNING with reasonstale_reservation, exactly like any other burn — so the skipped-callback case leaves an operator-visible signal instead of a silently immortal reservation.on_session_reset/on_session_enddrop any pending or bound record for a session the moment Hermes itself ends it, rather than relying solely on the TTL backstop.
The stamped human_origin object also carries a text field (round 6, sized in round 7): the
human's own message, truncated to 4096 characters — Telegram's own maximum message length, so a
legitimate full-length message is never truncated — independently of what gets hashed for the
bind-time equality check (the full text is always hashed). Mupot's server side requires the task
id to appear in it, binding the stamp to the intent the human actually expressed, not just his
identity.
_OriginStash's internal lock (round 7, kasra-review round-6 P2) is an RLock, held for the
full body of every mutating method — capture, bind, peek_and_reserve, resolve,
drop_session, clear — not only peek_and_reserve/resolve as in round 6, so concurrent tool
dispatch cannot double-spend a reservation AND concurrent captures/binds cannot race the
underlying pending/bound dicts either.
Only Telegram is supported today. Every other platform is a recorded, not silent, gap: the
first inbound message on an unsupported platform logs one INFO line naming it, and every
task_verdict call from that turn simply runs under the agent seat.
Documented residual. This remains a content-and-sender equality proof over a short window,
not a cryptographic custody token: an injected/internal turn on the human's own session runs
under the human's own sender id and platform (Hermes resolves both from the turn's session
source, which for an injected turn is a copy of the human's own stored origin), so the only
remaining barrier is that no in-plugin injector today emits a bare, attacker-chosen string
equal to the human's own text — every injector prepends a fixed, non-removable template. A
future injector or third-party pre_gateway_dispatch plugin able to emit an unwrapped string
would need to present it as the next qualifying pre_llm_call on that session, before the
human's own turn (if any) burns the record first. Separately: bind() always takes the OLDEST
matching pending record, so two identical human messages inside one window still produce a
stamp naming the older of the two message ids — content-correct, id-drifted; round 4 only stops
the newer one from lingering to be (mis)claimed by a later turn (it is burned as superseded
instead), it does not fix which of the two ids gets named.
A failed bind is invisible to the human by design (fail closed on the attestation, fail open on
the feature): the tool call still runs, under the agent seat, and this plugin never sends a
human_origin field at all. On mupot's server side, an agent-bound call with no supplied origin
gets back human_origin: {applied: false, reason: "absent"} in its response — distinct from
what a supplied but unresolvable origin would produce, but not a signal an operator would read
as "your approval didn't count as yours" on its own. The only operator-visible signal that
specifically names what happened is the plugin's own burning unconsumed human-origin capture
WARNING log.
Also documented (kasra-review round-4 P1-2): the pool drains per SESSION, not per MESSAGE. On
Hermes's own live default (busy_input_mode: interrupt), two Telegram texts sent inside the
debounce window are merged into one turn's inbound text before that turn ever reaches
pre_llm_call — but both were already captured as separate records first. The one turn that
runs presents the concatenated text, which matches neither individual capture, so both are
burned: fail-closed by design, not a bypass — neither message authenticates a task_verdict
call, and the human's remedy is to resend one message at a time.
./scripts/test.sh runs the standalone operator/provisioner and legacy stream tests.
Native gateway tests use the actual Hermes runtime and its isolated test runner:
HERMES_SOURCE=/path/to/hermes-agent bash scripts/test-native.shThe cross-repository acceptance additionally requires clean, pinned Mupot and Hermes checkouts and uses no credentials:
MUPOT_SERVER_SOURCE=/path/to/mupot \
HERMES_SOURCE=/path/to/hermes-agent \
HERMES_PYTHON=/usr/bin/python3 \
bash scripts/test-integration.shThe bounded local receipt is recorded in
docs/telegram-onboarding-evidence.md.
The CI native job pins Hermes commit
233757037df1f03f9fe1cfddc097acd5ad7f7510; it exercises plugin registration,
lease/ACK handling, private recipient selection, delivery/mirroring, retry recovery,
and the normal conversation activation path. No API credentials are required.
- Preserve the old adapter/config and their receipt state outside plugin discovery.
- Update this
mupotplugin; enable native mode and themupotinjection permission. - Disable obsolete
platforms/mupotandmupot-platformplugin entries so exactly one plugin owns the Mupot platform. Keep the configured state path to retain ACK and notification receipts. If the split adapter used its old default, explicitly set that exact old path; the new default is profile-local. Verify processed IDs and pending/notification records before and after the switch. - Restart the gateway after checking that no turn/delivery is active, then verify one authenticated source message, correlated ACK, native human-conversation turn, and channel delivery receipt.
Use a full gateway restart for this migration, not forced plugin reload. Existing legacy stream threads belong to the old process; a restart makes the single-receiver transition explicit. Native registration refuses a known active legacy stream.
Copy examples/operator-config.yaml into an isolated Hermes profile, replace every
placeholder, and put the agent-bound secret in that profile's protected environment:
MUPOT_AGENT_TOKEN=<agent-bound-token>
The plugin verifies the configured tenant and welded bound_agent_id before work and
fails closed if the token has owner/admin ladder authority. By default it does not
register permission, credential minting, verdict, publishing, spend, outbound
communication, deletion, or generic HTTP tools.
Only a trusted main Hermes profile should enable agent management:
plugins:
entries:
mupot:
settings:
mode: operator
operator:
base_url: https://your-pot.example
expected_tenant: your-tenant
squad_id: squad-id
agent_id: manager-agent-id
approval_owner: human-owner-member-id
pubsub_peer_agent_ids:
- isolated-dme-agent-id
agent_manager_enabled: trueEnabling the setting only registers the local tools. Mupot independently requires the
authenticated member to have both membership on that exact squad and the free-text
surface grant agents:manage. Before every management action, the plugin verifies its
normal welded identity and then calls the scoped agent_manager_status handshake. The
requested action is not sent if either proof fails.
Manager-created agents are always active member agents. The published example enables
neither manager mode nor credential management. Lifecycle effects write append-only,
attributed audit receipts. Credential-management tools require a second explicit local
setting and matching server capability; keep them disabled unless they have passed a
separate security review for the target deployment.
v0.2 ships the real CF provisioner.
mupot_provisionwithconfirm=True, dry_run=Falsecalls the Cloudflare API directly (pure stdlib urllib — no extra deps) to create D1 databases and KV namespaces, then writeswrangler.<slug>.tomlwith the resolved resource IDs. Default (dry_run=True) emits a plan without touching Cloudflare. RequiresMUPOT_CF_API_TOKENandMUPOT_CF_ACCOUNT_IDin the environment for apply mode.
hermes plugins install Mumega-com/mupot-pluginThis standalone repository is the Hermes install target. Version 0.3.0 mirrors the
restricted operator implementation reviewed in Mumega-com/mupot commit e8acf8b.
On a server, create a dedicated Hermes profile, install the plugin into that profile,
copy examples/operator-config.yaml, and set MUPOT_AGENT_TOKEN through the server's
secret manager or a profile-local file with mode 0600. Never commit the token.
To verify the complete plugin locally:
./scripts/test.shhermes skills install cloudflare/skillsIf you don't use Hermes, drop the bundled skill anywhere your agent loads skills:
cp -r skills/mupot-operator ~/.claude/skills/For users who want to deploy a mupot instance directly:
(Reads wrangler.example.toml, provisions bindings, deploys. Zero-code path.)
| Mode | Tool | What it does |
|---|---|---|
| provisioner | mupot_provision |
Idempotent Cloudflare provisioner. |
| provisioner | mupot_status |
Probe /health → {ok, tenant, url}. |
| provisioner | mupot_brain_enable |
Plan the DMN brain profile and schedule. |
| operator | mupot_operator_status |
Verify tenant, welded identity, and restricted privilege. |
| operator | mupot_operator_check_in |
Record on-demand Hermes presence. |
| operator | mupot_operator_task_board |
Read only the configured squad board. |
| operator | mupot_operator_task_create |
Create a self-assigned scoped task. |
| operator | mupot_operator_task_claim |
Claim permitted work as the configured identity. |
| operator | mupot_operator_record_finding |
Record evidence while work is active or blocked. |
| operator | mupot_operator_request_approval |
Route findings to the configured human; cannot decide the verdict. |
| operator | mupot_operator_complete_task |
Complete ungated work; Mupot still enforces unresolved gates. |
| operator | mupot_operator_send |
Send a durable, idempotent mailbox message to an explicitly configured peer agent. |
| operator | mupot_operator_inbox |
Peek this welded agent's inbox; consume only when explicitly requested after acceptance. |
| manager (opt-in) | mupot_agent_manager_list |
List configured-squad agents and non-secret token metadata. |
| manager (opt-in) | mupot_agent_manager_create |
Create an active member agent in the configured squad. |
| manager (opt-in) | mupot_agent_manager_set_status |
Pause or resume an agent in the configured squad. |
| manager (opt-in) | mupot_agent_manager_mint_token |
Mint a show-once member token welded to an agent. |
| manager (opt-in) | mupot_agent_manager_revoke_token |
Revoke an agent-bound token by ID. |
In v0.2 (real CF provisioner):
- Real apply: CF REST API via pure stdlib urllib (no extra deps) — creates D1 + KV idempotently
- Idempotent list-guard: paginates all existing resources before creating (no double-create)
wrangler.<slug>.tomlwritten with resolved D1 + KV IDs after apply- Optional
wrangler deployvia subprocess (Risk 4: version-check gate first) - Token security: never in argv, repr, error messages, or toml output
- Brain profile + cron plan emission (real-file cron, not symlink)
- Deploy-to-Cloudflare button
Deferred (v0.3+):
- CF OAuth one-click (pending Mumega OAuth app public approval)
- Full SDK provisioner (no wrangler dependency):
client.workers.scripts.update() - OAuth secret automation
- R2 / Vectorize / Queues provisioning (add via re-run)
mupot_revoke_tokenpost-provision cleanup (needs token ID at mint time)pot_registry/pot_ownersmigrations for the "Your Pots" console
Operator mode can run a background inbox watcher that surfaces new SOS-bus
and Mupot-inbox messages into the live Hermes conversation (inject_message),
with a macOS notification fallback. It exists because the desktop gateway does
not route SOS/Mupot inbox traffic on its own.
Enable per profile:
plugins:
entries:
mupot:
settings:
mode: operator
operator:
# ... base_url, expected_tenant, squad_id, agent_id, approval_owner ...
inbox_watch_enabled: true # opt-in; default off
inbox_watch_poll_seconds: 30 # floor 10
inbox_watch_sources: [mupot, sos] # or a subset
inbox_watch_sos_token_env: CYRUS_SOS_TOKENGuarantees: peek-only (never consumes — consuming stays the agent's explicit
act via mupot_operator_inbox); first poll baselines the existing backlog so
enabling never replays history; the delivered watermark advances only on
actual delivery, so throttled items are retried, not lost; per-source backoff
on transport errors; state file is per Hermes home. SOS transport note: the
bus WAF rejects urllib's default User-Agent (Error 1010); the watcher sends a
browser UA.
| Risk | Mitigation |
|---|---|
| CF token on disk | Least-scoped token (5 groups). Rotate after provision. CF OAuth coming. |
| Migration drift | ALWAYS --dry-run first. Tool emits this as a required step, never auto-applies. |
| Brain token scope | Must be task:read + priority:write only. NOT mcp:*. Operator's responsibility — the plugin documents the requirement but cannot enforce token scope. |
| Cron symlink | Real file only. Symlink → silent non-execution. Tool template uses real file. |
| Workers slot | Free tier = 100. Tool warns near limit. Centralised Workers-for-Platforms rejected (breaks sovereignty). |
You need a scoped token — NOT your Global API Key.
Create one at:
https://dash.cloudflare.com/profile/api-tokens
Minimum permissions required:
- Workers Scripts: Edit
- D1: Edit
- Workers KV Storage: Edit
- Account Settings: Read
./scripts/test.sh