Skip to content

Quick Start

This guide walks you through building a working real-time channel from scratch: a Gleam server that handles join and message events, wired to a Phoenix JS client running in the browser.

The snippets here are abbreviated to keep things readable. For a fully runnable application with HTML, static assets, and end-to-end tests, see the examples.

  • Gleam 1.18 or later — beryl is installed as a git dependency pointing at a subdirectory of its monorepo, and the path field that makes that possible was added in Gleam 1.18

  • Gleam project targeting Erlang (gleam new my_app)

  • beryl and the Mist WebSocket transport in your gleam.toml:

    [dependencies]
    beryl = { git = "https://github.com/tylerbutler/beryl.git", ref = "v0.0", path = "packages/beryl" }
    beryl_mist = { git = "https://github.com/tylerbutler/beryl.git", ref = "v0.0", path = "packages/beryl_mist" }

    Run gleam deps download to fetch them and their transitive dependencies. See the installation guide for details.

A channel is a Gleam module that returns a Channel(assigns, info) value. The assigns type holds per-socket state — anything you want to remember about this connection.

src/my_app/room_channel.gleam
import beryl
import beryl/channel.{type Channel, type HandleResult, type JoinResult}
import beryl/socket.{type Socket}
import gleam/dynamic.{type Dynamic}
import gleam/dynamic/decode
import gleam/json
import gleam/option.{Some}
/// Per-socket state for this channel.
pub type RoomAssigns {
RoomAssigns(username: String, channels: beryl.Channels, topic: String)
}
/// Build the channel handler.
pub fn new(channels: beryl.Channels) -> Channel(RoomAssigns, info) {
channel.new(fn(topic, payload, socket) {
join(channels, topic, payload, socket)
})
|> channel.with_handle_in(handle_in)
}
/// Called when a client sends a join request for a matching topic.
fn join(
channels: beryl.Channels,
topic: String,
payload: Dynamic,
socket: Socket(RoomAssigns),
) -> JoinResult(RoomAssigns) {
// Payloads arrive as `Dynamic`; decode the fields you need.
// Extract username from the join payload, default to "Anonymous".
let username = case
channel.decode_payload(payload, {
use u <- decode.field("username", decode.string)
decode.success(u)
})
{
Ok(u) -> u
Error(_) -> "Anonymous"
}
let assigns = RoomAssigns(username:, channels:, topic:)
let socket = socket.set_assigns(socket, assigns)
// Send back a reply — delivered to the client as phx_reply on the join ref.
channel.JoinOk(
reply: Some(json.object([#("username", json.string(username))])),
socket:,
)
}
/// Called for every push the client sends after joining.
fn handle_in(
event: String,
payload: Dynamic,
socket: Socket(RoomAssigns),
) -> HandleResult(RoomAssigns) {
let assigns = socket.get_assigns(socket)
case event {
"new_msg" -> {
// Decode the incoming text, then broadcast it as JSON to every socket
// joined to this topic (including the sender).
let text = case
channel.decode_payload(payload, {
use t <- decode.field("text", decode.string)
decode.success(t)
})
{
Ok(t) -> t
Error(_) -> ""
}
beryl.broadcast(
assigns.channels,
assigns.topic,
"new_msg",
json.object([
#("username", json.string(assigns.username)),
#("text", json.string(text)),
]),
)
// Return NoReply — phx_reply is NOT sent for broadcasts.
channel.NoReply(socket)
}
_ -> channel.NoReply(socket)
}
}

beryl doesn't expose a way to start an unmanaged process — beryl/supervisor is how you start it, and it returns a child specification for your application's own OTP supervisor. Wire everything together in your application entry point:

src/my_app.gleam
import beryl
import beryl/supervisor
import beryl_mist as mist_transport
import beryl/wire
import gleam/bytes_tree
import gleam/erlang/process
import gleam/http/request
import gleam/http/response
import gleam/otp/static_supervisor
import mist
import my_app/room_channel
pub fn main() {
// Build the supervised config, then add its child specification to your
// application's root supervisor.
let beryl_config = supervisor.config(beryl.config(wire.phoenix_codec()))
let assert Ok(_root) =
static_supervisor.new(static_supervisor.OneForOne)
|> static_supervisor.add(supervisor.start(beryl_config))
|> static_supervisor.start()
// Resolve the channels handle now that the supervisor is running.
let channels = supervisor.channels(beryl_config)
// Register the room channel for all "room:*" topics.
let assert Ok(_) =
beryl.register(channels, "room:*", room_channel.new(channels))
// Start the HTTP + WebSocket server. `handler` composes the WebSocket
// upgrade with your HTTP handler: upgrades on the configured path go to
// beryl, everything else falls through to `handle_http`.
let assert Ok(_) =
mist_transport.handler(
channels,
mist_transport.default_config("/socket/websocket"),
handle_http,
)
|> mist.new
|> mist.port(8000)
|> mist.start
process.sleep_forever()
}
fn handle_http(
req: request.Request(mist.Connection),
) -> response.Response(mist.ResponseData) {
case request.path_segments(req) {
[] ->
response.new(200)
|> response.set_body(mist.Bytes(bytes_tree.from_string("Hello!")))
_ ->
response.new(404)
|> response.set_body(mist.Bytes(bytes_tree.new()))
}
}

beryl uses the same wire format as Phoenix channels, so you can use the official phoenix npm package (or CDN build) in your frontend.

Terminal window
npm install phoenix
import { Socket } from "phoenix";
// "/socket" → client appends "/websocket" → hits "/socket/websocket" on the server.
const socket = new Socket("/socket");
socket.connect();
// Join the "room:lobby" topic.
const channel = socket.channel("room:lobby", { username: "alice" });
channel
.join()
.receive("ok", (resp) => {
// resp is the payload from channel.JoinOk reply — { username: "alice" }
console.log("Joined as", resp.username);
})
.receive("error", (resp) => {
console.error("Join failed", resp);
});
// Listen for broadcast messages.
channel.on("new_msg", (payload) => {
console.log("Message:", payload);
});
// Send a message.
channel.push("new_msg", { text: "Hello, world!" });

If you want the server to confirm delivery of an individual push, return channel.Reply from handle_in. The client receives a phx_reply event tied to the original message ref:

// Server side — acknowledge message delivery
"new_msg" -> {
beryl.broadcast(
assigns.channels,
assigns.topic,
"new_msg",
json.object([#("username", json.string(assigns.username))]),
)
channel.Reply(
event: "msg_ack", // event is ignored in the wire protocol; only payload matters
payload: json.object([#("status", json.string("ok"))]),
socket:,
)
}
// Client side — receive the acknowledgment
channel
.push("new_msg", { text: "Hello!" })
.receive("ok", (resp) => {
// resp is the payload from channel.Reply — { status: "ok" }
console.log("Delivered", resp);
});

Return channel.JoinError from your join callback to refuse a client:

fn join(...) -> JoinResult(RoomAssigns) {
case is_room_valid(topic) {
False ->
channel.JoinError(reason: channel.error("Room not found"))
True ->
// ... proceed as normal
}
}

The client receives an "error" reply on its .join() call.

  • Explore the full examples — three runnable demos with HTML frontends
  • Learn about Channels in depth — topic patterns, handle_out, terminate
  • Add Presence tracking to see who's online
  • Set up PubSub for distributed messaging
  • Configure WebSocket transport options including on_connect auth