Go SDK
The official NoLag SDK for Go. Idiomatic Go client with goroutines and channels support.
Installation
go get github.com/NoLagApp/go-sdkQuick Start
package main
import (
"fmt"
"time"
nolag "github.com/NoLagApp/go-sdk"
)
// The slug returned when you created the app (slugs carry a random suffix)
const appSlug = "chat-a3f9"
func main() {
// Create client with your actor token
client := nolag.New("your-actor-token")
// Broker errors (unknown room, no access) are asynchronous: register before Connect
client.OnError(func(err *nolag.ServerError) {
fmt.Printf("nolag: %v\n", err)
})
// Connect to NoLag
if err := client.Connect(); err != nil {
panic(err)
}
defer client.Close()
// Scope to an app and a room. The room must already exist.
room := client.SetApp(appSlug).SetRoom("general")
// Subscribe to a topic in the room
err := room.Subscribe("messages", func(data any, meta nolag.MessageMeta) {
fmt.Printf("Received: %v\n", data)
})
if err != nil {
panic(err)
}
// Publish a message
if err := room.Emit("messages", map[string]any{"hello": "world"}); err != nil {
fmt.Printf("Emit failed: %v\n", err)
}
// Get actor ID assigned by server
fmt.Println("Actor ID:", client.ActorID())
// Keep running
time.Sleep(60 * time.Second)
}Topics live inside rooms. The only hierarchy is app/room/topic: the room context above addresses chat-a3f9/general/messages. Topic names are single tokens ([a-zA-Z0-9_:-], no /). Rooms never exist implicitly: create them via the REST API or the portal first, or subscribing reports unknown_topic (42940) through OnError. App slugs always get a random 4-hex suffix, so read Slug from the create response and pass that to SetApp().
Configuration
Every field you omit keeps its default. Reconnect and QoS are pointers so
that an omitted field is distinguishable from false or QoS 0; set them with
nolag.Bool() and nolag.QoSLevel().
options := nolag.Options{
URL: "wss://broker.nolag.app/ws", // Custom broker URL
Reconnect: nolag.Bool(false), // Auto-reconnect; omit to keep the default (true)
ReconnectInterval: 5 * time.Second, // Reconnect interval (default: 5s)
MaxReconnectAttempts: 10, // Max attempts (default: 10); 0 keeps the default, -1 is unlimited
HeartbeatInterval: 30 * time.Second, // Heartbeat interval (default: 30s); 0 keeps the default, -1 disables
QoS: nolag.QoSLevel(nolag.QoSAtLeastOnce), // Default QoS; omit to keep the default (QoSAtLeastOnce)
LoadBalance: true, // Enable load balancing (default: false)
LoadBalanceGroup: "workers", // Load balance group name
ActorTokenID: "custom-id", // Optional actor token identifier
Debug: true, // Enable debug logging (default: false)
}
client := nolag.New("your-actor-token", options)Subscribing to Topics
Subscribe, Unsubscribe, and all filter methods return error for local
failures (not connected, encode failures). Broker-side refusals arrive on
OnError instead; see Error Handling.
client := nolag.New("your-actor-token")
room := client.SetApp(appSlug).SetRoom("general")
handler := func(data any, meta nolag.MessageMeta) {
fmt.Printf("Message from %s: %v\n", meta.Sender, data)
}
// Basic subscription. Subscribe returns an error
err := room.Subscribe("messages", handler)
if err != nil {
fmt.Printf("Subscribe failed: %v\n", err)
}
// With options (QoS, load balancing, filters)
err = room.Subscribe("tasks", handler, nolag.SubscribeOptions{
QoS: nolag.QoSLevel(nolag.QoSAtLeastOnce),
LoadBalance: nolag.Bool(true),
LoadBalanceGroup: "workers",
Filters: []any{"priority:high", "region:us"},
})
if err != nil {
fmt.Printf("Subscribe failed: %v\n", err)
}
// Client-level equivalent: the full app/room/topic path
err = client.Subscribe(appSlug+"/general/messages", handler)
// Unsubscribe (also returns an error)
if err := room.Unsubscribe("messages"); err != nil {
fmt.Printf("Unsubscribe failed: %v\n", err)
}Publishing Messages
Emit returns an error for local failures. Use EmitOptions to set QoS,
retain, and filter targeting.
client := nolag.New("your-actor-token")
room := client.SetApp(appSlug).SetRoom("general")
// Publish any data (maps, structs, strings, etc.). Emit returns an error
if err := room.Emit("messages", map[string]any{"text": "Hello!"}); err != nil {
fmt.Printf("Emit failed: %v\n", err)
}
// With options
err := room.Emit("status", map[string]any{"online": true}, nolag.EmitOptions{
QoS: nolag.QoSLevel(nolag.QoSAtLeastOnce), // Override the default QoS
Retain: true, // Broker keeps the last message for new subscribers
Filter: "priority:high", // Target subscribers with this filter
Echo: nolag.Bool(false), // Per-connection flag; see the note below
})
if err != nil {
fmt.Printf("Emit failed: %v\n", err)
}
// Client-level equivalent: the full path
err = client.Emit(appSlug+"/general/messages", map[string]any{"text": "Hello!"})Publishers never receive their own messages. The broker drops a message before delivering it to the actor that published it, whatever Echo is set to. Append your own message to the UI locally. Echo: nolag.Bool(false) only adds a per-connection drop for the rare case of two connections sharing one actor token.
Fluent API (SetApp / SetRoom)
The fluent API scopes all operations to an app/room pair.
Topics are automatically prefixed, so room.Emit("messages", ...) publishes
to "chat-a3f9/general/messages" when the app slug is chat-a3f9.
client := nolag.New("your-actor-token")
// The fluent API scopes operations to an app/room.
// Topics are automatically prefixed with "app/room/".
room := client.SetApp(appSlug).SetRoom("general")
// Subscribe: topic becomes "chat-a3f9/general/messages"
err := room.Subscribe("messages", func(data any, meta nolag.MessageMeta) {
fmt.Printf("Message: %v\n", data)
})
if err != nil {
fmt.Printf("Subscribe failed: %v\n", err)
}
// Emit: same prefix
if err := room.Emit("messages", map[string]any{"text": "Hello!"}); err != nil {
fmt.Printf("Emit failed: %v\n", err)
}
// Unsubscribe
room.Unsubscribe("messages")
// Filter management on a room
room.SetFilters("messages", []any{"priority:high"})
room.AddFilters("messages", []string{"priority:medium"})
room.RemoveFilters("messages", []string{"priority:high"})
// Room-scoped presence
room.SetPresence(map[string]any{"status": "online"})
// Get the full topic prefix
fmt.Println(room.Prefix()) // "chat-a3f9/general"Topic messages are delivered to the handler you pass to Subscribe. room.On
registers a named client event handler and does not receive topic messages.
Filter Management
Filters narrow which messages a subscriber receives. You can set filters at subscribe time
or manage them dynamically with SetFilters, AddFilters, and
RemoveFilters. On the publish side, use EmitOptions.Filter to
target specific subscribers.
client := nolag.New("your-actor-token")
room := client.SetApp(appSlug).SetRoom("ops")
handler := func(data any, meta nolag.MessageMeta) {
fmt.Printf("Order (filter %s): %v\n", meta.Filter, data)
}
// Subscribe with initial filters
err := room.Subscribe("orders", handler, nolag.SubscribeOptions{
Filters: []any{"region:us", "status:pending"},
})
// Replace all filters for a topic (empty slice = receive all messages)
err = room.SetFilters("orders", []any{"region:eu", "status:shipped"})
// Add filters to existing set (deduplicates automatically)
err = room.AddFilters("orders", []string{"status:delivered"})
// Remove specific filters
err = room.RemoveFilters("orders", []string{"status:shipped"})
// Emit with a filter value. Reaches subscribers with this filter, plus subscribers with no filters
err = room.Emit("orders", orderData, nolag.EmitOptions{
Filter: "region:us",
})
// Client-level equivalents take the full path
err = client.SetFilters(appSlug+"/ops/orders", []any{"region:eu"})No filters = wildcard: Subscribing without filters receives all messages on the topic, including filtered publishes. Subscribing with filters only receives messages published with a matching filter. Messages published without a filter are only delivered to wildcard (no-filter) subscribers. Each filter is its own routing key, not a privacy boundary. Max 100 filters per topic; values must not contain /, #, + or |. See Filters.
Connection Events
client := nolag.New("your-actor-token")
// Listen for connection events
client.On("connected", func(args ...any) {
fmt.Println("Connected!")
})
client.On("disconnected", func(args ...any) {
fmt.Println("Disconnected")
})
client.On("reconnecting", func(args ...any) {
attempt := args[0].(int)
fmt.Printf("Reconnecting... attempt %d\n", attempt)
})
// Prefer OnError for broker errors: it delivers a typed *nolag.ServerError
// with the error code, topic, and remediation hint. See Error Handling below.
client.OnError(func(err *nolag.ServerError) {
fmt.Printf("Error: %v\n", err)
})
// Presence events: args[0] is a nolag.ActorPresence (ActorTokenID, Presence)
client.On("presence:join", func(args ...any) {
actor := args[0].(nolag.ActorPresence)
fmt.Printf("%s joined: %v\n", actor.ActorTokenID, actor.Presence)
})
// Hydration: with a hydration webhook configured on the app, the broker sends
// the webhook's response body once per subscribe. topic is the bare topic name
client.On("hydration", func(args ...any) {
topic := args[0].(string)
data := args[1]
fmt.Printf("Initial state for %s: %v\n", topic, data)
})
// Remove all handlers for an event
client.Off("error")
// Check connection status
if client.Status() == nolag.StatusConnected {
fmt.Println("We're connected!")
}
// Get the actor ID assigned by the server after authentication
fmt.Println("Actor ID:", client.ActorID())See Webhooks for configuring hydration.
Presence
Presence is room-scoped. Set it through the room context or pass the room slug
to SetPresence; presence events arrive on the client.
client := nolag.New("your-actor-token")
room := client.SetApp(appSlug).SetRoom("general")
// Set your presence data in the room
if err := room.SetPresence(map[string]any{
"status": "online",
"typing": false,
}); err != nil {
fmt.Printf("SetPresence failed: %v\n", err)
}
// Or with an explicit room slug
if err := client.SetPresence(map[string]any{"status": "online"}, "general"); err != nil {
fmt.Printf("SetPresence failed: %v\n", err)
}
// Fetch the presence list for a room. Entries carry ActorTokenID, Presence and Status
presenceList, err := client.GetPresence("general")
if err == nil {
for _, actor := range presenceList {
fmt.Printf("%s: %v %s\n", actor.ActorTokenID, actor.Presence, actor.Status)
}
}
// Presence events are client-level: presence:join, presence:leave, presence:update
client.On("presence:join", func(args ...any) {
actor := args[0].(nolag.ActorPresence)
fmt.Printf("%s joined with %v\n", actor.ActorTokenID, actor.Presence)
})
client.On("presence:leave", func(args ...any) {
actor := args[0].(nolag.ActorPresence)
fmt.Printf("%s left\n", actor.ActorTokenID)
})
client.On("presence:update", func(args ...any) {
actor := args[0].(nolag.ActorPresence)
fmt.Printf("%s updated: %v\n", actor.ActorTokenID, actor.Presence)
})client.SetPresence(data) without a room is deprecated and is not broadcast.
GetPresence takes a room slug and fills only ActorTokenID, Presence and
Status on each entry; ActorType and JoinedAt are not sent by the broker.
Error Handling
Errors reach you through two separate channels, and confusing them is the most common reason a Go client appears to do nothing:
- Returned errors come from local, synchronous problems: not connected, encode failures, timeouts. Every operation returns one.
- Broker errors are asynchronous.
SubscribeandEmitare fire-and-forget, so a rejected subscription or an unwritable topic is reported later on the error event, not as a return value.Subscribereturningnilmeans the frame was sent, not that the broker accepted it.
Register OnError before calling Connect so nothing is missed during the
handshake.
Broker Errors
client := nolag.New("your-actor-token")
client.OnError(func(err *nolag.ServerError) {
// err also satisfies the error interface
log.Printf("nolag: %v", err)
switch err.Name {
case "unknown_topic":
// The room has not been created. Provision it via the rooms API.
log.Printf("missing room for topic %s: %s", err.Topic, err.Hint)
case "not_authorized":
log.Printf("actor lacks access to %s", err.Topic)
}
})
if err := client.Connect(); err != nil {
log.Fatal(err)
}ServerError carries the full frame:
| Field | Type | Description |
|---|---|---|
Code | int | Numeric error code, for example 42940. Zero if the broker sent none |
Name | string | Machine-readable name, for example unknown_topic. Always set |
Topic | string | The topic the error refers to, when topic-scoped |
Hint | string | Remediation hint from the broker, when provided |
MsgRef | string | The publish this error responds to, when applicable |
Protocol version 2. Code and Hint require protocol version 2. The SDK requests v2 automatically and
the negotiated result is available from client.ProtocolVersion(). Against an older
broker this returns 1 and only Name is populated.
Returned Errors
package main
import (
"errors"
"fmt"
nolag "github.com/NoLagApp/go-sdk"
)
func main() {
client := nolag.New("your-actor-token")
// Connect with error handling
if err := client.Connect(); err != nil {
fmt.Printf("Connection failed: %v\n", err)
return
}
defer client.Close()
// "chat-a3f9" is the suffixed slug returned when you created the app
room := client.SetApp("chat-a3f9").SetRoom("general")
handler := func(data any, meta nolag.MessageMeta) {}
// All operations return errors
if err := room.Subscribe("messages", handler); err != nil {
// Check for specific error types
if errors.Is(err, nolag.ErrNotConnected) {
fmt.Println("Not connected!")
}
fmt.Printf("Subscribe failed: %v\n", err)
}
if err := room.Emit("messages", "data"); err != nil {
fmt.Printf("Emit failed: %v\n", err)
}
if err := room.Unsubscribe("messages"); err != nil {
fmt.Printf("Unsubscribe failed: %v\n", err)
}
if err := room.SetFilters("messages", []any{"filter1"}); err != nil {
fmt.Printf("SetFilters failed: %v\n", err)
}
}
// Sentinel errors available:
// nolag.ErrNotConnected - operation attempted while disconnected
// nolag.ErrAuthFailed - authentication failed
// nolag.ErrTimeout - operation timed outQoS
QoS is a broker-hop setting, not an end-to-end guarantee. The level (0, 1 or 2) is validated and passed to the broker's internal MQTT hop. The WebSocket leg has an optional publish ack and no resend, so no level is an end-to-end delivery guarantee; deduplicate with an idempotency key if a message must not be applied twice. See QoS.
| Constant | Level | Description |
|---|---|---|
QoSAtMostOnce | 0 | Fire and forget on the broker hop |
QoSAtLeastOnce | 1 | Acknowledged on the broker hop (default) |
QoSExactlyOnce | 2 | Deduplicated on the broker hop |
REST API Client
The SDK also includes a REST API client for managing apps, rooms, actors, and scopes:
package main
import (
"context"
"fmt"
nolag "github.com/NoLagApp/go-sdk"
)
func main() {
ctx := context.Background()
// Create API client with project-scoped API key
api := nolag.NewAPI("nlg_live_xxx.secret")
// List apps: Data plus Pagination{Total, Page, PageCount}
apps, err := api.Apps.List(ctx, nil)
if err != nil {
panic(err)
}
fmt.Printf("%d of %d apps (page %d of %d)\n",
len(apps.Data), apps.Pagination.Total, apps.Pagination.Page, apps.Pagination.PageCount)
// Create a new app. Without Topics every subscribe is unknown_topic
app, err := api.Apps.Create(ctx, nolag.AppCreate{
Name: "my-chat-app",
Description: "A real-time chat application",
Topics: []string{"messages"},
})
if err != nil {
panic(err)
}
fmt.Printf("Created app: %s (slug %s)\n", app.AppID, app.Slug) // pass app.Slug to SetApp()
// Create a room in the app (rooms must exist before clients subscribe)
room, err := api.Rooms.Create(ctx, app.AppID, nolag.RoomCreate{
Name: "general",
Slug: "general",
})
if err != nil {
panic(err)
}
fmt.Printf("Created room: %s\n", room.RoomID)
// Create an actor (save the access token!)
actor, err := api.Actors.Create(ctx, nolag.ActorCreate{
Name: "web-client",
ActorType: nolag.ActorDevice, // ActorDevice, ActorUser, ActorService, ActorSession, ActorAgent, ActorOrchestrator, ActorObserver
})
if err != nil {
panic(err)
}
fmt.Printf("Actor token: %s\n", actor.AccessToken)
}Access Scopes
Manage access scopes for tenant isolation:
api := nolag.NewAPI("nlg_live_xxx.secret")
ctx := context.Background()
// List scopes: paginated like apps
scopes, err := api.Scopes.List(ctx)
if err != nil {
panic(err)
}
fmt.Printf("Found %d of %d scopes\n", len(scopes.Data), scopes.Pagination.Total)
// Create a scope
scope, err := api.Scopes.Create(ctx, nolag.ScopeCreate{
Slug: "tenant-acme",
Name: "Acme Corp",
})
if err != nil {
panic(err)
}
fmt.Printf("Created scope: %s\n", scope.AccessScopeID)
// Assign an actor to the scope
scopeID := scope.AccessScopeID
_, err = api.Actors.Update(ctx, actorID, nolag.ActorUpdate{
AccessScopeID: &scopeID,
})
// List actors in a scope (plain slice)
actors, err := api.Scopes.ListActors(ctx, scope.AccessScopeID)
// Update a scope
newName := "Acme Corporation"
_, err = api.Scopes.Update(ctx, scope.AccessScopeID, nolag.ScopeUpdate{
Name: &newName,
})
// Delete a scope (409 while actors are still assigned)
err = api.Scopes.Delete(ctx, scope.AccessScopeID)Load Balancing
Load balancing is opt-in. Without it every subscriber receives every message.
With it, subscribers that share a LoadBalanceGroup form a group and each
message is delivered to one member of the group:
processTask := func(data any, meta nolag.MessageMeta) {
fmt.Printf("Processing: %v\n", data)
}
// Enable load balancing per-subscription
client := nolag.New("your-actor-token")
room := client.SetApp(appSlug).SetRoom("image-processing")
err := room.Subscribe("tasks", processTask, nolag.SubscribeOptions{
LoadBalance: nolag.Bool(true),
LoadBalanceGroup: "task-workers",
})
if err != nil {
fmt.Printf("Subscribe failed: %v\n", err)
}
// Or enable load balancing globally via connection options.
// Fields you omit (Reconnect, QoS, intervals) keep their defaults
worker := nolag.New("your-actor-token", nolag.Options{
LoadBalance: true,
LoadBalanceGroup: "task-workers",
})Workers whose actor type holds a persistent session (agent, orchestrator)
also have messages queued while they are away; see Replay.
Type Definitions
import nolag "github.com/NoLagApp/go-sdk"
// WebSocket Client
nolag.Client // The real-time messaging client
nolag.Options // Connection options (URL, Reconnect, QoS, LoadBalance, etc.)
nolag.SubscribeOptions // Subscription options (QoS, LoadBalance, Filters, etc.)
nolag.EmitOptions // Publish options (QoS, Retain, Echo, Filter)
nolag.App // Intermediate context from SetApp()
nolag.Room // Scoped pub/sub context from SetApp().SetRoom()
nolag.Bool // func(bool) *bool, for pointer-typed option fields
nolag.QoSLevel // func(QoS) *QoS, for pointer-typed QoS fields
nolag.ProtocolVersion // Wire protocol version this SDK requests (2)
// Enums / Constants
nolag.ConnectionStatus // StatusDisconnected, StatusConnecting, StatusConnected, StatusReconnecting
nolag.ActorType // ActorDevice, ActorUser, ActorService, ActorSession,
// ActorAgent, ActorOrchestrator, ActorObserver
nolag.QoS // QoSAtMostOnce, QoSAtLeastOnce, QoSExactlyOnce (broker-hop levels)
// Errors
nolag.ServerError // Structured broker error (Code, Name, Topic, Hint, MsgRef)
nolag.ErrorHandler // func(err *ServerError)
nolag.ErrNotConnected // Not connected to broker
nolag.ErrAuthFailed // Authentication failed
nolag.ErrTimeout // Operation timed out
// Data types
nolag.MessageMeta // Message metadata (Sender, Timestamp, IsReplay, MsgID, Filter)
nolag.ActorPresence // Presence info (ActorTokenID, Presence, Status populated)
nolag.MessageHandler // func(data any, meta MessageMeta)
nolag.EventHandler // func(args ...any)
// REST API Client
nolag.API // REST API client
nolag.APIOptions // API client options
nolag.NoLagAPIError // API error type
nolag.APIError // Raw API error details
// Resources
nolag.AppResource, nolag.AppCreate, nolag.AppUpdate
nolag.RoomResource, nolag.RoomCreate, nolag.RoomUpdate
nolag.ActorResource, nolag.ActorWithToken, nolag.ActorCreate, nolag.ActorUpdate
nolag.ScopeResource, nolag.ScopeCreate, nolag.ScopeUpdate
nolag.Pagination, nolag.PaginatedApps, nolag.PaginatedScopes, nolag.ListOptionsRequirements
- Go 1.21+
- github.com/gorilla/websocket v1.5.1
- github.com/vmihailenco/msgpack/v5 v5.4.1
- github.com/pion/webrtc/v3 v3.2.50