Skip to main content
Back to Blog
Next.jsLangGraphSocket.ioReact FlowOllamaAI AgentsMongoDBStripeDockerAWSSystem Design

Architecting Visual AI Agent Builders in 2026: Infinite Canvas UIs with Next.js, LangGraph, and Socket.io

A hands-on 2026 blueprint for building a real-time, multiplayer infinite canvas where users visually wire AI agents together. Learn how to compile React Flow nodes into LangGraph pipelines running on Ollama, sync canvases with Socket.io, persist to MongoDB, meter usage with Stripe, and deploy with Docker on AWS.

October 29, 202626 min readNiraj Kumar

A couple of years ago, "building an AI agent" meant writing a long prompt, wrapping it in a loop, and hoping the model behaved. The interface was almost always a chat box. That worked for demos, but it broke down the moment a product manager asked a simple question: "What exactly does this agent do, step by step?"

Nobody could answer that by scrolling a chat log.

In 2026 the answer increasingly looks like a canvas. Teams want to see their agents: a researcher node feeding a summarizer, a router splitting work between specialist agents, a human-approval gate before anything gets emailed to a customer. They want to drag those boxes around together, in real time, the way they already do in Figma or Miro. And they want to press "Run" and watch the graph light up as each step executes.

This guide is a blueprint for building exactly that kind of product: a collaborative infinite-canvas SaaS where users visually wire together autonomous AI agents. We'll go from the frontend canvas all the way down to Docker images on AWS, and I'll be honest about the parts that hurt.

Here's the stack we'll use:

  • Next.js 16 (App Router) with React 19 for the app shell and canvas UI
  • React Flow (@xyflow/react) for the infinite canvas, nodes, and edges
  • LangGraph.js as the agent execution runtime
  • Ollama for local and self-hosted LLM inference
  • Node.js + Socket.io for multiplayer sync, presence, and live execution events
  • MongoDB for canvas documents, run history, and LangGraph checkpoints
  • Stripe Billing Meters for usage-based pricing
  • Docker + AWS (ECS Fargate, EC2 GPU, ElastiCache) for deployment

Let's build it.

Hero illustration of a collaborative infinite canvas with AI agent nodes wired together

Why Visual Agent Orchestration Is Taking Off

Before writing code, it's worth understanding why this pattern is winning, because the reasons shape the architecture.

Chat hides structure; canvases expose it

A multi-agent workflow is a graph. It has branches, loops, and shared state. A chat transcript flattens all of that into a single timeline, so debugging means reading logs. A canvas shows the topology directly, so a non-engineer can point at a node and say "this one is hallucinating."

Agents became graphs, not loops

Frameworks like LangGraph made the "agent as a state machine" model mainstream. Once your runtime is already a graph of nodes and edges, drawing it is the obvious next step. The canvas isn't a decorative layer on top; it's a one-to-one representation of the execution model.

Local models made experimentation cheap

With Ollama running capable open-weight models on a laptop or a single GPU box, users can iterate on agent graphs without watching a hosted-API bill climb. That changes product behavior: people run graphs dozens of times while designing them, which means your system must handle frequent, short, bursty executions.

Collaboration is now table stakes

AI workflows are rarely built by one person. A domain expert writes the prompts, an engineer wires the tools, and someone from compliance adds the approval gate. Multiplayer editing stopped being a "nice to have" and became a buying criterion.

High-Level Architecture

Here's the full system at a glance. Keep this picture in your head; every section that follows zooms into one box.

flowchart LR
  subgraph Client["Browser (Next.js + React Flow)"]
    UI[Infinite Canvas UI]
    Store[Zustand Canvas Store]
    SockC[Socket.io Client]
  end

  subgraph Edge["Next.js Server"]
    RSC[Server Components & Route Handlers]
    Auth[Auth / Session]
  end

  subgraph Realtime["Node.js Realtime Service"]
    IO[Socket.io Server]
    Redis[(Redis Adapter / PubSub)]
  end

  subgraph Exec["Execution Workers"]
    Queue[(BullMQ Queue)]
    Compiler[Graph Compiler]
    LG[LangGraph Runtime]
  end

  subgraph Infra["Data & Models"]
    Mongo[(MongoDB)]
    Ollama[Ollama GPU Nodes]
    Stripe[Stripe Billing Meters]
  end

  UI --> Store --> SockC
  SockC <--> IO
  IO <--> Redis
  RSC --> Mongo
  IO --> Mongo
  IO --> Queue
  Queue --> Compiler --> LG
  LG --> Ollama
  LG --> Mongo
  LG -- execution events --> Redis
  LG --> Stripe

A few deliberate decisions are baked into this diagram:

  1. The browser never runs agents. It edits a graph specification. Execution happens on workers.
  2. The realtime service is separate from Next.js. Long-lived WebSocket connections and serverless-style request handling have very different scaling profiles.
  3. Execution is queued. LLM inference is slow and GPU-bound; WebSocket handlers must stay fast.
  4. Redis is the nervous system. It fans out socket events across realtime instances and carries execution events from workers back to the right room.

The Core Idea: The Canvas Is a Compiler Input

This is the single most important concept in the whole article, so I'll state it plainly:

A canvas is source code. The server compiles it into a LangGraph StateGraph.

Each node on the canvas has a type (like llm, tool, router, or humanApproval) and a config object. Each edge connects an output handle to an input handle and may carry a condition. Together they form a JSON document we'll call a GraphSpec.

flowchart TD
  A[React Flow nodes + edges] --> B[GraphSpec JSON]
  B --> C{Zod validation}
  C -- invalid --> D[Inline canvas errors]
  C -- valid --> E[Cycle & reachability checks]
  E --> F[Node factory: type -> LangGraph node fn]
  F --> G[StateGraph with edges + conditional edges]
  G --> H[compile with MongoDB checkpointer]
  H --> I[stream run, emit events per node]

Thinking of it as a compiler pipeline gives you three huge benefits: you can validate before running, you can version specs like code, and you can unit-test the compiler without touching a browser.

Defining the GraphSpec schema

Share this schema between frontend and backend in a workspace package (for example packages/graph-spec).

ts
// packages/graph-spec/src/schema.ts
import { z } from "zod";
 
export const NodeType = z.enum(["input", "llm", "tool", "router", "humanApproval", "output"]);
 
export const LlmConfig = z.object({
  model: z.string().default("llama3.1:8b"),
  systemPrompt: z.string().max(8000),
  temperature: z.number().min(0).max(2).default(0.2),
  outputKey: z.string().regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/),
});
 
export const RouterConfig = z.object({
  routes: z.array(z.object({ label: z.string(), match: z.string() })).min(1),
  inputKey: z.string(),
});
 
export const SpecNode = z.object({
  id: z.string(),
  type: NodeType,
  position: z.object({ x: z.number(), y: z.number() }),
  config: z.record(z.string(), z.unknown()),
});
 
export const SpecEdge = z.object({
  id: z.string(),
  source: z.string(),
  target: z.string(),
  sourceHandle: z.string().nullable().optional(),
  label: z.string().optional(),
});
 
export const GraphSpec = z.object({
  id: z.string(),
  version: z.number().int().nonnegative(),
  nodes: z.array(SpecNode).max(200),
  edges: z.array(SpecEdge).max(600),
});
 
export type GraphSpec = z.infer<typeof GraphSpec>;

Notice the hard limits (max(200) nodes). Those aren't arbitrary. Every unbounded input in a multi-tenant system is a denial-of-service vector waiting to happen.

Building the Infinite Canvas with Next.js and React Flow

React Flow (now published as @xyflow/react) gives you panning, zooming, selection, minimap, and edge routing out of the box. Our job is to make it collaborative and fast.

Custom agent nodes

Each node type gets its own memoized component. Memoization matters: a canvas with 150 nodes re-rendering on every drag frame will feel like wading through syrup.

tsx
// apps/web/components/canvas/nodes/LlmNode.tsx
"use client";
import { memo } from "react";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import { useRunStatus } from "@/stores/run-store";
 
type LlmNodeData = { label: string; model: string };
 
function LlmNodeInner({ id, data, selected }: NodeProps & { data: LlmNodeData }) {
  const status = useRunStatus(id); // "idle" | "running" | "done" | "error"
 
  return (
    <div
      className={`rounded-xl border bg-white px-4 py-3 shadow-sm transition
        ${selected ? "ring-2 ring-indigo-500" : ""}
        ${status === "running" ? "animate-pulse border-amber-400" : ""}
        ${status === "error" ? "border-red-500" : ""}`}
    >
      <Handle type="target" position={Position.Left} />
      <div className="text-xs font-medium uppercase text-slate-500">LLM · {data.model}</div>
      <div className="mt-1 text-sm font-semibold text-slate-900">{data.label}</div>
      <Handle type="source" position={Position.Right} />
    </div>
  );
}
 
export const LlmNode = memo(LlmNodeInner);

The key detail is useRunStatus(id). Execution status lives in a separate store from the graph itself. When a run streams fifty status updates per second, only the affected nodes re-render, not the entire canvas.

The canvas component

tsx
// apps/web/components/canvas/AgentCanvas.tsx
"use client";
import { ReactFlow, Background, MiniMap, Controls } from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { useCanvasStore } from "@/stores/canvas-store";
import { useCanvasSync } from "@/hooks/use-canvas-sync";
import { LlmNode } from "./nodes/LlmNode";
import { RouterNode } from "./nodes/RouterNode";
import { ToolNode } from "./nodes/ToolNode";
import { RemoteCursors } from "./RemoteCursors";
 
const nodeTypes = { llm: LlmNode, router: RouterNode, tool: ToolNode };
 
export function AgentCanvas({ canvasId }: { canvasId: string }) {
  const { nodes, edges, onNodesChange, onEdgesChange, onConnect } = useCanvasStore();
  const { emitCursor } = useCanvasSync(canvasId);
 
  return (
    <div className="h-[calc(100vh-56px)] w-full" onPointerMove={(e) => emitCursor(e.clientX, e.clientY)}>
      <ReactFlow
        nodes={nodes}
        edges={edges}
        nodeTypes={nodeTypes}
        onNodesChange={onNodesChange}
        onEdgesChange={onEdgesChange}
        onConnect={onConnect}
        onlyRenderVisibleElements
        fitView
      >
        <Background gap={24} />
        <MiniMap pannable zoomable />
        <Controls />
      </ReactFlow>
      <RemoteCursors />
    </div>
  );
}

Define nodeTypes outside the component. Defining it inline creates a new object on every render, and React Flow will warn you and remount every node. It's one of the most common mistakes I see in code reviews.

Loading the canvas with Server Components

Use a Server Component to fetch the initial canvas so the first paint is instant and SEO-safe for any public "template gallery" pages.

tsx
// apps/web/app/(app)/canvas/[id]/page.tsx
import { getCanvas } from "@/server/canvas-repo";
import { CanvasHydrator } from "@/components/canvas/CanvasHydrator";
 
export default async function CanvasPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const canvas = await getCanvas(id); // checks membership + returns spec
  return <CanvasHydrator initial={canvas} />;
}

Real-Time Multiplayer with Socket.io

Now the fun part: making five people edit the same graph without stepping on each other.

Three kinds of traffic, three policies

The mistake almost every first version makes is treating all real-time messages the same. They aren't.

Traffic typeExampleFrequencyDelivery need
PresenceCursor positions, who's onlineVery highLossy is fine; drop stale frames
Canvas opsMove node, add edge, edit promptMediumMust be ordered and persisted
Execution eventsNode started, token streamed, node finishedBurstyOrdered per run; replayable

Socket.io's volatile emits are perfect for presence: if the client's buffer is full, the message is dropped instead of queued. Canvas ops get acknowledgements and a server-assigned version number. Execution events are published by workers through Redis and replayable from MongoDB.

The realtime server

ts
// apps/realtime/src/server.ts
import { createServer } from "node:http";
import { Server } from "socket.io";
import { createAdapter } from "@socket.io/redis-adapter";
import { Redis } from "ioredis";
import { verifySession } from "./auth";
import { applyCanvasOp } from "./canvas-ops";
import { enqueueRun } from "./runs";
 
const httpServer = createServer();
const pub = new Redis(process.env.REDIS_URL!);
const sub = pub.duplicate();
 
const io = new Server(httpServer, {
  cors: { origin: process.env.APP_ORIGIN, credentials: true },
  adapter: createAdapter(pub, sub),
  connectionStateRecovery: { maxDisconnectionDuration: 2 * 60 * 1000 },
});
 
io.use(async (socket, next) => {
  const user = await verifySession(socket.handshake.auth.token);
  if (!user) return next(new Error("unauthorized"));
  socket.data.user = user;
  next();
});
 
io.on("connection", (socket) => {
  socket.on("canvas:join", async ({ canvasId }, ack) => {
    if (!(await socket.data.user.canAccess(canvasId))) return ack({ ok: false });
    socket.join(`canvas:${canvasId}`);
    socket.to(`canvas:${canvasId}`).emit("presence:join", { user: socket.data.user.public });
    ack({ ok: true });
  });
 
  // Presence: lossy, high-frequency
  socket.on("presence:cursor", ({ canvasId, x, y }) => {
    socket.volatile.to(`canvas:${canvasId}`).emit("presence:cursor", {
      userId: socket.data.user.id, x, y,
    });
  });
 
  // Canvas ops: ordered, versioned, persisted
  socket.on("canvas:op", async ({ canvasId, baseVersion, op }, ack) => {
    const result = await applyCanvasOp(canvasId, baseVersion, op, socket.data.user.id);
    if (!result.ok) return ack({ ok: false, reason: result.reason, latest: result.latest });
    socket.to(`canvas:${canvasId}`).emit("canvas:op", { op, version: result.version });
    ack({ ok: true, version: result.version });
  });
 
  // Execution: enqueue, never run inline
  socket.on("run:start", async ({ canvasId, input }, ack) => {
    const run = await enqueueRun({ canvasId, input, userId: socket.data.user.id });
    ack(run); // { ok, runId } or { ok: false, reason: "quota_exceeded" }
  });
});
 
httpServer.listen(Number(process.env.PORT ?? 4000));

Resolving conflicts with optimistic versioning

applyCanvasOp uses a simple but robust approach: each canvas has a monotonically increasing version. A client sends the version its edit was based on. The server accepts the op if it doesn't conflict, bumps the version, and broadcasts.

ts
// apps/realtime/src/canvas-ops.ts
import { canvases } from "./db";
 
export async function applyCanvasOp(canvasId: string, baseVersion: number, op: CanvasOp, userId: string) {
  const update = toMongoUpdate(op); // e.g. { $set: { "nodes.$[n].position": op.position } }
 
  const res = await canvases.findOneAndUpdate(
    { _id: canvasId, version: { $gte: baseVersion } },
    { ...update, $inc: { version: 1 }, $set: { updatedAt: new Date(), updatedBy: userId } },
    { returnDocument: "after", arrayFilters: arrayFiltersFor(op) }
  );
 
  if (!res) {
    const latest = await canvases.findOne({ _id: canvasId }, { projection: { version: 1 } });
    return { ok: false as const, reason: "stale", latest: latest?.version };
  }
  return { ok: true as const, version: res.version };
}

Because most ops are commutative at the node level (two people moving different nodes never conflict), this works surprisingly well. Field-level ops ("set this node's position") rather than whole-document replacement are what make it work.

When you outgrow this, typically when several people are typing into the same prompt field at once, introduce Yjs for the text-heavy fields and keep Socket.io as the transport. You don't need to rip anything out.

sequenceDiagram
  participant A as Alice (browser)
  participant S as Socket.io Server
  participant M as MongoDB
  participant B as Bob (browser)

  A->>A: Optimistically move node N1
  A->>S: canvas:op {baseVersion: 41, op: move N1}
  S->>M: findOneAndUpdate (version >= 41)
  M-->>S: ok, version 42
  S-->>A: ack {ok, version: 42}
  S->>B: canvas:op {op: move N1, version: 42}
  B->>B: Apply op, set version 42

Throttling cursors on the client

ts
// apps/web/hooks/use-canvas-sync.ts
import { useEffect, useMemo } from "react";
import { io } from "socket.io-client";
import throttle from "lodash.throttle";
import { useCanvasStore } from "@/stores/canvas-store";
import { usePresenceStore } from "@/stores/presence-store";
 
export function useCanvasSync(canvasId: string) {
  const socket = useMemo(() => io(process.env.NEXT_PUBLIC_REALTIME_URL!, {
    auth: (cb) => fetch("/api/realtime-token").then((r) => r.json()).then(cb),
    transports: ["websocket"],
  }), []);
 
  useEffect(() => {
    socket.emit("canvas:join", { canvasId }, () => {});
    socket.on("canvas:op", ({ op, version }) => useCanvasStore.getState().applyRemote(op, version));
    socket.on("presence:cursor", (p) => usePresenceStore.getState().setCursor(p));
    socket.on("run:event", (e) => useRunStore.getState().ingest(e));
    return () => { socket.disconnect(); };
  }, [socket, canvasId]);
 
  const emitCursor = useMemo(
    () => throttle((x: number, y: number) => socket.volatile.emit("presence:cursor", { canvasId, x, y }), 40),
    [socket, canvasId]
  );
 
  return { socket, emitCursor };
}

Forty milliseconds (about 25 updates per second) is smooth enough for cursors and cheap enough to scale. Interpolate on the receiving side for buttery motion.

Diagram of three collaborators editing a canvas with color-coded live cursors and a version counter

Compiling the Canvas into a LangGraph Pipeline

This is where the canvas becomes a real agent. The worker pulls a job from the queue, loads the GraphSpec, compiles it, and streams execution.

Shared state

LangGraph nodes communicate through a typed shared state. For a visual builder, a flexible-but-bounded state works best: a message list plus a key/value bag that nodes write into via their outputKey.

ts
// apps/worker/src/state.ts
import { Annotation, MessagesAnnotation } from "@langchain/langgraph";
 
export const CanvasState = Annotation.Root({
  ...MessagesAnnotation.spec,
  vars: Annotation<Record<string, unknown>>({
    reducer: (prev, next) => ({ ...prev, ...next }),
    default: () => ({}),
  }),
  route: Annotation<string | null>({
    reducer: (_prev, next) => next,
    default: () => null,
  }),
});
 
export type CanvasStateT = typeof CanvasState.State;

A node factory

Each canvas node type maps to a function that returns a LangGraph node. This is the "standard library" of your product, and adding a new node type should mean adding one entry here plus one React component.

ts
// apps/worker/src/node-factory.ts
import { ChatOllama } from "@langchain/ollama";
import { SystemMessage, HumanMessage } from "@langchain/core/messages";
import { interrupt } from "@langchain/langgraph";
import { LlmConfig, RouterConfig } from "@acme/graph-spec";
import type { CanvasStateT } from "./state";
import { tools } from "./tools";
 
const ollamaBase = process.env.OLLAMA_BASE_URL ?? "http://ollama:11434";
 
export function buildNode(node: SpecNode) {
  switch (node.type) {
    case "llm": {
      const cfg = LlmConfig.parse(node.config);
      const model = new ChatOllama({ baseUrl: ollamaBase, model: cfg.model, temperature: cfg.temperature });
      return async (state: CanvasStateT) => {
        const prompt = interpolate(cfg.systemPrompt, state.vars);
        const res = await model.invoke([new SystemMessage(prompt), ...state.messages.slice(-12)]);
        return { messages: [res], vars: { [cfg.outputKey]: res.content } };
      };
    }
 
    case "router": {
      const cfg = RouterConfig.parse(node.config);
      return async (state: CanvasStateT) => {
        const value = String(state.vars[cfg.inputKey] ?? "").toLowerCase();
        const hit = cfg.routes.find((r) => value.includes(r.match.toLowerCase()));
        return { route: hit?.label ?? "default" };
      };
    }
 
    case "tool": {
      const tool = tools[node.config.toolName as string];
      if (!tool) throw new CompileError(node.id, "Unknown tool");
      return async (state: CanvasStateT) => ({ vars: { [node.id]: await tool.invoke(state.vars) } });
    }
 
    case "humanApproval":
      return async (state: CanvasStateT) => {
        const decision = interrupt({ nodeId: node.id, preview: state.vars });
        return { vars: { [`${node.id}_approved`]: decision === "approve" } };
      };
 
    default:
      return async () => ({});
  }
}

The humanApproval node uses LangGraph's interrupt(). Execution pauses, the checkpoint is saved to MongoDB, and the canvas shows an "Approve / Reject" prompt on that node. When a user clicks, you resume the graph with a Command({ resume: "approve" }). That's a feature that would take weeks to build from scratch, and it's a few lines here.

The compiler

ts
// apps/worker/src/compile.ts
import { StateGraph, START, END } from "@langchain/langgraph";
import { MongoDBSaver } from "@langchain/langgraph-checkpoint-mongodb";
import { GraphSpec } from "@acme/graph-spec";
import { CanvasState } from "./state";
import { buildNode } from "./node-factory";
import { mongoClient } from "./db";
 
const checkpointer = new MongoDBSaver({ client: mongoClient, dbName: "agents" });
 
export function compileSpec(raw: unknown) {
  const spec = GraphSpec.parse(raw);
  assertSingleEntry(spec);
  assertReachable(spec);
  assertBoundedCycles(spec); // every cycle must pass through a node with a max-iterations guard
 
  const graph = new StateGraph(CanvasState);
 
  for (const n of spec.nodes) {
    if (n.type === "input" || n.type === "output") continue;
    graph.addNode(n.id, buildNode(n));
  }
 
  const entry = spec.nodes.find((n) => n.type === "input")!;
  const terminal = new Set(spec.nodes.filter((n) => n.type === "output").map((n) => n.id));
  const resolve = (id: string) => (id === entry.id ? START : terminal.has(id) ? END : id);
 
  for (const n of spec.nodes.filter((x) => x.type === "router")) {
    const outgoing = spec.edges.filter((e) => e.source === n.id);
    const mapping = Object.fromEntries(outgoing.map((e) => [e.label ?? "default", resolve(e.target)]));
    graph.addConditionalEdges(n.id as any, (s) => s.route ?? "default", mapping);
  }
 
  for (const e of spec.edges) {
    const src = spec.nodes.find((n) => n.id === e.source)!;
    if (src.type === "router") continue;
    graph.addEdge(resolve(e.source) as any, resolve(e.target) as any);
  }
 
  return graph.compile({ checkpointer });
}

A quick note on the as any casts: LangGraph's TypeScript types are designed around graphs whose node names are known at compile time. In a visual builder, node names are dynamic user data, so a small, isolated cast at the compiler boundary is the pragmatic choice. Keep it contained to this one file.

Streaming execution back to the canvas

ts
// apps/worker/src/run.ts
import { compileSpec } from "./compile";
import { publishRunEvent } from "./events";
import { recordUsage } from "./billing";
 
export async function executeRun(job: RunJob) {
  const app = compileSpec(job.spec);
  const config = { configurable: { thread_id: job.runId }, recursionLimit: 50 };
 
  let tokens = 0;
  const stream = app.streamEvents({ messages: [{ role: "user", content: job.input }] }, { ...config, version: "v2" });
 
  for await (const ev of stream) {
    const nodeId = ev.metadata?.langgraph_node;
    if (ev.event === "on_chain_start" && nodeId) publishRunEvent(job, { type: "node:start", nodeId });
    if (ev.event === "on_chat_model_stream" && nodeId)
      publishRunEvent(job, { type: "node:token", nodeId, chunk: ev.data.chunk.content });
    if (ev.event === "on_chat_model_end")
      tokens += ev.data.output?.usage_metadata?.total_tokens ?? 0;
    if (ev.event === "on_chain_end" && nodeId) publishRunEvent(job, { type: "node:end", nodeId });
  }
 
  await recordUsage(job.workspaceId, { tokens, nodeExecutions: job.spec.nodes.length });
}

publishRunEvent writes to a Redis channel that the realtime service subscribes to and forwards to canvas:${canvasId}. Every collaborator watching the canvas sees nodes pulse amber, stream tokens into a side panel, and turn green. That moment, when the whole graph lights up in sequence, is what sells this product in demos.

sequenceDiagram
  participant U as User
  participant RT as Realtime Service
  participant Q as BullMQ
  participant W as Worker (LangGraph)
  participant O as Ollama
  participant R as Redis PubSub

  U->>RT: run:start {canvasId, input}
  RT->>RT: Check quota
  RT->>Q: enqueue job
  RT-->>U: ack {runId}
  Q->>W: deliver job
  W->>W: compileSpec()
  loop each node
    W->>R: node:start
    R->>RT: forward
    RT->>U: run:event
    W->>O: chat completion (stream)
    O-->>W: tokens
    W->>R: node:token / node:end
    R->>RT: forward
    RT->>U: run:event
  end
  W->>W: recordUsage()

Running Ollama safely in production

Ollama is a joy to use locally, but in a multi-tenant SaaS you need guardrails:

  • Set OLLAMA_NUM_PARALLEL to match what your GPU memory can actually handle, and set OLLAMA_MAX_LOADED_MODELS so one tenant switching models doesn't evict everyone else's.
  • Pin a model allowlist. Users pick from models you've pulled and tested; never pass arbitrary model names to ollama pull.
  • Pre-warm models at container start with a tiny request so the first real run doesn't pay a cold-load penalty.
  • Keep a hosted fallback. Abstract the chat model behind your node factory so a Pro plan can route to a hosted model when local capacity is saturated.

Persistence with MongoDB

MongoDB fits this domain naturally because a canvas is a document. Here's a schema that has held up well:

ts
// Collections
canvases: {
  _id, workspaceId, name, version,
  nodes: [{ id, type, position, config }],
  edges: [{ id, source, target, sourceHandle, label }],
  createdAt, updatedAt, updatedBy
}
 
canvas_snapshots: {   // immutable, written on "Publish" or every N versions
  _id, canvasId, version, spec, createdBy, createdAt
}
 
runs: {
  _id, canvasId, snapshotVersion, workspaceId, status, input,
  startedAt, finishedAt, tokens, error
}
 
run_events: {         // TTL-indexed, for replay and debugging
  runId, seq, type, nodeId, payload, at
}
 
checkpoints / checkpoint_writes  // managed by MongoDBSaver

Indexes you'll want on day one:

js
db.canvases.createIndex({ workspaceId: 1, updatedAt: -1 });
db.runs.createIndex({ canvasId: 1, startedAt: -1 });
db.run_events.createIndex({ runId: 1, seq: 1 });
db.run_events.createIndex({ at: 1 }, { expireAfterSeconds: 60 * 60 * 24 * 14 });

The most important rule: runs always point to an immutable snapshot, never to the live canvas. Otherwise someone editing the graph mid-run creates a debugging nightmare where the logs don't match what's on screen.

Usage-Based Billing with Stripe

AI products have real marginal costs, so flat pricing gets you in trouble fast. Stripe's Billing Meters let you report usage as it happens and let Stripe aggregate it per billing period.

Create two meters in the Stripe dashboard (or via API), for example agent_tokens and node_executions, and attach metered prices to your subscription products. Then report usage from the worker:

ts
// apps/worker/src/billing.ts
import Stripe from "stripe";
import { workspaces, usageLedger } from "./db";
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
 
export async function recordUsage(workspaceId: string, u: { tokens: number; nodeExecutions: number }) {
  const ws = await workspaces.findOne({ _id: workspaceId });
  if (!ws?.stripeCustomerId) return;
 
  // 1. Local ledger first: source of truth for quota enforcement
  await usageLedger.insertOne({ workspaceId, ...u, at: new Date() });
 
  // 2. Report to Stripe with an idempotency identifier
  await stripe.billing.meterEvents.create({
    event_name: "agent_tokens",
    identifier: `${workspaceId}-${Date.now()}-tokens`,
    payload: { stripe_customer_id: ws.stripeCustomerId, value: String(u.tokens) },
  });
}

Two practices make this robust:

  1. Enforce quotas locally, bill remotely. Your realtime service checks the local usageLedger before enqueuing a run. Stripe is for invoicing, not for real-time gatekeeping.
  2. Use identifiers for idempotency. Workers retry. Without a stable identifier per event, a retried job double-bills a customer, and that's the fastest way to lose trust.

Handle customer.subscription.updated and invoice.payment_failed webhooks in a Next.js Route Handler to keep plan limits in sync and to gracefully downgrade workspaces that stop paying.

Dockerized Deployment on AWS

Each service gets its own image, which keeps scaling independent.

dockerfile
# apps/realtime/Dockerfile
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/realtime/package.json apps/realtime/
COPY packages/graph-spec/package.json packages/graph-spec/
RUN corepack enable && pnpm install --frozen-lockfile --filter realtime...
 
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app ./
COPY . .
RUN corepack enable && pnpm --filter realtime build
 
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/apps/realtime/dist ./dist
COPY --from=build /app/node_modules ./node_modules
USER node
EXPOSE 4000
CMD ["node", "dist/server.js"]

For local development, a single docker-compose.yml brings the whole platform up:

yaml
services:
  web:
    build: { context: ., dockerfile: apps/web/Dockerfile }
    ports: ["3000:3000"]
    env_file: .env
  realtime:
    build: { context: ., dockerfile: apps/realtime/Dockerfile }
    ports: ["4000:4000"]
    depends_on: [redis, mongo]
  worker:
    build: { context: ., dockerfile: apps/worker/Dockerfile }
    depends_on: [redis, mongo, ollama]
    environment:
      OLLAMA_BASE_URL: http://ollama:11434
  ollama:
    image: ollama/ollama:latest
    volumes: ["ollama:/root/.ollama"]
    deploy:
      resources:
        reservations:
          devices: [{ driver: nvidia, count: 1, capabilities: [gpu] }]
  redis:
    image: redis:7-alpine
  mongo:
    image: mongo:8
    volumes: ["mongo:/data/db"]
volumes: { ollama: {}, mongo: {} }

In production on AWS, a layout that works well:

  • Next.js web app on ECS Fargate (or your preferred Next.js host) behind CloudFront
  • Realtime service on ECS Fargate behind an Application Load Balancer with sticky sessions enabled and WebSocket idle timeouts raised
  • Workers on ECS, autoscaled on BullMQ queue depth via a custom CloudWatch metric
  • Ollama on GPU-backed EC2 instances in a private subnet, reachable only from workers
  • ElastiCache for Redis for the Socket.io adapter and BullMQ
  • MongoDB Atlas (deployed in the same AWS region) for persistence
  • Secrets Manager for Stripe keys and database credentials

AWS deployment diagram showing CloudFront, ALB, ECS services, GPU EC2 Ollama nodes, ElastiCache, and MongoDB Atlas

A Real-World Example: The "Support Triage" Canvas

To make this concrete, here's a graph one of our pilot teams built in about twenty minutes:

  1. Input: a raw customer support email.
  2. LLM "Classifier" (llama3.1:8b): outputs category as billing, bug, or feature request.
  3. Router: branches on category.
  4. Billing branch: a Tool node looks up the customer's invoices, then an LLM drafts a reply.
  5. Bug branch: an LLM extracts reproduction steps; a Tool node creates a ticket.
  6. Human Approval: every drafted reply pauses for a support lead.
  7. Output: the approved response.
flowchart LR
  In([Email Input]) --> C[LLM: Classifier]
  C --> R{Router}
  R -- billing --> T1[Tool: Invoice Lookup] --> D1[LLM: Draft Reply]
  R -- bug --> E[LLM: Extract Repro Steps] --> T2[Tool: Create Ticket] --> D2[LLM: Draft Ack]
  R -- feature --> D3[LLM: Draft Thanks]
  D1 --> H{{Human Approval}}
  D2 --> H
  D3 --> H
  H --> Out([Approved Response])

What impressed the team wasn't the AI itself. It was that the support lead, who doesn't write code, rewired the bug branch on her own when she noticed tickets were missing a priority field. That's the real value proposition of visual orchestration: the people who understand the process can change the process.

Best Practices

  • Validate on both sides. Run the Zod schema in the browser for instant feedback and again on the server, because the server is the only one you can trust.
  • Version everything. Canvases, snapshots, node-type definitions, and prompts. When a run misbehaves, you need to know exactly which graph produced it.
  • Keep execution state out of the graph store. Graph structure changes rarely; run status changes constantly. Separate Zustand stores keep re-renders surgical.
  • Bound every loop. Set LangGraph's recursionLimit and require an explicit max-iterations guard on any user-created cycle.
  • Sandbox tools. Tool nodes that call external APIs or run code must execute with scoped credentials per workspace and strict timeouts.
  • Make runs replayable. Persist run_events with sequence numbers so a user who joins late, or reconnects, sees the full run.
  • Design for reconnection. Socket.io's connection state recovery helps, but your client should still re-fetch the canvas version on reconnect and reconcile.

Common Mistakes

  • Executing graphs inside the WebSocket handler. One slow model call and every socket on that instance lags. Always queue.
  • Broadcasting the whole canvas on every change. It works with two users and ten nodes. It collapses with ten users and two hundred nodes. Send ops, not snapshots.
  • Running against the live canvas instead of a snapshot. Logs stop matching the screen, and nobody can debug anything.
  • Defining nodeTypes inside the component. It forces React Flow to remount every node on every render.
  • Trusting user-supplied model names or prompts blindly. Allowlist models, cap prompt lengths, and treat tool outputs as untrusted input for prompt-injection purposes.
  • Billing only from Stripe data. Meter aggregation isn't instant. Enforce limits from your own ledger.
  • Forgetting sticky sessions or the Redis adapter. Socket.io across multiple instances without them produces mysterious "half the room didn't get the update" bugs.

🚀 Pro Tips

  • Ship a node SDK early. Define each node type as { schema, component, factory } in one package. Adding a node becomes a single pull request, and eventually you can open it to third-party developers as a marketplace.
  • Add "Run from here." LangGraph checkpoints let you resume from any node's saved state. Letting users re-run only the last three nodes while tuning a prompt saves enormous time and compute.
  • Color edges by data flow. During a run, animate edges as state passes through them. It's cheap to implement and makes the graph feel alive.
  • Auto-layout on import. Use a layout library like ELK or Dagre when users import a graph or generate one from a natural-language prompt, so they never start with a pile of overlapping boxes.
  • Let an agent build the agent. A "describe your workflow" box that outputs a GraphSpec (validated by the same Zod schema) is a wonderful onboarding shortcut.
  • Track cost per node. Show estimated tokens next to each LLM node. Users optimize what they can see.
  • Load-test presence separately. Use a tool like Artillery with the Socket.io engine to simulate fifty cursors per room before launch; presence is where scaling surprises usually hide.

📌 Key Takeaways

  • The canvas is a declarative spec; the server compiles it into a LangGraph StateGraph and runs it on workers.
  • Split real-time traffic into presence (lossy), canvas ops (ordered and persisted), and execution events (replayable).
  • Optimistic versioning with field-level ops handles most multiplayer conflicts; add Yjs only where concurrent text editing demands it.
  • Ollama is great for local and self-hosted tiers when you add queuing, concurrency caps, model allowlists, and a hosted fallback.
  • MongoDB maps naturally to canvas documents, and MongoDBSaver gives you durable checkpoints for pausing and resuming agents.
  • Stripe Billing Meters align revenue with real cost, but quota enforcement belongs in your own database.
  • Separate Docker images per service let the web tier, realtime tier, workers, and GPU nodes scale independently on AWS.

Conclusion

Visual AI agent builders sit at an interesting intersection of hard problems: collaborative editing, graph compilation, long-running stateful execution, GPU scheduling, and metered billing. Any one of these is a solid engineering project. Combining them is what makes this category both challenging and genuinely defensible.

The architecture in this guide is built around one principle that keeps everything sane: separate the description of an agent from its execution. The browser edits a graph. The realtime layer keeps everyone's view of that graph consistent. Workers compile and run immutable snapshots of it. Everything else, including billing, persistence, and deployment, falls into place once that boundary is clear.

If you're starting today, don't try to build all of it at once. Get a single-player canvas compiling into LangGraph and running on Ollama first. Then add Socket.io presence, then multiplayer ops, then billing. Each layer is independently valuable, and each one will teach you something about how your users actually think about their agents.

The chat box isn't going away, but for anything more complex than a single conversation, the future of AI tooling is spatial. Now you have a blueprint to build it.

References

Frequently asked questions

Why use LangGraph instead of writing my own agent execution engine?

LangGraph already gives you typed shared state, conditional edges, cycles, checkpointing, interrupts for human-in-the-loop, and streaming. Those are exactly the features a visual builder needs, and rebuilding them correctly (especially resumable checkpoints) takes months. Your job becomes compiling the canvas into a StateGraph, not reinventing a runtime.

Can React Flow handle large canvases with hundreds of nodes?

Yes, if you are careful. Use memoized custom node components, keep heavy data out of node objects, enable onlyRenderVisibleElements for very large graphs, and avoid re-rendering the whole graph on every remote cursor move by storing presence in a separate store.

Should I use Socket.io or a CRDT library like Yjs for multiplayer sync?

They solve different problems and work well together. Socket.io is the transport and room system; Yjs handles conflict-free merging of concurrent edits. For small teams an authoritative-server model over Socket.io is enough. Once you expect many simultaneous editors on the same graph, layer Yjs on top.

Is Ollama production-ready for a SaaS product?

Ollama is excellent for local-first, privacy-sensitive, and self-hosted tiers, and it runs fine on GPU-backed EC2 instances. For high-concurrency multi-tenant workloads, put it behind a queue, cap concurrency per GPU, and keep a hosted-model fallback so one slow model does not stall every customer.

How do I bill fairly for AI agent usage?

Use usage-based billing tied to a real cost driver, typically tokens processed or node executions. Stripe Billing Meters let you send meter events as runs complete and aggregate them per billing period, while you enforce plan limits in your own database before starting a run.

Discussion

All Articles
Next.jsLangGraphSocket.ioReact FlowOllamaAI AgentsMongoDBStripeDockerAWSSystem Design

Written by

Niraj Kumar

Software Developer — building scalable systems for businesses.

Building this for real? Next.js Developer Services — Next.js 15/16 App Router apps with TypeScript, Server Actions, and Vercel/AWS deploys.