DEV Community

flisk
flisk

Posted on Edited on

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 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
Enter fullscreen mode Exit fullscreen mode

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"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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',
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)