Go SDK

The official NoLag SDK for Go. Idiomatic Go client with goroutines and channels support.

Installation

go get github.com/NoLagApp/go-sdk

Quick 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. Subscribe and Emit are fire-and-forget, so a rejected subscription or an unwritable topic is reported later on the error event, not as a return value. Subscribe returning nil means 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:

FieldTypeDescription
CodeintNumeric error code, for example 42940. Zero if the broker sent none
NamestringMachine-readable name, for example unknown_topic. Always set
TopicstringThe topic the error refers to, when topic-scoped
HintstringRemediation hint from the broker, when provided
MsgRefstringThe 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 out

QoS

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.

ConstantLevelDescription
QoSAtMostOnce0Fire and forget on the broker hop
QoSAtLeastOnce1Acknowledged on the broker hop (default)
QoSExactlyOnce2Deduplicated 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.ListOptions

Requirements

  • 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

Next Steps