Hi, I’m @mattsuu a frontend engineer at Merpay. This is Day 7 of Merpay & Mercoin Tech Openness Month 2026.
EGP Code is an internal editor for building landing pages (LPs) with AI. We covered the background in Why we built EGP Code, an HTML-based AI editor for landing pages; this article digs into four mechanisms that run inside it. I hope it helps when you build AI into your own product.
EGP Code works with a single page: plain HTML mixed with a small set of our own <egp-*> Web Components that handle state and behavior.
<section class="p-6 text-center">
<h1>Spring Campaign</h1>
<p>Now accepting entries</p>
<egp-button>Apply</egp-button>
</section>
You edit this HTML by talking to an AI agent or by editing by hand in the editor, then check it in the preview and publish.
The four mechanisms we are going to talk about are (1) a recursive agent loop, (2) real-time sync via Firestore, (3) a test runner that runs entirely in the browser, and (4) a mapping table linking preview elements to the HTML. All of them revolve around the single HTML file from earlier.
Here is how a single instruction travels all the way to the preview, and where each mechanism fits in.

Mechanism 1: A recursive agent loop that bundles context and tools
A simple instruction aimed at one element (for example, "set the font size to 24px" or "make the text bold") just means locating that element and tweaking its CSS or wording. It usually finishes in a single pass. Bigger jobs are different: instructing several elements at once, building a screen or tests that hit an API, or fixing Lint and test errors takes several round trips between reasoning and running tools.
The agent runs in a recursive loop: reason → run a tool → append the result to the conversation history → reason again. A "tool" here is the definition and description of a function the AI can call when it decides it needs one. We expose operations like rewriting HTML, reading files, validating with Lint, and running tests as tools. The model picks what it needs, calls it, and reads the return value to decide its next move.

Every request carries four pieces of information. The system prompt sets the ground rules: the agent’s role and our code-generation conventions. Alongside it, we send the user’s instruction, the selected element, the current HTML, the conversation so far, and the list of available tools. Of these, we wrap the page state (current HTML, spec, tests) in XML tags, one set per kind of content.
We originally let the OpenAI API hold the conversation history for us. But once we adopted ZDR (Zero Data Retention) company-wide, we could no longer leave conversations on the provider side. Now we keep the full history ourselves and send it with every request, a stateless setup. To keep the history from ballooning, we summarize and compact the context once it grows past a threshold.
// One round of reasoning (excerpt, simplified)
const stream = await client.responses.stream({
...args,
input: sessionBuffer.getItems(), // send the full history we keep ourselves
previous_response_id: undefined,
store: false, // don't let the provider persist the conversation
});
A few parts of the loop are worth calling out.
The first is how we treat tool failures: an apply_patch diff that won’t apply, a Lint error, a failing test, and so on. Rather than halting the loop, we hand the error straight back to the agent as the tool’s result and let it self-correct.
The second is how the find_skill tool doles out information. For docs like internal API guides, we initially show only a list of IDs with one-line summaries and load the full text on demand. When an instruction such as "I want to show a product list" arrives, the agent first scans the list for something relevant, then pulls that doc’s body with find_skill. It reads the doc and calls the API with the correct arguments.

This reasoning-and-tools loop repeats until the agent makes an incremental HTML update with apply_patch, at which point a single instruction has become a finished edit.
Real-time steering: changing direction mid-loop
So far we’ve assumed the agent finishes one instruction before taking the next. In practice, you often want to change course partway through ("actually, make it blue") or add a request ("while you’re at it, fix the footer too"). We provide a way to inject an instruction mid-flight, without waiting for the current one to finish, and fold it into the loop that’s already running. This kind of mechanism is also called real-time steering.
Here is the flow when the user adds an instruction while the agent is still working.

When the user sends a message while the agent is working (the screen is loading), the client sends it as an interrupt rather than a normal instruction. The server writes that interrupt into a Firestore sub-collection, separate from the normal conversation history. It tags the write with the ID of the request currently running. The agent reads only the interrupts whose request ID matches the one it’s processing.
// Read only the interrupts addressed to this request, and delete them once read
const consumeSteeringMessages = async (conversationId, requestId) => {
const snapshot = await steeringRef
.where('requestId', '==', requestId) // limit to interrupts for this request
.orderBy('timestamp', 'asc')
.get();
const messages = snapshot.docs.map((doc) => doc.data());
await deleteDocs(snapshot.docs); // delete once read (so the same instruction can't apply twice)
return messages;
};
Because we filter by ID, we never pick up an interrupt meant for another request. Because we delete on read, the same instruction never applies twice or gets lost.
How we splice the interrupt into the loop depends on what the agent was about to do at that moment. There are two cases.
The first is when the agent was about to call a tool. We don’t run it. Instead of a return value, we hand back a note: the tool was not executed, and a new instruction arrived mid-process.
// Check for interrupts right before running a tool
const steering = await consumeSteeringMessages(conversationId, requestId);
if (steering.length > 0 && toolCalls.length > 0) {
for (const toolCall of toolCalls) {
// Don't run the tool; inject the interrupt in place of its return value
buffer.pushMessage({
role: 'tool',
tool_call_id: toolCall.id,
content: '[CONVERSATION_STEERING] A new instruction arrived mid-process ...',
});
}
continue; // back to reasoning, to rebuild the plan
}
The second is when the agent had finished a reply to the user instead of calling a tool. Normally we’d show that reply and the loop would end, but an interrupt has arrived and there’s no tool to stop. We haven’t shown the reply yet, so we drop it. Then we append the interrupt to the user’s previous instruction and resend it to ask for the follow-up work.
// No tool: drop the not-yet-shown reply and append the instruction
if (steering.length > 0 && toolCalls.length === 0) {
buffer.pop(); // drop the reply we haven't shown yet
const priorUserTurn = buffer.pop(); // take the user's previous instruction
buffer.pushMessage({
role: 'user',
content: `${priorUserTurn.content}\\n[CONVERSATION_STEERING] A new instruction arrived mid-process ...`,
});
continue;
}
In both cases, we don’t roll back side effects that already ran. We only stop a tool that was about to run or drop a reply we haven’t shown yet, then rebuild the plan.
Mechanism 2: Real-time sync using Firestore as the handoff point for instructions
As Mechanism 1 shows, the agent satisfies an instruction by shuttling between several tools, so an edit can take a while. If the screen stays frozen until everything finishes, the user just waits, unsure whether anything happened.
To avoid that, we use the Firestore SDK to receive changes in real time. The server writes out each operation as it completes. The browser subscribes to those writes and picks them up the moment they land. We get live, in-progress updates in the preview without running our own WebSocket.
Whenever the agent changes something, the server appends a document like this to a per-conversation collection.
{
"status": "PENDING",
"requests": [
{
"action": "setHtmlSchema",
"payload": "<body> ...updated HTML... </body>",
"reason": "Reflect the user's request"
}
]
}
The browser-side JS subscribes to the collection with the Firestore SDK (onSnapshot); when a PENDING document arrives, it applies each operation in requests to the editor state. setHtmlSchema, for example, swaps the HTML the editor is showing and re-renders the preview.

Once the browser has applied the requests, its JS writes status back to COMPLETED. "Apply-only" actions like swapping the HTML are fire-and-forget; they don’t wait for anything. Actions that need a result work differently. Take running tests: the server polls the document at a fixed interval until it turns COMPLETED, then reads the result back. That result becomes the tool’s return value, and the agent uses it to choose its next call.
Mechanism 3: A test runner that runs entirely in the browser
Manually checking an LP’s behavior is tedious: the API call when someone taps Apply, the display change based on the result, the navigation a link triggers. So in EGP Code, we make these behaviors testable.
You can write the tests right in the browser editor, or have the AI write them. But Jest and Vitest run on Node.js, so we can’t load them into a browser as-is. We built a small runner of our own that provides test / it / describe / expect. For assertions we lean on the standalone @vitest/expect, and for DOM work we use Testing Library directly.
From the test author’s side, it reads almost exactly like plain Jest or Vitest.
// Define a test with our custom `test` function
test('calls the API when the Apply button is clicked', async () => {
// Reproduce a user click with Testing Library
await userEvent.click(screen.getByText('Apply'));
// Verify the result with @vitest/expect
expect(mockEntry).toHaveBeenCalledWith({ campaign: 'X' });
});
We replace window.fetch inside the iframe and route every network call in a test through a mock. Any unmocked call errors out, so even a forgotten mock never triggers a real request.
Tests run in a dedicated iframe, and the editor page that creates it (the "host") drives the run over postMessage.

The host creates the iframe, injects the test HTML, and waits for the iframe to finish initializing (replacing window.fetch, and so on) before telling it to run. Send the run command too early and the iframe drops it, so the trick is to always wait for "ready."
The results travel back to the agent through the same Firestore action from Mechanism 2. On failure, the self-correction from Mechanism 1 takes over: the agent reads what broke, fixes the code, and runs the tests again.
Mechanism 4: A mapping table linking preview elements to positions in the HTML
So far we’ve looked at agent-driven edits, but sometimes a person edits by hand. Opening the Code tab lets you edit the HTML directly in the Monaco editor and check it live in the preview. To click a preview element and instruct the agent, or to jump to the matching line in Monaco, we need a way to tie a preview element back to its position in the HTML.

We do this by parsing the HTML and injecting a data-egp-src-id (an internal id that never reaches the published page) into every element before rendering the preview.
<section data-egp-src-id="src-10">
<h1 data-egp-src-id="src-11">Spring Campaign</h1>
<egp-button data-egp-src-id="src-42">Apply</egp-button>
</section>
Clicking the "Apply" button in the preview gives us src-42. Since the click may land on a Web Component’s internal element that has no id, we walk up the ancestors with closest to find the nearest data-egp-src-id.
That id is the editor’s internal id for each element, and it serves as the key into the mapping table. The mapping table, built at parse time, is a Map from "id → that element’s location in the HTML"; looking up an id gives you a character offset (the position in the HTML string) and the tag name.
// The mapping table (id → position in the HTML and tag)
Map {
'src-10' => { range: { startOffset: 0, endOffset: 130 }, tagName: 'section' },
'src-42' => { range: { startOffset: 64, endOffset: 110 }, tagName: 'egp-button' },
// ...
}
The element you click is a rendered DOM node, while the position lives in this mapping table. They are two separate worlds, and the data-egp-src-id on the element is the key that ties them together.
We use this mapping table for two things. The first is jumping in the Monaco editor.
// Get the position from the mapping table
const range = await mappingApi.findRangeById('src-42');
// Convert the offset to a Monaco line and column
const pos = model.getPositionAt(range.startOffset);
// Reveal that line in the center of the editor and move the cursor
editor.revealPositionInCenter(pos);
From the id, we pull the character offset out of the mapping table and convert it to a Monaco line and column. Then we scroll that line to the center of the editor and move the cursor there. That’s the jump.
The second is instructing the AI. Here too we start from that id to look up the mapping table, but we never send the id to the agent itself. The agent sees the real, saved HTML, which carries no data-egp-src-id. Instead, we pull the element’s HTML, tag name, and line number from the table and wrap them in XML alongside the user’s instruction.
<annotated_elements>
<annotation>
<tagName>egp-button</tagName>
<snippet><egp-button class="..."></snippet>
<lineNumber>10</lineNumber>
<instruction>Make it red</instruction>
</annotation>
</annotated_elements>
The agent uses these as clues to locate the target element in the HTML and carry out the instruction. Structuring instructions with XML tags like this is one of Anthropic’s prompting best practices.
Conclusion
An AI editor can look simple. But once you build the actual experience, small snags pile up: long waits, no visible progress, no sense of what you just edited. We built the mechanisms in this article one by one to clear those snags away. The more you lean on AI, the more it matters to design the experience and the safety that sit in front of the model. I hope this article serves as a useful reference for anyone weaving AI into their own product.
The next article is by @mikupo. Stay tuned!


