diff --git a/content/docs/utilities/runner.mdx b/content/docs/utilities/runner.mdx
index f0e8043f12..02b0aabd75 100644
--- a/content/docs/utilities/runner.mdx
+++ b/content/docs/utilities/runner.mdx
@@ -94,6 +94,72 @@ packages/runner/
## Configuration
+### Metadata Loading
+
+The Runner picks one of two metadata loaders when it mounts, from the `api` query
+parameter of the page URL (`src/App.tsx`). This is the Runner's only API base URL
+setting — it reads no environment variables and no config file:
+
+| URL | Loader | Where metadata comes from |
+| --- | --- | --- |
+| `http://localhost:5173/` | `LocalBundleLoader` | JSON bundled from `src/app-data/` at build time |
+| `http://localhost:5173/?api=/api` | `NetworkLoader` | `fetch`ed from the base URL you passed |
+
+The branch is `params.get('api')`, so an empty value (`?api=`) is falsy and still
+selects the local loader. Which strategy won is logged to the browser console —
+`📦 Using Local Bundle Loader` or `🔌 Using Network Loader: `.
+
+#### Serving metadata over HTTP (`?api=`)
+
+`NetworkLoader` uses the parameter value verbatim as a base URL and appends fixed
+paths to it, so a backend needs to serve exactly two kinds of JSON document:
+
+| Runner route | Request | Response |
+| --- | --- | --- |
+| _(any, once at startup)_ | `GET /app.json` | the app document (`AppComponentSchema`) |
+| `/` | `GET /pages/index.json` | the page document (`PageNodeSchema`) |
+| `/customers` | `GET /pages/customers.json` | the page document |
+| `/crm/accounts` | `GET /pages/crm/accounts.json` | the page document |
+
+Nested routes map straight through — the request path is `/pages` plus the
+browser path plus `.json`, with `/` rewritten to `/index` first.
+
+- **Relative or absolute base.** `?api=/api` keeps the requests same-origin (pair it
+ with a dev-server proxy or a reverse proxy in front of the built bundle);
+ `?api=https://metadata.example.com/api` goes cross-origin, which needs CORS on the
+ backend. The loader calls `fetch` with no second argument, so it sends no
+ credentials and no custom headers — an endpoint behind cookie or bearer auth will
+ not work as-is.
+- **Failures are silent.** Any non-2xx status, or a network/parse error, is turned
+ into `null`. The Runner then shows `Page not found: ` for a page, and for a
+ failed `app.json` it drops the app chrome (header/sidebar) and renders the page on
+ its own. The HTTP status never reaches the UI — read it from the Network tab.
+- **Read once, at mount.** The parameter is captured in a `useMemo` with an empty
+ dependency list. In-app navigation calls `history.pushState` with the bare path, so
+ the address bar loses `?api=…` after the first click; the loader already created
+ keeps serving that session, but reloading or sharing the resulting URL falls back to
+ the local bundle.
+- `NetworkLoader`'s own default base is `/api` (`constructor(baseUrl: string = '/api')`).
+ That default only applies when the class is constructed directly in code — the
+ Runner always passes it the query-parameter value.
+
+#### Running from bundled JSON (no `api` parameter)
+
+`LocalBundleLoader` resolves metadata from `packages/runner/src/app-data/` through
+Vite's `import.meta.glob` — at build time, not over the network. It globs
+`app-data/app.json`, `app-data/pages/**/*.json` and `app-data/*.json`, and resolves a
+route by trying, in order:
+
+| Runner route | Files tried, in order |
+| --- | --- |
+| `/` | `pages/index.json`, `pages/index/index.json`, `index.json` |
+| `/customers` | `pages/customers.json`, `pages/customers/index.json` |
+
+`src/app-data/` is git-ignored (see `packages/runner/.gitignore`) and is **not** part
+of a fresh checkout — you supply it, by copying or symlinking your own metadata
+directory there. With that directory absent, the three globs compile to `{}`, every
+load returns `null`, and `/` renders the built-in `No index page found.` placeholder.
+
### `vite.config.ts`
The Runner uses Vite for development and building:
diff --git a/packages/runner/README.md b/packages/runner/README.md
index 16ec6996b6..d9be98b96c 100644
--- a/packages/runner/README.md
+++ b/packages/runner/README.md
@@ -57,28 +57,42 @@ The runner comes with these plugins pre-configured:
- **@object-ui/plugin-charts** - Chart visualization components
- **Additional plugins can be added as needed**
-## Schema Loading
+## Metadata Loading
-The runner can load schemas from various sources:
+The runner picks its metadata loader when it mounts, from the `api` query parameter of
+the page URL (`src/App.tsx`). This is its only API base URL setting — it reads no
+environment variables and no config file:
-```typescript
-// From JSON file
-const schema = await import('./my-schema.json');
-
-// From JavaScript/TypeScript
-const schema = {
- type: 'page',
- title: 'My App',
- body: {
- type: 'card',
- content: 'Hello World'
- }
-};
+| URL | Loader | Where metadata comes from |
+| --- | --- | --- |
+| `http://localhost:5173/` | `LocalBundleLoader` | JSON bundled from `src/app-data/` at build time |
+| `http://localhost:5173/?api=/api` | `NetworkLoader` | `fetch`ed from the base URL you passed |
+
+With `?api=`, the value is used verbatim as a base URL and fixed paths are
+appended to it, so a backend only has to serve two kinds of JSON document:
-// From API endpoint
-const schema = await fetch('/api/schema').then(r => r.json());
+```text
+GET /app.json # the app document, loaded once at startup
+GET /pages/index.json # route "/"
+GET /pages/customers.json # route "/customers"
+GET /pages/crm/accounts.json # route "/crm/accounts"
```
+A relative base (`?api=/api`) keeps the requests same-origin; an absolute one needs
+CORS on the backend. `fetch` is called with no options, so no credentials or custom
+headers are sent, and any non-2xx status or network error becomes `null` — which the
+runner renders as a 404 rather than surfacing the status.
+
+Without the parameter, `LocalBundleLoader` resolves `src/app-data/app.json` and
+`src/app-data/pages/**/*.json` through Vite's `import.meta.glob`. That directory is
+git-ignored and absent from a fresh checkout, so every load returns `null` until you
+copy or symlink your own metadata directory into it.
+
+Full details — route resolution order, error handling, and the caveat that in-app
+navigation drops `?api=` from the address bar — are in the
+[Metadata Loading](https://www.objectui.org/docs/utilities/runner#metadata-loading)
+section of the docs.
+
## Configuration
Create a `runner.config.js` file to customize the runner: