Skip to main content
Back to Blog
Event-Driven ArchitectureLangGraphNode.jsAI AgentsOllamaAWSSocket.ioNext.jsn8nDocker

Architecting Event-Driven AI Agent Workflows: Integrating LangGraph, Node.js, and AWS for Real-Time Automation

Stop polling APIs. Learn how to build real-time, event-driven multi-agent AI workflows with LangGraph, Ollama local LLMs, Node.js, Express, n8n, Make.com, Socket.io, Next.js, Docker, and AWS — a practical 2026 blueprint for sovereign AI automation.

October 20, 202624 min readNiraj Kumar

A couple of years ago, I inherited an "AI automation" system that looked impressive in the demo and quietly bled money in production. Every 30 seconds, a cron job asked the CRM, the ticketing system, and the billing API the same question: "Anything new?" Ninety-eight percent of the time, the answer was no. The other two percent of the time, the answer arrived up to 30 seconds late, which is an eternity when a customer is threatening to churn in a live chat.

That system taught me a lesson I keep relearning: AI agents are only as responsive as the architecture that feeds them. You can have the smartest multi-agent graph on the planet, but if it waits on a polling loop, it is a very smart system that shows up late to every meeting.

In this guide, we are going to fix that properly. We will design an event-driven AI agent platform where:

  • Business systems push events the moment something happens
  • A Node.js + Express ingestion layer verifies and enqueues those events in milliseconds
  • n8n and Make.com handle smart routing and enrichment
  • LangGraph orchestrates a multi-agent workflow powered by Ollama local LLMs
  • Socket.io streams every agent state change to a Next.js dashboard in real time
  • Everything runs in Docker containers on AWS, inside your own network boundary

Let's build it the way you would build it for a real enterprise, not a weekend hackathon.

High-level architecture of an event-driven AI agent platform

Why Polling Breaks Down for AI Agents

Polling feels simple, which is exactly why it sneaks into so many codebases. But for AI workloads, it fails in three specific ways.

1. Latency is baked in. If you poll every 30 seconds, your average reaction time is 15 seconds before the agent even starts thinking. Add LLM inference and you are well past the point where "real-time" means anything.

2. Cost scales with time, not work. Every poll costs network calls, database reads, and often third-party API quota. Your bill grows whether or not anything happened. With AI agents, teams often make it worse by sending each polled batch through a model "just to check."

3. Rate limits become your ceiling. Most SaaS APIs throttle aggressively. Poll faster to cut latency and you hit 429 errors. Poll slower and your agents go stale.

Event-driven architecture (EDA) flips the relationship. Instead of your system asking, the source system tells you. Webhooks, change data capture streams, message queues, and cloud event buses all push a small, well-defined payload when state changes. Your agents wake up only when there is real work.

The Core Concepts You Need

Before writing code, let's agree on vocabulary. These terms will come up throughout the build.

Events, Not Requests

An event is an immutable fact about something that already happened: ticket.created, invoice.overdue, lead.scored. It is not a command. That distinction matters because events can be replayed, audited, and consumed by many independent subscribers without coupling them together.

A good event envelope looks like this:

{
  "id": "evt_01JB7Q3K9X2M8N4P5R6S7T8V9W",
  "type": "ticket.created",
  "source": "zendesk",
  "occurredAt": "2026-10-20T09:14:03.211Z",
  "tenantId": "acme-corp",
  "data": {
    "ticketId": "48213",
    "subject": "Charged twice for October",
    "priority": "high",
    "customerEmail": "jane@example.com"
  }
}

The id gives you idempotency, type drives routing, and tenantId keeps multi-tenant data separated from the very first hop.

Producers, Brokers, and Consumers

  • Producers emit events (your CRM, Stripe, an IoT gateway, n8n).
  • Brokers durably hold events until someone processes them (Redis with BullMQ, Amazon SQS, Amazon EventBridge, Kafka).
  • Consumers do the work (our LangGraph workers).

The golden rule: the component that receives an event should not be the component that does the expensive work. Receiving must be fast and boring. Processing can be slow and smart.

Stateful Agent Graphs with LangGraph

LangGraph models an agent workflow as a graph of nodes that read and update a shared, typed state. Edges, including conditional ones, decide what runs next. Two features make it a perfect fit for event-driven systems:

  • Checkpointing: Every step can be persisted, so a workflow interrupted by a container restart can resume instead of starting over.
  • Streaming: You can stream state updates node by node, which is exactly what we want to push to the frontend.

Sovereign AI with Ollama

"Sovereign AI" simply means you control where your models run and where your data goes. Ollama serves open-weight models through a simple HTTP API on hardware you own or rent. For regulated industries such as finance, healthcare, and government, keeping customer tickets and invoices inside your VPC is often not a nice-to-have. It is the requirement that gets the project approved.

The Reference Architecture

Here is the flow we are building, end to end:

  1. Source systems (Zendesk, Stripe, internal services) fire webhooks.
  2. n8n (self-hosted) or Make.com normalizes, filters, and enriches the payload, then forwards a clean event to our API.
  3. Express ingestion API verifies the HMAC signature, deduplicates by event ID, and enqueues a job in under 20 ms.
  4. BullMQ on Redis (Amazon ElastiCache) holds jobs durably with retries and backoff.
  5. LangGraph workers pick up jobs and run the multi-agent graph, calling Ollama on a private GPU host.
  6. Each graph step is broadcast through a Socket.io Redis emitter.
  7. Socket.io gateway pushes updates to the right room, and the Next.js dashboard renders them live.

Let's build each layer.

Step 1: Project Structure

A monorepo keeps shared types honest across the API, workers, and frontend.

event-agents/
├── apps/
│   ├── api/            # Express ingestion + Socket.io gateway
│   ├── worker/         # LangGraph agent workers
│   └── web/            # Next.js dashboard
├── packages/
│   └── shared/         # Event schemas (zod), types, constants
├── docker-compose.yml
└── package.json

Shared schemas are the contract between every service. Define them once with zod:

// packages/shared/src/events.ts
import { z } from "zod";

export const EventEnvelope = z.object({
  id: z.string().min(10),
  type: z.enum(["ticket.created", "invoice.overdue", "lead.scored"]),
  source: z.string(),
  occurredAt: z.string().datetime(),
  tenantId: z.string(),
  data: z.record(z.unknown()),
});

export type EventEnvelope = z.infer<typeof EventEnvelope>;

export const AGENT_QUEUE = "agent-events";

Step 2: A Fast, Paranoid Ingestion Layer with Express

The ingestion API has exactly three jobs: verify, deduplicate, enqueue. Nothing else. No LLM calls, no database joins, no "quick enrichment." If this endpoint is slow, upstream webhook providers will time out and retry, and you will drown in duplicate events.

// apps/api/src/server.ts
import express from "express";
import crypto from "node:crypto";
import { createServer } from "node:http";
import { Queue } from "bullmq";
import { Redis } from "ioredis";
import { EventEnvelope, AGENT_QUEUE } from "@event-agents/shared";
import { attachSocketGateway } from "./socket";

const redis = new Redis(process.env.REDIS_URL!, { maxRetriesPerRequest: null });
const queue = new Queue(AGENT_QUEUE, { connection: redis });
const app = express();

function verifySignature(rawBody: Buffer, signature: string | undefined) {
  if (!signature) return false;
  const expected = crypto
    .createHmac("sha256", process.env.WEBHOOK_SECRET!)
    .update(rawBody)
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post(
  "/webhooks/events",
  express.raw({ type: "application/json", limit: "256kb" }),
  async (req, res) => {
    if (!verifySignature(req.body, req.header("x-signature"))) {
      return res.status(401).json({ error: "invalid signature" });
    }

    const parsed = EventEnvelope.safeParse(JSON.parse(req.body.toString("utf8")));
    if (!parsed.success) {
      return res.status(422).json({ error: parsed.error.flatten() });
    }
    const event = parsed.data;

    // Idempotency: first writer wins, duplicates are acknowledged and ignored
    const isNew = await redis.set(`evt:${event.id}`, "1", "EX", 86_400, "NX");
    if (!isNew) return res.status(200).json({ status: "duplicate" });

    await queue.add(event.type, event, {
      jobId: event.id,
      attempts: 5,
      backoff: { type: "exponential", delay: 2_000 },
      removeOnComplete: 1_000,
      removeOnFail: 5_000,
    });

    // 202 Accepted: we have it, processing happens asynchronously
    return res.status(202).json({ status: "queued", threadId: event.id });
  }
);

app.get("/healthz", (_req, res) => res.send("ok"));

const httpServer = createServer(app);
attachSocketGateway(httpServer);
httpServer.listen(Number(process.env.PORT ?? 4000));

A few details here are worth calling out because they bite people constantly:

  • express.raw keeps the exact bytes the sender signed. If you parse JSON first and re-stringify it, whitespace and key order change and signature checks fail randomly.
  • timingSafeEqual prevents timing attacks on signature comparison.
  • SET ... NX plus BullMQ's jobId gives you two layers of deduplication. Webhook providers will deliver the same event more than once. Plan for it.
  • 202 Accepted is the honest status code. The work has not happened yet.

Step 3: Intelligent Routing with n8n and Make.com

Not every raw webhook deserves to wake up an AI agent. A Zendesk "ticket updated" event fires when someone changes a tag. You do not want a GPU spinning up for that.

This is where low-code orchestration tools shine. They sit between noisy source systems and your clean ingestion API.

When to Use n8n

Self-hosted n8n is my default for anything touching sensitive data. It runs in a container inside your VPC, so customer payloads never leave your network. A typical n8n workflow for our system looks like this:

  1. Webhook node receives the raw Zendesk payload.
  2. IF node drops events that are not new tickets or priority changes.
  3. HTTP Request node enriches with customer tier from your internal CRM API.
  4. Code node maps everything into our EventEnvelope shape and computes the HMAC signature.
  5. HTTP Request node posts to /webhooks/events.

The Code node for signing might look like this:

// n8n Code node (JavaScript), "Run Once for Each Item"
const crypto = require("crypto");

const envelope = {
  id: `evt_${$json.ticket.id}_${$json.ticket.updated_at}`,
  type: "ticket.created",
  source: "zendesk",
  occurredAt: new Date($json.ticket.created_at).toISOString(),
  tenantId: $json.account.subdomain,
  data: {
    ticketId: String($json.ticket.id),
    subject: $json.ticket.subject,
    priority: $json.ticket.priority,
    customerTier: $("Fetch CRM Tier").item.json.tier,
  },
};

const body = JSON.stringify(envelope);
const signature = crypto
  .createHmac("sha256", $env.WEBHOOK_SECRET)
  .update(body)
  .digest("hex");

return { json: { body, signature } };

Note: self-hosted n8n restricts built-in modules in Code nodes by default. Allow crypto with the NODE_FUNCTION_ALLOW_BUILTIN environment variable.

Notice the deterministic id. Because it is derived from the ticket ID and update timestamp, a retried n8n execution produces the same ID, and our ingestion layer deduplicates it automatically.

When to Use Make.com

Make.com is fantastic when the operations or marketing team owns the integration, and the data is low-risk: syncing a HubSpot lead score, a Typeform submission, or a Shopify order tag. Build a scenario with a webhook trigger, a filter, a JSON-building module, and an HTTP module pointing at your API.

A pragmatic split I have seen work well in large organizations:

Concernn8n (self-hosted)Make.com
Data sensitivityHigh (PII, financial, health)Low to moderate
Who owns itPlatform / engineering teamOps, marketing, RevOps
Network locationInside your VPCVendor cloud
Custom codeFull JavaScript and PythonLimited, module-based
Best forCore agent triggersPeripheral SaaS triggers

Both tools post to the same signed endpoint. Your backend does not care who sent the event, only that it is valid.

Step 4: Building the Multi-Agent Graph with LangGraph and Ollama

Now the fun part. Our example workflow handles inbound support tickets:

  • A Triage Agent classifies the ticket (billing, technical, churn risk).
  • Conditional routing sends it to a Billing Agent or a Technical Agent.
  • A Churn Guard step escalates to a human when risk is high.
  • A Responder Agent drafts a reply for human review.

Defining State

// apps/worker/src/graph/state.ts
import { Annotation } from "@langchain/langgraph";
import type { EventEnvelope } from "@event-agents/shared";

export const AgentState = Annotation.Root({
  event: Annotation<EventEnvelope>(),
  category: Annotation<"billing" | "technical" | "churn_risk" | undefined>(),
  confidence: Annotation<number>({ reducer: (_, b) => b, default: () => 0 }),
  findings: Annotation<string[]>({
    reducer: (a, b) => a.concat(b),
    default: () => [],
  }),
  draftReply: Annotation<string | undefined>(),
  needsHuman: Annotation<boolean>({ reducer: (_, b) => b, default: () => false }),
});

export type AgentStateType = typeof AgentState.State;

The findings reducer appends instead of overwriting, so every specialist agent can contribute notes without clobbering the others.

Connecting to Ollama

// apps/worker/src/llm.ts
import { ChatOllama } from "@langchain/ollama";

export const llm = new ChatOllama({
  baseUrl: process.env.OLLAMA_BASE_URL ?? "http://ollama:11434",
  model: process.env.OLLAMA_MODEL ?? "qwen3:8b",
  temperature: 0,
  keepAlive: "30m", // keep the model warm in GPU memory between events
});

That keepAlive setting matters more than people expect. Cold-loading a model into GPU memory can take several seconds. In an event-driven system with bursty traffic, keeping the model resident is the difference between "instant" and "why is it lagging?"

The Nodes

// apps/worker/src/graph/nodes.ts
import { z } from "zod";
import { llm } from "../llm";
import type { AgentStateType } from "./state";

const TriageSchema = z.object({
  category: z.enum(["billing", "technical", "churn_risk"]),
  confidence: z.number().min(0).max(1),
  reason: z.string(),
});

export async function triage(state: AgentStateType) {
  const classifier = llm.withStructuredOutput(TriageSchema);
  const result = await classifier.invoke([
    ["system", "You are a support triage agent. Classify the ticket. Be conservative with confidence."],
    ["human", JSON.stringify(state.event.data)],
  ]);
  return {
    category: result.category,
    confidence: result.confidence,
    findings: [`Triage: ${result.category} (${result.reason})`],
  };
}

export async function billingAgent(state: AgentStateType) {
  // In production, call a tool that queries your billing system here
  const res = await llm.invoke([
    ["system", "You are a billing specialist. Summarize the likely issue and next action in two sentences."],
    ["human", JSON.stringify(state.event.data)],
  ]);
  return { findings: [`Billing: ${res.content}`] };
}

export async function technicalAgent(state: AgentStateType) {
  const res = await llm.invoke([
    ["system", "You are a senior support engineer. Identify the probable root cause in two sentences."],
    ["human", JSON.stringify(state.event.data)],
  ]);
  return { findings: [`Technical: ${res.content}`] };
}

export async function churnGuard(state: AgentStateType) {
  const vip = state.event.data["customerTier"] === "enterprise";
  return {
    needsHuman: true,
    findings: [`Churn guard: escalated${vip ? " (enterprise account)" : ""}`],
  };
}

export async function responder(state: AgentStateType) {
  const res = await llm.invoke([
    ["system", "Draft a warm, concise customer reply using the findings. Never promise refunds."],
    ["human", state.findings.join("\n")],
  ]);
  return { draftReply: String(res.content) };
}

Wiring the Graph

// apps/worker/src/graph/index.ts
import { StateGraph, START, END } from "@langchain/langgraph";
import { AgentState, type AgentStateType } from "./state";
import { triage, billingAgent, technicalAgent, churnGuard, responder } from "./nodes";
import { checkpointer } from "./checkpointer";

function routeAfterTriage(state: AgentStateType) {
  if (state.confidence < 0.6) return "churnGuard"; // unsure? let a human look
  return state.category === "billing"
    ? "billingAgent"
    : state.category === "technical"
    ? "technicalAgent"
    : "churnGuard";
}

export const supportGraph = new StateGraph(AgentState)
  .addNode("triage", triage)
  .addNode("billingAgent", billingAgent)
  .addNode("technicalAgent", technicalAgent)
  .addNode("churnGuard", churnGuard)
  .addNode("responder", responder)
  .addEdge(START, "triage")
  .addConditionalEdges("triage", routeAfterTriage, [
    "billingAgent",
    "technicalAgent",
    "churnGuard",
  ])
  .addEdge("billingAgent", "responder")
  .addEdge("technicalAgent", "responder")
  .addEdge("churnGuard", "responder")
  .addEdge("responder", END)
  .compile({ checkpointer });

For the checkpointer, use a durable store in production. A Postgres checkpointer on Amazon RDS is a solid choice because you probably already run Postgres and it gives you an audit trail of every agent decision for free.

// apps/worker/src/graph/checkpointer.ts
import { PostgresSaver } from "@langchain/langgraph-checkpoint-postgres";

export const checkpointer = PostgresSaver.fromConnString(process.env.DATABASE_URL!);
// Call `await checkpointer.setup()` once at startup to create tables

LangGraph multi-agent workflow with conditional routing

Step 5: The Worker that Runs Graphs and Streams State

The worker consumes jobs from BullMQ, runs the graph with streaming enabled, and broadcasts each update. The key trick is the Socket.io Redis emitter: it publishes to Redis, and every Socket.io gateway instance subscribed through the Redis adapter forwards the message to connected browsers. Workers never hold a WebSocket connection.

// apps/worker/src/index.ts
import { Worker } from "bullmq";
import { Redis } from "ioredis";
import { Emitter } from "@socket.io/redis-emitter";
import { AGENT_QUEUE, type EventEnvelope } from "@event-agents/shared";
import { supportGraph } from "./graph";
import { checkpointer } from "./graph/checkpointer";

const connection = new Redis(process.env.REDIS_URL!, { maxRetriesPerRequest: null });
const emitter = new Emitter(new Redis(process.env.REDIS_URL!));

await checkpointer.setup();

const worker = new Worker<EventEnvelope>(
  AGENT_QUEUE,
  async (job) => {
    const event = job.data;
    const threadId = event.id;
    const room = `tenant:${event.tenantId}`;

    emitter.to(room).emit("agent:started", { threadId, type: event.type, at: Date.now() });

    const stream = await supportGraph.stream(
      { event },
      { configurable: { thread_id: threadId }, streamMode: "updates" }
    );

    for await (const update of stream) {
      const [node, patch] = Object.entries(update)[0];
      emitter.to(room).emit("agent:step", { threadId, node, patch, at: Date.now() });
    }

    const final = await supportGraph.getState({ configurable: { thread_id: threadId } });
    emitter.to(room).emit("agent:completed", { threadId, state: final.values, at: Date.now() });
  },
  {
    connection,
    concurrency: Number(process.env.WORKER_CONCURRENCY ?? 4),
  }
);

worker.on("failed", (job, err) => {
  if (!job) return;
  emitter.to(`tenant:${job.data.tenantId}`).emit("agent:failed", {
    threadId: job.data.id,
    error: err.message,
    attempt: job.attemptsMade,
  });
});

// Graceful shutdown so ECS task replacement does not kill in-flight graphs
process.on("SIGTERM", async () => {
  await worker.close();
  process.exit(0);
});

Because we use thread_id with a durable checkpointer, a retry after a crash resumes from the last completed node rather than re-running the whole graph. That saves GPU time and avoids double-drafted replies.

Step 6: The Socket.io Gateway

The gateway lives in the API service. It authenticates clients, puts them in tenant rooms, and relies on the Redis adapter to receive emitter messages.

// apps/api/src/socket.ts
import type { Server as HttpServer } from "node:http";
import { Server } from "socket.io";
import { createAdapter } from "@socket.io/redis-adapter";
import { Redis } from "ioredis";
import { verifyJwt } from "./auth";

export function attachSocketGateway(httpServer: HttpServer) {
  const pub = new Redis(process.env.REDIS_URL!);
  const sub = pub.duplicate();

  const io = new Server(httpServer, {
    cors: { origin: process.env.WEB_ORIGIN, credentials: true },
    adapter: createAdapter(pub, sub),
  });

  io.use(async (socket, next) => {
    try {
      const user = await verifyJwt(socket.handshake.auth.token);
      socket.data.tenantId = user.tenantId;
      next();
    } catch {
      next(new Error("unauthorized"));
    }
  });

  io.on("connection", (socket) => {
    // Clients never choose their own room: the server derives it from the JWT
    socket.join(`tenant:${socket.data.tenantId}`);
  });

  return io;
}

That comment is not decoration. Letting a client send join("tenant:other-company") is one of the most common multi-tenant data leaks I see in real-time dashboards.

Step 7: The Next.js Real-Time Dashboard

On the frontend, a small client hook subscribes to agent events and keeps a map of active runs.

// apps/web/app/agents/use-agent-stream.ts
"use client";

import { useEffect, useState } from "react";
import { io, type Socket } from "socket.io-client";

type Step = { node: string; at: number };
type Run = { threadId: string; status: "running" | "done" | "failed"; steps: Step[] };

export function useAgentStream(token: string) {
  const [runs, setRuns] = useState<Record<string, Run>>({});

  useEffect(() => {
    const socket: Socket = io(process.env.NEXT_PUBLIC_API_URL!, {
      auth: { token },
      transports: ["websocket"],
    });

    socket.on("agent:started", ({ threadId }) =>
      setRuns((r) => ({ ...r, [threadId]: { threadId, status: "running", steps: [] } }))
    );

    socket.on("agent:step", ({ threadId, node, at }) =>
      setRuns((r) => {
        const run = r[threadId] ?? { threadId, status: "running", steps: [] };
        return { ...r, [threadId]: { ...run, steps: [...run.steps, { node, at }] } };
      })
    );

    socket.on("agent:completed", ({ threadId }) =>
      setRuns((r) => ({ ...r, [threadId]: { ...r[threadId], status: "done" } }))
    );

    socket.on("agent:failed", ({ threadId }) =>
      setRuns((r) => ({ ...r, [threadId]: { ...r[threadId], status: "failed" } }))
    );

    return () => {
      socket.disconnect();
    };
  }, [token]);

  return Object.values(runs);
}
// apps/web/app/agents/live-runs.tsx
"use client";

import { useAgentStream } from "./use-agent-stream";

export function LiveRuns({ token }: { token: string }) {
  const runs = useAgentStream(token);

  return (
    <ul className="space-y-3">
      {runs.map((run) => (
        <li key={run.threadId} className="rounded-xl border p-4">
          <div className="flex justify-between text-sm">
            <span className="font-mono">{run.threadId}</span>
            <span>{run.status}</span>
          </div>
          <ol className="mt-2 flex gap-2 text-xs">
            {run.steps.map((s) => (
              <li key={s.at} className="rounded bg-neutral-100 px-2 py-1 dark:bg-neutral-800">
                {s.node}
              </li>
            ))}
          </ol>
        </li>
      ))}
    </ul>
  );
}

Keep the initial page load server-rendered (fetch recent runs from your API in a Server Component), then let the socket hook layer live updates on top. That way the dashboard is useful even before the WebSocket connects, and reconnecting clients do not see an empty screen.

Step 8: Containerizing with Docker

A single multi-stage Dockerfile pattern works for the API and worker.

# apps/worker/Dockerfile
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
COPY packages ./packages
COPY apps/worker ./apps/worker
RUN npm ci && npm run build -w apps/worker

FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/apps/worker/dist ./dist
USER node
CMD ["node", "dist/index.js"]

For local development, Docker Compose brings up the entire stack, Ollama included:

# docker-compose.yml
services:
  redis:
    image: redis:7-alpine
    ports: ["6379:6379"]

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: dev
      POSTGRES_DB: agents
    ports: ["5432:5432"]

  ollama:
    image: ollama/ollama:latest
    volumes: ["ollama:/root/.ollama"]
    ports: ["11434:11434"]
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: all
              capabilities: [gpu]

  n8n:
    image: n8nio/n8n:latest
    ports: ["5678:5678"]
    environment:
      WEBHOOK_SECRET: ${WEBHOOK_SECRET}
      NODE_FUNCTION_ALLOW_BUILTIN: crypto

  api:
    build: { context: ., dockerfile: apps/api/Dockerfile }
    environment:
      REDIS_URL: redis://redis:6379
      WEBHOOK_SECRET: ${WEBHOOK_SECRET}
      WEB_ORIGIN: http://localhost:3000
    ports: ["4000:4000"]
    depends_on: [redis]

  worker:
    build: { context: ., dockerfile: apps/worker/Dockerfile }
    environment:
      REDIS_URL: redis://redis:6379
      DATABASE_URL: postgres://postgres:dev@postgres:5432/agents
      OLLAMA_BASE_URL: http://ollama:11434
      OLLAMA_MODEL: qwen3:8b
    depends_on: [redis, postgres, ollama]

volumes:
  ollama:

Pull the model once with docker compose exec ollama ollama pull qwen3:8b, and you have a fully local, event-driven agent platform on your laptop.

Step 9: Deploying Securely on AWS

The production layout mirrors local development, with managed services replacing the stateful containers.

  • Amazon ECS on Fargate runs the api and worker services. Scale workers on queue depth, not CPU.
  • GPU-backed EC2 instances (for example, the G-family) run Ollama in a private subnet. Fargate does not offer GPUs, so this is the one place you manage instances, ideally through an ECS capacity provider or an Auto Scaling group.
  • Amazon ElastiCache for Redis backs BullMQ and the Socket.io adapter.
  • Amazon RDS for PostgreSQL stores LangGraph checkpoints.
  • Application Load Balancer fronts the API with sticky sessions enabled, which Socket.io needs if any client falls back to HTTP long-polling.
  • AWS Secrets Manager injects WEBHOOK_SECRET, database credentials, and JWT keys into tasks.
  • AWS WAF on the ALB rate-limits and filters the public webhook endpoint.
  • Amazon CloudWatch collects logs and custom metrics like queue depth and per-node latency.

Need events from AWS-native sources such as S3 uploads or DynamoDB Streams? Route them through Amazon EventBridge to an API destination (your signed webhook) or to SQS that a small bridge consumes. The pattern stays the same: events in, verify, enqueue.

AWS deployment topology with ECS, ElastiCache, RDS, and private GPU hosts

Autoscaling on the Right Signal

CPU is a terrible scaling signal for agent workers because they spend most of their time waiting on the LLM. Publish BullMQ's waiting-job count as a CloudWatch metric and scale on that:

// apps/worker/src/metrics.ts
import { CloudWatchClient, PutMetricDataCommand } from "@aws-sdk/client-cloudwatch";
import { Queue } from "bullmq";

const cw = new CloudWatchClient({});

export async function publishQueueDepth(queue: Queue) {
  const waiting = await queue.getWaitingCount();
  await cw.send(
    new PutMetricDataCommand({
      Namespace: "EventAgents",
      MetricData: [{ MetricName: "QueueDepth", Value: waiting, Unit: "Count" }],
    })
  );
}

Then attach a target-tracking policy to the worker service, aiming for something like a handful of waiting jobs per task. Remember the real bottleneck is GPU throughput, so scale Ollama capacity alongside workers or you will simply move the queue from Redis into Ollama.

Real-World Use Cases

This exact blueprint adapts to a surprising range of problems:

  • Customer support triage: New tickets are classified, enriched, and drafted within seconds, with high-risk accounts escalated to humans before anyone has to read the queue.
  • Financial operations: An invoice.overdue event triggers an agent that checks payment history, drafts a personalized reminder, and flags anomalies for the finance team, all without sending ledger data outside your VPC.
  • Security operations: Alerts from your SIEM become events. A triage agent correlates them, suppresses noise, and pushes only meaningful incidents to the on-call dashboard in real time.
  • Supply chain: IoT sensor thresholds publish events, and agents reroute shipments or reorder stock while operators watch decisions unfold live.
  • Sales and RevOps: Make.com forwards lead-score changes, and an agent writes context-aware follow-ups for reps the moment a prospect gets hot.

🚀 Pro Tips

  • Stream tokens, not just steps. Switch streamMode to include "messages" for nodes that write long text, and forward tokens over Socket.io. Watching a reply type itself out builds enormous user trust.
  • Make event IDs deterministic upstream. Derive IDs from source data (resource ID plus version or timestamp). Random UUIDs generated on retry defeat your deduplication.
  • Keep models warm. Set Ollama's keepAlive and send a lightweight warm-up request when a GPU node starts, so the first real event of the morning is not the slow one.
  • Use small models for routing, larger ones for writing. A compact model handles triage in a fraction of the time. Save bigger models for the responder node.
  • Version your graphs. Store a graphVersion in state. When you deploy a new graph, in-flight threads checkpointed under the old version should finish on compatible logic.
  • Add a human-in-the-loop interrupt. LangGraph supports interrupting before a node. Pause before anything customer-facing goes out, and resume when an agent in the dashboard clicks approve.
  • Trace everything. Attach the threadId to every log line across API, worker, and Ollama calls. When a customer asks "why did the bot say that?", you can replay the exact path.

Best Practices

  • Separate ingestion from processing. Webhook handlers should respond in milliseconds. All AI work belongs in workers.
  • Validate at every boundary. Use zod schemas at ingestion and structured output at every LLM call. Treat model output as untrusted input.
  • Design for at-least-once delivery. Assume every event arrives twice. Idempotent handlers and checkpointed graphs make duplicates harmless.
  • Isolate tenants from the first hop. Carry tenantId in every event, derive socket rooms server-side, and scope every database query.
  • Use dead-letter handling. After retries are exhausted, move failed jobs somewhere visible and alert on them. Silent failure is the worst failure mode in automation.
  • Keep the LLM in a private subnet. Ollama should never be reachable from the internet. Its API has no built-in authentication.
  • Apply backpressure. Limit worker concurrency to what your GPUs can actually serve. A queue that grows gracefully beats a GPU that crashes from memory pressure.

Common Mistakes to Avoid

  • Doing LLM work inside the webhook handler. The provider times out, retries, and you process the same ticket four times while paying for each.
  • Parsing JSON before verifying signatures. Re-serialized bodies rarely match the original bytes, so verification fails intermittently and teams "fix" it by disabling checks.
  • Letting clients pick their socket rooms. This is a direct path to leaking one customer's data to another.
  • Forgetting sticky sessions or the Redis adapter. Everything works with one container and mysteriously breaks at two, because events land on an instance the browser is not connected to.
  • Scaling workers on CPU. Agent workers idle while waiting for inference. You will under-scale during bursts and over-pay at night.
  • Using an in-memory checkpointer in production. It works perfectly until the first deploy restarts a container mid-workflow.
  • Exposing Ollama on a public IP. Open Ollama endpoints get discovered and abused quickly. Keep it private, always.
  • Routing every raw event to an agent. Filter in n8n or Make.com first. Not every tag change deserves a GPU.

📌 Key Takeaways

  • Events beat polling for AI agents on latency, cost, and rate-limit headroom. Let source systems push changes to you.
  • A thin, paranoid Express layer that verifies, deduplicates, and enqueues is the foundation of a reliable system.
  • n8n and Make.com filter and enrich noisy webhooks so agents only wake for meaningful work, with n8n keeping sensitive data inside your VPC.
  • LangGraph plus Ollama gives you explicit, checkpointed, private multi-agent workflows that survive restarts and retries.
  • Socket.io with a Redis adapter and emitter turns agent internals into a live, multi-tenant dashboard in Next.js.
  • Docker and AWS (ECS, ElastiCache, RDS, private GPU hosts) take the same architecture from your laptop to production without rewriting it.

Conclusion

The most impressive AI systems I have worked on were not the ones with the cleverest prompts. They were the ones where the plumbing was so dependable that nobody thought about it. An event fires, the right agent wakes up, its reasoning appears on a dashboard as it happens, and a human approves the result before the customer has even refreshed their inbox.

That experience comes from architecture, not from the model. By pairing an event-driven backbone with LangGraph's stateful orchestration and Ollama's local inference, you get agents that are fast, auditable, and fully under your control. Wrap it in Docker, deploy it on AWS with sensible boundaries, and you have a platform that can grow from one support workflow to dozens of business processes.

Start small. Pick one painful polling job in your stack, replace it with a signed webhook and a single LangGraph node, and wire up the live dashboard. Once your team sees an agent react in real time for the first time, you will not want to go back to asking "anything new?" every 30 seconds.

References

Frequently asked questions

Why use event-driven architecture instead of polling for AI agents?

Polling wastes compute, burns API rate limits, and adds latency equal to your polling interval. Event-driven systems push changes to your agents the instant they happen, so you only spend tokens and GPU time when there is real work to do.

Can LangGraph run with local LLMs through Ollama?

Yes. LangGraph is model-agnostic. Using the @langchain/ollama package, you can plug a locally hosted model into any graph node, including structured output and tool calling, without sending data to a third-party API.

Do I need both n8n and Make.com?

No. n8n is a strong fit when you want a self-hosted, sovereign routing layer inside your VPC. Make.com is useful for fast SaaS-to-SaaS integrations owned by non-engineering teams. Many enterprises use n8n for sensitive data and Make.com for low-risk business automations.

How do I scale Socket.io across multiple containers on AWS?

Use the Socket.io Redis adapter (backed by Amazon ElastiCache) so events reach clients connected to any instance, enable sticky sessions on the Application Load Balancer, and use the Redis emitter so background workers can broadcast without holding socket connections themselves.

Where should Ollama run on AWS?

Run Ollama on GPU-backed EC2 instances (or an ECS cluster using the EC2 launch type with GPU instances) inside a private subnet. Your API and workers reach it over the internal network, and no model traffic ever leaves your VPC.

Discussion

All Articles
Event-Driven ArchitectureLangGraphNode.jsAI AgentsOllamaAWSSocket.ioNext.jsn8nDocker

Written by

Niraj Kumar

Software Developer — building scalable systems for businesses.

Building this for real? LangChain Developer Services — Production LangChain & LangGraph systems for RAG, agents, and AI workflows.