Text that number over iMessage or SMS and your Hermes agent answers. Do the steps yourself, or hand this page to an AI coding agent. New to Hermes? Install Hermes on a Raspberry Pi.
api.plow.coThis page adds one platform to Hermes. The Plow API it talks to — activation, the chat, the message and WebSocket endpoints — is documented in the Plow Chat API.
Hermes loads platform plugins from ~/.hermes/plugins/<name>/. A platform plugin is a directory with two files: plugin.yaml, the manifest, and __init__.py, which holds the adapter and a register(ctx) entry point. Both are below in full. Create the directory and write the manifest:
$ mkdir -p ~/.hermes/plugins/plow-chat-platform $ cat > ~/.hermes/plugins/plow-chat-platform/plugin.yaml <<'EOF' name: plow-chat-platform label: Plow Chat kind: platform version: 0.1.0 description: > Plow Chat (iMessage/SMS) gateway platform for Hermes. Registers a platform named plow_chat backed by the Plow Chat REST API and WebSocket stream. requires_env: - name: PLOW_CHAT_CHAT_UID description: "Plow chat uid, cht_..." - name: PLOW_CHAT_TOKEN description: "Plow session Bearer token from activation redeem" secret: true EOF
Now the adapter, the entire platform in one file. Reading top to bottom: connect() starts _listen() as a background task. _listen() mints a short-lived WebSocket ticket from the Plow API, connects, and feeds each frame to _on_frame(), which filters for inbound message_received frames and hands their text to Hermes as a MessageEvent. Replies travel the other way through send(), one POST per reply. At the bottom, register() is what Hermes calls at plugin load, and check_requirements() is how the gateway decides to enable the platform: it returns true once the Step 3 credentials are in the environment. The only dependency beyond Hermes itself is aiohttp, which the Hermes venv already ships (its messaging platforms depend on it). Write the file:
$ cat > ~/.hermes/plugins/plow-chat-platform/__init__.py <<'EOF' """Plow Chat (iMessage/SMS) platform plugin for Hermes. Working-minimal: flat 5-second reconnect, no dedupe, no message splitting, no welcome.""" import asyncio import logging import os import aiohttp from gateway.config import Platform from gateway.platforms.base import BasePlatformAdapter, MessageEvent, SendResult BASE = "https://api.plow.co" log = logging.getLogger(__name__) class PlowChatAdapter(BasePlatformAdapter): def __init__(self, config): super().__init__(config=config, platform=Platform("plow_chat")) self.chat_uid = os.environ["PLOW_CHAT_CHAT_UID"] self.auth = {"Authorization": "Bearer " + os.environ["PLOW_CHAT_TOKEN"]} self._ws_task = None async def connect(self, *, is_reconnect=False): self._ws_task = asyncio.create_task(self._listen()) return True async def disconnect(self): if self._ws_task: self._ws_task.cancel() self._mark_disconnected() async def send(self, chat_id, content, reply_to=None, metadata=None): # Fresh session per call: Hermes may invoke send() from a different # asyncio task than the WebSocket loop, where a shared session breaks. async with aiohttp.ClientSession() as http: async with http.post(f"{BASE}/v1/chats/{self.chat_uid}/messages", json={"body": content.strip()}, headers=self.auth) as resp: data = await resp.json(content_type=None) if resp.status >= 400: return SendResult(success=False, error=f"Plow Chat {resp.status}: {data}") return SendResult(success=True, message_id=data.get("uid")) async def get_chat_info(self, chat_id): return {"name": "Plow Chat", "type": "dm"} async def _listen(self): while True: # reconnect forever; a real install would back off try: async with aiohttp.ClientSession() as http: async with http.post(f"{BASE}/v1/ws/ticket", json={"chat_id": self.chat_uid}, headers=self.auth) as resp: ticket = (await resp.json(content_type=None))["ticket"] async with http.ws_connect(f"wss://api.plow.co/v1/ws?ticket={ticket}", heartbeat=30) as ws: self._mark_connected() log.info("[plow_chat] websocket connected") async for frame in ws: if frame.type == aiohttp.WSMsgType.TEXT: await self._on_frame(frame.json()) except Exception as exc: log.warning("[plow_chat] websocket error: %s", exc) self._mark_disconnected() await asyncio.sleep(5) async def _on_frame(self, frame): msg = frame.get("message") or {} if frame.get("type") != "message_received" or msg.get("direction") != "inbound": return # ignore status frames and the echoes of our own sends text = (msg.get("body") or "").strip() if not text: return sender = msg.get("sender") or {} user_id = sender.get("uid") or "member" await self.handle_message(MessageEvent( text=text, source=self.build_source(chat_id=self.chat_uid, chat_name="Plow Chat", user_id=user_id, user_name=sender.get("display_name") or user_id), message_id=msg.get("uid"), )) def check_requirements(): return bool(os.environ.get("PLOW_CHAT_CHAT_UID") and os.environ.get("PLOW_CHAT_TOKEN")) def register(ctx): ctx.register_platform( name="plow_chat", label="Plow Chat", adapter_factory=lambda cfg: PlowChatAdapter(cfg), check_fn=check_requirements, platform_hint="You are chatting over an iMessage/SMS-style Plow Chat " "thread. Use concise plain text; markdown does not render.", ) EOF
Last, enable the plugin. Two names are in play: config enables the manifest name plow-chat-platform, while logs and commands use the runtime platform name plow_chat. Enable it — the robust way is the CLI:
$ ~/.local/bin/hermes plugins enable plow-chat-platform $ ~/.local/bin/hermes plugins list --plain --no-bundled | grep plow-chat-platform # verify enabled enabled user 0.1.0 plow-chat-platform
Or edit ~/.hermes/config.yaml directly. The one-liner appends a fresh plugins: section only when the file has none; if the file already has one, add - plow-chat-platform under its enabled: list yourself:
$ grep -q '^plugins:' ~/.hermes/config.yaml || printf 'plugins:\n enabled:\n - plow-chat-platform\n' >> ~/.hermes/config.yaml $ grep -A2 '^plugins:' ~/.hermes/config.yaml plugins: enabled: - plow-chat-platform
Confirm the files landed:
$ find ~/.hermes/plugins/plow-chat-platform -type f ~/.hermes/plugins/plow-chat-platform/plugin.yaml ~/.hermes/plugins/plow-chat-platform/__init__.py
~/.local/bin/hermes. A non-interactive ssh <host> '…' shell has no ~/.local/bin on PATH, and source ~/.bashrc won’t fix that. bash -lc also works. Installing for a named profile instead of the default? Repeat this step under "$HOME/.hermes/profiles/<name>/" — each profile keeps its own plugins/, config.yaml, and .env — and do the same in Step 3.This binds the chat to one sender, so it’s yours: you text one code from the iMessage account or phone number you’ll use to chat with Hermes. The block below is the whole activation client: it asks Plow to provision a chat, prints the code, polls until your text lands, then appends the credentials to ~/.hermes/.env at mode 600. Start it:
$ python3 - <<'EOF' import json, os, time, urllib.error, urllib.request BASE = "https://api.plow.co" def post(path, body): req = urllib.request.Request(BASE + path, data=json.dumps(body).encode(), method="POST", headers={"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=30) as resp: return json.loads(resp.read().decode()) act = post("/v1/auth/activate", {"name": "Hermes user", "provision_chat": True}) print("Text Plow Activate: %s to %s" % (act["display_code"], act["send_to"])) while True: try: r = post("/v1/auth/activate/redeem", {"activation_secret": act["activation_secret"]}) except urllib.error.HTTPError as e: if e.code == 410: raise SystemExit("Activation code expired -- run this block again for a fresh code.") raise if r.get("status") == "verified": break print("pending -- waiting for your text") time.sleep(5) env_path = os.path.expanduser("~/.hermes/.env") fd = os.open(env_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) with os.fdopen(fd, "a") as f: f.write("PLOW_CHAT_CHAT_UID=%s\n" % r["chat"]["uid"]) f.write("PLOW_CHAT_TOKEN=%s\n" % r["token"]) print("Verified: chat is active.") print("Chat uid: %s" % r["chat"]["uid"]) print("Wrote PLOW_CHAT_CHAT_UID + PLOW_CHAT_TOKEN to %s" % env_path) EOF Text Plow Activate: ABCDE to +1... pending -- waiting for your text
From that account or number, text the Plow Activate: … line the block printed, exactly as shown, to the number beside it — your code will differ from the ABCDE example. When your text arrives, the poll ends and the credentials land:
Verified: chat is active. Chat uid: cht_... Wrote PLOW_CHAT_CHAT_UID + PLOW_CHAT_TOKEN to /home/<user>/.hermes/.env
410 and the block exits with Activation code expired -- run this block again for a fresh code. Running it again prints a fresh code.Two values, appended by the block. The token is a user Bearer credential, not a per-chat secret — never commit or log it. Confirm both landed without printing them:
$ grep -oE '^PLOW_CHAT_[A-Z_]+=' ~/.hermes/.env # key names only — values stay off the screen PLOW_CHAT_CHAT_UID= PLOW_CHAT_TOKEN=
The activation, redeem, and chat details behind this step are in the Plow Chat API.
Text Plow Activate: … line first. Relay that exact line to the reader and wait — only they can send the text from the enrolled account or number. When it prints Verified: chat is active., continue to Step 4. If it prints Activation code expired, run the block again and relay the fresh code.The gateway reads plugins and the environment at startup, so the platform comes up only after you start it. With PLOW_CHAT_CHAT_UID and PLOW_CHAT_TOKEN already in ~/.hermes/.env from Step 3, check_requirements() returns true and the gateway enables plow_chat on boot — no more config edits. Register the gateway as a user service the first time, then start it:
$ ~/.local/bin/hermes gateway install # first time only — registers the user service $ ~/.local/bin/hermes gateway start $ ~/.local/bin/hermes gateway status # active
A gateway that was already running won’t see the new plugin until it restarts. Stop it, then start it:
$ ~/.local/bin/hermes gateway stop $ ~/.local/bin/hermes gateway start
Confirm the plow_chat platform loaded. The gateway logs the platform connect, then the adapter logs when its WebSocket is up:
$ grep plow_chat ~/.hermes/logs/gateway.log | tail -2 INFO gateway.run: ✓ plow_chat connected INFO hermes_plugins.plow_chat_platform: [plow_chat] websocket connected
From that same account or number, text the Plow chat. The first text gets a pairing code, not an answer: Plow verified your phone in Step 3, but Hermes runs its own sender gate and doesn’t know you yet.
you → hello? plow ← Hi~ I don't recognize you yet! Here's your pairing code: `7MYVLF36` Ask the bot owner to run: `hermes pairing approve plow_chat 7MYVLF36`
Approve that code on the host, once; Hermes knows you from then on:
$ ~/.local/bin/hermes pairing approve plow_chat 7MYVLF36 Approved! User <name> (usr_…) on plow_chat can now use the bot~
Then talk:
you → what's the weather looking like tomorrow? plow ← Checking — tomorrow near you is 18–24°C, clear in the morning...
Replies come back as plain text. The plugin’s platform_hint tells the model to skip Markdown, which iMessage renders as raw symbols.
~/.local/bin/hermes pairing approve plow_chat <code> on the host, then have them text once more.Read once before you start.
| Edge | What to do |
|---|---|
| Manifest name vs platform name | Config enables plow-chat-platform; the running platform is plow_chat. Enable the manifest name in plugins.enabled; don’t swap the two. |
aiohttp missing loads nothing | Hermes ships aiohttp in its own venv (the messaging platforms depend on it), so this is rare. Without it the plugin silently fails to load — no platform, no error. ~/.hermes/hermes-agent/venv/bin/pip install aiohttp restores it. |
| Env must exist before start | The gateway enables plow_chat only when PLOW_CHAT_CHAT_UID and PLOW_CHAT_TOKEN are in ~/.hermes/.env. Finish Step 3 before Step 4. |
| The token is a user credential | PLOW_CHAT_TOKEN is a user Bearer token, not a per-chat secret. Keep ~/.hermes/.env mode 600; never commit or log it. |
| First text gets a pairing code | Expected: Hermes gates unknown senders separately from Plow’s phone verification. Approve once with ~/.local/bin/hermes pairing approve plow_chat <code> (Step 5). |
| Activation code expires | If your text is late, redeem returns HTTP 410 and the Step 3 block exits with Activation code expired. Run the block again for a fresh code. |
| Plugin changes need a restart | Editing files under ~/.hermes/plugins/ does nothing to a running gateway. Apply changes with ~/.local/bin/hermes gateway stop then start. |
hermes not found over SSH | A non-interactive ssh <host> 'hermes …' shell has no ~/.local/bin on PATH. Use ~/.local/bin/hermes, or wrap in bash -lc. |
| Long replies aren’t split | send() POSTs the whole reply as one body; Plow rejects over-long messages, and that reply is lost with an error in the gateway log. If it bites, split the body in send(). |
| Duplicate delivery after a reconnect | The plugin keeps no record of seen message uids, so a text that lands while the socket is reconnecting can reach Hermes twice. If that bites, track message.uid in _on_frame(). |
| One chat per plugin instance | PLOW_CHAT_CHAT_UID is the single chat this platform handles. For a second chat, repeat Steps 2 and 3 under a named profile home, ~/.hermes/profiles/<name>/, which keeps its own plugins/, config.yaml, and .env. |
Remove the platform; leave Hermes itself running.
Stop the gateway, remove the plugin directory, drop the manifest name from plugins.enabled, and clear the credentials. Then start the gateway again so it comes up without plow_chat:
$ ~/.local/bin/hermes gateway stop $ rm -rf ~/.hermes/plugins/plow-chat-platform $ sed -i '/plow-chat-platform/d' ~/.hermes/config.yaml # drop it from plugins.enabled $ sed -i '/^PLOW_CHAT_/d' ~/.hermes/.env # clear the two PLOW_CHAT_* values $ ~/.local/bin/hermes gateway start
plow-chat-platform was the only entry under enabled:, the sed leaves an empty enabled:. That parses as no plugins, which is fine. Open ~/.hermes/config.yaml to confirm it still reads cleanly if you edited it by hand.Hermes itself is untouched, config and sessions included. Removing this platform doesn’t touch the Plow side either — the chat and its token live on. To delete the chat or revoke the token, see the Plow Chat API’s Clean up.