Skip to main content
Glama
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.

[![runtime: bun](https://img.shields.io/badge/runtime-bun-f9f1e1?logo=bun&logoColor=black)](https://bun.sh)
[![Claude Code plugin](https://img.shields.io/badge/Claude_Code-plugin-d97757)](https://code.claude.com/docs/en/plugins)
[![status: experimental](https://img.shields.io/badge/status-experimental-yellow)](#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`