# LiveKit Server SDK for Elixir

An idiomatic Elixir port of the [LiveKit](https://livekit.io) server SDKs. It lets an
Elixir backend mint LiveKit access tokens and talk to LiveKit's Twirp server APIs, using
the official [LiveKit protocol definitions](https://github.com/livekit/protocol) as the
source of truth and the [Ruby server SDK](https://github.com/livekit/server-sdk-ruby) as a
behavioral reference.

## Milestone status

**Milestones 1–4 are complete** (shared foundation, `RoomService`, `EgressService`,
`IngressService`, and `WebhookReceiver`).

| Module | Purpose |
| --- | --- |
| `LiveKit` | Small public entry point for building clients |
| `LiveKit.Config` | Validated URL and credentials |
| `LiveKit.Client` | Immutable handle holding config and HTTP options |
| `LiveKit.Grants` | Video, SIP, agent and claim grant encoding |
| `LiveKit.AccessToken` | HS256 access token creation |
| `LiveKit.Auth` | Short-lived service tokens for API calls |
| `LiveKit.TokenVerifier` | HS256 JWT verification |
| `LiveKit.Twirp` | Shared protobuf-over-HTTP transport |
| `LiveKit.Error` | Single, redacted error type |
| `LiveKit.Proto.*` | Generated protobuf messages |
| `LiveKit.RoomService` | Create and administer rooms and participants |
| `LiveKit.EgressService` | Start and manage recordings and streams |
| `LiveKit.IngressService` | Create and manage RTMP/WHIP/URL ingress |
| `LiveKit.WebhookReceiver` | Validate and decode webhook callbacks |

Still to come: `SIPService`, `AgentDispatchService`, and `ConnectorService`.

## Installation

Add the dependency to your `mix.exs`:

```elixir
def deps do
  [
    {:livekit_sdk, "~> 0.1.0"}
  ]
end
```

Then run `mix deps.get`. Requires Elixir 1.15 or later.

Docs: [hexdocs.pm/livekit_sdk](https://hexdocs.pm/livekit_sdk).

## Creating a client

A client is plain immutable data. It starts no process, opens no connection, and can be
built once and reused everywhere.

```elixir
{:ok, client} =
  LiveKit.client(
    url: System.fetch_env!("LIVEKIT_URL"),
    api_key: System.fetch_env!("LIVEKIT_API_KEY"),
    api_secret: System.fetch_env!("LIVEKIT_API_SECRET")
  )
```

`LiveKit.client!/1` raises `LiveKit.Error` instead of returning a tuple.

The SDK never reads environment variables itself; your application decides where
credentials come from.

## Generating access tokens

```elixir
{:ok, jwt} =
  LiveKit.AccessToken.new(
    api_key: "api-key",
    api_secret: "api-secret",
    identity: "user-123"
  )
  |> LiveKit.AccessToken.with_name("Alice")
  |> LiveKit.AccessToken.with_metadata(~s({"role":"host"}))
  |> LiveKit.AccessToken.with_video_grant(%LiveKit.Grants.Video{
    room_join: true,
    room: "support-room"
  })
  |> LiveKit.AccessToken.to_jwt()
```

The signed payload contains `iss` (the API key), `sub` (the identity), `iat`, `nbf`, `exp`
and the LiveKit grant claims. Tokens are signed with HS256 using the API secret; the secret
itself never appears in the payload.

Grants are structs, not free-form maps, and are encoded with the camelCase claim names
LiveKit expects:

```elixir
LiveKit.Grants.to_claims(%LiveKit.Grants.Video{room_join: true, can_publish: false})
#=> %{"roomJoin" => true, "canPublish" => false}
```

Unset (`nil`) fields are omitted from the token, while an explicit `false` is preserved —
LiveKit distinguishes "denied" from "unset" for permissions such as `canPublish`.

`LiveKit.Grants.SIP` and `LiveKit.Grants.Agent` cover the `sip` and `agent` claims, and are
attached with `with_sip_grant/2` and `with_agent_grant/2`.

### Token lifetime

The default TTL is 6 hours (`21_600` seconds), matching the Go and JavaScript SDKs. Set a
custom lifetime with `LiveKit.AccessToken.with_ttl/2` or the `:ttl` option. Zero and
negative TTLs are rejected with a `:validation` error.

## Calling the Room Service

`LiveKit.RoomService` wraps every RoomService RPC. Pass a client and keyword options;
the module builds the protobuf request and signs a short-lived service token with only
the grants that call needs:

```elixir
{:ok, %LiveKit.Proto.Room{} = room} =
  LiveKit.RoomService.create_room(client,
    name: "support-room",
    empty_timeout: 300,
    max_participants: 20
  )

{:ok, %LiveKit.Proto.ListRoomsResponse{rooms: rooms}} =
  LiveKit.RoomService.list_rooms(client)

{:ok, %LiveKit.Proto.ListParticipantsResponse{participants: participants}} =
  LiveKit.RoomService.list_participants(client, room: "support-room")
```

Other methods follow the same shape: `delete_room/2`, `get_participant/2`,
`remove_participant/2`, `mute_published_track/2`, `update_participant/2`,
`update_subscriptions/2`, `send_data/2`, `update_room_metadata/2`,
`forward_participant/2`, `move_participant/2`, and `perform_rpc/2`.

Under the hood they use `LiveKit.Twirp`, which posts protobuf to
`/twirp/livekit.RoomService/<Method>` with `Authorization: Bearer <jwt>`.

## Egress and Ingress

Record a room and manage ingress the same way:

```elixir
{:ok, %LiveKit.Proto.EgressInfo{} = egress} =
  LiveKit.EgressService.start_room_composite_egress(client,
    room_name: "support-room",
    layout: "speaker",
    output: %LiveKit.Proto.EncodedFileOutput{filepath: "support-room.mp4"}
  )

{:ok, %LiveKit.Proto.ListEgressResponse{items: items}} =
  LiveKit.EgressService.list_egress(client, room_name: "support-room")

{:ok, %LiveKit.Proto.IngressInfo{} = ingress} =
  LiveKit.IngressService.create_ingress(client,
    input_type: :RTMP_INPUT,
    name: "camera",
    room_name: "support-room",
    participant_identity: "camera"
  )

{:ok, %LiveKit.Proto.ListIngressResponse{items: ingresses}} =
  LiveKit.IngressService.list_ingress(client)
```

## Webhooks

LiveKit POSTs JSON events with `Content-Type: application/webhook+json` and an
`Authorization` JWT whose `sha256` claim matches the raw body. Always pass the
raw body string (not a decoded map):

```elixir
{:ok, receiver} =
  LiveKit.WebhookReceiver.new(
    api_key: System.fetch_env!("LIVEKIT_API_KEY"),
    api_secret: System.fetch_env!("LIVEKIT_API_SECRET")
  )

# In a Plug/Phoenix endpoint, use the raw body:
{:ok, %LiveKit.Proto.WebhookEvent{} = event} =
  LiveKit.WebhookReceiver.receive(receiver, raw_body, auth_header)

case event.event do
  "room_started" -> handle_room_started(event)
  "participant_joined" -> handle_participant_joined(event)
  other -> Logger.debug("unhandled webhook: #{other}")
end
```

`LiveKit.TokenVerifier` is also available for verifying access tokens in general.

You can also mint a service token directly:

```elixir
{:ok, jwt} =
  LiveKit.Auth.service_token(client, video_grant: %LiveKit.Grants.Video{room_list: true})
```

## Configuration options

`LiveKit.Config.new/1` (and therefore `LiveKit.Client.new/1`) accepts:

| Option | Required | Default | Notes |
| --- | --- | --- | --- |
| `:url` | yes | — | `http`, `https`, `ws` or `wss`. `ws`/`wss` are normalized to `http`/`https`, trailing slashes are removed, and default ports are dropped |
| `:api_key` | yes | — | Must be a non-blank string |
| `:api_secret` | yes | — | Must be a non-blank string |
| `:timeout` | no | `15_000` | Connect timeout in milliseconds |
| `:receive_timeout` | no | `30_000` | Response timeout in milliseconds |

`LiveKit.Client.new/1` additionally accepts `:request_options`, a keyword list merged into
every `Req` request. This is the seam for tests and for advanced HTTP tuning:

```elixir
LiveKit.Client.new!(
  url: "http://localhost:7880",
  api_key: "devkey",
  api_secret: "secret",
  request_options: [plug: {Req.Test, MyStub}]
)
```

## Error handling

Every public function returns `{:ok, value}` or `{:error, %LiveKit.Error{}}`. Bang variants
(`client!/1`, `new!/1`, `to_jwt!/1`) raise the same struct.

```elixir
case LiveKit.RoomService.list_rooms(client) do
  {:ok, response} -> handle(response)
  {:error, %LiveKit.Error{retryable: true} = error} -> retry_later(error)
  {:error, error} -> Logger.error(Exception.message(error))
end
```

`LiveKit.Error` carries a `type`, human-readable `message`, and — where available — the
Twirp `code`, HTTP `status`, sanitized `meta` and a `retryable` flag:

```elixir
%LiveKit.Error{
  type: :twirp,
  code: "not_found",
  status: 404,
  message: "room not found",
  meta: %{},
  retryable: false
}
```

Types are `:configuration`, `:validation`, `:authentication`, `:transport`, `:timeout`,
`:http`, `:twirp`, `:encoding`, `:decoding`, `:protobuf` and `:unknown`. Transport failures,
timeouts, HTTP 5xx/408/429 and Twirp codes such as `unavailable` are marked retryable.

## Protobuf generation

The protocol definitions are vendored in `priv/proto`, and the generated modules in
`lib/livekit/proto` are committed — a fresh clone compiles without `protoc`.

**Protocol version:** `@livekit/protocol@1.50.4`
(commit `2172178d20d4c8d14d6873b4e9560cc38cf48c0c`), recorded in `priv/proto/REVISION`.

To refresh them you need `protoc` and the `protoc-gen-elixir` plugin:

```bash
brew install protobuf                          # or your platform's equivalent
mix escript.install hex protobuf 0.14.1
export PATH="$HOME/.mix/escripts:$PATH"

mix proto.fetch      # vendor the pinned .proto files into priv/proto
mix proto.generate   # regenerate lib/livekit/proto
```

`mix proto.fetch --ref <commit-or-tag>` vendors a different protocol revision.

Generation is a development-time step: it never runs during compilation or application
startup. All files are compiled in one `protoc` invocation, and generated modules are
flattened from the protobuf `livekit` package into a predictable namespace:

```elixir
LiveKit.Proto.ListRoomsRequest
LiveKit.Proto.Room
LiveKit.Proto.ParticipantInfo.Kind
```

Enums, oneofs, maps, repeated fields and `optional` presence are all preserved.

## Security notes

- API secrets are never logged, never placed in token claims, and never included in error
  messages, error metadata or `inspect/1` output.
- `LiveKit.Error.sanitize/1` drops credential-shaped keys (`api_secret`, `token`,
  `authorization`, …) and replaces JWT-shaped values with `"[REDACTED]"`. All error metadata
  passes through it, so server responses that echo an `Authorization` header cannot leak it.
- Service tokens created by `LiveKit.Auth` live for 10 minutes and carry only the grants the
  specific call requires.
- Credentials live only in immutable `LiveKit.Config`/`LiveKit.Client` structs — never in
  application-global mutable state — and are never read at compile time or from the
  environment by the SDK.
- Required credentials are validated before any request is made.

## Design notes and differences from the Ruby SDK

- **Tagged tuples over exceptions.** The default API returns `{:ok, _}`/`{:error, _}`;
  raising is opt-in through bang functions.
- **No implicit environment lookups.** The Ruby SDK falls back to `LIVEKIT_API_KEY` and
  `LIVEKIT_API_SECRET`; this SDK takes credentials as explicit arguments.
- **Immutable token pipeline.** Instead of mutable accessors (`token.identity = ...`),
  tokens are built with `with_*` functions that return new structs.
- **One shared client and transport.** Rather than a Twirp client class per service, all
  services share `LiveKit.Client` and `LiveKit.Twirp`.
- **Short-lived service tokens.** Administrative tokens default to 10 minutes, while
  participant tokens default to 6 hours (matching the Go and JavaScript SDKs; the Ruby
  SDK's `DEFAULT_TTL` constant is 10 minutes).
- **A grant is not required to sign a token.** The Ruby SDK raises unless a video or SIP
  grant is present; this SDK follows the Go implementation and leaves that policy to the
  caller.
- **Protobuf transport.** Requests and responses use `application/protobuf` rather than the
  Twirp JSON encoding some SDKs use.

## Development

```bash
mix deps.get
mix compile --warnings-as-errors
mix format --check-formatted
mix test
```

The test suite is fully offline and deterministic: HTTP is exercised through a local
[Bypass](https://hex.pm/packages/bypass) server, and time is supplied through injectable
clock functions.
