Three AI Agents That Couldn't Talk to Each Other — So I Made Them a Drupal Content Type

K

Authored on

Image
casaloma

At any given moment I have three AI things running. There's a Node.js agent on my VPS wired into Claude Code, reachable over Telegram and Slack — the one I've written about twice already, first when I gave it persistent memory, and then when that memory handed me a token bill. There's a Python voice assistant on my desktop running on Gemini Live, which I talk to out loud while my hands are busy. And there's Cursor, in the IDE, doing the pair-programming thing.

Three capable tools. Zero awareness of each other. Which means that for months, the integration layer between my own AI agents was me. Read something on Telegram, retype it into the IDE. Ask the voice assistant to check something on the desktop, then relay the answer by hand to the agent on the server. I didn't have an AI workspace. I had three assistants and a very patient human courier.

The retyping was annoying. What actually pushed me to fix it was something worse: when the VPS agent hit its Claude usage limit halfway through a task, the task just stopped. No handoff, no fallback, nothing. There was a second AI sitting right there in my editor with plenty of budget left, and no way to say "here, you finish this."

The rule I set before writing any code: build nothing new

My first instinct was the obvious one — stand up a small message queue, or add a table and a couple of endpoints. I talked myself out of it in about ten minutes, because I realized I already had the thing I needed.

keboca.com already runs an MCP server with OAuth-scoped tools. I built it for something else entirely — tickets and time trackers — and it works. So the rule became: no new service, no new database table, no new dashboard. If the bus can't live inside Drupal content, I don't want it.

It could. The whole message bus is one content type: ai_message. Every single thing the agents say to each other — an instruction, a question, a status report, a result, an acknowledgment, an error — is a node with five fields:

  • field_from_agent — which agent sent it
  • field_to_agent — which agent it's for
  • field_message_typeinstruction, query, data, status_report, result, ack, or error
  • field_payload — the actual text
  • field_statuspending to read to acked

Three new MCP tools do all the work: send_ai_message, read_ai_messages, ack_ai_message. That's the entire API surface.

And here's the part I keep bragging about to people who did not ask: because it's just Drupal content, I can watch my three agents talk to each other in real time at /admin/content?type=ai_message. Filter by type, sort by date, click into a node and read the payload. I got a full audit trail of inter-agent conversation for free, and I didn't write a line of UI code for it. Views was already there. Views is always already there.

Polling, because NAT doesn't care about your elegant push architecture

One design decision looks lazy on paper and turned out to be the right call. The desktop assistant sits behind my home router. It can't receive a push, ever. So nobody pushes — every agent just polls for its own pending messages every 30 seconds.

Stateless, no webhooks to expose, no tunnel to maintain, and completely indifferent to which agent is behind which network. The trade-off is up to 30 seconds of latency on a round trip, and for "go check disk usage and tell me" that is nowhere close to a problem.

Where it broke

The architecture was fine on paper. Every place it touched something real, it broke, and honestly this is the part of the project worth reading.

The envelope

First smoke test from the VPS: the message node was created successfully — I could see node #163 sitting right there in the admin UI — and the client threw an exception anyway.

The client was reading the response shape I assumed the Tool API returned. It returns a different one:

// What my client expected
{ "result": { "id": 163 } }

// What the Tool API actually sends
{
  "success": true,
  "message": "Message created",
  "data": { "result": { "id": 163 } }
}

So I wrote a small recursive peeler that walks down through the data and result layers until it finds something that looks like the actual payload, plus a helper that doesn't care whether the ID arrives as id, nid, or message_id:

function unwrapEnvelope(response, depth = 0) {
  if (depth > 4 || !response || typeof response !== 'object') return response;
  if (response.data)   return unwrapEnvelope(response.data, depth + 1);
  if (response.result) return unwrapEnvelope(response.result, depth + 1);
  return response;
}

function extractMessageId(payload) {
  return payload?.id ?? payload?.nid ?? payload?.message_id ?? null;
}

Four levels of nesting is more defensive than it needs to be. I left it that way on purpose. This is the kind of code you only write after production disagrees with your assumptions, and by then you've stopped trusting your assumptions about the next layer too.

The message that arrived and then did nothing

Next problem was more subtle. When a bus message came in, the Telegram poller showed me a preview of the raw payload. Which sounds fine. It isn't.

I'd get a notification saying the desktop assistant reported disk at 82% — and that was it. The VPS agent had received a message from another AI and treated it like a postcard. No reasoning, no action, no answer. Just delivery.

The fix was to stop treating incoming bus messages as text to display and start treating them as prompts. Now an arriving message gets framed and run through the agent's main response function before anything goes out to Telegram, so what I actually see is an answer, not a forwarded string.

The ack storm I created twenty minutes later

That fix broke something immediately, which is my favorite genre of bug.

Every message type was now going through the model. Including bare ack receipts. An ack carries no content — it's a message whose entire meaning is "got it." And each one was burning a full Claude call against the agent's 240-second response budget to think very hard about the word "acknowledged."

The poller now recognizes housekeeping and consumes it quietly:

if (message.field_message_type === 'ack') {
  await markMessageRead(message.id);   // consume it, no model call
  continue;
}

Five lines. In hindsight, obvious. In the moment, I only found it because I was watching the log and wondering why a two-message exchange had produced four Claude invocations.

The bug that had nothing to do with any of this

While chasing the above, I noticed the desktop assistant was launching twice on every reboot. Turned out I'd enabled both an XDG autostart entry and a user systemd unit, months apart, and forgotten about the first one. Two mechanisms, both convinced they owned the process.

I killed the systemd unit and added an flock single-instance guard to the launch script, because I clearly can't be trusted to remember which autostart mechanism I picked. Not a bus bug at all. But it's the kind of thing you only find when you start paying close attention to what's actually running, and integration work forces you to pay close attention.

The handoff, which is the whole reason I built this

With the bus working, the usage-limit problem became about six lines of error handling. When the agent's Claude calls start failing with a usage-limit error, it doesn't die — it posts an instruction to Cursor and tells me it did:

if (isUsageLimitError(err)) {
  await sendAiMessage('cursor', 'instruction', pendingTask);
  await notifyOwner('Hit the usage limit — handed this off to Cursor.');
  return;
}

The task moves to the agent that still has budget. I find out from a Telegram message rather than by noticing, an hour later, that nothing happened.

The day it actually worked

The real test wasn't a unit test. I sat down with Telegram on my phone and the desktop in front of me, and typed:

"Tell the desktop assistant to check disk usage."

The VPS agent sent a query to the bus — node #172. Within 30 seconds the desktop assistant's polling thread picked it up, acked it, ran df -h, and posted the output back. The VPS agent polled, read the reply, and answered me on Telegram. Then the same round trip ran again in the other direction, with an instruction going to Cursor, Cursor executing it and posting a result, and the disk report landing in parallel.

Four nodes, three agents, one table. I opened /admin/content?type=ai_message and there was the entire conversation, every fingerprint in one view.

One small thing went off-script: the desktop assistant replied with type result instead of the status_report I'd specified. Nobody planned for that. It also didn't matter even slightly — the content was correct, so I accepted it as-is and moved on. I mention it because I nearly went back to enforce the type strictly, and I'm glad I didn't. Some deviations are bugs. Some are just a system being a little loose in a way that costs you nothing.

What I'd take from this if I were you

The bus turned three separate assistants into one coordinated system without adding a single piece of infrastructure. It rode entirely on a content type and OAuth scopes that already existed for something else. And the audit trail — the thing that would have been a whole side project anywhere else — came free with Drupal content.

That's the Drupal-shaped lesson here, and it's the same one from my memory bank article: before you reach for new infrastructure to solve an AI problem, check what your site already does well. Structured content, revisions, an admin UI, access control, a query layer. You paid for all of it. A message bus is just content with a sender, a recipient, and a status field.

But the honest lesson sits in the bug list, not the feature list. The design was right on paper the first time. Every single integration point was wrong — the shape of the API response, which messages deserve a model call, what happens when two autostart mechanisms both think they own a process. None of that was discoverable by thinking harder. I had to run it against production and read what came back.

Which is more or less what I'd tell you about any AI agent work right now. Plan it carefully, then expect the plan to survive contact with reality for about four minutes.

I hope it saves you an afternoon. Happy coding!