Engineering Journal
Table Formatter
Table Formatter

We built a handoff whose receiving end was a toast notification

2026-08-21

TLDR: We shipped a cross-tool handoff where the sender resolved real content, the transport delivered it, and the receiver popped a toast saying "Received 9 tags" and dropped them. Nobody noticed for weeks because every layer reported success. A handoff is not done when the data arrives; it is done when the data can be opened.

The assumption that seemed reasonable

We were connecting a document extractor to a table editor. The extractor finds structure in a PDF; the editor is where you fix what the extractor got wrong. The handoff was modelled as a transport problem: serialize the artifact, store it, pass a pointer, deserialize on the far side.

That framing produced a good transport. It has a typed envelope, a content-addressed store, provenance that survives the trip, and a receiver that dispatches on an action verb. All of it works.

The receiver looked like this:

if (action === 'load-tags' || action === 'load-text') {
    const tags = JSON.parse(await bridge.getStore(envelope.pointer)).tags;

const tableTags = tags.filter(t => t.kind === 'table'); if (tableTags.length) loadTablesAsSheets(//);

toast(Received ${tags.length} tag(s)); }

Read it as the author did and it is complete: tables become sheets, everything else is acknowledged. Read it as a user and it says something else. If you send nine equations, the branch that handles tables does not fire, and the only thing that happens is a message telling you nine things arrived. They did arrive. They went nowhere.

When it failed

It failed the first time anyone sent something that was not a table, which was later than it should have been because the tool is a table editor and tables are what we tested with.

The trigger was extending the extractor: we taught it to recognise display maths and reconstruct it into LaTeX, then added an Equations tab to the artifact panel with a button reading "Edit LaTeX in Table IDE". The button worked. It launched the editor, transferred nine equations, and the editor said "Received 9 tag(s)."

That was the whole feature. There was no equations surface in the editor. The button was a promise made by the sender about a capability the receiver did not have.

What was actually wrong

Three things, and only the first is obvious.

The receiver's default branch was silence. Unknown kinds fell through to a success toast. A dispatcher whose default case reports success is worse than one that throws: the throw would have been caught the first time anyone ran it.

The sender decided the destination. The routing table said equations go to the table editor, and nothing checked that claim against what the editor could actually do. The sender is the wrong place to hold that knowledge, because it is knowledge about the receiver.

The toast counted the wrong thing. "Received 9 tags" is a fact about the transport. The user does not care about the transport. The number they need is how many of those nine they can now open, and those numbers were 9 and 0.

That last one is the general failure. Every layer reported on itself, each honestly, and no layer reported on the outcome. This is the same shape as a deploy pipeline where every stage goes green and the site is down.

What got deleted

The pipeline stayed. What went was the idea that acknowledging is receiving.

The receiver now routes equations into a store the tool owns, and the toast reports what became openable rather than what arrived:

const eqTags = tags.filter(t => t.kind === 'equation' || t.artifact?.kind === 'equation');
eqTags.forEach((t, i) => addEquation(name(t, i), t.content?.latex || t.artifact?.latex || '', {
    // The return address. Without it a correction here can be exported but
    // never pushed back to the region it came from.
    origin: originOf(t),
    lineage: t.provenance || null,
}));

toast(Received ${tags.length} tag(s) + (eqTags.length ? , ${eqTags.length} equation(s) opened in the Equation Editor : '')); if (eqTags.length) enableEquationEditor();

And behind it, the thing that was actually missing: an editing surface. A list of the equations in the document, the TeX source, a live preview, the parser's real error message when the TeX will not compile, and a symbol palette. Roughly 600 lines, which is what "the receiving end" had been standing in for.

What replaced it

A rule about when a cross-tool route is allowed to exist:

A route may be offered only when the receiver has a surface that opens what the route sends.

We made that checkable rather than aspirational. A build-time check walks both sides and fails if any action verb has a sender and no receiver. It caught a mistake in this very change: we had written the sender to emit load-equations when the target was not a back-annotation, and no receiver compared against that string. The check refused the build: "sent from artifactsPanel.js, no tool compares against it, so the send silently does nothing." That branch was dead code that would have become a silent failure the day someone added a second equation route.

Notice what the check cannot see: whether the receiver's handler is any good. It only proves a string is compared somewhere. That is a floor, not a ceiling, and the floor is the part that was missing.

The generalizable lesson

When you build an integration, write down what the user can do after the data arrives. Not what arrives, not that it arrived, what they can do.

If the answer is "see a confirmation," you have built a transport and called it a feature. The transport is usually the easy half; it is testable, it has clear success criteria, and it feels like progress. The surface that opens what arrived is the expensive half, and skipping it is invisible precisely because the easy half reports success so convincingly.

Two questions would have caught ours before it shipped:

  1. What screen does the user land on? If nobody can name it, it does not exist.
  2. Does the success message describe the transport or the outcome? Ours counted tags. It should have counted things you can now edit.
Read this post in the full Engineering Journal →