Universal backchannel: `ubc --agent <uuid> "msg"` starts a turn in that session

"Slip a note under the door"
Your Claude Code sessions are islands. A build script, a cron job, another agent or you in a spare shell can't reach a session without typing into its window. To reach one from outside we need an address and a mailbox: anything writes, the session reads.
ubc is a backchannel into Claude Code. Any shell drops a message, and the session it's meant for starts a turn with it.
ubc "the build is green, go ahead and tag it"
The ubc plugin sent a message:
ubc message from my-repo:
the build is green, go ahead and tag it
Run ubc from a shell and it finds the session that shell belongs to: same herdr tab, same project folder, or a name you gave it. Pass --agent <uuid> to skip the guessing.
When more than one session matches, ubc lists them and exits 3. Pick one with --agent or --to.
/ubc name api in a session, then ubc --to api "deploy done" from anywhere.
The mod claims each message by moving it out of the inbox before it reads it. Two polls or two sessions can't deliver the same message twice.
A message that lands mid-turn waits for that turn to finish. Several waiting messages arrive as one prompt.
make test 2>&1 | tail -20 | ubc sends the output. --from ci signs it.
The mod draws nothing. It polls an inbox and starts turns. /ubc prints the session's id and a ready-to-paste send line.
It's a folder of text files and a 1.5 second poll, but it's replaced a lot of copy-pasting between my windows.
git clone https://github.com/eighteyes/universal-backchannel
cd universal-backchannel
ln -s "$PWD/bin/ubc" ~/.local/bin/ubc # the CLI
claude --plugin-dir "$PWD/mod/ubc" # a session with the mod loaded
To load the mod in every session, add the mod/ubc path to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json.
Needs: bash, jq (only for finding a session without --agent), and a Claude Code build that loads hooks-module mods.
Codex, Desktop, others: nothing yet. The mod needs Claude Code's $.prompt.submit to start a turn.
Hot reload: a mod folder under ~/.claude/dev-mods/ that's a symlink loads once and then ignores edits. Copy it in, or use --plugin-dir.
ubc "hi there from ubc" # to the session this shell belongs to
ubc --to api "deploy done" # to a session named with /ubc name api
ubc --agent <uuid> "hi" # by id
echo "build failed" | ubc # message from stdin
ubc --from ci "deploy done" # sign it (default: this folder's name)
ubc --who # which session a send would reach
ubc --list # live sessions
With no --agent, the first rule that matches wins:
rule matches
--to <name> a live session named with /ubc name <name>
$CLAUDE_CODE_SESSION_ID set in shells Claude itself starts (Bash tool, hooks)
$HERDR_TAB_ID a live session in the same herdr tab
$HERDR_WORKSPACE_ID a live session in the same herdr workspace
$PWD a live session whose cwd is this folder, above it or below it
Several matches narrow to those whose cwd nests with $PWD, then to an exact cwd. A session is live for 150s after its last heartbeat (UBC_LIVE_SECS).
event does
session.start registers /ubc; polls the inbox every 1.5s; heartbeats every 60s
command.run /ubc prints id, send line and waiting count; /ubc name <x> names it, /ubc name - drops it
/ubc can't deliver: Claude Code doesn't allow a prompt submit from a command hook, so delivery always comes from the poll.
~/.ubc/inbox/<uuid>/*.msg waiting; the CLI writes a dotfile and renames it
~/.ubc/read/<uuid>/*.msg delivered; never pruned
~/.ubc/sessions/<uuid>.json heartbeat: cwd, herdr ids, name, time
A message is from: <name>, a blank line, then the body. UBC_DIR moves the tree; UBC_FROM sets the default sender.
Nothing is authenticated. Anything that can write to ~/.ubc can prompt your sessions, the same as anything that can type into your terminal.
claude plugin test mod/ubc
Are welcome. I wanted agents and scripts to tap each other on the shoulder without a server, a socket or a pane, and a mailbox on disk turned out to be enough.
hooks/register.ts 131 lines1// ubc: universal backchannel, the session end. No pane.
2// - polls $UBC_DIR/inbox/<session id>/*.msg (default ~/.ubc), which the `ubc` CLI drops
3// - claims each message by moving it to $UBC_DIR/read/<session id>/, then
4// submits them all as one prompt, so a message starts a turn (queued behind a running one)
5// - keeps $UBC_DIR/sessions/<session id>.json fresh (cwd, herdr ids, name) so the CLI
6// can find this session from a shell that never saw its id
7// - /ubc prints this session's id, the send command and how many messages wait
8// (delivery stays with the timer: a command hook cannot submit a prompt)
9// - /ubc name <name> names this session for `ubc --to <name>`; /ubc name - drops it
10
11import type { EngineInterface, Register } from 'claude-code'
12
13const POLL_MS = 1500
14const BEAT_MS = 60_000
15let busy = false
16
17type Message = { from: string | null; text: string }
18type Paths = { sid: string; root: string; inbox: string; read: string; beat: string }
19
20async function paths($: EngineInterface): Promise<Paths> {
21 const sid = await $.session.id()
22 const root = (await $.env.get('UBC_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.ubc`
23 return {
24 sid,
25 root,
26 inbox: `${root}/inbox/${sid}`,
27 read: `${root}/read/${sid}`,
28 beat: `${root}/sessions/${sid}.json`,
29 }
30}
31
32// A message file is optional `key: value` header lines, a blank line, then the
33// body. A file with no blank line is all body.
34function parse(raw: string): Message {
35 const cut = raw.indexOf('\n\n')
36 if (cut < 0) return { from: null, text: raw.trim() }
37 const head = raw.slice(0, cut).split('\n')
38 if (!head.every(l => /^[a-z]+: /.test(l))) return { from: null, text: raw.trim() }
39 const from = head.find(l => l.startsWith('from: '))?.slice(6).trim() || null
40 return { from, text: raw.slice(cut + 2).trim() }
41}
42
43function format(list: Message[]): string {
44 return list.map(m => `ubc message${m.from ? ` from ${m.from}` : ''}:\n${m.text}`).join('\n\n')
45}
46
47async function nameOf($: EngineInterface, sid: string): Promise<string | null> {
48 const got = await $.store.get(`name:${sid}`)
49 return typeof got === 'string' ? got : null
50}
51
52async function beat($: EngineInterface): Promise<void> {
53 const p = await paths($)
54 const at = new Date(await $.clock.now()).toISOString()
55 const herdr = {
56 workspace: (await $.env.get('HERDR_WORKSPACE_ID')) ?? null,
57 tab: (await $.env.get('HERDR_TAB_ID')) ?? null,
58 pane: (await $.env.get('HERDR_PANE_ID')) ?? null,
59 }
60 const cwd = await $.session.cwd()
61 const entry = { sid: p.sid, name: await nameOf($, p.sid), cwd, herdr, at }
62 await $.fs.write(p.beat, JSON.stringify(entry) + '\n')
63}
64
65async function waiting($: EngineInterface, inbox: string): Promise<string[]> {
66 if (!(await $.fs.exists(inbox))) return []
67 return (await $.fs.list(inbox))
68 .filter(f => f.kind === 'file' && f.name.endsWith('.msg'))
69 .map(f => f.name)
70 .sort()
71}
72
73async function poll($: EngineInterface): Promise<void> {
74 if (busy) return
75 busy = true
76 try {
77 const p = await paths($)
78 const names = await waiting($, p.inbox)
79 if (names.length === 0) return
80 await $.process.run(['mkdir', '-p', p.read])
81 const got: Message[] = []
82 for (const name of names) {
83 // The move is the claim: a second poll or session never delivers it twice.
84 const moved = await $.process.run(['mv', `${p.inbox}/${name}`, `${p.read}/${name}`])
85 if (moved.exitCode !== 0) continue
86 got.push(parse(await $.fs.read(`${p.read}/${name}`)))
87 }
88 if (got.length > 0) await $.prompt.submit({ text: format(got) })
89 } finally {
90 busy = false
91 }
92}
93
94export const register: Register = on => {
95 on('session.start', async ($, e, next) => {
96 await $.command.register({
97 name: 'ubc',
98 description: "Show this session's ubc id; /ubc name <name> to name it",
99 })
100 $.clock.every(POLL_MS, () => poll($))
101 $.clock.every(BEAT_MS, () => beat($))
102 void beat($)
103 void poll($)
104
105 return next(e)
106 })
107
108 on('command.run', { command: 'ubc' }, async ($, e) => {
109 const p = await paths($)
110 const [verb, arg] = e.args.trim().split(/\s+/)
111 if (verb === 'name') {
112 if (arg === undefined || arg === '') return { text: `ubc: name is ${(await nameOf($, p.sid)) ?? 'unset'}` }
113 if (arg === '-') await $.store.delete(`name:${p.sid}`)
114 else if (/^[A-Za-z0-9_.-]+$/.test(arg)) await $.store.set(`name:${p.sid}`, arg)
115 else return { text: `ubc: bad name ${arg}: use letters, digits, _ . -` }
116 await beat($)
117 return { text: arg === '-' ? 'ubc: name dropped' : `ubc: named ${arg}\nsend: ubc --to ${arg} "msg"` }
118 }
119 await beat($)
120 const n = (await waiting($, p.inbox)).length
121 const named = await nameOf($, p.sid)
122 return {
123 text:
124 `ubc: this session is ${p.sid}${named ? ` (${named})` : ''}\n` +
125 `send: ubc --agent ${p.sid} "hi there from ubc"\n` +
126 `inbox: ${p.inbox}` +
127 (n > 0 ? `\n${n} message${n === 1 ? '' : 's'} waiting, delivered within ${POLL_MS / 1000}s` : ''),
128 }
129 })
130}
131