Directory and relay API
This is the developer-facing reference for the FOSS directory + relay API: the HTTP + WebSocket surface that lets you build a custom client that joins an end-to-end-encrypted (MLS / RFC 9420) channel, where the relay never sees plaintext. It is the Stage-0 keystone of the open-core split: this surface is FOSS (protocol / relay / directory + welcome), and only the rich layer (roles beyond owner-approve, org policy, billing, the hosted-relay account gating) is commercial.
The 8-operation surface below already ships end-to-end in skytale. You do not need to build any crypto: the MLS engine (key packages, Welcomes, group operations, ciphertext) lives in the SDK; this document is about the directory + transport the relay and API expose around it.
For the FIRM contract (the locked op table the FE and orchestrator integrate against), see docs/specs/2026-06-18-foss-directory-relay-api-design.md in the skytale repo. This reference documents the actually-shipped endpoint shapes; where it adds detail beyond the firm contract (e.g. exact JSON field names, status codes), the shipped code is authoritative.
The two hosts
Section titled “The two hosts”A channel client talks to two services:
| Host | Default URL | Role |
|---|---|---|
| API | https://api.skytale.sh |
The directory + welcome plane. Channel registration, invite tokens, the key-package directory, and the Welcome handoff. Postgres-backed; auth = your account API key (sk_...) or a minted Bearer JWT. |
| Relay | https://relay.skytale.sh |
The message plane. The zero-knowledge transport: a WebSocket for live publish/subscribe, plus two HTTP catch-up endpoints (GET /v1/messages, GET /v1/sync). Holds only ciphertext; auth = a Bearer JWT. |
Authentication
Section titled “Authentication”Every relay request and every directory request is authenticated with a Bearer JWT minted by the API account layer.
- The JWT is an HS256 token with
iss: skytale-api,aud: skytale-api, a near-futureexp(the API caps it at 300s), andsub= the account UUID. Your account API key (sk_live_…) is exchanged for one via the API (POST /v1/tokens→{ token }); the SDK does this for you. (The relay’s validator accepts eitherskytale-apiorskytale-relayas the audience and requiresiss: skytale-api.) - Relay auth bar (
/v1/messages,/v1/sync, the WebSocket): a valid JWT + a well-formedorg/ns/svcchannel name is sufficient to read or write any channel. This deliberately mirrors the relay’s existing gRPCSubscribeself-grant: the relay has no account-to-channel table, so it does not check channel ownership; that gate lives in the directory plane (the API) and in the MLS group itself (you cannot decrypt a channel you were neveradd_member’d to). The relay additionally honors revocation: if yourdid:skytale:{sub}has been revoked, relay endpoints return403 Forbidden. - Directory auth bar (
/v1/channels/*): the API’sAuthContext(your account API key or session). Owner-only operations (GET /pending,POST /welcome) additionally verify you own the channel.
Pass the token as Authorization: Bearer <JWT> on HTTP requests; on the WebSocket, send it in the first frame (see Messages).
Channel identity: names, group-ids, and credentials
Section titled “Channel identity: names, group-ids, and credentials”Channel name is always a 3-component, non-empty org/namespace/service string (e.g. acme/payments/agent-v1). The API validates: max 256 bytes, only [A-Za-z0-9/_.-], exactly 3 non-empty parts.
Group-id = SHA-256("org/ns/svc"). The hash is taken over the literal bytes org, /, namespace, /, service concatenated. This is computed identically by:
- the relay (
NameRegistry::hash_name), and - the SDK (
channel_id_from_name,sdk/src/channel.rs).
The creator sets this group-id when creating the MLS group; the joiner does NOT derive it independently: it arrives inside the Welcome (join_from_welcome). Convergence is therefore guaranteed by construction. If you build your own client, derive the transport channel-id with the same SHA-256("org/ns/svc") and pass it to your create_group(group_id) on the create path; on the join path, take the group-id from the Welcome and do not re-derive.
Identity is a did: string passed raw. You hand the same string to both the /v1/channels/join request (the identity field, opaque TEXT, 1 to 256 chars) and the MLS engine constructor. The engine detects the did: prefix and wraps it into the MLS credential as CBOR for you (build_identity, crates/skytale-mls-flutter/src/api/mls.rs):
did:key:…→ aDidKeycredential,- any other
did:…→ aDidWebcredential, - anything not starting with
did:→ a raw 32-byte key credential.
Do not hand-roll the credential bytes. For roster matching, the identity string you send to /join must be byte-identical to the credential URI the engine builds (i.e. the same raw did: string). MLS authenticates by the credential’s bound signature key, not by these identifier bytes, so the embedded pubkey is a deterministic per-device HKDF value; the identifier is for the roster, not for authentication.
The directory + welcome ops (API host)
Section titled “The directory + welcome ops (API host)”All paths below are on https://api.skytale.sh. All bodies are JSON; all key_package / welcome fields are base64-encoded MLS bytes.
Register a channel
Section titled “Register a channel”POST /v1/channels: owner registers a channel (idempotent upsert per account).
// Request{ "name": "acme/payments/agent-v1" }// 201 Created{ "id": "<uuid>", "name": "acme/payments/agent-v1" }Create an invite token
Section titled “Create an invite token”POST /v1/channels/invites: owner-only (must own the channel). Mints the skt_inv_… token a joiner uses to publish a key package.
// Request{ "channel": "acme/payments/agent-v1", "max_uses": 1, "ttl_seconds": 3600 }// 201 Created{ "token": "skt_inv_…", "expires_at": "<rfc3339>" }max_uses defaults to 1 (range 1 to 100); ttl_seconds defaults to 3600 (range 60 to 604800, i.e. 1 minute to 7 days).
Publish a key package (op 1, publishKeyPackage)
Section titled “Publish a key package (op 1, publishKeyPackage)”POST /v1/channels/join: a would-be member submits its MLS key package + identity, gated by an invite token. Cross-organization joins pass a federation_token instead of (or in addition to) the standard invite path.
// Request{ "channel": "acme/payments/agent-v1", "token": "skt_inv_…", // standard invite token "key_package": "<base64 MLS key package>", "identity": "did:web:agent.example.com", "federation_token": "skt_fed_…" // OPTIONAL: cross-org join (bypasses normal invite validation)}// 201 Created{ "request_id": "<uuid>" }Validation: key package ≤ 64 KB; identity 1 to 256 chars; the channel must have fewer than 1000 pending requests. When federation_token is present, the API validates it against the federation_invites table (consuming one use, org-matched) and proceeds through the same downstream flow.
Fetch pending key packages (op 2, fetchKeyPackages)
Section titled “Fetch pending key packages (op 2, fetchKeyPackages)”GET /v1/channels/pending?channel=acme/payments/agent-v1: owner-only (must own the channel). Returns the queue of join requests awaiting approval.
// 200 OK - NOTE: an object with a `requests` array, NOT a bare array{ "requests": [ { "id": "<uuid>", // the request_id to pass to /welcome "identity": "did:web:agent.example.com", "key_package": "<base64 MLS key package>", "created_at": "<rfc3339>" } ]}Ordered ascending by created_at. An empty queue is { "requests": [] }.
Publish a Welcome (op 3, publishWelcome)
Section titled “Publish a Welcome (op 3, publishWelcome)”POST /v1/channels/welcome: owner-only. After the owner runs add_member with a pending key package, it uploads the resulting MLS Welcome, which marks the request completed.
// Request{ "request_id": "<uuid>", "welcome": "<base64 MLS Welcome>" }// 200 OK{ "status": "completed", "welcome": null }Welcome ≤ 256 KB. Returns 404 Not Found if the request is not pending or the caller does not own the channel.
Poll for a Welcome (op 4, fetchWelcomes)
Section titled “Poll for a Welcome (op 4, fetchWelcomes)”GET /v1/channels/welcome/{request_id}: the joiner polls its own request (scoped to the caller’s account) until the Welcome is ready.
// 200 OK while waiting{ "status": "pending" }// 200 OK once the owner has submitted{ "status": "completed", "welcome": "<base64 MLS Welcome>" }welcome is omitted until status == "completed". 404 Not Found if the request id does not belong to the caller.
Acknowledge + GC a completed Welcome (op 5, ackWelcome)
Section titled “Acknowledge + GC a completed Welcome (op 5, ackWelcome)”POST /v1/channels/welcome/{request_id}/ack: the joiner, after it has fetched its Welcome and joined the group, drops the now-useless request row.
// 200 OK{ "acked": true }Scoped to the caller’s own account and to status = 'completed', so it can never delete another account’s request or an in-flight one. Idempotent: a second call (or a request that no longer exists) returns { "acked": false }. A background sweep on the API also GCs completed/expired requests after 24h, so calling ack is an optimization, not a correctness requirement.
Messages (the relay host)
Section titled “Messages (the relay host)”All message I/O is on https://relay.skytale.sh. Payloads are always base64-encoded MLS ciphertext; the relay cannot read them.
Live transport: the WebSocket (op 6 postMessage + op 7 fetchMessages, live)
Section titled “Live transport: the WebSocket (op 6 postMessage + op 7 fetchMessages, live)”Connect to wss://relay.skytale.sh/ws. The protocol is JSON frames, tagged by a type field:
-
Auth (first frame, required):
{ "type": "auth", "token": "<JWT>" }The server replies
{ "type": "ok", "message": "authenticated" }on success, or{ "type": "error", "message": "…" }and closes on failure. You have 10s to send auth; the first frame MUST be auth. -
Subscribe (required before you can publish to a channel):
{ "type": "subscribe", "channel": "acme/payments/agent-v1" }→
{ "type": "ok", "message": "subscribed to acme/payments/agent-v1" }. Max 50 channels per connection. -
Publish (op 6, postMessage):
{ "type": "publish", "channel": "acme/payments/agent-v1", "payload": "<base64 ciphertext>" }You must be subscribed to the channel first. Payload ≤ 1 MB (decoded). →
{ "type": "ok", "message": "published" }. -
Server push (op 7, fetchMessages, live): the relay pushes every message on a subscribed channel as:
{ "type": "message", "channel": "acme/payments/agent-v1", "payload": "<base64 ciphertext>" } -
Unsubscribe:
{ "type": "unsubscribe", "channel": "…" }.
The WebSocket is live-only: it does NOT replay history. A client that was offline uses the two HTTP catch-up endpoints below, then resumes the WebSocket.
Cold-open backfill (op 7, fetchMessages, history): GET /v1/messages
Section titled “Cold-open backfill (op 7, fetchMessages, history): GET /v1/messages”GET /v1/messages?channel=acme/payments/agent-v1&since_seq=<u64>&limit=<usize>: forward (ascending) scan of app messages only (entry_type 0) from the archive. This is the cold-open history path: a brand-new client with no local state replays the channel’s decryptable ciphertext.
// 200 OK - NOTE: an object with `messages` + `has_more`, NOT a bare array{ "messages": [ { "seq": 1, "payload": "<base64 ciphertext>", "ts": 1718000000000 } ], "has_more": false}seqis the per-channel monotonic sequence number, the pagination cursor.tsis the Unix-millisecond archive time.since_seq(default 0) returns only entries withsequence > since_seq.since_seq=0is a full cold-open from the start of retained history.limitdefaults to 100, hard-capped at 500.- Paging: re-request with
since_seq = messages.last().seqwhilehas_more == true.has_moreis computed server-side; do not infer pagination frommessages.len() == limit. (That inference is broken by the limit cap: a client requesting more than 500 gets a full-but-capped page whose length never equals the request, and would wrongly conclude it was caught up.) - “Full history” is bounded by the per-channel archive ring-buffer cap + TTL; evicted messages are gone.
Epoch catch-up: GET /v1/sync
Section titled “Epoch catch-up: GET /v1/sync”GET /v1/sync?channel=acme/payments/agent-v1&since_seq=<u64>&limit=<usize>: forward scan of the FULL log, ALL entry types, including MLS commits and Welcomes. An offline member uses this on reconnect to advance its MLS epoch: /v1/messages (app-only) cannot, because it filters out the commits the member missed.
// 200 OK{ "entries": [ { "seq": 1, "entry_type": 0, "payload": "<base64>", "ts": 1718000000000 }, { "seq": 2, "entry_type": 1, "payload": "<base64>", "ts": 1718000000001 }, { "seq": 3, "entry_type": 2, "payload": "<base64>", "ts": 1718000000002 } ], "has_more": false}entry_type: 0 = app message, 1 = commit, 2 = Welcome.- On reconnect, feed every
entry_type == 1(commit) entry to your MLS engine (process_message) to advance the epoch, and treatentry_type == 0entries as chat. (Entry-type 2 Welcomes are present for completeness; a member already in the group does not consume them.) - Same
since_seq/limit(default 100, cap 500) / server-computedhas_moresemantics as/v1/messages, and the same paging rule (since_seq = entries.last().seqwhilehas_more). - Same auth bar (valid JWT + 3-part channel + revocation check).
End-to-end client flows
Section titled “End-to-end client flows”There are three roles a client plays. The Python SDK SkytaleChannelManager (sdk/python/skytale_sdk/channels.py) and the Rust SkytaleClient (sdk/src/client.rs) are the reference implementations; port or wrap their patterns.
Owner (creator): create + run an add-loop
Section titled “Owner (creator): create + run an add-loop”The owner must be online to admit joiners (it is the only party that can run add_member and produce Welcomes).
create_channel("org/ns/svc")→ builds the MLS group with group-idSHA-256("org/ns/svc"), subscribes on the relay.POST /v1/channelsto register the channel in the directory.- Run the add-loop (the SDK does this on a background thread,
_join_poll_loop):GET /v1/channels/pending?channel=…→- take one request per cycle (rate-limits MLS epoch advancement),
base64-decodeitskey_package, calladd_member(key_package)→ produces a Welcome (and a commit that fans out to existing members over the relay) → POST /v1/channels/welcome { request_id, welcome }.
The reference loop processes at most one join per channel per poll cycle, exactly so that N joiners produce N sequential single-add commits rather than a burst.
Joiner: generate KP → publish → poll → join
Section titled “Joiner: generate KP → publish → poll → join”generate_key_package()→ an MLS key package; base64-encode it.POST /v1/channels/join { channel, token, key_package, identity }→{ request_id }. (Usefederation_tokenfor a cross-org join.)- Poll
GET /v1/channels/welcome/{request_id}untilstatus == "completed"; base64-decodewelcome. join_from_welcome(welcome)(the SDK’sjoin_channel(name, welcome)) → joins the MLS group (group-id arrives in the Welcome, do not re-derive), subscribes on the relay.- Optionally
POST /v1/channels/welcome/{request_id}/ackto GC the request.
Reconnect (returning member): sync, then resume
Section titled “Reconnect (returning member): sync, then resume”A member with local MLS state that was offline:
GET /v1/sync?channel=…&since_seq=<last-seq-seen>→ feedentry_type == 1commit entries to the MLS engine (epoch advance) andentry_type == 0entries to chat; page untilhas_more == false.- Resume the WebSocket (
subscribe+ livepublish/message). - For cold-open chat history UX (a fresh client with no local state),
GET /v1/messagesinstead.
The N-party shape
Section titled “The N-party shape”Adding N members is sequential single adds, not a batched commit: each joiner publishes its own join request; the owner’s add-loop admits them one per cycle; the relay fans out each Add-commit so existing members stay in epoch sync. This is the same shape the live dev mesh runs (one MLS group, 5 members). The skytale test suite includes an N>2 group-convergence + fan-out integration test (sdk/tests/group_convergence.rs) that exercises owner-creates → N-joiners-add-loop → owner-sends → all N receive.
Operation summary
Section titled “Operation summary”| # | Operation | Surface |
|---|---|---|
| 1 | publishKeyPackage | POST /v1/channels/join {channel, token, key_package, identity, federation_token?} → {request_id} (API) |
| 2 | fetchKeyPackages | GET /v1/channels/pending?channel=… → {requests:[{id, identity, key_package, created_at}]} (API, owner-only) |
| 3 | publishWelcome | POST /v1/channels/welcome {request_id, welcome} → {status, welcome} (API, owner-only) |
| 4 | fetchWelcomes | GET /v1/channels/welcome/{request_id} → {status, welcome?} (API, joiner) |
| 5 | ackWelcome | POST /v1/channels/welcome/{request_id}/ack → {acked} (API, joiner; GC) |
| 6 | postMessage | relay WebSocket {type:"publish", channel, payload} |
| 7 | fetchMessages | live: relay WebSocket server-push {type:"message", channel, payload} · cold-open backfill: GET /v1/messages?channel=&since_seq=&limit= → {messages:[{seq, payload, ts}], has_more} (relay, app-only) |
| 8 | syncLog | GET /v1/sync?channel=&since_seq=&limit= → {entries:[{seq, entry_type, payload, ts}], has_more} (relay, full log incl. commits, epoch-catchup) |
See also
Section titled “See also”- The FIRM contract:
docs/specs/2026-06-18-foss-directory-relay-api-design.mdin the skytale repo. - Reference clients:
sdk/python/skytale_sdk/channels.py(PythonSkytaleChannelManager),sdk/src/client.rs+sdk/src/channel.rs(RustSkytaleClient). - Shipped endpoint code:
api/src/routes/channels.rs(directory + welcome),relay/src/main.rs(/v1/messages,/v1/sync),relay/src/websocket.rs(the WebSocket).