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

note

Eject is the opposite of [embedding](/api/embedding-the-share-iframe.md). 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](/guides/embed-or-eject.md)** puts them side by side.

## The three calls[​](#the-three-calls "Direct link to 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[​](#pass-out_dir-and-know-which-transport-you-are-on "Direct link to 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[​](#read-manifestdatalane-before-you-write-any-data-wiring "Direct link to read-manifestdatalane-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[​](#lane-a--datalaneid--wasm-it-already-renders-real-data "Direct link to lane-a--datalaneid--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[​](#lane-b--datalaneid--token-any-other-viz "Direct link to lane-b--datalaneid--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**:

1. **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.
2. **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](/guides/the-wasm-query-lane.md#you-have-to-ask-for-the-extract-before-it-exists). Re-publishing does not bake an extract, so "publish and eject again" loops forever.

## Install the client[​](#install-the-client "Direct link to 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-typechecking-and-the-app-router "Direct link to 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[​](#what-eject-does-not-hand-you "Direct link to What eject does not hand you")

* **Anything in `manifest.internalDescriptors`** — platform metadata, already excluded from `files`. 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.
