# `@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.

```bash
pnpm add @ai-matrx/meet
```

Built on [`@ai-matrx/data/realtime`](../realtime/README.md) for signaling and
[`@ai-matrx/messaging`](../messaging/README.md) 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 `useCalls().canCall` is false and no call control renders 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 labeled 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)

Four subpaths (CORE-DESIGN §5.1): `@ai-matrx/meet/core` (framework-free; `@ai-matrx/meet`
re-exports it), `@ai-matrx/meet/react` (provider, hooks, `<MeetingSkinRoot>`, the record and
ringing surfaces), and one per skin — `@ai-matrx/meet/skins/meet` holds the Meet skin's parts
(`ControlBar`, `ParticipantTile`, `Captions`, …) for a host layout that composes them.

```tsx
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, MeetingSkinRoot, useCalls } 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: your own control, calling through the hook.
const calls = useCalls();
calls.canCall && <button onClick={() => calls.call([userId], "video")}>Call</button>

// Your meeting route — the skin root chooses the viewer's skin (Meet until S9):
<MeetingSkinRoot roomName={roomName} meetingId={meetingId} />
```

The stage fills the viewport (`100dvh`). To run a meeting inside a box — a board tile, a split
pane — give an ancestor a definite height and set `--mx-meet-height: 100%` on it; every screen
(pre-join, lobby, room, record) then fills that box.

🚨 **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:

```tsx
<MeetProvider client={supabase} baseUrl={AIDREAM_URL} organizationId={orgId}
              guestName={typedName} accessToken={async () => null}>
  <MeetingSkinRoot roomName={meeting.roomName} slug={slug} />
</MeetProvider>
```

Invitations ride the messaging seam — register the handlers once:

```tsx
<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_ROUTES` in `src/core/tokens.ts`.
- **The `communication.meet_*` schema in Matrx Main** — the tables and RPCs named in
  `src/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

```bash
pnpm --filter @ai-matrx/meet typecheck
pnpm --filter @ai-matrx/meet test
pnpm --filter @ai-matrx/meet check:package   # build + publint + packed-tarball canary
```

MIT licensed. Source: `aidream/apps/shared/meet`.
