Does a widget save you from the token blowup? Yes, but only if you build it to.
A widget does not automatically save context. It gives you three separate channels, and you decide what goes down each one.
Hand 100,000 rows to the wrong channel and it smashes your window exactly like it does today. The widget changes nothing on its own.
A lead row with name, title, company, domain, email and score runs roughly 30 to 50 tokens.
| Approach | What the model ingests | Verdict |
|---|---|---|
| Dump all 100,000 rows | ~3,000,000 β 5,000,000 tokens | Blows a 1M window three to five times over |
| Widget built carelessly | identical | Same rows, same channel, same blowup |
| Widget built correctly | ~50 tokens | One summary line. The rows never enter the conversation. |
A tool result carries more than one field, and they go to different places.
content
Text the model reads. This is the only field that costs you context.
Keep it tiny. "1,247 leads matched, top score 94."
structuredContent
The spec says it is "not added to model context." The widget reads it to draw itself.
Put your rows here, not in content.
Use the third one for anything that matters. Hosts have been inconsistent about structuredContent. Claude Code has shipped a bug where content got dropped when both were present, and some hosts forward the structured field to the model anyway.
visibility: ["app"] is normative in the spec, not a convention. The host must keep those tools out of the model's tool list. That's the guarantee you can build on.
You never move 100,000 rows anywhere at once. Not to the model, not to the widget.
Your server already has the 100,000 rows in DuckDB. The widget is just a window onto them. It pulls fifty at a time, and the model is not in that loop.
// The tool the MODEL can call. Its job is to say almost nothing. registerAppTool(server, "lead_queue", { description: "Open the lead queue", _meta: { ui: { resourceUri: "ui://leads/app.html" } }, }, async ({ market }) => ({ // β the model. ~50 tokens. This is your whole context bill. content: [{ type: "text", text: `1,247 leads in ${market}. Queue is open below.` }], // β the widget only. Never counted against context. structuredContent: { total: 1247, page: 1, rows: firstFiftyRows }, })); // The tool the WIDGET calls. The model cannot see or call it. registerAppTool(server, "lead_page", { visibility: ["app"], // β the load-bearing line inputSchema: { page: z.number(), sort: z.string() }, }, async ({ page, sort }) => ({ structuredContent: { rows: await db.page(page, sort) }, }));
Eventually you pick twelve leads and want Claude to write outreach for them. Two methods send exactly those twelve back, and nothing else.
ui/update-model-context β sets standing context the model sees on later turns. The host may overwrite the previous one, so treat it as "here is the current selection," not an append log.ui/message β drops content straight into the conversation now, as if it were said. Use it to hand over the selection and trigger a reply.Twelve rows is maybe 500 tokens. You looked at 100,000 and paid for twelve.
The mental model: the model is a coordinator that never touches the bulk data. It opens the widget, and later reads back a small answer. Everything in between happens between the widget and your server, on a wire the model isn't on.
Judged by how many rows you would otherwise push through the model.
| Job | Worth a widget? |
|---|---|
| Triaging a 100k lead list | Strongly. This is the case the mechanism exists for. |
| TAM review, approve/reject by row | Yes. Every click is free after the first. |
| Campaign preflight over 2,000 rows | Yes. Show red cells, send back only the broken ones. |
| "How many leads in Boston?" | No. One number, one turn. A widget adds work and saves nothing. |