Skip to content

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.

bashAn audience role
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.

bashA host and a viewer
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.

bashPOST /v1/rooms/{name}/stage-invites
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?"
  }'
Request fields
FieldTypeRequiredDescription
identitystringYesWho to invite. They must be in the room right now.
rolestringYesA role from the room's template that they do not already hold.
ttl_secondsintegerNoBetween 10 and 3600. Defaults to 120.
messagestringNoUp to 280 characters, shown to the invitee.
json201 Created
{
  "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.

jsonvelo.stage, abbreviated
{
  "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.

bashPOST /v1/stage-invites/{id}/accept
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.

tsThe viewer's app
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);
});
tsThe host's app
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.

tsCatching up after a reconnect
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.

bashPUT /v1/rooms/{name}/scene
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
  }'
Scene fields
FieldTypeRequiredDescription
layoutstringNopresentation, side_by_side, pip, grid or single. Defaults to presentation.
slotsarrayNoUp 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.
backgroundstringNoA #RRGGBB colour. Defaults to #0B0B0F.
show_namesbooleanNoDraw each participant's name on their tile. Defaults to true.
fitstringNocover or contain for cameras. Screen shares are always contained, so slides are never cropped.
expected_versionintegerNoThe 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.
json200 OK
{
  "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.

ts
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.

tsA second camera
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.

bashBroadcast to Twitch in the studio layout
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"
  }'
bashRecord the same picture
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.

bashPOST /v1/broadcasts/{egress_id}/outputs
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" }'
json201 Created
{
  "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
}
bashDELETE /v1/broadcasts/{egress_id}/outputs/{output_id}
curl -X DELETE https://api.usevelo.xyz/v1/broadcasts/EG_hX3k9QpL2mRt/outputs/out_7d1e44b0c9a2 \
  -H "Authorization: Bearer ***"
ts
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.

tsA live preview
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.

Edit this page on GitHub