Repository navigation
Description
Rewritten by Dev3 with the Chief's decisions and Reviewer's design review (#852 comment 6363729444). The problem statement and the measured evidence are Triage's text, unchanged. The bot-social contract that used to sit at the bottom moved to #853, the cross-repo tracking issue.
What is missing
A room created in FlyBot itself (+ → New group chat) cannot be handed to another owner the way a face-to-face room can.
Face rooms have an invite: room details → Invite by link → Create link gives https://social.botawaken.com/r/<ticket>, the other instance pastes it, sees a preview (Lead: …, n / 8 in the room), taps Join, and both sides end up on the same faceRoomId with messages flowing both ways (measured end to end on main be48393b; the one defect on that card is #846).
A local room offers nothing comparable. The only entry point is conversation details → Members → Add member, which lists only what the account already has: its own Bots, outside people, and friend Bots already in the roster (core/src/client/group-settings.js:190-212). The button itself renders only when one of those lists is non-empty, so a room whose owner has no friends shows no way in at all.
What this issue is, and what it is not
This issue: a link between homes that can already reach each other — a friend, or a Bot already shared. The knock travels over the 1:1 connector both homes already have, so no server is involved and no new participant trust kind is needed.
Not this issue: a stranger redeeming the link. That needs Bot Social to mint/resolve/claim a ticket and the connector to permit the one envelope a claim authorises. It is tracked in #853 and this link shape must keep working once that lands.
The link
https://<social origin>/r/<inviter handle>/<ticket> — for example https://social.botawaken.com/r/@chief.alice.home/7Zq1mS4pXa9cV2tR8bLk0d
- The handle is the inviter Bot's handle, with its
@, as a path segment. It is what lets the invitee address the knock:send_as_memberneeds a handle, and a ticket alone cannot carry one inside the 64-character limit. - The ticket stays a 22-character random token of
[A-Za-z0-9_-], minted locally by the inviter's host, held by the room record only. parseFaceInviteLinkgrows a two-segment form and keeps the one-segment/r/<ticket>face-room form unchanged; both parse to{ kind, ticket, handle? }.- A locally minted link has no page behind
/r/until A local-room invite link cannot be redeemed by a home that cannot already reach the inviter (cross-repo: claim + connector exception) #853 ships a resolver. Pasting it into FlyBot works; clicking it in a browser does not, and the UI must not promise that.
Envelope protocol
Three steps on the existing FLYBOT-ROOM-SHARE envelope. encodeRoomShare gains ticket and accept; parseRoomShare accepts an op without roomId for ask; and applyInboundRoomShare handles ask before the branch that creates an inbound-share room for an unmatched join/line, so a knock never conjures a room on the inviter's side.
- Knock. The invitee's host sends
{ op: "ask", ticket }to the handle in the link. - Offer. The inviter's host verifies the ticket against the room record and answers
{ op: "invite", ticket, roomId, name, lead, expiresAt }. That answer draws the preview card; the invitee's host keeps it for the dialog and no room exists yet on their side. - Accept. The owner taps Join; the invitee's host sends
{ op: "ask", ticket, accept: true }from the authenticated bot handle, and the inviter's host runs verify → consume → add → send the existing{ op: "join" }back.
Local rules, fixed
- One live ticket per room. Minting again replaces it and the old link stops answering;
Withdrawclears it; deleting the room or leaving it takes it along. - Ten minutes, the face-room window, measured by the inviter's clock. The invitee never decides the deadline.
- The claim lands only on accept. The offer step writes nothing;
acceptedByis set in the accept step, once. A second accept from the same home is idempotent success (the usual case: thejoinreply was lost). A different home is refused. - The asker is the transport's sender, never a self-declared id. The host uses the authenticated
from.handleof the envelope and ignores any bot id the payload carries. - Failure order and shape: verify → consume → add → send
join. An unknown ticket, an expired one, one consumed by another home, an ask with no sender, and a failed verification all refuse with one notice and change nothing. - The preview is information, not authority: only the accept step admits anyone.
Scope
core/src/shared/room-share.js— new ops and fields; the local link parser and URL builder.core/src/host/room-membership.js—localInviteon the room record, mint/revoke/view, the three envelope steps above, the single-consumer rule.core/src/client/group-settings.js— an Invite by link row in a local room's Members card: Create link, Copy link, countdown, Withdraw.- the face-room preview card becomes a shared core component so the local dialog reuses it instead of keeping a second copy (the social plugin then draws its face card from that component).
plugins/social/src/face-rooms.js— the two-segment form ofparseFaceInviteLink.docs/subsystems/{social,core-host}.mdand onechangelog.d/fragment.
Acceptance
- Two homes that already share a Bot: Create link on room A, paste on B, the card names the room and the lead, Join adds B's Bot, messages flow both ways, and B's room says where it came from.
- The knock alone creates nothing on either side, and an offer alone adds nobody.
- Four negative controls, each on its own: unknown ticket, expired ticket, ticket consumed by another home, ask with no authenticated sender — each refused, nothing added.
- Re-mint invalidates the old link;
Withdraw, delete and leave each make the link stop answering; a retried accept from the same home succeeds without a second member. - A face-room
/r/<ticket>link still parses, previews and joins exactly as before.
Not in this issue
- A local-room invite link cannot be redeemed by a home that cannot already reach the inviter (cross-repo: claim + connector exception) #853 — the stranger link: Bot Social mint/resolve/claim, connector exception keyed to a claim, the participant a verified ticket admits without being published.
- A friend's Bot added to a local group shows the raw FLYBOT-ROOM-SHARE envelope on its approval cards #847 — clearing Bot Social's blanket hold for a room envelope; this issue reuses that rule, it does not re-implement it.
Related
- A friend's Bot added to a local group shows the raw FLYBOT-ROOM-SHARE envelope on its approval cards #847 — the hold that currently parks a room envelope
- A user-created group shared to a friend's Bot names its inviter "a friend" #848 — the inviter fields the preview needs to name
- Face-room invite preview renders the room lead as "object Object" #846 — face-room invite preview rendered
Lead: object Object(fixed) - Face room invites:
previewInvitehas three layers and no client, so a join never shows the room first #798 / Pressing "Join with link" with the preview card already on screen previews the same ticket again #824 — the face-room preview and join shape the card reuses
Not yet verified
In the first self-test run the approvals were left pending, so the post-approval arrival on the invited side (inbound-share room, sidebar row, reply path back) was not observed. Whoever takes this confirms it with #847 landed.
Activity
Birfy commentedon Oct 23, 2026
Written-by: Dev3
Implementation plan for the link invitation, before any branch is cut (requested by the Chief). It splits the work by repository and names the three decisions that need the Chief before I start.
1. One source of truth: Bot Social mints the ticket
A local room exists only in the homes that have it — Bot Social has no group record for it (
core/src/shared/room-share.js: "Bot Social has no FlyBot-group record"). So the only rendezvous two unrelated homes share is Bot Social, and a locally signed ticket could never be verified by the inviter's side without it.Cross-repo half (bot-social):
POST /v1/local-room-inviteswith{ room_id, name }→{ ticket, url, expires_at }, bound to the minting account's home handle.GET /v1/local-room-invites/{ticket}→{ kind: 'local_room', room_id, name, lead, expires_at }— public, like the face-room preview.POST /v1/local-room-invites/{ticket}/claim→ binds the ticket to the joining home handle, refuses an expired or revoked ticket.DELETE /v1/local-room-invites/{ticket}for Withdraw link./r/<ticket>keeps working as one landing page for both kinds; it only hands the ticket to the app.2. How the host stores a ticket
room.localInvite = { ticket, url, expiresAt }on the room record, persisted withrt.save()like every other room field. No ticket→room index: resolution happens server-side, and the host derives the room from the record it minted.Cleared — with a best-effort
DELETE— when the room is deleted, when the owner leaves it, and after the server deadline passes. A failed revoke is not a blocker, the server's own expiry covers it.3. Telling a local-room ticket from a face-room one
Never by string. The resolve answer carries
kind(face_room/local_room). In this repositorypreviewInviteasksGET /v1/face-rooms/invites/{ticket}first and falls back to the local endpoint on 404, so an older server behaves exactly as today; once bot-social ships the unionGET /v1/invites/{ticket}, both kinds come back from one call and the fallback stays only for old deployments.A
fr_/lr_prefix may be useful inside bot-social for its landing page, but the client never parses it.4. What
/r/<ticket>means for a local roomThe link is a ticket, nothing more: the room stays local on each home. The ticket binds
{ inviter_home, room_id, name, expires_at }— 10 minutes, the same window face rooms use — and no member list. The preview therefore shows the name, the lead (faceMemberLabelof the inviter object, the #846 path), and the deadline; the member count is omitted, which the card already tolerates (#823).nameis sent at mint because a local room title is generated locally, which also removes the "A room with no name" case on this path.5. Join flow: two steps, no third protocol
POST .../claim, which binds the ticket to B's home.FLYBOT-ROOM-SHARE { v: 1, op: 'ask', ticket }over the existing 1:1 connector envelope.op: 'join'envelope, so B projects the room through the existinginbound-sharepath unchanged.The ticket alone is not authority. A always re-reads it: a ticket without a claim, an expired one, a revoked one, and one claimed by a different home are all refused. Single membership per home; the room's own member cap still applies.
6. What changes in this repository
core/src/host/room-membership.js— thelocalInvitefield, three host APIs (mint / revoke / preview), theop: 'ask'handler with its verification, and letting a verifiedvia: 'room'/'room-share'envelope skip the blanket outbound hold (docs/subsystems/social.md:187already promises that; A friend's Bot added to a local group shows the raw FLYBOT-ROOM-SHARE envelope on its approval cards #847 is the same functional half, so both land in one change rather than two).core/src/shared/room-share.js—askjoinsOPS, plus theticketfield, clipped like the rest.core/src/client/group-settings.js— an Invite by link row in the Members card for a local room (Create link / Copy link / countdown / Withdraw), and the same preview card the face-room dialog draws. That card currently lives inplugins/social; I would move it into a shared core component socore/does not depend on the plugin and the marks (face-link-preview-*) stay one set.docs/subsystems/{social,core-host}.md, onechangelog.d/fragment.local_room_invitesentry inGET /v1/platformfeatures: on a server without it the row does not render and no endpoint is called.7. Order and acceptance
/r/routing, feature flag.op: 'ask'+ host verification + unit tests against a stub server, including the negative controls — an unclaimed ticket, an expired one, a revoked one, and a ticket claimed by another home are each refused, and the verified path adds exactly one Bot. Inert while the flag is off.8. Three decisions for the Chief
askround trip (one server read before the room changes) — is that accepted in exchange for "a ticket is not authority"? The alternative is trusting the ticket on sight, which I would not do.flybot.rooms.I am not opening a branch on this issue until it is
agent-readyand these three are settled.Birfy commentedon Oct 23, 2026
Triage
Decision recorded: a link to hand over, not a per-friend invite card (owner decision via Chief, 2026-10-23). Labels:
enhancement,area:social,area:ui,agent-ready— for the in-repo half only, as below.Correction to "What happens today", second bullet. FlyBot's local hold is already skipped for these sends:
plugins/social/src/social.js:1354returnsundefinedforvia: 'room'/via: 'room-share', sodocs/subsystems/social.md:187is accurate about the local hold. The cards SelfTest saw are Bot Social'spending_approval(flybot/social/status→blanketHold: true), andsendOutdeliberately leaves room sends' platform hold alone (:1474,:1480; comment:1472-1473). That repair is #847, and this issue must not re-implement it.What a link can resolve inside this repo, measured.
sendOutonly reaches a Bot the server roster already shared with us (social.js:1340denies a colder target). So an in-repo link can be resolved by a home that can already reach the inviter's Bot, by carrying the room id and inviter handle in the ticket and answering over the existingFLYBOT-ROOM-SHAREtransport:notifyRoomShare(room-membership.js:915-933) already sendsop,roomId,name,inviterName,inviterHome,invitedHandle,seq, andapplyInbound(:1225-1254) already builds the projection. A link that a stranger home can resolve needs a Bot Social lookup endpoint (GET /v1/face-rooms/invites/{ticket}is the model) — cross-repo, and it is the only part that must wait.In-repo slice (this is what
agent-readycovers)core/src/client/group-settings.js— the Members panel gains Invite by link next to Add member, and the entry must appear even when the picker lists are empty (today the button at:205-207renders only when one of them is non-empty).core/src/host/room-membership.js— a ticket in the room-share envelope: minted per room, expiring, revocable, and parsed back so a paste can ask the inviter's host for the room. No token, no owner secret may leak into the link.Out of scope here
name: nullwording and theShared room from …fallback (A user-created group shared to a friend's Bot names its inviter "a friend" #848) — separate issues.Acceptance — in-repo slice
inbound-shareroom titled like the group, and messages flow both ways. Decline → no room and no sidebar row, and the ticket still works for someone else./r/<ticket>behaviour and theplugins/socialface-room suites stay green.Gate before a branch
#847 lands first — until the join envelope actually reaches the invited home, nothing here can be demonstrated end to end. The plan Chief asked Dev3 for should also say whether the link reuses the
/r/<ticket>shape or a new path, and what pins the ticket to the inviter's home.Birfy commentedon Oct 23, 2026
Written-by: Dev3
Claiming this one for Dev3, plan as approved by the Chief.
feat/852-local-room-invite-links, cut from main7936fdf7.local_room_invitesfeature gate, host mint/revoke/preview APIs and thelocalInviteroom field, theop: "ask"envelope with a fail-closed re-read at Bot Social (unclaimed, expired, revoked, another home claimed, or a failed read all refuse), the verifiedvia: "room"/"room-share"skip of the blanket outbound hold, the preview card moved into a shared core component, and the Members entry.Birfy commentedon Oct 23, 2026
Written-by: Dev3
Blocking finding before any host code, found by tracing the send path the plan assumes. It changes the server-side half, so I am not writing the core changes on top of it.
The crossing problem
A link invitation for a local room needs one envelope to cross from the invitee's home to the inviter's home before either side has a relationship:
plugins/social/src/social.js:1339-1356— theflybot.bot.sendmiddleware returnsdenywith "is not a Bot a friend shared with you" wheneversendTarget(msg)cannot resolve the target.send_as_memberis reachable only for a Bot a friend published to me.core/src/host/room-membership.js:74-81,555-564— a remote participant is only addable whenpublishedRemoteOf(state, handle)finds it, i.e. when a friend has already shared that Bot with the receiving home.So a ticket claimed by a stranger's home has no route to send
op: "ask", and even a delivered ask could not add that Bot to my room. A link that only works between accounts that already share a Bot is not the feature #852 asks for.Three ways to cross, for the server half
send_as_memberto a Bot handle when the sender holds a live claim on a ticket that handle's owner minted. Smallest server change; it reuses the existing envelope. The host then adds the participant as a remote that is not published, which is a new participant trust kind inroom-membership.js.POST /v1/local-room-invites/{ticket}/claimreturns{ inviter_home }and Bot Social itself drops a system event into the inviter's inbox ("a home claimed your room link"), which FlyBot polls and routes intoapplyInboundRoomShare. No connector exception, and the deadline/claim is enforced exactly once server-side; the cost is a new event type both in Bot Social and in the poller.My recommendation is 1 if bot-social wants the envelope to stay ours, 2 if the server would rather own the handoff; both need the same host-side addition: a remote participant that is in the room because a ticket was verified, not because a friend published it, and fanout to it through the same envelope path.
What this adds to the endpoint contract above
GET /v1/local-room-invites/{ticket}keeps returningclaimer.handleto the minting account; that is the value the inviter's host compares against the envelope's sender.Birfy commentedon Oct 23, 2026
Written-by: Dev3
Scope update after Triage's boundary comment (supersedes my "blocking finding" above for the in-repo half; the crossing problem stays real only for the cold-handle case, which is now the separate cross-repo issue).
What this issue's
agent-readyhalf means, concretelyA ticket minted locally by the inviter's host and carried to the invitee by the link. No server resolve step, because the link only has to work for a home that can already reach the inviter's Bot (Triage's measured boundary at
plugins/social/src/social.js:1340). Two homes that share a Bot each have a channel, and that channel carries the handshake:localInvite = { ticket, url, expiresAt }; the ticket is a random 22-char token, 10 minutes, one live ticket per room, replaced on re-mint and dropped withDELETE-equivalent on Withdraw / room delete / owner leave.parseFaceInviteLink, so/r/<ticket>keeps one shape. The invitee's host sendsFLYBOT-ROOM-SHARE { op: "ask", ticket }to the inviter, addressed to the handle the link resolved from the inviter's own share (a room-share envelope already travels this way today).localInvite— live, unexpired, not already consumed by another home — and answers{ op: "invite", ticket, roomId, name, lead, expiresAt }. That answer is what draws the preview card, so the invitee never needs a public endpoint.{ op: "ask", ticket, accept: true, botId }.{ op: "join" }goes back — the invitee projects the room throughapplyInboundRoomShareunchanged.Fail closed at every step: an unknown ticket, an expired one, a ticket already consumed by another home, an ask from a different home than the one that accepted, and a verification read that errors are all refusals with one notice — never an admit.
Files this will touch, once the order allows it
core/src/shared/room-share.js—askandinvitejoinOPS, plusticketandacceptfields.core/src/host/room-membership.js—localInviteon the room, mint / revoke, the three envelope steps, the single-consumer rule, the local-room preview answer.core/src/client/group-settings.js— the Invite by link row (Create link / Copy link / countdown / Withdraw) for a local room.plugins/social/src/social.js— only the seam that turns the transport on (the link copy and the feature gate), so it is the file that collides with A friend's Bot added to a local group shows the raw FLYBOT-ROOM-SHARE envelope on its approval cards #847 if both land together.changelog.d/fragment.Order is fixed by the Chief: #847 → #848 → then this. I make no source change before those two land.
Birfy commentedon Oct 23, 2026
Cross-repo half split out: #853 — the claim + connector exception for a stranger redeeming the same link, with the four fail-closed rejections. This issue stays the in-repo slice; nothing there changes the three-envelope contract or the
localInviterecord.Birfy commentedon Oct 23, 2026
Design review (not a verdict) — paper review of the issue body (including the "Endpoint contract" section) and the two scope comments, read against main
fb38c4b0. There is no PR yet, so this is not a conclusion and not a request to hold anything.Blocking: the issue holds two designs at once
The body's contract has bot-social mint the ticket (
POST /v1/local-room-invites, publicGET,claim,DELETE), and says "the local half stays inert behind alocal_room_invitesfeature until they exist". The later scope comment (6363122056) says the ticket is minted locally and resolved by anaskenvelope, with no server step in the in-repo slice. Those are not two phases of one design: one requires a server round-trip before a link can be previewed, the other explicitly avoids it.As written, an implementer can follow either and be wrong: the body's version ships nothing usable, the comment's version leaves the four endpoints unused. Please pick one for
#852and move the other under#853(the cross-repo half), then restate the in-repo contract in the body so the acceptance can be written against it. My read of the last two comments is that the in-repo design is the local-mint one; the body's contract is #853's.Blocking: the link cannot address the inviter
The local-mint design says the ticket is "a random 22-char token" and the invitee "sends
askto the handle the link resolved from the inviter's own share". Nothing in that sentence survives the code:parseFaceInviteLinkandFACE_INVITE_TICKET(plugins/social/src/face-rooms.js:46-58):[A-Za-z0-9_-]{16,64}, no dot. A ticket likelr1.<base64url…>never parses, and the client never parses a ticket, so it would be sent as an opaque string nobody can interpret.{home, roomId, expiry}does not fit that shape:{"h":"@chief.selftesta2.home","r":"session-e2439f82-0c89-4bba-a7d7-7d60572c19fd","e":1761212345678}is 132 base64url characters, twice the cap. Even home+expiry alone (47 characters) fits the alphabet but names no Bot, andsend_as_memberneeds a Bot handle, not a home, to address theask./r/<handle>/<ticket>, which changes the parser for both kinds), (b) a second path segment or query the client owns, or (c) the server lookup from the body's contract — which moves the preview behind A local-room invite link cannot be redeemed by a home that cannot already reach the inviter (cross-repo: claim + connector exception) #853.Also state the consequence of local-only tickets for the URL itself:
/r/<ticket>is bot-social's page and a locally minted ticket has no record there, so until #853 ships the link only works when pasted into FlyBot (the web page 404s). Either say that in the Expected section or stop promising a clickable page.Blocking: the envelope layer would silently drop the new fields and mis-route the new ops
core/src/shared/room-share.jsis a fixed schema, not a generic envelope:encodeRoomShare(:13-38) writes a closed field list.ticket,accept,lead,expiresAt(and a chosenbotId/handle) are dropped unless they are added there — a plan that assumes they travel is wrong until this file changes.parseRoomShare(:41-67) rejects every payload withoutroomIdand accepts onlyjoin|leave|line. The invitee'saskdoes not know the room id, so it parses as invalid and is dropped. Per-op validation (askneedsticket;acceptneedsticket+ handle) has to be written down.applyInboundRoomShare(core/src/host/room-membership.js:1138-1165) creates an inbound-share room for any non-leaveop when no room matches. Ifask/acceptwere merely added toOPS, the inviter's host would project a room from a knock, and the invitee's host would project one from aninviteanswer. The new ops need their own branch before that path, and the contract should say they never create a room, never add a participant, and never append a line.Details the local contract still has to pin
acceptedByon the room'slocalInvite, written at accept (never at preview), same-home retry answers success without adding twice, another home is refused, re-mint makes the old ticket unknown.event.fromHandle/event.fromHome), never a self-reportedbotIdin the JSON — and the admitted handle must belong to that home. The envelope'sinviter*fields are the sender's, so do not reuse them for the asker; name the new fields.join, with the retry of the same accepted ask a no-op success. Otherwise a lostjoinreply leaves A with the member and B with no room and no way in. List the refusals one per line (unknown, expired, replaced, consumed by another home, sender is not the accepting home, room deleted, inviter unreachable) and say no room state is created on either side before the admit succeeds.roomIdin the envelope is informational and never authorization. A ticket for room A pasted while aiming at room B must preview and admit A or refuse — never B.#817). "Their Bot" in the plan is not enough to write a test.Notes for the cross-repo contract (#853)
GET …/{ticket}returnsclaimeronly to "the minting account's credential". Pin which credential the inviter's host uses on that re-read; a home configured with a connector token gets 403 on owner endpoints (#44), so the comparison "claim is B" is unreadable there. If the host cannot readclaimer, it must refuse, not fall back toclaimed: true.DELETEis minting-only today).POST …/claimfrom a home that is already in the room should be idempotent (200), not409 already_claimed.Nothing here changes the scope split or the order
#847 → #848 → #852; it is all about what the in-repo contract must say before the branch is cut.10 remaining items
feat(social): two invite-link shapes, and a local room link is answer…
feat(social): two invite-link shapes, and a local room link is answer…
feat(social): two invite-link shapes, and a local room link is answer…
Birfy commentedon Oct 23, 2026
The remaining half of this issue now has its own ticket: #876 (Members card "Invite by link" row, the redemption dialog, and hoisting the room preview card into a shared component). #864 covers the wire only; this issue stays open until #876 lands.
Birfy commentedon Oct 23, 2026
The two halves are now tracked separately, and this umbrella stays open until both land:
ask/inviteenvelopes, the admission that runs verify → consume → add →join,acceptedBywritten once, the five local refusals, the two-shapeparseInviteLink, and the named refusal of a local link before the feature gate. Delivered by feat(rooms): answer a local room's invite link (#852) #864 (PASS on0d1f5db8); that PR can now declareFixes #877.Neither half closes this issue on its own.
feat(rooms): answer a local room's invite link (#852) (#864)
Birfy commentedon Oct 23, 2026
The ticket contract fixed here is superseded for the seat count by #878 (owner decision 2026-10-23): one ticket = one seat for one home, the redemption may carry several Bots of that home, and a room keeps several live tickets at once. The rest of the contract (22 characters, 10 minutes, cleared on revoke / room delete / leave, fail-closed) stands. #878 and #876 both have to land before this umbrella closes.
Send a room-scoped line by naming the room
feat(rooms): seat a stranger's Bots through a link Bot Social records (…
feat(social): a room link's shares reach exactly its seat, under that…
feat(rooms): Invite by link in the Members card, with its QR, and Joi…
fix(rooms): name the pair a room link seated (ThinkFlowLab#927)