---
title: Building an Agentic Chatbot with NoLag
description: "Step-by-step tutorial: combine @nolag/chat for the user-facing interface with @nolag/agents for AI-powered responses. Full code walkthrough."
excerpt: "Step-by-step tutorial: combine @nolag/chat for the user-facing interface with @nolag/agents for AI-powered responses. Full code walkthrough with escalation and monitoring."
date: '2026-05-19'
readTime: 14 min read
category: AI Agents
---

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

```bash [Terminal]
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](/docs/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:

```typescript [bridge.ts]
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:

```typescript [worker.ts]
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:

```typescript [escalation.ts]
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 });
  }
});
```
```typescript [reviewer.ts]
import { Approve } from "@nolag/agents";

// On the human reviewer's side (a dashboard or CLI in the same room)
const approve = new Approve(room, agents.agentId);

approve.onRequest((request, respond) => {
  console.log("Review needed:", request.action, request.context);
  respond("approved"); // or "rejected" / "deferred", with an optional reason
});
```

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:

```typescript [monitor.ts]
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 →](/docs/agents/getting-started)
