DEV Community

Babulu Shaik
Babulu Shaik

Posted on

I Built a 7-Agent Sales Pipeline and the Hardest Part Was the Topology

When I started designing VORTEX — a seven-agent pipeline that turns product usage signals into sales outreach — I made the same mistake most people make with multi-agent systems: I let agents talk to each other. Each one passed its output downstream, each one knew about its neighbor, and the routing logic quietly spread itself across the entire graph. Debugging became archaeology. The fix was boring and obvious in retrospect: one agent handles all routing, and nothing else is allowed to call anything else.


The VORTEX pipeline dashboard showing three Kanban columns — <br>
WATCHING, WARM_LEAD, and HOT_LEAD — with lead cards displaying <br>
intent scores, company names, and signal tags. The live activity <br>
feed on the right shows real-time agent events as they fire.


What the System Does

VORTEX is a seven-agent autonomous sales intelligence platform. It consumes raw behavioral events from a B2B SaaS product and produces scored leads, drafted outreach emails, autonomous voice calls via VAPI, and Slack alerts — all without a human in the loop until approval time.

The seven agents and their jobs:

  • Agent 1 — Behavioral Scout: Receives the raw webhook event, normalizes it into a typed activity atom, writes to Firestore.
  • Agent 2 — Intent Architect: Takes an activity atom, calls Groq (llama3-70b-8192), returns a score from 0–100 plus a tier classification (POWER_USER, AT_RISK, PASSIVE).
  • Agent 3 — Persona Scriptwriter: Takes the scored lead, calls Groq again, returns a drafted email with a subject line, body, and a separate sales contact note.
  • Agent 4 — VAPI Voice Caller: Runs on a schedule. Fetches HOT leads with call consent from Firestore, fires VAPI calls, writes outcomes back.
  • Agent 5 — Data Custodian: Runs daily. Fetches leads older than 30 days and deletes them.
  • Agent 6 — Social Intel Scraper: Runs every 6 hours. Hits Reddit and YouTube via Jina AI, runs the results through Groq for sentiment extraction, writes to a product_intelligence Firestore collection.
  • Agent 7 — Executive Router: The only agent that talks to other agents.

Workflow orchestration runs on Cascadeflow. The agent audit trail — what every agent saw, decided, and output for a given lead — is handled by Hindsight. The React dashboard reads from Firestore in real time via onSnapshot listeners. There's no polling, no message queue, no additional read layer.

The Routing Architecture

Here's how the main pipeline flows:

Your SaaS  →  POST /webhook/vortex-event
                    ↓
           Agent 1 — Behavioral Scout
           Normalizes payload → activity atom → Firestore
                    ↓
           Agent 7 — Executive Router
           Reads atom, applies thresholds, routes
                ↙         ↘
       Agent 2            Agent 3
  Intent Architect    Persona Scriptwriter
  Groq: score+tier    Groq: email draft
                ↘         ↙
               Firestore leads
                    ↓
            React Dashboard
            onSnapshot listeners
Enter fullscreen mode Exit fullscreen mode

Agents 4, 5, and 6 run on independent schedule triggers and don't connect to this webhook chain.


Architecture diagram showing the star topology of the VORTEX <br>
agent pipeline. Your SaaS sends a webhook to Agent 1, which <br>
normalizes the payload and passes it to Agent 7. Agent 7 branches <br>
to Agent 2 and Agent 3, both of which write back to Firestore. <br>
The React dashboard reads from Firestore via onSnapshot. Agents <br>
4, 5, and 6 appear separately on the right with clock icons, <br>
each connecting only to Firestore on independent schedules.


The reason Agent 7 is the only router — and this took me longer to land on than it should have — is that conditional logic in pipelines has a way of migrating toward whoever calls next. In my first sketch, I had a linear chain: Agent 1 called Agent 2, Agent 2 called Agent 3, Agent 3 called Agent 7. Clean. But then I needed to handle cases: skip the email draft if the score is below 40; fire the Slack alert immediately if the score is above 90; queue a VAPI call if call consent is set and urgency is HIGH.

In a linear chain, those conditions have to live in the agent that precedes the branch point. Agent 3 would have needed to decide whether to call Agent 7 based on the score — but Agent 3's job is email generation. It shouldn't know anything about Slack alerts. You end up with routing logic scattered across multiple agents, each making partial decisions that are hard to trace independently.

The fix is a star topology. Agent 7 is the only node with outbound edges to other agents in the Cascadeflow graph. Every other agent is a leaf: it accepts input, does one thing, and returns output. All conditional logic lives in Agent 7's Decision Logic node:

// Agent 7 — Executive Router: Decision Logic (n8n Code node)
const { intent_score, urgency, call_consent } = leadData;

const tier = intent_score >= 80 ? 'HOT_LEAD'
           : intent_score >= 50 ? 'WARM_LEAD'
           : 'WATCHING';

if (tier === 'HOT_LEAD') {
  await callAgent(2, leadData);      // Full intent classification
  await callAgent(3, leadData);      // Draft personalized email
  await fireSlackAlert(leadData);    // Immediate #sales notification

  if (call_consent && urgency === 'HIGH') {
    await enqueueVAPICall(leadData); // Agent 4 picks this up on schedule
  }
}

if (tier === 'WARM_LEAD') {
  await callAgent(3, leadData);      // Email draft only — no Slack blast
}

await updateFirestore('leads', leadId, { status: tier, intent_score });
Enter fullscreen mode Exit fullscreen mode

When a lead doesn't get a Slack alert, I look at one file. When I need to add a new routing condition — pause outreach during a trial's first 48 hours, for example — I add it to one place. The Cascadeflow graph definition enforces this: leaf agents can't have outbound edges to sibling agents in the same workflow. It's a constraint I'd otherwise have to enforce manually and would inevitably forget.

Normalizing Before You Think

Agent 1 does nothing except normalization. This sounds obvious, but it's easy to skip when you're prototyping quickly and every event source sends slightly different field names.

// Agent 1 — Normalize Activity Atom (n8n Code node)
const atom = {
  user_id:            payload.user_id,
  event_type:         payload.event || payload.event_type,
  feature:            payload.feature || payload.feature_name,
  session_mins:       payload.session_duration_mins ?? payload.session_mins ?? 0,
  teammates_invited:  payload.teammates_invited ?? 0,
  api_calls_today:    payload.api_calls_today ?? 0,
  call_consent:       payload.call_consent === true,
  email_consent:      payload.email_consent !== false,
  plan:               payload.plan ?? 'unknown',
  timestamp:          new Date().toISOString(),
};
Enter fullscreen mode Exit fullscreen mode

After this runs, every downstream agent operates on an atom with a fixed shape. Agent 2 never needs to defensively null-check session_duration_mins versus session_mins. Agent 7 never gets a surprise undefined in its threshold comparison. The normalization boundary means inconsistent upstream sources are someone else's problem.

Half the reliability issues I've seen in event-driven pipelines come from agents that have accumulated defensive checks because they've been burned by inconsistent upstream payloads. The correct fix isn't more defensive checks — it's normalizing once, at the entry point, and letting everything else assume clean data.

Making Agent Decisions Observable

The hardest part of running a multi-agent system isn't getting it to work. It's figuring out why it didn't work for a specific lead three hours ago. Firestore shows you the current state. It doesn't show you the reasoning chain that produced it.

My first attempt at this was a manual agent_logs Firestore collection. Each agent wrote structured entries keyed by lead ID. It worked well enough until the log format started drifting between agents — Agent 2 wrote classification, Agent 7 wrote tier, and the field names for timestamps were inconsistent across all seven. Reconstructing a full decision chain meant querying multiple documents, joining them by timestamp, and hoping nothing was written out of order. When Agent 7 made a routing decision based on what Agent 2 returned, you could only verify that by opening two separate documents and comparing timestamps by hand.

This is where Hindsight replaced the manual approach. Instead of each agent writing to its own log structure, every decision gets written to Hindsight keyed by lead ID — input received, LLM completions for Agents 2 and 3, routing outcome from Agent 7, all of it in one place. Agent memory means those decisions are queryable after the fact as a single ordered chain: pull up any lead ID and get the full reasoning sequence without joining anything manually.

In the dashboard, this surfaces as the Debate Log: a terminal-style view that replays what each agent saw and decided for any selected lead. The React component replays the Hindsight-stored log entries line by line:

// AgentActivity.jsx — replays Hindsight log entries for a given lead
const allLines = DEBATE_LOG.flatMap(block => [
  {
    isHeader: true,
    agent:    block.agentName,
    time:     block.time,
    agentId:  block.agent,
  },
  ...block.lines,  // The actual Hindsight-stored log lines for this agent's run
]);
Enter fullscreen mode Exit fullscreen mode

For a lead like Rahul Sharma at FinTrack Inc. — who hit his API export limit mid-session — the output looks like this:

[14:03:01] > AGT-01 — BEHAVIORAL SCOUT
  Event: api_limit_hit · Session: 47 min
  API calls today: 98 / 100
  Teammates invited: 3 — adoption signal detected
  → Routing to Executive Router

[14:03:02] > AGT-02 — INTENT ARCHITECT
  Invoking Groq llama3-70b-8192...
  Classification: POWER_USER
  Intent score: 91 / 100 · Urgency: HIGH
  Primary pain: USAGE_LIMIT

[14:03:03] > AGT-03 — PERSONA SCRIPTWRITER
  Subject: You hit the Data Export limit — here's how to unblock your team
  Body: 178 words · Personalization tokens: 4

[14:03:05] > AGT-07 — EXECUTIVE ROUTER
  Score 91 ≥ 80 → HOT_LEAD tier
  ✓ Slack fired → #sales
  ✓ Email queued for approval
  Pipeline complete · 4.2s
Enter fullscreen mode Exit fullscreen mode

The Debate Log terminal in the Agent Activity tab, showing the <br>
full reasoning chain for a HOT lead. Four agent blocks are visible: <br>
AGT-01 Behavioral Scout captures the api_limit_hit event, AGT-02 <br>
Intent Architect returns a score of 91 and classifies POWER_USER, <br>
AGT-03 Persona Scriptwriter outputs a 178-word email draft, and <br>
AGT-07 Executive Router fires the Slack alert and queues the email. <br>
Total pipeline time: 4.2 seconds.


That's not a trace I reconstructed manually. That's what the Hindsight log gives you for every lead, without any additional instrumentation. The difference between having this and not having it is the difference between debugging a distributed system and guessing at one.

Firestore as the Shared Contract

The seven agents don't share memory directly. The only state they share is Firestore, and the security rules define the contract for who can write to what:

// firestore.rules
match /leads/{leadId} {
  allow read:  if request.auth != null;
  allow write: if request.auth != null;  // Agents 1, 7 write; 2, 3 read
}

match /product_intelligence/{docId} {
  allow read:   if true;                 // Dashboard reads publicly
  allow write:  if request.auth != null; // Agent 6 writes only
}

match /activity_feed/{eventId} {
  allow read:   if true;
  allow create: if true;   // All agents append; no agent deletes
}
Enter fullscreen mode Exit fullscreen mode

The React dashboard hooks into these collections with onSnapshot listeners. Any Firestore write from any agent propagates to the UI in real time:

// hooks/index.jsx
export function useLeads() {
  const [leads, setLeads] = useState(INITIAL_LEADS);

  useEffect(() => {
    const q = query(
      collection(db, 'leads'),
      orderBy('intent_score', 'desc'),
      limit(50)
    );
    return onSnapshot(q, (snapshot) => {
      const fbLeads = snapshot.docs.map(doc => ({
        id: doc.id,
        ...doc.data(),
      }));
      if (fbLeads.length > 0) setLeads(fbLeads);
    });
  }, []);

  // ...
}
Enter fullscreen mode Exit fullscreen mode

I considered a message queue for agent-to-agent output. The problem is that the dashboard needs a queryable, consistent view of the current state of all leads — not just a stream of events. Firestore gives you both. Agents write to it, the dashboard reads from it in real time, and there's no additional infrastructure to maintain.


The Agent Activity tab showing a 4x2 grid of agent cards. Agent 7 <br>
Executive Router has a yellow glowing border indicating PROCESSING <br>
state. Its workflow node pipeline is visible inside the card — <br>
Webhook Trigger → Decision Logic → Update Firestore → Slack Alert <br>
— with the active node highlighted in yellow as the pipeline walks <br>
through each step in real time.


What I'd Do Differently

Strict input validation at Agent 1. The current normalization is lenient — missing fields get defaults. In production, a malformed payload that makes it past Agent 1 is significantly harder to debug than one that fails loudly at the boundary with a clear error message. Leniency at the entry point is a false kindness.

Per-agent Hindsight namespaces. All agent log entries currently go into the same Hindsight workspace, queryable by lead ID. As the system scales, I'd want per-agent namespaces to run fleet-level analytics: how often does Agent 2 return POWER_USER vs AT_RISK classifications? What's the distribution of urgency scores over the last 30 days? That's useful signal for tuning the Groq prompts and isn't something you can reconstruct efficiently from per-lead traces.

Explicit routing conditions in the Cascadeflow graph. The routing thresholds — score ≥ 80 for HOT_LEAD, score ≥ 50 for WARM_LEAD — currently live in Agent 7's Code node. I'd rather declare them in the Cascadeflow graph definition itself, so someone reading the topology can see "Agent 7 routes to Agent 3 when score ≥ 50" without needing to read the implementation. Separating the routing declaration from the routing implementation makes it much easier to reason about at a system level.

Takeaways

Things that applied here and will generalize to any multi-agent pipeline:

One router, not a mesh. Routing logic grows over time — new conditions, new exceptions, new downstream integrations. You want all of that growth concentrated in one place, with one set of tests, and one place to look when something doesn't route correctly.

Normalize at the entry point. Agents that are defensive about their input shape are agents that are compensating for a problem upstream. Fix the problem upstream. One normalization pass at the boundary is worth ten defensive null-checks distributed across six agents.

Build the audit trail first. In any system where LLMs are making decisions, you need to be able to reconstruct what each model saw and returned for any given input. Hindsight-style observability isn't something you bolt on after debugging gets painful — it's something you wire in from day one, because the debugging is going to happen and you'll want it to take minutes, not hours.

Shared state beats direct messaging for observable systems. Firestore as the shared layer means the dashboard and the agents are reading and writing the same data, with the same real-time guarantees. A separate message queue would have required building a distinct read model for the UI and introduced another system to operate.

Let leaf agents be dumb. Agents 2, 3, 4, 5, and 6 each do one thing. They're easy to test in isolation, easy to swap out, and easy to reason about. Complexity lives in Agent 7, where it's explicit and intentional. The moment a leaf agent starts making decisions about what should happen next, you've got routing logic in the wrong place.

Closing

This started as a lead scoring webhook. It became something more useful once I stopped thinking about agents as a chain and started treating routing as a first-class design decision — not an afterthought wired into whichever agent happened to sit upstream.

The topology is boring to look at. One router, six leaves, shared Firestore, no direct agent-to-agent memory. But boring topologies are the ones that are still working six months later, that you can debug at 2am without reconstructing context, and that you can hand to another engineer without a 45-minute walkthrough.

The complexity lives in Agent 7. That's where it belongs.

Top comments (0)