Skip to content

The Bridge: JSON Over Patch Cables

Running Inside Ableton Live covers why Producer Pal fuses a Node.js server and Live's full API into one Max for Live device. This page is the deeper, more technical look at how they actually talk to each other: two separate JavaScript runtimes sending JSON over Max patch cables.

It's a fun corner of the project, with a couple of real problems that had to be solved along the way.

Two runtimes in one device

Inside the Producer Pal device there are two JavaScript objects wired together in the Max patch:

  • node.script runs mcp-server.mjs — the full Node.js MCP server. This is where the AI connects, where npm packages and the network live, but it has no access to the Live API.
  • v8 runs live-api-adapter.js — a JavaScript engine with direct access to the Live API, but no Node.js, no npm, and no network.

Neither one can call the other directly. They live in separate runtimes. The only thing connecting them is the Max patch itself: patch cables between the two objects, carrying Max messages back and forth. So Producer Pal's bridge is built out of exactly that — every request and response is a Max message sent down a cable from one runtime to the other.

A Max message is just a list of "atoms" (symbols and numbers). To move a rich tool call across that wire, Producer Pal serializes everything to JSON strings and ships them as atoms.

Here's the actual top-level patch, with the bridge wiring right out in the open:

The main Producer Pal Max patch, showing the node.script and v8 objects wired together

You can pick out node.script ./mcp-server.mjs and v8 ./live-api-adapter.js near the center, with patch cables running between them. Most of the other boxes are plumbing for the device itself: the many s ---… (send) and r ---… (receive) objects are named wireless connections that let this main tab talk to the device's other tabs — Context and Setup — and to the server status display (the p node-status subpatcher shown as a bpatcher). Sends and receives keep that cross-tab messaging tidy without dragging cables all over the patch; the bridge proper is just the node.scriptv8 pair.

A round trip

When the AI calls a tool, here's the path the data takes:

  1. Node → V8 (request). The MCP server emits a Max message:

    mcp_request  <requestId>  <toolName>  <argsJSON>  <contextJSON>

    The requestId is a UUID used to match the eventual response back to the waiting promise. The patch routes this message to the v8 object's mcp_request() handler.

  2. V8 does the work. It parses the JSON, runs the tool against the Live API (launching clips, writing notes, reading tracks — whatever was asked), and builds a result object.

  3. V8 → Node (response). It serializes the result back to JSON and sends it home as an mcp_response message, which the server matches to the original requestId and hands back to the AI.

Conceptually simple. Two things make it harder than it looks.

Problem 1: messages have a maximum length

A single Max message atom can't be arbitrarily long — there's a hard ceiling around 32,767 characters. Producer Pal's responses routinely blow past that: reading a busy track, or returning a clip full of notes, can produce hundreds of kilobytes of JSON. Send that as one atom and Max silently truncates it, corrupting the message.

The fix is chunking. Before sending, the V8 side splits the JSON string into pieces small enough to survive the wire:

MAX_CHUNK_SIZE = 30000   // ~30 KB per chunk, comfortably under the 32,767 limit
MAX_CHUNKS     = 100     // up to ~3 MB per response

planChunks() slices the JSON left-to-right into 30 KB chunks and sends them as multiple atoms in one message. The receiver glues them back together with a plain join(""). This relies on one guarantee Max gives us: the atoms of a single message arrive in the order they were sent, so no per-chunk sequence numbers are needed.

If a response somehow needs more than 100 chunks (~3 MB), Producer Pal refuses to send a corrupt blob — it replaces the payload with a clear "response too large" error instead. (You can find the chunking logic in mcp-response-utils.ts.)

Problem 2: capturing warnings from the V8 side

The second problem is subtler. While a tool runs, the V8 code may want to warn the AI about something — "quantize parameter ignored for audio clip," for example. Producer Pal uses warn-and-skip rather than hard failures, so these warnings need to reach the AI as part of the response.

But there's a catch: a runtime's log and error output doesn't travel down patch cables. When the v8 object prints to the Max console, that text goes to the Max window — it's not part of any message coming out of the object. So we can't just rely on console output; the warnings have to be deliberately routed onto an outlet and then stitched into the response message before it crosses back to Node.

Producer Pal does this in two halves — the V8 code and the Max patch cooperate:

On the V8 side, the object has two outlets:

  • Outlet 0 carries the actual result: the chunked JSON, terminated by a special delimiter string.
  • Outlet 1 carries warnings. console.warn() is wired to emit each warning string out of outlet 1 (see v8-max-console.ts).

In the Max patch, a small subpatcher named route-results-and-warnings recombines those two separate streams into a single message:

The route-results-and-warnings subpatcher: inlet 1 and inlet 2 feeding a t l b zlclear, a list.group 1000, and a list.join

Inlet 1 receives the result (the chunked JSON and its delimiter); inlet 2 receives warnings. The list.group 1000 object buffers warnings as they arrive, and when the result comes in, t l b zlclear triggers list.join to append the buffered warnings onto the end of the result list (then clears the buffer for the next request). The merged message is what finally crosses back to Node.

The key to keeping the two halves apart is a demarking symbol that V8 places between the JSON chunks and the warnings:

$$___MAX_ERRORS___$$

So the full response message that arrives back at the Node server looks like this:

mcp_response  <requestId>  <chunk1> <chunk2> … <chunkN>  $$___MAX_ERRORS___$$  <warning1> <warning2> …
└─ message ─┘ └─ id ─────┘ └──── JSON, split at 30 KB ───┘ └─── delimiter ───┘ └──── captured warnings ────┘

The Node side splits on that delimiter:

  • Everything before it is JSON chunks → reassemble and JSON.parse().
  • Everything after it is captured warnings → each one is appended to the response as a WARNING: text block.

That last step is what makes warn-and-skip real, actionable feedback: the warnings the V8 code emitted while talking to the Live API end up as text the AI actually reads in the tool result, not messages lost in the Max console. The delimiter also doubles as an integrity check — if it's missing, the receiver throws loudly instead of trying to parse a malformed message.

Why it's built this way

Two separate JavaScript runtimes, JSON marshalled into Max atoms, chunked to dodge a length limit, with a side channel for warnings merged back in by the patch — it's more machinery than a single process would need. But it's exactly this machinery that lets one Max for Live device offer both a modern Node.js server and complete, real-time Live control at once. Solving the bridge once, properly, is what keeps everything above it simple: you drop in one device and the whole thing just works.

Released under the GPL-3.0 License.