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
- role/
- AI Engineer & Product Builder
- span/
- Jun 2025 — present
replay of a real two-turn run · the second turn is where it gets interesting
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.
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.
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.
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
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.
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
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.
- 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
)Anatomy of a single turn
Six things happen before the user sees a word. Five of them exist to keep the sixth one reliable.
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.
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.
- router_node1 classify call
Route the follow-up
- super_agentreasoning cycle
Re-reads and re-classifies the whole request
- marketerreasoning cycle
Re-decides which channel this belongs to
- 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.
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"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"
) # → TrueState 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 reasonsA 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 ignoretransfer_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 scratchpadChild 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 3amdetect_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.
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
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 runFive 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.
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.
Frontend
Next.js 15 · React · ZustandAWS Amplify · CloudFront- App Router, 5 Zustand stores
- NextAuth session, 24h max age
- WebSocket consumer
- Cloudinary upload proxy
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
Message broker
RabbitMQ · amqp091-goManaged / self-hosted- agent_requests queue
- agent_messages consumer
- asset_notifications consumer
- Server boots even if broker is down
AI Engine
FastAPI · LangGraph 0.6ECS Fargate · 1024 cpu / 2048 mem- Graph compile + AgentResolver
- SSE invoke endpoint
- Checkpoints, summaries, memory
- Agent run + cost telemetry
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.
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
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 toolsThe 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.
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 requestIn 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 hereOn 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 probeTenancy 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.
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.
- 01
Merge to prod
.github/workflows/deploy.ymlGitHub Actions triggers on push to the prod branch only.
- 02
Build for amd64
--platform linux/amd64Multi-stage Docker build on python:3.12-slim and golang:1.23-alpine, running as a non-root appuser.
- 03
Push to ECR
amazon-ecr-loginTagged image lands in the private registry.
- 04
Rolling update
--force-new-deploymentECS starts replacement tasks alongside the old ones. No blue/green ceremony, no downtime window.
- 05
Prove health first
timeout 10s · retries 3curl -f /health every 15s with a 30s startPeriod and 3 retries. Old tasks only drain after the new ones answer.
- 06
Drain gracefully
timeout_graceful_shutdown=5SIGTERM 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.
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.
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