← Back to blog
AI AGENTS14 min read

Building an Agentic Chatbot with NoLag

HB
Henco Burger
May 19, 2026

This tutorial walks through building a customer support chatbot that combines two NoLag Blueprints: @nolag/chat for the user-facing interface and @nolag/agents for AI-powered response generation. The result is a production-ready chatbot with typing indicators, conversation context, sentiment-based escalation, and observability.

Architecture Overview

The system has three components:

  1. Chat frontend - Users interact with a standard chat room powered by @nolag/chat. They see typing indicators, message history, and presence.
  2. Bridge service - A server-side process that connects to both the chat app and the agents app. It forwards user messages to the agent workflow and sends responses back to chat.
  3. AI workers - Agent processes that receive tasks via the Handoff pattern, call an LLM, and return responses. They can be scaled horizontally.

Step 1: Install Dependencies

npm install @nolag/chat @nolag/agents @nolag/js-sdk

Step 2: Set Up Apps in the Portal

Create two apps in your NoLag project:

  1. A Chat Blueprint app (nolag-chat-sdk), named for example support-chat
  2. An Agents Blueprint app (nolag-agents-sdk), named for example support-agents

The slug you get back for each app has a random suffix (support-chat-a3f9); that suffixed slug is the appName you pass to each wrapper. Rooms never exist implicitly, so also create a support room in the chat app and a support-workflow room in the agents app (or use the seeded general and default-workflow rooms).

Create three kinds of actors:

  • Bridge actor (type orchestrator) - granted access to both apps
  • Worker actor (type agent) - granted access to the agents app
  • User actors (type user) - granted access to the chat app (one per user, or use client tokens)

Step 3: Build the Bridge

The bridge is the key piece. One connection carries two wrappers, one per app, and the bridge translates between chat messages and agent tasks:

import { NoLag } from "@nolag/js-sdk";
import { NoLagChat } from "@nolag/chat";
import { NoLagAgents, Handoff } from "@nolag/agents";

// One connection, two wrappers: the bridge actor has access to both apps
const client = NoLag(BRIDGE_TOKEN);
const chat = new NoLagChat({
  client,
  appName: CHAT_APP_SLUG, // the suffixed slug of the chat app
  username: "Support Bot",
  avatar: "/bot.png",
});
const agents = new NoLagAgents({
  client,
  appName: AGENTS_APP_SLUG, // the suffixed slug of the agents app
  agentId: "bridge",
  presence: { name: "bridge", role: "orchestrator" },
});

await client.connect();
await Promise.all([chat.ready(), agents.ready()]);

const chatRoom = chat.joinRoom("support");
const agentRoom = agents.room("support-workflow");
const handoff = new Handoff(agentRoom);
const botId = chat.localUser!.userId;

// Forward user messages to the agent workflow. Publishers never receive
// their own messages, so the bot's replies never come back through here.
chatRoom.on("message", async (msg) => {
  // Show typing indicator while the worker thinks
  chatRoom.startTyping();

  const history = chatRoom.getMessages().slice(-10).map((m) => ({
    role: m.userId === botId ? "assistant" : "user",
    content: m.text,
  }));

  const result = await handoff.dispatch(
    "customer-support",
    { userMessage: msg.text, userId: msg.userId, history },
    { waitForResult: true, timeout: 30000 },
  );

  chatRoom.stopTyping();
  if (result && result.status === "success") {
    chatRoom.sendMessage(String(result.payload.response));
  }
});

The bridge shows typing indicators while the agent is processing, giving users a natural chat experience. The last ten messages are passed to the agent as role/content pairs so it has conversation context. dispatch() checks room presence before publishing and throws if no connected worker advertises the capability, so start a worker before the bridge or pass allowNoWorkers: true.

Step 4: Build the AI Worker

Workers advertise their capabilities through presence and process incoming tasks. You can use any LLM - OpenAI, Anthropic, a local model, or a custom pipeline:

import { NoLag } from "@nolag/js-sdk";
import { NoLagAgents, Handoff, Observe } from "@nolag/agents";

// loadBalance: with several workers in the "support-workers" group, each task
// goes to one of them. Without it every worker answers every task.
const client = NoLag(WORKER_TOKEN, { loadBalance: true, loadBalanceGroup: "support-workers" });
const agents = new NoLagAgents({
  client,
  appName: AGENTS_APP_SLUG,
  agentId: "support-worker-1",
  presence: { name: "support-worker-1", role: "agent", capabilities: ["customer-support"] },
});
await client.connect();
await agents.ready();

const room = agents.room("support-workflow");
const handoff = new Handoff(room);
const observe = new Observe(room, agents.agentId);

type Turn = { role: "user" | "assistant"; content: string };

handoff.onTask(["customer-support"], async (task, respond) => {
  const userMessage = String(task.payload.userMessage);
  const history = (task.payload.history ?? []) as Turn[];
  const started = Date.now();

  try {
    // Call your LLM of choice
    const completion = await openai.chat.completions.create({
      model: "gpt-4o",
      messages: [
        { role: "system", content: "You are a helpful customer support agent." },
        ...history,
        { role: "user", content: userMessage },
      ],
    });

    respond("success", { response: completion.choices[0].message.content });
    observe.emit("task:completed", { taskId: task.taskId, durationMs: Date.now() - started });
  } catch (err) {
    respond("error", {}, { code: "llm_failed", message: String(err) });
    observe.emit("task:failed", { taskId: task.taskId, error: String(err) }, "error");
  }
});

Workers are stateless and horizontally scalable. Run more instances with the same loadBalanceGroup and the broker hands each task to one member of the group. Load balancing is opt-in on the core client; leave it off and every instance receives, and answers, every task.

Step 5: Add Escalation

Use the Approve pattern to escalate sensitive conversations to a human. The worker detects negative sentiment and requests approval before responding:

import { Approve } from "@nolag/agents";

// In the worker, detect when to escalate
const approve = new Approve(room, agents.agentId);

handoff.onTask(["customer-support"], async (task, respond) => {
  const userMessage = String(task.payload.userMessage);
  const sentiment = await analyzeSentiment(userMessage);

  if (sentiment.score < -0.7) {
    const suggestedResponse = await generateResponse(task.payload);

    // Escalate: a human sees the request in an onRequest() handler and
    // answers approved, rejected, or deferred
    const approval = await approve.request(
      "escalate-to-human",
      { userMessage, suggestedResponse },
      { urgency: "high", timeout: 5 * 60 * 1000 },
    );

    if (approval.decision === "approved") {
      respond("success", { response: suggestedResponse });
    } else {
      respond("success", { response: "A member of our team will follow up with you shortly." });
    }
  } else {
    // Normal LLM response
    const response = await generateResponse(task.payload);
    respond("success", { response });
  }
});

The approval topic is published retained, so the most recent approval message is delivered to a reviewer who joins the room later.

Step 6: Monitor Everything

The Observe pattern gives you visibility into the chatbot's performance. Handoff does not emit events on its own; the worker in Step 4 emits task:completed and task:failed, and any observer in the room can consume them:

import { NoLag } from "@nolag/js-sdk";
import { NoLagAgents, Observe } from "@nolag/agents";

const client = NoLag(MONITOR_TOKEN);
const agents = new NoLagAgents({
  client,
  appName: AGENTS_APP_SLUG,
  agentId: "monitor",
  presence: { name: "monitor", role: "observer" },
});
await client.connect();
await agents.ready();

const room = agents.room("support-workflow");
const observe = new Observe(room, agents.agentId);

// Track metrics
let totalQueries = 0;
let avgResponseTime = 0;

observe.on((event) => {
  totalQueries++;
  const duration = Number(event.payload.durationMs);
  avgResponseTime = (avgResponseTime * (totalQueries - 1) + duration) / totalQueries;
  console.log(`Avg response: ${avgResponseTime}ms | Total: ${totalQueries}`);
}, { category: "task:completed" });

observe.on((event) => {
  console.error("Agent failed:", event.payload.error, "from", event.emittedBy);
}, { category: "task:failed" });

// Monitor agent health
const workers = room.findAgents("customer-support");
console.log("Workers online:", workers.length);

room.on("presenceLeave", (actorId) => {
  console.warn("Agent left:", actorId, "remaining:", room.getConnectedAgents().length);
});

What You Get

  • A chat interface with typing indicators and presence - users don't know they're talking to an AI
  • Horizontally scalable AI workers, load balanced across a group when you enable it on the client
  • Human escalation for sensitive conversations
  • Observability: response times, error rates, agent health
  • The last ten messages passed as conversation context on every task
  • Per-topic access control separating user traffic from agent traffic

The entire system runs on NoLag's real-time infrastructure. No Redis queues, no custom WebSocket servers, no separate monitoring stack.

Get started with @nolag/agents →