Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 3 min read

How I Built a Multi-Agent Procurement System with LangGraph, Mastra, and Flisk

Multi-agent orchestration often sounds easier than it is. When building a system where a Python legal bot needs to debate a TypeScript commercial bot, the infrastructure overhead alone can stall the project. You find you

Multi-agent orchestration often sounds easier than it is. When building a system where a Python legal bot needs to debate a TypeScript commercial bot, the infrastructure overhead alone can stall the project. You find yourself wrestling with multiple runtime environments, dockerizing standalone processes, and duplicating tools across separate codebases.

To solve this, I built an enterprise-grade multi-agent procurement system completely locally using Flisk (flisk.sh). Flisk acts as a local runtime gateway, bundling multi-language agents, persistent databases, and event streams into a hot-reloading local stack.

Here is the exact architecture and implementation for a stateful, cross-language procurement network.

The Architecture Setup

The system breaks down into four core decoupled layers:

  1. The BFF & User Interface (Next.js): The control center where contract drafts are uploaded and live AI negotiation loops are monitored on an interactive canvas.
  2. The Legal Agent (Python + LangGraph): A cyclical graph that extracts contract data and checks it against compliance matrices. It includes a Human-in-the-Loop checkpoint gate.
  3. The Commercial Agent (TypeScript + Mastra): A fast TypeScript node that reviews financial lines, balances department limits, and drafts counters.
  4. The Secure Gateway (MCP Server): An abstraction proxy providing bounded local database and mock ERP data access to both agents via the Model Context Protocol.

Setting Up the Stack with Flisk

Instead of crafting manual docker-compose structures, we initialize our resources right out of the box using the Flisk CLI:

# Initialize a local multi-agent stack
mkdir procurement-core && cd procurement-core

# Scaffold the independent runtime environments
flisk resource legal-agent --type agent --stack procurement --yes
flisk resource commercial-agent --type agent --stack procurement --yes
flisk resource secure-gateway-mcp --type mcp --stack procurement --yes

# Spin up the environment 
flisk up procurement

Flisk leverages a local .flisk/procurement/service.json configuration file to unify network paths, set up environment registries, and enable hot-reloading:

{
  "version": "1.0",
  "stack": "procurement",
  "services": {
    "secure-gateway": {
      "image": "node:20-alpine",
      "command": "node dist/index.js",
      "ports": ["3001:3001"]
    },
    "agent-legal": {
      "image": "python:3.11-slim",
      "command": "python agent.py",
      "env": {
        "MCP_GATEWAY_URL": "http://secure-gateway:3001/sse",
        "REDIS_URL": "redis://event-broker:6379"
      }
    },
    "agent-commercial": {
      "image": "node:20-alpine",
      "command": "npx mastra start",
      "env": {
        "MCP_GATEWAY_URL": "http://secure-gateway:3001/sse",
        "REDIS_URL": "redis://event-broker:6379"
      }
    },
    "event-broker": {
      "image": "redis:7-alpine",
      "ports": ["6379:6379"]
    }
  }
}

Streaming Live Negotiations to Next.js

To track AI-to-AI arguments in real time, standard polling doesn't cut it. Both agent nodes stream state updates to our local Redis event broker, which the Next.js BFF hooks into using Server-Sent Events (SSE):

// app/api/negotiation-stream/route.ts
import { NextResponse } from 'next/server';
import { createClient } from 'redis';

export async function GET(request: Request) {
  const redisClient = createClient({ url: process.env.REDIS_URL });
  await redisClient.connect();

  const responseStream = new TransformStream();
  const writer = responseStream.writable.getWriter();
  const encoder = new TextEncoder();

  // Listen directly to the multi-agent network channel
  await redisClient.subscribe('procurement-negotiation-channel', (message) => {
    const eventPayload = `data: ${message}\n\n`;
    writer.write(encoder.encode(eventPayload));
  });

  request.signal.addEventListener('abort', async () => {
    await redisClient.disconnect();
    writer.close();
  });

  return new NextResponse(responseStream.readable, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache, no-transform',
      'Connection': 'keep-alive',
    },
  });
}

Building the Control Canvas

On the frontend, we map incoming data payloads directly to an infinite UI canvas built with @xyflow/react. When an agent is actively working or requests a Human-in-the-Loop check, the node pulses dynamically on the dashboard:

// components/NegotiationCanvas.tsx
import React, { useEffect, useState } from 'react';
import { ReactFlow, Background, Controls, Node, Edge } from '@xyflow/react';
import '@xyflow/react/dist/style.css';

const initialNodes: Node[] = [
  { id: 'user-ui', position: { x: 250, y: 0 }, data: { label: 'πŸ“₯ Ingestion Hub' }, style: { padding: 10, borderRadius: 8 } },
  { id: 'agent-legal', position: { x: 50, y: 150 }, data: { label: 'βš–οΈ Legal Node (Python)' }, style: { padding: 10, borderRadius: 8 } },
  { id: 'agent-commercial', position: { x: 450, y: 150 }, data: { label: 'πŸ’° Commercial Node (TS)' }, style: { padding: 10, borderRadius: 8 } },
];

export default function NegotiationCanvas() {
  const [nodes, setNodes] = useState<Node[]>(initialNodes);

  useEffect(() => {
    const eventSource = new EventSource('/api/negotiation-stream');

    eventSource.onmessage = (event) => {
      const data = JSON.parse(event.data);
      setNodes((nds) =>
        nds.map((node) => {
          if (node.id === data.activeAgentId) {
            return {
              ...node,
              style: { 
                ...node.style, 
                backgroundColor: data.status === 'INTERRUPT' ? '#fee2e2' : '#eff6ff',
                borderColor: data.status === 'INTERRUPT' ? '#ef4444' : '#3b82f6',
                boxShadow: '0 0 15px rgba(59, 130, 246, 0.5)'
              },
            };
          }
          return node;
        })
      );
    };

    return () => eventSource.close();
  }, []);

  return (
    <div style={{ width: '100%', height: '500px' }}>
      <ReactFlow nodes={nodes} fitView>
        <Background />
        <Controls />
      </ReactFlow>
    </div>
  );
}

By allowing Flisk to govern the connectivity and lifecycle of our cross-language stacks, we turn what used to be a sprawling infrastructure problem into a smooth, hot-reloading local dev experience.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.