Your agent should not be retyping tool output

Gmail Triage · No. 135

Shipped

gmailtriage v0.7.0 sorts a Gmail inbox under rules the user wrote, and this tag carries two versions’ worth of work on the same lesson. Across eight real runs, the sorting decisions were consistently fine; nearly all the cost and all the data damage lived in the plumbing around them, and the worst piece was the agent retyping search_threads responses into its own snapshot JSON. The release replaces that with an ingest contract: every tool response is written to a file byte-for-byte, and a deterministic normalizer turns those files into the snapshots every other command reads. This guide builds that contract, including the three refusals that make it hold.

Where transcription creeps in

An agent pipeline usually has a model in the middle: a tool returns JSON, the model reads it, and something downstream needs a file. The path of least resistance is letting the model write that file “based on” the response, and it feels harmless because the model just saw the data. In this skill it cost sixty to ninety seconds of JSON authoring per run, and it was the main way a field got dropped or mangled on the way in. One run re-edited its snapshot by hand three times as rules were added mid-run. A language model is a lossy channel; asking one to copy structured data is asking for a subtly different document with the same shape.

The fix is old advice applied to agents. Alexis King’s Parse, don’t validate argues that parsing should happen once, at the boundary: “Get your data into the most precise representation you need as quickly as you can.” For an agent skill, the boundary is the moment a tool responds, and the precise representation is a snapshot your scripts define. The agent’s only job at that boundary is to save the evidence: write the raw response to a file exactly as it arrived, then hand the paths to code. Reshaping is not judgment, so no model belongs in it.

The normalizer owns the reshaping

The ingest step reads the raw files and produces the snapshot. The first function accepts one raw search response and pulls out the per-thread facts:

// ingest.mjs
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);

// One raw search_threads response, written to a file verbatim, into
// normalized threads. {} is tolerated because it is real: an empty
// fetch returns it.
export function normalizeSearchThreads(raw, what = 'search_threads output') {
  if (!isObj(raw)) {
    throw new Error(`${what}: expected the raw response object, written verbatim; got ${Array.isArray(raw) ? 'an array' : typeof raw}`);
  }
  const threads = raw.threads ?? [];
  if (!Array.isArray(threads)) throw new Error(`${what}: "threads" is not an array`);
  const out = [];
  for (const t of threads) {
    if (!isObj(t) || !t.id) throw new Error(`${what}: a thread without an id`);
    const msgs = Array.isArray(t.messages) ? t.messages : [];
    const first = msgs[0] ?? {};
    out.push({
      id: t.id,
      from: first.sender ?? null,
      subject: first.subject ?? null,
      date: first.date ?? null,
      labelIds: [...new Set(msgs.flatMap((m) => m?.labelIds ?? []))],
    });
  }
  return out;
}

Domain rules live here as code, not as model behavior: the first message supplies sender and subject because Gmail orders a thread oldest-first and the first message is the one whose subject the sender chose, and labelIds is the union across messages because a thread counts as filed if any message in it is. When the same mailbox is fetched several ways, the sources overlap, and merging them is also a rule, not a judgment call. This and the rest of the functions here live in the same ingest.mjs:

// Union the fetches, deduped by thread id. Overlap is the normal
// path, not an edge case. Label ids union on collision; the first
// source to name a sender, subject or date wins.
export function mergeThreadSources(...sources) {
  const byId = new Map();
  for (const list of sources) {
    for (const t of list ?? []) {
      const prev = byId.get(t.id);
      if (!prev) { byId.set(t.id, { ...t }); continue; }
      prev.labelIds = [...new Set([...(prev.labelIds ?? []), ...(t.labelIds ?? [])])];
      prev.from ??= t.from;
      prev.subject ??= t.subject;
      prev.date ??= t.date;
    }
  }
  return [...byId.values()];
}

Build the output as an allowlist

Raw Gmail responses carry a snippet beside every subject, and on a real mailbox those snippets have held live verification codes. The snapshot must never contain one, and the way to make that structural rather than aspirational is to build every output object field by field:

// The whole snapshot schema. Nothing else is ever written to disk.
export const SNAPSHOT_FIELDS = ['id', 'from', 'subject', 'date', 'labelIds', 'category', 'hasUnsubscribe'];

export function toSnapshot(threads, promoIds = [], updateIds = []) {
  const promos = new Set(promoIds);
  const updates = new Set(updateIds);
  return threads.map((t) => {
    const category = promos.has(t.id) ? 'promotions' : updates.has(t.id) ? 'updates' : null;
    return {
      id: t.id,
      from: t.from,
      subject: t.subject,
      date: t.date,
      labelIds: t.labelIds ?? [],
      category,
      hasUnsubscribe: category !== null,
    };
  });
}

There is no spread of the input object anywhere in that function. A new field appearing upstream, sensitive or not, cannot leak into the snapshot, because nothing copies fields it did not name. That is the difference between “we filter out the snippet” and “the snippet has no path to disk”.

Refuse degraded input by name

The Gmail API can return messages in reduced formats; the official Format reference documents metadata as returning “only email message ID, labels, and email headers”, and tool layers expose the same idea as a metadata-only view that strips subjects. That view is exactly right for a fetch that exists only for its thread ids, and exactly wrong for the main fetch. A run that fetched the inbox metadata-only produced subject-less ghost threads that a later audit reported as unclaimed mail, which read as a broken mailbox instead of a broken fetch. The ingest step now refuses that input by name, before anything is written:

// Threads that arrived without a sender or subject. The likely cause
// is a metadata-only fetch of a view that needed the full one, and
// refusing here turns an hour of downstream confusion into one re-fetch.
export const validateIngest = (threads) =>
  threads.filter((t) => !t.from || !t.subject).map((t) => ({
    id: t.id,
    missing: [!t.from ? 'from' : null, !t.subject ? 'subject' : null].filter(Boolean),
  }));

A named refusal at the boundary is worth more than a stack trace three commands later; King’s essay calls the alternative shotgun parsing, where checks are smeared across the processing code and a bad input is half-consumed before anything notices.

Run the pieces against a saved raw file to verify your own version:

// demo.mjs
import { readFileSync, writeFileSync } from 'node:fs';
import { normalizeSearchThreads, mergeThreadSources, toSnapshot, validateIngest } from './ingest.mjs';

const raw = JSON.parse(readFileSync(process.argv[2], 'utf8'));
const threads = mergeThreadSources(normalizeSearchThreads(raw));
const problems = validateIngest(threads);
if (problems.length) {
  console.error('refusing: threads missing fields', JSON.stringify(problems));
  process.exit(1);
}
writeFileSync('threads.json', JSON.stringify(toSnapshot(threads), null, 2));
console.log(`${threads.length} thread(s) ingested`);

Fed a verbatim two-thread response, the run prints:

2 thread(s) ingested

Fed a metadata-only response with the subject stripped, it refuses with the evidence and a non-zero exit:

refusing: threads missing fields [{"id":"t9","missing":["from","subject"]}]

Both behaviors are the feature, and grep -c snippet threads.json coming back zero is the allowlist doing its job.

When state changes mid-run, re-fetch

The contract has one tempting escape hatch. Midway through a run the mailbox changes, say a label gets created, and the raw labels file on disk is now stale. Editing that file by hand is thirty seconds, and it quietly ends the whole guarantee: the file stops being evidence and becomes another transcription. The v0.7.0 answer is a narrow flag, ingest --labels-only, that rebuilds just the labels snapshot from one fresh verbatim fetch without asking for the four thread files back. Making the honest path cheap is what actually protects the contract; the rule against hand-edits only holds if a re-fetch is one command.

The same release also taught the audit command to reconcile rule destinations against the real label list. The trigger was a live run that collided with a mid-run folder deletion: a rule kept filing into a folder that no longer existed, and the audit called the system clean because no one had ever asked it that question. Snapshots let you answer questions cheaply, but only the questions you remember to ask of them.

Gotchas

  • Undo state does not belong in a session workspace. Every mutating run writes a receipt so it can be reversed. Three real runs steered their receipts into a per-session scratch directory, which was destroyed with the session, and those runs are now permanently un-undoable. Receipts default to ~/.gmailtriage/receipts/ and the undo command finds the newest itself. The XDG Base Directory spec names undo history explicitly as state that “should persist between (application) restarts”; put it under the user’s home, never under the run’s.
  • A mailbox snapshot inside a git checkout is one git add from public. One run wrote its snapshot into the project checkout, and the cleanup that removed it deleted the run’s receipt along with it. The CLI now refuses to write data files anywhere inside a git working tree, with an explicit override flag for the rare case that is genuinely wanted. If your pipeline handles personal data, make the safe location the enforced one, not the documented one.
  • A coherence check that can never pass is a check people stop running. Mail the user sent to themselves and never filed was counted as “mail no rule claims”, so a perfectly sorted mailbox still audited non-zero, forever. The fix reclassifies self-sent mail and counts it separately, and the test corpus now proves the clean state is reachable with sent-only threads present. When you add a gate, freeze a fixture that passes it; a gate with no reachable green state trains everyone to ignore red.
  • Re-planning against a stale snapshot un-converges the run. After moves are applied, the on-disk snapshot still describes the old mailbox, so the next plan proposes the same moves again. One run hand-edited the snapshot three times to chase this. The apply step now replays exactly the authorized moves onto the snapshot file, so a re-plan converges without another fetch and without a human touching a data file.

Sources

Changelog

  • gmailtriage 0.7.0 — audit catches dangling rules, rules --remove, labels-only ingest (#229) (5abd709)
  • gmailtriage 0.6.0 — ingest, durable receipts, repo guard, sent-only fix, output diet (#224) (5fed1ce)