Eject your dashboard
Publishing to Vibedasher is the normal end of the loop: that is what keeps a dashboard hosted, governed, and wired to data somebody maintains. Eject is the other door, and it is open on purpose. The dashboard is plain React and SQL, and at any moment you can take the whole tree and run it in your own app, on your own hosting, under your own build.
This is not an export of a picture or a JSON blob you would have to reimplement. It is the
running source: components, pages, charts, stores, hooks, a package.json rebuilt from the
tree's real imports, a Tailwind config, a CSS entry, a tsconfig.json, a Vite config, an
index.html, and a main.tsx that mounts it. npm install && npm run dev serves it as its
own React app.
Eject is the opposite of embedding. Embedding puts our rendered dashboard inside an iframe in your page. Ejecting hands you the source and takes us out of the render path entirely — we keep only the data engine underneath. If the question is "do I own this code", eject is the answer. If you are still choosing between the two, Embed or eject puts them side by side.
The three calls
eject_viz(viz_id, out_dir=...)— the whole tree plus a wiring manifest: the query contract, theme tokens, the entry point and its prop order, the file list, the region, and the data lane.eject_viz_files(viz_id, paths=[...])— file contents on demand. This is the follow-up when a response was paged, and it is also how you read one file cheaply.eject_instructions()— the wiring reference, returned as structured data rather than prose you have to parse. Everything on this page is a reading of it; call it when you want the machine-readable original.
Pass out_dir, and know which transport you are on
A real dashboard is on the order of forty-odd files and half a million characters. With
out_dir set to an absolute path, the tree is written straight to disk and the response
stays small no matter how big the tree is.
Over the stdio transport — the standard launch, where your MCP client spawns the server
as a local subprocess under your own user — that path is your own disk, which is what makes
writing there legitimate. Over the HTTP transport it would be the server's disk, so
out_dir is refused there rather than writing files somewhere you cannot read while
reporting success. Paging is the only route on HTTP.
Without out_dir, files are packed into the response up to a character budget and the rest
come back as delivery.pendingPaths. That list is the contract: a response with a
non-empty pendingPaths is a page, not a tree. Keep calling eject_viz_files with it
until it comes back empty.
Read manifest.dataLane before you write any data wiring
This is the first decision and the one you cannot change afterwards, because it decides who may read the dashboard.
Lane A — dataLane.id == "wasm": it already renders real data
For a viz that is public and published, eject emits a small runner (vibedasherWasm.ts)
and the tree renders real rows on npm run dev with no credential of any kind: no API
key, no embed token, no backend of your own, and it works from any origin including
localhost and a plain static file server.
The mechanism: an anonymous GET for the viz's wasm plan returns one presigned Parquet
extract per dataset; the runner registers each under its SQL alias in DuckDB-WASM and
executes every chart's SQL in the visitor's browser. Its only dependency,
@duckdb/duckdb-wasm, is on npm. main.tsx awaits the runner's warm-up before mounting, so
a broken configuration is one painted panel rather than a grid of blank tiles.
There is nothing to wire here. Do not add a credential on top — it buys you a broken bootstrap in exchange for nothing.
What this lane is not: it serves the published extract, not a live query, so refreshing what it shows means re-publishing. And it exists only while the viz stays public and published.
What each visitor gets, stated plainly. The extract is keyed by dataset and snapshot,
never by a query — the same SELECT with and without a WHERE hands back the identical
file. So it holds every row of each dataset the dashboard reads, and it is one file, the same
one, for everybody. A filter you add in the page changes what is drawn, never what was
delivered, and the bytes are in the network tab either way. That is inherent to putting the
data in the browser. If viewers must see different rows, take lane B.
Lane B — dataLane.id == "token": any other viz
There is no credential-free lane and eject did not emit one. The wasm-plan endpoint is gated
by the same public-and-published check as the rest of the anonymous surface, so pointing at
it by hand does not help. npm run dev still mounts, and paints a panel naming the endpoint
it needs — it does not fail silently and it does not substitute fake data.
Two wirings work, and both put the credential on a server you control:
- Your app serves an embed-token route. Same-origin, answering
application/json{"token": "..."}, minted with the API key it holds server-side. Point the tree's embed-token URL at it and construct the client with the token. - You query in trusted server code with
new Vibedasher({ apiKey })and pass the result down, or proxy{ sql, params }through your own route.
Never put an API key in browser code. It carries full account authority and has no scopes. A scoped embed token is the client-side credential; an API key is not.
These two are also the only shapes that can show one viewer fewer rows than another. An
embed token may carry filters, which the platform appends to every query it authorizes —
server-side, ahead of any join, aggregate or LIMIT, where the page cannot widen it. In
trusted server code you narrow the SQL yourself.
If eject reports lane token with the reason no-wasm-extract on a viz that is public and
published, the dataset simply has no extract yet. Asking for one is the only thing that builds
it — see The wasm query lane.
Re-publishing does not bake an extract, so "publish and eject again" loops forever.
Install the client
npm i @vibedasher/client
pip install vibedasher
The npm package is @vibedasher/client. There is no @vibedasher/sdk and there never
will be. Install the real packages rather than vendoring them.
Styling, typechecking, and the App Router
Styling is three pieces and you need all three. tailwind.config.js (dark mode on a
selector strategy keyed to an attribute the tree puts on its own root, so the dashboard's dark
mode is scoped independently of your app's, plus a safelist for classes assembled at runtime
and therefore invisible to Tailwind's scanner); styles/dashboard.css (the Tailwind entry and
a :root block of design tokens as build-time defaults); and themeStyles.ts (the same
tokens as a string the app injects at runtime, per theme). The CSS gives you a themed first
paint; themeStyles.ts gives you live light/dark switching. If your app already uses Tailwind
with a class-based dark mode, do not merge the configs — one Tailwind config supports one dark
mode setting. On Tailwind v4, reference the shipped config with @config.
Typechecking. npm run typecheck (tsc --noEmit) is expected to report zero errors on a
freshly ejected tree, and two gates hold that on every run of our test suite — one typechecks
a full generated eject, the other installs, typechecks and builds a hand-authored one. Two
conditions bound the claim, and neither is a hedge. It holds under the shipped
tsconfig.json, which sets strict: false on purpose, because the generated chart effects
are written against loosely-typed query rows; turning strict on will produce errors that are
then yours to maintain. And it holds for a tree whose branch files were written recently — a
dashboard published long enough ago carries frozen files from an older shape, two spots of
which are known to fail tsc while building and running correctly, since Vite transpiles
without typechecking. The fix is to re-publish, which rewrites the branch files from the
current shape.
Next.js App Router. Every module is a server component by default. App.tsx and the
stores/ modules already carry 'use client' in the eject — the stores touch
localStorage at module scope and would crash on the server. Marking App.tsx puts its whole
import graph in the client bundle, so nothing else needs the directive unless you import a
chart, component or store directly from a server component.
What eject does not hand you
- Anything in
manifest.internalDescriptors— platform metadata, already excluded fromfiles. Do not copy it. - Anything in
manifest.hostOnlyImports— served from our renderer's own bundle, on no registry, deliberately absent. Replace those before you build. - The data platform. Eject takes us out of the render path, not out of the data path. The connections, datasets, refreshes, dbt projects, governance and query engine underneath stay where they are maintained — that is the half of the split you were not trying to own. The credential-free lane is the one exception, and only because it is reading a published extract rather than querying live.