The problem
I needed an LLM to control a runtime that only exists in the user's browser: a target embedded in a sandboxed iframe. Users describe what they want in plain language, an agent figures out the steps, and the target updates live.
One problem. The agent runs on my server. The target lives in the browser. The server literally cannot touch it. No DOM access, no connection into the iframe's world. The thing the agent needs to control is a black box on the other side of an iframe boundary.
The naive approach and why it fails
The standard AI SDK pattern is: declare tools with schemas, the model calls them, the server executes them. That works when your tools touch databases, APIs, files. It collapses the moment your "tool" must act on a client-side runtime that only the browser can reach.
Option A: execute on the server, mutate some shared state, have the client poll for changes. Polling a live runtime for parameter changes is gross.
Option B: give up and pre-build every interaction as a button. Boring, and it doesn't scale.
Option C: the split-brain pattern. This is the one I went with.
The split-brain pattern
Split the brain: declare the tools on the server (so the model knows their names, schemas and descriptions), but execute them in the browser (where the actual targets live), then feed the results back into the conversation.
The model never knows the difference. As far as it's concerned, setParameter is a perfectly normal tool that returns { ok: true }.
Server side, the tools are declared with schemas but deliberately have no execute:
// Server side: the model sees this and knows how to call it.
setParameter: {
description:
'Set a single parameter of the active target, e.g. speed, mode, or a view position.',
inputSchema: z.object({
param: z.string().describe('Parameter name, matching the active target schema (e.g. `speed`).'),
value: z.union([
z.number(),
z.boolean(),
z.string(),
z.array(z.number()).describe('A [x, y, z] number array for view position / look_at.'),
]),
}),
},
Client side, the same tool is intercepted in onToolCall, executed against the iframe, and the result is pushed back into the conversation:
// Browser side: the tool actually executes here.
onToolCall: async ({ toolCall }) => {
if (toolCall.toolName === 'setParameter') {
const { param, value } = toolCall.input;
const ok = applyParameter(param, value); // postMessage into the target iframe
addToolOutput({
tool: 'setParameter',
toolCallId: toolCall.toolCallId,
output: ok
? { ok: true, param, value }
: { ok: false, error: `Parameter "${param}" not found in the active target schema.` },
});
}
},
The trick that makes it seamless: addToolOutput returns the result to the model exactly as if the server had executed it. The model is none the wiser.
The "Proceed." loop
There's a subtlety. When the model emits tool calls, the SDK wants to pause and wait for results. But my tool calls execute instantly in the browser, and I want the agent to keep going until its whole task is done: pick a target, read its schema, set three parameters, confirm in plain language.
The AI SDK has sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls, which re-triggers a send when the last message is a completed tool-call round. But there's a catch: the SDK expects the last message to be a user message before sending. So I inject a synthetic user turn:
prepareSendMessagesRequest: (options) => {
const msgs = options.messages ?? [];
const last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
const needsUserTurn =
last &&
last.role === 'user' &&
Array.isArray(last.parts) &&
last.parts.some((p) => p.type === 'tool-result');
const finalMessages = needsUserTurn
? [
...msgs,
{
id: `user-proceed-${Date.now()}`,
role: 'user',
parts: [{ type: 'text', text: 'Proceed.' }],
},
]
: msgs;
return { ...options, body: { ...options.body, messages: finalMessages } };
},
When the last turn ended in tool results, a whisper-quiet "Proceed." is appended, and the model continues: inspect results, call more tools, or wrap up with a summary. The user never sees it. The agent loops until it's genuinely done.
The wire protocol and the ready handshake
Now the boring but critical part: the iframe communication. The parent and every target speak one wire format over postMessage:
- Parent to target:
{ type: 'apply', payload }and{ type: 'reset' } - Target to parent:
{ type: 'ready' }and{ type: 'state', payload }
And one race condition that will bite you: the target's script may not have executed when the agent applies the first parameter. Messages posted into a frame whose listeners aren't registered yet are silently dropped. The fix is a tiny queue:
const postToTarget = useCallback((message: object) => {
const frame = iframeRef.current;
if (!frame || !frame.contentWindow || !frame.src || frame.src === 'about:blank') return false;
if (!iframeReadyRef.current) {
pendingMessagesRef.current.push(message); // not ready yet, queue it
return true;
}
frame.contentWindow.postMessage({ source: PARENT_SOURCE, ...message }, origin);
return true;
}, []);
On the target side, a shared bridge script buffers anything that arrives before init finishes, then flushes it the moment it signals ready:
function handleEvent(event) {
var msg = event && event.data;
if (!msg || typeof msg !== 'object' || msg.source !== PARENT_SOURCE) return;
if (!ready) { buffer.push(msg); return; } // buffer until init completes
dispatch(msg);
}
ready: function () {
// ... flip any stale status badge, then:
ready = true;
var buffered = buffer.splice(0, buffer.length);
for (var i = 0; i < buffered.length; i++) dispatch(buffered[i]);
window.parent.postMessage({ source: TARGET_SOURCE, type: 'ready' }, window.location.origin);
}
If the agent sets a parameter before the target finished loading, it lands in the buffer and applies the moment the target is ready. No dropped tool calls, no "did my request even go through?"
The schema is the contract
Every target ships a JSON manifest that doubles as its config, its parameter schema, and its documentation. Units included:
{
"id": "preset-01",
"type": "apply",
"data": {
"settings": {
"speed": { "type": "number", "unit": "m/s", "value": 8.0 },
"mode": { "type": "string", "value": "auto" }
},
"display": {
"show_vectors": { "type": "boolean", "value": true }
},
"view": {
"position": { "type": "array", "length": 3, "unit": "world units", "value": [0, 12, 12] }
}
}
}
The model is told to call getParameters before touching anything, because the real parameter name is spd, not speed, and values carry units. The tool reads the schema, returns typed values, and the model derives missing values from first principles rather than guessing.
This is the part that makes the whole thing feel magical in practice: adding a new target is just dropping a file and its schema JSON in a folder. No app changes, no protocol changes, no per-target code. The schema is the contract, and both the model and the target read from the same source of truth.
What I'd tell someone building this
Never let the model guess parameter names. A
getParameterstool that returns the exact schema costs one tool call and saves an entire conversation of "the parameterfoowas not found."The
{ value }wrapper is the enemy of every boundary. Whether a value arrives wrapped or plain depends on which layer produced it. Normalize once at the sender and defend once at the receiver, then forget about it. Both sides of my protocol unwrap defensively so targets never need their own logic.Rate limits on free tiers are real, and the SDK retry is useless against them. Gemini free tier returns 429 with a
RetryInfoblock telling you exactly when to retry. The AI SDK's default retry is immediate, which is pointless against a 40-second cooldown. Wrapfetch, parse the retry delay, and actually sleep:
fetch: async (input, init) => {
const maxAttempts = 3;
for (let attempt = 0; ; attempt++) {
const res = await fetch(input, init);
if (res.status !== 429 || attempt >= maxAttempts - 1) return res;
const body = await res.text().catch(() => '');
let delayMs = 25000;
try {
const j = JSON.parse(body);
const retryInfo = j?.error?.details?.find(
(d) => d['@type'] === 'type.googleapis.com/google.rpc.RetryInfo'
);
const seconds = parseFloat(retryInfo?.retryDelay?.replace(/[^0-9.]/g, '')) || 0;
if (seconds > 0) delayMs = Math.min(Math.ceil(seconds * 1000) + 2000, 45000);
} catch {}
await new Promise((r) => setTimeout(r, delayMs));
}
},
- The tool call loop will eat your tokens if you let it. Make the model confirm what changed in one short plain-language line after acting. It reads as a status update to the user and stops the model from narrating endlessly.
Why this pattern is bigger than iframe targets
The split-brain pattern applies anywhere your agent needs to control something the server can't reach: a browser extension, a canvas-based renderer, a local device, a sandboxed iframe, a user's own machine. Declare the tools server-side, execute them where the real target lives, feed the results back. The model never needs to know the difference.
It's the difference between an agent that talks about your product and an agent that drives it.
Top comments (0)