@ai-matrx/meet
v0.7.8
Published
Enterprise, AI-native calls and meetings for Matrx clients: LiveKit Cloud rooms with server-minted tokens, instant ring/accept/decline, Zoom-grade meetings with durable links, lobby admission and guest join, host controls, Egress recording with enforced c
Maintainers
Readme
@ai-matrx/meet
Enterprise, AI-native calls and meetings for every Matrx client: tap a person and be in a call in two seconds, or run a meeting that rivals Zoom — lobby, host controls, screen share, recording — where the AI half is not an add-on but the reason ours is better.
One provider, one component, under an hour to full production conferencing including the AI.
pnpm add @ai-matrx/meetBuilt on @ai-matrx/realtime for signaling and
@ai-matrx/messaging for invitations. There is zero hand-rolled
.channel( code in this package, and there must be none in yours.
Why this package exists
Video calling is the feature every team estimates at two weeks and ships in three months, because the hard parts are not the tiles. They are: who is allowed in this room, what happens when the token expires mid-meeting, why the camera light stays on after you leave, why the phone in the other room is still ringing after you answered on your laptop, why the headset you unplugged is still "selected", and what a guest who has no account is allowed to do. Every one of those has a name in this README and a regression test beside it.
We are deliberately inflexible. LiveKit Cloud is the media infrastructure, hardcoded (D1/D2). There is no provider abstraction and never will be. The package does exactly what we want it to do, and that is the product.
The doctrine
1. The client NEVER holds a LiveKit secret.
Not in an env var, not in a build-time constant, not "just for local dev". A LiveKit API secret in a browser bundle mints a token for any room in the project, including meetings the holder was never invited to — it is a full compromise of every conversation in the organization.
So the package has no code path that could accept one. It asks aidream; aidream mints. That is the only authorization path into a room (R1), and a guest's entire capability is the token they were given: exactly one room, nothing else (R5).
2. A token is refreshed before it bites, and minted ONCE.
Refresh happens at 80% of the token's life, not when a reconnect fails at 100% — the classic two-hour-meeting bug is a session that outlives its token and cannot come back. And every concurrent asker shares ONE mint: a React strict-mode double effect that mints twice gives one identity two LiveKit sessions, and LiveKit disconnects the older one. That looks exactly like "my call randomly drops when I switch tabs".
A token with no expires_at is refused rather than used, because a token the package cannot
schedule a refresh for is a meeting that will silently fail to reconnect.
3. The phase never lies. There is no infinite spinner.
requesting-token → lobby | connecting → connected ⇄ reconnecting → left, and any failure becomes
failed with the remedy attached to the snapshot. Reconnecting is a visible state, because a
frozen tile with no explanation is indistinguishable from a crashed app.
Device failures get four different sentences, not one shrug: permission denied, no such device,
device already in use by another app, and insecure context (getUserMedia does not exist on
http). A single "media error" would tell the user the wrong thing three times out of four.
4. A call reaches you on TWO legs, and rings ONCE.
The durable leg is the invite row and its Postgres Changes event: it survives a reload, a late-joining tab, and a dropped socket, and it is what makes a missed call a fact rather than a lost packet. The instant leg is a broadcast on your user channel, with no database round trip. Both are deduplicated on the invite id.
Neither is a fallback for the other. Remove the durable leg and calls get lost; remove the instant leg and ringing feels slow.
Around that: every ring ends (an unanswered call writes missed), answering on one device
stops every other (the settled signal on your own channel), being in a call answers busy
rather than ringing out, and the state machine refuses to resurrect a settled call when a late
accept arrives.
5. A guest is a first-class identity, not a degraded user.
A meeting link works for anyone (D6). Guests enter a name, wait in the lobby, and the host admits them. Authenticated users skip the prompt; host powers require authentication.
This is enforced by TYPES, not by discipline: MeetIdentity.userId is UserId | null, and every
operation that needs an account takes a UserId. A guest provider has no call center at all —
so <CallButton> renders nothing rather than a disabled button (no dead ends).
6. Admission is a server decision. Roles come from the token.
admit is an auth-checked RPC; the broadcast that follows only tells the knocker, who has no
database read of their own. Two hosts clicking "admit" is the ordinary case, and the RPC is what
makes it idempotent.
Every participant's role is read from the server-minted token metadata. A client that could decide it was host could mute anyone. And a co-host cannot remove the host — without that rank check, "host controls" is a mutual-eviction button.
7. A durable link is an identity, not a URL for one occurrence.
A meeting slug resolves before, during, and after its scheduled time, and a recurring meeting keeps ONE link. A link that dies when the meeting ends breaks every "here are the notes" follow-up, which is when people actually go looking for it.
Slugs deliberately exclude the characters people mishear reading them aloud: no 0/O, no
1/l.
8. Consent is a property of the package, not a prop.
There is no showRecordingIndicator={false}. Recording someone without telling them is illegal in
a two-party-consent jurisdiction and indefensible everywhere else, so the indicator and the
join-time notice ship with the package. The only knob is the org's recordingPolicy — disabled
/ host-controlled / always-on — which decides whether recording may happen at all, never
whether people are told.
The indicator turns solid when the server says the egress is running, never on a client's hope; a refused start reverts it rather than leaving a red dot over a recording that is not running. And a recording lands in the platform files system as a file id, never a provider URL — a LiveKit URL is an orphan the day the room is garbage collected.
9. The AI is a participant, and it looks like one.
The note-taker joins the room as a real LiveKit participant with is_agent in its token metadata,
so it renders as a labelled tile. An invisible listener in a meeting is the thing nobody should
ever ship.
Everything AI executes on the platform agent system through @ai-matrx/agents. The package never
talks to a model, never holds a prompt, and never carries an agent definition — it takes agent IDs
as injected identity, and an unconfigured capability reports unavailable with the remedy
while the UI hides that action entirely.
🚨 THE USER-INPUT LAW. user_input is what a human typed. Transcripts, rosters, and agendas
travel as named variables. The server stores and frames user_input as the turn's human
utterance, so smuggling a transcript through it corrupts every conversation it touches. Exactly
one call in this package sends a user_input: the in-meeting question a person actually asked.
Interim transcript lines never reach an agent, and a truncated transcript says so on the wire.
10. The stage is arithmetic, and it never shuffles.
Screen share (or an explicit pin) owns the stage; then active speakers, then raised hands, then cameras-on, then stable by join time. Tiles that reshuffle on every roster event are the most disorienting thing a meeting UI can do.
A large meeting pages and says +N more people — it does not render 200 videos and die, and it
does not silently drop the people it cannot fit (R5).
11. Nothing fails silently, and no control is a lie.
Every error is a MeetError with a stable code and a remedy sentence. Every stand-in
announces itself: an unplugged headset says "your remembered microphone is no longer connected —
using the system default" rather than quietly switching you to the laptop mic.
A control an actor cannot use is absent, never disabled-looking and never a button the server will refuse.
The one-hour integration (R7)
import "@ai-matrx/meet/tokens.css"; // defaults — override these with your brand
import "./brand.css";
import "@ai-matrx/meet/styles.css"; // structural, last
import { MeetProvider, IncomingCallHost, MeetingRoom, CallButton } from "@ai-matrx/meet/react";
export function App({ children }) {
return (
<MeetProvider
client={supabase}
baseUrl={AIDREAM_URL}
displayName={user.name}
organizationId={org.id}
accessToken={async () => (await supabase.auth.getSession()).data.session?.access_token ?? null}
transport={matrxTransport}
agents={{ noteTaker: NOTE_TAKER_AGENT_ID, liveIntelligence: LIVE_AGENT_ID }}
>
{/* Mount ONCE. Calls now ring on whatever surface the user is on. */}
<IncomingCallHost />
{children}
</MeetProvider>
);
}
// Anywhere a person appears:
<CallButton person={{ userId, displayName, avatarUrl }} />
// Your meeting route:
<MeetingRoom roomName={roomName} meetingId={meetingId} />🚨 You do not tell Meet who the user is, and there is no prop for it. The acting user is read
from client's own session — the same client that mints the JWT on every request — so the id in a
p_user_id argument and the id in the token cannot disagree. Through 0.5.x this WAS a prop, every
host filled it from a copy in its own state, and a rotated auth cookie left that copy naming an
account the tab could no longer prove it was; meet_pending_call_invites refused those reads at
403 with "the acting user … is not the authenticated user". Pass displayName and avatarUrl —
they are chrome — and nothing else about identity. A userId prop is ignored and says so.
A guest join is the same provider with guestName, which is the one way to declare the guest
lane. A guest has no account, so that mount reads no session at all:
<MeetProvider client={supabase} baseUrl={AIDREAM_URL} organizationId={orgId}
guestName={typedName} accessToken={async () => null}>
<MeetingRoom roomName={meeting.roomName} slug={slug} />
</MeetProvider>Invitations ride the messaging seam — register the handlers once:
<MessagingProvider
actions={[
createCallInviteHandler({ calls, repository, onJoin: (invite) => router.push(`/meet/${invite.roomName}`) }),
createMeetingInviteHandler({ onOpen: (payload) => router.push(`/meet/${payload.slug}`) }),
]}
>What the host owns, and what it must never do
Inject: the Supabase client, aidream's base URL, who the user is (or a guest name), the org, an access-token source, the Matrx transport, agent ids, brand token values, and a router.
Never: catch a package error to reinterpret it, retry a package call, validate a package input, or branch on a package quirk. That logic belongs in here (C22) — open a PR against this package, release it, and delete the massage.
Server prerequisites
This is the client half. Two server-side pieces are required and are not built yet:
- The aidream meet router — token minting (identity, grants, TTL, guest + lobby gating),
Egress start/stop, LiveKit webhooks that keep the database TRUE-current, and the LiveKit Agents
note-taker. The exact routes this package calls are
MEET_ROUTESinsrc/core/tokens.ts. - The
communication.meet_*schema in Matrx Main — the tables and RPCs named insrc/core/repository.ts, under the platform's canonical DB conventions.
Until both exist the package fails loudly, with a remedy naming the missing piece. It never
fabricates a room, a token, or a meeting record. See FEATURE.md.
Gates
pnpm --filter @ai-matrx/meet typecheck
pnpm --filter @ai-matrx/meet test
pnpm --filter @ai-matrx/meet check:package # build + publint + packed-tarball canaryMIT licensed. Source: aidream/apps/shared/meet.
