Skip to content

Latest commit

 

History

History
231 lines (164 loc) · 121 KB

social.md

File metadata and controls

231 lines (164 loc) · 121 KB
subsystem social
owned_paths
plugins/social/

social (@flybot/social)

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).

Owns

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).

Does not own

  • Plugin runtime hooks/slots → plugin-runtime.
  • Core server/client → ds-bot-core.
  • Standalone core/web-search.js (not part of social).

Key files

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.

Entry points

  • plugins/social/index.js — loaded as @flybot/social.

Invariants

  1. 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 reach core/ or another plugin by relative path: that path is missing after plugin add …&path:/plugins/social (#153). Tests may import from core/ for fixtures. scripts/lint-plugins.mjs fails the leak and packs the plugin then imports it.
  2. Uses documented plugin APIs only.
  3. 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 (the bot-social transport 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), because send_as_member opens 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. Other sendChat failures are one short English line with a next step (reconnect, invite, try again) — never Bot 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-originated bot.send / message_friend_bot to 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_bot tells the model to relay that reason as written.
  4. Access is a per-Bot grant (#90, #138): default deny, Chief included. register / registerAll register 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). pollOnce reads inboxes of non-room-only members only. Friends' Bots is list_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, via grantBotToFriend / 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 (via mcp-client.js listGrants / revokeGrant, preferring list_bot_grants / revoke_bot_grant) list people as Name (@home) and revoke one person. Core reach() / flybot.grants are 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 and reach() 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 drops roomOnly, keeping the stored ownerName/home and filling either from whoami when the room-only record never had it (#863). Registration also records the owner-level ownerName and home on that member identity (#848) from one whoami: top-level display_name and home, else connector_identity.bot_handles[0], else the flybot-home entry in bots[] (given the @), and registerAll fills them into an identity saved before this without registering it again; the group room-share envelope reads them to name the inviter.
  5. 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's Unknown FlyBot endpoint social/… message: apply registers the social UI endpoints with a friendly Social is not running: <reason> refusal and flybot.warn records the failure. The client also maps bot/not-found on social/* to that idle state. Add friend shows You are @your.home (#181).
  6. 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 calls social/face-join once 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 leak members / events / room_id. Every FACE_ROOMS.md error code maps to English copy (rate_limited / locked_out include retry_after). The waiting room polls social/face-rooms (which refreshes from Bot Social, including joinCode) 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 is social/face-start then openSession(roomId), and the screen follows if someone else starts. Peer room.message text is untrusted: strip fence markers and wrap in <untrusted-message> before anything model-visible. Bot replies POST /v1/face-rooms/{id}/messages; pending_approval shows in the existing queue and in the chat (owner Approve / Reject). Owner-typed FLYBOT-FACE-HUMAN lines 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's settings.faceAutoSend, off by default): when that Bot's line comes back pending_approval and 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 other hold_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 accepts face_room_auto_send_off (holdAllowsFaceAutoSend, on top of holdIsBlanket); the two outbound paths keep the blanket rule alone (#865). The older account-wide auto_approve_face_rooms (whoami / MCP set_auto_approve) reads as every switch on; the first per-Bot change keeps the others on here and turns the account-wide one off (a confirmation_required answer is retried with confirm_code in the same click; the UI never asks for a code). REST /v1/me is 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 (after cursor; 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-join without start answers { 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: true adopts the held room through GET, 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's expires_at corrected by the Bot Social clock offset (from the Date header, applied only at 3 s or more) and catch up on visibilitychange. 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 ../.. into core/ breaks the installed bundle).
  7. A friend's Bot's reply to a line you typed (or to any request) goes back through bot.send as an auto-reply and, 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.js replyRemote), the Bot's row in state lists its heldReplies (id, status, turn, recipient; owner only, no text) and counts a waiting one in pendingCount, and that turn's bubble is drawn held (dashed, core/src/client/held-reply.js) with the decision on it (flybot.approvals inline entry social-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, through social/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's allow says autoSent (with the same recipient) 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 shared canRetry() 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's recipient (the friend's owner name, stored as recipientName, 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 bare the room (#991), the same way the room bullet below says. A hold made before the tag (no replyIn) 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 a reply, never for a new request (#272).
  8. 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 by hooks.js release only; a Bot-supplied field is overwritten). The approval id comes from the send_as_member answer, 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 same confirm_code step 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 way autoSendFaceLine does 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's hold_reason and clears the line only when it is blanket_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): autoSendFaceLine clears face_room_auto_send_off too, while both outbound paths here never do. A room envelope is the other outbound path (#847, #1113, #1118): autoSendRoomEnvelope clears a blanket hold, and it also clears a room envelope whose hold_reason is policy, whose reasons are 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 as policy stays 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 an outbound_message whose text is a real room-share envelope. A face-room line (face_room_message) is not that clear: a policy hold still waits for the owner even when the Bot's text starts with the marker. The field wins when it and reasons disagree. A server that sends no field keeps the old allow-list: reasons must be non-empty and every entry exactly mvp.all_outbound_requires_approval, with no risk_flags, so a DLP category, dlp.blocked, a reason this build does not know (policy.*), an empty reasons list 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 through pendingApprovals(), which would also post the waiting notice for a hold it is about to clear, and a failed decide_approval only 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.
  9. 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 a share.requested event to my Bot's queue. pollOnce keeps it as one card per sender and Bot (settings.shareRequests, names cleaned, no peer content) and listed on social/status as shareRequests; 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 map bot_not_shared — and capability_not_granted only 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 as profile.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 prefixes Not delivered:), carrying noRetry so no card offers a Retry that cannot work, and with no retry through another member. Core reach() still refuses such an inbound message locally as the safety net.
  10. 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", with grants_ready and grants) before the friend's first message. pollOnce runs syncOutgoingGrants({ only }) for that Bot when it reads that event (or grant.changed granted), 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 calls report_not_delivered { message_id, reason: 'not_shared_yet' } (a server without the tool only logs it). On the sender's home, a message.not_delivered event 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.
  11. A face room has one room lead (总负责人, #476; Bot Social #32). viewFaceLead reads effective_lead_bot_handle / lead_bot_handle / created_by_me from 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, then POST /v1/face-rooms/{id}/lead (Bot Social: creator only, owner token only, no MCP tool). room.lead_changed renders as a platform notice rebuilt from the lead's fields. On a server listing member_roles, publishFaceRoles sends 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's members[].role is cleaned (cleanFaceRole, 80 chars, no markup or @) and treated as data.

A remote turn that died (#1068)

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.

Inviter sees an accepted invite (#1054)

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.

Paused friend chat (#404; no turn limit since 2026-10-19)

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.

Connector-token scope (#156)

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.

Inline approvals and compact Friends' Bots (#114, #115)

  • A held send is never without a place (#371): the approval carries the Bot Social identity that wrote it (approvalView → botId; pendingApprovals maps it to the local Bot as fromId) 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 reads Held for your approval: contains commitment language (host t, 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 (the chatApprovals provider answers for local Bots by fromId). 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 local flybot/approvals holds 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, one Name → Name line (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-friend social/auto-accept, or in a face room the writing Bot's own social/face-auto switch, #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_code in the same decide_approval / set_auto_approve call). 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 core decide retry) only while trying again could work: the error a sender marks with noRetry — a friend's Bot no longer shared with this home, where Bot Social answers bot_not_found (#954) — arrives as retry: false and 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 TeamSidebar list (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 are flybot.new-chat.action rows 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).

Face-to-face waiting grid, leave, and social + (#124, #125, #126)

  • Waiting UI (join dialog and flybot.room.overview while candidate) 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), never BoxA's BoxA or generic BoxA'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 by faceRoomId, 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 with You left this room. (before, dropFace removed 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 (FaceRoomStatus on flybot.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 shorter Bob left the room is 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_exists is 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 (slot flybot.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 a You are @your.home line (#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 (inviteControls in client.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 is Invite grants {Bot} only. and adds Revoke anytime in People with access. only once People with access lists someone (ManageAccess reports its count through onGrants); the empty list says Nobody 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 named Bot (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, and No 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. Held face_room_message drafts show in that face chat with readable names (Chief → the room), never raw bot_01… or a FLYBOT-FACE-HUMAN envelope.

A group's own header before its first turn (#998)

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).

Face-room human turns and UI polish (#166, #167)

  • 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 @all is 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 passes attested: true to postHuman only 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/test has the two-home case. Explicit mention list (#293): the sender's host resolves the picker's labels against the room (faceMentions().handles; core onFaceLine(fn) passes { mentions } for the owner's typed line) and faceApi.send puts the handles in mentions next to typed_by_owner only when GET /v1/platform features lists mentions (the one feature probe, serverMentions()); never on the legacy envelope, Bot lines or an older server. The receiver reads mentions (cleanMentions, at most 8 handles) only from a line the server attests typed_by_owner (renderFaceEvent), and postHuman resolves 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 (core openingSpeakers), attested or as the envelope, so it is never reported as lost. plugins/social/test/social.test.js pins 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] = faceRoomId when Bot Social's card does not name the room, and pendingApprovals drops 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 read Chief → the room — a hold whose room id names no number and no live room keeps the bare the room. A number is the six digits only (#1005): a stored joinCode that 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, the roomApprovals provider and hidden-remotes match the room id strictly.
  • Face-room holds (face_room_message / roomId) attach pendingCount to that room only. They do not badge an unrelated friend row via from/to handle. 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.joined event 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. faceSnapshotPast keeps a room left / closed once 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): onFaceLine sends it wrapped as FLYBOT-FACE-HUMAN and 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); pendingApprovals auto-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, and faceOutbound neutralizes 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 with rooms.postHuman as a bubble from that person.
  • A peer's room.message text is shown exactly as Bot Social delivered it, paragraphs included (#302): viewFaceEvent is idempotent (an already viewed event keeps its plain untrustedText; faceApi.get views once and applyFaceSnapshot → renderFaceEvent views again), and faceUntrustedText unwraps 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 in ownLineApprovals and 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 through bot.send keeps 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 incoming FLYBOT-FACE-HUMAN-looking direct line stays the Bot's line: text is not an attestation. pendingApprovals / social/decide-approval call flybot.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: true only when an owner credential with the attest_typed_by_owner right (the FlyBot connector token; never a Bot token or a plain connector token) sends it with typed_by_owner: true — through send_as_member or send_face_room_message. The stamp is a top-level event field, covered by the platform countersignature, and absent when false; GET /v1/platform lists typed_by_owner under features. Sending: sendChat (the owner's typed line in a remote chat) and the face-room onFaceLine human fanout send the line plain with typed_by_owner: true instead of the FLYBOT-FACE-HUMAN envelope, but only when serverAttests() says the server stamps (GET /v1/platform features; 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 (and faceOutbound still neutralizes the envelope marker in them). Receiving: pollOnce reads event.typed_by_owner === true and the peer's owner_display_name from the server's from, never from the text; a direct line then goes to remote.chats.deliver({ human: true, ownerName }), which puts typedBy on the message source, turns.js marks that turn (state.remoteTypedTurns[session][turn], published as roster.remoteTypedTurns), and AssistantCell draws 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 to flybot.bot.send with from.typedBy (the server's owner_display_name; the host copies it, cleaned, to the message source's remote.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-room room.message with the stamp renders as the person (renderFaceEvent(event, { attested }), rooms.postHuman, the name from the authenticated from). On a server that stamps, an unstamped FLYBOT-FACE-HUMAN envelope 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's bt-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 shows Pick a Bot first.). The waiting countdown reads m:ss left to join and 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 (faceRoomCount in core group-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, one path in an svg), 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 is FLYBOT- 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's BarcodeDetector and the camera with getUserMedia; 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 is That 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 same social/face-join the 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 with POST /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's https://…/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 — parseFaceInviteLink returns 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-invite answers before the face_room_invites gate, 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 is That is not an invite link. It looks like https://…/r/…, and the 6-digit number stays the way in. Joining is POST /v1/face-rooms/join-invite, and each refusal keeps its own sentence: invite_not_found, expired and revoked each 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 the face_room_invites entry in GET /v1/platform features: on a server without it the endpoints answer { unsupported: true } without calling a route that is not there, and the card says Invites 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 in SOCIAL_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-preview answers { 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, reads Face-to-face room instead of reporting the missing field, #859), the lead, members / maxMembers when 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 cleans name and lead itself with the same rules as cleanFaceName, 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's stripFaceMarkers does: 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 same invitePreviewOf / inviteCountdown rules, and kind renames only the data-flybot hooks (face-link-preview here, room-invite-preview in 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 same faceMemberLabel rule the member list uses — Lead: Chief (SelfTest A2) — while a string answer keeps the cleanFaceName path, 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, one from → to line, 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 by room.heldLines, #456, and by room.pendingApprovals as 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: onFaceLine answers { held: true, approvalId } for a Bot line Bot Social holds, social/decide-approval calls flybot.rooms.settleHeld, Approve makes it a room bubble and Reject a grey <Bot> · Not sent line, with no Rejected. note in the face room. A decided card leaves the dock at once (no Approved / Rejected stub, any card kind). A face card's from is the room's own #384 label for that local Bot (Researcher (Alice) → the room, faceFromLabel). applyFaceSnapshot posts a human or system event for a round only when the core room logged it now (applyFaceLine refuses 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 HeaderPill through composer-label.js (placeholder CSS var plus aria-label and data-placeholder on [data-composer-input], from the first paint, restored on cleanup; #419, #499); chat-header tok jargon stays in pristine DSH / ds-bot.

Face-room poll identity (#122)

  • list / poll must not persist or send as=@. An empty, missing, or literal @ handle stays empty; GET omits as unless it is a real Bot handle. Rooms first seen from list seed asBot from my_bots[].handle (or a local member already in the snapshot). A stored bogus @ is healed, and face_room_not_found for a still-listed room repairs local meta (clear as, retry with my_bots or no as) 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 (seen sits 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 stamp created_at in 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.

Approval waiting notice (#123)

  • Syncing a held outbound draft into chat must not call remote.chats.deliver with a home-level handle (@box.home) or with no session. Resolve a listed friend Bot under that home, chats.open that 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.

Onboarding, grants, hide, and approval (#136–#140)

  • #136: A taken displayName at onboard retries Name2 … 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? } (REST PATCH /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). avatar is a preset id, a single emoji, or data:image/(png|jpeg|webp);base64 ≤ 64KiB; color is #rrggbb. Remote look is read from list_remote_bots / list_friends peer / list_bot_grants / face-room members[] (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 is avatar_updated_at; missing look falls back to the handle-seeded palette. What is sent is the look the owner's home draws (shownLook in look.js): a Bot with no stored shape or color gets the id-picked default of core characters.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 as BotMark draws them. A new identity gets its look before registerOnce returns, 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 the joinedAs of 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 lists SelfB1935 (@selfb1935.home); a label grantPersonView already made keeps its display name when syncOutgoingGrants reads the normalized grant again (#275), and only a bare handle falls back to the lowercase home. grantPersonView / normalizeGrant read Bot Social's object-shaped grantee (handle, owner_display_name) and never String() it ([object Object] → @objectobject). Core grants store a canonical @handle (never decorated Chief@…). 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's list_bot_grants marks that grant reply_only (bot-social #23), which normalizeGrant keeps as replyOnly (with side); when present it is the only rule (#495): replyOnly: true is talk-through and replyOnly: false is 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 in accessRows. Only a server that does not send the field falls back to the guess: grantee in inboundBots, and either a relationship this home joined or, for joins from before that record, the home's first relationship with the Bot in via. social/join-invite records 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 of relationship_ids would 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/access returns one row per Bot (#995): accessRows keys a grant that names a Bot (its member differs from the person's home) by that Bot, labels it from the name Bot Social sent for it in list_bot_grants (normalizeGrant keeps botName / ownerName; a server that sends no name falls back to Owner (@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 carries grantIds, which social/revoke-grant revokes 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's grantIds. 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's grantIds, so the person's Revoke cannot break that chat. Every revoke runs in a per-Bot fence: a syncOutgoingGrants whose list_bot_grants overlapped it does not store that list and resolves false (fail closed), and the roster refreshes once after the fence. The joined relationship and the offered Bot are recorded right after join_invite, before the owner-approval follow-up. Join copy names the person (owner_display_name), not the shared Bot. social/join-invite marks 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 real join_invite answer has no bot field: inviter is the invited Bot itself (a member handle such as @chief.bob.home; joined.bot, inviter.bot and offered_bot are the older shapes, with inviter at the home). A member-level inviter counts 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 in inboundBots waits for the grant sync before the roster is published (#303; "already fetched" is the rosterFetched flag, 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, as registerAll synced 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 previous remote, so a refresh that also brings another new Bot cannot store a talk-through grant as a core grant. syncOutgoingGrants resolves false when 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): a list_remote_bots answer that lands after a newer one (the background refresh hide-remote starts, 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-reads social/access every 5 s while visible, and social/access syncs 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 on list_remote_bots are seeded into inboundBots once 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 browser confirm()); 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 Bob with a one-line hint, #417) and calls decline_bot_grant { grant_id, bot_id } (DELETE /v1/remote-bots/{grantId}) so the owner's Manage access drops it. grant_not_found is treated as already removed. An older server (404 on the tool/endpoint) falls back to the local hide. Re-joining after a decline may return regranted: true on the existing friendship; live access still answers 409 relationship_exists; a spent invite is 410 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_exists social/join-invite answers { 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}, inviteInfo in mcp-client.js, sent without a token) for the invite's Bot and owner. restored lists only Bots that were locally hidden or declined and are listed again after the unhide. The panel shows neutral text You already have Chief (SelfA1233). (Bot first, owner in parentheses, #384), or Chief (SelfA1233) is back in your list. when something really came back; never red. Without a home (legacy) the join still throws, with the neutral You already have this friend's Bot.. #1000: that path — and a /join_invite that answers regranted: true on 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 and sharedBack absent — and the Add friend line says Your <Bot> is shared back, so they can reach you too. — Your Bot when the server names none — instead of only You already have …. Bot Social takes the friend by bot id, never by home (grant_bot_to_friend sends friend_bot_id / friend_owner_id, and the REST body keeps only those two), so when the invite info names no bot_id the plugin resolves it from list_friends (peer.bot_id, the field friendsOverview already 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, and sharedBack stays 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 bare Chief is shared back reads as the friend's Chief being shared twice (#1026). A name the owner already gave as Your … is printed as it is, so a Bot named Your Bot keeps its one Your instead of reading as Your 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 / Name3 still change within the 32-character homeHandleOf limit.
  • #666: A friendship can be removed outside this plugin — the agent's remove_friend tool, or the Bot Social console — and the cached roster used to keep that Bot for up to REMOTE_TTL_MS (30 s). A send in that window was accepted and held, and approving it lost the line: Bot Social answered send_as_member: no active relationship between these bots. The send policy now asks Bot Social about the friendship itself before it asks the owner: friendshipActive reads list_friends for the home and for the sending Bot's own member identity (a per-Bot friendship, #420, is listed there), accepts only an entry whose status is active (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, and forgetFriendHome drops 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: a relationship.changed event 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 a friends array 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-asks list_friends with { 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 with no active relationship. The probe also carries its own FRIEND_CALL_MS = 1.5 s deadline through callTool(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).

The server in Settings (#337)

  • Where it comes from. config.server in the profile patch is the default. A server picked in Settings → Bot Social is kept in flybot-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 with server URL is required, Social is not running: …; the page says No server set). The page reads social/server (a read; { server, source: settings|config|none, defaultServer, state: connected|connecting|not-configured|error, handle, error, canChange, known[], platform? }: never a token) and calls social/server-check (it makes the host fetch an address the caller names, so it follows the write rules like social/server-set).
  • Validation. parseServerUrl: https:// only, http:// just for localhost, 127.0.0.1 and [::1]; no user name or password, query or fragment; at most 200 characters; an address without a scheme gets https:// (http:// for those local hosts). probeServer GETs <server>/v1/platform without 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 (a name, a platform_key or a features list) and its features are shown (cleaned, at most 40) in words (featureLine in client.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-set repeats 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 suffix startSocial already used for backups, tag = first 8 hex of the sha-256 of the normalized address) and flybot-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 (the member identity, the autoApprove marks and the person/room grants, which are Bot Social ids and handles) are parked in flybot-social-bots.<tag>.json (no token) and cleared before the new server starts, so a stale grant to @bob.home can 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 (needs confirm: 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 when dispose() 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 marked left here — 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).

Friends and sharing on the Bot Social page (#1074)

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).

A share to a Bot the other home removed (#1128)

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).

Add friend invite input (#310)

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).

Thinking status for friends' Bots (#297)

  • Signal. Bot Social's presence feature (GET /v1/platform features; the same probe as typed_by_owner / mentions, serverPresence()): MCP set_presence { bot_id, state: thinking|idle, to | room_id } and list_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 option presence: false turns 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's turn.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) says thinking for the Bot's Bot Social identity (social.member.bot_id), repeats every 10 s (cap 60 beats), and says idle at turn.end. The feed gives turn.start a trigger only from a delivery recorded before it; when a friend's line is recorded after turn.start (the turn has no trigger), that turn waits in unclaimed and the friend's exchange.delivered on the same Session starts the beat (#332), but only when it is recorded within CLAIM_MS (5 s) of turn.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 a trigger, 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_limited is skipped.
  • Receive. social/typing (host endpoint) is asked every 2 s by the PeerThinking dock 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 s list_presence poll 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-room postRemote) ends its line at once and the lagging server entry is ignored for 4 s.
  • UI. One quiet line above the composer (flybot.session.dock social-thinking, below the approval card): Coder is thinking…, A and B are thinking…, A, B and 2 more are thinking…, in the core bt-shimmer style of the activity row. In a direct chat it uses the chat's own Bot name; in a room the participant's name, with · Owner when 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.

Auto-send per pair (#417)

  • 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; approvalFor reads 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's set_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 into serverAutoApproved, 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/access rows carry person and autoSend) 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 same policy hold 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 hint Your 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-accept per friend, Auto-send on for X → Y), this section is current.

One name for a friend's Bot (#384, #305)

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).

Friends listed per Bot (#420)

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).

Past rooms, unique titles, poll, and + (#129–#133)

  • Expired matching (candidate whose expiresAt has passed) and a peer leaving the last remote seat move the hosted room to Past rooms on the next poll (rememberPastFace, status closed / left). The local room stays so Open still works; Leave is not shown on those cards. Face-room names are Face-to-face · Alice, Bob · 482913 (people plus the 6-digit code, or Oct 16 21:40 when there is no code) so two rooms with the same people stay distinct. The literal Face-to-face room is not a fallback. The number printed there and on the waiting card is the six digits only (#1005): faceWaitCode and shownFaceTitle refuse a stored value that is not exactly six digits (1234567 never reads as 123456) 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 → closed with peersGone) 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 marked closed so the sidebar's active list drops it, and Past rooms keeps that one row (a left room stays Left). Its endedAt is the snapshot's closed_at when Bot Social sent one, so both homes show that time; otherwise the first time this home noticed. The owner who left keeps Left: rememberPastFace never turns a left row into closed when a later poll finds the room gone, and their room reads You 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 room is not also posted into the transcript. The number a room was joined by goes with every upsertFace (joinCode) and core keeps it on room.face.joinCode, so the title stays Face-to-face · Alice, Bob · 482913 through 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): faceRoomTitle takes the snapshot's members plus every participant the hosted room ever had (like the core title), and viewHostedFace for a closed or left room lists all participants, marking who had left left: 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 by rememberPastFace, else the start (or creation) time; today reads 14:05, an earlier day Oct 19, and the hover text has the full Ended Oct 19 14:05. The fold remembers whether it is open across a reload (localStorage flybot.social.pastRoomsOpen).
  • The face-to-face join field for the room number starts empty with a light 6 digits hint (normal letter spacing, 13px), not a 000000 that 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 000000 code 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 from plugins/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, else inboxPollMs (default 15s). While a FlyBot window is on screen (flybot.watched(), #497) a second timer also reads the inbox at the facePollMs cadence, 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), never bot_01….

Face-to-face two-instance rooms (#117)

  • The social strip polls social/face-rooms with poll: true about 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 short 10m / joining label so the name and Start fit; after Enter chat / start the hosted room.face.status is open. After Leave, only Past rooms remains — no empty "Face-to-face rooms" header or Auto-send. Waiting tiles use the server label (Chief · BoxB). A snapshot member that is new on this side still gets a join line when the join event is before the floored after cursor. Owner-typed lines in the open room fan out over onFaceLine: Bot Social face messages are bot-authored, so FlyBot sends a FLYBOT-FACE-HUMAN envelope as the owner's local room Bot. The owner's click/type is consent to send; a pending_approval stays in the in-chat card until Approve / Always approve / Reject unless face Auto-send is on (#159). The other instance applies the line as authorKind: 'human'. The displayed sender is the server-stamped from member, never the envelope who. 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.

Inbound shared groups

  • Bot Social has no FlyBot-group record (list_conversations is 1:1; inbox is message.received after a send). The plugin therefore carries a FLYBOT-ROOM-SHARE envelope on send_as_member text. pollOnce calls flybot.rooms.applyInbound before 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 back pending_approval clears Bot Social's own blanket hold by itself (autoSendRoomEnvelope, holdIsBlanket() on hold_reason of #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 as policy with 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 an outbound_message room envelope; a face-room policy hold 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 local session-… 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, or social/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 is candidate or drops every other home is closed with peersGone instead 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).

A local room's link for a stranger (#883, #884)

  • Bot Social #60 keeps a local room's invite tickets (/v1/local-room-invites, feature local_room_invites). face-rooms.js createFaceRoomApi().localInvites has mint / preview (with this home's token the minter reads every home that joined, claimers, and a claiming home its own claimer) / 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.js registers them as the host's seam (flybot.rooms.serveInvites); verify is the preview read, and mint answers undefined on 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 with claimed is this home's own claim, never someone else's, and joined is read before it: joined is 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's roomId) — 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 (via room / room-share, never a plain line, no files), from the exact local Bot, to the exact handle, under the room id in meta.scope, as flybot.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 as send_as_member with room_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 a user-mention of 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 a FLYBOT-ROOM-SHARE op:line whose seq is 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 Social room_id. A ticket-admitted seat names room_id as that link's scope (this room's id on the host, the host's room id on the guest). A plain line and message_bot to 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. pollOnce also calls flybot.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 (sent is not deny or hold with no claim). A claim the host could not finish (waiting on room-invite) is named on this row, and Seat them calls room-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 says Too many attempts (or rate_limited / locked_out) waits out the retry it names, up to 10 minutes. sendTicketShare refuses a payload.text outside 1–4000 characters before calling Bot Social. A seating send that comes back pending_approval and is not cleared is not reported as delivered: autoSendRoomEnvelope returns 'kept' for a finding (the card stays, the host records hold) and false when the hold is not listed yet (the host retries). A blanket hold, and an ordinary policy hold roomPolicyClears accepts, still leave. Covered by social.test.js (#883, #1064, #1161) and face-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 in sendChat) pass room_id = the frm_ id when roomContextFor finds a room in settings.faceRooms this home has not left that still holds both Bots (the target as a remote member), so Bot Social decides on the room-scoped grant the pair already holds instead of answering no_relationship. A room line (via room / room-share, see above) and a local room (#852, no face_rooms row) 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_shared reads that room is closed between these Bots now. Send again from the room, or join it again. and a room-scoped no_relationship reads 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 via and without a separate Sharing grant. The line goes out as the room envelope (sendCoSeatMention), not a 1:1 that Bot Social answers with no_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, forgetFriendHome hides 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".

Discarded promise .catch (#213)

The plugin's discarded .catch(() => {}) calls carry a one-line reason comment or void; check-empty-catch enforces it (see docs/code-standards.md).

Tests

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.

Common mistakes

  • Putting social logic under core/.
  • Assuming social owns web-search.js.
  • Importing ../../../core/… from plugin runtime sources (breaks a packaged install, #153).