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:
- Chat frontend - Users interact with a standard chat room powered by
@nolag/chat. They see typing indicators, message history, and presence. - 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.
- 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-sdkStep 2: Set Up Apps in the Portal
Create two apps in your NoLag project:
- A Chat Blueprint app (
nolag-chat-sdk), named for examplesupport-chat - An Agents Blueprint app (
nolag-agents-sdk), named for examplesupport-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.