Skip to main content
Markdown

Team meetings

A video meeting for each team, project or event: people join from a button, see and hear each other, share their screens, and leave. Your server decides who may join, and the lead ends the meeting for everyone from a button of their own.

This page builds it with you from an empty folder: a small Node.js app with a design team that Ann leads and Ben is on. Cara is not on the team, so the meeting turns her away. Every file is here in full, and you run it in three browser windows at the end. Then your own app changes three things.

Ann, Ben and Cara in one group call from three browsers, with the call screen grid: each sees the other two and their own camera, then the call controls. The cameras are illustrations

How it goes​

Ann leads the design team, user 34 in your app. Ben is on the team, user 42. Cara is not, user 43.

  1. Ann presses Join the meeting. The button names the meeting team:7, the group call of team 7. There is nothing to create first: the first one to join opens it.
  2. Her page asks your server whether Ann may join team:7: the SDK sends a request to /api/realtime/calls on your own server, with her usual cookie. It is your own route, not ours: WordPlus Cloud never sees this request.
  3. Your server runs your rule for Ann and team 7. She is on the team, so it says yes, and your server signs the answer with your secret.
  4. With that answer, Ann is in the meeting, on our media servers, with her camera and microphone.
  5. Ben joins the same way, and each sees and hears the other. Their video and sound are encrypted end to end, with a key your server gives only to those your rule lets in.
  6. Cara presses Join the meeting too. Your rule says no, as she is not on the team, and her page shows You cannot make this call.
  7. Ann presses End the meeting for everyone. Her page posts to a route of your own, which checks that she leads the team and ends the meeting: Ben is out at once.

A meeting also closes on its own once the last person leaves, and the same name opens a new one later.

You write none of the requests of steps 2 to 4: the SDK in the page and the WordPlus package on your server make them. You write the rule, one line for the route, the route that ends the meeting, and a page with the buttons, as below.

What you need​

  • Node.js 22 or newer.
  • An app in WordPlus Cloud. Apps are in early access, so first ask for it. Then in your account, go to Apps, then Add app, and give it the address http://localhost:3000. You get its key and its secret (getting started).
  • Three browser windows, each signed in as someone else: a normal window, a private one, and one of another browser.

Step 1. Make the project​

Make a folder, and in it the file package.json:

mkdir team-meetings
cd team-meetings
package.json
{
"name": "team-meetings",
"private": true,
"type": "module",
"scripts": {
"postinstall": "npm run build",
"build": "esbuild page.js --bundle --format=esm --target=es2022 --outfile=public/page.js",
"start": "node --env-file-if-exists=.env server.mjs"
},
"dependencies": {
"@wordplus/realtime": "^1.0.0",
"express": "5.3.0",
"express-session": "1.19.0"
},
"devDependencies": {
"esbuild": "0.28.2"
}
}
  • The dependencies: the WordPlus SDK, Express for the server, and express-session for who is signed in.
  • postinstall runs after npm install: it bundles your page script. The browser files of the SDK, its worker, room client and ready-made parts, come from the package routes, so there is nothing to copy.
  • build bundles page.js with esbuild into public/page.js, which the browser loads.
  • start starts the server, with your keys from .env.

Step 2. Your keys​

Make the file .env, with the key and secret of your app:

.env
# Your app in WordPlus Cloud: Apps, then Add app,
# with http://localhost:3000 among its addresses.
WORDPLUS_SITE=
WORDPLUS_SECRET=
# Your own: openssl rand -hex 32. Keep it as long as the app.
WORDPLUS_LOCAL_KEY=
# Signs the session cookie: any long random string.
SESSION_SECRET=
  • WORDPLUS_SITE is the key of your app, such as s1500000007.
  • WORDPLUS_SECRET is its secret. It stays on your server, and signs every answer of your rule.
  • WORDPLUS_LOCAL_KEY is a key of your own, which seals what goes through WordPlus Cloud and makes the keys of encrypted meetings. Make one with openssl rand -hex 32.
  • SESSION_SECRET signs the session cookie of this example: any long random string.

Step 3. The server​

The file server.mjs. Read it from the top: each part has a comment, and the notes under it say more.

server.mjs
// Team meetings: a video meeting for the design team. Ann leads it, Ben is
// on the team, and Cara is not. The tutorial:
// https://docs.wordplus.cloud/use-cases/meetings/
//
// cp .env.example .env # your app key and secret, and a local key
// npm install # bundles page.js
// npm start # http://localhost:3000
import express from 'express';
import session from 'express-session';
import { createRealtime } from '@wordplus/realtime/server';

// Your users and your team. A real app has them in its database.
const USERS = {
34: { name: 'Ann' },
42: { name: 'Ben' },
43: { name: 'Cara' },
};
const TEAM = { id: 7, name: 'Design team', lead: 34, members: [34, 42] };

// The WordPlus package, with your keys from .env.
const realtime = createRealtime({
site: process.env.WORDPLUS_SITE,
secret: process.env.WORDPLUS_SECRET,
localKey: process.env.WORDPLUS_LOCAL_KEY,
// The name the others see in the meeting.
profile: (id) => (USERS[id] ? { name: USERS[id].name } : null),
});

// The rule: who may join the meeting of a team, such as team:7.
// It runs each time someone joins.
realtime.groupCalls('team:{id}', (user, { id }) => {
// The members of the team, and nobody else.
if (!(Number(id) === TEAM.id && TEAM.members.includes(user))) return false;
// The lead hosts it: mute, let speak and remove, from the call screen.
return user === TEAM.lead ? { admin: true } : true;
}, { encrypt: true }); // its video and sound encrypted end to end

const app = express();

// Who is signed in, kept in a session cookie.
// Your app has its own sign-in: this stands in for it.
app.use(session({
secret: process.env.SESSION_SECRET || 'change me',
resave: false,
saveUninitialized: false,
}));

// The route the page asks before it joins, /api/realtime/calls,
// and the others the package answers, for the user of the session.
app.use('/api/realtime', realtime.node((req) => req.session.user ?? null));

app.use(express.urlencoded({ extended: false }));
app.use(express.static('public'));

// Sign in as Ann, Ben or Cara.
app.get('/login', (req, res) => res.send(`<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Sign in</title></head>
<body>
<h1>Sign in</h1>
<form method="post" action="/login">
<button name="as" value="34">Ann, who leads the team</button>
<button name="as" value="42">Ben, on the team</button>
<button name="as" value="43">Cara, not on the team</button>
</form>
</body>
</html>`));

app.post('/login', (req, res) => {
if (USERS[req.body.as]) req.session.user = Number(req.body.as);
res.redirect('/');
});

// Ending the meeting, for the lead alone: the button of Ann posts here,
// and your server ends the call for everyone in it.
app.post('/meetings/7/end', async (req, res) => {
if (req.session.user !== TEAM.lead) return res.sendStatus(403);
await realtime.call('team:7').end();
res.redirect('/');
});

// The button that joins the meeting, for everyone: the rule decides.
const JOIN_BUTTON = `<wordplus-room room="team:7" type="video" title="Design team">
Join the meeting
</wordplus-room>`;
// The button that ends it, for the lead.
const END_BUTTON = `<form method="post" action="/meetings/7/end">
<button>End the meeting for everyone</button>
</form>`;

// The page: the settings of the user, then the buttons.
app.get('/', async (req, res) => {
const me = req.session.user;
if (!USERS[me]) return res.redirect('/login');

const config = await realtime.config(me);
const settings = JSON.stringify(config).replace(/</g, '\\u003c');

res.send(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>${TEAM.name}</title>
<script type="application/json" id="realtime">${settings}</script>
<script type="module" src="/page.js"></script>
</head>
<body>
<h1>Hello, ${USERS[me].name}</h1>
${JOIN_BUTTON}
${me === TEAM.lead ? END_BUTTON : ''}
</body>
</html>`);
});

const port = Number(process.env.PORT || 3000);
app.listen(port, () => console.log(`http://localhost:${port}`));

What each part does:

  1. Your users and your team. Ann, Ben and Cara, and the design team, team 7, which Ann leads and Ben is on. In your app, they come from your database.
  2. The WordPlus package. createRealtime() takes your keys, where the page finds the browser files (rooms is the client of group calls), and profile: the name the others see in the meeting.
  3. The rule. realtime.groupCalls() is called once, for every group call whose name fits team:{id}. It runs each time someone joins, with user the one joining and id the team: yes for a member of team 7, no for anyone else. For Ann, who leads it, the yes is { admin: true }: she hosts, and the call screen gives her Mute Microphone, Block and Allow Broadcast and Kick User on the tile of each of the others. Each asks your route, which runs this rule again for her. encrypt: true encrypts the video and sound end to end.
  4. The route. app.use( '/api/realtime', … ) is the one line that answers the page when it asks before joining (step 2 of how it goes). It gives the package the user of the session, and the package runs your rule for them. /api/realtime is only the usual address: any other works, as any address you like shows.
  5. Signing in. Three buttons, for Ann, Ben and Cara. Your app has its own sign-in instead.
  6. Ending the meeting. POST /meetings/7/end checks that the one asking leads the team, then realtime.call( 'team:7' ).end() takes everyone out of the meeting at once. A page cannot end a meeting for the others by itself: it asks your server, which decides.
  7. The buttons. JOIN_BUTTON is the <wordplus-room> element, which joins team:7. Everyone gets it, Cara too, to show that the rule decides, not the page. END_BUTTON is a form that posts to the route of part 6, and only Ann gets it.
  8. The page. realtime.config( me ) is what the page needs to connect as the signed-in user, and goes into the page as JSON. Then the buttons.

Step 4. The page script​

The file page.js. It runs on every page:

page.js
// The script of every page: it connects, then starts the call screen,
// which shows the meeting once you join it.
import { connect } from '@wordplus/realtime';

// The settings your server wrote into the page: the user and their keys.
const settings = JSON.parse(document.getElementById('realtime').textContent);

const rt = await connect(settings);
await rt.callScreen();
  • connect() connects the page as the signed-in user, with the settings your server wrote into the page.
  • rt.callScreen() starts the call screen. It makes the <wordplus-room> button work, and shows the meeting once you join: a grid of everyone, the one speaking marked, the camera, the microphone, screen sharing and Leave.

Step 5. Install and start​

npm install
npm start

npm install also bundles public/page.js. npm start prints http://localhost:3000.

Step 6. Meet​

  1. Be Ann. Open http://localhost:3000 in a normal window, and press Ann, who leads the team. You see Hello, Ann, Join the meeting, and End the meeting for everyone.
  2. Be Ben. Open it in a private window, and press Ben, on the team. You see Hello, Ben, and Join the meeting.
  3. Be Cara. Open it in another browser, and press Cara, not on the team. She sees Join the meeting too.
  4. Ann joins. As Ann, press Join the meeting, and let the browser use the camera. The meeting opens, with her own camera.
  5. Ben joins. As Ben, press Join the meeting. Each window now shows the other person beside its own camera.
  6. Cara tries. As Cara, press Join the meeting. She sees You cannot make this call, and stays out.
  7. Ann ends it. As Ann, press End the meeting for everyone. The meeting closes for Ben too.

What happened​

Each step of how it goes, and the code that did it:

StepWhat happenedThe code
1Ann pressed Join the meeting<wordplus-room room="team:7"> in her page, from server.mjs
2Her page asked your serverthe SDK, started by connect() and rt.callScreen() in page.js, posting to /api/realtime/calls
3Your server ran your rule, and signed a yesapp.use( '/api/realtime', realtime.node( … ) ), then realtime.groupCalls( 'team:{id}', … )
4, 5Ann, then Ben, were in the meetingWordPlus Cloud, on its media servers, encrypted end to end by encrypt: true
6Cara was turned awaythe same rule, which said no for her
7Ann ended it for everyoneEND_BUTTON, then app.post( '/meetings/7/end', … ) and realtime.call( 'team:7' ).end()

More on steps 2 to 4: how a page asks your server.

If it does not work​

  • Ann or Ben sees You cannot make this call: your rule said no. They must be in TEAM.members, and the button must name team:7.
  • The meeting window opens, but nobody shows: group calls need the room client, which the package routes serve at /api/realtime/files/…. They must answer GET too: app.use( '/api/realtime', realtime.node( … ) ) does.
  • End the meeting answers Forbidden: only the lead may end it. Sign in as Ann.
  • No page connects: .env must hold the key and secret of your app, and http://localhost:3000 must be among its addresses in WordPlus Cloud.
  • No camera: a browser gives the camera only to https pages and to localhost.

Into your own app​

The same pieces, with three changes:

  1. Your users and teams. USERS and TEAM read your database, and the rule asks it whether the user is on the team named by id.
  2. Your sign-in. In realtime.node( ( req ) => … ), give the user your own sign-in found, in place of req.session.user.
  3. The buttons. Show Join the meeting to the members of the team, and the buttons that run it to its lead. Your rule and your routes still decide, whatever the page shows.

On another stack, the guide of each sets up the route and the settings in the page: Next.js, Laravel, Django, FastAPI and WordPress. The rule is then the same in each language:

// The members of the team, and nobody else.
realtime.groupCalls( 'team:{id}', async ( user, { id } ) => isMemberOf( id, user ), { encrypt: true } ); // encrypted end to end
  • encrypt: true encrypts its media end to end, with a key your server makes.
  • An answer for one person may say more: { hidden: true } for someone who listens unseen, { publish: false } for someone who only watches (permissions).

Running it from your server​

A lead the rule answers admin: true gets mute, let speak and remove on each tile of the call screen, with nothing more to write. Beyond those, the lead runs the meeting through routes of yours, as /meetings/7/end does above: mute someone, take them out, or end it. Each route checks that the lead asks, then calls one of these (buttons for the host):

const meeting = realtime.call( 'team:7' );
await meeting.get(); // who is in it, and what each sends
await meeting.mute( 42, 'microphone' ); // a microphone left on
await meeting.remove( 42 ); // out now, and their tokens from before refused
await meeting.end(); // over, for everyone

realtime.listCalls() lists the meetings going on, for a page that says which teams meet now.

Your own interface instead​

The call screen and the button are optional. The same meeting with screens of your own, on the calls API, after connect():

const calls = await rt.calls();
const meeting = await calls.group( 'team:7', { type: 'video' } );

const media = await navigator.mediaDevices.getUserMedia( { video: true, audio: true } );
await meeting.setTrack( 'camera', media.getVideoTracks()[ 0 ] );
await meeting.setTrack( 'microphone', media.getAudioTracks()[ 0 ] );

meeting.on( 'person', ( person ) => { // arrived, or what they send changed
person.camera?.attach( tileOf( person.user ).video );
person.screen?.attach( tileOf( person.user ).screen );
person.microphone?.attach( tileOf( person.user ).audio );
} );
meeting.on( 'person_left', ( person ) => removeTile( person.user ) );
meeting.on( 'speakers', ( people ) => highlight( people.map( ( p ) => p.user ) ) );

shareButton.onclick = async () => {
const screen = await navigator.mediaDevices.getDisplayMedia( { video: true } );
await meeting.setTrack( 'screen_share', screen.getVideoTracks()[ 0 ] );
};
muteButton.onclick = () => meeting.setTrack( 'microphone', null ); // stops sending it
leaveButton.onclick = () => meeting.leave();

Group calls need the room client, which the package routes serve and the settings name, so connect( settings ) finds it (calls).

Variations​

  • A meeting with an agenda and notes: a room of the same name beside it, with emit: [ 'note' ] (editing together).
  • Invite someone who is not on the team: a call between two people that someone adds to becomes a group call of its own (calls).
  • Speakers and an audience: most people listening, a few speaking (classes and audio rooms).
  • A lobby before the meeting: the lead admits each guest (waiting rooms).
  • Credits: up to 32 people on video at once take no credits with 1,000 connections, and each one above takes a credit a minute (limits).