Guides

A customer joins the LiveKit room from a link, not a seat

Heliune guest call rooms are public /call/{room} pages. The customer copies no password. The agent copies a link. Listen, whisper, and barge stay on the same room.

A Heliune voice room has two doors. The agent and the supervisor walk in from the workspace calls page. The customer walks in from /call/{room}. That second door is a guest call room. It is not a seat. It is not the inbox. It is not a second phone product with its own login.

The omnichannel note already names listen, whisper, and barge. This page names the link the customer actually opens, the token that lets them publish, and what they cannot hear. If you only needed “we have voice,” you would stop at the other guide. If you need to know how a stranger joins without a workspace account, stay here.

Go available opens the room

On the calls page the primary action is Go available. That posts to /api/calls. The handler requires calls.join. It then checks voice minutes. Free and Starter have zero. Pro has fifty. Business has two hundred. Extra packs are three hundred minutes. If the plan has no minutes, the room does not open. The error is “Voice is on Pro and Business.”

A new call starts ringing. The room name is heliune.{orgId}.{suffix}. The prefix is the product slug. The org id is bound into the name so a token for one workspace cannot join another workspace’s room by guessing a suffix. The customer name on that row starts as “Waiting for customer” until someone on the guest page types a name.

Recording defaults on. The list row shows a REC badge when recordingEnabled is true. Ending the call writes a recording object on the same workspace: rec_{callId}, duration, a transcript preview that is just “Voice session · mm:ss,” and canListen. call.ended fires automations. If nobody took a ringing room and you mark it missed, call.missed fires instead.

The customer link is the public door

While the call is live — ringing, active, or on hold — the row has a Customer link button. Copy link on the voice-room panel does the same thing. The URL is {origin}/call/{roomName}. The room name is encoded. There is no invite token in the query string. The room name is the capability.

That path is public in the proxy. /call/ and /api/livekit/guest do not require a workspace session. The widget has the same pattern: a visitor hits /widget/{id} without signing in. Chat uses the widget. Voice uses the guest room. Do not paste a /calls workspace URL to the customer. They cannot open it.

The live guest page is noindexed on purpose. robots already disallow /call/. Search should find this note, not a ringing room. The title on that page is “Join call.” It is a door, not a marketing URL.

What the guest actually sees

The guest page is a single panel. The kicker says Heliune voice. The heading is Join this call. A name field asks the customer to identify themselves so the agent knows who is on the line. Join call posts { roomName, name } to /api/livekit/guest.

  • If the name is empty, the guest token still issues. The display name falls back to the call’s customerName, then to “Customer.”
  • If the room is not live, the response is 404: “This call is not available.” Ended and missed rooms do not hand out tokens.
  • Twenty join attempts per minute per client and room return 429. The guest is told to wait a minute.
  • After a token, the heading becomes Connected, or You’re in the queue when LiveKit keys are missing.
  • Leave ends the tab. The copy says they can close it. It does not hang up the agent’s room by itself.

The guest identity on the LiveKit token is customer-{callId}. The role is customer. Customers can publish. They are not hidden. They are not supervisors. The staff token route is a different door: it requires a session, calls.join or calls.supervise, and it re-checks that the room belongs to the caller’s org.

Two token routes, one room

Staff tokens come from /api/livekit/token. The body can ask for agent, customer, or supervisor. A supervisor without calls.supervise gets 403. An agent without calls.join gets 403. The room name is parsed. If the org in the name does not match the session org, the response is “Call not found.” findOrgCallByRoom runs the same check against the workspace snapshot.

Guest tokens skip that session. They look up the call by room name, including the org embedded in heliune.{orgId}.{suffix}. If the call is not live, there is no token. That is the whole authorization model for a stranger: the room must exist and still be ringing, active, or on hold.

createLiveKitToken is shared. Without LIVEKIT_API_KEY and LIVEKIT_API_SECRET the function returns token: null and demo: true. The guest page then shows You’re in the queue — “This workspace has not connected live audio yet. Your place in line is held for the agent.” The calls page says the same on the agent side: the customer link still holds their place. Do not invent a second hold product. Demo is the missing keys.

Listen, whisper, and barge stay on the staff door

The guest page never offers listen, whisper, or barge. Those buttons sit on the live row next to Customer link. Listen is subscribe-only and hidden: the supervisor hears the room and does not publish audio. Whisper and barge can publish. Whisper writes supervisorMode onto the token metadata. The supervisor identity is supervisor-{mode}.

The guest VoiceSession sets ignoreWhisper. When a remote track arrives from an identity that starts with supervisor-, the client reads that metadata. If supervisorMode is whisper, the guest does not attach the audio. The agent still hears the whisper. The customer does not. That is the product difference between whisper and barge. Barge publishes in the clear. Listen publishes nothing.

A lead who wants to hear the room uses listen from the workspace. They do not open /call/{room} and pretend to be the customer. The guest door is for the person on the other end of the support call. Mixing those identities is how you leak a whisper.

The inbox thread is still a different object

A widget visitor already has an inbox conversation keyed by widget id and visitor id. A guest call does not open that thread by itself. The calls list is the voice object. The inbox row is the chat object. They can belong to the same customer in the workspace story. The guest link does not create a widget-channel conversation.

If the visitor started in a pane or a floating launcher, you still reply in the inbox. If they then need to talk, you go available, copy the customer link, and send it on that thread. The supervisor who later whispers is on the LiveKit room, not on the widget iframe. Do not attach a “voice widget.” Voice minutes are a plan entitlement. The guest page is the join surface.

Assignee on the inbox header is still a person on the thread. It is not the agentName on the call row. Unassigned on the call means nobody in the workspace joined as agent yet. Those are two fields. The rail can draft a flow or a widget while the room is live. It cannot mint a guest token. list_workspace will not show /call/{room}.

What happens when the room closes

End on a live row sets status to ended, or missed if it was still ringing. Duration is the elapsed seconds from startedAt. Voice seconds increment by the delta. If you have used the month’s minutes, the next Go available fails with a pack-or-upgrade error. The guest who still has the tab open will get “This call is not available” on a refresh, because isLiveCall is false.

The recording lands in the recordings list with canListen. That listen is playback of the file, not supervisor listen on a live room. The names collide in English. They are different buttons. Supervisor listen needs calls.supervise and a live token. Recording listen needs the file the end-call path wrote.

Automations that watch call.ended can open a follow-up task on the customer the inbox already knows. Native actions are still assign.supervisor_queue, assign.round_robin, tag.add vip, and create.task. The guest page does not fire those. The workspace does, after the room closes.

Plans, keys, and what Free cannot do

Free is two seats, one widget, one flow, twenty-five builder messages, and no voice minutes. You can still resolve and snooze in the inbox. You cannot Go available. Starter raises seats, widgets, flows, and automations. Voice minutes stay at zero. The guest page exists in the codebase either way. Without minutes, there is no live room to copy a link from.

Pro is where the customer link becomes real: fifty voice minutes, unlimited widgets and flows, custom dashboards, custom MCP. Business raises minutes to two hundred. Extra voice packs are three hundred minutes. Those packs do not add a second join URL. /call/{room} is the same door on both plans.

LiveKit keys are environment configuration, not a plan row. A Pro workspace without LIVEKIT_URL still opens a demo queue. The guest sees You’re in the queue. The agent sees the same warning on the voice-room panel. Add the keys if you want audio. Do not tell a customer the product is broken because the demo hold appeared. The hold is honest.

A worked join

You are on Pro. You click Go available. The list shows a ringing row, queue Support, customer Waiting for customer, REC on. You copy the customer link. You paste it into the inbox thread the widget already opened, or into email. The stranger opens /call/heliune.{yourOrg}.{suffix}.

They type a name. Join call hits /api/livekit/guest. The call is live, so they get a customer token. Connected. Waiting for the agent to join — unless you already joined as agent, in which case they hear you. A supervisor can hit listen on the same row. The guest does not see that person. If the supervisor switches to whisper, the agent hears the lead; the guest’s ignoreWhisper path drops that track.

You end the room. The guest tab says Call ended. The workspace writes the recording and runs call.ended. If you resolve the inbox thread next, conversation.resolved is a different trigger on a different object. Do not expect the guest hangup to resolve the chat.

What this is not

It is not a calendar booking page. There is no public schedule. Go available is the agent offering a room now. It is not a Zoom personal meeting id that stays valid all week. When the call is ended or missed, the link dies. It is not a widget display mode. Floating, page, and pane are chat placements. The guest room is a full-page join panel.

It is not a way to skip Pro. The guest route does not check voice minutes because the room already exists. Creating the room did. If you try to share a leftover link from a closed call, the guest gets 404. If you try to share a workspace /calls URL, the proxy sends them to login.

It is not a second inbox. Messages typed in the guest panel are not stored as chat. The panel collects a name, then audio. Notes after the call still belong on the inbox row you already had, or on the recording preview. The rail will not “join the call” for you. Supervisor modes are token grants, not tool calls.

Failure modes you can name

404 on join: the room is not live, the name was typed wrong, or the call already ended. 429: too many posts to /api/livekit/guest for that room. 402 on Go available: no voice minutes, or the month is used up. 403 on staff join: missing calls.join or calls.supervise. 404 on staff token: the org in the room name is not your org.

Guest hears a supervisor they should not: ignoreWhisper only skips identities that start with supervisor- and metadata supervisorMode whisper. A barge is supposed to be heard. A listen supervisor should be hidden by the LiveKit grant, not by the guest client. If listen is audible, the token grant is wrong, not the guest page copy.

Empty recordings list after a long talk: end the call from the workspace so durationSec and recordVoiceDelta run. Closing the guest tab is not End. Demo rooms without keys never attach VoiceSession on the guest side, so there is no live audio to record.

How this sits next to the other notes

The omnichannel page is the shell: inbox, widget, room, rail. The widget page is the chat install. The builder page is the rail that drafts those objects. This page is only the customer door on the room. If a sentence here could describe any “click to join” vendor, it does not belong. The names that have to survive are /call/{room}, customer-{callId}, ignoreWhisper, heliune.{orgId}.{suffix}, and Customer link.

Search should keep finding this note. The guest frame, the inbox, and /api stay noindexed. A stranger comparing voice products should finish knowing they will send a link, not create a seat, and that whisper is a staff grant the guest client is built to ignore.

More guides