Skip to content

OpenClaw + Hermes Support - #2

Merged
timothyerwin merged 37 commits into
mainfrom
gateway
Aug 14, 2026
Merged

OpenClaw + Hermes Support#2
timothyerwin merged 37 commits into
mainfrom
gateway

Conversation

@timothyerwin

@timothyerwin timothyerwin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Self-hostable memcode gateway, plus one-command migration from OpenClaw and Hermes.

Direction: the same memcode binary doubles as a long-lived gateway (memcode gateway) — a generic event → objective → action runtime whose first external surface is a chat channel. It listens on the surfaces you already use, turns each inbound message into an agent job, and posts the result back. Coding is one use case, not the thing it's built around; an inbound message is just a task. Hosted Memcode Cloud gateway = managed convenience; self-hosted gateway = trust / privacy / OSS credibility.

What's in it

Gateway

  • Channels: Telegram, Discord, Slack (Socket Mode), GitHub (CI-failure webhook), WhatsApp (Meta Cloud API, inert until business verification).
  • Loop: event → authorize → dedup → durable inbox → agent job → reply, each job a crash-isolated subprocess so one bad run can't wedge the others.
  • Reliability: durable SQLite inbox (at-least-once, dedup on redelivery), per-conversation ordering + bounded concurrency, durable poll offsets, reconnect backoff with jitter, one length-aware egress chunker, HMAC-verified webhooks.
  • Authorization: default-deny on stable user ids (not mutable @Handles); group channels require a mention, so ambient chatter spawns no jobs; per-channel model tier routing; scheduled (cron) tasks; per-conversation session continuity.
  • Config: one gateway.yaml for settings, secrets in the global .env under each platform's conventional variable name. Runs foreground or as an installed launchd / systemd service.
  • Safety: gateway jobs run with the permission gate failing closed (a chat message cannot become RCE), plus adversarial-audit hardening and open-surface startup warnings.

Migration — dedicated memcode claw (OpenClaw) and memcode hermes (Hermes), no auto-detect (naming the source avoids importing a stale install). Each moves the full install: channels, provider API keys, skills, and long-term memory.

Memory — a global/local split (~/.memcode/memory.md plus per-repo .memcode/memory.md), loaded into every session. Migrated memory is read from the source's markdown stores into the global file, so it is actually loaded and used, the way it was in the source assistant, not parked in an unread file.

Deliberately NOT in the first slice

22 messenger adapters, WhatsApp/Signal beyond the scaffold, a visual workflow builder, "automate anything" positioning.

Status

Implemented and tested: gofmt / go vet / go test ./... green across the branch (~60 files, ~5,500 lines). Docs live under /docs/cli/gateway and /docs/cli/memory. Ready to merge.

The package is the hosted Cloud API side-channel client (websearch/webfetch/
advisor/BYOK over /v1/*), not a gateway runtime. Reserve internal/gateway/
for the real self-hostable gateway. Updates the two importers
(provider/byok.go, provider/wire.go) and the guard-test path assertion; no
behavior change, no new dependencies.
The same memcode binary now runs as a long-lived 'memcode gateway': a
pluggable channel adapter interface (internal/channels), a Telegram adapter
(hand-rolled Bot API long-poll, no SDK), and a runtime/router
(internal/gateway/server) that turns each inbound message into a detached
agent job (reusing internal/jobs — crash-isolated subprocess) and posts the
result back. Self-hosted: the bot token lives in the global .env
(MEMCODE_TELEGRAM_BOT_TOKEN). Command hidden from --help while WIP.

Proves the external-surface -> objective/agent-spine loop with the simplest
channel; Discord/Slack/GitHub/WhatsApp follow on the same interface.
… in gateway.yaml)

Configure channels with 'memcode gateway setup': an interactive wizard that
routes each answer the way memcode already splits config — bot tokens to the
global .env, non-secret knobs to ~/.config/memcode/gateway.yaml. A channel is
enabled when its secret is present. Replaces hand-setting MEMCODE_*_BOT_TOKEN
env vars by hand.
Real-time gateway websocket via bwmarrin/discordgo (isolated to this adapter,
enforced by the vendor-SDK guard test). Skips our own + other bots' messages,
splits replies to Discord's 2000-char limit on newline boundaries. Enabled by
MEMCODE_DISCORD_BOT_TOKEN in the global .env.
Outbound websocket via slack-go — no public inbound URL needed. Skips bot
messages (including our own replies) and message subtypes, acks each request
promptly. Enabled by MEMCODE_SLACK_APP_TOKEN + MEMCODE_SLACK_BOT_TOKEN in the
global .env. SDK isolated to this adapter (vendor-SDK guard).
GitHub is an event source, not a chat channel: an HMAC-SHA256-verified webhook
receiver that turns a failed workflow_run into an agent task and routes the
result to a configured chat conversation (github.reply_to, e.g. telegram:123456).
Deliveries are de-duplicated on X-GitHub-Delivery; memcode's own bot and
memcode/* branches are ignored so a fix run can't trigger itself. The gateway
now serves webhooks on :8787 (configurable) alongside the chat channels.
Webhook-in (GET verification handshake + POSTed messages), Graph-API-out for
replies. Decouples reply routing from Start-driven channels via a small
replySender interface, so WhatsApp registers its Send without pretending to be a
long-poll channel. Stays inert until whatsapp.active is set in gateway.yaml —
Meta business verification is an external account state, so activation is a
manual switch. Tokens in the global .env, phone number id in gateway.yaml.
Bot tokens are third-party platform credentials, not memcode infra — so they take
each platform's own conventional variable name (TELEGRAM_BOT_TOKEN,
DISCORD_BOT_TOKEN, SLACK_APP_TOKEN/SLACK_BOT_TOKEN, GITHUB_WEBHOOK_SECRET,
WHATSAPP_*), matching what every platform's docs and both major self-hosted
gateways (Hermes, OpenClaw) use. Users can paste values straight from platform
docs, and a config exported from another gateway drops in unchanged. Only
memcode's own infra keeps the MEMCODE_ prefix.
The #1 failure mode in both Hermes and OpenClaw: at-least-once inbound + in-memory
dedup means a restart, reconnect, or provider retry re-runs an old message as a
fresh (paid) agent turn. Fix it with a stable per-message identity carried on
every Inbound (Telegram update_id, Discord message id, Slack event ts, GitHub
delivery, WhatsApp wamid) and a dedicated SQLite state store: MarkProcessed is an
atomic INSERT OR IGNORE, so it both survives restarts and guards two concurrent
deliveries of the same id. Records prune after 30 days. Kept separate from the
core event store so the spine's interface stays clean.
…onfig

The gateway ran arbitrary agent tasks in Auto mode for anyone who could message
the bot. Both Hermes and OpenClaw gate every channel with an allow-list; so do we
now. Config is restructured to a channels.<name> object (matching both, and the
import target) holding each channel's settings plus allow_from. The router drops
any chat message whose principal isn't allow-listed — default-deny, with a global
allow_all escape hatch and a per-inbound Trusted bypass for signature-verified
webhooks (GitHub). The setup wizard captures allowed principals per channel.
One shared channels.Chunk (loss-free, newline-preferring) replaces Discord's
private copy — both Hermes and OpenClaw grew message-too-long bugs exactly where
a side path bypassed the shared splitter. Telegram now: chunks to its 4096 limit;
persists the getUpdates offset in the state store so a restart resumes at the
last ack instead of replaying (and re-running) the backlog; backs off with
exponential + jittered delay capped at 60s so it can't resonate with Telegram's
~30s session TTL; and honors 429 retry_after on send instead of hammering.
Replace the fire-and-forget 'go handle()' per inbound with a dispatcher: each
conversation gets a worker that processes its messages one at a time in order
(replies can't interleave, a conversation can't double-spend on overlapping
turns), while a global semaphore caps concurrent agent jobs so a message flood
can't spawn unbounded subprocesses. Different conversations still run in
parallel up to the cap.
WhatsApp inbound POSTs were unauthenticated — anyone who knew the URL could
inject messages. Meta signs the raw body with the app secret (X-Hub-Signature-256);
we now verify it and reject unsigned/forged POSTs, keeping the GET verification
handshake as a separate path (it carries no signature). The app secret is a new
.env key (WHATSAPP_APP_SECRET), required before the channel will activate. The
per-phone allow_list still governs which senders may drive the agent — the
signature authenticates the transport, the allow-list authorizes the sender.
(GitHub dedup is already durable via the router's state-store dedup.)
`memcode gateway import [openclaw.json]` migrates an existing OpenClaw setup —
maps each supported channel's credentials to memcode's .env keys and its allow
list to channels.<name>.allow_from, merging into the current config. Resolves
OpenClaw's SecretInput forms (literal, $ENV shorthand, {source:env} ref via the
environment); anything it can't carry (external secret providers, unset env refs,
unsupported channels like Signal/iMessage, WhatsApp's non-transferable creds) is
reported as a note, never silently dropped. Finds the config at OpenClaw's own
default locations when no path is given. Uses stdlib JSON (OpenClaw writes plain
JSON) — no JSON5 dependency, which would have dragged a JS VM into the CLI.
Rewrite the gateway README for the channels.<name> config, default-deny
allow-lists, the reliability invariants (idempotent dispatch, per-conversation
ordering, durable offset, reconnect backoff, one egress, authenticated
webhooks), and `gateway import`. Surface the gateway in the main README.
Slack was posting full reply text directly while the docs claimed all outbound
goes through one length-aware chunker. Route Slack sends through channels.Chunk
like Telegram and Discord, splitting under Slack's message limit.
Telegram and Discord emitted the @username as the principal when present, so a
user who allow-listed their numeric id was rejected once they had a username, and
a mutable handle is a weaker key anyway. Emit the stable numeric id (Telegram
user id, Discord snowflake) as the principal; Slack and WhatsApp already used
stable ids. The setup wizard now teaches allow-listing ids.
…ter)

An allow-listed user chatting normally in a Discord/Slack/Telegram channel spawned
a paid agent job for every message. Match the established convention (Hermes and
OpenClaw both default require-mention in groups on all three): a direct message
always triggers, but in a group the bot acts only when addressed. Each adapter
learns its own identity (Telegram getMe, Discord State.User, Slack auth.test) and
detects addressing structurally — Telegram message entities + reply-to-bot
(UTF-16-correct offsets), Discord mentions array + reply-to-bot, Slack <@botId>.
The router drops a non-direct, non-mentioned message unless channels.<name>.
respond_to_all is set. WhatsApp Cloud messages are 1:1 so they count as direct.
Identity-fetch failure degrades safe: DMs still work, group mentions just won't
match.
Make dispatch actually durable: an adapter now hands each message to a Sink that
records it in a durable inbox BEFORE the provider is acked (Telegram advances its
offset, Slack acks the socket, GitHub/WhatsApp return 2xx only after the row is
written). A worker drains the inbox — replaying anything a crash left pending — and
runs each as an agent job, marking it done only after the job COMPLETES. So a
redelivery or restart never re-runs a finished job (the inbox (channel,message_id)
key is the dedup), and a crash can at worst re-run an interrupted one: at-least-once,
not the lost-or-duplicated delivery Codex flagged.

Gateway activity is logged to the MAIN event store — gateway_message_received /
job_spawned / result_posted / message_dropped / unauthorized, with channel,
conversation, principal_id, message_id, job_id — so it's visible to memcode. Per
the objective doctrine, an inbound chat message is NOT turned into an objective.

Replaces the in-memory dedup + channel with the inbox; the dispatcher is now a
generic per-key serial executor.
…, durable inbox)

Document the two-check gate (default-deny on stable ids + require-mention in
groups with respond_to_all to relax), and correct the reliability section to the
durable-inbox / at-least-once model and the main-event-log visibility. Note the
chunker covers the chat channels.
A schedules: list in gateway.yaml runs a task on a cadence (every: "24h" or a
cron expression) and posts the result to a chat conversation. Each fire builds a
Trusted synthetic message and calls the same Deliver the channels use, so it
flows through the durable inbox → worker → reply spine unchanged — the scheduler
is just another producer of inbox rows. Uses robfig/cron (stdlib-only). This is
the parity gap that turns the gateway from purely reactive into autonomous.
A conversation now keeps context across messages instead of a fresh stateless
subprocess each time. The gateway derives a deterministic session id per
(channel, conversation) and passes it to the job; the --job child pins it and
resumes the prior transcript if it exists (resume-or-create via the chat seams),
so follow-ups continue the same session. No mapping is stored — the id is derived.

Runtime change is minimal and behavior-preserving: a caller can pin a session id
(SetSessionID), which StartChat honors verbatim; every existing caller still
mints fresh (a leftover sessionID between two chats on one Session is unaffected).
jobs.Spawn gains a session arg (empty for TUI/background jobs).
channels.<name>.tier routes a channel's agent runs to a stronger model:
"strong" or "frontier", empty stays automatic (cheap for routine work). A
code-review channel can run strong while a status channel stays cheap. Reuses the
existing --tier force-escalation path through jobs.Spawn; no new signature. A
per-channel specific-model pin can follow once SetModel is confirmed to force
through automatic routing.
`memcode gateway install` writes an OS-native service unit that runs the gateway
in the current project — a launchd LaunchAgent on macOS, a systemd --user unit on
Linux — and prints the one command to start it. `gateway uninstall` removes it.
It writes the unit but doesn't run launchctl/systemctl itself, since activating a
system service is the operator's call. Unit generation is a pure, tested function.
@timothyerwin
timothyerwin marked this pull request as ready for review August 14, 2026 10:40
…ow-list

Locks the security boundary Kimi probed: an untrusted chat message from an
unlisted principal is still dropped, while a Trusted producer (schedule or
signature-verified webhook) enqueues by design. Trusted is set only by the HMAC
GitHub trigger and fireSchedule (operator config); no chat adapter sets it.
`gateway import` now auto-detects the format: OpenClaw JSON (top-level channels)
or Hermes YAML (platforms). Hermes tokens live in ~/.hermes/.env under the same
variable names memcode uses, so they carry over directly; allowed_users becomes
channels.<name>.allow_from. With no path it checks OpenClaw's default location
then Hermes's. Also fixes anyToString to handle YAML integer ids (were dropped).
The audit's headline: the gateway already fails safe — a ModeAuto job has no
approver, so the permission gate denies every dangerous/catastrophic command
(chat cannot become RCE). These fixes close the residual issues it found:

- WARN loudly at startup on open surfaces (allow_all, allow_from "*",
  respond_to_all) — an autonomous agent for un-allow-listed senders should be a
  deliberate choice, not a silent one
- Importers strip "*" from an allow-list rather than silently inheriting an open
  channel from OpenClaw/Hermes (reported as a note)
- gateway.yaml written 0600 (the allow-list of ids is sensitive on a shared host)
- Reject control chars in the service-unit binary/workdir path and XML-escape the
  plist, closing unit-file directive injection
- Session id derivation widened to 128 bits (headroom; it grants nothing anyway)
The path was already optional (import auto-detects OpenClaw's and Hermes's default
locations), but advertising it in the usage made it look required. Drop it from
Use, and lead the help with 'just run it'. A path is now an escape hatch for a
non-standard config location, not the front door.
…turn

Instructions (MEMCODE.md) already split user-wide vs project. Memory did
not exist as a first-class store at all — learned facts were per-project
only, so nothing the agent knew about the user travelled between repos.

Add memory.md as the facts counterpart to MEMCODE.md's rules: global
~/.memcode/memory.md AND project .memcode/memory.md, additive (a global
fact travels across projects, a project fact stays put), loaded once per
session and injected every turn alongside instructions. Framed as
background knowledge, never as instructions to act on, so a memory can
never steer the agent. The global file is the destination for memories
migrated in from another assistant.
Replace the single auto-detecting 'gateway import' with a source-specific
command per assistant. Guessing between two installs was the bug: a user who
moved OpenClaw -> Hermes still has ~/.openclaw around, and auto-detect would
silently import the stale one. Naming the source removes the ambiguity.

Each migrates the full install, not just channels:
  - channels     -> gateway.yaml + global .env (as before)
  - API keys     -> provider keys copied to the global .env (same names)
  - skills       -> SKILL.md dirs copied to ~/.memcode/skills, conflicts kept,
                    flagged as third-party code to review
  - memory       -> conversation/history store preserved under
                    ~/.memcode/imported/<source>/, with an idempotent pointer
                    written into global memory.md

memcode's memory is per-repository, so an assistant's global memory has no
native destination; it is preserved and pointed at rather than dropped or
force-parsed into a store where it does not belong.
The previous cut preserved the memory store as a file and left a pointer.
That is not how OpenClaw and Hermes behave: their memory is global and
actually loaded. So do it properly.

Their memory is markdown, not an opaque DB, so it can be read and used:
  - OpenClaw: workspace MEMORY.md, USER.md, SOUL.md, AGENTS.md, and the
    daily memory/*.md files, searched across the configured workspace and
    OpenClaw's renamed variants (workspace-main, workspace-assistant).
  - Hermes: ~/.hermes/memories/*.md, entries delimited by bare § lines.

Port Hermes's own extractor (openclaw_to_hermes.py extract_markdown_entries):
each bullet and paragraph becomes an entry prefixed with its heading context,
fenced code blocks and table rows are dropped, entries are deduped by
whitespace-normalized text and capped to a 20k budget. The result is written
into global ~/.memcode/memory.md as a bounded, marker-delimited block that a
re-run replaces in place, so migrated memory is loaded into every session the
way it was in the source assistant.
@timothyerwin timothyerwin changed the title gateway: self-hostable event/objective/action runtime (WIP) OpenClaw + Hermes Support Aug 14, 2026
Two reliability gaps the durability language was writing checks the code
didn't cash (external review):

Reply loss: process() marked an item done before sending the reply and never
retried a failed send, so a completed job's result could vanish on a transient
channel error. Split the flow: a finished job moves pending -> replied with its
result stored, so it is never re-run; delivery is a separate step that retries
in-process and, if the channel is still down, stays queued and replays after a
restart. New 'replied' inbox state + PendingReplies outbound queue.

Double-processing: the only guard against running an item twice was an
in-memory map, so a second 'memcode gateway' on the same repo would double
every job. Take an exclusive flock on the project at startup; a second gateway
is refused with a clear message. No-op on platforms without flock (the service
installer is macOS/Linux only).
The Linux service unit interpolated the binary and working-directory paths
raw, so a path with a space split into two ExecStart arguments and a literal %
was read as a systemd specifier. Quote both paths and double %; reject a path
containing a double quote rather than emit a broken unit. macOS already
XML-escaped its plist values.
The package comment said it read OpenClaw's JSON5, but it parses with
encoding/json — which is correct, since OpenClaw writes plain JSON and Hermes's
own importer reads openclaw.json the same way. Correct the comment to match.
@timothyerwin
timothyerwin merged commit 4064207 into main Aug 14, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant