# Meet

URL: https://docs.hanzo.ai/docs/openapi/meet

> The virtual office: it decides who may join a room and mints the short-lived token that lets them in.

Also for this capability: **API** · [CLI](https://docs.hanzo.ai/docs/cli/meet) · [MCP](https://docs.hanzo.ai/docs/mcp-tools/meet) · [SDKs](https://docs.hanzo.ai/docs/sdks)

The virtual office: it decides who may join a room and mints the short-lived token that lets them in.

> [All API references →](https://docs.hanzo.ai/docs/openapi) · [Get started →](https://docs.hanzo.ai/docs/guides)

|                |                                        |
| -------------- | -------------------------------------- |
| **Base URL**   | `https://api.hanzo.ai`                 |
| **Operations** | 7                                      |
| **Auth**       | `Authorization: Bearer $HANZO_API_KEY` |

## Specification

> **HIP-1252 · Meet — The Join Decision** — Draft · [read the specification →](https://github.com/hanzoai/hips/blob/7ed737180339cd05b5f2935eafe7f7b83d2449a2/HIPs/hip-1252-meet-the-join-decision.md)

Meet is the virtual office's one server decision: may this caller join this
room. `hanzoai/cloud` `apps/meet` answers it at `/v1/meet` by minting a
short-lived join token; the media itself — audio, video, screen share — rides a
direct browser-to-LiveKit WebRTC connection and never passes through the API
binary.

This HIP declares the capability: no store, the token wire that must not
change, the SPA address that violates the address rule and how it resolves, and
the stage.

### Motivation

A call needs exactly one thing from a server that owns tenant rows: an answer,
signed with the media plane's key, that a specific person may enter a specific
room. Everything else a "meetings service" might hold — presence, media,
recording — is either the media server's or nobody's. Holding only the decision
means no meet pod, no second copy of membership, and no media proxy to become a
bandwidth product.

### Specification

The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted
as in RFC 2119.

#### §1 The store

The capability owns none. Its only material is the LiveKit signing key, read
from the same `keys.yaml` the media server validates against (Secret
`livekit-keys`, mounted read-only; `apps/meet/meet.go`). There MUST be one
representation of that key: a second copy projected into env would mint tokens
refused at the media edge, silently. Membership is not held here either — it is
asked of `team`, which owns the workspace rows, over the internal plane
(`apps/meet/meet.go:144`, `cloud.Ask` of `TeamMember` / `TeamWorkspaces`).

#### §2 The address

The surface is `/v1/meet`, and it is the whole of it: `getToken`, `session`,
`health`. There is no second, versionless root any more.

The call client is not a cloud address. It is its own image
(`ghcr.io/hanzoai/meet`, built from `hanzoai/admin` `apps/meet` at base `/`) on
its own host, `meet.hanzo.ai`, served by `ghcr.io/hanzoai/static`. Cloud
embedded a copy at `/meet` and `/meet/*` — a HIP-0139 §3.3 violation ledgered
at `openapi/misfiled.txt` — and that copy is deleted rather than folded: a fold
to `/v1/meet/ui` would have kept a browser app on the API origin under a
tidier name, and by then the real destination was already serving.

Deletion cost the client nothing, which is what made it the right shape rather
than the bold one: its two server calls are `/v1/meet/session` and
`/v1/meet/getToken`, it carries a bearer for both, and both are answered
identically whether or not the bundle shares this origin. The media plane never
touched either — a direct browser-to-LiveKit connection.

#### §3 Operations

`GET /v1/meet/health` is typed (`apps/meet/meet.go:361`). The other two are
declared with prose beside the route and cannot be values:
`POST /v1/meet/getToken` answers the raw token as `text/plain` — one opaque
string, read by the published office client with `res.text()`, and a typed
operation always marshals JSON (`apps/meet/meet.go:262`). That wire MUST NOT
change. `GET /v1/meet/session` answers a shape assembled per caller — who they
would be seated as, where the media plane is, which workspaces they may open a
room in — already narrowed to what the mint would grant, so offer and grant
cannot drift (`apps/meet/meet.go:280`).

#### §4 Tenancy

The caller is verified by IAM and by nothing else (HIP-0026): meet holds no
key that verifies a caller, so there is no second bearer authority to disagree
with the first. What the principal MAY do is asked of `team` per request. The
identity stamped into the token is the server's — the caller's team account —
never the request body's: the media server evicts a duplicate identity, so a
caller-chosen one would let anyone eject a colleague. A minted token lives ten
minutes and is spent immediately by the connect that follows. Missing or
ambiguous key material is a 503 that names the file and Secret in the boot log
while the caller gets an unadorned "not configured".

#### §5 Metering, events, telemetry, stage

The capability is metered (`plugin/meet/main.go:28`, `Price: cloud.Metered`;
`spend.go:305`), and the one billed act is the seat: minting a join token
admits a participant to the media server, gated before the token exists and
debited per seat at `MEET_FEE_CENTS` (`apps/meet/meter.go`). The unit is the
seat, not the minute, because media rides browser-to-SFU directly and no
duration ever reaches this binary — pricing a minute would invent a number
nobody measured. The lobby, the health probe and the SPA stay free: they are
reads. It publishes no events on the bus and emits nothing to observability
beyond the request span every route gets. Stage: `beta` — a virtual-office
vertical, not part of the agentic-OS `ga` set; the manifest row declares it
(`manifest/apps.go:360`, `Stage: Beta`), so per HIP-0139 §8 the prefix
answers 404 to orgs without the `meet` flag.

#### §6 Upstream

The server derives from none: the join token is LiveKit's access-token JWT,
implemented here over the standard library's HMAC, with no LiveKit code
embedded. The embedded SPA ships LiveKit's own client stack —
`@livekit/components-react` over `livekit-client`, Apache-2.0 — inside a
committed Vite build (`apps/meet/ui/dist`), because a hand-rolled WebRTC client
is a second implementation of a protocol whose reference implementation the
media server already ships against.

### Rationale

The alternative is a meetings service with its own store: rooms, membership,
presence. Every row it held would be a copy of something `team` or the media
server already owns, and the copies would disagree. Holding only the signing
key and asking the owner per request keeps one authority per fact, at the cost
of one internal-plane round trip on the mint path.

### Security Considerations

The wrong implementation admits an attacker to a room: a mint that skips the
membership ask, a caller-supplied identity that evicts a colleague, or a token
long-lived enough to be worth stealing. Each is closed above — the ask is per
request, the identity is the server's, the ttl is ten minutes. The remaining
exposure is the signing key itself, which exists in one file shared with the
media server, is never projected into env, and whose absence fails closed with
a reason that does not enumerate secret plumbing to the caller.

## Four surfaces

| Surface  | Reaches this capability as                   | Coverage     |
| -------- | -------------------------------------------- | ------------ |
| **REST** | `meet` at its own prefix                     | 7 operations |
| **CLI**  | `hanzo meet …`                               | 7 of 7       |
| **SDK**  | `MeetApi` in every published client          | 7 methods    |
| **MCP**  | tool `meet` on `https://api.hanzo.ai/v1/mcp` | 5 operations |

## Quickstart

```bash
export HANZO_API_KEY=sk-...   # console.hanzo.ai → API keys
```

Then the first call — a read that needs nothing but the key. `GET /v1/meet/health`, operation `get_meet_health`:

**CLI**

```bash
hanzo meet health
```

**SDK**

**.ts**

```ts
import { Configuration, MeetApi } from 'hanzoai';

const api = new MeetApi(new Configuration({ accessToken: process.env.HANZO_API_KEY }));
const { data } = await api.getMeetHealth();
```

**.py**

```python
from hanzoai.cloud import ApiClient, Configuration
from hanzoai.cloud.api import MeetApi

client = ApiClient(Configuration(access_token=os.environ["HANZO_API_KEY"]))
result = MeetApi(client).get_meet_health()
```

**.go**

```go
cfg := hanzoai.NewConfiguration()
cfg.AddDefaultHeader("Authorization", "Bearer "+os.Getenv("HANZO_API_KEY"))
client := hanzoai.NewAPIClient(cfg)

resp, _, err := client.MeetAPI.GetMeetHealth(context.Background()).Execute()
if err != nil {
	return err
}
```

**.rs**

```rust
use hanzo_client::apis::{configuration::Configuration, meet_api};

let mut cfg = Configuration::new();
cfg.bearer_access_token = std::env::var("HANZO_API_KEY").ok();

let result = meet_api::get_meet_health(&cfg, Default::default()).await?;
```

**.java**

```java
import ai.hanzo.cloud.ApiClient;
import ai.hanzo.cloud.api.MeetApi;

ApiClient client = new ApiClient();
client.setBearerToken(System.getenv("HANZO_API_KEY"));

var result = new MeetApi(client).getMeetHealth();
```

**HTTP**

```bash
curl https://api.hanzo.ai/v1/meet/health \
  -H "Authorization: Bearer $HANZO_API_KEY"
```

**MCP**

Tool `meet`, op `get_meet_health` — POST the JSON-RPC envelope to `https://api.hanzo.ai/v1/mcp`.

```bash
curl -X POST https://api.hanzo.ai/v1/mcp \
  -H "Authorization: Bearer $HANZO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
       "jsonrpc": "2.0",
       "id": 1,
       "method": "tools/call",
       "params": {
         "name": "meet",
         "arguments": {
           "op": "get_meet_health",
           "input": {}
         }
       }
     }'
```

Answers `200` with `object` — ok.

## Endpoints

| Endpoint                                                                               | What it does                                                    |
| -------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| [`GET /v1/meet/call`](https://docs.hanzo.ai/docs/openapi/meet/meetcall)                | Where a room's call happens                                     |
| [`POST /v1/meet/getToken`](https://docs.hanzo.ai/docs/openapi/meet/post-meet-gettoken) | Mint a join token for one video room                            |
| [`GET /v1/meet/health`](https://docs.hanzo.ai/docs/openapi/meet/get-meet-health)       | Reports whether the office can mint join tokens.                |
| [`GET /v1/meet/record`](https://docs.hanzo.ai/docs/openapi/meet/meetrecordread)        | What is being recorded in a room, and where the file goes       |
| [`POST /v1/meet/record`](https://docs.hanzo.ai/docs/openapi/meet/meetrecordstart)      | Start recording a room, or return the recording already running |
| [`DELETE /v1/meet/record`](https://docs.hanzo.ai/docs/openapi/meet/meetrecordstop)     | Stop a room's recording                                         |
| [`GET /v1/meet/session`](https://docs.hanzo.ai/docs/openapi/meet/get-meet-session)     | What this caller may open a room in                             |

---

[All Hanzo APIs](https://docs.hanzo.ai/docs/openapi) · [Interactive reference](https://docs.hanzo.ai/docs/openapi/reference)
