Skip to main content
Glama

hermes-ego

Hermes drives ego lite through an MCP relay and a Mac daemon. The daemon dials out. The Mac does not accept a connection. This is a link-proving prototype for the Hermes Agent Plugin Hackathon. The catalog package is the portable Agent Plugins v1 directory plugins/hermes-ego (plugin.json, not a native plugin.yaml Python plugin). The relay and the Mac daemon stay separately installed programs, the same split as the catalog entry local-mcp.

Two ways to run it:

A. Same machine. Hermes, the relay, the daemon, and ego lite are on one Mac. The relay binds 127.0.0.1:8767. The daemon uses ws://127.0.0.1:8767/agent. Linux can run the relay half of this; the daemon still needs a Mac with ego lite.

B. Split. The relay and Hermes run on a Linux VPS. EGO_RELAY_HOST=127.0.0.1, so the relay is not on a public port. The Mac daemon reaches it through an SSH local forward, or through Tailscale when the tailnet ACL allows that address. Shawn's production link is this shape.

Mode A, one Mac                         Mode B, split
┌──────────────────────────┐            Mac                              VPS
│ Hermes ──MCP──▶ relay    │            ┌─────────────────────┐         ┌─────────────────────┐
│ ego lite ◀── daemon ─────┘            │ ego lite            │  SSH -L │ Hermes              │
│              ws://127.0.0.1:8767      │ daemon ─────────────┼────────▶│ relay 127.0.0.1:8767│
└──────────────────────────┘            └─────────────────────┘         └─────────────────────┘

The relay's socket-and-MCP shape is derived from hermes-tabs (MIT). That copyright is in LICENSE and NOTICE.

Quickstart

Two layers. Layer A is the relay and the Mac daemon, below. Layer B is the portable plugin Hermes loads, in Hermes plugin (layer B). The plugin does not start the relay or the daemon. Layer A has to be up before the plugin's tools can answer.

Install uv. On a Mac, install ego lite and leave its GUI running. This repository does not ship ego lite, and this packaging change did not verify a checksum-pinned download URL. The product page linked at the top is the reference already in this tree. Install ego lite from a source you trust. scripts/doctor.sh looks for ego-browser on a daemon role.

scripts/install.sh creates ~/.config/hermes-ego (mode 0600), writes config, runs uv sync, and installs a service unit. It does not print tokens. A token that is already set, and is not the example placeholder, is left alone.

Mode A on a Mac (relay + daemon):

git clone <this repo> hermes-ego && cd hermes-ego
scripts/install.sh
scripts/doctor.sh

The default role on macOS is both. The relay unit is com.hermes.ego.relay. The daemon unit is com.hermes.ego.daemon. Notes default to ~/hermes-notes.

Mode B, relay on the VPS. Root gets a system unit. Any other user gets a systemd user unit:

git clone <this repo> hermes-ego && cd hermes-ego
scripts/install.sh --role relay --save-root /root/hermes-notes --save-tz Europe/Berlin
scripts/doctor.sh --role relay

Mode B, daemon on the Mac. --ssh-target installs com.hermes.ego.tunnel, which runs ssh -N -L 127.0.0.1:8767:127.0.0.1:8767 user@vps. The daemon then uses ws://127.0.0.1:8767/agent. For Tailscale instead, omit --ssh-target and pass --relay-url wss://<vps>.<tailnet>.ts.net/agent.

scripts/install.sh --role daemon --ssh-target user@vps
scripts/doctor.sh --role daemon

Use the same token on both sides. The installer will not print it. On the relay it lives in ~/.config/hermes-ego/relay.env as EGO_RELAY_TOKEN. On the Mac it is token in ~/.config/hermes-ego/config.toml. --token is accepted and then ignored when a real token is already stored. It is visible in the process list; the hidden prompt (or EGO_RELAY_TOKEN in the environment for that one command) is quieter.

scripts/doctor.sh prints PASS, FAIL, and SKIP lines and exits non-zero when any check fails. It checks Python 3.11+, uv or the repo venv, ego-browser on a daemon role, that a token is set, that EGO_SAVE_ROOT exists and its owner can write it, that the relay answers on /mcp, that ego_save_page_markdown is in the MCP tool list, that the daemon is connected with capabilities=fetch_images, that ego lite answered, and that the systemd unit is not failed and its recent journal has no traceback.

Related MCP server: Better Remote Commander

Environment

Variable

Where

Default

EGO_RELAY_TOKEN

relay env

required. Bearer for /mcp and the daemon hello.

EGO_RELAY_HOST

relay env

127.0.0.1. Leave it there in both modes.

EGO_RELAY_PORT

relay env

8767

EGO_SAVE_ROOT

relay env

unset disables ego_save_page_markdown only

EGO_SAVE_TZ

relay env

UTC. An IANA name, for example Europe/Berlin.

EGO_SAVE_UID, EGO_SAVE_GID

relay env

uid/gid of EGO_SAVE_ROOT

EGO_IMAGE_FETCH

relay env

relay

EGO_IMAGE_BUDGET_MS

relay env

90000

HERMES_EGO_ENV

Mac relay launchd

path of relay.env. systemd uses EnvironmentFile instead.

relay_url, token, device

daemon config.toml

ws://127.0.0.1:8767/agent after install

/mcp requires Authorization: Bearer. A request with an Origin header is rejected with 403. GET /mcp is 405. The daemon sends the token in the first WebSocket message, not in the URL. A bad token is closed with 1008.

Hermes plugin (layer B)

plugins/hermes-ego is what a catalog subdir points at. Hermes connects to the relay that layer A already bound at http://127.0.0.1:8767/mcp. The bearer is ${EGO_RELAY_TOKEN} in mcp.json. The token is not in the file. Put the same value the relay uses into the Hermes profile environment, as EGO_RELAY_TOKEN (the plugin settings field relay_token stores that name). Do not commit it.

Check the package with the command catalog CI runs:

hermes plugins validate plugins/hermes-ego --install-deps

Use an absolute path when the shell is not at the repo root. This directory has no Python dependencies. --install-deps is still the admission flag.

Until the public repository exists, copy plugins/hermes-ego to ~/.hermes/plugins/hermes-ego so plugin.json is directly inside that folder, then:

hermes plugins enable hermes-ego

After https://github.com/loop2zero/hermes-ego exists, the current plugins guide installs a package that is not at the repo root as owner/repo/subdir (the iris-chat catalog page uses that shape):

hermes plugins install loop2zero/hermes-ego/plugins/hermes-ego --no-enable
hermes plugins enable hermes-ego

If this Hermes rejects the extra path segment, use the copy into ~/.hermes/plugins/hermes-ego above. That layout is what the developer guide discovers.

Portable packages stay disabled until enable. Start a new session so the MCP server connects. Details, including why this is Streamable HTTP rather than a stdio launcher, are in plugins/hermes-ego/README.md.

The server name is ego. A mcp_servers.ego block already in ~/.hermes/config.yaml replaces the package entry, including trust: untrusted. Remove that block, or set trust: untrusted on it.

Hermes

The hand-written server below is the same connection the plugin declares. Prefer layer B so the catalog pin and the untrusted gate travel with the package. Use this block only when you are not enabling the plugin. If both exist, the block wins and drops the package's trust unless you add trust: untrusted.

In ~/.hermes/.env:

EGO_RELAY_TOKEN=the-same-token-as-the-relay

In ~/.hermes/config.yaml, Hermes on the same host as the relay (mode A, or mode B on the VPS):

mcp_servers:
  ego:
    url: http://127.0.0.1:8767/mcp
    headers:
      Authorization: "Bearer ${EGO_RELAY_TOKEN}"

Checked against the Hermes docs on 2026-11-06. url, headers, and ${EGO_RELAY_TOKEN} from ~/.hermes/.env are documented. transport: http is what hermes-tabs ships; the Hermes config reference treats streamable HTTP as the default when url is set and says transport: sse selects SSE. If a build rejects transport: http, delete that line. This repo was not run against a live Hermes process.

Discord or any other Hermes channel, as the channel prompt:

When reading a page, call ego_get_page_text with the url so the open and the read are one call. Do not call ego_navigate and then ego_get_page_text as two calls; another caller can change the shared tab in between. When saving a page, call ego_save_page_markdown with the url (or omit it for the current tab), a relative subdir, and a short slug. Do not transcribe the page with write_file or apply_patch. The tool stores the markdown verbatim and returns only the path and counts.

Security

The bearer token is the only authentication on /mcp and /agent. Keep relay.env and config.toml mode 0600, and do not commit them. The relay binds localhost. Do not publish port 8767. In mode B the Mac connects out (SSH forward or Tailscale); the VPS does not open a port for the daemon.

ego_save_page_markdown writes only under EGO_SAVE_ROOT. Absolute paths, .., and symlinks under that root are rejected. The root itself may be a symlink; the real directory is the jail. The tool does not chown the root or directories that already existed. Files it creates are mode 0644, directories 0755, owned by the root's uid and gid unless EGO_SAVE_UID / EGO_SAVE_GID override that. Notes are never overwritten: the name is YYYY-MM-DD-<slug>.md, then -2 through -100.

There is no click or type tool. The daemon uses one task space, hermes-ego, and only pages it created (openedBy === "agent").

Security / disclosures (catalog rule 13)

What a user should know before enabling plugins/hermes-ego. The same text is in that directory's README, which is the file a catalog page renders when subdir is set. Nothing here is a real address, hostname, channel, or token.

  • Long-running relay. Layer A leaves a relay process bound to 127.0.0.1:8767. The plugin does not supervise it. Do not publish that port.

  • Mac daemon runs the ego CLI. A separate process on the Mac talks to ego lite and dials out to the relay (loopback, or an SSH local forward when Hermes is on another host). The Mac does not accept a connection from the Hermes host.

  • Logged-in browser. Tools use the user's ego lite session, including sites that session is logged into. There is no click or type tool.

  • Filesystem write. ego_save_page_markdown writes markdown under the configured save root, for example an Obsidian vault. Paths outside that root are rejected.

  • Image download. A save downloads third-party image URLs from the page. Leftovers can finish in a background pass after the tool returns.

  • Optional SSH tunnel. When Hermes is not on the relay's machine, an SSH local forward makes 127.0.0.1:8767 on the Hermes host the relay. The plugin still calls loopback. The token is not put in the SSH command by this package.

  • Token. EGO_RELAY_TOKEN comes through the Hermes profile environment (the plugin secret or ~/.hermes/.env). It is not committed. The package does not read relay.env, auth.json, or a browser profile.

  • Approvals. The package marks server ego untrusted, so Hermes asks before tools that are not annotated readOnlyHint: true. The relay does not set that hint, so reads ask too, and cron or any other unattended run fails closed.

  • No telemetry and no self-update in the plugin directory.

ego lite itself is not part of this repository. Install it on the Mac before layer A. A download URL with a checksum was not verified for this change.

Tools

Tool

Behavior

ego_status

Daemon socket (online), device, ego version, egoReachable, and capabilities from the hello. online stays true when the daemon is up but ego lite is down.

ego_list_tabs

Agent tabs: label, title, url, active.

ego_navigate(url)

http and https only. One shared tab. Does not read.

ego_get_page_text(url?, label?, mode?)

Pass url to open that page and read it under one tab lock. Omit url to read the current agent page. auto: yitang /fs-doc/ authored markdown up to 1,000,000 characters; every other page is a generic article extract.

ego_save_page_markdown(subdir, slug, url?, label?, title?, mode?)

Writes that page on the relay. Returns path and counts, not the body. Same mode values as ego_get_page_text. Pass url to open and save under one tab lock.

mode is auto, generic, snapshot, or yitang.

Mode

What is saved

auto

yitang when the URL host is yitang.top (or a subdomain) and the path contains /fs-doc/. Otherwise generic.

yitang

Authored lesson markdown from the page's dataJson. Unchanged.

generic

Article markdown from the page HTML the daemon reads in ego: title, headings, paragraphs, lists, links, and images.

snapshot

The accessibility tree, cut at 20,000 characters.

generic does not download the URL again, so a logged-in page still extracts. Nav, footer, header, aside, and cookie or consent blocks are removed before trafilatura writes the markdown. Image destinations stay ![alt](https://...), so the relay budget and the Mac backfill run as they do for any other note. If ego cannot return HTML, the daemon sends the accessibility snapshot and the relay drops obvious chrome lines (Accept all, Privacy Policy, and the same kind of landmark). That fallback is still the accessibility tree, only shorter. An older daemon that does not send HTML keeps returning the raw snapshot for non-yitang pages.

ego_get_page_text with url, and ego_save_page_markdown with url, hold the relay tab lock across navigate and read. Two of those calls at once cannot return each other's page. ego_navigate followed by ego_get_page_text without url drops the lock between the calls, so another client can navigate in the gap. ego_status does not take the lock and still returns while a read is in progress. Pass url or label, not both.

The lock that makes an open-and-read atomic is the relay's. The daemon still runs list_tabs, navigate, and get_page_text one command at a time, and status still bypasses that daemon lock. The daemon lock does not cover the gap between two MCP calls. There is still one shared agent tab; this does not add tabs.

Saving a page and images

The note body is the get_page_text string for the mode above. The edits are http(s) image links rewritten to files under assets/<note-stem>/, a failure marker on images that did not download, and YAML frontmatter. Fenced code and non-http tokens such as ![image](boxcnToken) are left alone. Generic article images use the same rewrite, the same 90s relay budget, and the same Mac backfill.

Frontmatter includes title, source_url, captured_at, device, mode, chars, images, images_ok, images_failed, image_refs, and truncated.

EGO_IMAGE_FETCH defaults to relay. The synchronous image phase runs on the relay and stops starting new downloads after EGO_IMAGE_BUDGET_MS (default 90s). Downloads already in flight finish the current attempt. Successes are kept. The rest are marked budget, or with the real error if an attempt already failed. The note is written and the tool returns. images_pending is how many distinct URLs are left for one background pass. images_via_daemon and images_via_relay count only that synchronous phase.

After the tool returns, if the daemon hello listed fetch_images, one background pass asks the Mac for the leftovers. The Mac fetches them (China CDN is reliable from the Mac) and streams the bytes. The relay writes them into the same asset directory, with the same path jail and owner, and replaces each **[image download failed]** marker with a relative link. It then sets images_ok, images_failed, and images from the final body. image_refs stays the count from the original capture. A file already on disk is not downloaded again. A second pass for the same note does not start while the first is running. <note>.pending.json lists what is still open and is removed when the pass finishes. There is no MCP response for the backfill. If the daemon lacks fetch_images or is disconnected, the pass logs backfill skipped and the markers stay.

auto asks the Mac during the save, then the relay, and does not schedule the background pass. daemon uses only the Mac during the save.

Either side: 4 at a time, at most 100 distinct URLs, 8 MiB each, 3 tries, backoff 0.4s then 0.8s, connect timeout 5s, body timeout 10s. A 404, a non-image, a private address, or a file over the cap is not retried. One bad image does not fail the save.

Layout

Path

What

plugins/hermes-ego/

Portable Agent Plugins v1 package (catalog subdir).

docs/catalog/

Draft catalog entry and the NousResearch PR description. Not opened against Nous from here.

relay/

MCP server and daemon WebSocket.

agent/hermes_ego_daemon.py

Mac daemon.

deploy/

systemd unit and launchd plists. install.sh fills in paths.

scripts/install.sh

Config, venv, and service install.

scripts/doctor.sh

Self-check.

smoke_test.py

Relay, real daemon, fake ego-browser.

uv sync
uv run python smoke_test.py

An old daemon still returns a snapshot for pages that are not yitang lessons. An old relay still cuts every page at 20,000 characters and does not know mode=generic. fetch_images needs the daemon from the keepalive change. Without that capability the synchronous relay save still finishes and the background pass logs backfill skipped. Generic extraction needs this relay (it runs trafilatura) and a daemon that reads page HTML.

User unit:

cd ~/hermes-ego
git pull
scripts/install.sh --no-start
systemctl --user restart hermes-ego-relay

The live VPS relay is often a non-git tree at /root/hermes-ego and a system unit, not the user unit in deploy/. The Mac tree is often ~/code/hermes-ego or ~/hermes-ego, also not a clone. Copy the changed files and restart that unit.

An open-and-read fix lives in relay/server.py only. Copy that file to the VPS and restart the relay. The running daemon does not need that file and does not need a restart. Copy README.md if you keep the operator notes beside the code. Copy agent/test_save_page.py, agent/test_ego_status.py, agent/hermes_ego_daemon.py, and smoke_test.py when you want the race tests on that machine; the daemon copy is a comment about the per-command lock, not a behavior change.

install -m 644 relay/server.py /root/hermes-ego/relay/
systemctl restart hermes-ego-relay

The broader file set, when the change is not just that lock:

install -m 644 relay/server.py relay/save_page.py /root/hermes-ego/relay/
install -m 644 agent/article_text.py agent/yitang_text.py agent/image_fetch.py /root/hermes-ego/agent/
uv pip install --python /root/hermes-ego/.venv/bin/python 'trafilatura>=2.0'
systemctl restart hermes-ego-relay

On the Mac, generic mode needs the daemon that reads page HTML. Copy that file and the mode list, then kickstart. The Mac does not need trafilatura; the relay extracts.

install -m 644 agent/hermes_ego_daemon.py agent/yitang_text.py ~/hermes-ego/agent/
launchctl kickstart -k "gui/$UID/com.hermes.ego.daemon"

The running relay does not import hermes_ego_daemon.py. The test suite on the VPS does, because agent/test_image_fetch.py imports it. Copy agent/hermes_ego_daemon.py, agent/yitang_text.py, agent/article_text.py, agent/test_image_fetch.py, agent/test_save_page.py, agent/test_article_text.py, agent/test_install_doctor.py, and smoke_test.py as well when you want to run tests there.

A finished save logs save phases with via_relay and pending. Leftovers log backfill start and backfill done. The daemon logs fetch_images with kib_s.

Troubleshooting

scripts/doctor.sh is the first check. FAIL daemon is not connected means the WebSocket hello has not stuck: in mode B that is usually the SSH forward or Tailscale, then the token, then the daemon process. FAIL capabilities do not include fetch_images means the connected daemon is too old for backfill. FAIL ego lite is not reachable means the GUI or the ego-browser bootstrap server is down; ego-browser --version can still succeed in that state.

Relay journal: journalctl --user -u hermes-ego-relay -n 200 or, for the system unit, journalctl -u hermes-ego-relay -n 200. A failed unit or a traceback is a doctor failure. Daemon log: ~/.local/state/hermes-ego/daemon.log.

SSH upload through a proxy is slow. If the Mac uses Clash and image backfill or scp to the VPS sits at tens or a few hundred KiB/s, the VPS public IP is probably leaving through a proxy node. Add a Clash DIRECT rule for that IP and measure again. The relay still binds 127.0.0.1. The DIRECT rule only changes the path SSH takes to the VPS. The synchronous save does not depend on that upload: it downloads on the relay for at most 90s and returns. The background pass is what uploads the images the relay did not get.

Tailscale is the other split path. tailscale serve on the VPS, or EGO_RELAY_HOST on the tailnet address, keeps the bearer token on the tailnet. Prefer wss:// when the path is not the SSH forward. Without TLS, the token and every page cross the network in the clear.

Caddy, if you do publish a name, should proxy /agent only and answer 404 for everything else. Hermes on the VPS still uses http://127.0.0.1:8767/mcp and does not need that name.

Notes that are still assumptions

The daemon reuses a page only when tabs() reports openedBy === "agent" and a label. It does not call finish() or release(). One daemon per relay: a second hello replaces the first socket. Image frames are 256 KiB with at most two unacked, so a keepalive ping is not stuck behind a multi-megabyte SSH backlog. The relay waits 60 seconds for a tab command and does not retry it after a disconnect.

License

MIT. See LICENSE and NOTICE.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      Not graded
      quality
      B
      maintenance
      Enables self-hosted remote control of your own computers from Claude and ChatGPT, allowing file management, terminal access, and process control on paired machines via an outbound WebSocket relay you host.
      38 npm
      5
      ISC
    • A
      license
      Not graded
      quality
      B
      maintenance
      Enables remote AI agents to control a local, authenticated desktop browser over MCP through an outbound WebSocket bridge and Chrome extension, supporting tab management, navigation, page reading, clicking, typing, scrolling, screenshots, and JavaScript evaluation.
      1
      MIT
    • A
      license
      Not graded
      quality
      C
      maintenance
      Enables ChatGPT's web interface to operate a real Mac by executing shell commands and reading or writing files over a reverse SSH tunnel, without installing a client or consuming API quota.
      MIT