HomeAboutProjectsExperienceSkillsEducationContact
← Back to Projects
Hierarchical multi-agent platform

A twelve-agent LangGraph hierarchy that runs an e-commerce business from plain English. A Go gateway takes the request, RabbitMQ decouples it, a FastAPI orchestrator reasons over it, and a FastMCP server turns the decision into 190+ real API calls across Shopify, HubSpot, Meta, Zendesk, Drive and n8n.

0

Governed agents

0+

MCP tools

0ms

Avg latency

0/s

Requests served

LangGraphFastMCPGo + GinFastAPIRabbitMQNeo4jECS Fargate
role/
AI Engineer & Product Builder
span/
Jun 2025 — present
View Demo + Docs
POST /api/v1/chats/:id/invoketext/event-stream
routetransfertool_calltool_resultteleport

replay of a real two-turn run · the second turn is where it gets interesting

LangGraph
FastAPI
Go
Next.js
RabbitMQ
MongoDB
Neo4j
PostgreSQL
AWS
Docker
TypeScript
Tailwind
LangGraph
FastAPI
Go
Next.js
RabbitMQ
MongoDB
Neo4j
PostgreSQL
AWS
Docker
TypeScript
Tailwind
§ 01Walkthrough

Watch it run before you read how it works

A tour of the product — prompt to orchestration to a finished artefact in a real workspace.

app.ignitic.ai
View Demo + Docs

demo videos · SRS/SDS · pilot program · concept deck

The command centre

Natural-language input through the Super Agent into specialised workers, then out to n8n and the platforms they own — and what that replaces on the left.

§ 02The brief

Three people, one complaint: the work is manual and it never ends

Ignitic AI was scoped from real elicitation, not from a feature list. Every agent in the roster traces back to somebody's Monday morning.

0%squeezed

Requirement interviews with e-commerce operators kept landing on the same number: roughly a 35% profit margin squeeze from rising ad costs and manual overhead. Not a tooling problem — a labour problem wearing a tooling costume.

the same job, two ways

EffortSwitch between nine tools by handOne abstraction layer, one prompt
ScalingHire another operatorDescribe the job in a sentence
IntegrationSiloed platforms that never talkUnified n8n + MCP orchestration

The Founder

Yearly numbers live in three dashboards that disagree.

One report across Daraz, Shopify and Amazon without a spreadsheet week.

analytics_agent — Shopify and GA4 behind a single question.

The Operator

The same forty customer questions, every single day.

Repetitive inquiries handled without a human reading them first.

customer_support_agent — the most-requested feature in elicitation.

The Agency Owner

Nine clients, nine logins, nine sets of credentials.

Run every client portfolio from one interface without cross-contamination.

Org-scoped tenancy — secrets, assets and quotas keyed per organization.

§ 03Architecture

A directed graph, not a black-box supervisor

LangGraph ships a prebuilt supervisor node. I did not use it — it hides the handoff logic, and handoff logic is exactly where a twelve-agent hierarchy goes wrong. This tree is hand-compiled, and every edge is inspectable.

  • Orchestration
  • Research & Analysis
  • Growth & Content
  • Commerce
  • Operations & Records

pick a node — the lineage lights up

12

prebuilt agents

2

orchestrators

3

depth levels

154

tools in roster

Facebook Page Agent

facebook_page_agent

worker

lineage · what the router rebuilds

super_agentmarketerfacebook_page_agent

Page publishing, comments, insights via the Graph API.

A grandchild of the root — and the node that made naive re-routing unaffordable, because every follow-up message had to walk the tree twice.

Meta Graph API
mcp mount
/facebook_page_agent
tools in scope
8
parent
marketer
children
leaf

Custom agents slot into the same tree

A user-defined agent is just a document: a parent, a type, a system prompt and a tool allow-list. Point one at a descendant of itself and the graph refuses to compile.

# checked before compile, not at runtime
detect_hierarchy_cycles(agents)
# → ValueError: cycle via marketer → custom_x → marketer

Agent(
  identifier = "wholesale_desk",
  type       = "worker",
  parent     = "super_agent",
  tool_names = ["hubspot_deals_create", ...],  # [] = all
)
§ 04The loop

Anatomy of a single turn

Six things happen before the user sees a word. Five of them exist to keep the sixth one reliable.

graph traversal · one user messageauto-playing

summarize

Compress before you reason

Every turn enters through LangMem's SummarizationNode, not through the model. If the thread has crossed MAX_TOKENS_BEFORE_SUMMARY it collapses into a RunningSummary first, so no agent ever meets a saturated context window.

# graph entry — runs first, every turn
context: dict[str, RunningSummary]
MAX_TOKENS_BEFORE_SUMMARY = 20_000
SUMMARIZATION_MODEL = "openai/gpt-4o-mini"

Everything above shares one state object

AgentState(TypedDict)
  • messagesAnnotated[Sequence[BaseMessage], add_messages]Reducer-merged transcript
  • active_agentNotRequired[Optional[str]]Who spoke last — the teleport key
  • agent_stackNotRequired[List[str]]Lineage the router rebuilds
  • summarized_messagesNotRequired[list[BaseMessage]]Post-compression view
  • contextNotRequired[dict[str, RunningSummary]]LangMem summary store
  • remaining_stepsNotRequired[RemainingSteps]Runaway-loop budget
  • start_timedatetimeFeeds cost + latency telemetry

The three violet fields are the whole trick. active_agent records who spoke last, agent_stack records how we got there, and messages is reducer-merged so a rewritten history evicts the old one instead of stacking on top of it.

The journey of a single prompt

One high-level goal fans out through the Super Agent to specialised workers, into n8n, and back out across Slack, Drive, Shopify and HubSpot.

§ 05The hard part

The grandchild problem, and the shortcut that fixed it

A user talks to an agent two levels down, then sends a follow-up. Walk the tree again and every message pays for its ancestors' reasoning. That was the single most expensive bug in the architecture — and it was a design bug, not a code one.

  1. router_node1 classify call

    Route the follow-up

  2. super_agentreasoning cycle

    Re-reads and re-classifies the whole request

  3. marketerreasoning cycle

    Re-decides which channel this belongs to

  4. facebook_page_agenttool call

    Finally does the work the user asked for

4

nodes traversed

3

reasoning cycles

0

ancestors skipped

state teleportation

Three steps inside router_node. It buys the shortcut with one cheap classification call, then writes the destination state directly instead of walking to it — the graph arrives at the grandchild without the parents ever executing.

1

Identifier extraction

Read the message history and pull the last agent that actually spoke — facebook_page_agent, two levels down the tree.

last = extract_last_speaker(state["messages"])
# → "facebook_page_agent"
2

Intent classification

One cheap, domain-aware call: does this new message continue that agent's job, or has the user changed subject? Only a yes earns the shortcut.

continues = await classify(
    msg, domain_of(last), model="openai/gpt-4o-mini"
)  # → True
3

State reconstruction

A pre-compiled ancestor map turns the target into a full lineage, and agent_stack is rebuilt in one write. The user lands inside the grandchild with the parents' reasoning cycles never executed.

stack = ANCESTORS[last] + [last]
# → ["super_agent", "marketer", "facebook_page_agent"]
return Command(goto=last, update={"agent_stack": stack})

Four guardrails that stop a hierarchy misbehaving

Teleportation makes the happy path cheap. These make the unhappy paths survivable — each one closes a failure mode that showed up in testing.

Intent Interceptor

Runs before the child reasons

A programmatic gate at the top of every child turn. If the request has drifted out of that agent's domain, it never reaches the model — the turn is handed back instead of half-answered.

Escape Hatch

A tool the child cannot ignore

transfer_back_to_parent is injected as a priority tool into every child, with instructions to invoke it the moment a request is unserviceable. It ends the 'trapped sub-agent' failure mode by construction.

State Encapsulation

The parent never sees the scratchpad

Child messages stop being flushed to global state. The Super Agent receives a synthetic handoff record instead of the child's tool logs, so it cannot re-narrate work the user already read.

Cycle Detection

Fails at build time, not at 3am

detect_hierarchy_cycles() walks the parent graph before compile and raises ValueError. A custom agent pointed at its own descendant is rejected at creation, not discovered in production.

§ 06Tool surface

181 tools on the server. Never more than a handful in the prompt.

Fifteen independent FastMCP apps mounted behind one Starlette server. Handing all of them to every agent is the fastest way to make a capable model look stupid.

tools registered on the server

181/ 181

pick a server to watch the window shrink

Every square is a real registered tool. Drop this many schemas into one context window and the model starts overlooking the ones in the middle — the failure gets worse as you add capability, which is precisely the wrong direction.

15 mounted FastMCP apps

/custom155 re-exports + dynamic

Flat re-export of every domain server, plus n8n workflow tools registered at boot from GET /api/v1/workflow-template/?limit=0. Only workflows whose first node is n8n-nodes-base.webhook are accepted.

Why not just give every agent everything?

Because retrieval accuracy collapses. Drop 181 tool schemas into one context window and the model starts missing the tool sitting in the middle of the list — the 'lost in the middle' failure. The fix is structural, not prompt engineering.

How the trim works

Each Agent document carries a tool_names allow-list. After the MCP client fetches a server's tools, _filter_tools_for_agent intersects them against that list, so the compiled ReAct node only ever knows about its own slice. An empty list means the whole server.

What it costs

One extra design decision per agent, and a contract test — test_mcp_server_registrations.py asserts the exact tool count of all 15 servers, so a silent registration drift breaks CI instead of production.

Registration is a contract, so drift breaks CI instead of production

# tests/servers/test_mcp_server_registrations.py
EXPECTED = {
    "hubspot_mcp": 34, "email_marketing_mcp": 42, "customer_support_mcp": 15,
    "google_ads_mcp": 14, "gdrive_mcp": 13, "meta_ads_mcp": 13,
    "analytics_mcp": 10, "product_researcher_mcp": 8, "facebook_page_mcp": 8,
    "business_analyst_mcp": 7, "shopify_mcp": 6, "marketer_mcp": 4,
    "instagram_mcp": 4, "seo_mcp": 3,
}   # sum = 181, asserted per server on every run
§ 07Memory

Five layers, because “memory” is five different problems

What the graph needs to resume a run, what the model needs to stay under its window, what the user needs to scroll back, and what the business needs to remember next quarter — none of those are the same store.

Not every call deserves the expensive model

Routing, summarising and entity extraction run on every single turn. Putting a frontier model on those would triple the bill for work that needs a classifier, so each role gets its own env-configured tier through OpenRouter.

  • Agent reasoningopenai/gpt-4o

    Holds hierarchy context through long tool-calling chains

    OPENROUTER_API_KEY

  • Summarizationopenai/gpt-4o-mini

    Runs every turn — has to be cheap

    SUMMARIZATION_MODEL

  • Intent classificationopenai/gpt-4o-mini

    One call, one boolean, no reasoning budget needed

    SUMMARIZATION_MODEL

  • Graph extractionopenai/gpt-4o-mini

    Entity extraction for Neo4j writes

    GRAPHITI_LLM_MODEL

  • Embeddingstext-embedding-3-small

    1536-dim vectors for asset retrieval

    GRAPHITI_EMBEDDING_MODEL

Vision arrives as a message, not a tool result

When an MCP tool returns an image, a hook converts it into a HumanMessage before it reaches the model — so a vision-capable agent actually looks at the screenshot instead of reading a URL and guessing.

§ 08Platform

Four services, one request, no blocking

Agent reasoning takes seconds. An HTTP handler that waits for it is a handler that falls over under load — so the write path returns a request id and the answer comes back over a socket.

ws
  1. Frontend

    Next.js 15 · React · ZustandAWS Amplify · CloudFront
    • App Router, 5 Zustand stores
    • NextAuth session, 24h max age
    • WebSocket consumer
    • Cloudinary upload proxy
  2. Backend API

    Go 1.23 · Gin · GORMAWS ECS · behind ALB
    • JWT auth + refresh, bcrypt
    • Organizations, members, invitations
    • AES-256-GCM secret vault
    • Credit accounts + entitlements
  3. Message broker

    RabbitMQ · amqp091-goManaged / self-hosted
    • agent_requests queue
    • agent_messages consumer
    • asset_notifications consumer
    • Server boots even if broker is down
  4. AI Engine

    FastAPI · LangGraph 0.6ECS Fargate · 1024 cpu / 2048 mem
    • Graph compile + AgentResolver
    • SSE invoke endpoint
    • Checkpoints, summaries, memory
    • Agent run + cost telemetry
  5. MCP Server

    Starlette · FastMCP 2.11ECS Fargate · VPC-internal only
    • 15 mounted FastMCP apps
    • Auth + execution-logging middleware
    • Dynamic n8n workflow tools
    • Zero credential storage

third-party surface

ShopifyHubSpotZendeskMeta GraphGoogle AdsGA4DriveBrevoMailchimpApifyn8n

request path · hop 1 of 6

FrontendBackend API

HTTPS · Bearer JWT

POST /api/v1/agents/chat

Three stores, three jobs

  • PostgreSQL (RDS)22 Goose migrations

    Identity, orgs, secrets, credits

  • MongoDB Atlas1536-dim index

    Checkpoints, transcripts, vectors

  • Neo4jOptional, fails soft

    Graphiti knowledge graph

Relational data stays relational. Graph state, conversation history and embeddings live where those shapes are cheap to query — and the broker being down degrades the platform instead of stopping it.

End-to-end microservices

Presentation, orchestration and infrastructure on one page — including the memory split between MongoDB checkpoints and the Neo4j graph.

§ 09The bottleneck

8.6 seconds to 194 milliseconds, from one lock

A load test found a listing endpoint timing out at sixty seconds. The cache in front of it was correct — it just had no idea what to do when fifty callers missed it simultaneously.

k6 load profile

k6 · 50 virtual users · ~3.5 min ramp / hold / ramp-down

  • Average latency8.6 s−97.7%
  • p95 latency59.95 s−98.8%
  • Median latency2.18 s−96.9%
  • Throughput5.03 req/s×25.7
  • Checks passed83.19%+20.2%
  • Failed requests10%−100.0%

checks passed · 490 / 589 checks

50 virtual users hit a cold _TOOLS_CACHE50 concurrent MCP fetches

Every request missed the cache at the same instant, so every request opened its own fetch for the same tool list.

Symptom

Under 50 concurrent users, GET /api/v1/agents/ was timing out at 60 seconds and one request in ten failed outright. The endpoint does almost nothing — it lists agents.

Cause

A cold _TOOLS_CACHE. All 50 requests missed simultaneously, and all 50 independently opened an MCP fetch for the same server. The cache was correct in isolation and catastrophic in parallel — a textbook thundering herd.

Fix

A per-server asyncio.Lock in _TOOLS_CACHE_LOCKS, with a double-checked read inside the lock. First caller fetches, the other 49 wait on the lock and then find the cache warm. Ten minutes of TTL, one fetch.

Double-checked locking

Check the cache without a lock, because that is the hot path. Take the lock only on a miss, then check again — because by the time you hold it, someone else has probably already done the work.

_TOOLS_CACHE: dict[str, list[BaseTool]] = {}
_TOOLS_CACHE_LOCKS: dict[str, asyncio.Lock] = {}

async def get_tools(server: str) -> list[BaseTool]:
    if (hit := _TOOLS_CACHE.get(server)) is not None:
        return hit                                # 1st check — lock-free

    lock = _TOOLS_CACHE_LOCKS.setdefault(server, asyncio.Lock())
    async with lock:
        if (hit := _TOOLS_CACHE.get(server)) is not None:
            return hit                            # 2nd check — herd lands here
        tools = await client.get_tools(server)     # exactly one fetch
        _TOOLS_CACHE[server] = tools
        return tools

The remaining p95 of 747ms is still above the 500ms target this project set for itself at 50 concurrent users. I would rather publish that than round it off.

§ 10Trust

An agent holding your Shopify key is a different risk profile

The moment software can act on a merchant's behalf, credential handling stops being a checklist item. Four boundaries, each one assuming the previous one failed.

At rest, in Go

Org and user secrets are sealed with AES-256-GCM under a 32-byte ENCRYPTION_KEY, a fresh 96-bit nonce per write. ciphertext and iv are stored as separate BYTEA columns with the algorithm recorded alongside.

secrets(app, name, organization_id) UNIQUE
  ciphertext BYTEA
  iv         BYTEA   -- 96-bit, per-write
  algo       TEXT    -- 'AES-256-GCM'

At rest, in Python

Third-party credentials held by the AI Engine are Fernet-encrypted under PASS_ENCRYPTION_FERNET_KEY, separate from the Go vault so a single key leak cannot open both stores.

PASS_ENCRYPTION_FERNET_KEY=...
GET /api/v1/credential/{type}
  → decrypt on read, per request

In use, nowhere

The MCP server stores no credentials at all. It requests one at the moment of tool invocation, uses it inside that call frame, and holds no reference afterwards — so plaintext never reaches application state or a log line.

# on_call_tool
cred = await engine.credential(kind)   # decrypted here
return await shopify.call(cred, **args)  # and only here

On the network

The MCP server has no public route. It is reachable only from inside the VPC, and every message passes AuthenticationMiddleware, which rejects any call arriving without Authorization and X-Chat-ID.

# AuthenticationMiddleware.on_message
if not auth or not chat_id:
    raise NotFoundError()  # not 401 — no surface to probe

Tenancy is enforced at the row, not the query

Organizations are the tenant boundary. Secrets, assets, todos and credit accounts all carry an owner type and owner id, and the secrets table is uniquely keyed on (app, name, organization_id) so two tenants physically cannot collide on a credential name.

Membership is a join table with three roles — admin, member, viewer — and vector retrieval is namespaced the same way, so a semantic search inside one org can never surface another org’s brand documents. System tests assert the role matrix rather than trusting it.

§ 11Delivery

Merge to prod, and the fleet replaces itself

No maintenance window, no manual step, and no old container retired before a new one has proven it can answer.

  1. 01

    Merge to prod

    .github/workflows/deploy.yml

    GitHub Actions triggers on push to the prod branch only.

  2. 02

    Build for amd64

    --platform linux/amd64

    Multi-stage Docker build on python:3.12-slim and golang:1.23-alpine, running as a non-root appuser.

  3. 03

    Push to ECR

    amazon-ecr-login

    Tagged image lands in the private registry.

  4. 04

    Rolling update

    --force-new-deployment

    ECS starts replacement tasks alongside the old ones. No blue/green ceremony, no downtime window.

  5. 05

    Prove health first

    timeout 10s · retries 3

    curl -f /health every 15s with a 30s startPeriod and 3 retries. Old tasks only drain after the new ones answer.

  6. 06

    Drain gracefully

    timeout_graceful_shutdown=5

    SIGTERM gives in-flight work five seconds to finish before the container is reaped.

Task sizing follows the workload, not a default

  • AI Engine1024 cpu · 2048 memGraph compile plus concurrent reasoning
  • MCP Server512 cpu · 1024 memI/O bound — 15 apps waiting on HTTP
  • Backend APIECS behind an ALBTLS terminates at the edge, 8080 inbound only
  • FrontendAmplify · CloudFrontSSR at the edge, Node 20 build

If it ran, there is a record of it

  • CloudWatch Logs + loguru
  • Langfuse / LangSmith traces
  • Sentry error capture
  • OpenTelemetry (OTLP)
  • X-Request-ID propagation
  • logs table — 8 audit sections

Every tool execution and agent transition is written with a request id, and the Go side mirrors requests into an audit table split across eight sections. Autonomy without a trail is not a feature, it is exposure.

§ 12Field proof

Then a real manufacturer ran their business on it

User acceptance testing with Ajsamco, a motorcycle apparel manufacturer. Not a scripted demo — their actual operations, across their actual departments, for weeks.

0+

Workflows executed

0

Departments covered

0+

Tools exercised

B2B Sales

Find UK motorcycle clubs and US distributors worth approaching.

Ran LinkedIn and Apify scrapers, vetted the results against member counts and activity, and handed a shortlist to the CRM.

Finance

Is the new airbag jacket line viable at our B2B price?

Executed a breakeven analysis over fixed costs against the proposed wholesale price, and returned the unit volume needed to clear it.

Marketing

Plan the production brief and draft the storefront listing.

Wrote the brief to Google Drive and composed a Shopify product listing with SEO keywords already in place.

Support & CRM

Log this lead. Triage that complaint.

Router separated HubSpot lead logging from Zendesk ticket triage across the run — two adjacent domains, no cross-delivery.

The result that mattered most was not a workflow completing — it was the router never mis-delivering. Across the run, HubSpot lead logging and Zendesk ticket triage stayed separated, and follow-up messages landed on the leaf agent that had been doing the work rather than restarting at the top of the tree. That is the whole thesis of the architecture, validated by somebody who had no interest in the architecture.

§ 13What I take from it

What twelve agents actually taught me

The interesting problems in agentic systems are not the prompts. They are state, routing, concurrency and audit — which is to say, they are systems engineering with a language model in the loop.

Hierarchy is a routing problem

The hard part of a 12-agent tree was never the agents. It was making sure control returned to the right node, and that a follow-up message did not pay for the whole tree again.

Constrain the context, not the model

Tool allow-listing, state encapsulation and entry-point summarisation all do the same job: keep the window small enough that the model stays reliable. None of them are prompt tricks.

Concurrency bugs hide behind correct code

The cache that took latency from 8.6s to 194ms was already there. It only needed a lock — and a load test honest enough to find it.

Autonomy needs a paper trail

Every tool execution, agent transition and token cost is recorded against a request id. An autonomous system you cannot audit is not a product, it is a liability.

what I would build next

  • Human-in-the-loop approval gates on write-tier tools, using the checkpointer's pending_sends
  • Per-agent p95 budgets in CI so a regression fails the build, not the demo
  • Ancestor-map teleportation extended to sibling handoffs, not just lineage