---
title: Go SDK
description: Complete reference for the NoLag Go SDK. Connect, subscribe, publish, and manage real-time messaging with idiomatic Go patterns.
---

# Go SDK

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

## Installation

```bash [Terminal]
go get github.com/NoLagApp/go-sdk
```

## Quick Start

```go [Go]
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](#rest-api-client) 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()`.

```go [Go]
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](#error-handling).

```go [Go]
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.

```go [Go]
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`.

```go [Go]
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.

```go [Go]
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](/docs/concepts/filters).

## Connection Events

```go [Go]
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](/docs/concepts/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.

```go [Go]
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

```go [Go]
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

```go [Go]
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](/docs/concepts/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:

```go [Go]
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:

```go [Go]
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:

```go [Go]
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](/docs/concepts/replay).

## Type Definitions

```go [Go]
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

- [Learn about Topics](/docs/concepts/topics)
- [Rooms](/docs/concepts/rooms)
- [Presence Tracking](/docs/concepts/presence)
- [Filters](/docs/concepts/filters)
- [Replay](/docs/concepts/replay)
- [REST API Reference](/docs/api-reference)
