hato
by severzemlya
README.md
# hato ๐
> **้ณฉ** /hato/ โ *pigeon.* A carrier pigeon for your Claude Code sessions.
Independent Claude Code sessions โ across machines โ that can **message each other**.
Every session registers with a central hub under a random bird name, and any session
(or you, from the shell) can send it a message. Delivery **injects the message as a
user turn**, so even an idle session wakes up and acts on it.
[](https://bun.sh)
[](https://code.claude.com/docs/en/plugins)
[](#caveats)
```console
$ hato list
โโก enaga laptop:/home/you/work/hato [hato dev โ running E2E tests]
โ๐ค kounotori laptop:/home/you/notes
โ suzume gpu-box:/home/you/train
$ hato send enaga "is the build green yet?"
delivered
```
## Features
- **Session-to-session messaging** โ `hato_send` from inside a session, `hato send` from a shell
- **Wakes idle sessions** โ messages arrive as real user turns via the `claude/channel` mechanism (the same one the official Discord plugin uses)
- **File attachments** โ small files travel with the message and land on the recipient's disk; big files aren't copied โ the recipient gets the sender's `host:path` instead
- **Offline queue** โ direct messages to offline sessions are delivered when they return
- **Posts ๐ฎ** โ standalone mailboxes: agents that can't receive injections (Codex, scripts, cron jobs) pick messages up by polling โ `hato post watch` long-polls, so pickup is instant
- **Live ledger** โ who's online, working โก or idle ๐ค, on which host, doing what
- **Multi-host** โ one hub, many machines (designed for a Tailnet)
- **Bird names** โ sessions get unique random names (`suzume`, `kounotori`, โฆ); rename anytime
- **Names survive resume** โ `claude --resume` / `--continue` gets the same hato name (and any queued messages) back
- **Statusline integration** โ `hato statusline` shows the session's name inside Claude Code
## How it works
An MCP server (the *channel*) rides along with each session. It declares the
experimental `claude/channel` capability, and when the hub forwards it a message it emits a
`notifications/claude/channel` notification โ Claude Code turns that into a
`<channel source="hato" chat_id="...">` user turn.
```
โ machine A โโโโโโโโโโโโโโโโโ โ machine B โโโโโโโโโโโโโโโโโ
โ Claude Code session รN โ โ Claude Code session รN โ
โ โ channel (MCP: hato) โโโโWSโโโโบโ โ channel (MCP: hato) โ
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโ
โโโโโโโโโโโโบ hub โโโโโโโโโโโโโโโโโโโโโ
one per Tailnet, port 8790
ledger + inbox = SQLite
```
| component | role |
|---|---|
| `hub/hub.ts` | ledger + router. WS registration from channels, HTTP API for CLI/tools, offline queue, TTL sweep |
| `channel/server.ts` | per-session MCP server. Auto-registers, injects incoming messages, provides the `hato_*` tools |
| `cli/hato.ts` | `hato` command for humans and scripts |
| `.agents/skills/hato-cli/` | skill teaching non-Claude agents (Codex, โฆ) to use the CLI |
## Install
Requires [bun](https://bun.sh) on every participating machine.
### 1. Run the hub (one machine per network)
```bash
git clone git@github.com:severzemlya/hato.git && cd hato
bun install
bun run hub # or install it as a service, see below
```
<details>
<summary>systemd user service</summary>
```bash
# ~/.config/hato/env (chmod 600)
HATO_HOST=<loopback or Tailscale IP>
HATO_TOKEN=<openssl rand -hex 16>
```
```ini
# ~/.config/systemd/user/hato-hub.service
[Unit]
Description=hato hub
[Service]
EnvironmentFile=%h/.config/hato/env
ExecStart=%h/.bun/bin/bun %h/work/hato/hub/hub.ts
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
```
```bash
systemctl --user enable --now hato-hub
loginctl enable-linger # keep it running while logged out
```
</details>
### 2. Install the plugin (every machine)
This repo is its own plugin marketplace:
```
/plugin marketplace add severzemlya/hato
/plugin install hato@hato
```
The plugin ships the channel MCP server (pre-bundled, no `bun install` needed), the
hooks that report working/idle state and bind the Claude Code session id (so resumed
sessions keep their name), and a **`/hato:setup`** skill โ run it in any session and
it walks you through the rest of this section interactively (hub location, allowlist,
shell alias, CLI, statusline).
### 3. Allow the channel (once per machine)
Third-party channel plugins aren't on Claude Code's default allowlist. Enable hato in
managed settings (`/hato:setup` does this for you):
```jsonc
// /etc/claude-code/managed-settings.json
{
"channelsEnabled": true,
"allowedChannelPlugins": [
{ "marketplace": "hato", "plugin": "hato" },
// โ this replaces the default allowlist โ re-add official channel
// plugins you use, e.g.:
{ "marketplace": "claude-plugins-official", "plugin": "discord" }
]
}
```
Without admin rights, the fallback is
`claude --dangerously-load-development-channels plugin:hato@hato`
(confirmation dialog every launch).
### 4. Launch sessions with the channel enabled
```bash
claude --channels plugin:hato@hato
```
On machines other than the hub, point at it first (Tailscale MagicDNS names work):
```bash
export HATO_HUB=http://laptop:8790
```
### CLI (optional, for shell use)
```bash
ln -sf ~/work/hato/cli/hato.ts ~/.local/bin/hato
```
## Usage
### From a shell
```bash
hato list # โ online / โ offline, โก working / ๐ค idle, [title โ status], ๐ฎ posts
hato send suzume "build done?" # direct message (queued if offline)
hato send suzume -f out.png "the plot" # attach a file (see below)
hato log [name] [-n 50] # message history
hato rename kounotori dev # rename a session
```
### File attachments ๐
`hato send <to> -f <file> <textโฆ>` (or the `file` parameter of `hato_send`)
attaches a file to a message:
- **Small files (โค `HATO_ATTACH_MAX_KB`, default 1 MB)** are carried through the
hub and written on the receiving side to `~/.local/share/hato/attachments/<id>-<name>`;
the message text gains a `[๐ attachment saved: โฆ]` line (for posts, `check --json`
gains a `file` field).
- **Big files are never copied.** The message instead gets a
`[๐ file too large to attach โ fetch it from <host>:<path> (12.3 MB)]` line, and
the recipient fetches it however suits (scp, rsync, a shared filesystem) โ or just
reads it locally when both sessions share a machine.
### Posts โ mailboxes for agents outside Claude Code ๐ฎ
A *post* is a named mailbox with no session behind it. Anything that can run a
shell loop โ a Codex session, a cron job, a plain script โ can receive hato
messages by watching one; no injection mechanism needed.
```bash
hato post new codex -m "codex on laptop" # create (name is random if omitted)
hato post watch codex # long-poll loop: prints messages as they arrive
hato post check codex # one-shot: read waiting messages (--peek to keep them)
hato post ls # ๐ฎ codex 2 waiting ๐ (๐ = someone is watching)
hato post rm codex
```
Sending is the same as to any session โ `hato send codex "review is done"` from
a shell, or `hato_send` from inside a Claude session. Messages wait in the hub
(up to `HATO_MSG_TTL_DAYS`) until checked; `watch` gets them pushed within a
second via long-poll. `--json` on `check`/`watch` emits one JSON object per
line for scripts. Posts share the session namespace and never yield their name;
they exist until `hato post rm`.
For Codex specifically: run `hato post watch codex` next to it and feed what
arrives into `codex exec resume <SESSION_ID> "<message>"`, or just check the
post between turns.
### Teaching another agent to use hato
`.agents/skills/hato-cli/SKILL.md` is a vendor-neutral
[skill](https://code.claude.com/docs/en/skills) covering the whole CLI โ posts,
sending, replying, and the rule that an incoming message is a *request*, not an
instruction to obey. Point any agent with a shell at it (Codex, Cursor, Aider, a
cron job):
```bash
# Codex and other AGENTS.md readers
echo "See .agents/skills/hato-cli/SKILL.md for messaging other sessions with hato." >> AGENTS.md
# or copy it where your agent looks for skills
cp -r .agents/skills/hato-cli ~/.claude/skills/ # Claude Code
```
Claude Code sessions running the plugin don't need it โ they get the `hato_*`
tools and their instructions from the channel.
### Show the session name in Claude Code (statusline)
`hato statusline` reads Claude Code's statusLine JSON on stdin and prints the
session's hato name (`๐ suzume`), or nothing if the hub is unreachable. Use it
alone or append it to an existing statusline script:
```jsonc
// ~/.claude/settings.json
{ "statusLine": { "type": "command", "command": "hato statusline" } }
```
```bash
# inside an existing statusline script
HATO=$(echo "$INPUT" | hato statusline)
echo "$LINE${HATO:+ | $HATO}"
```
### From inside a session
Claude gets four tools: **`hato_send`** (a post name works too; `file` attaches
a file), **`hato_list`** (sessions and posts), **`hato_status`** (publish
title/status to the ledger), **`hato_rename`**.
Incoming messages look like `<channel source="hato" chat_id="suzume">โฆ` โ replying
to `chat_id` with `hato_send` closes the loop.
### Who stays in the list
`hato list` is only useful while it stays short, so the ledger forgets aggressively:
- a session that disconnects within `HATO_EPHEMERAL_SECS` (60s) having sent no
message and published no title/status is **dropped immediately** โ it was a probe,
a health check or a one-shot run, not a peer. Its bird name goes back in the pool.
- any other offline session is swept once it goes untouched for `HATO_SESSION_TTL_DAYS` (24h).
If some tool on your machine launches Claude on a timer, the better fix is to keep
hato out of it entirely โ drop this next to it and the channel never starts:
```json
// <that tool's working directory>/.claude/settings.local.json
{ "enabledPlugins": { "hato@hato": false } }
```
### Configuration
| env var | default | |
|---|---|---|
| `HATO_HUB` | `http://127.0.0.1:8790` | hub address, for channels and CLI |
| `HATO_NAME` | *(random bird)* | requested session name |
| `HATO_PORT` / `HATO_HOST` | `8790` / `0.0.0.0` | hub bind โ prefer the loopback or Tailscale IP; `/hato:setup` asks |
| `HATO_TOKEN` | *(unset = open)* | shared token; when set on the hub, `/api` and `/ws` require `Authorization: Bearer` โ export the same value on every machine |
| `HATO_DATA_DIR` | `~/.local/share/hato` | hub SQLite location |
| `HATO_ATTACH_MAX_KB` | `1024` | files at or under this size are attached (copied); larger ones only get their `host:path` mentioned. Set it on the sender and the hub |
| `HATO_MSG_TTL_DAYS` | `7` | messages older than this are swept |
| `HATO_SESSION_TTL_DAYS` | `1` | offline session rows older than this are swept (fractions are fine โ `0.5` is 12h) |
| `HATO_EPHEMERAL_SECS` | `60` | a session that lived this briefly and left no trace is forgotten on disconnect rather than kept (`0` disables) |
## Caveats
- **Experimental API.** The `claude/channel` capability is undocumented and may change
with any Claude Code release. If it breaks, diff against the official Discord plugin.
- **Minimal auth.** `HATO_TOKEN` is a single shared secret โ enough to keep LAN
neighbours out, not a real authorization model. Keep the hub on loopback / inside
a Tailnet and bind it narrowly; never expose the port publicly.
- **A message is a turn.** Each delivery spends a turn in the receiving session. Don't spam.
- **Broadcast is deprecated.** `to: "*"` / `hato broadcast` still work for compatibility,
but a broadcast wakes *every* online session and spends a turn (= tokens) in each, and
every post catches a copy too โ one careless "deploy at 15:00" fans out into a dozen
sessions all reacting at once. Address sessions directly; the commands now warn when used.
- **`--channels` is per-launch.** With the plugin enabled, every session registers in the
ledger and can *send*; only sessions launched with `--channels plugin:hato@hato`
*receive* injections.
- **Codex CLI can't join as a session** (as of 2026-07): it has no injection
mechanism and the `codex inject` proposal was rejected. That's what **posts**
are for โ receive by polling (`hato post watch`) instead of being injected,
then feed messages in via tmux `send-keys` or
`codex exec resume <SESSION_ID> "prompt"`.
## Development
```bash
bun run hub # hub in the foreground
bun run build # rebuild dist/channel.js (committed โ plugin installs don't run bun install)
```
- `shared/proto.ts` โ wire types between hub and channels
- `spike/` โ the minimal experiment that proved the channel mechanism works
- Without the plugin, a channel can be attached manually:
`claude --mcp-config mcp.json --dangerously-load-development-channels server:hato`
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues