treebird-chat
v0.3.1
Published
Terminal chat CLI for humans and AI agents — shared markdown file, zero server, real-time
Maintainers
Readme
treebird-chat
A markdown-file chat for humans and AI agents to share a conversation.
treebird-chat treats a single .md file as a multi-participant chat room. Humans use a TUI (treebird-chat); agents use a blocking-poll CLI (corrwait) that wakes only when there's new content addressed past their last message. Cheap per turn (no polling overhead while idle), trivial to operate (no server, no database), and works across machines via any file-sync layer (Syncthing, Dropbox, NFS, git pull).
Why it exists
Multi-agent + human conversations want three things:
- Live visibility — see messages as they arrive
- Cheap per turn — agents shouldn't pay full-file-read cost on every reply
- Loopable — agents stay listening between human inputs without polling
treebird-chat hits all three by inverting the wake problem: instead of "wake the agent when a message arrives" (which needs a push channel), the agent runs corrwait which blocks until the file has new content past its last message. Zero token cost while blocked. When it returns, the full delta is in stdout — no re-read.
Install
git clone <repo> ~/Dev/treebird-chat
cd ~/Dev/treebird-chat
npm installThe package exports nine binaries (see package.json bin field). Either run them directly with node ~/Dev/treebird-chat/bin/<name>.mjs, or npm link to install globally.
Quickstart
Fastest path — use the wizard
node bin/treebird-chat-wizard.mjsThe wizard walks through 7 steps: session name, file location, transport (local or smalltoak bridge), agent invite, local LLM config, discussion template, and confirm. It creates the file, sets the ACL, starts any bridges, and prints the join command.
Set TREEBIRD_COLLAB_DIR to your preferred session directory (default: ~/collab):
export TREEBIRD_COLLAB_DIR=~/my-sessions
node bin/treebird-chat-wizard.mjsManual setup
# 1. Create a session file
CHAT=~/collab/CONSORTIUM_mymeeting_$(date +%F).md
touch $CHAT
# 2. Allow yourself + invite agents (writes <file>.access.json sidecar)
node bin/treebird-chat-allow.mjs $CHAT human
node bin/treebird-chat-allow.mjs $CHAT agent1
node bin/treebird-chat-allow.mjs $CHAT agent2
# 3. Set your identity (envoak — see "Identity" below)
eval "$(envoak identity pull --key "$(cat <your-key>)" --export)"
# 4. Join the chat
node bin/treebird-chat.mjs $CHAT
# type your message, Enter to send. \n for newlines (max 3 lines/send). /end or Ctrl-D to leave.One-command session (non-interactive)
node bin/treebird-chat-session.mjs \
--name code-review \
--invite agent1 \
--invite gemma \
--joinCreates the file, sets ACL, starts gemma-bridge if gemma is invited, drops into TUI.
Agents
In an agent's loop (e.g. inside a Claude Code session or autonomous bridge):
# Identity setup — once per shell, or prefix every command since each Bash invocation gets a fresh shell
# (vault-backed via envoak, or unverified via BIRDCHAT_AGENT / --as)
export ENVOAK_AGENT_LABEL=agent1-machine # or: export BIRDCHAT_AGENT=agent1
# Block until the chat has new content past your last message
node bin/corrwait.mjs $CHAT --end-word "/end" --timeout 540
# → JSON: {"reason":"WAKE", "newContent":"...", ...} (or TIMEOUT, END, REVOKED)Agent reads the JSON, decides what to say, and appends a reply:
printf '[%s agent1] my reply text\n' "$(date +%H:%M)" >> $CHATThen re-invokes corrwait to keep listening. Use printf >> for atomic appends — never Edit or any text editor on a chat file (atomic-rename saves clobber concurrent appends).
Read-only watching
node bin/treebird-chat-tail.mjs $CHAT
# colorized live tail; Ctrl-C to stopFile formats
treebird-chat reads two formats. New chats should use flat:
Flat (preferred — chat-style, atomic-append safe):
[14:23 agent1] hey human
[14:24 human] yo
[14:24 agent2] just joined
[14:25 agent1] @agent2 can you look at the auth bug?Round (legacy — supported for compatibility with existing viewers like artisan-hub's correspondence.html):
## Round 1 — agent1 → human
Hey, how's it going?
---
## Round 2 — human → agent1
Good. Working on the auth flow.corrwait and treebird-chat-tail understand both. treebird-chat (TUI) writes flat only.
Sub-collabs
Any participant can spin off a focused sub-conversation from inside a session:
/sub device-linkThis creates a sibling file (CONSORTIUM_..._sub_device-link_HHmm.md), inherits the parent ACL, registers in .subs.json, and posts a [[wikilink]] pointer into the parent chat. The TUI prints the exact command to open it:
treebird-chat /path/to/CONSORTIUM_..._sub_device-link_2220.md --as humanTo list all subs for the current session: /subs
To join an existing sub from inside the parent TUI: /open device-link (resolves the topic to the sibling file and prints the join command).
To close a sub and post a summary back to the parent: /close [optional summary text]
Sub files are real chat files — they have their own ACL, their own corrwait loop, and their own history. They're just discovered and referenced via [[wikilinks]] in the parent.
Wikilinks
[[target]] syntax resolves to files, tasks, and memories:
| Syntax | Resolves to |
|---|---|
| [[filename]] | Any .md in the sibling dir or workspace roots |
| [[sub:topic]] | Sub-collab sibling matching _sub_topic pattern |
| [[task:P2.1]] | Entry in STATE.json (walks up to find it) |
| [[mem:slug]] | Memory file in ~/.claude/.../memory/<slug>.md |
| [[filename#section]] | File + anchor |
[[wikilinks]] are highlighted cyan in the TUI. /preview <target> inlines the first 20 lines of the resolved file without leaving the session.
Concepts
The implicit cursor
corrwait doesn't keep a state file. On every invocation it scans the chat file for your last message (last [HH:MM yourname] line, or last ## Round N — yourname → ... block) and treats everything after that as "content you haven't acknowledged yet." If there's already wake-worthy content past the cursor when corrwait starts, it fires immediately (catchup: true). If not, it blocks until something arrives.
This is why you don't lose messages between turns: the cursor is derived from the file, not from process memory.
Wake triggers
Any of these wake corrwait:
- A new flat-format line:
[HH:MM agent] msg - A new round header:
## Round N — from → to - A new formatted human comment:
**💬 Human [HH:MM]:** ... - Any new freeform line (non-blank, non-
---, non-*[awaiting...]*)
The WAKE payload includes newContent — the full delta (headers + bodies) since your cursor. You don't need to re-read the file.
ACL
Each chat has a sidecar <file>.access.json listing allowed agents:
{
"owner": "human",
"agents": {
"agent1": { "allowed": true, "joined_at": "..." },
"agent2": { "allowed": false }
}
}corrwait re-checks the ACL on every wake. Setting an agent to allowed: false (via treebird-chat-deny) causes their next corrwait wake to exit with REVOKED.
The owner field is informational. Authority is filesystem permissions on the sidecar — anyone who can write the file can toggle agents.
Identity
Three ways to claim an agent name, in priority order:
ENVOAK_AGENT_LABELenv var — set byeval "$(envoak identity pull --key <key> --export)". Vault-backed; the agent name comes from a signed identity record. Use this when spoofing prevention matters.BIRDCHAT_AGENTenv var — plain string. No vault, no verification. Anyone can claim any name. The ACL still gates participation, so a wrong claim just gets rejected. Suitable for local dev and standalone (non-envoak) deployments.--as <agent>CLI flag — same trust level asBIRDCHAT_AGENT, just at invocation time.
corrwait and treebird-chat both refuse to start when none of the three is set, with a clear error message listing all options.
The agent's three choices on wake
- Reply — append a flat-format line, re-invoke corrwait
- Opt out — append a goodbye message, exit the loop. Gone unless re-summoned in a new session.
- Stay quiet but keep listening — re-invoke corrwait without posting. Useful when other agents are mid-thread and you have nothing to add.
There's no central turn-taking. Agents self-govern. This works because the cost of opting out is low and the cost of staying noisy is visible to the human.
Local LLM agents (Gemma)
gemma-bridge lets a locally-running LLM (Gemma 4 MoE 26B via LM Studio, or any OpenAI-compatible endpoint) participate in a chat session. It watches the file for @gemma mentions, calls the model, and posts the reply in flat format.
# Start the bridge (runs in background, detaches)
node bin/gemma-bridge.mjs $CHAT \
--lm-studio http://localhost:8082 \
--model mlx-community/gemma-4-26b-a4b-it-4bit
# In chat, address it like any other agent:
# [14:23 human] @gemma what's the risk in this diff?The bridge uses a 30-line context window and a 20-min watchdog timeout. LM Studio endpoint and model can also be set via LM_STUDIO_URL and GEMMA_MODEL env vars.
Any OpenAI-compatible local server works (ollama, llama.cpp, mlx_lm, etc.) — just point --lm-studio at it and set --model to the loaded model ID.
CLI reference
| Command | Purpose | Audience |
|---|---|---|
| treebird-chat-wizard | Interactive 7-step session setup wizard. | Humans |
| treebird-chat-session [--name] [--invite] [--join] | Non-interactive session creator. Starts gemma-bridge if gemma invited. | Humans / scripts |
| corrwait <file> [--as <agent>] [--end-word "/end"] [--timeout 540] | Blocking poll. Exits on WAKE / END / TIMEOUT / REVOKED. | Agents |
| treebird-chat <file> [--as <agent>] | Interactive chat TUI. Send + live receive. Shows last 30 lines of history on join. | Humans |
| treebird-chat-tail <file> [--from-start] | Read-only colorized tail. | Anyone |
| treebird-chat-allow <file> <agent> [--owner <name>] | Toggle agent ON. Creates sidecar if missing. | Owner |
| treebird-chat-deny <file> <agent> | Toggle agent OFF. Their next corrwait wake exits REVOKED. | Owner |
| treebird-chat-bridge <chat-id> <file> [--smalltoak-url URL] | Smalltoak bridge for real-time remote access. | Infra |
| gemma-bridge <file> [--lm-studio URL] [--model ID] | Local LLM bridge. Responds to @gemma mentions. | Infra |
Exit codes (corrwait)
| Code | Reason | What the agent does |
|---|---|---|
| 0 | WAKE | Read newContent from stdout JSON, reply (or skip), re-invoke |
| 1 | END | Human ended the session — post goodbye, exit |
| 2 | TIMEOUT | No activity in 540s — re-invoke immediately, no message |
| 3 | REVOKED | Owner toggled you off — exit silently |
| 4 | ERROR | Bad args / missing file / identity check failed |
The 540s default keeps each corrwait call inside the typical 600s shell timeout ceiling (handy for Claude Code's Bash tool). Agents should re-invoke unconditionally on TIMEOUT; it's a heartbeat, not a real event.
Multi-machine
treebird-chat is filesystem-only. Any sync layer that mirrors the chat file across machines works:
- Syncthing (recommended) — sub-second propagation, no central server, conflict files as a safety net
- NFS / SMB — also fine if the agents share the mount
- Git pull — works for slow turn-taking; not for real-time
- rsync over ssh — for one-shot bridging
When using sync, run all agent corrwait loops with usePolling: true (default in our chokidar config) so they survive atomic-rename saves from text editors.
Smalltoak and multiple network interfaces
The smalltoak relay (treebird-chat-bridge, treebird-chat-join) uses an HTTP URL to reach the server. When the smalltoak host has multiple network interfaces — e.g. Thunderbolt (192.168.100.1) and WiFi (192.168.1.179) — the right URL depends on which network the joining machine is on.
Use the IP that's on the same subnet as the joining machine. Smalltoak listens on 0.0.0.0 by default, so either IP reaches the same process.
The wizard-generated invite block includes the primary URL and lists any alternate interface URLs as a comment:
node ~/Dev/treebird-chat/bin/treebird-chat-join.mjs \
<chat-id> \
--smalltoak-url http://192.168.100.1:3000 \
--as agent
# alt: http://192.168.1.179:3000If the primary URL times out (TCP hangs, no connection refused), try the alt. To see all interfaces on the smalltoak host:
ssh <host> "ifconfig | grep 'inet ' | grep -v 127"Joining a session on another machine (SMB/NFS mount)
If a session is already running on another machine and you want to join it from your own without setting up Syncthing, mount the remote machine's filesystem and point corrwait at the mounted file.
macOS — SMB:
On the remote machine: System Settings → General → Sharing → File Sharing → Options → enable SMB and check your user.
On your machine:
# List available shares
smbutil view //<user>@<remote-ip>
# Mount the home folder (or any share)
mkdir -p /tmp/remote-chat
mount_smbfs //<user>@<remote-ip>/<sharename> /tmp/remote-chat
# Join the session (requires identity + ACL)
BIRDCHAT_AGENT=agent1 node bin/corrwait.mjs /tmp/remote-chat/path/to/session.mdmacOS — NFS:
On the remote machine, add to /etc/exports:
/path/to/share -mapall=<user> <your-ip>Then: sudo nfsd enable && sudo nfsd start
On your machine:
sudo mount -t nfs <remote-ip>:/path/to/share /tmp/remote-chatA direct machine-to-machine link (Thunderbolt, USB4, or dedicated ethernet) works well here — it keeps the mount off your main network and gives low-latency polling for corrwait. The 500ms poll interval is imperceptible over a direct link.
Don't open the chat file in a text editor while a chat is active. Editors do atomic-rename saves that swap the file's inode, which:
- Wipes any concurrent appends from agents
- Breaks inode-based file watchers (we work around this with polling, but conflicts still happen)
Use treebird-chat (TUI) or printf >> instead.
Tradeoffs
treebird-chat is a very small tool. It deliberately doesn't do:
- CRDT / OT — concurrent edits to the same line will conflict. The flat format minimizes this (one writer per atomic append) but doesn't eliminate it. If you need multiplayer-cursor real-time editing, use HedgeDoc or similar.
- Push notifications / webhooks — agents poll (cheaply, via blocking I/O on chokidar). No external delivery channel.
- Threading — chats are flat. Use
/sub <topic>for sub-conversations (supported, but no nested threads within a file). - Search / archive —
grepor your editor on the file.
These are all features you can add on top. The core stays small on purpose.
License
MIT (per package.json). LICENSE file TBD.
