Your MFEs Don't Need a Backend to Talk to Each Other — or to Run AI Agents

javascript dev.to

Introducing Nirnam — a zero-dependency message bus and browser-native agent framework for micro-frontends. No backend required — not for MFE communication, and not for running AI agents.

"The human mind operates by association. With one item in its grasp, it snaps instantly to the next that is suggested by the association of thoughts, in accordance with some intricate web of trails carried by the cells of the brain."
Vannevar Bush, As We May Think (1945)

Bush wrote that sentence envisioning a machine that could store knowledge and traverse it the way a human mind does. He called it the Memex. What he could not have imagined is that eighty years later, a browser tab would have everything needed to build exactly that — a connected web of components that communicate, remember, and reason.

Nirnam started with a solution, got named after my parents' initials, and turned into a framework for browser-native communication layer for MFEs, Browser Workers and AI Agents within the same origin.


Section 1: The Problem

Micro-frontend Communication Conundrum

Let's say your e-commerce website grew to infinity (and beyond). With a growing technical team, the development cycle got messier with each feature delivery. So, you decided to manage your development in compartments. On the frontend, you've split your monolith into micro-frontends. The cart MFE needs to know when the product MFE fires an "add to cart" event. The recommendation MFE needs to ask the user profile MFE for preferences. So you do what everyone does: you wire up a window.__sharedBus global, or you reach for postMessage and start writing serialisation boilerplate. Six months later, you have a different mess than the one you started with.

World of Workercraft

You're building a browser game. Physics simulation runs in a dedicated worker — it has to, otherwise collision detection bogs down the main thread and frame rate tanks. Enemy pathfinding runs in a second worker for the same reason. A third handles the WebSocket connection to the game server.

Now the AI worker needs to react to collisions detected by the physics worker. The network worker needs to broadcast incoming world-state updates to both. The main thread needs position data from physics to drive the render loop.

So you start wiring postMessage. Each pair of workers that need to talk gets its own channel. The main thread becomes a relay — it forwards physics events to the AI worker because dedicated workers cannot message each other directly. You write serialisation boilerplate. You track which worker has initialised before you send. You add a fourth worker — audio — and rewire everything again. Six months later you have more message-plumbing code than game code, and every new worker multiplies the surface area.

Multi-agents of Madness

AI agents are everywhere, but every framework assumes you have a Node.js server running the loop. What if your app is a local-first SPA? A browser extension? A micro-frontend shell where the user's data never leaves their machine? To top it all, what if you want these local client controlled agents to collaborate together in the client machine?


Section 2: The Orchestra — 3 Layered Bus

Tab A                          Tab B                         Tab C
[MFE Shell]  [Product MFE]    [Cart MFE]                [Agent Host]
     |              |               |                         |
     +----------SharedWorker (static URL)--------------------+  ← Layer 3: cross-tab routing
                    |
             SharedWorker (blob URL)                           ← Layer 2: same-tab registry
                    |
           BroadcastChannel                                    ← Layer 1: cross-tab fan-out
Enter fullscreen mode Exit fullscreen mode

The NirnamBus acts as the communication bus between micro-frontends, multiple agents, and browser workers. Three layers were built to tackle particular challenges of browser native API and origin restrictions.

  • Layer 1 — BroadcastChannel is a browser native API. It fans out messages and works great across tabs, right out of the box! But, it has a limitation. It is only for broadcast, it does not handle request-reply, nor routing.
  • Layer 2 — SharedWorker (blob URL): Here comes the next layer to handle request-reply and routing. The SharedWorker's source is bundled into the library and created at runtime with URL.createObjectURL(new Blob([…])). You get SharedWorker semantics with zero deployment — no static file, no CDN, no build config change. Allowing teams to lay communication lines between without controlling the inner asset pipeline. But with a dynamic blob URL changing at every app mount/unmount, it doesn't allow for cross-tab communication without a static URL.
  • Layer 3 — SharedWorker (static URL): A build plugin is needed for a static worker URL to enable true cross-tab communication within the same origin. An optional build plugin can be configured with one line. Plugins available for Vite, Webpack and RsBuild.

Section 3: Transport Layer

Pub/Sub across two MFEs

Git: https://github.com/shaurcasm/nirnam/tree/main/Examples/react-mfe

// Bus
// host/src/App.tsx line 9
// Module-level bus — one per MFE, shared across all components in this app.
const bus = createBus();

// Publisher
// host/src/App.tsx lines 20–29
useEffect(() => {
  bus.publish('counter', counter);
}, [counter]); // fires every second as the counter increments

// Subscriber + reverse publish
// remote/src/components/Button.tsx lines 10–22
useEffect(() => {
  return bus.subscribe<number>('counter', (count) => {
    setHostCounter(count);
  }); // to receive the host counter live
}, []);

const handleClick = () => {
  const event: ButtonEvent = { message: `Clicked at ${new Date().toLocaleTimeString()}` };
  bus.publish('remote-click', { response: event.message });
}; // on button click to send back to host

// Host subscribing to remote
// host/src/App.tsx lines 24–29
useEffect(() => {
  const unsub = bus.subscribe<ButtonEventResponse>('remote-click', (event) => {
    setLastRemoteEvent(event);
  });
  return unsub;
}, []);
Enter fullscreen mode Exit fullscreen mode

Two completely independent React apps — a host and a remote — share nothing except the bus and a topic name string. The only contract is the topic string and the payload type, decoupled MFEs.

Request-Reply

Git: https://github.com/shaurcasm/nirnam/tree/main/Examples/transport

// Requester
// src/components/HostPanel.tsx lines 27–39
const getTotal = useCallback(async () => {
  setBusy("total");
  setResult(null);
  try {
    const total = await bus.request<void, number>("cart:getTotal", undefined, 3000);
    setResult(`Cart total: $${total}`);
  } catch (e) {
    setResult(`Error: ${(e as Error).message}`);
  }
  setBusy("");
}, [log]);

// Handler
// src/components/CartPanel.tsx lines 31–35
const unsubTotal = bus.handle<void, number>("cart:getTotal", () => {
  const t = totalRef.current;
  return t;
}); // synchronous handler returning a value; Nirnam wraps the Promise automatically

// With payload
// HostPanel.tsx lines 41–55
const checkout = useCallback(async () => {
  setBusy("checkout");
  setResult(null);
  try {
    const r = await bus.request<{ note: string }, { orderId: string; total: number }>(
      "cart:checkout", { note: "rush" }, 3000,
    );
    setResult(`Order placed: ${r.orderId} ($${r.total})`);
  } catch (e) {
    setResult(`Error: ${(e as Error).message}`);
  }
  setBusy("");
}, [log]);

// CartPanel.tsx lines 37–49
const unsubCheckout = bus.handle<{ note?: string }, { orderId: string; total: number }>(
  "cart:checkout",
  (payload) => {
    const orderId = `ORD-${Date.now().toString(36).toUpperCase()}`;
    const t = totalRef.current;
    bus.publish("order:placed", { orderId, items: itemsRef.current, total: t, note: payload?.note });
    setItems(INITIAL_ITEMS);
    setOrderCount(c => c + 1);
    return { orderId, total: t };
  },
);
Enter fullscreen mode Exit fullscreen mode

The generic types for the payload are enforced end-to-end. The handler never sees the request ID or the correlation machinery — it just returns a value. Simple communication. सरल सञ्चारः

Streaming

Git: https://github.com/shaurcasm/nirnam/tree/main/Examples/transport

// Stream requester
// src/components/HostPanel.tsx lines 62–75
const streamPrices = useCallback(async () => {
  setPrices([]);
  setBusy("stream");
  try {
    for await (const chunk of bus.requestStream<void, string>("cart:priceStream", undefined)) {
      setPrices(p => [...p, chunk]);
    }
  } catch (e) {
    // handle error
  }
  setBusy("");
}, [log]);

// Stream handler
// src/components/CartPanel.tsx lines 52–59
const unsubStream = bus.handleStream<void, string>("cart:priceStream", async function* () {
  for (const item of itemsRef.current) {
    const chunk = `${item.name}: $${item.price}`;
    yield chunk;
    await new Promise(r => setTimeout(r, 400));
  }
});
Enter fullscreen mode Exit fullscreen mode

We use the same stream pattern of AsyncIterable used here for cart items for LLM token streams. It is a vital piece of the puzzle for the Agent framework.


Section 4: Agent Framework

Nirnam also has a browser-native agentic framework with underlying communication layer as NirnamBus. This allows building and managing agents on the client side itself, letting clients configure their own LLMs and keep data on their own end. This opens a whole host of consumer friendly applications where the consumer has control over their own data.

chat() is a conversation window, run() is a background job, process() is a webhook handler.

Basic Agent (chat)

Git: https://github.com/shaurcasm/nirnam/tree/main/Examples/agents

// Agent + hooks
// src/components/ChatTab.tsx lines 52–62
const agent = useAgent({ llm, tools: TOOLS, autoCleanup: false }); // Creates the agent
const { messages, send, isStreaming, error, clearMessages } = useAgentChat(agent); // the entire chat loop in one hook
const status = useAgentStatus(agent); // gives live initialising -> ready -> busy lifecycle

const submit = (e: React.FormEvent) => {
  e.preventDefault();
  const text = input.trim();
  if (!text || isStreaming || !agent) return;
  send(text);
  setInput("");
};
Enter fullscreen mode Exit fullscreen mode

The entire agentic loop — LLM call → tool call decision → tool execution → follow-up LLM call — happens inside send(). The component only sees messages.

Agent with Filesystem Access

Git: https://github.com/shaurcasm/nirnam/tree/main/Examples/agents

// Agent config
const agent = useAgent(withPreset(presets.codeReview(), { llm, autoCleanup: false }));
/**
  presets.codeReview() gives read-only mode and a code-focused system prompt in one call;
  withPreset merges the preset with the required llm field
*/

// Grant + Revoke
const grantAccess = async () => {
  if (!agent) return;
  setGrantError(null);
  try {
    const handle = await agent.requestFolderAccess({ mode: "read" });
    setFolderName(handle.name);
  } catch (e) {
    // AbortError = user cancelled the picker — not an error worth showing.
    if ((e as Error).name !== "AbortError") {
      setGrantError("Could not access folder. Please try again.");
    }
  }
};

const revokeAccess = () => {
  agent?.revokeFolder();
  setFolderName(null);
  clearMessages();
};
Enter fullscreen mode Exit fullscreen mode

Tools available after grant — read_file, list_directory, create_directory, write_file, delete_file, move_file — all scoped to the granted folder root; path traversal (..) is blocked.

Tool Interceptors

const agent = createAgent({
  llm,
  tools: TOOLS,
  onBeforeToolCall: async ({ name, input }) => {
    if (name === 'delete_file') return { deny: true, reason: 'File deletion is disabled' };
    console.log('[audit]', name, input);
  },
  onBeforeLLMCall: async ({ messages }) => {
    return { messages: [{ role: 'system', content: 'Respond in under 100 words.' }, ...messages] };
  },
});
Enter fullscreen mode Exit fullscreen mode

Middleware layer is the compliance hook for enterprise teams — cost limits, audit logs, PII stripping before the LLM call.

asTool() — Agent-as-Tool

const summariser = createAgent({ agentId: 'summariser', llm, systemPrompt: 'Summarise text concisely.' });

const coordinator = createAgent({
  llm,
  tools: [
    summariser.asTool({ name: 'summarise', description: 'Summarise a block of text' }),
  ],
});

await coordinator.chat('Summarise this paragraph: ...');
// coordinator calls the summarise tool → summariser agent runs → result flows back
Enter fullscreen mode Exit fullscreen mode

Unit Testing & Persistence

For testing, Mock LLM is provided with (_isMock: true) for unit testing without API keys. History management is done through exportHistory() / importHistory() for persistence and session restore. autoCleanup — the agent destroys itself on beforeunload so there are no memory leaks in an MFE that gets unmounted.


Section 5: Cross-tab Agents

Git: https://github.com/shaurcasm/nirnam/tree/main/Examples/cross-tab-agent

What if you wanted one AI agent to serve an entire browser session — across every tab the user has open? With scope: 'page', Nirnam can do that.

Cross-tab Agent — Host Side

// src/App.tsx lines 269–291
const agent = createAgent({
  agentId: AGENT_ID,
  scope: 'page',
  llm,
  bus,
  autoCleanup: true,
}); // the scope:'page' flag registers the cross-tab bus handlers
agentRef.current = agent;

agent.onStatusChange(s => {
  setStatus(s);
  if (s === 'ready') setBusy(false);
});

// onMessage fires for EVERY user/assistant message — including those that
// arrive via AgentProxy calls from other tabs. This gives the host a live
// "server log" of all conversations going through the agent.
agent.onMessage(msg => {
  setMessages(prev => {
    if (prev.some(m => m.id === msg.id)) return prev; // StrictMode guard
    return [...prev, { id: msg.id, role: msg.role, text: msg.content }];
  });
});

agent.ready.then(() => {
  const hist = agent.history;
  if (hist.length > 0) {
    setRestoredCount(hist.length);
    setMessages(hist.map(m => ({
      id: m.id,
      role: m.role,
      text: m.content,
      restored: true,
    })));
  }
});
Enter fullscreen mode Exit fullscreen mode

Cross-tab Agent — Client Side

// src/App.tsx lines 375–423
// Verify the host agent is actually reachable on mount
useEffect(() => {
  bus.discoverAgents().then(agents => {
    const found = agents.some(a => a.agentId === AGENT_ID && a.metadata?.scope === 'page');
    setConnected(found);
    if (!found) onNoHost();
  });
}, [onNoHost]);

const proxy = useMemo(() => createAgentProxy(AGENT_ID, bus, { timeout: 60_000 }), []);
// synchronous, no connection until first call

const sendMessage = useCallback(async (e: FormEvent) => {
  e.preventDefault();
  const text = input.trim();
  if (!text || streaming) return;
  setInput('');
  setStreaming(true);

  const userMsgId = `${Date.now()}-user`;
  setMessages(prev => [...prev, { id: userMsgId, role: 'user', text, fromTab: TAB_ID }]);

  const streamId = `${Date.now()}-stream`;
  setMessages(prev => [...prev, { id: streamId, role: 'assistant', text: '', fromTab: 'host' }]);

  try {
    let first = true;
    // streaming response from the host tab, token by token
    for await (const chunk of proxy.chatStream(text)) {
      setMessages(prev => prev.map(m =>
        m.id === streamId ? { ...m, text: (first ? '' : m.text) + chunk } : m,
      ));
      first = false;
    }
  } catch (err) {
    const msg = (err as Error).message ?? String(err);
    setMessages(prev => prev.map(m =>
      m.id === streamId
        ? { ...m, text: `Error: ${msg}`, role: 'system' }
        : m,
    ));
    if (msg.includes('NO_HANDLER') || msg.includes('TIMEOUT')) onNoHost();
  } finally {
    setStreaming(false);
  }
}, [input, streaming, onNoHost]);
Enter fullscreen mode Exit fullscreen mode

The LLM runs in the host tab's main thread — no Worker API restrictions (fetch works, DOM works). Cross-tab communication requires Layer 3 (build plugin). If persistence is enabled, history is persisted to IndexedDB automatically — reload the host tab and the conversation continues. Concurrent proxy requests from multiple tabs are serialised internally — the agent's history is never corrupted. createAgentProxy() is synchronous — no connection is established until the first call.


Section 6: Multi-Agent Topologies

Pipeline Topology

Git: https://github.com/shaurcasm/nirnam/tree/main/Examples/agents

// src/components/PipelineTab.tsx lines 40–66
useEffect(() => {
  const sharedBus = createBus();
  busRef.current = sharedBus;

  const summarizer = createAgent({
    llm,
    bus: sharedBus,
    systemPrompt: SUMMARIZER_PROMPT,
    autoCleanup: false,
  });
  const planner = createAgent({
    llm,
    bus: sharedBus,
    systemPrompt: PLANNER_PROMPT,
    autoCleanup: false,
  });
  summarizerRef.current = summarizer;
  plannerRef.current = planner;

  // Wire: summarizer → planner via the Nirnam bus.
  teardownRef.current = connectAgents([summarizer, planner], {
    topology: "pipeline",
    topic: PIPELINE_TOPIC,
  }); // one call wires them: summariser's output is automatically published to the topic, planner subscribes and processes it

  return () => {
    teardownRef.current?.();
    summarizer.destroy();
    planner.destroy();
    sharedBus.close();
  };
}, [llm]);
Enter fullscreen mode Exit fullscreen mode

Fan-out Topology

const sentiment  = createAgent({ agentId: 'sentiment',  llm, systemPrompt: 'Classify sentiment: positive/negative/neutral.' });
const keywords   = createAgent({ agentId: 'keywords',   llm, systemPrompt: 'Extract 5 keywords as a comma-separated list.' });
const translator = createAgent({ agentId: 'translator', llm, systemPrompt: 'Translate the input to French.' });

connectAgents([sentiment, keywords, translator], { topology: 'fan-out', topic: 'analysis:input' });

// Trigger all three in parallel:
bus.publish('analysis:input', { text: 'Nirnam makes browser messaging effortless.' });
Enter fullscreen mode Exit fullscreen mode

All three agents receive the same input simultaneously. There is no coordinator loop. The bus is the orchestration layer.

MCP Transport

Git: https://github.com/shaurcasm/nirnam/tree/main/Examples/mcp-agent

// MCP Server Side
const bus = createBus();

async function startCalcServer() {
  const transport = new NirnamMCPTransport({ agentId: "calc-agent", bus });

  const server = new McpServer({ name: "calc-agent", version: "1.0.0" });

  server.tool("add", "Add two numbers", { a: z.number(), b: z.number() },
    async ({ a, b }) => ({ content: [{ type: "text", text: String(a + b) }] })
  );

  server.tool("multiply", "Multiply two numbers", { a: z.number(), b: z.number() },
    async ({ a, b }) => ({ content: [{ type: "text", text: String(a * b) }] })
  );

  await bus.register({ agentId: "calc-agent", capabilities: ["add", "multiply"], metadata: { type: "mcp-server" } });
  await server.connect(transport); // the bus becomes the transport layer
}
Enter fullscreen mode Exit fullscreen mode

The MCP server code is untouched — only the transport changes. If a team already has MCP tool servers, they get Nirnam's bus routing for free by swapping in NirnamMCPTransport.


Section 7: When to Use Nirnam (and When Not To)

Use Nirnam when:

  • You have 2+ MFEs on the same origin that need to communicate at runtime
  • You want typed request-reply or streaming between browser contexts (tabs, iframes, workers)
  • You're building AI features into a browser app and can't or don't want a backend agent loop
  • You're building a local-first app, browser extension, or Electron app where server round-trips are undesirable
  • You want multi-agent coordination that survives across MFE mount/unmount cycles
  • You want a single AI agent to serve an entire browser session across multiple tabs

    Don't use Nirnam when:

  • Your MFEs are on different origins (cross-origin: SharedWorker and BroadcastChannel both require same-origin)

  • You need server-side agent orchestration (RAG pipelines, vector store retrieval, long-running background jobs) — reach for Vercel AI SDK, Mastra, or LangChain instead

  • You need rich React streaming UI hooks — Vercel AI SDK's useChat is better for that

- Your team doesn't control the browser environment (SSR-only Next.js, etc.)

Section 8: Getting Started

npm install @palinc/nirnam
Enter fullscreen mode Exit fullscreen mode

Subpaths:

  • @palinc/nirnam — core bus
  • @palinc/nirnam/vite — Vite plugin (Layer 3 setup)
  • @palinc/nirnam/rsbuild — Rsbuild plugin (Layer 3 setup)
  • @palinc/nirnam/webpack — Webpack plugin (Layer 3 setup)
  • @palinc/nirnam/react — React hooks
  • @palinc/nirnam/angular — Angular service
  • @palinc/nirnam/agents — agent framework
  • @palinc/nirnam/agents/react — React hooks for agents
  • @palinc/nirnam/agents/testing — mock LLM utilities

- @palinc/nirnam/mcp — MCP transport

Closure

"There is a new profession of trail blazers, those who find delight in the task of establishing useful trails through the enormous mass of the common record."
Vannevar Bush, As We May Think (1945)

Bush could not have shipped npm install. But eighty years later, the trails he imagined are a createBus() call away. The browser has everything — SharedWorker, BroadcastChannel, the Fetch API, IndexedDB, the File System Access API. Someone just had to wire it up.

Please share your thoughts in the comments!

Source: dev.to

arrow_back Back to Tutorials