Skip to main content

The wasm query lane

Most queries run on our side: you send SQL, we authorize it, execute it, and hand back typed rows. The wasm lane inverts that. You ask for a plan instead of rows — presigned Parquet, one file per dataset, plus the SQL to run over it — and the query executes in the visitor's browser through DuckDB-WASM.

This is the lane behind a dashboard that needs no credential at all: no API key, no embed token, no server of your own. It is also the lane with the sharpest trade-off on the whole platform, and the trade-off is not hidden anywhere — it is the same fact as "no secret to leak", read from the other side.

Asking for it​

One argument on the query call selects the lane.

from vibedasher import Vibedasher

client = Vibedasher(api_key="...")

result = client.query(sql="SELECT region AS dimension, SUM(price) AS value FROM sales GROUP BY 1", type="wasm")

if result.mode == "wasm":
run_locally(result.plan) # plan.sql + plan.sources
else:
print(result.rows, result.fallback_reason)

Over MCP it is the same choice, spelled run_query(sql, type='wasm').

type is "backend" (the default) or "wasm". Everything above the lane choice is unchanged: authorization, alias-only validation and filter injection all still happen server-side, before the capability check. Asking for wasm skips no gate. A dataset the key cannot reach is rejected before any extract is materialized or any URL is signed.

Branch on mode, never on what you asked for​

Asking for wasm does not guarantee getting it. If any participating dataset is not wasm-capable, the query executes server-side exactly as usual — and says so rather than failing.

FieldMeaning
modeWhat actually ran: "backend" or "wasm". This is the field to branch on.
fallbackReasonSet only when type="wasm" was asked for and refused, naming why.
planSet only when mode == "wasm".
columns / rowsEmpty when mode == "wasm" — nothing was computed.

The table gives the wire spellings. The Python SDK exposes them snake_case (result.fallback_reason), the TypeScript client camelCase, and MCP tool parameters are snake_case throughout — so type is type everywhere, but fallbackReason is fallback_reason in Python.

A silent fallback would be the worst possible behaviour here: your code would receive rows, believe it was on the credential-free lane, and ship a build that needs a credential it does not have. So the fallback is never silent, and the reason is always named.

What a plan contains​

{
"mode": "wasm",
"plan": {
"sql": "SELECT region AS dimension, SUM(price) AS value FROM sales GROUP BY 1",
"sources": [
{ "alias": "sales", "datasetId": 12, "url": "https://...", "bytes": 4194304 }
]
}
}
  • plan.sql is the injected SQL — filters applied, parameters already escaped literals. It is not your raw SQL, and that is the point: both lanes compute identical results, so a dashboard cannot mean one thing served from our engine and another thing served from the browser.
  • sources[].alias is the dataset's cleanSQLName — exactly the table name plan.sql references, and the name the Parquet file must be registered under locally. The file already carries clean column names.
  • sources[].url is presigned and read-only. It is fetchable from any origin, and so is the plan endpoint itself.

Executing a plan is: fetch each url, register it in DuckDB-WASM as a view named alias, run plan.sql. An ejected dashboard on this lane ships a runner that does exactly that and nothing else; its only dependency is @duckdb/duckdb-wasm, which is on npm.

You have to ask for the extract before it exists​

This is the one sequencing rule, and skipping it produces a loop that never terminates.

Asking for type="wasm" is the only thing that builds a dataset's wasm extract. The extract is materialized lazily, by that ask — or by the refresh path after a dataset becomes ready — and nowhere else.

So if eject reports dataLane.id == "token" with the reason no-wasm-extract, the fix is to run one query per named dataset with type="wasm" — any trivial SELECT against the alias will do — and then eject again. A returned plan means the extract now exists; for this purpose you can ignore the plan's contents entirely.

Publishing does not bake an extract. "Re-publish and re-eject" will loop forever.

The anonymous lane, and why it is not in the API reference​

The lane above needs an API key, because it is you building a dashboard. What a visitor's browser calls is a different, unauthenticated endpoint: the viz's wasm plan, at

GET /v1/public/viz/{account_slug}/{viz_slug}/wasm-plan

It is deliberately absent from the generated API reference and from both SDKs, and that absence is a design decision rather than an omission. The route is anonymous and browser-facing; there is no API-key call for it, so it has no place in an API-key SDK. It is excluded at the framework level, which is why you will not find it by browsing the reference — the only description of it is this page and the runner that calls it.

Its guards are identical to the anonymous cache route beside it: the account must exist and allow public dashboards, and the viz must be public and published. A private or protected dashboard answers 404 here. Nothing about it is a client-side default you can override.

It returns { sources: [...], unavailable: [...] }. A dataset that is not wasm-capable is reported in unavailable with a reason rather than dropped from sources silently — because a client that received a short sources list and no explanation would render a partial dashboard and call it done.

Why a protected dashboard can never be served this way​

A protected dashboard's rows are scoped per viewer by rewriting the SQL server-side. A presigned Parquet file is the whole table. There is no way to honour a row predicate that the client could simply decline to run — so the lane is not "not yet implemented" for protected dashboards; it is structurally excluded, and it always will be.

What you are trading away​

State it before you build, because it is the half you cannot change afterwards.

The extract is keyed by dataset and snapshot, not by your query. The same SELECT with and without a WHERE hands back the identical file. Your predicate does not shrink it. Consequences, all of them the same consequence:

  • It holds every row of each dataset the dashboard reads.
  • It is one file, the same file, for everybody who opens the page.
  • The page issues no server query, so nothing in the path could give one viewer fewer rows than another.
  • A filter applied in the browser changes what is drawn, never what was delivered — and the bytes are in the network tab either way.
  • It serves the published snapshot, not a live query. Refreshing what it shows means re-publishing.

In exchange: no key to leak, no token to refresh, no backend of your own to run or pay for, no rate limit in front of the page, and a dashboard that keeps rendering from a static host.

Also worth knowing: the row cap does not apply here. The server-side lane stops at 100,000 rows and sets truncated, which makes every total computed from the prefix silently wrong. The wasm lane's Parquet is the whole dataset per snapshot, precisely because it is keyed by dataset rather than by your result — so it is also the honest answer to a genuinely large result set, not only to a credential problem.

When to pick something else​

You needLane
rows you will read and reason aboutrun_query / client.query — the default backend lane
a lot of rows on diskrun_query_to_file(output_path=...)
a public dashboard with no credential and no backendwasm
different viewers to see different rowsa scoped embed token carrying filters, or trusted server code that narrows the SQL
live data rather than a published snapshotthe backend lane, behind a server you control

A scoped-token client refuses type="wasm" outright rather than quietly running server-side: a caller who asked for wasm and got rows anyway would have no way to notice. That refusal is the same principle as fallbackReason — a lane change must never be invisible.

For the two wirings that can scope a viewer, see Row-level filtering.