Meu-Agent / gateway
AGENTS.md0 starsChanged 10 days agoMITView on GitHub
Applies on top of the root AGENTS.md. Long-form: website/docs/developer-guide/gateway-internals.md. New platform adapter: follow gateway/platforms/ADDING_A_PLATFORM.md step by step. gateway/run.py is the facade; phases live in run_*.py (startup, adapters, inbound, turn, busy, goals, notifications, shutdown, ...), sessions in session*.py, slash handlers in slash_commands_*.py mixins, authorization in authz_mixin.py, adapters in platforms/<name>.py over platforms/base.py. builtin_hooks/ is the extension point for always-registered gateway hooks (none shipped). The gateway reads user YAML raw (run.py + config.py), not through DEFAULT_CONFIG — a key the CLI sees but…
- Reads credentials
In this repository57 other files it gives its agents.
- agent/AGENTS.mdAGENTS.md
- AGENTS.mdAGENTS.md
- cron/AGENTS.mdAGENTS.md
- hermes_cli/AGENTS.mdAGENTS.md
- hermes_platform/AGENTS.mdAGENTS.md
- plugins/AGENTS.mdAGENTS.md
- pm/AGENTS.mdAGENTS.md
- skills/AGENTS.mdAGENTS.md
- tests/AGENTS.mdAGENTS.md
- tools/AGENTS.mdAGENTS.md
- tui_gateway/AGENTS.mdAGENTS.md
- web/AGENTS.mdAGENTS.md
- apple-notesSkill
- apple-remindersSkill
- findmySkill
- imessageSkill
- claude-codeSkill
- codexSkill
- computer-useSkill
- hermes-agentSkill
- architecture-diagramSkill
- ascii-videoSkill
- baoyu-infographicSkill
- claude-designSkill
- humanizerSkill
- local-pngSkill
- manim-videoSkill
- p5jsSkill
- popular-web-designsSkill
- songwriting-and-ai-musicSkill
- sdlc-reviewSkill
- email-inbox-triageSkill
- himalayaSkill
- youtube-contentSkill
- obsidianSkill
- document-to-action-itemsSkill
- google-workspaceSkill
- mapsSkill
- meeting-action-itemsSkill
- microsoft-officeSkill
- pdfSkill
- product-price-monitorSkill
- teams-meeting-pipelineSkill
- weekly-review-planningSkill
- arxivSkill
- competitor-news-monitorSkill
- grounded-citationsSkill
- xurlSkill
- codebase-inspectionSkill
- dogfoodSkill
- githubSkill
- requesting-code-reviewSkill
- simplify-codeSkill
- spikeSkill
- systematic-debuggingSkill
- test-driven-developmentSkill
- blocked-page-recoverySkill
The file
Files
gateway/ — messaging gateway, adapters, delivery
Applies on top of the root AGENTS.md. Long-form: website/docs/developer-guide/gateway-internals.md.
New platform adapter: follow gateway/platforms/ADDING_A_PLATFORM.md step by step.
Shape
gateway/run.py is the facade; phases live in run_*.py (startup, adapters, inbound, turn, busy,
goals, notifications, shutdown, ...), sessions in session*.py, slash handlers in
slash_commands_*.py mixins, authorization in authz_mixin.py, adapters in platforms/<name>.py
over platforms/base.py. builtin_hooks/ is the extension point for always-registered gateway
hooks (none shipped). The gateway reads user YAML raw (run.py + config.py), not through
DEFAULT_CONFIG — a key the CLI sees but the gateway doesn't means you're on the wrong loader
(hermes_cli/AGENTS.md). Each adapter picks a base toolset (Telegram → "messaging").
Slash commands: handlers are looked up by name through _command_handler_table; a command is
listed in _IDLE_COMMANDS or _PLAIN_COMMANDS (works mid-run) in run_busy.py. No
if canonical == ... chains. Registry + adding a command: hermes_cli/AGENTS.md.
The gateway has TWO message guards — both must bypass approval/control commands
While an agent is running, an inbound message passes two sequential guards: (1) the base
adapter (platforms/base.py) queues it in _pending_messages when session_key in
self._active_sessions; (2) the runner (run.py/run_busy.py) intercepts /stop, /new,
/queue, /status, /approve, /deny before they reach running_agent.interrupt(). Any new
command that must reach the runner while the agent is blocked (approval prompts) MUST bypass BOTH
guards and be dispatched inline — never via _process_message_background(), which races session
lifecycle.
Streaming delivery contract (stream-is-the-message adapters)
Adapters with draft_stream_is_message = True (relay Slack native streaming) keep ONE cumulative
native stream per turn; the stream IS the final message. Four invariants, each from a live
duplicate-final incident (NS-658 canary ledger, hermes#85796 / gateway-gateway#210); violating any
re-creates a duplicate or frozen stream:
- Draft frames are prefix-stable. Frame N must be a string prefix of frame N+1. Never mutate
drafts per tick — no fence-closing (
ensure_closed_code_fences), cursor suffix, segment-state resets at tool boundaries, or mrkdwn conversion. A non-prefix frame forces a whole-snapshot re-append ("stacked copies"). The finalize path may still transform the real final. - The consumer declares the final; the adapter never guesses.
finish(final_text)carries the completedfinal_response(verifier footer, completion explainer included). New post-stream augmentation MUST ride this payload — mutatingfinal_responseafter the seal re-opens thedelivered_final_matchesmismatch → corrective duplicate send. - Interim sends carry
metadata["_interim_send"] = True. Any consumer-sideadapter.send()that is not the turn-final (commentary, segment-tail flushes) must set it or seal-interception seals the live stream with interim text. Seal-interception exists at BOTH egress doors (send()andsend_for_platform()); a new egress door needs the same two checks. - Reconcile by edit, never by plain send. A lane delivering a final beside a sealed stream
(queued follow-ups, media-accompanied finals) first tries
edit_messageon the consumer'smessage_id; plainsend()only when no editable message exists. A sealed native stream is a regular message —chat.updateworks (live-verified).
Contract tests: tests/gateway/test_stream_final_contract.py (mutation-checked). Slack ground
truth: chat.*Stream speaks STANDARD markdown, not mrkdwn; stopStream.markdown_text APPENDS;
startStream/stopStream are Tier 2 (~20/min). Check draft_stream_is_message is True —
MagicMock adapters in older tests auto-create truthy attributes.
Background process notifications
terminal(background=true, notify_on_complete=true) arms a watcher on the gateway loop
(run_notifications.py::arm_process_watcher) that starts a new turn on completion; verbosity is
display.background_process_notifications. Injection requires the admit_internal_event receipt
(a handler returning None is not acceptance); refused admission refunds every claimed batch sibling
without spending an attempt. Long form: website/docs/developer-guide/gateway-internals.md
§ Background process notifications.
Canonical local CLI/TUI/Desktop sessions admit completion and watch events into the same durable
FIFO as human input (private launch policy, internal_notification display kind); observers never
own or consume those turns, and an interrupted started turn stays unknown and pauses its followers.
Gateway lifecycle vs. the Desktop app
hermes serve (control plane, desktop-spawned child) dies with the app — by design. The messaging
gateway (gateway run) SURVIVES the app: the serve backend's /api/gateway/* endpoints spawn it
detached (_spawn_hermes_action — start_new_session / DETACHED_PROCESS), so before-quit's
SIGTERM never reaches it and bots keep running. The known breach is the Windows shim-unlock
teardown (taskkill /T /F on venv-shim holders, #85265), which exists to let updates proceed and is
replaced by #92091's pause-for-update. Do NOT "fix" gateway-dies-with-app by re-parenting the
gateway under the backend, and do NOT "fix" update locks by widening the tree-kill. Gateways stamp
code_sha/code_version into gateway_state.json (status.py) so the updater can verify a fleet.
Profile scope (adapters, turns, and everything between turns)
- One identity per inbound event, canonicalized FIRST.
gateway/session_identity.py::resolve_identityanswers "which bot received it / who may admit it / where does it run" ONCE per event and pins a frozenRoutingIdentityon the source (wire-invisible, like_transport_adapter_ref). Every ingress path calls the canonicalize seam before it derives a key: the adapter side (platforms/base.py::_canonicalize—handle_message,_enqueue_text_event, Telegram photo / album routing,_handle_message_while_active, every_source_session_key) and the runner side (run_adapters.py::_canonicalize— the per-profile / default message, busy and platform-event handlers, the adapter auth-check callback,run_inbound.py::_hm_admit_event). No key derivation before it; an unresolved identity under multiplexing (route to an unserved profile) is dropped with one WARNING at the first seam it reaches, never keyed intoagent:main(parked_route_refusal.drop_rejected_route; a route to a parked profile also tells the sender in-channel to resend, since a direct bot's update is already confirmed and never redelivered; a relay frame is ACKed and refused alike (ParkedProfileRefusal: a withheld one stalls the ack-gated drain); a dispatch its transport has not acknowledged yet, e.g. Google Chat HTTP events, runs inretryable_ingress()and answersParkedRouteRefused(503) instead, releasing its dedup claim)._transport_owner,_authorization_home_for_source,_resolve_profile_home_for_source,_session_key_profileand_resolve_profile_for_keyread the identity when present and fall back to their old chain only for sources nothing resolved (restored rows, hand-built sources). Never derive a second answer next to the identity; extend the object.transport_profile≠runtime_profileis normal (shared bot → routed satellite). A source copy goes throughsession_identity.replace_sourceso the identity travels with it (_apply_topic_recoverydoes). Intake vs delivery adapter.
authz_mixin.py::_intake_adapter_for(source)is the bot that RECEIVED the event (live transport ref → relay socket → identity'stransport_profile); it gates intake policy (ignored channels, relay fronting, re-dispatch of a still-live event) and fails closed toNonefor a source with no live provenance._delivery_adapter_for(source)is the bot that ANSWERS — the receiving bot whenever known, else the unique owner of(platform, runtime_profile)via_adapters_for_profile. Never readself.adapters[platform]for a source; never add a third resolver. The matrix (tests/gateway/test_multiplex_transport_matrix.py):Topology runtime intake delivery per-credential bot, no route owner owner adapter owner adapter shared credential → satellite ( profile_routes)routed receiving adapter receiving adapter ( _is_shared_bot_satelliteafter restart)shared bot → profile that owns its own bot routed receiving adapter receiving adapter (conversation continuity; #70625's "routed bot" reading was not adopted) secondary-owned bot → default(bot_profile)default ( agent:main)receiving adapter receiving adapter restored / hand-built, no live provenance stored source.profileNoneunique owner of (platform, runtime); a disconnected secondary →None, never the default bot- Identity survives the process.
SessionEntry.transport_profile(routing index +sessions.transport_profile, nullable, reconciled bySCHEMA_SQL) persists the receiving bot next to the key namespace; the namespace says where a lane RUNS, the column says which bot may DELIVER to it. Anything reviving a session from durable state (auto-resume, heartbeat restore, plugin injection, background-process events) readsentry.originthroughauthz_mixin.py::_restored_source, which re-pins aRoutingIdentity(transport=None)viasession_identity.restore_identity;_delivery_adapter_forthen delivers through that bot's adapter or nothing (never the default bot by heuristic). Rows without the column (pre-PR-5) keep the_is_shared_bot_satellitefallback. Deferred callbacks capture the identity/home at command time (/modelpicker);_run_in_executor_with_contextcarries the scope over thread hops. Relay:_with_scopeechoes the routedprofileon every outbound frame andfollow_upderives it from the key namespace so the connector stamps it on the nextpassthrough_forward. Ambientget_active_profile_name()reads ingateway/are boot-only and marked# launch profile, pre-identity; a path with an identity readsidentity.runtime_profile. - Token locks. An adapter that connects with a unique credential (bot token, API key) calls
acquire_scoped_lock()fromgateway.statusinconnect()/start()andrelease_scoped_lock()indisconnect()/stop(), so two profiles cannot share one credential. Canonical:plugins/platforms/irc/adapter.py. - Multiplex profile-scoped env reads MUST fail closed — never borrow from
os.environ(agent/secret_scope.py; #72348, #86905). Undergateway.multiplex_profiles,os.environholds the DEFAULT profile's values; a secondary profile's.envexists only in its secret scope, bound per profile activity by_profile_runtime_scope(turn, callback, eviction, tick — never only "per turn"). All profile-level env config — credentials (app_secret, tokens) AND authorization (FEISHU_ALLOWED_USERS,{PLATFORM}_ALLOW_ALL_USERS,GATEWAY_ALLOW_ALL_USERS,group_policy,allow_bots) — is read scope-aware: adapters viagateway.platforms._shared.get_scoped_secret(the ONE implementation; adapters import it as_get_scoped_secret;extra_or_secret/seed_extra_from_env/env_is_connectedbuild on it), gateway authz via_shared.platform_gate_env(imported byauthz_mixin.pyas_auth_env). Scope installed + multiplex active → a scoped miss returns the default, NEVERos.environ(a leaked allowlist skips the allow-all check and silently rejects every secondary-profile sender, #86905). The unscoped default-profile path (UnscopedSecretError) and single-profile deployments keep theos.environread — there it IS the profile's own value. Never re-implement the reader in an adapter (thetry get_secret / except UnscopedSecretError: os.getenvshape drifts into a fallback-after-miss leak); import the shared one.tests/gateway/test_shared_platform_boilerplate.pyasserts every plugin's_env_enablementreads only through it. - Scope is bound per profile activity, not per turn.
run.py::_profile_runtime_scope(home)wraps a routed turn (run_turn.py::_profile_scope_for_source); the same binding wraps every path that touches a session's home, secrets or terminal scope with no turn on the stack: release and eviction (run_agent_cache.py::_run_release_in_profile_scope— TTL, LRU and memory-pressure eviction all route through it soon_session_end/memory flush hit the OWNING profile's provider), shutdown (run_shutdown.py::_finalize_session), post-turn media delivery (platforms/base.py::_media_delivery_scope), deferred callbacks (pickers, reactions — capture the routed home at command time, re-enter the scope in the callback), notifiers and outbound webhooks. Resolve the owning home from the session record (profile_home,agent:<profile>:key), never fromos.environ, which holds the launch profile. Why: eviction that flushed under the launch scope wrote a secondary profile's memories into the default profile's store, silently. api_serverrebuilds the agent per request but not the memory provider. The adapter bypassesTurnRunnerand the agent cache (per-request callbacks, model route, ephemeral prompt), soplatforms/api_server_memory_sessions.pyparks each session's initialisedMemoryManagerbetween requests (exclusive check-out in_create_agent, check-in in the turn'sfinally, keyed by profile home +agent.session_id; idle/LRU eviction shuts down under the owning home) andAIAgent(memory_manager=...)adopts it without a second provider init. Without it the previous turn's queued recall never reaches the next request (#120116).multiplex_profiles: falseis not "no scope ever". A native hosted room serving a second profile flips the process-wide guard (tui_gateway/launch_profile_policy.py:: activate_multi_profile_hosting) inside the gateway process, after the adapters were wired; every standalone entry point (run_turn.py::_profile_scope_for_source, the primary adapter's message / busy / platform-event handlers viarun_adapters.py::_standalone_scoped) then binds the launch profile's OWN scope throughrun_turn.py::_standalone_launch_scope—.envover the env frozen at activation, never a.env-only rebuild (systemd /op runkeys have no file) and never liveos.environ. Gate a new standalone path on that helper, not on the config flag (#112878).- Hooks and observers register per served profile.
builtin_hooks/,agent/shell_hooks.py:: register_from_configand lifecycle observers are prepared under each profile's scope at startup and on profile add/remove; idempotence keys include the profile, andhooks/paths resolve at call time (a module constant freezes to the first importer). Shell hooks are also re-synced (added AND unregistered) on every local session create/resume viasession_policy.refresh_profile_hooks->shell_hooks.sync_from_config, the only caller that removes. - Adapter YAML never reaches
os.environunder multiplex.platforms/_shared.py:: apply_yaml_bridgeseedsPlatformConfig.extraand skips the environ write under a secondary profile's scope; gate/allowlist reads go throughplatform_gate_env. Aif not os.getenv(X): os.environ[X] = …bridge is first-profile-wins across the process — test two profiles with conflicting flags before touching precedence. - Unserved is reported, never silent. Shared-ingress platforms (Relay) run on the default
profile only; a secondary enabling one is logged once with the remedy and stamped into runtime
status (
run_adapters.py::_note_unserved_secondary_platform). The WhatsApp bridge is per profile: a paired secondary gets its own adapter and port (plugins/platforms/whatsapp/bridge_ownership.py), an unpaired one is reported aswhatsapp_unpaired, and a secondary never adopts or signals a bridge its own pidfile (pid + start time + port) does not identify.needs_attentionis set and cleared at the single writer (_update_platform_runtime_status) on the connect path. - One launch-home identity. "Does this task serve a routed profile?" compares the override
with
hermes_constants.get_routing_process_hermes_home()(agent/secret_scope.py:: serves_routed_profileand_is_process_home,tools/environments/local.py::_is_routed_home,hermes_cli/env_loader.py::_process_hermes_home), never withos.environ["HERMES_HOME"]read live: an embedding host that mirrors the served profile into the env var per turn (Hermes WebUI) pins its own home withpin_process_hermes_home(), and without a pin the resolver isget_process_hermes_home()unchanged. Do not add another routing decision that compares againstget_process_hermes_home()directly; that resolver is for process-level assets.
Tests
tests/gateway/. Adapter tests exercise the real delivery path with a fake transport; never
assert on hardcoded platform lists or command counts (root: no change-detectors). Session-key and
guard behaviour are invariants worth a test; platform API quirks belong in connector comments +
tests, not in prose.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

