A skill that finishes into a tab nobody has open did not finish
Console-mode skills completed into a workbench nobody was looking at. How results got routed to the surface the user is on, and the sibling-tree trap that cost two days.
Your agent runs a job. The job succeeds. The row in your job table says completed. And the person who asked for it is on a different tab, in a different app, and never finds out. Every framework I have read treats “the tool returned” as the end of the arc. It is not the end. The end is when a human sees the result, and almost nobody records that.
I had this bug for months without naming it. Skills fired from the browser side panel ran in console mode and wrote their output into a workbench tab in the web console. The workbench is a fine place for output. It is also a page nobody keeps open. So the demo worked, the logs said “done”, and the actual experience was: press a button, nothing visibly happens, assume it broke.
The last hop: a skill_result frame, a badge, and a seen record
The fix is not clever, which is the point. When a skill finishes a real run, the gateway pushes a skill_result frame over the WebSocket bridge to the extension. The extension keeps a rolling ten of these and lights the toolbar badge. The side panel renders the newest three above the search box. When the panel opens, it marks them seen, clears the badge, and relays a skill_result_seen event back to the gateway, which records it once per result, first occurrence only.
Dry runs are excluded on purpose. A rehearsal must not ring the bell, or the badge stops meaning anything within a day.
“Delivered” and “noticed” are separate columns because they answer different questions. Delivered tells you the transport worked. Noticed tells you the product worked. I had years of the first and zero of the second, and the gap between them is exactly the gap between a demo landing and a user keeping the thing installed.
The panel work in the public tree is in extension/Store-vodou-bridge/sidepanel.js and background.js; the gateway side is MCP-servers/Vodou-Console/src/vbb/bridge.ts and src/index.ts. The engine gained one thing, a search result that carries a provenance label computed once in core, so the panel stops guessing where a memory came from.
Two days of patches landed in the tree the store never packs
Here is the part that earns the post. All of the extension work from the two preceding steps, the readiness probe, the demo prefill, and then this badge and briefing code, was patched into extension/vodou-bridge. The Chrome Web Store zip is built from extension/Store-vodou-bridge. That is a sibling tree, 50% larger, carrying a separate line of work. Nothing in the build complained. The pack script packed exactly what it was told to pack, which was a tree without any of my changes in it.
I have a memory entry titled “three extension builds diverged” that exists for precisely this failure, and I still hit it live. The fix was to port every patch to the store tree by anchor, confirm both trees carry identical capability code, and then verify the packed zip actually contains the strings, not just that the source directory does.
The smaller bugs along the way were all the same shape: the surface failed silently. The page-memory box cached its query key before the fetch, so one failed request against a gateway still booting silenced the box for that page forever. Every later refresh saw the key unchanged and returned early. An empty panel is indistinguishable from “nothing matched”. The fix was to commit the key only on success, in both lanes.
The “Fill ticked fields” button looked dead because the card was bound to a tab id that stopped answering after an extension reload. Now it tries the card’s tab, then the active tab if it is the same site, and puts the exact reason on the status line: nothing ticked, that tab did not answer, switch back to the page. A button that fails must say why, or the user reads it as broken.
And the merge step that folds the model’s answers into the fill card used div > div:last-child as its selector. That matched the row’s whole body and overwrote the label and input, so four rows read only “you answered here”. It also re-applied unchanged rows and announced “4 more just arrived” for rows that had not changed. Explicit class hooks, and count only rows whose answer actually differs.
The property: every terminal event has a record on the surface the user fired it from
Stated so you can check it against a codebase, not as advice. For every event type that ends a job, there exists a delivery record whose target is the surface the request originated on, and a distinct seen record written by that surface. If the seen record does not exist as a separate write, the system cannot distinguish “it worked” from “it worked and someone was there”.
The second property, from the tree trap: the artifact you ship is produced from the tree you patched, and the check is on the artifact, not the directory. A green build from the wrong tree is still green.
Five minutes: join finished jobs to your delivery table and count the orphans
Nothing here needs my stack. Assume a jobs table with finished_at and a deliveries table with job_id, surface, delivered_at, seen_at. If you have no deliveries table, that is the finding, and you can stop reading.
SELECT
COUNT(*) AS finished,
SUM(d.job_id IS NULL) AS never_delivered,
SUM(d.job_id IS NOT NULL AND d.seen_at IS NULL) AS delivered_not_seen,
SUM(d.seen_at IS NOT NULL) AS seen
FROM jobs j
LEFT JOIN deliveries d ON d.job_id = j.id
WHERE j.finished_at > datetime('now', '-7 days')
AND j.dry_run = 0;
Passing looks like finished roughly equal to seen plus a small delivered_not_seen tail that shrinks over hours. Failing looks like what mine did: never_delivered equal to finished, or seen at zero with no seen_at column to even query. Then group by surface and compare it to where requests originate; if every delivery targets a surface no request ever comes from, you have my workbench tab.
For the artifact check, take the identifier you just added and look for it inside the thing you ship, not the source directory:
unzip -p dist/extension.zip sidepanel.js | grep -c 'skill_result'
grep -c 'skill_result' src/sidepanel.js
The two numbers should match. Mine were 0 and 11. For the silent cache key, grep for a dedupe key assigned above an await in the same function; every hit is a fetch that one failure can silence permanently.
The guides end the arc at the tool return
The HLD handbook’s agent architecture chapter defines the design space as a reasoning pattern, a tool interface, a memory architecture and a control strategy with a step budget and human-in-the-loop. That is right and complete for the loop. It has no term for what happens after the loop exits. OpenAI’s practical guide is the same: orchestration and guardrails, with the result assumed to reach the requester. The arxiv production-workflow guide argues for pure-function tool invocation over MCP, which I agree with, and a pure function’s return value goes to its caller, which in an agent system is a program, not a person. Agent Surface catalogues how agents read context, call tools and ask for approval; the return direction, agent to human, is thin there too.
None of them are wrong. They stop one hop early. The oversight layer everyone draws is about the human seeing the plan before it runs. The hop I was missing is the human seeing the result after it ran, and that needs its own event, its own record and its own surface.
Still open: page identity fills forward, so a new user sees an empty card
The page-memory box shows what you already know about the page you are on. Provenance, not similarity: the database recorded where a memory was made. But that identity only accrues while the lane is on, and it cannot be backfilled. In my own store, 135 of 47,777 chunks carry a source URL, almost all from test hosts. So a new user grants a privacy-sensitive permission and is shown an empty card. The last fix in this set changed the empty copy so the card reads as young rather than broken, because the old copy discouraged the exact grant that would fill it. The card is still empty on day one. The copy is honest about it now, and that is all it is.