DEV Community

Cover image for From n8n to IRC-A: a week migrating a real project — and a 679-token bill that's hard to believe
Sandro Garcia
Sandro Garcia

Posted on

From n8n to IRC-A: a week migrating a real project — and a 679-token bill that's hard to believe

I went a bit quiet last week. The reason: I was heads-down working on the framework to complement the SDK. And what better way to build a framework that's genuinely useful and simple to use than starting from a real project — and seeing what functionality can be "packaged" into it?

So I spent the week migrating an old customer-service project from my wife's business, from n8n to IRC-A. And every time I use this approach, I fall in love with it a little more.


The magic (not black magic): extending the system with a single curl

It's amazing to watch: you build an agent or an MCP tool, connect it to the server, and two seconds later your multi-agent system's capabilities have grown. No extra code. No component knowing about any other. No drawing graphs and edges. Just this:

curl -X POST "http://[irc-server]/register/agent?url=http://[agent-url]&channels=%23content"
Enter fullscreen mode Exit fullscreen mode

This is the magic Alan Kay envisioned with Smalltalk — objects sending messages to each other without knowing one another — brought to the agentic era.


A real log, unedited

So this doesn't sound like marketing, here's a real log from the running system. The user asks the chatbot: "How many customers did we have in August?"

main-agent       | [Chatbot] Message received in crm session: How many customers did we have in August
customers-agent  | [Customers Agent] Processing query: 'How many customers did we have in August'
customers-agent  | [Customers Agent] Technical intent refined by LLM: 'count_customers_by_date CRM'
customers-agent  | [Customers Agent] /discover Raw Response: {"status":"success","det":"v4.public.eyJ...","url":"http://host.docker.internal:8003","target_node_id":"count_contacts","type":"tool"}
customers-agent  | [Customers Agent] BFA Gateway indicated calling -> Type: 'tool', Destination: 'http://host.docker.internal:8003'
customers-agent  | [Customers Agent] Schema for Tool 'count_contacts' per BFA/Fallback: {'type': 'object', 'properties': {'from': {'type': 'string'}, 'to': {'type': 'string'}}}
customers-agent  | [Customers Agent] Extraction LLM response: '{"from":"2026-08-01","to":"2026-08-31"}'
customers-agent  | [Customers Agent] ---> FINAL PARAMETERS TO SEND TO 'count_contacts': {'from': '2026-08-01', 'to': '2026-08-31'}
customers-agent  | [Customers Agent] Invoking P2P Tool 'count_contacts' at http://host.docker.internal:8003/tools...
customers-agent  | [Customers Agent] P2P Tool 'count_contacts' Response: "{\"count\":21}"
Enter fullscreen mode Exit fullscreen mode

Read that log again and notice the important part: nobody knows anybody.

  • The main-agent doesn't know the customers-agent exists, and it doesn't know the count_contacts tool either. It only knows its own job: talk to the user and fulfill their request, asking the Gateway for assistance.
  • The customers-agent doesn't know the main-agent, nor the count_contacts tool. It only knows its responsibilities, defined in its agent-card:
agent_id="customers_agent",
name="Customers Agent",
description="Agent in charge of handling any kind of task on the customers and contacts database. It connects to the EspoCRM CRM through its MCP and exposes tools to query, add, update and delete contacts.",
tags=["customers", "contacts", "leads", "crm"],
examples=[
    "I want the list of customers from last month",
    "how many new customers did we have this month",
    "how many contacts do we have",
    "how many contacts are registered",
    "Add the customer 'John Doe' with phone 123456789",
    "Update 'John Doe's phone to 987654321",
],
Enter fullscreen mode Exit fullscreen mode

And its prompt is as simple as this:

system_prompt = (
    "You are the specialist agent for CRM customers/contacts. "
    "Convert the user's request into a single short MCP tool-search phrase (e.g. 'count_contacts CRM', 'search_contacts CRM') "
    f"to look up the right tool on the BFA network for: '{user_message}'"
)
Enter fullscreen mode Exit fullscreen mode

Here user_message has already been refined: an earlier LLM pass distills the exact intent, shortens the message, and cuts unnecessary token spend.

The full flow:

  1. The agent refines the intent: "count_customers_by_date CRM".
  2. The BFA Gateway tells it who to call, hands over the authorization token (DET) and the parameter schema.
  3. The agent extracts the parameters and invokes the tool P2P.
  4. Result: {"count": 21}.

Without anyone knowing anyone — or any tool — we got the answer. It's that simple.


The other number hiding in that flow: 679 tokens

Here's the part that made me stare at the LangSmith dashboard for a while.

During testing, a user had a complete two-turn interaction with the system: they asked whether a contact existed in the CRM (she didn't), then asked to add her with name and phone number (done). That flow involved 2 agents coordinating, 4 LLM calls, and an MCP tool execution.

The total cost: 679 tokens. Less than a tenth of a cent.

The per-call breakdown (gpt-4.1-mini, temperature 0, straight from the traces):

Step LLM call Tokens Cost
Lookup Intent refinement → search_contacts CRM <name> 103 <$0.0001
Lookup Parameter extraction for search_contacts 192 <$0.0001
Insert Intent refinement → add_contact CRM <name> <phone> 135 <$0.0001
Insert Parameter extraction → {"firstName": ..., "lastName": ..., "phone": ...} 249 $0.0002
Total 679 < $0.001

For context: a typical agentic loop dragging its full conversation history and tool state through every step easily burns 5k–10k tokens per task. This is two orders of magnitude less. Why?

  1. LLM calls are surgical. The model is used exactly twice per agent turn: once to distill intent into a short search phrase, once to extract parameters against a JSON schema. No endless chit-chat, no history re-feeding.

  2. Discovery and routing cost zero tokens. Finding who to call is FAISS vector search over capability metadata — deterministic, fast, and free of LLM involvement.

  3. Execution is P2P. Once the agent knows the destination and holds its authorization token (DET), it talks directly to the tool. There's no central orchestrator inflating every step with system-wide context.

Efficiency in agent systems isn't achieved with shorter prompts. It's achieved with architecture — using the LLM where it adds value (understanding, extracting) and not where it doesn't (searching, routing, authorizing).


The week's real value: what a real project taught the framework

As I said, the goal was to improve the SDK and isolate what matters most for the framework. And this mini-project worked exactly as hoped: migrating something real exposed friction I would never have found writing toy tests. Here are the 6 improvements that came out of the week:

1. Semantic routing and circular interception in FAISS

The problem: when asking "how many new customers were added from July 27th to 30th?", the Gateway returned the main_agent itself as the destination instead of delegating to the customers_agent or the MCP tool. This created circular invocation loops customers_agent -> main_agent -> customers_agent, which were rejected with an error.

The cause: the main_agent's descriptions and examples contained broad domain keywords ("customers", "contacts", "tasks"), making its embedding vector overlap with the specialists' vectors in the Gateway's FAISS index. On top of that, the customers_agent was sending generic search phrases that didn't indicate it was looking for MCP tools.

The fix: I narrowed the main-agent's description down to purely conversational welcome tasks, and instructed the customers-agent's LLM to generate short search phrases explicitly oriented toward MCP tools ("count_contacts CRM"). Architectural lesson: in semantic routing, agent descriptions are the routing contract — if they overlap, the system gets confused; if they're precise, the system routes itself.

2. Empty {} parameters caused by the Gateway omitting input_schema

The problem: the count_contacts tool was receiving empty arguments {} and returning the unfiltered total count; save_contact failed with ValueError: at least one field is required to create the contact.

The cause: the Gateway's /discover endpoint sometimes returned "input_schema": {} instead of the registered JSON Schema. The agent's parameter-extraction LLM, upon receiving an empty schema, correctly concluded the tool took no parameters.

The fix: a fallback table KNOWN_MCP_SCHEMAS in the agent. If the Gateway returns an empty schema for known tools, the agent dynamically injects the fallback JSON Schema, ensuring the LLM extracts from, to, firstName, phone, etc., in ISO format. The agent becomes resilient to an imperfect Gateway — defense in depth applied to multi-agent systems.

3. The Gateway's Pinger was deregistering live agents

The problem: the logs periodically showed:

[DISCOVERY] http://customers-agent:8311: Endpoint is dead/unreachable. Automatically unindexed from FAISS.
Enter fullscreen mode Exit fullscreen mode

The agent was alive, but the Gateway kept delisting it.

The root cause (my favorite of the week): during synchronous LLM calls, Python blocked Uvicorn's event loop on the main thread. When the Gateway's Pinger sent GET /tools every 3 seconds, the agent couldn't respond in time, and the Gateway assumed the container had died.

The fix: make LLM invocations asynchronous:

await asyncio.to_thread(llm_router.generate, ...)
Enter fullscreen mode Exit fullscreen mode

The event loop stays 100% free to answer health checks at 0ms in the background. A classic concurrency bug disguised as a network bug.

4. Hardcoded ports and URLs

Agents were trying to register using stale fallbacks (127.0.0.1:8003, port 8005) that conflicted with the Docker setup. The obvious but necessary fix: everything comes from the environment. CUSTOMERS_AGENT_PORT, MAIN_AGENT_URL, BFA_GATEWAY_URL — zero magic values in the code.

5. No module named 'llm_router' inside the containers

When running the agent from its subdirectory, the project root wasn't on Python's sys.path. A two-move fix: insert the root directory at the top of the script, and declare PYTHONPATH=/app in docker-compose.yml.

6. MCP registration failing due to an incomplete path

# ❌ This failed with "Failed to discover MCP tools":
curl -X POST "http://localhost:8000/register/mcp?url=http://host.docker.internal:8003/mcp"

# ✅ This registered all 10 CRM tools in one shot:
curl -X POST "http://localhost:8000/register/mcp?url=http://host.docker.internal:8003&channels=%23deotroangulo"
Enter fullscreen mode Exit fullscreen mode

The Gateway expects the root base URL, from which it dynamically discovers the endpoints. A small framework UX detail that's now documented and polished.


Why I insist this is architecturally clean

After this week, I can say it with more confidence than ever:

  • Total decoupling: the main-agent and the customers-agent don't know each other. Tomorrow I could delete the customers-agent and register a new one written in another language, and the system wouldn't even notice. That's real maintainability, not theoretical.

  • Discovery, not configuration: capabilities register themselves and are discovered semantically via FAISS. No graphs to redraw, no edges to rewire when you add a node.

  • Security in the protocol: the Gateway issues a token (DET) carrying the permitted action, the issuer, and the audience. Authorization travels with discovery.

  • Contained failures: every bug from this week was fixed in a single component. Not one fix required touching the other agents. That's the acid test of a clean architecture: changes stay local.

  • Efficiency by design: 679 tokens for a full lookup + insert flow isn't an optimization trick — it's what falls out naturally when the LLM only does what only the LLM can do.

Migrating away from n8n wasn't just a tooling change: it was moving from a hand-drawn workflow to a network of agents that organize themselves. And best of all, the framework came out of the week stronger — because every difficulty found in a real project became a permanent SDK improvement.


Are you using n8n, LangGraph, or another orchestrator? How do you handle capability discovery between agents — and have you ever measured tokens per task? I'd love to hear about it in the comments.

Top comments (0)