CADS-Tunnel docs
How-to

Invite someone into your channel with a link

Self-serve a channel membership grant covers the case where you know the e-mail of who should join and allow-list it ahead of time. This page covers the other case: you already know exactly which identity should join — a demo’s waiting room, a participant a bridge is handing something concrete to — because they’ve already generated their key material and sent you the public half. Instead of asking for their e-mail and hoping they log in with a matching one, you mint a link bound to that specific identity. They log in, click confirm, and land with a real membership under their own account.

Same caveat as the e-mail allow-list flow, confirmed against the same handler chain (claim_invite_confirm runs the identical do_claim as POST /portal/channels/:channel/claim): confirming an invite adds a channel_members row, it does not mint or return a CT_CHANNEL_GRANT. A standard ct-agent channel client still needs a real grant from the operator (ct-agent channel grant) after confirming — see the same warning on [Self-serve a channel membership grant](/how-to/self-service-channel-grant/#1-the-channel-owner-allow-lists-an-e-mail) for what happens if you skip that step.

1. The joiner generates key material locally and sends you the public half

Same primitive Set up an Agent-Fabric channel’s step 1 and the e-mail allow-list flow’s step 3 both use — private keys never leave the machine that generates them:

CT_CHANNEL_OPERATOR_PUBKEY=<channel operator's pubkey> \
CT_CHANNEL_BRIDGE_HOLDER=<the peer's holder_pubkey> \
CT_CHANNEL_HOLDER_KEY=<your own holder private key> \
CT_CHANNEL_NOISE_PUBKEY=<your own noise public key> \
./ct-agent channel member-material
holder_pubkey     = ...
noise_pubkey      = ...
channel_id        = ...
noise_attestation = ...

They send you holder_pubkey, noise_pubkey and noise_attestation — the three public values, nothing private.

2. The channel owner mints the invite

API-only today — no portal button for this step yet, unlike the confirm side below. Owner-scoped, same bearer token as every other /me/channels call (see Self-service channel registry):

curl -X POST https://bunsenbrenner.org/me/channels/<64 hex channel id>/claim-invites \
  -H "Authorization: Bearer <your OIDC token>" \
  -H "Content-Type: application/json" \
  -d '{"holder":"<holder_pubkey>","noise_pubkey":"<noise_pubkey>","noise_attestation":"<noise_attestation>","label":"optional name"}'
{"invite":"<token>","url":"https://bunsenbrenner.org/portal/claim?invite=<token>","expires_at":1234567890}

The attestation is checked at mint time, not left for the joiner to hit as a confusing failure — a bad key fails right here with a 400. The link is single-use and expires in 15 minutes; send the url to the joiner however you’d normally reach them (there’s no in-band delivery, this only mints the link).

Not logged in yet? The link round-trips through login and back to itself. Once logged in, the page shows the channel, the label (if the owner set one), and the holder’s identity, with a single Join channel button — nothing to type, no channel id to communicate separately. Confirming records the membership under the joiner’s own sign-in, exactly like the allow-list-and-claim flow, so it shows up on both accounts’ channels page and the owner can revoke it the same way.

A used or expired link shows a plain “already used” / “expired” message rather than a raw error — safe to click twice by accident, the second click just won’t do anything.

See also

Found an error, or something that didn't work as documented? Open an issue →