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 project targeting Erlang (gleam new my_app)
  • gleam add beryl adds beryl and all its dependencies

A channel is a Gleam module that returns a Channel(assigns) 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/decode
import gleam/json
import gleam/option.{None, 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) {
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: json.Json,
socket: Socket(RoomAssigns),
) -> JoinResult(RoomAssigns) {
// Extract username from the join payload, default to "Anonymous".
let username = case
json.parse(json.to_string(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: json.Json,
socket: Socket(RoomAssigns),
) -> HandleResult(RoomAssigns) {
let assigns = socket.get_assigns(socket)
case event {
"new_msg" -> {
// Broadcast to every socket joined to this topic (including the sender).
beryl.broadcast(assigns.channels, assigns.topic, "new_msg", payload)
// Return NoReply — phx_reply is NOT sent for broadcasts.
channel.NoReply(socket)
}
_ -> channel.NoReply(socket)
}
}

Wire everything together in your application entry point:

src/my_app.gleam
import beryl
import beryl/transport/mist as mist_transport
import gleam/bytes_tree
import gleam/erlang/process
import gleam/http/request
import gleam/http/response
import mist
import my_app/room_channel
pub fn main() {
// Start the beryl channel system.
let assert Ok(channels) = beryl.start(beryl.default_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.
let assert Ok(_) =
fn(req) {
// Upgrade WebSocket requests first; fall through to HTTP for everything else.
mist_transport.upgrade(
req,
channels.coordinator,
mist_transport.default_config("/socket/websocket"),
fn() { handle_http(req) },
)
}
|> 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", payload)
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.