Guides/Co-streaming and studio layouts
Co-streaming and studio layouts
A host brings someone from the audience onto the stage, and their screen and both cameras go out as one picture to a recording and to every platform you stream to. The pieces are a stage invite the guest has to accept, a scene that says what the picture shows, and a broadcast whose destinations you can change without stopping it.
The shape of a co-stream
Everyone, on stage or not, is a participant in the same room. What separates the audience from the people on stage is their role: a viewer subscribes, a guest publishes a camera and a microphone, a host publishes those plus a screen and runs the show. Going on stage is a role change, and a stage invite is a role change the other person has to agree to.
- Stage invite
- A pending offer to move one participant into another role. Nothing changes until they accept. It expires on its own, and both sides hear about every step on the velo.stage data topic and through webhooks.
- Scene
- One per room. Which participants and which of their tracks appear, in what arrangement, on what background. It is versioned, so two producers cannot silently overwrite each other.
- Studio layout
- A recording or broadcast started with layout studio renders the room's scene. Change the scene and every running studio egress follows within a second, without a restart and without dropping viewers.
- Broadcast outputs
- The RTMP urls a running broadcast pushes to. Add Twitch, YouTube or Kick mid show, or drop one, while the others keep streaming.
Roles for the stage
The default template has two roles. host publishes audio, video and a screen share and holds every moderation permission. guest publishes audio and video. Add a third, subscribe only role for the audience so that joining a room does not put anyone on air.
curl -X PUT https://api.usevelo.xyz/v1/templates/tpl_0123456789ab/roles/viewer \
-H "Authorization: Bearer ***" \
-H "Content-Type: application/json" \
-d '{
"publish": { "allowed": [] },
"permissions": { "send_data": true }
}'Mint each person a room token for their starting role. Your backend does this with the project key, and the browser only ever sees the room token.
curl -X POST https://api.usevelo.xyz/v1/tokens \
-H "Authorization: Bearer ***" \
-H "Content-Type: application/json" \
-d '{ "room": "launch-show", "identity": "host-1", "role": "host" }'
curl -X POST https://api.usevelo.xyz/v1/tokens \
-H "Authorization: Bearer ***" \
-H "Content-Type: application/json" \
-d '{ "room": "launch-show", "identity": "viewer-208", "role": "viewer" }'Moving into a role also moves the participant's publish ceiling, the velo.publish.max_kbps attribute the media server enforces. A viewer promoted to guest can publish a camera at the guest's bitrate straight away, without reconnecting. See templates and roles for what a ceiling is made of.
Inviting someone on stage
The host calls the invite endpoint with their own room token. A token needs the change_role permission to invite, which host has. Your server can do the same with the project key, for example from a moderation dashboard.
curl -X POST https://api.usevelo.xyz/v1/rooms/launch-show/stage-invites \
-H "Authorization: Bearer $HOST_ROOM_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: invite-viewer-208-1" \
-d '{
"identity": "viewer-208",
"role": "guest",
"ttl_seconds": 120,
"message": "Want to demo your build with me?"
}'| Field | Type | Required | Description |
|---|---|---|---|
| identity | string | Yes | Who to invite. They must be in the room right now. |
| role | string | Yes | A role from the room's template that they do not already hold. |
| ttl_seconds | integer | No | Between 10 and 3600. Defaults to 120. |
| message | string | No | Up to 280 characters, shown to the invitee. |
{
"invite": {
"id": "stg_4be19c07a2d3",
"project_id": "prj_9c2f4a71b0d8",
"room": "launch-show",
"participant_identity": "viewer-208",
"role": "guest",
"previous_role": "viewer",
"message": "Want to demo your build with me?",
"status": "pending",
"invited_by_identity": "host-1",
"invited_by_api_key_prefix": null,
"expires_at": "2026-10-05T18:02:00Z",
"responded_at": null,
"created_at": "2026-10-05T18:00:00Z",
"updated_at": "2026-10-05T18:00:00Z"
},
"delivered": true
}Velo stores the invite, then sends it to the invitee and to the inviter as a reliable data message on the velo.stage topic. delivered says whether the media server took that message. The invite exists either way, so an app that was reconnecting when it was sent finds it by listing pending invites.
{
"v": 1,
"type": "stage_invite.created",
"invite": { "id": "stg_4be19c07a2d3", "participant_identity": "viewer-208", "role": "guest", "status": "pending" }
}A participant can only have one pending invite per room. A second one is refused with 409 stage_invite_exists until the first is answered, cancelled or expires. Withdraw an invite with POST /v1/stage-invites/{id}/cancel.
Accepting or declining
Only the invitee can answer with a room token. Your server can answer on their behalf with the project key, which is how you would build an auto accept for a known co-host.
curl -X POST https://api.usevelo.xyz/v1/stage-invites/stg_4be19c07a2d3/accept \
-H "Authorization: Bearer $VIEWER_ROOM_TOKEN" \
-H "Content-Type: application/json" \
-d '{}'Accepting applies the role live, exactly as a role change would: new grants, the new velo.roleattribute and the new publish ceiling reach the participant without a reconnect. The response carries the answered invite, the role change it caused and the participant's new state. Once it returns, the guest can turn on their camera.
- accept
- stage_invite.accepted, then participant.role_changed.
- decline
- stage_invite.declined. The role is untouched.
- cancel
- stage_invite.cancelled, raised by the inviter or your server.
- no answer
- stage_invite.expired, once, about thirty seconds after expires_at at the latest.
To take someone off stage, change their role back with POST /v1/rooms/{name}/participants/{identity}/role. Leaving the stage needs no consent, so it needs no invite.
In the browser
createStage from @usevelo/client wraps all of this for a connected room. It authenticates with the room token, listens on the stage and scene topics, and ignores any message on them that came from another participant rather than from Velo, so a peer cannot forge an invite.
import { connectToRoom, createStage } from "@usevelo/client";
const room = await connectToRoom({ token, url });
const stage = createStage({ room, token, baseUrl: "https://api.usevelo.xyz" });
stage.onInvite(async ({ invite }) => {
const yes = await confirmDialog(invite.message || "The host invited you on stage");
if (yes) {
await stage.accept(invite.id);
await room.localParticipant.setCameraEnabled(true);
await room.localParticipant.setMicrophoneEnabled(true);
} else {
await stage.decline(invite.id);
}
});
stage.onInviteUpdate(({ type, invite }) => {
updateGuestList(invite.participantIdentity, type);
});const { invite, delivered } = await stage.invite("viewer-208", {
role: "guest",
ttlSeconds: 120,
message: "Want to demo your build with me?",
});
if (!delivered) {
showToast("The invite was saved; it will show when their app next lists invites");
}
await stage.cancel(invite.id);A participant who joins or reconnects after an invite was sent will not see the data message. List pending invites once after connecting.
const { items } = await stage.listInvites({ status: "pending", identity: room.localParticipant.identity });
for (const invite of items) {
promptForInvite(invite);
}The scene
The scene decides what a studio recording or broadcast shows. Every room has one; until it is set it is the default at version 0, which shows every screen share and camera in the room in the presentation layout.
curl -X PUT https://api.usevelo.xyz/v1/rooms/launch-show/scene \
-H "Authorization: Bearer $HOST_ROOM_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"layout": "presentation",
"slots": [
{ "identity": "host-1", "source": "screen_share" },
{ "identity": "host-1", "source": "camera" },
{ "identity": "viewer-208", "source": "camera" }
],
"background": "#0B0B0F",
"show_names": true,
"fit": "cover",
"expected_version": 2
}'| Field | Type | Required | Description |
|---|---|---|---|
| layout | string | No | presentation, side_by_side, pip, grid or single. Defaults to presentation. |
| slots | array | No | Up to nine entries of identity, source (camera or screen_share) and an optional track_name. Order is placement order. Empty means everyone, screen shares first, then by join time. |
| background | string | No | A #RRGGBB colour. Defaults to #0B0B0F. |
| show_names | boolean | No | Draw each participant's name on their tile. Defaults to true. |
| fit | string | No | cover or contain for cameras. Screen shares are always contained, so slides are never cropped. |
| expected_version | integer | No | The version you last read. If the scene has moved on, the write is refused with 409 scene_version_conflict. |
- presentation
- The first slot fills most of the frame, the next four stack in a column on the right. The usual choice for a screen plus cameras.
- side_by_side
- Up to three slots split the frame evenly. Two people talking.
- pip
- The first slot fills the frame, up to three more float over its bottom right corner.
- grid
- Up to nine slots in an even grid.
- single
- Only the first slot. Cut to one person or to the screen.
{
"room": "launch-show",
"scene": {
"layout": "presentation",
"slots": [
{ "identity": "host-1", "source": "screen_share" },
{ "identity": "host-1", "source": "camera" },
{ "identity": "viewer-208", "source": "camera" }
],
"background": "#0B0B0F",
"show_names": true,
"fit": "cover"
},
"version": 3,
"updated_at": "2026-10-05T18:01:12Z",
"updated_by_identity": "host-1",
"updated_by_api_key_prefix": null,
"studio_available": true,
"egresses": { "updated": ["EG_hX3k9QpL2mRt"], "failed": [] }
}A write is stored first, then pushed to every studio egress running in the room. egresses.updated lists those that took it, egresses.failed those that did not. A failure there does not undo the write; the scene is still the new one, and the next write or the next egress picks it up. Every client in the room also receives the new scene on the velo.scene topic, which is how a producer panel on a second machine stays in step.
const current = await stage.getScene();
await stage.setScene(
{
layout: "pip",
slots: [
{ identity: "host-1", source: "screen_share" },
{ identity: "viewer-208", source: "camera" },
],
},
{ expectedVersion: current.version },
);
stage.onScene(({ version, scene }) => {
renderLayoutPicker(scene.layout, version);
});Changing the scene
A room token needs start_recording to change the scene and can read it with any role. A slot naming someone who has not joined, or a track that is not published, is skipped rather than drawn as a black box, so you can set up the scene before the guest arrives.
Two cameras from one person are two tracks with the same source. Publish the second one under its own name and point a slot at it with track_name.
const overhead = await createLocalVideoTrack({ deviceId: overheadCameraId });
await room.localParticipant.publishTrack(overhead, {
name: "overhead",
source: Track.Source.Camera,
});
await stage.setScene({
layout: "presentation",
slots: [
{ identity: "host-1", source: "screen_share" },
{ identity: "host-1", source: "camera", trackName: "overhead" },
{ identity: "viewer-208", source: "camera" },
],
});Going live with the studio layout
Start a broadcast or a room composite recording with layout set to studio. Template destinations accept it too. Everything else about the broadcast is unchanged: up to eight RTMP urls, stream keys redacted on read, the same broadcast.* webhooks.
curl -X POST https://api.usevelo.xyz/v1/rooms/launch-show/broadcasts \
-H "Authorization: Bearer ***" \
-H "Content-Type: application/json" \
-d '{
"urls": ["rtmp://live.twitch.tv/app/live_123456_abcdef"],
"layout": "studio"
}'curl -X POST https://api.usevelo.xyz/v1/rooms/launch-show/recordings \
-H "Authorization: Bearer ***" \
-H "Content-Type: application/json" \
-d '{ "type": "room_composite", "layout": "studio" }'The studio layout is drawn by a compositor page that the egress service loads. A self hosted deployment enables it by setting STUDIO_TEMPLATE_URLon the control plane; the bundled compose files point it at the console's /studio/composite route. Without it, asking for the studio layout answers 503 studio_unavailable, and studio_available on the scene reads false.
Changing destinations live
A running broadcast can gain or lose a destination without stopping. The people watching on the other platforms see nothing happen.
curl -X POST https://api.usevelo.xyz/v1/broadcasts/EG_hX3k9QpL2mRt/outputs \
-H "Authorization: Bearer ***" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: add-youtube-1" \
-d '{ "url": "rtmp://a.rtmp.youtube.com/live2/abcd-efgh-ijkl-mnop" }'{
"id": "out_7d1e44b0c9a2",
"egress_id": "EG_hX3k9QpL2mRt",
"url": "rtmp://a.rtmp.youtube.com/live2/REDACTED",
"status": "active",
"added_at": "2026-10-05T18:14:03Z",
"removed_at": null
}curl -X DELETE https://api.usevelo.xyz/v1/broadcasts/EG_hX3k9QpL2mRt/outputs/out_7d1e44b0c9a2 \
-H "Authorization: Bearer ***"import { VeloClient } from "@usevelo/client";
const velo = new VeloClient({
baseUrl: "https://api.usevelo.xyz",
apiKey: process.env.VELO_API_KEY as string,
});
const youtube = await velo.addBroadcastOutput(egressId, process.env.YOUTUBE_RTMP_URL as string, {
idempotencyKey: `youtube-${showId}`,
});
const { outputs, limit } = await velo.listBroadcastOutputs(egressId);
await velo.removeBroadcastOutput(egressId, youtube.id);From your server
- At most eight outputs are active at once. The ninth is refused with 409 output_limit_reached.
- The same url cannot be active twice. Remove it first, and it can be added again later.
- The only active output cannot be removed; stop the broadcast instead. That is 409 last_output.
- Urls are encrypted at rest and every read redacts the stream key. Removing an output twice returns it both times.
- Once a broadcast is ending or has ended, its outputs are frozen, and changes answer 409 broadcast_ended.
- Adding needs start_recording on a room token and removing needs stop_recording. Listing needs the project key.
Previewing the scene in your app
@usevelo/client/scene exports the layout engine the compositor itself uses. Hand resolveSceneTilesthe scene and the room's participants and it returns each tile with its track, its rectangle as fractions of the frame, its fit and its stacking order. A producer view drawn from it matches the stream exactly.
import { resolveSceneTiles } from "@usevelo/client/scene";
function participantsOf(room) {
return [room.localParticipant, ...room.remoteParticipants.values()].map((participant) => ({
identity: participant.identity,
name: participant.name,
joinedAt: participant.joinedAt?.getTime(),
tracks: [...participant.trackPublications.values()].map((publication) => ({
sid: publication.trackSid,
source: publication.source,
name: publication.trackName,
kind: publication.kind,
muted: publication.isMuted,
})),
}));
}
stage.onScene(({ scene }) => {
for (const tile of resolveSceneTiles(scene, participantsOf(room))) {
placeVideo(tile.trackSid, {
left: `${tile.rect.x * 100}%`,
top: `${tile.rect.y * 100}%`,
width: `${tile.rect.width * 100}%`,
height: `${tile.rect.height * 100}%`,
objectFit: tile.fit,
zIndex: tile.zIndex,
});
}
});The entry has no dependency on livekit-client, so it also runs on a server or in a worker, for example to render a thumbnail of the current layout.
Errors you will meet
- 409 stage_invite_exists
- The participant already has a pending invite in this room. The message names it.
- 409 already_in_role
- The participant already holds the role you invited them to.
- 409 stage_invite_expired
- The invite passed expires_at before it was answered.
- 409 stage_invite_not_pending
- It was already accepted, declined or cancelled.
- 404 stage_invite_not_found
- No such invite, or a room token from another room asked for it.
- 404 participant_not_found
- The person you invited is not in the room.
- 403 permission_denied
- A room token without the permission the call needs, or someone other than the invitee answering.
- 400 invalid_scene
- A scene field failed validation. The field is named in the error.
- 409 scene_version_conflict
- Someone changed the scene since you read it. Read it again and reapply your change.
- 503 studio_unavailable
- This deployment has no studio compositor configured.
- 409 output_exists
- The broadcast already streams to that url.
- 409 output_limit_reached
- Eight outputs are already active.
- 409 last_output
- Removing it would leave the broadcast with nowhere to go.
- 409 broadcast_ended
- The broadcast is ending or has ended.
What this does not do
- One room, not two.The guest joins the host's room. Linking two separate rooms so each audience watches the other is not something Velo does.
- One picture per broadcast. Every output of a broadcast gets the same composite. For a vertical copy, start a second broadcast with its own outputs.
- No overlays or graphics. The compositor draws video, names and a background colour. Lower thirds, logos and alerts belong in your app or in a custom layout.
- Participant recordings ignore the scene. Only room composite recordings and broadcasts can use the studio layout.
Was this page useful?