Repository navigation
feat(app): ask a teammate's host to join the team over the tailnet - #563
Merged
Merged
Conversation
A host that isn't in a team finds peers whose hello names a team directory and offers "<person> on <machine> is in a team. Ask to join". The peer's owner approves or denies in their app; on approval the directory URL and secret travel over the tailnet and the asking host joins as with directory-set. Asks end on deny, disconnect, a repeat ask, or after five minutes. Why: apps/host/src/protocol.ts#type Hello | hello answers any tailnet peer, so team carries only the directory's host, never the secret Why: apps/host/src/gateway.ts#function asksChanged | only the local app can answer an ask (team-answer is a keychain op), so asks reach local sockets only
iamnbutler
added a commit
that referenced
this pull request
Oct 22, 2026
#561) * feat(app): invite teammates with one link that fills in Join your team The Invite screen and Settings → Team → Invite… copy an ace://join link carrying the directory URL and team secret. Pasting it into either join form fills both fields, and the desktop app registers its channel's scheme so opening the link brings up Join your team, filled in, for the person to check and join. Why: apps/desktop/electrobun.config.ts | each build channel registers its own scheme (ace, ace-canary, ace-dev) so a dev or Canary build never answers stable's invite links; apps/app/src/invite.ts reads all of them. Why: apps/app/src/invite.ts#function parse | an invite link only fills the join form; joining must stay an explicit Check and join, since a link can name any directory. * lane: confirm notes for invite link * lane: sync invite-link memory * feat(app): send invites as an https link to the join page Chat apps linkify https, not ace://. The link opens ace.githubnext.com/join, which offers the download and hands off to ace://join. Pasting either form still fills Join your team. Why: apps/app/src/invite.ts#function link | the invite must stay in the URL fragment so the secret never reaches the site's server * lane: confirm invite parse note * lane: sync invite-link memory * feat(app): ask a teammate's host to join the team over the tailnet (#563) * feat(app): ask a teammate's host to join the team over the tailnet A host that isn't in a team finds peers whose hello names a team directory and offers "<person> on <machine> is in a team. Ask to join". The peer's owner approves or denies in their app; on approval the directory URL and secret travel over the tailnet and the asking host joins as with directory-set. Asks end on deny, disconnect, a repeat ask, or after five minutes. Why: apps/host/src/protocol.ts#type Hello | hello answers any tailnet peer, so team carries only the directory's host, never the secret Why: apps/host/src/gateway.ts#function asksChanged | only the local app can answer an ask (team-answer is a keychain op), so asks reach local sockets only * lane: confirm notes for ask to join * lane: sync ask-to-join memory
iamnbutler
added a commit
that referenced
this pull request
Oct 22, 2026
… from the app (#553) * feat(app): offer Get your team started or Join your team on first run * chore: confirm the app selection note * fix(app): turn off autocorrect in the team setup fields * feat(app): deploy the team's Cloudflare services from Get your team started Ace Helper ships prebuilt directory and channel bundles and deploys them with the owner's wrangler login, naming them after the tailnet, then joins the directory. The Set up step shows progress instead of a command to copy. Fly.io still runs the checkout script. Why: apps/host/src/team.ts | Ace Helper runs wrangler as plain Bun via BUN_BE_BUN, and deploys the bundles apps/desktop/scripts/helper.ts writes to Resources/services; both layouts must stay in step. * chore: confirm gateway and helper notes after adding team-deploy * lane: sync team-onboarding memory * feat(app): link Deploy to what it creates in the team's Cloudflare account * feat(app): invite teammates with one link that fills in Join your team (#561) * feat(app): invite teammates with one link that fills in Join your team The Invite screen and Settings → Team → Invite… copy an ace://join link carrying the directory URL and team secret. Pasting it into either join form fills both fields, and the desktop app registers its channel's scheme so opening the link brings up Join your team, filled in, for the person to check and join. Why: apps/desktop/electrobun.config.ts | each build channel registers its own scheme (ace, ace-canary, ace-dev) so a dev or Canary build never answers stable's invite links; apps/app/src/invite.ts reads all of them. Why: apps/app/src/invite.ts#function parse | an invite link only fills the join form; joining must stay an explicit Check and join, since a link can name any directory. * lane: confirm notes for invite link * lane: sync invite-link memory * feat(app): send invites as an https link to the join page Chat apps linkify https, not ace://. The link opens ace.githubnext.com/join, which offers the download and hands off to ace://join. Pasting either form still fills Join your team. Why: apps/app/src/invite.ts#function link | the invite must stay in the URL fragment so the secret never reaches the site's server * lane: confirm invite parse note * lane: sync invite-link memory * feat(app): ask a teammate's host to join the team over the tailnet (#563) * feat(app): ask a teammate's host to join the team over the tailnet A host that isn't in a team finds peers whose hello names a team directory and offers "<person> on <machine> is in a team. Ask to join". The peer's owner approves or denies in their app; on approval the directory URL and secret travel over the tailnet and the asking host joins as with directory-set. Asks end on deny, disconnect, a repeat ask, or after five minutes. Why: apps/host/src/protocol.ts#type Hello | hello answers any tailnet peer, so team carries only the directory's host, never the secret Why: apps/host/src/gateway.ts#function asksChanged | only the local app can answer an ask (team-answer is a keychain op), so asks reach local sockets only * lane: confirm notes for ask to join * lane: sync ask-to-join memory * lane: confirm gateway note after merging main
This was referenced Oct 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A teammate can join without being sent anything. Their Ace finds a teammate's host on the tailnet that is already in a team and offers " on is in a team. Ask to join". The teammate approves in their own Ace, and the directory URL and secret move host to host over the tailnet. Part of #538 and #550, stacked on #561.
hellonow carriesteam, the directory's host only (never the secret). Peers already ask every tailnet peer forhello.team-peersop asks each open peer for a freshhello, so a teammate who joined later shows up. The first-run choice screen and Join your team both list them, refreshing every 5 s.team-ask(local owner only) sendsteam-requestto that peer and waits.whois, which now also returns the machine name. It then pushes{ asks }to its local app only.team-answeris a keychain op, so only the local owner can answer. Approving hands overinvite(), and the asking host runs the existingsetDirectory: check, then store.Smoke test
Two source hosts with separate homes and Keychain services, both on the real tailnet listener, plus a local
wrangler devdirectory.Exercised end to end:
helloto a tailnet peer socket carriedteam: 127.0.0.1:8791and no secret.team-answerandteam-invite.team-requestover the tailnet socket, with a realwhois, made A's app show the prompt. Deny made the asker fail with "They declined…".Not exercised: B finding A through
team-peersand its UI offer. Peers come fromtailscale status, which never lists your own machine, so two hosts on one Mac can't discover each other. I sent the ask over the same kind of tailnet socket thatpeers.tsopens instead. The 5-minute expiry also wasn't waited out.Built in Ace
No. Claude Code built this outside Ace while the user was away, from the user's phone request. Ace can't yet run an unattended multi-lane pass; see #5.