Last Tuesday, an agent moved a file. I didn't ask it to. The log said "move_file". That's it. No context. No reason. No trail. Just a verb and a timestamp. I stared at the screen. What was it thinking? Where did that file go? Why did it touch that path?
That moment changed how I think about AI logs. Logs tell you what happened. They don't tell you why. And in AI products, "why" is the whole product. The user needs to understand the agent's reasoning. The designer needs to see where the reasoning breaks. The developer needs to reproduce the failure. A raw log can't do any of that.
So I built a replay console. It records every agent action, then uses a free model to narrate the sequence like a film. With a free server, the whole thing runs without touching my wallet. MonkeyCode provides both. Its free model access and free server option are enough for this build. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Here's the workflow. You'll build a logger, a narrator, and a simple player. Each step has a verification command. If a step fails, stop and fix it. A replay console on a broken foundation is just a pretty lie.
Step 1: Record every action as a story beat
Start with a plain Node service. The logger appends each tool call as a JSON line. Time, action, input, output. That's your raw footage.
mkdir replay-console
cd replay-console
npm init -y
npm pkg set type=module
npm install express
Create the logger.
// logger.js
import { appendFile } from 'node:fs/promises';
export async function logAction(entry) {
const line = JSON.stringify({ ...entry, at: new Date().toISOString() });
await appendFile('actions.jsonl', line + '\n');
}
Verify it writes a line.
node -e "import('./logger.js').then(m => m.logAction({ action: 'move_file', target: 'tmp/x.txt' })).then(() => console.log('logged'))"
Check the file.
cat actions.jsonl
You should see one JSON object. That's your first frame. Without this step, you have nothing to replay. Log everything. Even the actions that seem trivial. The trivial ones often hide the real bugs.
Step 2: Turn raw footage into a narrative
A log is not a story. It's a list of facts. The model makes it a story. We'll send the last five actions to the free model and ask for a plain-language replay.
// narrate.js
export async function narrate(actions) {
const res = await fetch(`${process.env.MODEL_URL}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.MODEL_KEY}`,
},
body: JSON.stringify({
model: process.env.MODEL_NAME,
messages: [
{ role: 'system', content: 'Describe what the agent did and why, in two sentences. Use plain language. Name the actions explicitly.' },
{ role: 'user', content: JSON.stringify(actions) },
],
temperature: 0,
}),
});
const data = await res.json();
return data.choices[0].message.content;
}
Verify with a sample.
MODEL_URL=... MODEL_KEY=... MODEL_NAME=... \
node -e "import('./narrate.js').then(m => m.narrate([{action:'read_file',path:'a.txt'},{action:'move_file',target:'b.txt'}])).then(console.log)"
You should get a sentence like "The agent read a.txt, then moved it to b.txt." If you get a hallucination, lower the temperature or add more context to the system prompt. The model needs to see the action names to narrate them honestly.
Step 3: Build the replay player
Now we show it. A simple page that loads the last actions and plays them one by one. This is where accessibility matters. Your users may not see the screen. They need to hear the replay.
<!-- public/index.html -->
<main>
<h1>Agent replay</h1>
<div id="stage" aria-live="polite"></div>
<button id="play" type="button">Play</button>
<script type="module" src="app.js"></script>
</main>
The aria-live region announces each beat to screen readers. Without it, the replay is silent for blind users. That's not a nice-to-have. It's the core experience.
// public/app.js
const res = await fetch('/replay');
const data = await res.json();
let index = 0;
document.querySelector('#play').addEventListener('click', () => {
if (index < data.length) {
const beat = data[index];
document.querySelector('#stage').textContent = `${beat.at} — ${beat.narration}`;
index++;
}
});
Server side:
// server.js
import express from 'express';
import { readFile } from 'node:fs/promises';
import { narrate } from './narrate.js';
const app = express();
app.use(express.static('public'));
app.get('/replay', async (req, res) => {
const lines = (await readFile('actions.jsonl', 'utf8')).trim().split('\n');
const actions = lines.slice(-5).map(JSON.parse);
const narration = await narrate(actions);
const beats = actions.map((a, i) => ({ ...a, narration: narration.split('. ')[i] || narration }));
res.json(beats);
});
app.listen(process.env.PORT || 3000);
Verify locally.
curl -s localhost:3000/replay
You should see JSON with a narration field per action. If the narration is empty, your model call failed. Check the env vars.
Step 4: Deploy to the free server
MonkeyCode's free server option means you can deploy this without paying for infrastructure. Push the folder, set the three env vars, and your console gets a public URL. Cold starts are real. The health check tells you when the server is actually warm.
curl -s https://your-app-url/replay
If you get a timeout, wait a few seconds and retry. The free server sleeps when idle. That's the trade-off for free hosting. Your replay console is for debugging, not for production traffic.
Step 5: Test the replay against reality
Here's the hard part. The narration is a model's guess. It can be wrong. So we add a check: the narration must reference the actual action names. If it doesn't, we flag it.
// verify.js
export function verifyNarration(narration, action) {
return narration.includes(action.action);
}
If the model says "moved a file" but the action was "delete", the replay is lying. Show a warning in the console.
node -e "import('./verify.js').then(m => console.log(m.verifyNarration('moved a file', {action:'delete'})))"
Prints false. Good. Now wire it into the server.
app.get('/replay', async (req, res) => {
const lines = (await readFile('actions.jsonl', 'utf8')).trim().split('\n');
const actions = lines.slice(-5).map(JSON.parse);
const narration = await narrate(actions);
const beats = actions.map((a, i) => {
const text = narration.split('. ')[i] || narration;
return { ...a, narration: text, verified: verifyNarration(text, a) };
});
res.json(beats);
});
Now the console shows a red flag when the model drifts from the facts. That's your stop condition. If the replay can't tell the truth, you can't trust the agent.
What this pattern can't do
The replay is only as good as the log. If you don't log context, the model invents it. The free model can misread intent. The free server can cold-start slowly. The token allowance is generous but not infinite.
So keep the human in the loop. This console doesn't replace judgment. It replaces guesswork. The evidence either reaches the human, or the action doesn't fire.
Who should not use this? Anyone needing forensic-grade audit trails. This is for design research and debugging, not compliance. Use it to understand your agent's behavior. Then build the real controls with your risk team. This is a fire drill, not a fire code.
The log tells you what. The replay tells you why. Your users deserve both. Build the replay console. Run it against your agent's worst moment. That's the test that matters.
If you build this, share the weirdest replay you find. That's where your product's real problems live.
Top comments (0)