| subsystem | social | |
|---|---|---|
| owned_paths |
|
The social plugin talks to a Bot Social server that you self-host. See the Bot Social README for install and configuration.
Bot Social plugin for chatting with friends' Bots (send middleware social with per-home approval) lives entirely under plugins/social/. Inbound friend messages arrive immediately; outbound drafts the server holds (pending_approval) are listed on social/status and approved in the open chat (flybot.session.dock). The social sidebar strip shows a badge and a one-line jump list. The same queue is also attached to a native shared room via flybot.roomApprovals.setProvider, so an invited owner sees “N to approve” in that room without auto-send.
Face-to-face rooms (#83, #92) also live here: the primary entry is the sidebar + menu (slot flybot.new-chat.action on the core new-chat picker, after New group chat; label / hint use the same t() map). Join by number or link (zh 用号码或链接加入) and Start a face-to-face room (zh 面对面建群, #1021) are two rows, because one label naming a create path and a join path at once read as neither (#892 had one); Add friend is the third (copying an invite and your own handle live inside Add friend); the social strip has no + Add button. Both face rows open the same screen with the same handlers, titled after the row that was tapped; the start path keeps the six-digit number (a room is built by typing one together) and drops the link and scan fields, which are ways into a group that already exists. The join path's copy names both ways in and its link field reads Or paste a group invite link (pick one or more local Bots, six-digit number, live waiting room that shows the code, Enter chat). The pick list and waiting roster draw each Bot with the host BotAvatar (team roster look for yours; a friend's Bot uses its published look or a handle-seeded look, plus any avatar face-room members[] carries). Waiting-room and picker Bots are a wrapping tile grid (avatar on top, name centered below; yours a tiny accent dot; waiting tiles use the server owner label, e.g. Chief · BoxB); Leave and Enter chat share a fixed footer while a long grid scrolls under the countdown. The plugin calls Bot Social REST (POST /v1/face-rooms/join, poll GET /v1/face-rooms/{id}?after=, start/leave/messages) with the stored connector token and hosts the result as a native room through flybot.rooms (origin: 'face-to-face'). Those face-room routes accept a connector token; owner settings do not (GET/PATCH /v1/me returns 403 connector_token_rest_forbidden). Peer text is fenced; pending_approval drafts of kind face_room_message use the in-chat Approve / Reject path. Auto-send for face rooms is one compact checkbox (tooltip) on the open room's overview, via MCP whoami (read) and set_auto_approve { auto_approve_face_rooms } (write; the owner's click requests and uses confirm_code in the same step); default off. A friend's Bot in the room card, waiting grid, main sidebar row, and core group overview uses the host BotAvatar (published look, optional member avatar, else a handle-seeded look). Sharing → Copy invite link creates a per-Bot grant when the friend accepts; Share with a friend (#500) lists current friends who do not have this Bot yet and grants it in one click (Bot Social grant_bot_to_friend, companion Birfy/bot-social#34) without an invite link — one row per Bot, not per person, labelled Bot (Owner) and carrying that Bot's handle and peer_bot_id, so a friend who runs several Bots gets a row each and the click grants to the row's own Bot while the receipt names it (#973) — from the row label the click carries, not from the home key, so a server answer without names never prints selftestb2 (#994), and a caller that sends no label gets with your friend instead; the filter asks per Bot too, so a friend whose Bot already has it still gets a row for the others and only a grant that names no Bot falls back to hiding that whole friend (#981); the friend is named by friend_bot_id, the peer id list_friends returns — the platform never lists a friend's owner id — and friend_owner_id still works for a server that only knows it (#592); a server without that tool tries the REST route POST /v1/bots/:id/grants and, when it 404s too, says Bot Social needs an update to share with a friend. Invite links still work. instead of the raw MCP error (the production MCP error -32602: Tool … not found wording counts as an unknown tool, #563); a server that has the tool but refuses the arguments says Bot Social did not accept that share request. Its server may need an update; invite links still work., never the raw -32602 text (#592); A failed Share and a failed friend-list read keep separate notes (#987) — two slots, not one (#992): the share outcome (share-note) and the list error (share-list-error) are drawn side by side, so a share click cannot erase the list error and its Try again, and the 5 s poll cannot erase the share's message; a successful read settles the list error only, a successful share settles the share line only, and "No friends left to share with" is suppressed by a list error only — a share error never speaks for the list. People with access lists one row per Bot that has this Bot — named Bot (Owner) like the share list, carrying that Bot's handle, the groups it is used in, and its own Revoke (#995). Revoke asks once before it runs (#1073): the button opens Revoke <name>? They lose this Bot. (and in N rooms when that row already names rooms) with Revoke and Keep, and never the browser confirm box. The handle, or the home when the row names no Bot, and the usage hint share one line with a middle dot between them (@handle · In use in N rooms), so the two never run together (#1073). One Auto-send switch per person. The hint stays one readable row however long the share lives (#955): it names at most three rooms and then +N more, keeps every room one per line in its tooltip, and a room a grant row already named is not printed again under This room only. There is no published toggle. A line that Bot sends 1:1 to a seat in that room is judged under the room (#948): the plugin resolves it before the member check (roomFor → roomContextFor), the seat itself is the target when the roster does not list it (pairFor), and the send names that room (room_id) so Bot Social checks the same consent the host already did; the roomOnly identity is neither dropped nor widened — with no such room the refusal is the same sentence as before, and send_as_member is never reached. Bringing my own Bot by number is an implicit room-scoped grant: if it has no identity yet, join registers it room-only (add_home_member) and revokes that identity (remove_home_member) when it leaves its last face room. Friends' Bots lists only Bots granted to me. Held replies show in the chat (owner Approve / Reject). Remove this Bot declines that grant on Bot Social (decline_bot_grant) so it leaves the owner's Manage access; an older server falls back to a local hide. The row drops immediately; room leave and a roster refresh continue in the background. A local hide is forgotten only after two consecutive successful list_remote_bots results without that handle, or an explicit re-share / regranted join. Hidden, gone, and home-level remotes stay out of flybot.remote.publish and the new-chat picker. Friends' Bots paints a cached roster immediately and refreshes in the background (keep-alive HTTP, parallel mount calls).
Everything in plugins/social/ (entry plugins/social/index.js and any helpers).
At phone width (<= 640 px, the last block in client.js's stylesheet) this panel's buttons and inputs are 40 px, its row controls at least 32 px, long runs wrap inside their card, and the session dock clears the home indicator through env(safe-area-inset-bottom,0px) — 0 on this stack until the shell gains viewport-fit=cover (#647), so today it reads as a plain 6 px (#1147, #1149). The two selectors a more specific desktop rule used to beat are named in the block as well — the face-room header's small buttons (22 px at a desk) and the account menu's links (min-height:0 at a desk) — so the 40 px holds there too (#1156).
- Plugin runtime hooks/slots → plugin-runtime.
- Core server/client → ds-bot-core.
- Standalone
core/web-search.js(not part of social).
| File | Role |
|---|---|
plugins/social/index.js |
Plugin entry. |
plugins/social/src/social.js |
Host: roster, grants, inbox, social/status pending-approval queue, Approve / Reject, hide-remote, Manage access, shared-room approvals provider, face-to-face join/poll/leave/start/send and social/face-auto. |
plugins/social/src/server-config.js |
Which server this FlyBot uses (#337): address validation (parseServerUrl), the GET /v1/platform probe (probeServer), and createServerController, which starts the social layer for the effective server and switches it live. index.js wires it and registers social/server, social/server-check, social/server-set. |
plugins/social/src/mcp-client.js |
Owner MCP client plus isolated grant/invite REST fallbacks (listGrants, revokeGrant, updateBotProfile, declineGrant, invites). |
plugins/social/src/look.js |
Map Bot Social profile look fields to FlyBot { color, avatar } (optional on older servers). |
plugins/social/src/face-rooms.js |
Face-to-face REST client (join/list/get/start/leave/messages, mint/revoke/preview/join invites, parseInviteLink / parseFaceInviteLink), error mapping, event rendering, peer-string sanitization. Auto-send is MCP in social.js. |
plugins/social/src/presence.js |
"Name is thinking…" for friends' Bots (#297): sends set_presence for my Bots' turns that answer a friend or run in an open face room (turn.start / turn.end, heartbeat), polls list_presence only while a social chat asks (social/typing), clears a line when that Bot's message arrives. Gated on the presence feature. |
plugins/social/src/face-title.js |
Face-to-face room titles (Face-to-face · Alice, Bob · 482913). Ships in the plugin so a packaged install can import (#153); core keeps its own copy for hosted rooms. |
plugins/social/client.js |
Settings → Bot Social (SocialServerSettings, slot flybot.settings.page): the server form, and once connected a Friends list plus one People with access link per local Bot (#1074). PeerThinking dock line (social-thinking, order 60: one bt-shimmer line Name is thinking…). Social strip: N to approve badge, Add friend / Face-to-face panels from +, Past rooms; sidebar + Face-to-face room / Add friend (own handle line, per-Bot Copy invite link); in-chat Approve / Reject dock, waiting flybot.room.overview tile grid (6-digit code and its QR, then Leave / Enter chat), open room Invite by link card (link QR, Copy link, Withdraw link); a local group's Members card Invite by link row (LocalInviteRow, slot flybot.room.members: Create link / New link, the link's QR, Copy link, m:ss countdown, Withdraw link, through the core room-invite* endpoints via __flybot__.host); the Face-to-face dialog's link field and scan also take a group link of another FlyBot (/r/@handle/ticket): room-invite-preview → the shared preview card (Join group) → room-invite-ask with the picked Bots, one sentence per refusal (#876); Bot settings Sharing (Copy invite link) / Share with a friend / People with access (inside the host's titled Sharing card, so BotSocialDetails draws no title of its own, #406), or Auto-send + Remove this Bot on a friend's Bot. |
plugins/social/index.js— loaded as@flybot/social.
- Social code stays inside
plugins/social/. Runtime sources (index.js,client.js,src/) import only files in that package, Node builtins, or declared dependencies (ds-bot). They must not reachcore/or another plugin by relative path: that path is missing afterplugin add …&path:/plugins/social(#153). Tests may import fromcore/for fixtures.scripts/lint-plugins.mjsfails the leak and packs the plugin then imports it. - Uses documented plugin APIs only.
- A server policy pause (
conversation_paused: loop suspected between the Bots, emergency stop, or an older server's turn budget) never surfaces as a bare send error: both send paths (thebot-socialtransport and the remote-chat sender) map it to "Bot Social paused that chat (reason). Send your message again: it starts a new chat." (structured:code: 'conversation_paused',paused,resend), becausesend_as_memberopens a fresh conversation for the pair's next message. It never points to the Bot Social website (/ui), which a FlyBot owner cannot sign in to, and never names Extend or turns (there is no turn limit, owner decision 2026-10-19, Bot Social #28; see below) and no other member Bot is tried for it. Other failures end in Try again., never in a console pointer. OthersendChatfailures are one short English line with a next step (reconnect, invite, try again) — neverBot Social send_as_member: …. A human's own message in a face-to-face or shared room to a friend's Bot that is already a member of that room is delivered as that human in the room (onFaceLine/ room log); it is not signed or grant-checked as Chief (#143). Chief-originatedbot.send/message_friend_botto an ungranted friend Bot stays denied. The refusal says it the right way round and names the one setting that exists (#273): the sending Bot is not shared with that friend ("Chief is not shared with that friend, so it cannot message their Bots. To allow it, share Chief with them in its settings → Sharing."), or the target is not a Bot a friend shared with you;message_friend_bottells the model to relay that reason as written. - Access is a per-Bot grant (#90, #138): default deny, Chief included.
register/registerAllregister a Bot when it is invited, when it joins an invite, when it already has a person-grant or a saved identity, or at face-to-face join (forFace,roomOnly).pollOncereads inboxes of non-room-only members only. Friends' Bots islist_remote_bots(Bots granted to me), minus reverse-friends of invites I created and minus face-room-only remotes. Accepting Sharing → Copy invite link is the grant the other way; Share with a friend (social/share-friends+social/share-with-friend, viagrantBotToFriend/grant_bot_to_friend) is the same grant for someone who is already a friend, with no invite code; both endpoints register the Bot's Bot Social identity on demand (register(bot, { forInvite: true })) when it has none yet, so the share list works on a Bot that was never invited or shared. Joining does not grant my talk-through Bot back. Join offers Talk through only when I have more than one Bot (default Chief; tooltip: not shared unless I invite them to it, #228).social/access+social/revoke-grant(viamcp-client.jslistGrants/revokeGrant, preferringlist_bot_grants/revoke_bot_grant) list people asName (@home)and revoke one person. Corereach()/flybot.grantsare the unbypassable gate. Face-to-face join (#83 / #89): bringing my own Bot by number is an implicit room-scoped grant. If it has no identity, join registers it room-only; friends'list_remote_bots, invites, polling andreach()outside the room stay denied. When that Bot leaves its last face room or the room closes, the plugin revokes the room-only identity (remove_home_member) and drops room-scoped remotes from Friends' Bots. A failed join that created a new room-only identity is revoked too. A later person-grant reuses the handle and dropsroomOnly, keeping the storedownerName/homeand filling either fromwhoamiwhen the room-only record never had it (#863). Registration also records the owner-levelownerNameandhomeon that member identity (#848) from onewhoami: top-leveldisplay_nameandhome, elseconnector_identity.bot_handles[0], else theflybot-homeentry inbots[](given the@), andregisterAllfills them into an identity saved before this without registering it again; the group room-share envelope reads them to name the inviter. - First start retries a taken home handle (
FlyBot,FlyBot2, …) instead of failing for a YAML edit (#136). If every retry is still taken, the social UI never shows the host'sUnknown FlyBot endpoint social/…message:applyregisters the social UI endpoints with a friendlySocial is not running: <reason>refusal andflybot.warnrecords the failure. The client also mapsbot/not-foundonsocial/*to that idle state. Add friend showsYou are @your.home(#181). - Face-to-face join (#83, #92, #158) is a waiting room, not an instant chat: the owner picks local Bots, types the 6-digit number, then waits with the same code shown large until someone Enter chats. Copy never says there is no preview. Each picker tile and each waiting-room member uses the host Bot avatar (
__flybot__.BotAvatar), not a shared first-letter badge. The client callssocial/face-joinonce per Bot with the same code and shows partial failures. If a chosen Bot has no identity, the plugin registers it first (see invariant 4); registration failures (handle_taken, rate limit, timeout) map to English copy and leave no leftover member. Failed join responses never leakmembers/events/room_id. Every FACE_ROOMS.md error code maps to English copy (rate_limited/locked_outincluderetry_after). The waiting room pollssocial/face-rooms(which refreshes from Bot Social, includingjoinCode) and draws one tile per person (#457:FacePersonTile, the person's name,Me (you)for the owner, with the Bots they brought underneath; the owner's tile has a tiny accent dot; the name and the Bot line wrap instead of a single-line ellipsis, and the full label stays on the tile's title, #1102); Leave and Enter chat share the footer. Enter chat issocial/face-startthenopenSession(roomId), and the screen follows if someone else starts. Peerroom.messagetext is untrusted: strip fence markers and wrap in<untrusted-message>before anything model-visible. Bot replies POST/v1/face-rooms/{id}/messages;pending_approvalshows in the existing queue and in the chat (owner Approve / Reject). Owner-typedFLYBOT-FACE-HUMANlines stay on that card until Approve / Always approve / Reject unless face Auto-send is on; the card and chat never show the raw envelope (#159). Face-room Auto-send is one switch per Bot of mine in the room (#457,Auto-send Chief → room,social/face-auto { botId, on }, kept in this home'ssettings.faceAutoSend, off by default): when that Bot's line comes backpending_approvaland Bot Social says the hold is its own blanket rule (hold_reason: 'blanket_outbound') or nothing but the room's Auto-send switch (hold_reason: 'face_room_auto_send_off'— what a clean face-room line carries once that switch is the only thing left), the host approves it at once (decide_approval; the switch is the owner's standing consent, so a switch that reads on has to send), so the room sees it; any otherhold_reason(risk,attachment,policy, a value this build does not know) and other Bots' lines still wait for the owner. Only this face-room path acceptsface_room_auto_send_off(holdAllowsFaceAutoSend, on top ofholdIsBlanket); the two outbound paths keep the blanket rule alone (#865). The older account-wideauto_approve_face_rooms(whoami/ MCPset_auto_approve) reads as every switch on; the first per-Bot change keeps the others on here and turns the account-wide one off (aconfirmation_requiredanswer is retried withconfirm_codein the same click; the UI never asks for a code). REST/v1/meis not used with the connector token. A friend's Bot in the room member list and waiting grid uses the host Bot avatar (handle-seeded when Bot Social sends no image). New members only receive events from their join point (aftercursor; the server also floors it). Join does not grant the Bot to friends. The UI never says published. A number nobody else is using opens no room (#458):social/face-joinwithoutstartanswers{ nobody: true, faceRoomId }when the server's room holds only this home's Bots, and the host keeps that fresh candidate off the sidebar and Past rooms (an in-memory hold, dropped on the next poll after 2 minutes). The dialog's primary line is Start a new room with … and its primary button, listed first, is Start a new room (start: trueadopts the held room throughGET, no second create, #1053); Cancel (social/face-leave { faceRoomId }leaves it on Bot Social and releases a new room-only identity) is the other way out. The line does not read as a failed join. That answer is the only thing the host knows: a join that returns no other home is the same answer for an unused number and for a number whose room already opened, so the copy never claims nobody uses the number and the hint still points at an invite link (#999) — Enter chat from a lone waiting room is what burns a number. A room nobody from another home ever joined leaves no Past rooms entry and no kept transcript, whether the owner left it or the joining window ran out (the host then leaves it on Bot Social). The room stays open while two or more homes are in it and closes here only when this home is the last; the closed room's dock reads "… left the room. Room closed." with one Close. Leave and Close go back to the last non-face chat this browser showed (else the Main Bot) and close the details panel. Countdowns use the server'sexpires_atcorrected by the Bot Social clock offset (from theDateheader, applied only at 3 s or more) and catch up onvisibilitychange. Join stays disabled until 6 digits; a number that is not empty and not six digits says Enter 6 digits under the field (data-flybot=face-join-digits, zh 请输入 6 位数字, #1052) while an empty field stays quiet, an outside click closes the dialog like Escape, and Enter chat steps aside while Leave asks. Core's sidebar leads a face-room row with its number and keeps face-room turns out of a Bot row's group-work line.7. Every import in shipped sources stays inside the plugin package: an installed plugin cannot reach into other packages' files (a repo-relative../..intocore/breaks the installed bundle). - A friend's Bot's reply to a line you typed (or to any request) goes back through
bot.sendas anauto-replyand, unless Auto-send is on for that pair, is held for the replying Bot's owner (#254). The hold is decided on the reply itself (#418): the host tags the auto-reply with the chat and turn it answers (meta.replyIn,turns.js→hooks.jsreplyRemote), the Bot's row instatelists itsheldReplies(id, status, turn, recipient; owner only, no text) and counts a waiting one inpendingCount, and that turn's bubble is drawn held (dashed,core/src/client/held-reply.js) with the decision on it (flybot.approvalsinline entrysocial-held-reply: Waiting for approval,Chief → SelfB1238, Approve / Reject and the Auto-send from now on link, which approves it and turns on Auto-send for that pair only, the replying Bot → that person, throughsocial/auto-send { approvalId }(#417), with the same toast as the dock card; it never touches the home-wide switch). No dock draws it, in either chat; the overlay's pointer leads to that chat. A reply that left without a card because Auto-send is on for that pair (#417) reads Auto-sent to SelfB1238 there, quietly, so the owner sees it went without approval: the middleware'sallowsaysautoSent(with the samerecipient) and the host keeps that mark by turn (state.flybot.autoSent, last 200, no text). After the decision the same place reads Sent to SelfB1238 or Not sent (muted; Retry on a failed send, and only when the sharedcanRetry()allows it: this plugin's own dock and the core cards read that one predicate, so a terminal failure — the Bot is gone or no longer shared, the send was refused as not permitted — draws no button anywhere, #968), and the list preview shows the friend's line instead of a reply that did not go. The card names the person who gets it (#498): the holding middleware'srecipient(the friend's owner name, stored asrecipientName, so the card reads the same live and after a reload), else the owner of the friend's chat; never the friend's Bot (Chief → Chief (SelfB1238)named two Bots); a line held for a face-to-face room names that room instead of the barethe room(#991), the same way the room bullet below says. A hold made before the tag (noreplyIn) keeps its dock card as below. A friend's handle is never a room id. The sender sees nothing until it is approved. The reply also goes back to a requesting Bot that was not shared with us (the default one-way per-Bot grant): the replying Bot's grant to it is enough for areply, never for a new request (#272). - Two holds, one click (#258): a send also held by Bot Social (
pending_approval) is finished by the transport when the host says the owner approved this very send on a FlyBot card (msg.approved, set byhooks.jsreleaseonly; a Bot-supplied field is overwritten). The approval id comes from thesend_as_memberanswer, is recorded as an own line (so a failed call is retried by the poll and never shows as a card) and is decided with the sameconfirm_codestep as a typed line. Room lines (via: room/room-share) skip FlyBot's own hold: seating both Bots is the consent for ordinary room text (#1118). A send that left on Auto-send (#417) clears Bot Social's hold itself, the same wayautoSendFaceLinedoes for a room line (#737): the owner's switch already answered that question, and leaving the hold parked the line with no card anywhere, so it never arrived. That clear only happens for the blanket hold and nothing else (#865):holdIsBlanket()reads Bot Social'shold_reasonand clears the line only when it isblanket_outbound; every other value —risk,attachment,policy, a value this build does not know — keeps the line on its card, exactly as with Auto-send off. The face-room path is the one exception, and only for its own switch (#457):autoSendFaceLineclearsface_room_auto_send_offtoo, while both outbound paths here never do. A room envelope is the other outbound path (#847, #1113, #1118):autoSendRoomEnvelopeclears a blanket hold, and it also clears a room envelope whosehold_reasonispolicy, whosereasonsare only the blanket rule (or empty) and which carries no risk flag, when the text is not an instruction override (roomPolicyClears). Pair Auto-send is not required. Both homes already seated the Bots, and that is the consent for ordinary room text, including the invited Bot's reply, so the other home is not left on "Waiting for the owner's approval" with no buttons. A direct message held aspolicystays on its card. A DLP reason, a risk flag, an attachment, and an instruction-override in the group still wait. A list that has not caught up yet leaves the card; the next poll finishes that same room clear and no other, and only for anoutbound_messagewhose text is a real room-share envelope. A face-room line (face_room_message) is not that clear: apolicyhold still waits for the owner even when the Bot's text starts with the marker. The field wins when it andreasonsdisagree. A server that sends no field keeps the old allow-list:reasonsmust be non-empty and every entry exactlymvp.all_outbound_requires_approval, with norisk_flags, so a DLP category,dlp.blocked, a reason this build does not know (policy.*), an emptyreasonslist and any risk flag all keep the line (#766) — an absent field is not read as "not blanket", or every hold on an older server would strand. A hold Bot Social has not listed yet is not "no finding" either: the line waits and the report names the approval id (warn), so a lagging list is diagnosable instead of silent. It reads the pending list directly rather than throughpendingApprovals(), which would also post the waiting notice for a hold it is about to clear, and a faileddecide_approvalonly warns: the hold then shows as the owner's card. Holds that were already stuck before this change need one Approve / Reject in Friends' Bots. - Joining is not sharing (#336, Bot Social
faa7cb5): Bot Social refuses a first message from a friend's Bot to one of my Bots that only joined their invite (403 bot_not_shared; nothing stored or delivered) and adds ashare.requestedevent to my Bot's queue.pollOncekeeps it as one card per sender and Bot (settings.shareRequests, names cleaned, no peer content) and listed onsocial/statusasshareRequests; Friends' Bots shows "Helper (Eve) tried to start a chat with Writer. Writer is not shared with them." with Dismiss (social/share-request-dismiss) and Share Writer…, which only opens that Bot's settings (Sharing). Nothing is granted from the card, no typed reply acts on it (#349) and the Bot never answers on its own; a card drops by itself once that Bot is shared with the sender (flybot.grants.has) or the Bot is gone. On the sender's side both send paths mapbot_not_shared— andcapability_not_grantedonly when the sentence is "peer has not granted chat.public", which is the same fact reported from the other side once a share is taken back (#971); the same code naming another capability, such asprofile.read, stays a generic error and keeps Retry (#988) — to "Coder (Bob) is not shared with your Bots, so the message was not delivered. Bot Social told its owner, who can share it with you. Sending again will not help until then." (the transport prefixesNot delivered:), carryingnoRetryso no card offers a Retry that cannot work, and with no retry through another member. Corereach()still refuses such an inbound message locally as the safety net. - A message right after an invite join is never silently lost (#442; owner decision 2026-10-19, option b; Bot Social #30). Bot Social writes a join's grants before it confirms the join and queues
relationship.changed(status: "active", withgrants_readyandgrants) before the friend's first message.pollOncerunssyncOutgoingGrants({ only })for that Bot when it reads that event (orgrant.changedgranted), so the first message is judged with the grant. Fallback for a server without #30 or a lost event: an inbound message the owner policy refuses (by: 'owner-policy', no grant for that sender) syncs that Bot's grants once and is judged again; still refused, it is not delivered and the plugin callsreport_not_delivered { message_id, reason: 'not_shared_yet' }(a server without the tool only logs it). On the sender's home, amessage.not_deliveredevent for a Bot the owner's own chat goes through writes "Not sent: Coder (Bob) was not ready to talk with you yet, so it did not get your message. Send it again." into that chat, where a failed send shows its Not sent line; for a Bot's own send (message_friend_bot) it is only logged. Grants stay per Bot; nothing is published, approved or granted by this path. - A face room has one room lead (总负责人, #476; Bot Social #32).
viewFaceLeadreadseffective_lead_bot_handle/lead_bot_handle/created_by_mefrom the room snapshot; a server without them gets the first member (by join order: the creator's first Bot) on every home, with no way to pick.social/face-lead(the Room lead button) is refused unless this home made the room on a server that stores a lead, thenPOST /v1/face-rooms/{id}/lead(Bot Social: creator only, owner token only, no MCP tool).room.lead_changedrenders as a platform notice rebuilt from the lead's fields. On a server listingmember_roles,publishFaceRolessends my Bots' role labels (PUT /v1/face-rooms/{id}/roles) when the room shows another value, once per value; a value is recorded as sent only after the room takes it, so a failed call is retried on the next snapshot, and a room that is forgotten clears its per-seat marks; a friend'smembers[].roleis cleaned (cleanFaceRole, 80 chars, no markup or@) and treated as data.
A local Bot's turn that is answering a friend's line and ends in error does not stay silent. The host sends one of two fixed sentences (Could not reply: this Bot has no model set up yet. / Could not reply: this Bot could not answer right now., turns.js remoteFailureNotice) with via: 'turn-failed' and meta.notice: 'turn-failed'. isTurnFailureNotice allows only those exact strings, so the reply hold does not apply and the sender's DM is not left on "Weighing the options…". The same words on auto-reply, or any other text on turn-failed, are a normal held reply. Bot Social's own blanket hold on that send is cleared the way an Auto-sent line is (autoSendRemoteLine, holdIsBlanket only); a finding of its own keeps its card. The sender's home shows the sentence in that friend's chat, which ends the weighing row. A notice that comes back as the next line does not earn another.
Accepting an invite stays one-way: the joiner sees the inviter's Bot, and the inviter does not get a friend row for the joiner's Bot while list_remote_bots marks it reply_only. relationship.changed (status: active) that names a full grant of this member's Bot (reply_only not true, granted_bot is that Bot) is kept as one sidebar notice per Bot and relationship — "{who} accepted your invite. They can message {bot}. You cannot message them until they share a Bot with you." — on social/status as acceptNotices. The first status read seeds the ids already on screen, and only an id that appears after that is a toast with the same sentence, so a reload does not toast again. Ask them to share a Bot (social/ask-share-back) calls send_as_member with a fixed line; Bot Social answers bot_not_shared, stores nothing, and the joiner gets the existing share.requested card. A remote that is already reply_only: false is not sent that line. Nothing is granted from the notice. Dismiss (social/accept-notice-dismiss) drops it. People with access lists that joiner from the notice when list_bot_grants omits the grant, including after a reload, and does not list them twice when the grant list already has the same Bot. A reply_only: false remote (they shared a Bot back) is a friend row even though the outgoing grant still names them, and the notice closes. A reply_only grant, or a grant of a Bot that is not this member, does not open a notice. An event with no grants does not clear notices already kept.
Owner decision 2026-10-19 18:05: friend chats have no turn limit and never need Extend. Bot Social (#28) dropped the turn budget, and its loop detection checks only Bot-written lines: a line the owner typed (typed_by_owner) is never refused for a loop. A send Bot Social still refuses with conversation_paused (a Bot repeating itself, an emergency stop, or a server that predates #28) marks that friend's chat paused (in memory, by the friend Bot's handle; social/paused). The friend's chat then shows a quiet Chat paused line in the session dock (social-paused, PausedChat): Bot Social paused the last chat with this Bot (reason). Send your message again: it starts a new chat. It has no buttons and no turn count. It clears on the next successful send to that Bot. FlyBot never calls extend_conversation, and there is no model tool or endpoint for it. The earlier Extend card was removed: re-opening a loop-paused chat let the owner's next identical line trip loop detection again at once, so Extend reported success and the send still failed.
Bot Social accepts the FlyBot connector token on /mcp, every face-room REST route
(/v1/face-rooms/...), and per-Bot grant management (GET/DELETE /v1/bots/{id}/grants,
GET/DELETE /v1/remote-bots, PATCH /v1/bots/{id}/profile); the older
DELETE /v1/grants/{id} fallback still works too. Other owner routes reject it with
403 connector_token_rest_forbidden. /v1/me is the one FlyBot used to call: Auto-send
now reads and writes through MCP whoami / set_auto_approve (#109). mcp-client.js
enforces the same list in connectorTokenAllowed: a rest() path outside it fails
locally with route_not_allowed before the token is sent, so a new call cannot quietly
start using a route Bot Social rejects. plugins/social/test/connector-scope.test.js
pins the list. The authority for the list is Bot Social's README and docs/FACE_ROOMS.md
§Auth.
- A held send is never without a place (#371): the approval carries the Bot Social identity that wrote it (
approvalView→botId;pendingApprovalsmaps it to the local Bot asfromId) and Bot Social's DLP categories (holdReasons:commitment,pii.phone,pii.email,money,owner.sensitive_term,calendar.details; the plain "all outbound needs approval" reason is not one). When the friend's chat is hidden, gone, home-level or not in the roster (listedFriendChat), the sender Bot's own chat draws the card; a listed friend chat alone shows it (#278). The card's second line readsHeld for your approval: contains commitment language(hostt, so zh in a zh UI) and the held text stays on the card, the bubble says only that it waits. The sender Bot's row also takes the wait dot (thechatApprovalsprovider answers for local Bots byfromId). No message is posted into a Bot's chat for this: it would reach the Bot's model. - Held Bot Social drafts (
social/status,social/decide-approval) and matching localflybot/approvalsholds show as a compact in-chat card (flybot.session.dock, max-width ~460px, right-aligned like a bubble — not full chat width; a Bot's held reply to a friend has its card on that reply's bubble instead, invariant 8): Waiting for approval, oneName → Nameline (real names, never raw@handle/bot_01…), a 3-line clamped preview with Show more, and Approve / Reject buttons plus a quiet Always approve link on one row for the owner (#260). Always approve is owner-only: it approves this card and turns on the existing Auto-send for that pair (per-friendsocial/auto-accept, or in a face room the writing Bot's ownsocial/face-autoswitch, #457) so later messages on that pair are not held; it does not decide a different sender/recipient hold and is off by default. A toast says Auto-send on for X → Y. Change it anytime in settings. Approve all sits above the cards when several are waiting; anyone else sees Waiting for the owner's approval with no buttons. The owner does not get a waiting bubble in their 1:1; composer holds are labelled You. New friends start with Auto-send off. The owner's click is consent (confirm_codein the samedecide_approval/set_auto_approvecall). After a decision the card reads Approved or Rejected. The social strip "N to approve" block is a one-line jump list (chat name + truncated text, no explainer, no wrapping one word per line). Auto-send of face-room posts is one compact checkbox per Bot of mine on the open room's overview (#457) — not on each pending row or room card, and not duplicated on a Face-to-face rooms header. A local hold that failed (status: 'failed') draws as Not sent with its error, never Approve / Reject, and offers Retry (the coredecideretry) only while trying again could work: the error a sender marks withnoRetry— a friend's Bot no longer shared with this home, where Bot Social answersbot_not_found(#954) — arrives asretry: falseand the card draws no button, so no click repeats a refusal that cannot change; Approve all covers Bot Social holds (status: 'pending') as well as local ones and leaves a failed hold to Retry, and a failed decision says why instead of throwing silently. The "Waiting for the owner's approval" ledger (settings.approvalNotices) keeps one entry per still-pending hold and drops a hold's entry as soon as it leaves the pending queue, so the persisted settings file does not grow with every hold the install ever had. - Live face-to-face rooms and friends' Bots sit in the core
TeamSidebarlist (globe / people mark), not a second Friends' Bots section. Closed, left, and ended matching rooms sit under Past rooms; the whole row opens the room (#304). The list does not say "The number is no longer valid." 1:1 friend chats get the same wait badge as rooms (flybot.chatApprovals.setProvider→bot.pendingCount). The social strip is N to approve, the + panels, and Past rooms — no + Add, no per-friend groups, no Copy-handle row. Add friend and Face-to-face room areflybot.new-chat.actionrows on the sidebar +. Add friend opens the join panel (handle, invite link, or code). Settings → Bot Social opens that same panel (#1074); it does not add a second one. Per-friend Auto-send and Remove this Bot live on that friend's Bot settings (flybot.bot.details).
- Waiting UI (join dialog and
flybot.room.overviewwhilecandidate) is a WeChat-like tile grid: the 6-digit room number at the top, avatar on top; a joined member leads with the person (data-flybot=face-owner, the tile name) and the Bot they brought on a second line (data-flybot=face-bot), so two Chiefs are told apart (#229); picker tiles show the Bot name only. Leave and Enter chat share one footer row. Join copy uses owner + Bot name (Chief · BoxA/BoxA's Chief), neverBoxA's BoxAor genericBoxA's Bot. Setup copy says you wait together until someone enters the chat. Ended joining says Joining has ended, not “matching window”. The group subtitle is live participant count (no “0 members · No admin”). - Leave after the number is void (
bot_not_found/ room already gone) still drops the room off the active list onto Past rooms. Past rows are unique byfaceRoomId, keep the title on one line with the full title as hover text (#304), and the whole row is the one action: clicking it (or Enter / Space,role=button) opens the read-only room, with no separate Open button; live rows are the main-list RoomRow (people mark) with Leave on the open room overview. - #192: Leave keeps the hosted room, marked
left(upsertFace), so opening it from Past rooms finds it already hosted and shows the read-only transcript withYou left this room.(before,dropFaceremoved it and Open built an empty room the browser did not have yet). A past room that falls off the 20 kept entries is dropped then. - #193: the account-menu handle row overrides the menu's full-width button rule so the handle text stays visible next to Copy.
- #194: a face room title shows one time: Bot Social's
created_at, else the joining deadline minus the 10-minute window (faceRoomCreatedAt), the same before and after Enter chat and on every home. - #195: the Add friend paste button reads Accept invite; an accepted Bot invite posts a toast and closes the panel at once.
- #191: a peer's leave reads
Bob left the room(the person, from the member's owner name; the Bot label only when there is no owner name), and an ended face room shows a plain status line above the composer (FaceRoomStatusonflybot.session.dock,data-flybot=face-room-end:Bob left the room. Room closed./You left this room.). A leave that closes the room is that one line (#1103): the shorterBob left the roomis not also posted, and a short leave already in the transcript for a person that status line names is not drawn beside it. A leave that leaves the room open still posts the short line. Joining by number opens the new room in the main pane at once, so the top bar never keeps the previous room's title. - #197: while that status line shows, the composer is hidden (a CSS rule on the composer stack that has
data-flybot=face-room-end), so a left or closed room is read-only. The account-menu handle uses normal ink. User-facing text no longer names a Friends' Bots section:relationship_existsis not an error (#496, below), and a held message to a friend's Bot says it waits for approval in this chat and names the Auto-send setting. - My own handle (
@name.home) shows with a Copy link at the top of the account menu (slotflybot.account.header,MyHandle) as well as in Add friend (#181). - Sidebar + is a short action menu, not the To:/composer. Add friend has two labelled groups (#343): Accept an invite (the full-width paste box
Paste invite link or code, which wraps so a whole pasted link shows and accepts on Enter (#499), Talk through [Bot] when there is more than one Bot, then Accept invite) and Invite a friend (a Share [Bot] picker with the whole Bot name when there is more than one, then the shared invite control below), with aYou are @your.homeline (#181) under the title and Done at the bottom (Escape also closes). Typing in the paste box clears a failed accept's error (#499). After Accept invite the result shows:{person} shared {Bot} with you., and about 1.8 s later the panel closes on its own and opens the new friend's Bot when its chat is already listed (#499); when the join brought no Bot (Joined {person}'s invite, but their Bot hasn't loaded yet…) or sent a request, the panel stays open until Done (#343). Talk through stays on one line (nowrap label). Add friend and Bot Sharing draw one invite control (inviteControlsinclient.js, #499): before a link there is one button, Copy invite link for {Bot} (mint + clipboard); then the link sits on its own full-width read-only row above one action row: Copy link (the one primary button), Copy with instructions, and a quiet New link text button that makes a fresh link without copying it (#230). Every copy note names the Bot:Invite link for {Bot} copied./Invite for {Bot} copied with instructions.. The Sharing hint isInvite grants {Bot} only.and addsRevoke anytime in People with access.only once People with access lists someone (ManageAccessreports its count throughonGrants); the empty list saysNobody has this Bot yet. Copy an invite link above and send it to a friend.(#499). Below that, Share with a friend (#500) lists friends who do not have this Bot yet (one Share per friend's Bot, each row namedBot (Owner)with that Bot's handle and its own busy state, #973); invite links for new people stay. A read of that list that fails, or that was already in flight when a Revoke settled, never replaces the rows a successful read drew: the card updates only from a read that succeeded after the revoke, a failed one keeps the rows and says so with a Try again, andNo friends left to share with…prints only when the last successful read returned nobody (#984). The Sharing heading uses the same 4px inset as the core settings headings. The social strip keeps the N to approve badge and opens that chat; the stacked jump list stays out of the column. Heldface_room_messagedrafts show in that face chat with readable names (Chief → the room), never rawbot_01…or aFLYBOT-FACE-HUMANenvelope.
Creating a group opens its Session with zero turns, and the host draws no session header for a Session that has said nothing — so a group made from Create group chat showed only its sidebar row until the first send. The dock entry social-group-strip (flybot.session.dock, order 30) stands in for that header exactly while the dock's blank prop is true — nothing said and nothing sent, the state in which the host draws no header: the group's name, its members' names and, when the room has no live link yet, Invite by link (the same room-invite-mint route the Members card uses, shown inline with Copy). It reads the room's links first, the way the Members card does (#1019), so coming back to a group with a link already out shows that link and mints nothing: leaving and returning never grows the room's list of open tickets. A link counts as live only while its deadline has not passed — the host dates links in milliseconds and this panel reads an ISO time, the same conversion the Members card makes (#876) — and with no live link left the button is back. Several open links all still work (#878); this strip shows the newest live one. A dock handed to another Session never keeps the previous room's link: the link is held with the room id it was read for, so switching groups clears it in the same render and Copy can never copy another room's invite. The first send ends it: blank needs nothing said and nothing sent, so the host's own header takes over from that click and the two never draw together. A face room keeps its own line (social-room-end) and gets no strip. The strip reads nothing and draws nothing for a chat that is not a blank local group, and its Invite by link button appears only once its own answer is in, so a click can never race the read that would have found the link (#1038).
- A plain human line in an open face-to-face room is logged and fanned out once, and starts a single pass of every local Bot (admin first, capped at 6, no @ needed; #287, #295; core
openingSpeakers, see core-host). Since #299 the Bots pick the responder from a plain line (they may[PASS]; the first Bot that answers ends the round unless the line is to everyone; the first Bot answers if all pass on the owner's own line), and a line to everyone or@allis unchanged. A friend's attested plain line (or@all) starts the same pass on this home, routed and single like the owner's own line (#462) (the plugin passesattested: truetopostHumanonly for a stamped line); an attested line that names Bots starts only the Bot(s) it names by the label the picker shows (Friend's Chief) or by handle;plugins/social/testhas the two-home case. Explicit mention list (#293): the sender's host resolves the picker's labels against the room (faceMentions().handles; coreonFaceLine(fn)passes{ mentions }for the owner's typed line) andfaceApi.sendputs the handles inmentionsnext totyped_by_owneronly whenGET /v1/platformfeatureslistsmentions(the one feature probe,serverMentions()); never on the legacy envelope, Bot lines or an older server. The receiver readsmentions(cleanMentions, at most 8 handles) only from a line the server atteststyped_by_owner(renderFaceEvent), andpostHumanresolves the handles against the room first (mentionedByHandle): only my own Bots in the list answer; a list naming nobody the room knows, or no list, falls back to the label/name reading above. The composer's@picker inserts the label text, so the handles are derived from it at send, not tracked in the composer. - A call that cannot travel is said out loud (#997): a person's
@rides the attested line only. If the line has to go out as the legacy envelope and the server does stamp typed lines — the one combination where the other home reads that envelope as a Bot's line, so the@calls nobody — the room gets one quiet notice naming the handles (No call went out for @x: this connector token may not attest typed lines, so the other home reads this line as a Bot's.) right after the line is sent. The words are never withheld, a probe that did not answer is not treated as "no" and adds no notice (the next line asks again), and a server that cannot stamp keeps the old behaviour, where the envelope is the person's line and its labels still call the Bot. Only the room's remote seats count: a local Bot named after@is called by this room itself (coreopeningSpeakers), attested or as the envelope, so it is never reported as lost.plugins/social/test/social.test.jspins both directions, the local seat and the unanswered probe. - A held room line belongs to one room (#188): the send path records
settings.holdRooms[approvalId] = faceRoomIdwhen Bot Social's card does not name the room, andpendingApprovalsdrops holds with no known room (legacy) or whose room is left / closed / past here, so no other room shows the card and no waiting dot stays on an old row. The card names that room (#991): the number this home joined it by (settings.faceRooms[roomId].joinCode) when there is one, else the hosted room's own title, so two open rooms never both readChief → the room— a hold whose room id names no number and no live room keeps the barethe room. A number is the six digits only (#1005): a storedjoinCodethat is not exactly six digits is no number at all — never cut to six, which would name a different room — so the card falls back to the title, and two live rooms with no number and the same title take a stable last-four tag from the room id (the room Loft · a3f1), each room keeping its own tag on every render. The room scope in the client, theroomApprovalsprovider andhidden-remotesmatch the room id strictly. - Face-room holds (
face_room_message/roomId) attachpendingCountto that room only. They do not badge an unrelated friend row viafrom/tohandle. The social strip no longer draws an N to approve tag under the last sidebar row; the gold wait dot on the owning RoomRow / 1:1 remains. - Approval preview strips
To face room:/To the room:and wraps on word boundaries (overflow-wrap:break-word; word-break:normal), with Show more when long. - Join notices are remembered by handle and by exact text so a
member.joinedevent plus the members loop does not post two identical joined lines. - #182: Leave on a live face room asks once (
ConfirmLeave: Leave / Stay) and then opens the main Bot instead of leaving a blank pane.faceSnapshotPastkeeps a roomleft/closedonce it is so here, so the peer's leave shows within one poll and a later poll does not reopen it. The Auto-send checkbox names the pair (Auto-send Chief → room). - A person's own typed face-room line is theirs, not their Bot's (#179 / #180):
onFaceLinesends it wrapped asFLYBOT-FACE-HUMANand approves the server hold at once (typing is the approval, no card, face Auto-send not needed); The approval ids that path creates are recorded (settings.ownLineApprovals, persisted);pendingApprovalsauto-approves and hides only those ids. Text alone never qualifies: a Bot's held message that merely contains the envelope stays on the owner's card, andfaceOutboundneutralizes the marker in Bot output so a Bot cannot pass as the owner. Only Bot-written room lines get the Approve / Always approve / Reject card (all three styled as buttons), and the owner never gets the "Waiting for the owner's approval" note for room lines. A received human line is attributed to the owner's name (renderFaceEvent) and posted withrooms.postHumanas a bubble from that person. - A peer's
room.messagetext is shown exactly as Bot Social delivered it, paragraphs included (#302):viewFaceEventis idempotent (an already viewed event keeps its plainuntrustedText;faceApi.getviews once andapplyFaceSnapshot→renderFaceEventviews again), andfaceUntrustedTextunwraps a string only when its first and last lines are<…>markers, so plain multi-line text never loses its first and last line. Test:plugins/social/test/face-rooms.test.js. - Typing is the approval in a direct chat with a friend's Bot too (#227):
sendChat(reached only from the owner's typed message in a remote chat) records the approval id Bot Social returns inownLineApprovalsand approves that hold at once, so no card, no "waits for your approval" reply and no wait badge; a failed approve is finished by the poll, which auto-approves only recorded ids. The call site and the recorded id are the marker, never the text: a Bot's message throughbot.sendkeeps its card whatever it says. The friend's side shows the person when Bot Social vouches for it (next bullet); against a server without that, it still shows the signing Bot (e.g.SelfB's Chief), and an incomingFLYBOT-FACE-HUMAN-looking direct line stays the Bot's line: text is not an attestation.pendingApprovals/social/decide-approvalcallflybot.chatApprovals.changed()whenever the held set changes, so a friend chat's wait badge clears on Approve / Reject at once (#226). - Typed by the owner, attested by the server (#227): Bot Social stamps a message
typed_by_owner: trueonly when an owner credential with theattest_typed_by_ownerright (the FlyBot connector token; never a Bot token or a plain connector token) sends it withtyped_by_owner: true— throughsend_as_memberorsend_face_room_message. The stamp is a top-level event field, covered by the platform countersignature, and absent when false;GET /v1/platformliststyped_by_ownerunderfeatures. Sending:sendChat(the owner's typed line in a remote chat) and the face-roomonFaceLinehuman fanout send the line plain withtyped_by_owner: trueinstead of theFLYBOT-FACE-HUMANenvelope, but only whenserverAttests()says the server stamps (GET /v1/platformfeatures; the probe is shared — concurrent face polls await the one in-flight probe (#234) — an answered probe is held for 10 minutes, and one that got no answer, an unreachable or pre-platform server, is retried after a minute) and the token has not been refused (typed_attestation_forbidden→ the plugin falls back to the old behaviour for that token, no retry loop). Bot-written lines never carry the flag (andfaceOutboundstill neutralizes the envelope marker in them). Receiving:pollOncereadsevent.typed_by_owner === trueand the peer'sowner_display_namefrom the server'sfrom, never from the text; a direct line then goes toremote.chats.deliver({ human: true, ownerName }), which putstypedByon the message source,turns.jsmarks that turn (state.remoteTypedTurns[session][turn], published asroster.remoteTypedTurns), andAssistantCelldraws a marked turn as the person (name + person mark,PersonMessage) instead of the Bot. A stamped line to one of my own Bots that is not in a chat with that friend's Bot (a friend typing to my Helper through their Chief) goes toflybot.bot.sendwithfrom.typedBy(the server'sowner_display_name; the host copies it, cleaned, to the message source'sremote.typedBy), and the chat draws it as the person (RemoteMessage: name, person mark,via <Bot>), not as the friend's Bot; an unstamped line from the same Bot stays a Bot line (#233 follow-up). A face-roomroom.messagewith the stamp renders as the person (renderFaceEvent(event, { attested }),rooms.postHuman, the name from the authenticatedfrom). On a server that stamps, an unstampedFLYBOT-FACE-HUMANenvelope is not a person's line: it is the Bot's line with the marker unwrapped to plain text, so a Bot cannot pass as the owner by text (#118, #184, #187 still hold). On a server that does not stamp (old deployment), the legacy envelope path is unchanged. Tests:plugins/social/test/social.test.js(typed_by_owner),core/test/remote-chat.test.js,core/test/turn-tail.test.js. - Friend Bot details overview is Auto-send plus a muted Remove this Bot link (not a primary button). The DSH settings-shell chrome around empty local panes is unchanged. The host opens that overview via the compact remote Bot settings sheet (
remote-bot-settings); the sidebar gear is a sibling of the friend'sbt-row, not nested inside it (#166). The Auto-send box holds that setting only, and Remove this Bot sits in its own row below it; the sheet takes focus when it opens and closes on Escape, and inside a tree item the row with its gear shrinks to fit (.bt-tree-item>.bt-row-wrap), so focusing the gear never scrolls the sidebar sideways (#274). - Face-to-face room cards use a short status chip (
data-flybot=face-status): live rooms read open; expired matching windows under Past rooms read ended; candidates show a countdown (12m/45s); closed/left keep their labels (#166). - Join → waiting → leave (#306): Join is enabled as soon as a Bot is picked (a short number answers with the inline
Enter a 6-digit number.; no Bot picked showsPick a Bot first.). The waiting countdown readsm:ss left to joinand ticks every second. The dialog overlay (.fs-dialog-back, z-index above the details panel and the Bot pane) covers the whole window, and while it waits in a room the details panel draws no second waiting card for it (faceDialog); my own Bots' waiting tiles use the roster look in both places. Every Leave (waiting dialog, waiting overview, open room) asks once (Leave / Stay,ConfirmLeave) and lands on the main Bot (openMainBot). In an open room Leave sits on the status-chip line, a row above the Auto-send toggle. The room subtitle counts what it means:2 people · 2 Bots(faceRoomCountin coregroup-settings.js). - QR for a room number (#773): the waiting dialog and the room panel draw the 6-digit number as a QR under it (
data-flybot=face-qr, onepathin ansvg), so a phone camera or another FlyBot can scan instead of typing. The image is computed in the browser — byte mode, error correction M, version 1–10 picking the smallest that holds the text, every module of the matrix — with no request, no third-party image service and no runtime dependency. The payload isFLYBOT-plus the six digits and nothing else: a deadline in the image would re-encode every second and a screenshot would lie, so the countdown line beside it keeps ticking. The code goes when joining ends and as soon as the room opens, so a number on screen is never one that no longer works. Scanning reads the pixels with the browser'sBarcodeDetectorand the camera withgetUserMedia; a browser without the decoder says so before it asks for the camera, a refused camera gets one sentence, and a QR from anywhere else isThat is not a FlyBot room code. Ask for the 6-digit number instead.Typing the number always stays, and a successful scan ends in the samesocial/face-jointhe number field uses — it only fills the field. - Invites into a room that already exists (#773, Bot Social #39): an open room shows Invite by link on its overview (
data-flybot=face-invite), and Create link mints one withPOST /v1/face-rooms/{id}/invites→{ ticket, url, expires_at }. What it draws is the URL itself as a QR, so a phone camera opens the room and FlyBot's own scan joins with the same ticket; nothing is minted in the browser. The card carries the link as text, Copy link, a countdown to the server deadline, and Withdraw link (DELETE /v1/face-rooms/{id}/invites/{ticket}), which drops the image and says nobody can join with it any more. The link expires with the room, so the code goes with the deadline. The input field and the scanner read one field with two shapes (parseInviteLink): this server'shttps://…/r/<ticket>, and a link into a room on another FlyBot,https://…/r/@handle/<ticket>(#852). The face-room path only ever answers the first —parseFaceInviteLinkreturns the ticket for the face shape and nothing for the local one — and a local link is refused by name, in the dialog, in the scanner and at the host route (social/face-join-inviteanswers before theface_room_invitesgate, since that ticket was never this server's):That link opens a room on another FlyBot. It does not join a face-to-face room.Anything that is neither shape isThat is not an invite link. It looks like https://…/r/…, and the 6-digit number stays the way in. Joining isPOST /v1/face-rooms/join-invite, and each refusal keeps its own sentence:invite_not_found,expiredandrevokedeach ask for a new one,room_full,ended→ the room has closed,already_member→ you are already in that room. Both the mint and the join paths are behind the same gate, so an older server never receives a request it cannot answer. The whole card is behind theface_room_invitesentry inGET /v1/platformfeatures: on a server without it the endpoints answer{ unsupported: true }without calling a route that is not there, and the card saysInvites need a newer Bot Social. The 6-digit number still works.Those endpoints (social/face-invite,social/face-invite-revoke,social/face-invite-preview,social/face-join-invite) are listed inSOCIAL_UI_ENDPOINTS, so the UI cannot call what the host does not serve. A pasted or scanned invite asks the room first (#798):social/face-invite-previewanswers{ roomId, name, lead, members, maxMembers, expiresAt }and the dialog draws a card whose first line is the name (a room Bot Social has no name for, which is every room FlyBot opens, readsFace-to-face roominstead of reporting the missing field, #859), the lead,members / maxMemberswhen both are numbers (the count alone when only one is), and the deadline as a countdown; Join on that card runs the join above and Clear empties the field. Pressing Join with link again while that card is already up for the link in the field asks the host for nothing and keeps the card, countdown and all (#824); the guard compares the parsed ticket, not the typed text, while a field edit or Clear drops the card so the next press looks the link up once. The preview is information, never a gate: a 404 or a timeout drops the card and the same join runs with the same sentence;{ unsupported: true }is the one answer that stops there, because the invite feature is off on that server and a join would only reach the same sentence through a route that is not there. The card cleansnameandleaditself with the same rules ascleanFaceName, so a stranger's brackets and markup reach the dialog as plain text, and it strips the untrusted fence markers until the string stops changing, as the server'sstripFaceMarkersdoes: one pass can remove a match and assemble a new one, so a nested<untrusted-<untrusted-filemessage>would otherwise leave a complete<untrusted-message>in the card a person reads (#822). Since #876 that card is not markup of this plugin's own:RoomInvitePreview(core/src/client/room-invite-preview.js, registered on__flybot__because a classic-script plugin cannot import it) draws it from the sameinvitePreviewOf/inviteCountdownrules, andkindrenames only thedata-flybothooks (face-link-previewhere,room-invite-previewin the member-invite dialog), so the two surfaces render one tree instead of two copies. The lead arrives from Bot Social as the member object ({ handle, display_name, owner_name }), not as a string, so the host renders it with the samefaceMemberLabelrule the member list uses —Lead: Chief (SelfTest A2)— while a string answer keeps thecleanFaceNamepath, and an object that names nobody leaves the lead line out (#846). Every Bot picked in the dialog joins (#817): the link path asks the host once per selected Bot, exactly as the 6-digit number does. It has its own stop table, because the invite refusals do not look like the number ones: a ticket that is unknown, expired or withdrawn, a room that has ended and a room that is full stop the loop after the first refusal (those answers cannot change for the next Bot), while a refusal about one Bot alone — already in the room, its token revoked, not found, forbidden — keeps the other Bots going. A Bot that cannot join is listed by name while the Bots that did bring the room up, so one failure never hides the others. - Status chips on room details and Past rooms (
data-flybot=face-status) read Open / Closed in sentence case with readable contrast, and the past row keeps its own spacing between avatars, title, time and chip (#170). - One card-placement rule (#418): a held message's decision sits where that message is. A Bot's reply to a friend has its buttons on the held reply bubble in place (no copy in the dock of that chat or the friend's chat); a message with no bubble of its own (a Bot's
message_friend_bot, a typed composer line held by policy, a face-room line) has its card in the dock of the chat it belongs to, as below. - In-chat approval cards (
flybot.session.dock) are right-aligned bubbles capped at ~460px: Waiting for approval, onefrom → toline, a word-wrapped preview with Show more, and Approve / Reject (one right-hand group) with a quiet Always approve link at the left of the same row, alone above them when the card is narrow (#166, #260, #271). One place per approval (#278): a Bot's direct message to a friend is drawn in the friend's chat once that chat exists (the sending Bot's own chat only until then), never in a Face-to-face room that has the friend in it; a Face-to-face room shows, counts as waiting, and gets a "Waiting for the owner's approval" notice for just the lines held for its own room id. The client also hides that old notice in a Face-to-face room's history. The held text shows once (#306): a Bot line held in a Face-to-face room keeps its text in the card, and the room draws no bubble for it until it is approved (GroupMessage,core/src/client/cells.js, matched byroom.heldLines, #456, and byroom.pendingApprovalsas a fallback); a line the owner typed is never held and stays a plain bubble. A Bot's message to a friend (message_friend_bot) has no bubble in either chat, so the card is already its only place. #456: the held room line lives only on its card (greyed preview) until Approve:onFaceLineanswers{ held: true, approvalId }for a Bot line Bot Social holds,social/decide-approvalcallsflybot.rooms.settleHeld, Approve makes it a room bubble and Reject a grey<Bot> · Not sentline, with noRejected.note in the face room. A decided card leaves the dock at once (no Approved / Rejected stub, any card kind). A face card'sfromis the room's own #384 label for that local Bot (Researcher (Alice) → the room,faceFromLabel).applyFaceSnapshotposts a human or system event for a round only when the core room logged it now (applyFaceLinerefuses an event id it has), so an event read twice starts no second round. - On a phone-width screen (below 640px, #362) the hold card's Approve / Reject are 40px touch targets at the right of their row; wider screens keep the compact 28px buttons.
- The composer Message {name} label is held by core
HeaderPillthroughcomposer-label.js(placeholder CSS var plusaria-labelanddata-placeholderon[data-composer-input], from the first paint, restored on cleanup; #419, #499); chat-header tok jargon stays in pristine DSH / ds-bot.
list/pollmust not persist or sendas=@. An empty, missing, or literal@handle stays empty; GET omitsasunless it is a real Bot handle. Rooms first seen fromlistseedasBotfrommy_bots[].handle(or a local member already in the snapshot). A stored bogus@is healed, andface_room_not_foundfor a still-listed room repairs local meta (clearas, retry withmy_botsor noas) instead of warning on every poll.- An event that arrives without an
event_id(#1045) is keyed by the line itself —faceEventKey, from the event type, the sender handle, the server time and a bounded digest of the text (seensits in the owner's settings, so the key never carries the line itself, #1062) — so a poll that is handed the same untracked line back does not post it into the room a second time; a line with a real id still keys by that id, and an event with neither an id nor anything to name it by keeps no key. Because a server may stampcreated_atin whole seconds, the key also carries the event's own cursor, so the same sentence twice from one sender inside one second is two lines (#1063). When the server stamps neither an id nor a cursor, the key carries the event's place in the snapshot instead: that separates two identical lines read in one snapshot, but it cannot tell a new identical line from an old one once the snapshot slides — a line landing on a place already seen keys the same and is dropped.
- Syncing a held outbound draft into chat must not call
remote.chats.deliverwith a home-level handle (@box.home) or with no session. Resolve a listed friend Bot under that home,chats.openthat 1:1 if needed, then deliver so the waiting line lands. If no Bot/session can be opened, skip with a clear log; never throw into the poll loop.
- #136: A taken
displayNameat onboard retriesName2… rather than refusing to start. Your handle is shown in Add friend (#181). - #137: Owner-chosen Bot avatar/color is sent with MCP
update_bot_profile { bot_id?, display_name?, avatar?, color? }(RESTPATCH /v1/bots/{id}/profile) whenever a local Bot's look changes, on startup if not yet synced, and again for every new identity of that Bot (the synced record names the identity: a room-only identity is released after its last face room and the next room registers a new one, #182).avataris a preset id, a single emoji, ordata:image/(png|jpeg|webp);base64≤ 64KiB;coloris#rrggbb. Remote look is read fromlist_remote_bots/list_friendspeer /list_bot_grants/ face-roommembers[](avatar,avatar_kind,color,avatar_updated_at) and shown in the main sidebar list, group member lists, waiting tiles, the picker, and chat headers. Cache key isavatar_updated_at; missing look falls back to the handle-seeded palette. What is sent is the look the owner's home draws (shownLookinlook.js): a Bot with no stored shape or color gets the id-picked default of corecharacters.js(defaultShapeOf/defaultColorOf, kept equal to core by a test), so the other home shows that look and not a handle-seeded one; a stored image or emoji is sent as is, image before emoji before shape asBotMarkdraws them. A new identity gets its look beforeregisterOncereturns, so a face-room join never shows peers a seeded look first (#182). In the waiting dialog my own Bot is found by its room record (remote: false, local id), the Bot list handle, or thejoinedAsof this join (mineBots), and always uses the roster look (#182). Face-room events carry no look —members[]is the source. All fields are optional so an older Bot Social is unchanged. - #138 / #161 / #164: Accepting a Helper invite grants Helper only. The joiner picks a talk-through Bot (not shared). Success copy is
{person} shared {Bot} with you.Manage access listsSelfB1935 (@selfb1935.home); a labelgrantPersonViewalready made keeps its display name whensyncOutgoingGrantsreads the normalized grant again (#275), and only a bare handle falls back to the lowercase home.grantPersonView/normalizeGrantread Bot Social's object-shapedgrantee(handle,owner_display_name) and neverString()it ([object Object]→@objectobject). Core grants store a canonical@handle(never decoratedChief@…). Friends' Bots drops reverse-friends of invites this home created (outgoingPeerHandles) and room-only remotes, but a handle the joiner explicitly received (inboundBots[handle]) always shows (#178): the talk-through grant on the joiner's own Bot names the shared Bot as grantee and must not hide it, nor appear in that Bot's People with access (isTalkThroughGrant). Bot Social'slist_bot_grantsmarks that grantreply_only(bot-social #23), whichnormalizeGrantkeeps asreplyOnly(withside); when present it is the only rule (#495):replyOnly: trueis talk-through andreplyOnly: falseis always a share, so a share in a relationship both homes recorded as joined (two friends joining each other's invite, which Bot Social regrants into the first relationship) stays listed and revocable on both homes, and a confirmed share is never kept apart as a possible talk-through grant inaccessRows. Only a server that does not send the field falls back to the guess: grantee ininboundBots, and either a relationship this home joined or, for joins from before that record, the home's first relationship with the Bot invia.social/join-inviterecords the joined relationship (settings.joinedRelationships[rid]), because Bot Social lists a friendship's relationships (relationship_id= the first,relationship_ids= all) without saying who joined, and both sides of a relationship hold a grant naming the other's Bot: matching any ofrelationship_idswould hide a real share when a Bot shared with us later joins our invite (#383: an already-shared Chief that joined the friend's Helper invite listed the friend twice; a join made before the record, in a second relationship, still shows twice until it is joined again).social/accessreturns one row per Bot (#995):accessRowskeys a grant that names a Bot (itsmemberdiffers from the person's home) by that Bot, labels it from the name Bot Social sent for it inlist_bot_grants(normalizeGrantkeepsbotName/ownerName; a server that sends no name falls back toOwner (@bot.home), never two identical person rows), and the row's Revoke takes exactly that grant — so one friend running two Bots is two rows and taking one back leaves the other. A grant that names no Bot keeps the per-person row it had, and a row still carriesgrantIds, whichsocial/revoke-grantrevokes one after another: grants of one Bot to one person (two invites) are that Bot's row, and #387's own-row talk-through grant is never in another row'sgrantIds. A grant to a Bot shared with us in a relationship not recorded as joined (possibly the talk-through side of an older join) keeps a row of its own and is never in another row'sgrantIds, so the person's Revoke cannot break that chat. Every revoke runs in a per-Bot fence: asyncOutgoingGrantswhoselist_bot_grantsoverlapped it does not store that list and resolvesfalse(fail closed), and the roster refreshes once after the fence. The joined relationship and the offered Bot are recorded right afterjoin_invite, before the owner-approval follow-up. Join copy names the person (owner_display_name), not the shared Bot.social/join-invitemarks the offered Bot inbound before its first roster refresh (#228): the grant sync that refresh starts then already sees the talk-through grant as talk-through, so no core grant on the joiner's Bot names the inviter's Bot and the re-filter does not drop the shared Bot until a later refresh (it shows with the success toast). Bot Social's realjoin_inviteanswer has nobotfield:inviteris the invited Bot itself (a member handle such as@chief.bob.home;joined.bot,inviter.botandoffered_botare the older shapes, withinviterat the home). A member-levelinvitercounts as the offered Bot, so it is marked inbound before the refresh and the toast names it (#342; without it #303's awaited sync stored the server's talk-through grant on the joiner's Bot as the joiner sharing back, and the re-filter hid the inviter's Bot and listed the inviter in the joiner's People with access). A talk-through grant is never sharing: the joiner's Bots stay default-deny after a join. A Bot that appears on a roster this home already fetched and is not ininboundBotswaits for the grant sync before the roster is published (#303; "already fetched" is therosterFetchedflag, so an empty roster of a home with no friends yet counts and the first friend's Bot waits too; the very first fetch does not wait, #92, asregisterAllsynced grants at start): the Bot that just joined our invite would otherwise be published for a moment, get a chat record on this home and be marked gone seconds later (the next load opened that dead chat). The awaited sync classifies talk-through grants against the fresh roster (syncOutgoingGrants({ roster })), not the previousremote, so a refresh that also brings another new Bot cannot store a talk-through grant as a core grant.syncOutgoingGrantsresolvesfalsewhen any Bot's grants could not be listed; the publish then fails closed: the unseen Bots are withheld from this publish and from the background re-filter (withheldRemotes), stay unseen for the next fetch (which syncs again), and appear only after a sync succeeds. Bots already seen and Bots shared with us never wait, so the usual refresh still re-filters in the background. A late grant re-filter of an older roster fetch never overwrites a newer one (fetch counter, not a timestamp). Fetches are numbered when they ask, not when they land (#224): alist_remote_botsanswer that lands after a newer one (the background refreshhide-remotestarts, a slow link) is dropped, so it cannot hide a re-shared Bot again; the grant re-filter applies the same answer again and does not count as a second absent fetch toward forgetting a local hide. An open People with access re-readssocial/accessevery 5 s while visible, andsocial/accesssyncs only that Bot's grants (syncOutgoingGrants({ only })). Bot Social itself still records the talk-through side of the relationship as a grant on the joiner's Bot (list_bot_grants); FlyBot filters it on both sides (#161, #178). On upgrade, remotes already onlist_remote_botsare seeded intoinboundBotsonce so reciprocal friends are not dropped — except the Bot that joined our invite and face-room-only remotes. Face-room remotes leave Friends' Bots when the room is left; existing friend handles are not reclassified as room-only. - #139: Remove this Bot asks once, inline in the friend Bot's settings (
Remove Helper (Bob) from your list? Your own Bots stay shared. Bob can share it again.· Remove · Keep, #230, #496; never the browserconfirm()); when the roster still lists another Bot of that friend, the question names the link that keeps the friend instead of leaving the surviving row unexplained (Bob stays a friend: still shared through Writer., #1001; the names are the roster's own, so a friend with no other Bot listed keeps the sentence above), and the friend's Auto-send checkbox says what it covers (Auto-send my Bots' replies to Bobwith a one-line hint, #417) and callsdecline_bot_grant { grant_id, bot_id }(DELETE /v1/remote-bots/{grantId}) so the owner's Manage access drops it.grant_not_foundis treated as already removed. An older server (404 on the tool/endpoint) falls back to the local hide. Re-joining after a decline may returnregranted: trueon the existing friendship; live access still answers409 relationship_exists; a spent invite is410 invite_unavailable. Copy stays English (“You already have Helper (Bob).”, “This invite was already used.”). Re-joining an existing friendship unhides only that invite's Bot/peer home. - #496: re-accepting an invite for a Bot this home already has is a result, not an error. On
409 relationship_existssocial/join-inviteanswers{ alreadyHave: true, status: 'active', bot, botName, ownerName, restored: [{ handle, name, owner }] }. Bot Social's 409 names no Bot, so the plugin reads the public invite info (GET /v1/invites/{code},inviteInfoinmcp-client.js, sent without a token) for the invite's Bot and owner.restoredlists only Bots that were locally hidden or declined and are listed again after the unhide. The panel shows neutral textYou already have Chief (SelfA1233).(Bot first, owner in parentheses, #384), orChief (SelfA1233) is back in your list.when something really came back; never red. Without a home (legacy) the join still throws, with the neutralYou already have this friend's Bot.. #1000: that path — and a/join_invitethat answersregranted: trueon a relationship that already exists — also grants the joining Bot back to the inviter's Bot (grant_bot_to_friend,chat.public), because Bot Social applies neither half of an invite again on an existing relationship: the inviter's first line to the joining Bot was held as an unknown recipient until that side shared again. It is the half only the joining home can create, so it is best effort — a server without the tool or REST route leaves the accept successful andsharedBackabsent — and the Add friend line saysYour <Bot> is shared back, so they can reach you too.—Your Botwhen the server names none — instead of onlyYou already have …. Bot Social takes the friend by bot id, never by home (grant_bot_to_friendsendsfriend_bot_id/friend_owner_id, and the REST body keeps only those two), so when the invite info names nobot_idthe plugin resolves it fromlist_friends(peer.bot_id, the fieldfriendsOverviewalready reads): first the Bot whose handle is the one the invite named, else the only active friendship of the inviter's home. A friend running two Bots under one home is not granted at all when the invite names no Bot, andsharedBackstays absent, so no Bot the invite did not name is given access (#1024). It names the joining side because both homes can run a Bot with the same name: a bareChief is shared backreads as the friend's Chief being shared twice (#1026). A name the owner already gave asYour …is printed as it is, so a Bot namedYour Botkeeps its oneYourinstead of reading asYour Your Bot(#1037). - #140: Auto-send defaults off. Accepting a friend request uses that relationship's peer home for Auto-send, not the first roster entry. The owner sees Approve / Reject in the chat, not “Waiting for the owner's approval.” Composer drafts are labelled You.
- #136 (suffix): Onboard retries truncate the sanitized handle body so
Name2/Name3still change within the 32-characterhomeHandleOflimit. - #666: A friendship can be removed outside this plugin — the agent's
remove_friendtool, or the Bot Social console — and the cached roster used to keep that Bot for up toREMOTE_TTL_MS(30 s). A send in that window was accepted and held, and approving it lost the line: Bot Social answeredsend_as_member: no active relationship between these bots. The send policy now asks Bot Social about the friendship itself before it asks the owner:friendshipActivereadslist_friendsfor the home and for the sending Bot's own member identity (a per-Bot friendship, #420, is listed there), accepts only an entry whosestatusisactive(or absent) and whose peer handle belongs to that friend's home, and caches the answer for 2 s so a burst of lines costs one call per friend, not one per line. No answer at all (an older server, a link that is down) keeps the roster's verdict: the check catches a removal, it does not invent one. When the friendship is gone the send is denied with the usual is not a Bot a friend shared with you reason, above the hold and Auto-send branches, andforgetFriendHomedrops that friend from the cached roster and republishes, so the list and the picker stop offering a Bot that can no longer be reached. Room lines (via: room/room-share) skip the check. The other half is the cache: arelationship.changedevent in either direction now invalidates the roster (fetchedAt = 0) and every cached friendship answer (forgetFriendStatus), so a send in the same millisecond is judged on a fresh answer rather than the two-second one — the earlier version only dropped the roster, and a send 500 ms after the event still read "still friends". Two more holes close with it (#666 review): an answer without afriendsarray is not evidence of a removal (it keeps the roster's verdict, or one server that changed shape would unfriend everyone), and releasing a held card re-askslist_friendswith{ fresh: true }— bypassing the two-second answer at the moment the owner approves — so a card held before the removal fails with the is not a Bot a friend shared with you reason instead of being handed to a server that refuses it withno active relationship. The probe also carries its ownFRIEND_CALL_MS = 1.5 sdeadline throughcallTool(name, args, { timeoutMs }), because it runs on the send path and must not add the client's 30 s deadline to every line when Bot Social is down. Tests:plugins/social/test/social.test.js(#666, including the +0 ms and +500 ms cases).
- Where it comes from.
config.serverin the profile patch is the default. A server picked in Settings → Bot Social is kept inflybot-social-server.json(mode 600, next to the credentials file) and wins while it exists; choosing the default again removes the file. Nothing configured anywhere behaves as before (the start fails withserver URL is required,Social is not running: …; the page says No server set). The page readssocial/server(a read;{ server, source: settings|config|none, defaultServer, state: connected|connecting|not-configured|error, handle, error, canChange, known[], platform? }: never a token) and callssocial/server-check(it makes the host fetch an address the caller names, so it follows the write rules likesocial/server-set). - Validation.
parseServerUrl:https://only,http://just forlocalhost,127.0.0.1and[::1]; no user name or password, query or fragment; at most 200 characters; an address without a scheme getshttps://(http://for those local hosts).probeServerGETs<server>/v1/platformwithout credentials, 8 s deadline,redirect: 'manual'(a redirect is refused, so the saved address is the checked one); the answer must look like Bot Social (aname, aplatform_keyor afeatureslist) and itsfeaturesare shown (cleaned, at most 40) in words (featureLineinclient.js:typed_by_owner→ typing as you,mentions→ @mentions,presence→ online status; an unknown id is only counted, and every raw id is in the line's tooltip, #421).social/server-setrepeats both before it touches anything, so a UI that skipped Check gains nothing. - A server is an identity. The default server keeps its files. Any other server gets
flybot-social.json.<tag>(credentials; the same suffixstartSocialalready used for backups,tag= first 8 hex of the sha-256 of the normalized address) andflybot-social-settings.<tag>.json; a different server never reads another's file, and no credentials file is ever deleted or rewritten by a switch. The per-Bot parts that live in host state (thememberidentity, theautoApprovemarks and the person/room grants, which are Bot Social ids and handles) are parked inflybot-social-bots.<tag>.json(no token) and cleared before the new server starts, so a stale grant to@bob.homecan never apply to a different Bob on the new server; switching back restores them and the saved credentials, without a second onboarding. The old server's friends' Bots leave the list when the new server's first roster fetch replaces it (remote.publish); face-to-face rooms already hosted stay as local transcripts, but nothing polls them. - Applied live, with a way back.
social/server-set(needsconfirm: true; a write, so a read-only host and a multi-member team refuse it) stops the social layer, parks the old server's Bot identities, saves the choice, restores what the new server had, and starts again (first start onboards). If that start fails or takes over 45 s, the previous server's files, identities and choice are put back, it is started again, and the error says so; a start that finishes late is disposed. A disposed instance never re-arms its poll: a face poll already in flight whendispose()runs cannot install a new interval on the discarded layer, so it never keeps polling (or delivering from) the server it was built for. When the switch succeeds and the address really changed, the previous server's hosted face-to-face rooms — open or still forming (candidate) — are markedlefthere — a read-only past room whose composer is gone, since the new server cannot reach them. Renaming this home on the same server is not a switch: that server still reaches those rooms, so only a new address ends them (the room and its composer stay as they were). Switching back does not reopen them: a room this home left stays a past room (faceSnapshotPast, #182), and a line to it is refused (bot/room-ended). The Settings page says plainly that this is a new identity, that friends and sharing stay on the old server, and that the old sign-in is kept as a backup and never deleted. Tests:plugins/social/test/social.test.js(Settings server,server controller,parseServerUrl,probeServer).
Settings → Bot Social lists friends and shares once the server is connected (state: 'connected'). Until then the page is only the server form: a missing, connecting, or failed server draws neither section. Friends reads social/overview and shows each accepted friend (name, handle), each incoming request (accept it in Add friend), and each outgoing request that is still waiting. A name is one line: controls and angle brackets are stripped and the text is capped at 80, so a value that skipped the host's cleanText still cannot break the row. A failed read says so and is not drawn as an empty list. Add friend closes Settings and opens the sidebar panel the + menu already opens (openAddFriend); that + row stays. Sharing lists this home's Bots from social/status (the roster's own Bots, never a friend's, when that read fails or comes back empty) with the Main Bot first. Each People with access button opens that Bot's settings (openSettings('bots', id)) and scrolls to ManageAccess, where Revoke stays; opening Bot settings any other way does not scroll. Tests: plugins/social/test/social.test.js (#1074).
Share with a friend reads the roster it already paints from (remoteBots()), and a row whose Bot is no longer in it stays drawn with removed: true and the reason <name> was removed from that home. Sharing with it cannot reach anyone.; its Share is disabled, and a click that still reaches social/share-with-friend is refused with bot/removed-bot before any grant is sent (a bot/ code is the only shape the channel passes through unchanged, core/src/host/channel.js:24, so the refusal reaches the panel as a code and not as bot/refused), so the panel never reports a share that reaches nobody. removedOnRoster decides it: both the row's peerId and its handle must be known (a home missing from the cached roster, or an empty bots list, decides nothing and shares as before rather than guessing), and a hit on either the id or the handle means the Bot is still there, because one id alone cannot tell an old server's renumbering from a removal. No extra round trip — it reads the roster the panel already paints from. Tests: plugins/social/test/social.test.js (#1128).
social/join-invite runs the box text through parseInviteInput (social.js) before join_invite. It takes https://host/i/CODE, the same without the scheme, a link whose /i/ is missing (host plus one bare token), a bare code, or the whole copied invite text (the first token with /i/ or a URL wins), and sends only the code: Bot Social's join_invite looks up a code (it strips just a leading …/i/), so no host is ever built or added. Copy invite link hands out the server's invite.url through inviteLink, which rebuilds origin/i/CODE if a server ever answered a url without /i/; Bot Social itself always emits /i/CODE (inviteUrl) and serves only that route, so a link without /i/ comes from hand-trimming or a client that dropped it. A pasted host that is not config.server is kept as host; a not-found then says the link may come from another server. mapJoinInviteError turns invite_not_found, expired / revoked / used invites, an own invite, blocked and rate_limited into one short sentence each ("This invite link wasn't found. It may be incomplete, expired, or already used. Ask your friend for a new link."); anything else passes through unchanged. The client (plugins/social/client.js) shows those sentences as written (#347): errorText relabels a message as "This invite was already used" only for the raw server words (invite_unavailable, invite is used, invite_used), never for the not-found sentence, which itself mentions "already used" as one possible cause; run() keeps its "… failed:" prefix for every other message and drops it only for the "already used" sentence; the neutral already-have sentence shows without error styling (#496).
- Signal. Bot Social's
presencefeature (GET /v1/platformfeatures; the same probe astyped_by_owner/mentions,serverPresence()): MCPset_presence { bot_id, state: thinking|idle, to | room_id }andlist_presence. Ephemeral and content-free: handle, display name and state only, TTL 20 s, 30 writes/min per Bot, never stored. The server allows it only between Bots with an active relationship (direct) or members of an open room, and shows an entry only to the other side. Plugin optionpresence: falseturns it off here. Against an older server (feature absent, or an unknown tool) nothing is sent, polled or shown. - Send (
src/presence.js). On the feed'sturn.start, a turn in a Bot's own chat triggered by a friend's line (trigger.from.kind === 'remote', to that Bot's handle) or a turn of a Bot in an open face-to-face room (to that room's server id) saysthinkingfor the Bot's Bot Social identity (social.member.bot_id), repeats every 10 s (cap 60 beats), and saysidleatturn.end. The feed givesturn.startatriggeronly from a delivery recorded before it; when a friend's line is recorded afterturn.start(the turn has notrigger), that turn waits inunclaimedand the friend'sexchange.deliveredon the same Session starts the beat (#332), but only when it is recorded withinCLAIM_MS(5 s) ofturn.start: a line that reaches a Bot already busy with something else is answered in a turn of its own and does not announce the running one. A turn the user started (it has atrigger, so a friend's line that arrives during it does not claim it), a turn in a local group, a Bot with no identity, a read-only host, and a turn that ended while the feature probe ran send nothing. A refused signal (no_relationship,face_room_not_found, …) stops that turn's heartbeat;rate_limitedis skipped. - Receive.
social/typing(host endpoint) is asked every 2 s by thePeerThinkingdock of an open face room (a friend's 1:1 chat no longer draws this line: the host's activity row, with the friend's Bot mark, covers it from my send until the reply lands, #443); each ask keeps a 2.5 slist_presencepoll alive for 15 s, so nothing polls when no social chat is on screen. Expiry is measured on this host's clock from the server's remaining time. A Bot's message (remote.chats.deliver, face-roompostRemote) ends its line at once and the lagging server entry is ignored for 4 s. - UI. One quiet line above the composer (
flybot.session.docksocial-thinking, below the approval card):Coder is thinking…,A and B are thinking…,A, B and 2 more are thinking…, in the corebt-shimmerstyle of the activity row. In a direct chat it uses the chat's own Bot name; in a room the participant's name, with· Ownerwhen two share one. It shows only for that chat's Bot or that room, never for a left/closed/candidate room. Permissions are unchanged: the signal neither grants nor bypasses anything.
- One term, Auto-send, on the card, the toast and the setting. The held-message card's quiet link reads Auto-send from now on (it was Always approve): it approves this card and turns on Auto-send for that pair only, my Bot that wrote it → that person. The toast names both and the place to undo it:
Auto-send is on for Chief → SelfB1238. Turn it off in Chief → Sharing → People with access.A face-room line keeps face Auto-send (Turn it off in the room overview.). - Stored per pair like per-Bot grants: the local Bot's field
autoApprove[home](social/auto-send { botId, home, on }from the switch, or{ approvalId }from the card link, where the host reads the sender Bot and the recipient's home from the hold itself;approvalForreads it first). An older home-wide entry (settings.autoAccept[home]) still reads as on for every Bot of mine to that person until one is changed. Bot Social'sset_auto_approve { relationship_id, auto_approve_chat }is set on that Bot's own friendships with the person; a friendship held by the home identity is turned off only when no other Bot of mine still auto-sends there. The ids set this way go intoserverAutoApproved, so the home-wide catch-up sync leaves them as chosen. The owner's switch or link is the consent; the confirm code is used by the plugin in the same call and never asked of anyone. - Visible and reversible where the approver is: each person's rows in a local Bot's Sharing → People with access (
social/accessrows carrypersonandautoSend) get one Auto-send checkbox (default off, on the first of that person's rows since it is one pair setting, #995) next to Revoke; off brings back the card on the next reply. Ordinary group lines leave without that switch (#1118): a room envelope Bot Social held only as suspected injection (hold_reason: policy, no DLP reason, no risk flag, no file, and no instruction-override in the text) is cleared because the room is the consent (roomPolicyClears, #1113). The switch still covers a 1:1 reply. An instruction-override, a DLP category, a risk flag, a file, and the samepolicyhold on a direct message still wait. - The friend's Bot settings checkbox is for all my Bots: Auto-send my Bots' replies to Bob (
social/auto-accept, home-wide) with the hintYour Bots' replies to Bob go out without asking you first. What you type is not affected.Setting it clears each Bot's own choice for that person; it reads on only while every Bot of mine auto-sends to them (social/settings). - A Bot Social hold no Bot of mine wrote turns nothing on (
Sent. Auto-send was not turned on: no Bot of yours wrote this.). Where this section and the card paragraphs above differ (Always approve,social/auto-acceptper friend,Auto-send on for X → Y), this section is current.
faceMemberLabel (face-room join/leave lines and member labels), the waiting tiles (FaceTile title) and a face room's thinking line (every remote Bot with a known owner, not only on a name clash) all read <Bot> (<owner>), the same as the core's remoteBotTitle; a friend's Chief is Chief (Bob). faceOwnerName (#526) reads the owner for those labels, the leave line, a person's typed line and viewFaceMember's ownerName (the seat the room, the waiting tiles and the host read): an owner name Bot Social made up from an install id (FlyBot 682b8a43) gives way to the username in the handle (Reviewer (flybot682b8a43) joined, flybot682b8a43 left the room), the same rule as the core's ownerNameOf. Approval cards name the receiving person instead (#418, #498): localHoldView and ChatApprovalDock take the hold's recipientName, else friendPerson (the friend chat's owner through the core's shared formatter, globalThis.__flybot__.names.friendBotOwner), so a card reads Chief → UiA1415. social/friends adds each Bot's owner / homeName (additive), and the composer @ rows from this plugin (friendRows) sit in the core's Bots section, skip handles the core already lists from roster.remoteBots (no separate Friends' Bots section), and insert that name — @Coder (Bob) , the row's name, with the handle as description and as the fallback text on a host without __flybot__.names.remoteMentionLabel — so the token the row shows is the token the bubble chips (#967).
social/overview (friendsOverview) asks list_friends for the home and for every active member identity, as setAutoAccept does, because a per-Bot invite hangs the relationship on the member (@chief.alice.home ↔ @chief.bob.home), not on the home. Each relationship is listed once (by relationship_id); a row found through a member carries that local Bot (botId, botName). Pending requests are gathered the same way, so Add friend shows per-Bot requests too. A member whose read fails costs only its own rows; a failed home read still fails the call. Owner decision (2026-10-19): a grantee's lines stay in the Bot's own chat (invariant 8) with no thread per person, and a friend with no Bot shared to me gets no sidebar row; the Bot's row turns unread for a friend's line instead (core friendLineAt, see core-client).
- Expired matching (
candidatewhoseexpiresAthas passed) and a peer leaving the last remote seat move the hosted room to Past rooms on the next poll (rememberPastFace, statusclosed/left). The local room stays so Open still works; Leave is not shown on those cards. Face-room names areFace-to-face · Alice, Bob · 482913(people plus the 6-digit code, orOct 16 21:40when there is no code) so two rooms with the same people stay distinct. The literalFace-to-face roomis not a fallback. The number printed there and on the waiting card is the six digits only (#1005):faceWaitCodeandshownFaceTitlerefuse a stored value that is not exactly six digits (1234567never reads as123456) and fall back to the code the owner typed, or to the creation time. - Ended-room state and titles (#304): when every other home's Bots have left an open room, this home marks it Closed (it did not leave;
faceSnapshotPast→closedwithpeersGone) and then leaves it on Bot Social itself (leaveFaceRoom({ status: 'closed' })), so it stops polling the room and the room-only identity is released (invariant 4). If that leave fails (timeout, 5xx), the next poll that still finds the room open on Bot Social tries it again. A room that drops off Bot Social's list without this home leaving is the same end (#1070): the hosted room is markedclosedso the sidebar's active list drops it, and Past rooms keeps that one row (aleftroom stays Left). ItsendedAtis the snapshot'sclosed_atwhen Bot Social sent one, so both homes show that time; otherwise the first time this home noticed. The owner who left keeps Left:rememberPastFacenever turns aleftrow intoclosedwhen a later poll finds the room gone, and their room readsYou left this room.while the others read<Name> left the room. Room closed.That status line is the one leave signal when the room closes (#1103): the shorter<Name> left the roomis not also posted into the transcript. The number a room was joined by goes with everyupsertFace(joinCode) and core keeps it onroom.face.joinCode, so the title staysFace-to-face · Alice, Bob · 482913through polls, Leave and the end; Past rooms show that same title (people rebuilt from the row's members when it has them, the number or stored time kept; with neither, the creation time, never the joining deadline). A left or closed room's details are read-only: the status chip only, no Auto-send and no Leave. Past rows list everyone who was in the room (#304):faceRoomTitletakes the snapshot's members plus every participant the hosted room ever had (like the core title), andviewHostedFacefor a closed or left room lists all participants, marking who had leftleft: true(Open on a dropped room seats only the others again); a row stored before this is refreshed from its hosted room. Each row shows its time (data-flybot=face-time):endedAt, set once byrememberPastFace, else the start (or creation) time; today reads14:05, an earlier dayOct 19, and the hover text has the fullEnded Oct 19 14:05. The fold remembers whether it is open across a reload (localStorageflybot.social.pastRoomsOpen). - The face-to-face join field for the room number starts empty with a light
6 digitshint (normal letter spacing, 13px), not a000000that looks pre-filled (#173). A number that is not empty and not exactly six digits keeps Join disabled and shows Enter 6 digits under the field (#1052). Six digits clear that line and enable Join once a Bot is picked; an empty field shows nothing. - Round-3 polish (#173): Accept invite is no longer a separate + row; Add friend opens the single join panel for both a handle and an invite link or code. Escape closes the Add friend and Face-to-face panels, the
000000code is a placeholder, Past rooms and friend rows use shorter copy, and Bot settings group invite and access under Sharing / People with access. - Open on a Past room calls
social/face-open(plugins/social/src/social.js): it returns the existing local hosted session or recreates one from the saved room, so a peer home can open a room that already left Bot Social (#171). Titles come fromplugins/social/src/face-title.js, with participant owners in stable handle order, so both homes show the same name (#172). - Inbox and face poll share one timer:
facePollMs(default 4s) while any live face room exists, elseinboxPollMs(default 15s). While a FlyBot window is on screen (flybot.watched(), #497) a second timer also reads the inbox at thefacePollMscadence, so a friend's first line, the held reply's Waiting for approval card, the Inbox count and the row's unread badge reach the open window within a few seconds instead of up to 15 s later (the self-test's "only after a reload"). It skips a tick the shared timer already covers (a live face room) or that follows an inbox read by less than half its period, and it reads nothing with no window open, so Bot Social sees the faster reads only while someone watches. The open face overview keeps polling about every 3s after Enter chat, not only while matching. The social strip still refreshes past rooms about every 5s. - Sidebar + holds Face-to-face room, Add friend, Accept invite, one-click Copy invite link (right-click a Bot), and Copy my handle. Friend requests is not a duplicate + row. Per-friend Auto-send is on that Bot's settings; face-room Auto-send is on the open room overview — both say Auto-send ("Don't ask each time."). Hold labels resolve the real Bot name (
Chief → the room), neverbot_01….
- The social strip polls
social/face-roomswithpoll: trueabout every 5s, so a newly ended room appears under Past rooms without a reload. Live rooms are hosted in the core sidebar. While matching, the status chip is a short10m/joininglabel so the name and Start fit; after Enter chat / start the hostedroom.face.statusisopen. After Leave, only Past rooms remains — no empty "Face-to-face rooms" header or Auto-send. Waiting tiles use the serverlabel(Chief · BoxB). A snapshot member that is new on this side still gets a join line when the join event is before the flooredaftercursor. Owner-typed lines in the open room fan out overonFaceLine: Bot Social face messages are bot-authored, so FlyBot sends aFLYBOT-FACE-HUMANenvelope as the owner's local room Bot. The owner's click/type is consent to send; apending_approvalstays in the in-chat card until Approve / Always approve / Reject unless face Auto-send is on (#159). The other instance applies the line asauthorKind: 'human'. The displayed sender is the server-stampedfrommember, never the envelopewho. Owner cards unwrap the envelope to the human text. Incoming peer/human bodies are stored fenced in the room log (#98). Join still does not grant the Bot to friends. The host still publishes listed remotes into the main sidebar.
- Bot Social has no FlyBot-group record (
list_conversationsis 1:1; inbox ismessage.receivedafter a send). The plugin therefore carries aFLYBOT-ROOM-SHAREenvelope onsend_as_membertext.pollOncecallsflybot.rooms.applyInboundbefore 1:1 chat delivery, so the Bot's owner sees the group as soon as they are added.via: 'room'/via: 'room-share'skip the social outbound hold (the add-to-group click is consent), and a send the host sees come backpending_approvalclears Bot Social's own blanket hold by itself (autoSendRoomEnvelope,holdIsBlanket()onhold_reasonof #865, the allow-list of #737): the invite and the room's lines leave with no card, while a hold with a finding of its own keeps its card. An ordinary group line held aspolicywith no DLP reason, risk flag, attachment, or instruction-override is cleared the same way, including the invited Bot's reply and including when the pending list lagged and the next poll finishes it (#1113, #1118). That poll decides only anoutbound_messageroom envelope; a face-roompolicyhold still waits for the owner. Pair Auto-send is not required. A direct message stays on the blanket rule alone. That card reads one sentence about the envelope (Invite <Bot> to the group "<name>",Group message to <Bot>: <line>), never the JSON or a localsession-…id (#847). A file, a DLP reason, a risk flag, or an instruction-override still waits on its card. A roster fetch that no longer lists a remote, orsocial/revoke-grant, leaves those rooms / hides inbound projections. Revoking a grant also leaves shared face rooms that still seat that grantee (leaveSharedFaceRooms, before the grant delete): the only server call is leaving this home's own seats, so the other home sees a normal departure and does not stay on the waiting lobby. A leave that fails is warned and the revoke still completes. An open room (one already started) whose next snapshot iscandidateor drops every other home is closed withpeersGoneinstead of returning to Waiting for others; a room that was only ever a candidate lobby stays one until its window runs out. Peer seats are soft-left (leftSeq/leftAt), not deleted (#1072).
- Bot Social #60 keeps a local room's invite tickets (
/v1/local-room-invites, featurelocal_room_invites).face-rooms.jscreateFaceRoomApi().localInviteshasmint/preview(with this home's token the minter reads every home that joined,claimers, and a claiming home its ownclaimer) /claim/revoke(ticket, { endClaims })/endClaim(ticket, home)(Bot Social #65: a link has no deadline and admits anyone it is given to until withdrawn), on the same connector token as the face-room routes; a refusal keeps Bot Social's code (expired,revoked,already_claimed,own_invite,invite_not_found, …).social.jsregisters them as the host's seam (flybot.rooms.serveInvites);verifyis the preview read, andmintanswersundefinedon a server without the feature, so the host keeps its friend-only link. The Members card's Invite by link row (LocalInviteRow, #876, #892) mints with Create link / New link, shows the QR, the link, Copy link and Withdraw link; a link has no deadline, so it says Works until you withdraw it. (a link an older server dates counts down), and when earlier links of the room are still open it says how many and offers Withdraw all. On the joining side a preview withclaimedis this home's own claim, never someone else's, andjoinedis read before it:joinedis true when this home already holds the room — a seat of that link, or a room the host added a Bot to by add-member (#1176, recognised through the preview'sroomId) — and the dialog then says This group is already in your sidebar. with no Join card and no claim sent, because nothing is left to ask. With a claim and no room here (#899) it says Waiting for to let you in… and keeps the card, so Join group claims again (the same Bots are an idempotent claim) and knocks again. - The connector exception is one pair at a time.
ticketPair(msg)passes a send only when it is a room share (viaroom/room-share, never a plain line, no files), from the exact local Bot, to the exact handle, under the room id inmeta.scope, asflybot.rooms.ticketPeers()lists it (a seat here from the Bot its link named; on the guest side, the seated Bots to the room's inviter, and the claim's accepting knock), and only when the text is the host's whole envelope (FLYBOT-ROOM-SHARE\n{json}) for that same room id. The roster sync (leaveMissingRemotes) never drops a seat a link admitted (admittedBy: 'ticket'): its link decides, not the friend list. Such a send skips the roster and the hold (the link is the owner's consent, like adding a friend's Bot to a group) and goes out assend_as_memberwithroom_id= that scope, which Bot Social's room reach carries and refuses once the link is withdrawn or given back. Every other send to someone off the roster keeps the deny sentence, except auser-mentionof a Bot that still has a live seat in a local or inbound group this sender belongs to (#1126), including a seat a group invite admitted (admittedBy: 'ticket'). That mention is the group's line: it goes out as aFLYBOT-ROOM-SHAREop:linewhoseseqis the room's next line number (flybot.rooms.takeLineSeq, the same counter a stored line uses), so a second mention is a new line and not a redelivery of seq 0. An inbound mirror names the host's room id inside the envelope. An ordinary local room names no Bot Socialroom_id. A ticket-admitted seat namesroom_idas that link's scope (this room's id on the host, the host's room id on the guest). A plain line andmessage_botto that same seat stay denied, and a mention after the seat has left is refused again. A stranger outside every shared room keeps the deny sentence.pollOncealso callsflybot.rooms.seatOpenClaims(#1064) before it reads any inbox, so a claim is seated even when the Bot the link knocks on is room-only and is not polled; the guest's Join group says the group was asked only when the ticket was claimed or the knock actually left (sentis notdenyorholdwith no claim). A claim the host could not finish (waitingonroom-invite) is named on this row, and Seat them callsroom-invite-seat. #1161: that poll reads one open link about once a minute instead of previewing every link on every tick, and a preview that saysToo many attempts(orrate_limited/locked_out) waits out the retry it names, up to 10 minutes.sendTicketSharerefuses apayload.textoutside 1–4000 characters before calling Bot Social. A seating send that comes backpending_approvaland is not cleared is not reported as delivered:autoSendRoomEnvelopereturns'kept'for a finding (the card stays, the host recordshold) andfalsewhen the hold is not listed yet (the host retries). A blanket hold, and an ordinarypolicyholdroomPolicyClearsaccepts, still leave. Covered bysocial.test.js(#883, #1064, #1161) andface-rooms.test.js(#884). - A 1:1 line between two Bots that are both live in one open face room names that room too (#881, Bot Social #43/#50): both send paths (a Bot's line in
sendOut, the owner's typed line insendChat) passroom_id= thefrm_id whenroomContextForfinds a room insettings.faceRoomsthis home has not left that still holds both Bots (the target as aremotemember), so Bot Social decides on the room-scoped grant the pair already holds instead of answeringno_relationship. A room line (viaroom/room-share, see above) and a local room (#852, noface_roomsrow) name none. A mention of a shared local-group seat (#1126) does not use this face id: an ordinary seat names none, and a ticket-admitted seat names the link's scope (above); a room that is left, closed or dropped stops answering, and the next send is back to the plain per-grant rules. A room-scoped refusal is the room's answer and is never retried on another Bot of this home:room_not_sharedreads that room is closed between these Bots now. Send again from the room, or join it again. and a room-scopedno_relationshipreads the room no longer lets these Bots reach each other. Join the room together again. - #1141. Every local Bot that still sits in a live ordinary group with a remote seat may message that seat, and a send to that seat's home, without being the Bot named in the friendship
viaand without a separate Sharing grant. The line goes out as the room envelope (sendCoSeatMention), not a 1:1 that Bot Social answers withno_relationship. The owner's typed line in that seat's chat (sendChat) uses the same envelope when no covered Bot can send it. A stranger, and a seat that has left, stay on the old refusal. When Bot Social reports the friendship gone,forgetFriendHomehides that home's remote chats, drops its seats (including a ticket seat), and a later send says the friend is gone instead of staying "you are not friends".
The plugin's discarded .catch(() => {}) calls carry a one-line reason comment or void; check-empty-catch enforces it (see docs/code-standards.md).
Covered by the plugin's own tests under plugins/social/ / core test suite as wired. Packaged-install import (#153) is scripts/lint-plugins.mjs plus .github/scripts/plugin-imports.test.mjs.
- Putting social logic under
core/. - Assuming social owns
web-search.js. - Importing
../../../core/…from plugin runtime sources (breaks a packaged install, #153).