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.
Prerequisites
Section titled “Prerequisites”-
Gleam 1.18 or later — beryl is installed as a git dependency pointing at a subdirectory of its monorepo, and the
pathfield 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 downloadto fetch them and their transitive dependencies. See the installation guide for details.
1. Define a channel
Section titled “1. Define a channel”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.
import berylimport beryl/channel.{type Channel, type HandleResult, type JoinResult}import beryl/socket.{type Socket}import gleam/dynamic.{type Dynamic}import gleam/dynamic/decodeimport gleam/jsonimport 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) }}2. Start beryl and register the channel
Section titled “2. Start beryl and register the channel”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:
import berylimport beryl/supervisorimport beryl_mist as mist_transportimport beryl/wireimport gleam/bytes_treeimport gleam/erlang/processimport gleam/http/requestimport gleam/http/responseimport gleam/otp/static_supervisorimport mistimport 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())) }}3. Connect from the browser
Section titled “3. Connect from the browser”beryl uses the same wire format as Phoenix channels, so you can use the official
phoenix npm package (or CDN build) in your frontend.
npm install phoenix<script src="https://cdn.jsdelivr.net/npm/phoenix/priv/static/phoenix.min.js"></script>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!" });4. Acknowledging a specific push
Section titled “4. Acknowledging a specific push”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 acknowledgmentchannel .push("new_msg", { text: "Hello!" }) .receive("ok", (resp) => { // resp is the payload from channel.Reply — { status: "ok" } console.log("Delivered", resp); });5. Rejecting a join
Section titled “5. Rejecting a join”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.
Next steps
Section titled “Next steps”- 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_connectauth