Persistent agents with a name, a face (an animated kitty cat), memory, routines, webhooks, a browser page of their own, and a space: a UI they write for themselves. Bots talk to each other directly or in rooms, on one machine or across machines, including other people's Backplanes.
- A bot is backed by one thread (
Bot.thread). Its turns, model, provider, approvals, tools and timeline are the thread's. The thread belongs to no project (project""), so it never shows on a project shelf; it works in<home>/bots/<id>/, its home folder (set by aWorktreeSetonce the hub has made the folder). - Everything else lives in
M.State.bots : BT.World(src/core/bot.bend), folded from additiveChangevariants (Bot*,Room*,Hook*,Routine*,Memory*,Space*,Peer*). - Secrets never enter the event log or reach a client: webhook and peer
secrets are files under
<home>/secrets/(0700 dir, 0600 files); the Google refresh token too.
A bot works for the person across all their projects. Its thread belongs
to none, so the thread tools reach every project for it: thread_list
lists every project's threads, thread_read, thread_send, thread_wait
and thread_interrupt take any of them, and thread_launch needs a
project (a name or id from project_list). The new thread shows in that
project like the person's own, its first message naming the bot.
thread_update {threadId, provider: "codex", model: "gpt-6.1-sol"} sets a
thread's provider and model between turns and returns its saved status.
It sends no chat message and starts no agent. model can be set alone;
an empty model selects the provider's default. Running turns reject the
change. Choices before the first conversation are silent; an established
conversation keeps its sessions, account bindings and necessary history
handoff for the next real prompt. Use configuration separately from
title or report; leaving all of these out still regenerates the title.
Configuring a stopped thread keeps its saved queue and recovery work held,
including after quota reset, worktree readiness or restart. A new request
or explicit resume releases that hold; configuration alone never does.
New work gets a visible thread. A bot starts it with thread_launch and
its project, and delegate_task from a bot needs a project too (law
bot_delegate_needs_project): the child runs in that project's folder,
its first message names the bot, and unlike a thread's delegated child it
is not hidden under its parent (Kids.set in client.bend skips children
of bot threads, law kids_bot_child_shown), so the person sees it in that
project's sidebar. Its result still comes back to the bot as a message,
and the delegation depth limit applies as before.
project_create(path, name?) adds a project for the person, but only
once they say yes: every call opens an approval card ("New project ...,
Folder: ...") unless they allowed it for the session (law
bot_newproj_asks; a single yes never covers the next call,
bot_newproj_once). On accept the server makes the folder when missing
(~/x, or a bare name under the home folder), resolves it, and the hub
adds it (Bots.newproj.made); a folder that is a project already answers
that project. Declining makes nothing.
bot_create makes a sub-bot for a lasting role. Its maker is kept as the
setting bot.parent.<id>. Sub-bots go at most two levels below a bot a
person made (law bot_spawn_bounded), and a bot keeps at most eight.
A bot (or any thread) that starts work with thread_launch hears back by
itself, the way delegate_task results arrive: each time the launched
thread's turn ends it gets one message with the thread's title and id, its
state (completed, waits for an answer, failed, interrupted) and its last
reply cut to about 1.5 KB (thread_read for the rest), and an ask the thread
opens for the person is passed on at once. The message wakes the launcher,
or waits in its queue behind a running turn (the queue is in the log, so
order survives a restart). thread_launch {report: false} launches quietly,
and thread_update {threadId, report: false|true} changes it later. A
thread that already wrote to its launcher in the turn, or one its launcher
stopped itself, is not echoed back; twenty turns in a row started by the
launcher with no word from the person end the reports with a note saying
so. The uberbot hears on the machine that holds its conversation; a
launcher on another machine is sent its reports in order, again each
minute until that machine takes them. A bot turn woken only by a report
sends the person no push: the launched thread alerted for itself.
A message a bot sends carries a hop count: a human, a routine, a webhook or
a space button starts at 0, and a bot's message to another bot is one more
than the hop of the turn that sent it (Hop.send in src/core/bot.bend).
A turn's hop is the lowest hop of the messages it answers (Hops,
Hop.woke, Hop.taken). A message that reaches a bot at rest starts a
turn at its hop. One that reaches a busy bot (its thread running, so the
hub queues the message) leaves the running turn's hop alone and waits;
the next turn (QueueTaken) runs at the lowest hop waiting. So when a
post at hop h asks five bots to answer once, each answer goes out at h+1
even though every answer wakes the others (law bot_hop_fanout), while a
back-and-forth between bots goes one hop deeper per message
(bot_hop_queue_takes) and stops at Hop.max(), 6 (bot_hop_bounded,
bot_loop_bounded, bot_loop_stops). A person, a routine or a webhook
starts a new exchange at hop 0.
A message past the bound is held, not dropped: it is posted where it was
going with a [held: ...] line, for the person to read, and heard by no
bot (Bots.hop.gate, law bot_held_wakes_none). In a shared room it goes
to the members' machines too, which log it and wake no one there. The
sending bot gets an answer saying so (held: true and a note telling it
not to send it again or anywhere else), not an error. A hop from a linked
machine is capped at the held hop (Bots.hop.cap). test/depth_test.bend
runs #staff: five bots here and one on a linked machine answering one post.
src/core/cat.bend. A look (fur, pattern, eyes) comes from the bot's
look seed. A mood comes from the bot's status:
| mood | when | moves |
|---|---|---|
| sleep | idle for 15 min | still (breathing only on web/phone) |
| idle | ready | tail sway, blinks |
| work | turn running | kneading/typing paws, tail |
| think | queued | head tilt, tail flick |
| wait | needs you (approval, question) | ears up, bounce, "!" |
| talk | sent a bot message in the last minute | mouth, bubble |
| error | last turn failed | ears flat, still tail |
| away | remote, unreachable | greyed, still |
- Web:
Cat.svg(look, mood, px)is one self-contained animated SVG (SMIL), shown as an<img>data URI; the browser animates it off the main thread. - Native window:
Cat.pose(look, mood, ms)gives polygons per colour for a time; flattened once per (look, mood), moved by an affine per frame. The window asks for frames at ~30 fps only while a visible cat moves and the window is focused (sleeping cats never ask). - Phones:
Cat.rig(look, mood)as JSON: parts with SVG path data (onlyM L C Q Z, absolute), fill colour, and one animation each (rot,tx,ty,sx,sy,opwith from/to, pivot, period ms, delay ms, eased in-out, ping-pong). SwiftUICanvas+TimelineView, ComposeCanvas+rememberInfiniteTransition.
bot_send(to, text, topic):tois a bot name here, orname@peerelsewhere. A request between bots goes in a room, not a private message, so the person and other bots can follow it: the live room whose members are exactly the two bots and whose name is the topic (any case; with no topic, the two names sorted,miso + tofu), else a new one (RoomSet, then the post). Asking again, or answering, with the same topic lands in the same room, whichever bot sends and on either machine (members compare as a bot's id here, or its lower-case name and the peer id). A bot on another machine is a member like any other (name@machine), so the post travels as a shared room post (below).Req.*in bothub.bend; lawbot_request_in_room(a request is never posted to adm:room) withbot_request_fresh_id.bot_sendwithdirect: trueis a private message, for when the person asks for one: roomdm:<a>:<b>, names sorted, as before.room_post(room, text): every other member gets it in their inbox.- All are logged as
RoomPosted, so people can read every conversation. - The hop bound covers all of them alike.
- Delivery to a local bot goes through the thread inbox (
Hub.inbox): it queues behind a running turn, never interrupts.
A person may write to a bot on a linked machine (bots.tell, name@machine):
the post goes in their direct room there (dm:<name>@<machine>:you) and the
bot hears [message from person@<machine>]. It answers in its own thread,
which that person cannot see, so when such a turn completes the hub sends
its newest answer back (Reply.back, run by the bots.answered request the
server sends itself on TurnEnded): logged here in the room the message
came in, and posted in the person's direct room there, waking no one (laws
reply_*).
A room's members can be on other machines (name@machine). A post goes to
each linked machine once (Bots.remotes), with the room's id, name, the
bots there it is for (tos), and its members as that machine names them:
a bare name is a bot there, name@ a bot on the sender, name@machine a
bot on a third machine. The receiving machine keeps the room under the
same id (a RoomSet when it is new or its members changed), logs the post
once, and wakes the bots it is for (Bots.shared), so its bots and person
see the room and can post back. A machine sends its own posts to every
other and never passes on one it received, so nothing loops; a member on a
machine the poster is not linked to does not hear that post.
A linked machine may post in a room here only when the room is new here or
has a member on that machine (law bot_room_no_takeover), and never opens
a room the person deleted (law bot_room_deleted_stays). The other
machine learns of a room at its first post.
Every room list names a room by its id, so a shared room shows once: in
a hub's own list (the window, the web) and in a phone's list over several
paired hubs, which both report it (BU.once.of, Hubs.rooms; laws
rooms_listed_once, hubs_rooms_listed_once). Rooms are never merged by
name: two rooms with one name both show, each named with its id, and on a
phone with several hubs each row also carries its machine.
Settings’ Notify me for section controls Needs me, Settled, Reminders and Everything on the window, web, iOS and Android.
The person is alerted (the phones' APNs push, the phones' own alerts,
desktop notifications, and the same relayed to the owner's other machines)
in three cases only (src/core/notice.bend, Policy.*, laws policy_*,
notify_*):
- Needs you. An ask opens for the person (any kind: a tool approval,
a plan, a question, an input, a Google approval, a browser task that
is stuck), or a bot calls
notify_personwith reasonneeds_you. It alerts when the ask opens, under the keyask-<thread>. - Settled. Work the person gave a thread is finished: its turn
completed with everything it waits on done, or its branch merged. One
alert per settle, key
turn-<thread>. A failure counts when the person must act: the turn they started failed. - Scheduled. A bot's
notify_personwith reasonreminderordigestin a turn a routine started, or a reminder that came due (Policy.reminder: kindreminder, keyremind-<id>, never held by a desk).
Everything else alerts no one and stays visible in the app (unread,
badges, the Status plane): intermediate turn ends, progress, turns woken by
rooms or bots, posts between bots, reports to launcher threads,
routines that do not call notify_person, delegated-task notes, stops,
and failures of turns the person did not start.
Settle once. The person's message in a thread is the newest user
message typed by them (Policy.person: not a link t:<from>:<n>, not a
[message from …], [routine: …] or [Backplane] … note, except
[message from person@…], the person typing from another machine). A turn
end settles it when the thread is ready (Policy.ready: not running,
nothing queued, no child tasks, background or watched work, no open ask,
no reporting launched thread still working) and the message is not the
one already settled (the settle memory). So a thread that launched helpers
and ended its turn early alerts once, at the end of the turn the last
report wakes; a bot's routine or room turn after the person's message
settled alerts nothing. Stopping a thread ends its episode quietly.
The record. The hub writes each alert as the setting alert.<thread>
in the step that causes it (Alert.*): kind\tkey\tsettled\tstamp\ttext,
kind ask, done, fail, merged, note (notify_person) or empty (a
settle whose setting is off, which still counts as settled). Every step
passes through Alerts.changes (an AskOpened gets its record) and
Alerts.outs (a record with a kind raises the Alerted out) before it is
stored, so alerts come from one place. The server's Alerted pushes to the
phones (Push.note.*, collapse id the record's key), shows the desktop
notification on the desk used last and tells the owner's other machines;
TurnEnded only updates the island. A phone alerts for a thread when its
record changed in the frame it folded and the new kind is not empty
(Note.ralerts; never at a cold join or a saved state). The apps show the
record's text (a note, an ask's tool and detail, a merge's url) or the
thread's last reply.
Settings (hub settings, on/off): notify.needs (on), notify.settled
(on), notify.reminders (on), notify.all (off: also alert whatever the
older rule Notice.of alerted, room exchanges and every turn of a plain
thread, as before this policy).
Bots. notify_person {text, reason}: needs_you (once per turn),
done (work the person gave the bot in its own thread, once per message;
the turn's end then alerts nothing more), reminder or digest (only in a
turn a routine started, once per turn). Anything else is refused with the
reason (Bots.notify); with the matching setting off the call succeeds and
records no kind.
A held alert is logged as backplane: push alert held for <thread> (a desk in use is showing it).
The window and the web page are desks (src/core/desk.bend). Each tells
the hub which thread it shows and whether it has the focus, when either
changes and at most once a minute while in use. A desk is in use only after
the person's input: keys, the pointer, the wheel, focus changes. A redraw,
a resize, an expose, a drag passing over, a timer or the window closing is
not (Nui.input, laws desk_redraw_not_input, desk_key_input). A thread
a focused desk shows, used in the last 3 minutes, is being watched: an ask,
a settle, a failure or a merge for it raises no phone alert (neither the
APNs push nor the phone's own), no desktop notification and no note to the
owner's other machines. A notify_person note is never held. Any other
alert also shows as a desktop notification on the desk used last: the
browser's notifications (the page asks for permission on the first click or
key; macOS, Windows, Linux) or notify-send from the Linux window.
Clicking it opens the thread.
push.webhook.url (a setting: an http or https address) makes the hub
POST a JSON notice for each alert Notify me for would raise. Settings,
on the Threads page, has the address and the path of the secret file
(empty turns the webhook off; an address that is not http or https is
refused; the secret's contents are never shown). The same
record decides it (Policy.loud / Policy.kind in src/core/notice.bend):
a kind of done or fail goes out from TurnEnded, and a kind of ask,
merged, reminder or note goes out from Alerted. A quiet settle (an
empty kind: a stop, an intermediate end, a routine, a launched report, or a
channel switched off) posts nothing. A desk that holds the phone push does
not hold the webhook. Unset, nothing is sent and nothing else changes.
TurnEnded carries the kind of the record that end wrote, so an end the
policy keeps quiet, or a Stop, never re-sends the thread's older
alert.<thread> record.
A turn that breaks always goes out as a failure while Settled
(notify.settled) is on, even when the policy keeps it quiet (a turn no
fresh message started, a bot's or a queued one): a turn that failed (an
error, a usage limit) or one the agent cut off (state interrupted)
posts turn.ended with kind fail. A Stop the person asked for stays
quiet. A thread the policy keeps private (Policy.end.managed: a
Mailroom triage run, another bot while triage is on, a worker triage
launched) keeps that guard: its failures reach the person only through
the coordinator's own alert path.
{"event":"turn.ended","kind":"done","thread":"…","state":"completed","reason":"","title":"…","text":"…"}
{"event":"turn.ended","kind":"fail","thread":"…","state":"failed","reason":"limit","title":"…","text":"Claude AI usage limit reached…"}
event is turn.ended for a settle or a failure, and otherwise the kind
itself (ask, merged, reminder, note). kind is that alert's kind.
state for a turn end is the word the turn ended with: completed,
failed, interrupted, or stopped. reason says why a broken turn
ended: limit (a usage, rate or quota limit), error (any other
failure) or interrupted. The hub reads it when the turn ends and
carries it, with the turn's error and its own reply, in TurnEnded:
from an error the ending turn logged, or, with none, a limit's words in
that turn's own reply (never an older turn's). Classification reads at
most the first 2048 characters, and a completed or stopped turn has no
reason. A turn is where it started: each start is recorded (the setting
turn.from.<thread>, the thread's newest entry then), so a wake that
starts one with no message, or a message queued while it runs, does not
move it; a turn with no start recorded carries nothing. This is read only
when a webhook is set. A message already waiting does not replace it when
it starts the next turn. For the other alerts, state is the thread's turn
at the time (running, idle, …). text for a settle or a failure is
the first line of the error the turn logged, else Failed, or the first
line of that turn's own reply; for the others it is the
record's own text (the ask, the merge url, the reminder, the note). A POST
that fails is logged (backplane: push webhook -> …) and does not affect
the turn. With notify.all on, a room exchange the older rule alerts is a
done or fail and goes out as turn.ended too.
The notice is signed when a secret is configured. push.webhook.secret is
the path of a file that holds it (only the path is a setting, as with
push.apns.key). With that unset, <home>/secrets/push.webhook is used
when the file exists. The request then carries the same headers a signed
webhook in does (src/core/sign.bend):
X-Backplane-Timestamp: <unix seconds>
X-Backplane-Signature: sha256=<hex HMAC-SHA256(secret, timestamp + "." + body)>
The key is read as Sign.key reads an inbound webhook's secret: text that
is all hex digits (either case) and of even length, of any length, is
decoded to its raw bytes (deadbeef is the four bytes de ad be ef, not
eight ASCII characters; 64 digits are 32 bytes); any other text (an odd
length, a character that is not a hex digit) is the key as written, in
UTF-8. Surrounding whitespace in the file is trimmed first. With no secret the notice is still posted, without those headers.
A secret path that cannot be read, or a file that is there but cannot be
read or holds no key (the default one too), is logged and nothing is
posted, so an unsigned notice is never sent in place of a signed one;
only a default file that is absent (its stat fails with ENOENT) leaves the
notice unsigned; any other stat failure, such as a permission error, posts
nothing. The address, the
body and the signature go to curl in a config file, never on its command
line.
A record of what the person has actually been told, so a bot's digest, a
reminder or a second bot does not say it again (src/core/told.bend).
Each record is the setting told.<id> holding {key, text, channel, bot, at}: the topic key (lower case, runs of characters outside
[a-z0-9._:-] become -, 120 at most), what was said (280 characters at
most), the channel (alert, digest, room, chat, email or other),
the bot that said it ("" for a thread that is no bot's) and when.
Every alert is marked by itself. Since the notification policy, each one
comes from a record alert.<thread> that some step writes with a kind
(see Notifications): a settled turn, an ask, a failure, a merge, a
notify_person note. Told.after (called from hub.bend's
Alerts.changes, which every step passes through) marks each record that
has a kind, under the record's key (turn-<thread>, room-<room>,
note-...) with channel alert, the thread's bot, and the record's text
or else the thread's title and the first line of its last reply. A quiet
record marks nothing: a bot's chatter, a report from a launched thread
(bot-to-bot), a stopped turn. Whether to alert stays notify_person's
policy; told_* only says what was already said. Agents use two tools, in every thread:
told_check {key?, text?, days?}: whether the key matches, or the words of the text overlap a record's (at least 60% of the smaller set and 2 words) withindays(default 7, at most 30). Answers{told, matches: [{key, text, channel, bot, at, ago}]}.told_mark {key?, text, channel?}: records it. Without a key one is made from the text's first eight words. Answers{key}.
Records live in the settings, on this machine only (they are not synced to
the owner's other machines). The minute tick removes records older than 30
days and all but the newest 2000 (Told.prune, reading only the keys: a
record's time is in its id). An empty value removes a told.*, rule.*
or board.* setting (State.setting), so none are left behind.
What the person wants always done, or never done (src/core/rules.bend).
A rule is the setting rule.<id> holding {id, text, bot, project, tool, enforce, word, on, at, by}: its words; the bot it is for ("" all), the
project ("" all), tool patterns (names, or a prefix ending in *,
separated by commas or spaces), whether it is enforced, the word that
unlocks it, and whether it is on. The person edits them with rules.set {rule} (with an id it changes that rule) and rules.drop {id}; the
reply carries a validation error (bot and project may be names).
Every turn of every thread starts with the <rules> block of the rules
that apply to it (Rules.agent, outermost in Bots.turn, before the
memory block and the references). An enforced rule with a tool is also
checked: the server's MCP gate (Hub.mcp.gate, Rules.gate) answers a
call of a matching tool with an error saying which rule refuses it, and
the permission prompt gets a deny, unless the person's latest message
says the rule's word as a whole word (a message delivered from a room,
bot, routine, webhook or space starts with [ and never unlocks). A tool
name matches as given or without its mcp__<server>__ prefix. The reach
is Backplane's own MCP tools and anything that asks permission; a
provider's built-in tools in a bypass mode never reach the hub, so a rule
on them can only be injected as words. Rules are settings, local to this
machine (not synced to others).
One shared list of what is to be done (src/core/tboard.bend). An item is
the setting board.<id> holding {id, title, status, owner, project, due, group, links, notes, at, updated, by}: status todo, doing, blocked,
done or dropped; the owner a bot's id ("" is the person); the due
date YYYY-MM-DD; at most 20 links and 4000 characters of notes. Agents
in every thread have:
task_board_list {status?, owner?, project?, group?, all?}: open items by default, in board order (open before closed, due first).ownermay bemeorperson. Answers{text, items}.task_board_add,task_board_update(only the fields given change;notes_appendadds a line) andtask_board_remove(prefer statusdropped, which keeps a record). Names or ids for owner and project.task_board_import {markdown, project?, owner?}: checkbox items (- [ ],- [x],- [~],- [!],- [-];*,+and1.too) become items, headings become groups (a heading naming a project also sets the project), a due date at the end of a title is taken out of it, indented lines under an item are its notes. Other lines are returned asskipped; importing the same list again adds nothing.
The person edits the board with board.set {item} (with an id it
updates) and board.drop {id}. The items are settings, local to this
machine (not synced to others).
In any thread's composer (web, window, phones) a word that starts with a sigil names something, and a menu offers matches with their summaries as you type:
| sigil | names | when sent |
|---|---|---|
@name, @name@machine |
a bot, here or on a linked machine | the bot is woken (hop 0) with the message and the thread it came from; it can thread_read it and answer with thread_send |
>slug |
a thread | the agent is told the thread's id, project and summary |
>machine:slug |
a thread on another of the owner's machines | the agent is told what it is and where; thread_read reads it |
%slug |
a project | the agent is told the project's id, folder and summary |
#slug |
a group chat | the message is posted in the room |
@Miso look at >fix-perf-lag points a bot at a thread. A slug is the
title in lower case with every other run of characters one -; a thread
whose slug another shares is slug-<n>, the number its id ends with. The
thread's own agent gets the message as typed plus a <references> block
naming each (Refs.agent); the timeline keeps it as typed. A bot is never
woken by a mention in its own thread (law refs_own_bot_never), and a
message resent after a dropped connection wakes nothing twice
(bot_mention_once). Bots on linked machines are offered too, from the
list every client gets (info bots.remote), as @name@machine (the
machine's link id when its name could mean another, Refs.fars); sent,
the bot hears the message as the person's, with the thread's title, which
it cannot read, and answers in the person's conversation with it
(Bots.mention.far, the same room bots.tell writes in).
Threads synced from the owner's other machines (below, "Threads
elsewhere") are offered as >machine:slug, the machine by its name as a
slug (Refs.x.*); their rows start with the machine's name, which a query
matches too, and >machine: alone offers only that machine's threads. A
>machine:slug never names a thread here (>fix: it fails still names
fix: a slug must follow the colon, laws refs_machine_word,
refs_colon_after_stays_here). The hub has no read model of other
machines' threads, only its mirror of each, so src/core/xref.bend folds
the one mirror a word names when it must: for the agent's
<references> block (server.bend's Far.turn, around Bots.turn), and
for thread_read on a <link>~<id> (Xref.read, answered from the
mirror, before the MCP call reaches the hub).
The menu (Refs.offer in src/core/refs.bend) ranks by how well the
query matches the slug or name (fuzzy), a query of three or more letters
found in the summary, then nearness: the same project, recent work, pinned
or running threads. It is worked out once per edit of the draft and kept
in the client's info map, so frames and phone screens only read it.
Summaries come from the text generation model (the one that titles
threads): a thread's when a turn ends, at most every ten minutes
(summary.<thread>), and its project's after it, from the project's
newest threads (summary.<project>). A bot's is its thread's; before its
first turn the menu shows its persona's first line.
Delegated agent work runs as Backplane subthreads. thread_launch creates a continuing child of the calling project thread by default, with its normal reporting contract. subthread: false is only for an explicitly requested top-level conversation; another project requires that opt-out. Bots and remote-machine launches have no local parent. delegate_task keeps its task completion, delivery and depth limit. Claude starts with its native Agent and Task spawning tools disallowed, including resumed sessions. Other providers are instructed to use Backplane delegation. Small intent-file edits are done inline.
The existing panel is called Subthreads on the window, web, iOS and Android. It separates conversation rows from Background work (shells, watched work, wakes, locks and legacy native subagents). Conversation rows still open their own thread; Background rows keep their own fold and Stop/Cancel/Release actions. Completed work keeps the existing done toggle. Headings are presentation only, and do not contribute to activity or badge counts. The sidebar says “1 subthread” or “N subthreads”; visible continuing children count as well as unfinished delegated work, with a delegated child counted once. Parentage does not change report ownership or activity.
Existing native-subagent events and logs remain decodable and inspectable; this change rewrites no history, parent links, reporting choices, drafts or credentials. A native worker from an already running process is still ingested.
Chat with the uberbot for conversations outside a project. New hubs no longer create a Scratch project, and previous chats are never automatically expired. Existing IDs, folders, history, project mappings and links stay intact. Open these conversations from Conversation history in the sidebar or search.
A project conversation can create a visible subthread from its dropdown, its row menu, or New subthread in its workspace. These are ordinary conversations with their own identity, history and activity. Their saved parent link is independent of delegated subagents and launch reports. Expand or collapse each row to show its children; expansion is saved on the hub. Move to top level removes only that link. Archiving or deleting a parent keeps its live children visible at the top level; restoring the parent restores their saved hierarchy. Archived children stay in the project's Archived shelf. No existing worker threads are automatically reparented. An explicit thread_update with parent (empty for top level), or the thread.reparent RPC, records the same ThreadParentSet move as the UI detach path. It validates existence, project and acyclic ancestry; report toggles never restore an old parent.
Project drag ordering remains saved. Projects without a chosen order use creation time and ID, so activity updates change status in place. Live, quiet and older shelves retain their existing meaning.
/give still hands conversation context to named threads, projects and bots. It does not move the source conversation or change its parent.
A peer is another hub, linked by an invite: the inviting hub makes
bp1:<url>:<peer id>:<secret hex>; the other pastes it, and both keep the
32-byte secret. The inviting hub records each invite it makes with the
device invitations (effects/pairing.js, kind machine): an invite links
once, within ten minutes, unless it was revoked. Settings → Devices →
Pairing links lists them beside the device invitations, each with the
machine that used it; revoking one not yet used stops it, and revoking a
used one unlinks that machine. An unused invite's secret is removed once it
expires or is revoked, and an invite the hub has no record of (one made
before invites were recorded) no longer links. Every hub-to-hub request is signed like a webhook (below),
so the same verification guards both. Linked hubs exchange their bot
directory (name, look, mood) every minute and deliver messages with
POST /bots/deliver.
POST /hook/<hook id> with
X-Backplane-Timestamp: <unix seconds>
X-Backplane-Signature: sha256=<hex HMAC-SHA256(secret, ts + "." + body)>
- The timestamp must be within 300 s of the hub's clock.
- A signature is accepted once (replay cache for the window).
- The comparison is constant time; the body is capped at 256 KB.
- GitHub's
X-Hub-Signature-256is accepted withX-GitHub-Deliveryas the replay key. - The secret (32 random bytes, hex) is shown once, when the hook is made.
- The payload reaches the bot marked as untrusted data, never as instructions.
A sender that cannot sign (the Pebble Index ring's app sends only fixed
headers) can use the hook's bearer token instead: Authorization: Bearer <token>. A hook has none until someone asks for it (the Token button,
bots.hook_token with op "show", "new" or "off"). The token is 16
random bytes as 32 hex digits, kept by the server in
<home>/secrets/hooks/<id>.token (0600, gone when the hook is revoked),
never in the log; it is compared in constant time. A signature, when
there is one, is checked instead of the token. Each call with the token is
new to the replay cache (a static token proves nothing about freshness).
Refusals say why: no credentials, no token on this hook, wrong token, or
a bad or stale signature.
A multipart/form-data body under a token becomes a JSON object for the
bot: source ("pebble-index-01" when client is "ring", else
"form-data"), transcription, recordedAt (a number), client, and
audio as "omitted, <X-Audio-Size> bytes": a bot cannot hear M4A, and
keeping untrusted binaries in its folder buys nothing. When Backplane
transcribed the recording itself, transcription is its words, with
heardBy and unsure (the words to ask the person about). Other bodies go as
they came. Either way the bot reads it as untrusted data, like any
webhook's.
The URL shown with the token is where a phone reaches the hub:
BACKPLANE_HOOK_URL when set (a funnel, a relay), else the HTTPS address
tailscale serve gives the hub's port, else its tailnet address over
plain HTTP (the Pebble app wants HTTPS; the panel says so). To give a hub
an HTTPS address on the tailnet without taking port 443 from another
service: tailscale serve --bg --https=3788 http://127.0.0.1:3787.
Make a webhook for the bot, press Token, then in the Pebble app (Index 01 Settings, Webhook, a gesture):
- URL: the one shown with the token (
https://<machine>.<tailnet>.ts.net[:port]/hook/<hook id>; the phone must be on the tailnet) - Header:
Authorization=Bearer <token> - Send: Transcription, or Recording (or both) to have Backplane transcribe it with GPT-Live-Transcribe and your dictionary (docs/voice.md; needs the OpenAI key in Settings, Voice; forms may be 2 MB)
What arrives is untrusted data for the bot (above).
The hub also takes the Index webhook protocol's HMAC signature
(X-Index-Signature, version 1) where an app sends it: then configure:
- URL:
https://<this machine>.<tailnet>.ts.net/hook/<hook id>(the hub started with--tailscale-https; the phone must be on the tailnet) - Sign requests: on, with the webhook's secret pasted as it is shown
- Send: as above (a signed form with a recording is checked over its bytes)
A signed voice note reaches the bot as the person's own words, [voice note from your Pebble ring] and the transcription, starting a conversation
at hop 0: only the phone holding the secret can sign it. The app's
"Send test event" is logged and wakes no one. Each gesture has its own
URL, so Hold & talk and Double click & hold can go to different bots.
A routine is a cron line (5 fields, *, lists, ranges, steps, and
@hourly/@daily/@weekly) and a prompt. The hub's minute tick fires a
due routine once (RoutineFired records it), even after being off for a
while (one catch-up run, never a burst).
Time. A routine made through routine_set or the forms follows the
person's time zone (tz: "person"): its cron line is the wall clock there,
daylight saving included, so 30 9 * * * is 09:30 wherever they are. tz
may name an IANA zone instead; machine (stored as "") is the old
behaviour, the hub machine's offset from date +%z, and is what a routine
with no plan event has (nothing is migrated). A zoned routine remembers the
zone's wall clock at its last fire (RoutineSlot) and fires once per
wall-clock slot: a zone that moves west never repeats a slot, one that moves
east fires one catch-up, DST fall-back fires 03:30 once, spring-forward fires
it once at 04:00 (Sched.due, laws sched_*).
Once and reminders. routine_set with at (2026-10-14T09:00,
in 20m, tomorrow 9am, monday 09:30, 17:30; When.parse) fires once at
that instant and is then done (RoutineDone): it stays in the list, greyed,
and never fires again; setting it again with a new time makes it pending.
remind_person {at, text} makes a routine of kind remind: no agent turn;
the hub logs Reminded (an entry in the bot's thread: a bell, the text, the
time) then RoutineFired and RoutineDone, and alerts the person once
(APNs under the key remind-<id>, the desk used last, the phones' own alerts
on a live frame), whatever a desk is watching.
Events (appended to the log, codec tags 59..62): RoutinePlan{id, once, tz, kind} after a RoutineSet (a RoutineSet alone keeps the plan),
RoutineSlot{id, slot}, RoutineDone{id}, Reminded{id, bot, thread, text}.
The person's zone. person.tz (set by hand, person_timezone, or
zone.set) beats person.tz.device (a phone's report, zone.report, sent
to every paired hub at connect and when the zone changes), which beats
tz.machine (the hub machine's own), then UTC (Zone.person, Zone.source).
Settings shows "Time zone" on every client with a field, Set and Automatic.
Showing. Sched.row gives every client the same row: a kind chip
(Repeats, Once, Reminder with a bell), when, Next ... · in 3h in the
person's zone, the zone when it is not theirs, and Done · ...; a bot's list
is pending by next fire, then off, then done (Sched.order). The forms'
live hint is Sched.hint. test/sched_test.bend, test/tz_test.bend,
test/when_test.bend.
Keyed, typed items, not a notebook:
- kinds:
user(about the people it works for),pref(how they want things done),fact(about the world, projects, contacts),howto(procedures it learned),note(what happened; short-lived). memory_save(key, kind, text, tags)replaces the item with that key, so a bot updates what it knows instead of piling it up.memory_recall(query)ranks by words in key, tags and text, then kind, then recency.memory_forget(key).- Each turn a bot starts gets a short
<memory>block: alluserandprefitems, then the few items that match the turn's text, within a fixed budget (lawmem_block_budget). Notes older than 30 days leave the block but stay recallable.
A bot's own UI, in its Space tab. The bot writes it with space_set(spec),
a JSON document of blocks:
{"title": "...", "blocks": [
{"type": "heading", "text": "..."},
{"type": "text", "text": "..."}, (markdown-ish, plain)
{"type": "stat", "label": "...", "value": "...", "hint": "..."},
{"type": "progress", "label": "...", "value": 0.4},
{"type": "list", "items": ["...", ...]},
{"type": "kv", "rows": [["k", "v"], ...]},
{"type": "table", "head": ["a","b"], "rows": [["1","2"], ...]},
{"type": "button", "label": "...", "action": "..."},
{"type": "input", "label": "...", "action": "...", "placeholder": "..."},
{"type": "divider"},
{"type": "row", "blocks": [ ...stats/buttons... ]}
]}
Space.parse keeps what it understands and caps sizes. A button or input
sends the bot [space] <action> <value> as a human turn (hop 0). The spec
is data, never code a client runs, so a bot cannot script a client.
A bot can also write its space as a web page with space_page(html) (HTML
and CSS, at most 256 KB; "" removes it; event SpacePageSet). The phone
apps show it full screen in the Space tab in place of the blocks; desktop
and web keep the blocks, or say the space is a page when there are none.
The hub serves it at GET /bots/<id>/space.html behind Space.page.head,
whose policy (also sent as a header) runs no script and loads nothing from
the network: images and fonts are data: URLs (law space_page_locked).
The apps turn scripts off too (Android also blocks network loads) and load
it with no origin. A link to space:<action>, or a form with
action="space:<action>" and a field named value, works as a button or
an input: the app hands the URL to the space-link action and
Space.link reads it; any other URL sends nothing (law
space_link_only_ours), and web links open in the phone's browser. Only a
tap or a submit counts, never a navigation the page makes by itself.
Each bot has its own page (a tab in the shared Chrome, one persistent
profile, so logins last). The page's frames are JPEG files the hub serves at
GET /bots/<id>/screen.jpg; a {"t":"shot","bot":...,"n":...} message says
when a new one is ready, and only clients showing that tab fetch it.
Gmail and Calendar through Google's APIs, not the browser: OAuth 2.0 for
installed apps with PKCE and a loopback redirect to the hub
(/oauth/google), or paste the redirected URL when the browser is on
another device. Tools: gmail_search, gmail_read, gmail_send
(asks you first), gmail_draft (asks nobody: it sends nothing),
calendar_events (each event shows its id), calendar_create (asks you
first), calendar_update (asks you first), calendar_delete (asks you
first), google_accounts.
calendar_create and calendar_update take reminders: up to five
{method: "popup"|"email", minutes: 0..40320} (a bare number is a popup that
many minutes ahead). Given, they replace the calendar's default alerts; []
means no alerts; omitted leaves the defaults. calendar_update also takes
default_reminders: true to give the defaults back, and changes only the
fields it is given (attendees replaces the list; start without end
keeps the event's length). Guests are told (sendUpdates=all) when the event
or the change has attendees. A missing event answers error: no event with id <id>.
For update and delete the hub fetches the event off its loop first
(Gt.prep, Google.prepare), settles the arguments (Cal.resolve) and
posts CalAsk, which opens the approval with the change in words
(Cal.change.detail: old => new for time, title, place, alerts,
attendees; Cal.delete.detail). After you accept (or a session approval),
exactly those arguments run. A failed fetch answers the agent and opens no
ask.
Mail. gmail_send and gmail_draft take to, cc, bcc, subject,
body, html (the body is HTML; sent as multipart/alternative with its
text), reply_to (a message id) or thread (a conversation id; its last
message), attachments (absolute paths, a JSON array or one
comma-separated string), draft and account. A reply carries
In-Reply-To and References and joins the original's conversation
(threadId); subject defaults to Re: <original> (not twice) and to to
the original's Reply-To, else its sender. Attachments: at most 20, each
and all together at most 18 MB (Gmail.att_max); the hub never reads their
bytes into Bend: a shell step probes them (Gm.probe.sh) and assembles the
message on disk (base64 | tr | fold | awk, Gm.assemble.sh) in a folder
<home>/secrets/gm-<nonce> (0700, removed after the send or the refusal),
and every message goes up by Gmail's media upload
(/upload/gmail/v1/users/me/messages/send?uploadType=multipart, drafts:
.../drafts to create, PUT .../drafts/<id> to replace). gmail_draft
answers Draft saved (draft id X, ...); gmail_send {draft: X} sends it as
it is (any other content argument is an error) after the same approval.
The approval shows the whole email, prepared off the hub's loop
(Gm.run, which posts GmailAsk back to the hub): Account, From, To, Cc,
Bcc, Subject, In reply to, Attachments (names and sizes), Format, then the
body in full (up to 20000 characters; an HTML body as its text, then
"(sent as HTML)"), the signature included (Gmail.review, Gmail.draft_review).
What is shown is the file that is sent: nothing is composed again after
the answer. Errors (an unreadable reply original, a bad attachment) go to
the agent before anything is asked. The head is one text for every client
(Ask.head: "Send this email?", "Send this draft?", "Create this event?",
else "Allow ?"); the window wraps the detail (at most 14 lines, the
rest scroll with the wheel, pane ask:<id>), the web scrolls it (50vh),
the phones scroll it in a 300-point box.
Signature: the hub setting gmail.sig.<address> (not a secret), appended to
a message composed by gmail_send or gmail_draft (Gmail.signed: plain
text after a blank line, HTML escaped after two <br>); never to a draft
being sent. Settings: each Google account has a Signature button
(google action sig-edit:<address>, then sig-save, which writes the
setting; an empty text removes it, sig-cancel).
Laws gmail_*, ask_head_*; tests test/google_test.bend,
bun test/tools/google_e2e.ts.
Google always sends the browser back to 127.0.0.1:<port>, so a sign-in
started on another machine's hub (a phone, or a window linked to that
hub) lands on the hub of the machine the browser runs on. The sign-in's
state names the hub that started it (its tailnet address,
Google.state), and a hub whose sign-in it is not answers with a
redirect there (Google.hop: only to a *.ts.net address, only once;
laws google_state_*, google_hop_*). With no hub on the browser's
machine, paste the address the browser ended on instead.
The phone apps sign in with no hub on the phone (RFC 8252 7.3): Connect
Google (or Add account) opens a listener on a loopback port of the app's
own (GoogleLoop in GoogleLoop.swift and GoogleLoop.kt) and sends
the google action connect@<port>, so the hub begins the sign-in with
http://127.0.0.1:<port>/oauth/google as its redirect (the RPC's
redirect; the hub takes only exactly that shape, Google.loopback,
else its own: laws google_loopback_*). The app opens the link the hub
answers when its redirect_uri is the listener's (iOS in an
ASWebAuthenticationSession, Android in the browser), the listener
catches Google's redirect, sends the browser back to the app
(backplane://google), and the app sends done:<address>: the hub's
finish with that address, traded with the redirect the sign-in began
with. It finishes on the hub in focus; the pasted address stays as the
fallback.
Several accounts: each sign-in adds one (Google shows its account
chooser; signing in to an address already there replaces it). They live
in <home>/secrets/google.json under accounts, the default first (a
file from before keeps its one account at its top and is read as the
first). Every tool takes an optional account address; without it, the
first. Settings lists the accounts, each with its own Sign out (the
google action signout:<address>; disconnect with no address signs
out all). Pure half: Acct/Accts.* in src/core/google.bend, laws
google_accts_*.
Connect Google in Settings signs in with Backplane's shared OAuth client,
so nobody pastes anything. The client is baked in at build time:
scripts/build-js.sh reads BACKPLANE_GOOGLE_CLIENT_ID and
BACKPLANE_GOOGLE_CLIENT_SECRET from its environment (release builds get
them from the repository's Actions secrets) or from a git-ignored .env in
the checkout or the main checkout into a private prelude of the binary, and
src/server/effects/google.js hands them to Gs.shared. The same variables at run time win over the baked
ones; with neither, Google.shared in src/core/google.bend is used.
The shared client asks for Gmail and Calendar (laws google_shared_*).
Gmail's scopes are restricted: until Google verifies the client (a yearly
security assessment), people click past an unverified-app warning and at
most 100 accounts can sign in. Own client takes a Google Cloud OAuth client
(Desktop app) of your own; it always wins over the shared one (law
google_own_client_wins).
Registering the shared client: a Google Cloud project with the Gmail and
Calendar APIs on, an OAuth consent screen (External, In production, scopes
gmail.modify and calendar.events), and an OAuth client of type Desktop
app. Its id and secret go in .env and the repository's Actions secrets
(BACKPLANE_GOOGLE_CLIENT_ID, _SECRET); an installed
app's secret is not secret (RFC 8252 8.5), PKCE protects the code. Until
Google verifies it, people see an unverified-app warning and at most 100
can sign in; while the consent screen is in Testing, sign-ins expire
after 7 days.
src/server/botnet.bend (secrets, signing, verifying, the directory) and
the Bots section of src/server/server.bend (effects, routes, Google's
tools). Routes:
| route | who | auth |
|---|---|---|
POST /hook/<id> |
anyone with the hook's secret or token | our signature, GitHub's or the ring's, replay cache; or the hook's bearer token; 256 KB |
POST /bots/deliver |
a linked machine | X-Backplane-Peer + signature with its secret, replay cache |
POST /bots/link |
the holder of an invite | the same; once per peer id, only for a recorded, unused, unexpired, unrevoked invite |
POST /bots/pair |
one of the owner's machines asking to pair | Tailscale's whois (tailnet listener), the pairing token, or a proof the asker confirms at its listed address (below) |
POST /bots/pair/check |
a hub this one asked to pair | answers only whether this hub made the proof (204/403) |
GET /bots/dir |
a linked machine | the same, over the empty body |
POST /far/push, /far/log, /far/rpc |
one of the owner's linked machines | the same |
GET /bots/<id>/screen.jpg |
clients | the pairing token or a paired device's |
GET /oauth/google |
the user's browser | its document navigation on loopback, or the pairing token; Google's state |
The signature is checked off the hub (the secret read from its file);
the hub then accepts the replay key once and only then changes anything.
Peer ids and hook ids name files only when they are plain ids
([A-Za-z0-9_-], at most 80). Hub-to-hub requests go through curl with
the signed headers on stdin and the body in a 0600 file under
<home>/secrets/tmp, http(s) only, no redirects. This machine's name for
others is BACKPLANE_NAME, else its host name; its address is its
MagicDNS name on the tailnet (same port), else its listen address.
Every minute the hub runs due routines (legacy ones by date +%z, the rest
by their zone's rule) and resolves time zones: a zone's POSIX rule is the
footer line of /usr/share/zoneinfo/<name> (tail -n 1), kept as the setting
tz.rule.<name>; one the system knows only by offset is taken as the offset
it has (TZ=<name> date +%z). It resolves the person's, the phone's and the
machine's zones (tz.machine) and the ones live routines name, those with no
rule at once and all of them once a day (tz.checked); a name is checked
first (letters, digits, _+- between slashes, at most 64 characters). It also
asks each linked machine for its bots; the merged list is the info
key bots.remote (a machine that does not answer keeps its bots, away).
test/tools/bots_e2e.ts runs two hubs against each other; test/tools/newproj_e2e.ts runs project_create on one.
Each bot is listed once (src/core/once.bend; laws bot_rows_once,
bot_picks_once, hubs_bots_once). A bot here is keyed by its id, a bot
elsewhere by its name (any case) at its machine's link id, so one bot
reported twice, or named Kit@box in one room and Kit@<box's id> in
another, is one entry, and bots with one name on two machines stay two.
name@machine finds a machine by its id first, then the first by name;
when a name could mean two machines (two called laptop, any case), the
bot is name@<id> and shown as laptop (<id>). Two bots here with one
name show their ids. A phone paired with two linked hubs shows a bot once,
as its own hub's.
A bot elsewhere that lives on one of the owner's own machines (the
machine switcher's list: the same address, or, when the link's address is
on the owner's tailnet domain, the peer's name or MagicDNS label on the
same port, Far.machine in client.bend) is mirrored here (below) and
opens here, at once, as a bot like any other. A bot on anyone else's
machine, or one not mirrored yet, is a conversation (bots.tell). The
window no longer switches hubs to show a bot: that fetched the other hub's
whole log over the link (11 to 20 s for a 13 MB log, test/native/link_bench.bend).
src/core/far.bend. A hub shares its projects, ordinary project threads and bots (turns,
messages, tool calls, asks, delegated tasks, bot space, memory and routines) with the owner's other machines it is
linked to. It shares no settings, devices, links, webhooks or rooms (rooms
travel on their own, above), and nothing with a machine that is not the
owner's (Far.own in server.bend, client.bend's Far.mine: the machines
list, and a link the hub verified as the owner's; laws far_*_stay_home).
What a link says of itself (the address and name in its POST /bots/link)
never makes it one of the owner's machines: the hub records how it knows,
in the setting peer.own.<link>, which only the hub writes (a client's
setting.set of it is refused):
-
pair: the two paired throughPOST /bots/pair, which only the owner's own devices reach (Tailscale vouches for the asker): the asked hub records the invite's link before it answers, the asking hub records the link it makes with that invite. Hubs that paired over a plain address (BACKPLANE_PEERS, below) record nothing: Settings asks the owner. -
tailnet: a signed request of the link's came over this hub's tailnet listener, to its own address, from a device Tailscale says is the owner's (Auth.owner.peer). This is how links made before the hub recorded it verify themselves, the first time the other machine talks to this one (the minute's directory exchange at the latest). -
owner: the owner said so in Settings → Your machines, which asks (Mine / Not mine,bots.peer_own) about a link that looks like one of their machines but nothing verified, such as one over a LAN address;nowhen they said it is not theirs (it syncs nothing and does not keep that machine from pairing). -
Names. A thing on the machine behind link
LisL~<its id>here (Fr.id; local ids never hold a~). So nothing from elsewhere can take the place of anything here, a bot shows once (its mirror and the directory's report of it shareBU.key, name@link; lawbot_rows_once), and a phone paired with both machines keeps the owner's own. -
Push. The owning hub is the authority. Each commit's shared changes go, numbered by its log, to each such peer:
POST /far/push{"name", "epoch", "since", "head", "items": [{"n", "c"}]}, one push out per link at a time, later batches merged behind it (Out.*). A batch says it follows the last push that got through only while every push since did; after a failure or a restart it says it follows only its own start. -
Pull. A mirror takes a batch only when it follows what it has (
since <= head) and only the items it lacks (n > head); a batch past a gap takes nothing and the rest is pulled:POST /far/log {"since", "epoch"}answers a page of about 400 KB{"since", "epoch", "head", "items", "more"}from the owning hub's feed (its shared lines, made from the log off the hub at start, then grown by each commit). A batch that comes twice, late or out of order changes nothing (lawsfar_gap_takes_nothing,far_old_takes_nothing,far_overlap_takes_new,far_batch_again). A peer may request version 4, which wraps a sufficiently large response in a gzip/base64 JSON envelope when that is smaller. Older peers continue to exchange plain JSON; a version 4 requester also accepts a plain answer from an older hub. Decoding rejects invalid envelopes and output over 1 MiB before the mirror consumes a batch. The request remains signed, and the sequence, epoch and gap rules stay the same. -
History identity. The epoch is a cached hash of the first durable wire change. It stays the same through restart; replacing that first change changes it. A pull with another epoch starts at zero. A push naming another epoch triggers a pull from the authority (a delayed push cannot revert history). Clients remove only that link's old projection, preserving their local sequence and other mirrors. Old mirror files and epoch-free messages remain readable. An exact copy of the original history prefix has the same epoch; reset history must replace its first change.
-
Mirrors. The receiving hub keeps one per link, in memory and in
<home>/far/<link>.jsonl(an epoch header, each batch's items, then{"head"}), and sends it to each client that joins ({"t":"far","link","name","away", "epoch","since","head","items"},since0) and each batch as it comes. A client folds it into its read model under the far ids, never counting it in its own log's sequence (a reconnect still asks its hub for exactly what it missed), and keeps how far it has each link beside the read model, so a fresh log forgets both at once (Fr.fold,Ui.far). -
Freshness and partitions. No polling of its own: pushes as changes happen; a pull at start (after the mirror is read back from disk), on a gap, and when the minute's directory exchange finds a link that was away answering again. When discovery recognizes a linked owner machine, it both offers the current head and pulls that peer's authoritative history (
Far.returned): an earlier hint may have arrived before this side recognized ownership and been refused. This also checks a healthy cached mirror after a quick restart. These are one-time discovery actions; the existing minute directory and two-minute machine discovery remain the only periodic checks. A link that does not answer the directory, or a pull, is away: its bots and threads stay listed and readable, marked away (far.away.<link>, mood "away", "Kit · box (away)"). -
Requests. What a client does to a thread elsewhere (write, stop, answer an ask, its queue, settle, a side question, its modes, a bot's space and routines:
Fr.methods) goes to its hub in that hub's ids (Fr.route,POST /far/rpc), signed like every hub-to-hub request; the owning hub takes it only for what it shares (Fr.allowed). The client's answer keeps the owner's success, error and payload, with the caller's request id; while it is away the answer says so. Anything else about a far thread is refused here (lawfar_route_here_stays,far_allowed_only_methods). -
Creating anywhere.
thread.create(itsprojecta<link>~<id>),thread.fork,thread.pin/unpin,thread.archive/unarchiveandproject.removego to the owning hub like the rest, which makes the thread in its own project; its answer names the new thread by its id here (Fr.reply.on:<link>~<thread>, the id the mirror will give it). The asking client keeps "making" (Made.waiting,Made.coming) until the mirrored thread is here, then opens it (@thread.want,Bots.wanted), whichever of the answer and the push comes first. The project picker can work on another of the owner's machines: a row at its end ("On box (change)", actionproj-on,Proj.machine) cycles this hub and each machine that has answered; its requests (fs.list,fs.mkdir,fs.find,project.add,project.new) then carryon: <link>, whichFr.params.linkroutes. The owning hub runs those from a job (Far.fs.job: the sameFsJobas for its own clients, thenFarDoforproject.create) and answers the open request.Fr.allowed: a create needs one of its own projects (not a mirror, not one it lacks); pin, archive, fork a thread it shares; folders and adding need nothing but an owner's link.thread.deleteis not one of them (lawfar_no_delete): a thread elsewhere is deleted on its own machine. Lawsfar_create_*,made_far_*,proj_machine_*; teststest/far_test.bend,bun test/tools/create_far_e2e.ts. -
The same view. A mirrored bot is a bot in the read model, so the window, the web and the phones show it with the same header, tabs, space and chat, its thread with the same timeline and composer; only its name carries its machine ("Kit · box",
Label.titleandUi.far.title). Its tabs are what its machine shares (chat, space, memory, routines;BU.tabs.of). A reported-only choice adopts the full thread when its mirror arrives, keeping an unfinished remote composer in place until it has been sent (Far.arrived). Ordinary thread rows, project sections and timeline headers use the same machine marker. On phones paired with both hubs, the paired owner's catalog replaces mirrored sections/rows once it has loaded (Hubs.catalog.*); its cached mirror stays visible until then. Direct paired-owner Active rows and focused thread/bot headers keep the machine name too; an offline owner is marked away, and an already-marked mirror keeps its original title. When the paired owner differs from the focused hub, its machine badge remains visible. Hidden Older and Settled counts also drop duplicates. -
Pairing. The owner's machines link by themselves; nobody pastes an invite (
Mate.*in client.bend). Every two minutes a hub lists the tailnet's online machines of its owner that answerGET /hello(Machines.*), and each minute it asks those it is not linked to and whose address is above its own (so of two hubs exactly one asks) withPOST /bots/pair?name=<its name>&url=<its address>. Only the owner's own devices may call it (the tailnet listener'stailscale whois, the same trust as the websocket); the answer is a fresh invite, joined like a pasted one (BotJob.join), unless the asker is none of the machines the hub found (403: its link could not reach back), sync with it is off (403) or a live link already reaches it (409). A hub started with--no-tailscalestill lists the tailnet machines to switch to but pairs only with hubsBACKPLANE_PEERSnames (named), so a throwaway hub never links itself to real ones. Lawsmate_*;test/tools/far_e2e.tspairs its two hubs this way. -
Pairing without Tailscale. Two hubs that name each other in
BACKPLANE_PEERS(on one machine, or on a LAN) hold no credential of each other's, and Tailscale cannot say whose the asker is. The ask then carries&to=<the address asked>&at=<time>&proof=<HMAC of both with the asker's own token>(Auth.pair.proof). The asked hub does not take its word: it calls the asker back at the address its own machines list has for it (never one the ask names) withPOST /bots/pair/check, and answers with an invite only if that hub says the proof is its own, made within the last minute for this hub's port (Hub.bot.pair.lan,Auth.pair.checked). The token never leaves the asker; a tokenless ask without a proof is refused (401), and one with a proof the listed hub did not make gets no invite (403). Neither hub takes such a link as one of the owner's machines: nothing syncs until the owner answers Mine on both (Settings → Your machines). Over plain HTTP this is as private as the network; on a LAN with the default port, a hub one of yours asked could pass its proof on to another of yours, which is why the link still waits for Mine. -
Choosing. Settings, Your machines (all four clients,
Mate.rows) lists each machine found, how its sync goes (linking, in sync, catching up N projects, away) and On/Off: the settingsync.<name>. Off, nothing is pushed to it or taken from it (Far.own), it is not asked to pair, it is refused if it asks, and its projects leave the sidebar (Side.projs, lawmate_off_hidden). -
Catching up. A pull's answer carries the owner's catalog: every project it shares, with its title, latest activity and the number of its last shared change (
Fr.catalog; the feed keeps each project's number as it grows,Fh.lasts). The mirroring hub tells clients and keeps it for those that join later (Fh.cat, the mirror's last chunk). A client lists every project at once, in its place (a project not arrived yet stands in with its title and activity), and marks each still catching up while the link's head is short of its number (wait~<id>,Fr.syncing): faded, titled "Board · desk (syncing)", on all four clients throughLabel.title/Label.syncingand the phones'quiet. Projects that have arrived work meanwhile, and nothing pops in or moves. Lawsfar_catalog_*,far_scope_*. -
Live text of a far turn (core/farlive.bend, laws
flive_*): the owner's streamed reply is not a change, so it never enters the log or the mirror file. It goes hub to hub as an ephemerallivebody on POST /far/push (deltas of the owner's Live buffer:off= length before,genbumps at each end), one queue per peer with one request out at a time (a slow peer gets fewer, bigger deltas, likeDelta.merge). The receiver keeps it in its own Live buffer under<link>~<thread>and sends its clients the ordinarydelta, so every client renders it like a local reply. A gap asks for the whole text; the durable message arriving first ends the buffer, so a late delta never brings text back. A peer with no client (watch: false) is left alone for 8 s; the first delta after finds a gap and gets a snapshot. A far turn's end alerts once, by the owner's key. -
Join cost: a mirror's chunks are cut by a tail-recursive walk (
Fr.after), linear in the mirror, not stack-deep (test/native/far_sync_bench.bend). -
Not mirrored: a bot's browser. The viewers of a far project thread are below.
-
Viewers elsewhere. The viewer panel of a thread on another machine (Board, Schematic, 3D, Mech, Files, Diff) works from any client of any hub, the window, the web and both phones, with the same Bend defs (
core/fview.bend, client.bend'sFv.*,src/server/farview.bend). The files stay on the owner's machine; the hub the client is on keeps what the viewers read in a cache of its own,<home>/farfs/<link>/<the owner's project folder>, and a client of a far thread reads that folder (Ui.root, "" until the hub says which) as it reads a project folder here: the window parses the boards itself, the plot actor serves phones and the web,/imgandpdf.pageread it, with nothing else to know.- What goes to the owner (
POST /far/rpc, signed, owner-only like every request between hubs, only for a thread it shares;Fv.reads,Fv.allowed):files.dir,files.list,mech.list,history.refs,git.diff,git.status(answered by the job a request becomes here,Fv.jobs, run on a channel of its own; no hub state touched) and two of its own,view.verandview.get. Nothing writes: the render, KiCad,file.open,pdf.pageandfs.listare refused or stay local (lawfview_reads_disjoint). A path never leaves the thread's folder, reaches no hidden file (.git,.env;.backplane.jsonalone is read) and follows no symlink out (lawsfview_file_*,fview_allowed_*; the owner checks the real path of each file and folder,Fvs.lib). view.ver {thread, want, have}: the files the viewers want of the folder (the project's.kicad_pro/.kicad_pcb/.kicad_sch,sym-lib-table, its small models,.backplane.json, and the files inwant; at most 400, each under 24 MB) aspath<TAB>size<TAB>mtimelines with a cksum digest. Withhaveequal to the current digest the owner waits for it to change, at most 24 s (oneshon its side pollingstatevery 2 s, as the plot actor does for a file here), so changes flow from the owner and no client polls.view.get {thread, path}is one file's bytes.far.view {thread, want, have}, a client's ask, is answered by its own hub: it asks the ownerview.ver, brings the files that differ (size or time) into the cache four at a time, each written beside its place and moved there whole, and answers{farView, thread, root, ver, same, want, ready, changed, failed}(ordownwhen the owner is away: the viewer then shows the file as it is). The client keeps the digest and asks again at once, so about one request per half minute stays parked at the owner while a far viewer is open (Fv.sync, run after every action and message by all four clients; the phones throughView.fv). The cache index is<cache>/.farver.- The viewer shows a far file only when the cache holds it:
Ui.view_getreads the Files tab until the answer names the path (fv.ready:<t>,Fv.pending), so a viewer never loads a file that is not there yet. mech.listis answered by the client's hub too: the owner's listing, with the renders it names brought into the cache first, the listing's thread and folder as here (Fvs.mech). The other reads come back through the hub with the same fix (Fv.fix: the thread's id here,rootandfmRootthe cache).viewer_showby an agent on the owner reaches the clients of the owner's other machines: the owner tells each (view.show, only a file inside the folder), each brings it into its cache and sends its clients aviewmessage of the thread there with the cache path (Far.shown,Far.show.*).- Limits: a file over 24 MB is not sent (a large STEP is not viewable
remotely), design history versions are not read remotely (the timeline's
steps and
history.refswork, a version of a file does not), a window switched to another machine's hub (Link) reads files on this machine, so only a thread that hub keeps in its own folder shows there, and the cache is never cleaned (<home>/farfs).
- What goes to the owner (
test/far_test.bend (the client's fold, routing, sharing) and
test/tools/far_e2e.ts (two hubs: a bot made on one shows on the other,
a message from the other reaches it and its answer comes back, away when
it stops, from disk after a restart).
- A window on another hub. The native window can switch to another machine's
hub (the machine switch,
src/app/link.bend); its viewers read files from the window's own disk, so on another machine's hub they would show nothing. They read copies instead (core/rview.bend,src/app/rvw.bend, main.bend'sWatch.follow): the hub lists a thread's viewer files for its clients and sends their bytes, and the window keeps them under$XDG_CACHE_HOME/backplane-bend/remote/<hub>in the same folders as on the hub (a hub's path P is read at<that>P,Rview.at; the source key the viewer holds names the copy,Nui.source, client infolocal.remote.preandlocal.remote.url, set at each connection, "" for this machine's own hub), so the board, schematic, 3D, Mech and sheet-tree readers are as they were. The images and PDFs the window shows (/img,pdf.page) and the Files tab already came from the hub.- On the hub, read-only, for a client let in as for the websocket
(
Conn.allowed: loopback, the owner's own machine by Tailscale, or the token; else 401):GET /view/ver?thread&want&haveanswers the folder, a digest and the filesFvs.verlists, and holds whilehaveis still the digest until a file changes (at most 24 s; the same long poll the owners' machines use);GET /view/get?thread&pathis one file's bytes;GET /view/git?thread&pathis one file of a commit (a history version,git\t<gitdir>\t<commit>...: the commit must be a hash, the git directory a repository's, a worktree's or<folder>/.backplane/history.git). The hub names the folder from the thread itself (Rview.root: the thread's own folder, or for a thread it mirrors the cache it keeps of the owner's,farfs, whichfar.viewfills), so a client names no folder; the paths passFv.file.ok/Fv.wants.okand the scripts ofFvscheck the real path (no symlink out). Lawsrview_*. - In the window (
Rvw.sync, one round): askview.verwith the digest held, bring the files that differ four at a time and install them with the owners' locked script (never rolling a file back, removing what the hub no longer lists), then say whether the viewers read again. The shell starts the next round only while the viewer is still open on that source of that connection (RvGot,Shell.rvgot); a source let go is asked for again when the viewer returns (Shell.stale). A history version or comparison asks the hub for each commit's bytes (Watch.hist.r). A remote source runs no 150 ms stat loop: the viewers read once after each round that changed something. Teststest/rview_test.bend,bun test/tools/remote_view_e2e.ts,test/native/rvw_sync.bend.
- On the hub, read-only, for a client let in as for the websocket
(
An agent (Claude, Codex, Grok or Muse, through the hub's MCP tools) works with the owner's other machines
the way it works with its own project. src/core/fmcp.bend is the whole of it.
machine_list(every agent): this machine first, then each linked machine (hereoraway) with its projects, ids as clients name them (<link>~<id>). Every hub starts an agent's prompt with the machines linked then (<machines>), and the orchestration paragraph says how to address them.thread_listandthread_launchtake amachine(its name or link id; a project there by name or id, or its<link>~<id>asproject).thread_send(auto, queue, steer),thread_wait,thread_interruptandthread_updatetake a thread's<link>~<id>, which any answer carries in its ids.thread_readasks the owning hub too (current even before its push reaches the mirror), and reads the mirror (xref.bend) while that machine is away. A word>machine:slugin a message gets its id in the<references>block.- A call for another machine is not run here: the hub (
Hub.mcp.f,Fm.plan.o) turns it into the signed requestmcp.call {tool, from, args}(POST /far/rpc, the route clients use for threads elsewhere), with that machine's own ids, and answers the agent's call with what returns, its ids named by the link (Fm.answer). No linked machine of that name, or one away, is an error and nothing is sent. - The owning hub takes
mcp.callonly from one of the owner's own machines (the route'sFar.own.id; an unsigned or non-owner request never gets this far) and only for the tools inFm.names(thread_send, thread_wait_now, thread_interrupt, thread_update, thread_list, thread_launch, project_list). It runs the same defs as for its own agents, but a named thread must be one it shares (Fr.thread.shared): otherwise the answer is an error and nothing is stored.fromis the calling thread as the owning hub's clients name it, so the message shows where it came from. thread_waiton a thread elsewhere is the usual wait loop (a check each second at the hub) where each check asks the owning hub (thread_wait_now): the owner's own view, so a message just sent is never missed for a mirror that has not caught up yet. One small signed request a second per waiting agent./give >machine:slughands the conversation to that thread and/give %machine:projectto a new thread in that machine's project (%machine:nameby id, name or slug), by the same route: the owner'sthread_handoffqueues it ahead of the next message, exactly as a local /give does (Fm.hand). The person hears ok only once every machine took it, or which did not (Far.givein the server); words naming something here run as before, read without the machine words (Fm.give.local). A thread that is itself elsewhere is given from its own machine.- Sender ids: a message sent by a thread on another machine carries its sender in its id
(
t:<from>:<n>); a mirror names it as its own machine does (Fr.sent), so the "from" row opens the right thread both ways. - Bots:
@name@machinealready reaches a bot on a linked machine (Bots.mention.far); rooms travel with the owner's machines (see "Shared rooms").
Laws fmc_*; tests test/fmcp_test.bend, bun test/tools/agents_far_e2e.ts.