# 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.

- Author: Chad Priest
- Published: 2026-08-30
- Canonical URL: https://blog.vodou.ai/agent-results-delivered-vs-noticed/
- Tags: ai-agents, observability, architecture, mcp, browser-extension

---

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.

**Diagram: Delivered is a hop. Noticed is the return trip.**

A skill result travels from the gateway over the bridge to the extension badge, then to the panel, and a seen event travels back

```text
  [Gateway] --> [Bridge frame] --> [Extension] --panel opens--> [Side panel (fixed)] --skill_result_seen--> [seen record (fixed)] --> [Gateway]
  [Bridge frame]
  [Extension]
  [Side panel (fixed)]
  [seen record (fixed)]

  notes:
    Gateway: skill finishes, not a dry run
    Bridge frame: skill_result
    Extension: rolling ten, badge lit
    Side panel: newest three above search
    seen record: first occurrence only

  The gateway now knows two facts per result: that it left, and that a person looked at it.
```

"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.

**Diagram (timeline)**

Patches landed in the wrong extension tree over two days before the store pack was verified to contain them

```text
  08-17  ->  page memory box rendered, lastQuery committed before fetch (problem)
  08-18  ->  fill button dead: bound to a tab that no longer answers (problem)
  08-18  ->  merge selector overwrote label and input on four rows (problem)
  08-19  ->  11e patched into vodou-bridge; store packs from Store-vodou-bridge (problem)
  08-19  ->  ported by anchor, zip verified, packed as 0.5.97.76 (fixed)
  08-21  ->  empty page card reads as young, not broken (fixed)
```

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.

```sql
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:

```bash
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](https://hld.handbook.academy/curriculum/ai-ml-system-design/ai-agent-architectures/) 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](https://cdn.openai.com/business-guides-and-resources/a-practical-guide-to-building-agents.pdf) is the same: orchestration and guardrails, with the result assumed to reach the requester. The [arxiv production-workflow guide](https://arxiv.org/html/2512.08769) 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](https://agentsurface.dev/) 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.

---

Source: [A skill that finishes into a tab nobody has open did not finish](https://blog.vodou.ai/agent-results-delivered-vs-noticed/) by Chad Priest, from Building Vodou in Public.
