You have a local model running. Ollama, llama.cpp, vLLM, it doesn't matter. It answers prompts on localhost. Now you want other parts of your system to send it work and get results back. The usual next step is to reach for a framework: LangChain, CrewAI, AutoGen. That's a lot of machinery for what is fundamentally a messaging problem.
This tutorial skips the framework. You'll wire up your local model as a NoLag worker agent in under 5 minutes. The model handles inference. NoLag handles the transport: getting tasks to the model and results back to whoever asked.
What You'll Build
A local Ollama worker that:
- Connects to NoLag and advertises its capabilities via presence
- Picks up tasks dispatched by any other agent in the system
- Calls your local model and sends results back
- Shows up in the NoLag portal dashboard so you can see it's alive
Then you'll add a second model with different capabilities and watch tasks reach the right worker. Zero changes to the dispatching code.
Prerequisites
- Ollama installed:
curl -fsSL https://ollama.com/install.sh | sh - A model pulled:
ollama pull llama3.2 - A NoLag account: create an app from the
nolag-agents-sdkblueprint in the portal and create one actor of typeagentper worker plus one of typeorchestratorfor the dispatcher. The app slug you get back has a random suffix (my-agents-app-a3f9); that suffixed slug is theappNamebelow. The blueprint seeds adefault-workflowroom, which is the room this tutorial uses. - Node.js 18+
Step 1: Create the Worker
Install the dependencies:
npm install @nolag/agents @nolag/js-sdk ollama
The worker connects to NoLag, declares what it can do, and listens for matching tasks. When a task arrives, it calls Ollama and sends the result back.
import { NoLag } from "@nolag/js-sdk";
import { NoLagAgents, Handoff } from "@nolag/agents";
import { Ollama } from "ollama";
const ollama = new Ollama({ host: "http://localhost:11434" });
const client = NoLag(WORKER_TOKEN);
const worker = new NoLagAgents({
client,
appName: APP_SLUG, // the suffixed slug returned when you created the app
agentId: "llama-worker",
presence: {
name: "llama-worker",
role: "agent",
capabilities: ["summarize", "classify"],
metadata: { model: "llama3.2", provider: "local" },
},
});
await client.connect();
await worker.ready();
const room = worker.room("default-workflow");
const handoff = new Handoff(room);
handoff.onTask(["summarize", "classify"], async (task, respond) => {
const response = await ollama.chat({
model: "llama3.2",
messages: [
{ role: "system", content: `You are a ${task.capability} agent.` },
{ role: "user", content: String(task.payload.text) },
],
});
respond("success", {
result: response.message.content,
model: "llama3.2",
latencyMs: Date.now() - task.createdAt,
});
});
console.log("Worker online. Waiting for tasks...");That's the entire worker. No routing table, no queue to operate. The presence block tells the system what this worker can do, and onTask keeps only the tasks whose capability matches. Every task in the room reaches every worker; the capability filter decides which one acts on it.
Step 2: Dispatch a Task
From any other process (a backend service, a CLI script, another agent) dispatch a task by capability:
import { NoLag } from "@nolag/js-sdk";
import { NoLagAgents, Handoff } from "@nolag/agents";
const client = NoLag(ORCHESTRATOR_TOKEN);
const orchestrator = new NoLagAgents({
client,
appName: APP_SLUG,
agentId: "orchestrator",
presence: { name: "orchestrator", role: "orchestrator" },
});
await client.connect();
await orchestrator.ready();
const room = orchestrator.room("default-workflow");
const handoff = new Handoff(room);
// Check who's online
const workers = room.findAgents("summarize");
console.log(`${workers.length} summarize worker(s) online`);
// Dispatch a task. dispatch() checks presence first and throws if no
// connected worker advertises the capability.
const result = await handoff.dispatch("summarize", {
text: "NoLag is a real-time messaging infrastructure..."
}, {
waitForResult: true,
timeout: 30000,
});
if (result) {
console.log("Result:", result.payload.result);
console.log("Model:", result.payload.model);
console.log("Latency:", result.payload.latencyMs, "ms");
}The orchestrator doesn't import ollama. It doesn't know the worker runs Llama. It dispatches a "summarize" task and gets a result. If you later swap the worker to use Mistral, or move it to a GPU server across the network, the dispatching code doesn't change. The dispatcher and the worker are different actors, which matters: a publishing actor never receives its own messages, so one token shared by both would never see the result.
Step 3: Add a Second Model
Pull another model and spin up a second worker with different capabilities:
ollama pull mistral
import { NoLag } from "@nolag/js-sdk";
import { NoLagAgents, Handoff } from "@nolag/agents";
import { Ollama } from "ollama";
const ollama = new Ollama({ host: "http://localhost:11434" });
const client = NoLag(MISTRAL_WORKER_TOKEN);
const worker = new NoLagAgents({
client,
appName: APP_SLUG,
agentId: "mistral-worker",
presence: {
name: "mistral-worker",
role: "agent",
capabilities: ["extract-entities", "translate"],
metadata: { model: "mistral", provider: "local" },
},
});
await client.connect();
await worker.ready();
const room = worker.room("default-workflow");
const handoff = new Handoff(room);
handoff.onTask(["extract-entities", "translate"], async (task, respond) => {
const response = await ollama.chat({
model: "mistral",
messages: [
{ role: "system", content: `You are an ${task.capability} agent.` },
{ role: "user", content: String(task.payload.text) },
],
});
respond("success", {
result: response.message.content,
model: "mistral",
latencyMs: Date.now() - task.createdAt,
});
});Now dispatch tasks and each one is acted on by the worker that registered its capability:
// The orchestrator doesn't change.
// Dispatch by capability; the matching worker picks it up.
const summary = await handoff.dispatch("summarize", {
text: document
}, { waitForResult: true });
// -> handled by llama-worker
const entities = await handoff.dispatch("extract-entities", {
text: document
}, { waitForResult: true });
// -> handled by mistral-worker
const translation = await handoff.dispatch("translate", {
text: summary ? summary.payload.result : document
}, { waitForResult: true });
// -> handled by mistral-worker
// All three tasks ran on local models.
// No API keys. No egress. No per-token billing.The orchestrator dispatches by what needs doing, not by which model does it. Add a third model, remove one, restart a worker: the system adapts because discovery is based on presence, not hardcoded addresses.
What You Get for Free
Presence
Every connected worker shows up in real time. You always know which models are online and what they can do.
// See every connected agent, and react as they come and go
function printAgents() {
for (const agent of room.getConnectedAgents()) {
console.log(`${agent.name} [${agent.status ?? "online"}]`);
console.log(` capabilities: ${agent.capabilities.join(", ") || "(none)"}`);
console.log(` model: ${agent.metadata?.model}`);
}
}
room.on("presenceJoin", printAgents);
room.on("presenceUpdate", printAgents);
room.on("presenceLeave", printAgents);
// Output:
// llama-worker [online]
// capabilities: summarize, classify
// model: llama3.2
// mistral-worker [online]
// capabilities: extract-entities, translate
// model: mistral
// orchestrator [online]
// capabilities: (none)The NoLag portal's Agents view shows this same list graphically, no custom monitoring needed.
Reconnection
If a worker disconnects (machine reboots, network blip, Ollama restart), the core client reconnects automatically and the wrapper restores its room and presence. Whether tasks dispatched in the meantime are waiting for it depends on how it subscribed: NoLag holds and replays missed tasks only for load-balanced worker groups with persistent sessions on the hosted platform (see replay). A single worker subscribed without load balancing misses whatever was dispatched while it was away, and the dispatcher's waitForResult timeout is what tells you.
Load Balancing
Run two instances of the same worker on two machines and turn load balancing on for both:
// Each task goes to ONE member of the "llama-workers" group
const client = NoLag(WORKER_TOKEN, { loadBalance: true, loadBalanceGroup: "llama-workers" });Load balancing is opt-in. Without it every instance receives, and answers, every task.
Observability
The portal's Agents tab shows which agents are connected and what they advertise. Results carry completedBy and whatever metadata the worker adds (model, latency), and workers can emit structured events with the Observe pattern for an observer agent to log to your own system.
Why Not a Framework?
Frameworks like LangChain and CrewAI solve a real problem: making it easy to build agent logic. But they also own the transport. Your agents communicate through function calls inside a single process. That means:
- Agents can't run on different machines
- You can't scale workers independently
- If the coordinator crashes, everything stops
- Adding a human approval step means hacking the framework's internals
NoLag doesn't replace your agent logic. It replaces the wiring between agents. Use whatever prompt engineering, RAG pipeline, or chain-of-thought approach you want inside your worker. NoLag just makes sure tasks get to the right worker and results get back.
Next Steps
- Add a cloud model: see Hybrid LLM Coordination for mixing local and proprietary models
- Add human approval: gate sensitive actions with the Approve pattern before they execute
- Share state: use the Blackboard pattern to track model performance metrics across all workers
- Read the docs: @nolag/agents getting started guide
Your local model is the smart part. NoLag is the dumb pipe that makes it reachable. That's the whole idea.