- Book: AI That Ships
- The series: AI in TypeScript — 5 books, from your first LLM call to agents in production — all five here
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
An AI incident does not look like a normal one. There is no stack trace, no
failing health check. There is a customer saying the agent told them something
false, or an audit log showing forty refunds in an hour that should have been
four.
The investigation then depends entirely on what you happened to be logging
before it happened. Nothing is reproducible — same input, different run, so
the trace is the evidence, and if it was not captured it does not exist.
The record has to be enough to reconstruct the decision
Per run, one row:
export type RunRecord = {
runId: string;
userId: string;
startedAt: string;
finishedAt: string;
// what produced this output
model: string;
promptVersion: string;
toolsetVersion: string;
temperature: number;
// what the agent saw
input: string;
retrievedDocIds: string[]; // ids, not bodies
contextTokens: number;
// what it did
toolCalls: { name: string; argsHash: string; ok: boolean; ms: number }[];
turns: number;
outcome: Outcome;
costUsd: number;
// who allowed it
approvals: { by: string; at: string; verdict: string }[];
capabilities: string[];
};
The four version fields are what turn "quality dropped last Tuesday" from an
argument into a query. retrievedDocIds is what lets you ask whether the
agent was reading a poisoned document — the single most common cause of an
agent behaving strangely for one tenant and fine for everyone else.
capabilities answers "how was it even able to do that", which is usually the
second question after "what did it do".
Store the full trace separately, with a shorter life
The record above is small enough to keep for a year. The full transcript is
not, and it is also the part containing customer data.
await traceStore.put(runId, {
messages: redact(messages),
toolResults: toolResults.map((r) => ({ ...r, body: truncate(r.body, 4_000) })),
}, { ttlDays: 30 });
Thirty days covers essentially every investigation. Longer, and you are
holding customer conversations you have no product reason to hold.
Redaction at write time, not at read time:
const PATTERNS = [
[/\b[\w.+-]+@[\w-]+\.[\w.]+\b/g, "[email]"],
[/\b(?:\d[ -]*?){13,19}\b/g, "[card]"],
[/\b(sk|pk|ghp|xox[baprs])[-_][A-Za-z0-9]{16,}\b/g, "[secret]"],
] as const;
Imperfect, and still far better than the alternative. Anything genuinely
sensitive should be referenced by id and never enter the transcript in the
first place.
One id, threaded everywhere
res.setHeader("X-Run-Id", runId);
logger.info("agent", { runId, ... });
await db.refund.create({ data: { ..., runId } });
await mailer.send({ ..., headers: { "X-Run-Id": runId } });
The reason to put it on the effect — the refund row, the sent email — is
that incidents start from the effect. Someone found a wrong refund. Without a
runId column on that table, connecting it to a run is a timestamp-matching
exercise across two systems.
The first ten minutes
Contain before you diagnose. The flag from your rollout should still
exist:
await flags.update("agent-v1", { mode: "suggest" });
One rung down puts a human in front of the output without taking the feature
away. If the effect is irreversible and ongoing, go to off.
Find the blast radius.
SELECT run_id, user_id, outcome, cost_usd
FROM run_records
WHERE started_at > now() - interval '6 hours'
AND tool_calls @> '[{"name": "refund_order"}]'
ORDER BY started_at;
Answering "how many" before "why" is what lets you tell support something true
in the first half hour.
Freeze the evidence. Traces have a TTL, and an investigation that runs
past it loses its own material:
await traceStore.hold(runIds, { until: addDays(new Date(), 180) });
Do this early. It is the step most often remembered on day 29.
Reproduce from the checkpoint, not from the input
Re-running the input gives you a different run and tells you nothing.
Replaying the stored state is the closest thing to a reproduction available:
const history = [];
for await (const s of graph.getStateHistory({ configurable: { thread_id: runId } })) {
history.push(s);
}
const before = history.find((s) => s.next.includes("refund_order"));
await graph.invoke(null, before.config); // with side effects disabled
With side effects disabled — a dry-run tool wrapper, because otherwise
investigating a wrong refund issues more wrong refunds.
Fork with one value changed to test a hypothesis. That is how you distinguish
"the model misread correct data" from "the data in state was already wrong",
and those have completely different fixes.
The three causes worth checking first
Injected instructions. Pull the retrieved documents for the run and read
them. Content written by the person who benefits from the agent's action is
the classic case, and retrievedDocIds is what makes it a two-minute check.
A silent upstream change. Compare model and promptVersion against runs
from before the incident window. Provider-side changes land without a deploy.
A capability that was too wide. Look at capabilities and ask whether the
action should have been possible at all. Frequently the honest finding is that
the model behaved reasonably given tools it should never have had.
Write-ups that are worth writing
The action items that hold up are not "improve the prompt". Prompts are not a
control — they influence behaviour, they do not constrain it.
The ones that hold up look like:
- narrowed the capability so the action is no longer possible unapproved - added an amount ceiling enforced in the tool, not requested in the prompt - added the failing case to the golden set so a regression is caught nightly - added the missing field to the run record so the next investigation is faster
That last one is the compounding item. Every incident reveals a field you
wished you had; adding it is how the next investigation takes an hour instead
of a day.
If this was useful
AI That Ships covers running AI
features in production — trace and audit design, redaction and retention,
containment, replay-based investigation, and the follow-ups that actually
prevent recurrence.
The full series is at
xgabriel.com/ai-in-typescript.



Top comments (0)