Meet
Package meet is the virtual office: it decides who may join a room and mints the short-lived token that lets them in.
Package meet is the virtual office: it decides who may join a room and mints the short-lived token that lets them in.
| Base URL | https://api.hanzo.ai |
| Operations | 3 |
| Auth | Authorization: Bearer $HANZO_API_KEY |
Specification
HIP-1252 · Meet — The Join Decision — Draft · read the specification →
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 | 3 operations |
| CLI | hanzo meet … | 3 of 3 |
| SDK | MeetApi in every published client | 3 methods |
| MCP | tool meet on https://api.hanzo.ai/v1/mcp | 13 operations, 2 under the document's own id — ask describe for the rest |
Quickstart
export HANZO_API_KEY=sk-... # console.hanzo.ai → API keysThen the first call — a read that needs nothing but the key. GET /v1/meet/health, operation get_meet_health:
hanzo meet healthimport { Configuration, MeetApi } from 'hanzoai';
const api = new MeetApi(new Configuration({ accessToken: process.env.HANZO_API_KEY }));
const { data } = await api.getMeetHealth();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()cfg := cloud.NewConfiguration()
cfg.AddDefaultHeader("Authorization", "Bearer "+os.Getenv("HANZO_API_KEY"))
client := cloud.NewAPIClient(cfg)
resp, _, err := client.MeetAPI.GetMeetHealth(context.Background()).Execute()
if err != nil {
return err
}use hanzo_cloud::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?;import ai.hanzo.cloud.ApiClient;
import ai.hanzo.cloud.api.MeetApi;
ApiClient client = new ApiClient();
client.setRequestInterceptor(b -> b.header("Authorization", "Bearer " + System.getenv("HANZO_API_KEY")));
var result = new MeetApi(client).getMeetHealth();curl https://api.hanzo.ai/v1/meet/health \
-H "Authorization: Bearer $HANZO_API_KEY"Tool meet, op get_meet_health — POST the JSON-RPC envelope to https://api.hanzo.ai/v1/mcp.
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 |
|---|---|
POST /v1/meet/getToken | Mint a join token for one video room |
GET /v1/meet/health | Health reports whether the office can mint join tokens. |
GET /v1/meet/session | What this caller may open a room in |
How is this guide?