# Studio

A development server with a web UI: inspect requests, explore endpoints, call them from a playground, and run mock servers.

## Overview

Courier Studio is a development server with a web UI: a request inspector, an endpoint explorer, an API playground, mock servers, and more. You run it next to your app, and your app connects to it — the server side through `attachRuntime()`, the browser side through one of the build integrations below.

In the inspector, a call's answer and its variables show as a tree or as text. **Find** searches their keys and values: the tree keeps what matches and what holds it, opened, with the text marked; the raw text marks its lines; Enter and Shift+Enter move from one match to the next. A body larger than 16 KB shows in part, each part left out named where it was, and **Find** searches the whole of it, which the app or the mock keeps: what the parts left out hold shows too, the raw text's lines numbered as in the whole body, and the line under the field says how much it found — or, once the whole is no longer kept, that the search covers the part shown, and why. Each row of the tree copies the path code reaches it by, such as `data.cities[41].name`, and the headers' filter marks what it finds.

A request that got no answer shows why, as the platform said it. Its **Error** tab puts the code in its badge — a system's, such as `ENOTFOUND`, `ECONNREFUSED` or `DEPTH_ZERO_SELF_SIGNED_CERT`, Bun's `ConnectionRefused`, or Courier's own `TIMEOUT` for a request whose `timeout` ran out — and the text under the message, each said once: no badge repeats the error's kind, and no line what the message already says. The raw error carries both as `cause`, the **Overview** lists them as its **Cause**, and the **Response** tab — the Playground's too — says there was no answer, with the code and the text, rather than that the server answered with an empty body. The suggested fix follows the code: a host name that did not resolve, a connection that failed, a certificate that was not trusted, an answer that broke as it arrived, an API that sent no headers in time, redirects that never reach an answer; a code it has no advice of its own for gets the general one and is named in it. A browser's fetch gives no code and says only that it failed — `Failed to fetch` in Chrome, `Load failed` in Safari, `NetworkError when attempting to fetch resource.` in Firefox — and a request refused by CORS fails the same way, so the fix says what else it can mean. A call made through the proxy shows the cause the server met, as the server's side of the call reports it; the browser is told no more than before. A request a middleware failed because a connection of its own was refused, as a `fetch` for a token can be, shows the code and the text the system gave it too.

A stream whose connection cannot be made says so, rather than standing open and live: its **Messages** tab and its **Overview** say it is reconnecting, why its last connection ended — the platform's code and text, the status a server refused it with, a server that fell silent, or how a socket closed — how many connections in a row failed and when the next one opens, and its handshake reads *never opened*. The Timeline and the other lists say *retrying* — and, while its first connection is being opened, show it waiting as a request in flight — and the [editor's hints](/docs/studio/editor) count the streams reconnecting apart from the ones open. A stream that gives up before any connection opened says so, with the code and the fix it usually needs. A browser's WebSocket, and Node's own, tell no more than the close code `1006`: why the socket failed is theirs to keep, where a socket of the `ws` package gives the system's code and text. A stream or a channel the proxy relays tells how its upstream connection stands. One served in process opens no connection and stands open, as does every stream of an app on an older Courier. An ended stream says who closed it — its server, its caller, its lifetime, a server that shut down — and, closed while it was reconnecting, why its last connection ended.
The Playground starts an endpoint with variables its schema takes, read from the schema itself — Zod, Valibot, or any schema with a `toJsonSchema()` such as ArkType's: a number at zero or at the bound nearest it, a string of its least length, an enum's or a literal's first value, the first option of a union its schema takes, a sample of a known string format — an email, a URL, a UUID, a date and a time, an IP address — and a nested object filled in the same way, three levels down. A field whose schema checks more than Studio reads — a pattern, a refinement, a `Date`, which JSON does not hold — is left out when it may be, and starts as the nearest value of its kind when it is required. A variable the endpoint's `.defaults()` fills in starts at its default.

A call whose variables the input schema refused says so where its answer would be: its **Response** tab — the Playground's too — says it was not sent and what the schema said of the first field it refused, and its **Request** tab marks each one. The Playground's status reads *variables refused*, the line under the variables keeps the refusal until they change, and the toast says the input schema refused the call rather than that a request failed.

## Install

> **In one command**
>
> `npx @noverax/create-courier` sets Studio up with the rest: both packages, the integration for your framework, a mock API to work against, and a `studio` script to start it with. See [Create Courier](/docs/create-courier).

By hand:

```bash
npm install --save-dev @noverax/courier-studio
npm install @noverax/courier-studio-core
```

Studio needs Node.js 22.13 or newer. Studio is a development tool, so it is a dev dependency. `@noverax/courier-studio-core` is what your server instrumentation imports `attachRuntime()` from, so it is a dependency: a production install leaves dev dependencies out, and a server that imports the runtime does not start without it — even one that calls it only in development. `attachRuntime()` runs in production too, where it registers the config's sources. An app that only connects its browser through a build integration can leave it out.

## Start Studio

```bash
npx @noverax/courier-studio
```

Studio listens on `http://localhost:6280` — at both `127.0.0.1` and `::1`, so it answers whichever one a client dials — or on the `studio.host` and `studio.port` of your `courier.config.ts`. Server runtimes and the build plugins read the same block, so moving Studio there moves them with it: a server runtime reads the nearest config at or above its working directory, and a build plugin the nearest at or above Vite's root — or, for webpack, esbuild and Rollup, the working directory. Flags: `--port`, `--host`, `--cwd` (project root), `--dotenv` (the `.env` files to read, instead of the project's [development set](/docs/studio/project-loading#loading)), `--open` (open a browser), `--version`. Studio writes the [routes module](/docs/proxy-mode/expose) a page's proxy client takes each time it loads the config; [`courier-cli routes`](/docs/proxy-mode/expose#review-it-as-a-permission) writes it without Studio, and checks it in CI. Its commands run without the server: `courier-studio openapi` writes the sources as [OpenAPI documents](/docs/advanced/openapi), `courier-studio verify` checks [recorded calls](/docs/testing/recorded-tests) against the definition, `courier-studio diff` reports what a change to the definition [breaks for its callers](/docs/testing/breaking-changes), and `courier-studio mcp` serves Studio to [an editor's agent](/docs/agents/studio). Studio keeps its working files — imported API state, copies of the files it replaced or removed, saved sessions — in `.courier/`, which ignores itself in git.

What Studio shows is kept in `.courier/studio.db`, a SQLite database built into Node, with nothing to install or set up. The requests it captured are there, the latest 2000 of them, so a page that reloads, or a Studio that restarts, shows them again. A request still open when Studio stopped is shown as ended, since no runtime can say any more how it ended. The saved sessions are there too, and which one a page loaded: a page that reloads shows that session again, recording paused, until you go back to the live requests with the play button or **Back to live requests** in the command palette. A session shared as a [session file](/docs/studio/session-files) opens in a colleague's Studio the same way. So are the requests each mock answered, the latest 500, which the Mocks view's log shows again, and the Playground's history of calls. So is what you set, and what you leave unfinished: the settings; the requests you pin, which the log keeps as it lets its oldest go; the Playground's drafts; Get started's progress; a mock wizard or an API import left midway, which opens again where it was; and the network conditions and the scenario set on each mock, which it has again when it starts again — unless the mocks file now declares other network conditions, the newer word. All of it is kept for the project, so another browser shows it too; a page opened without the token sees it but keeps its own changes to itself. The browser keeps only the page's token, and a copy of the settings so the page opens in its theme before Studio answers. Studio creates the file readable and writable by its owner only.

**Settings → Storage** shows the room each part of `.courier/` takes — the database on disk and the data in it, the captured requests, the mocks' logs, the Playground's history, the saved sessions, the mocks' stores, and the backups of the files imports replaced — and clears any of them, or all at once, after asking. A mock that runs resets its store to its seeds; the backups an undo still needs stay. The database gives the room its deleted rows took back to the disk as it goes, and is compacted after each clear, so it does not keep growing as the logs let their oldest requests go. Once it grows past the size set there, 500 MB to start with, a line under the header says so. To start empty, clear everything there — or delete the file with Studio stopped.

No credential is written there. The value of `Authorization`, `Proxy-Authorization`, `Cookie`, `Set-Cookie`, and of any header whose name has a word such as `auth`, `token`, `secret`, `password`, `session`, `csrf` or `api-key` in it, is kept as `[redacted]` in the request log, the mocks' logs, saved sessions and the Playground's history, and so in a HAR or Postman export of a session. The live view, which nothing writes down, shows the values whole to a page opened from Studio's link, and redacted to any other; a request replayed after a reload carries `[redacted]` instead.

The texts that could hold one are kept the same way. The page opened from Studio's link — the one that holds its token — shows each as your app was told it: an error's text and its cause, a stream's and a middleware's, what the Playground was answered, a request's address. What leaves that page has the credentials in them cut: what Studio writes in `.courier/studio.db`, a saved session and what it exports — a HAR, a Postman collection, a [session file](/docs/studio/session-files) — what [an agent](/docs/agents/studio) is told, and what a page opened without the token is shown. Cut are an address's user and password, its secret parameters and a path segment that reads as a token; a `user:password@` before a host; a header value the platform quoted; a JWT or a provider's key, such as `ghp_…`, `sk_live_…`, `AKIA…`, `xox…`, `npm_…` or `SG.…`; a credential after `Bearer` or `Basic`; and the value of a pair named as a token, a key, a secret or a password. A word is cut only when it reads as a credential, so `Token expired` and `tokens: 3 of 5 used` stay as they are; a `user:password` with no host after it and no word of signing in around it is not recognized. Only the start of a long text is read, so a text of any size costs the same. Your app cuts nothing and carries no code for it: it tells Studio what it was told, and Studio cuts what leaves.

A body larger than 16 KB, sent or answered, is kept and shown as a part of it: the first items of a list, the small fields of an object and a part of each large one, the start of a text, each part left out named where it was, as `"… 45,800 more items"`, beside the size of the whole. **Download whole** in the request's Response or Request tab saves the whole body as a file, from the app or the mock that holds it: each keeps the whole bodies of its latest requests, up to 64 MB, a page in the browser up to 16 MB. A replay sends the whole variables, never the part, and is refused once they are gone; a snippet of a request whose body is too large to write out sends it from the file **Download whole** saves. A saved session keeps the parts, and its HAR and Postman exports say how large each whole was. A page that connects is sent each log's newest requests, each whole, as many as fit 16 MB.

When the database cannot be kept there — `.courier` is a link, the disk is read-only, a newer Studio made it — Studio keeps its data in memory for that run, and says so. A file there that is not a database is moved aside to `studio.db.broken-<time>`, and a new one is started.

Studio loads `courier.config.ts` from the project root, with the `courier.mocks.ts` beside it, and loads them again whenever either one, or a module either imports, is saved: see [How Studio loads the project](/docs/studio/project-loading).

A project that has no API sources yet starts with this `courier.config.ts` at its root — the APIs page offers it to copy while there is no config, and keeps **Import API** off until one loads:

```ts
import { defineConfig } from "@noverax/courier/config";
export default defineConfig({ sources: {} });
```

**Import API** then writes the project's first API into its output directory — `src/api/<source name>/` by default — where `source.ts` exports the source under its source name. Add it to `sources` under that name, and the APIs page lists it as imported:

```ts
import { defineConfig } from "@noverax/courier/config";
import { petstore } from "./src/api/petstore/source";

export default defineConfig({ sources: { petstore } });
```

Studio makes changes only for a page opened from the link it prints in its terminal, `http://localhost:6280/#token=…`. The token is the project's: Studio keeps it in `.courier/studio-token`, readable by your account alone, and prints the same one each time it starts, so a page you opened once keeps working; the page takes the token out of its address bar and keeps it for its next visits. A page or process without it still sees everything Studio shows, the credentials of live traffic redacted, but Studio refuses its every change: writing the project's files, driving the mocks, sending from the Playground, saving, loading or deleting sessions, and clearing the log. Nor does Studio read past what it shows for it. It opens no file such a page names and shows it none of the project's code — where an endpoint is called or declared, the files of an import in a preview of a change, a line of `courier.config.ts` — fetches a specification from no host on this machine or its network, only pasted or from the public internet, checks no import for updates, and runs none of the project's formatters on code it generates for it. Every other path under `/api` asks for the token too, so even one Studio does not have is answered 401, and such a page may send Studio's socket only what reads: a request's or a mock's body, a mock's records. Its errors and live messages name the project's files relative to it, never by their path on this machine. Studio answers only its own page and the apps running on this machine, so on the default `localhost` nothing else can reach it; bound to the network (`--host 0.0.0.0`), anyone on that network can see what it shows, and a change still needs the token. Its pages refuse to be shown in a frame, so another site cannot put Studio under a page of its own. The Playground runs a call through your application's server, which has the same token: its runtime reads `.courier/studio-token` beside `courier.config` each time it connects to Studio. Studio sends Playground calls, and the headers typed into them, only to a runtime that presented it; any other process that connects as a runtime has its traffic shown in the Timeline and is sent no call, and the Playground says why.

The build integrations connect your app to Studio; they do not start it.

## Connect the browser

Every integration connects only a development build, as the bundler tells one: the dev server of Vite, Astro or Nuxt, `next dev`, webpack's `mode: "development"`, an esbuild build that neither minifies nor defines `process.env.NODE_ENV` as `"production"`, and Rollup's watch mode (`rollup -c -w`). A production build — `vite build`, `webpack --mode production` or webpack with no `mode`, a minified esbuild build, a one-off `rollup -c` — is left untouched, whether or not `NODE_ENV` is set, and `NODE_ENV=production` rules out any build. Pass `{ enabled: true }` or `{ enabled: false }` to decide yourself.

| Import path | For | Register |
|---|---|---|
| `@noverax/courier-studio/vite` | Vite, and the frameworks built on it | `plugins: [courierStudio()]` |
| `@noverax/courier-studio/react`, `/vue`, `/sveltekit`, `/remix`, `/solid` | React, Vue, SvelteKit, React Router (Remix), SolidStart — the Vite plugin under a framework name | `plugins: [courierStudio()]` in `vite.config.ts` |
| `@noverax/courier-studio/astro` | Astro | `integrations: [courierStudio()]` |
| `@noverax/courier-studio/nuxt` | Nuxt, with any of its builders | `modules: [courierStudioModule]` |
| `@noverax/courier-studio/next` | Next.js under webpack | `withCourierStudio()(nextConfig)`, imported in `next dev` alone — see [Next.js](/docs/frameworks/nextjs) |
| `@noverax/courier-studio/webpack` | webpack | `plugins: [courierStudio()]` |
| `@noverax/courier-studio/esbuild` | esbuild | `plugins: [courierStudio()]` |
| `@noverax/courier-studio/rollup` | Rollup | `plugins: [courierStudio()]` |
| `@noverax/courier-studio/angular` | Angular | `setupCourierStudio()` in `main.ts` |

```ts
// vite.config.ts
import { courierStudio } from "@noverax/courier-studio/vite"
import { defineConfig } from "vite"

export default defineConfig({
  plugins: [courierStudio()],
})
```

### Next.js

Next.js 16 builds with Turbopack, which runs no webpack plugins. Connect the browser from `instrumentation-client.ts` (Next.js 15.3 and later, under either bundler):

```ts
// instrumentation-client.ts
import { attachRuntime } from "@noverax/courier-studio-core/runtime"

if (process.env.NODE_ENV === "development") {
  attachRuntime("next-browser")
}
```

A browser bundle cannot read `courier.config.ts`, so when Studio is not at the default address, pass it through `env: { COURIER_STUDIO_URL: "http://localhost:6290" }` in `next.config.ts`.

Under webpack — Next.js 14 and 15 by default, `next dev --webpack` since — `withCourierStudio()` connects the browser without that file. Since `next start` loads `next.config` as well, and Studio is a dev dependency an install for production leaves out, import it only in the development phase, as [the Next.js guide](/docs/frameworks/nextjs) shows. It also keeps `ws`, which the server's Studio connection runs on, out of the server bundle: bundled, `ws` fails on every frame of 48 bytes or more, and the server never reaches Studio. Without the wrapper, add `serverExternalPackages: ["ws"]` to the config. Under Turbopack it leaves your config untouched and prints the snippet above if the file is missing.

### Nuxt

Import the module rather than naming it as a string, and pass options in the tuple form:

```ts
// nuxt.config.ts
import { courierStudioModule } from "@noverax/courier-studio/nuxt"

export default defineNuxtConfig({
  modules: [courierStudioModule],
})
```

### Angular

```ts
// src/main.ts
import { setupCourierStudio } from "@noverax/courier-studio/angular"

setupCourierStudio() // or setupCourierStudio({ studioUrl: "http://localhost:6290" })

bootstrapApplication(AppComponent, appConfig)
```

## Connect the server

```ts
import { attachRuntime } from "@noverax/courier-studio-core/runtime"

await attachRuntime()
```

Call it once at startup — from `instrumentation.ts` in Next.js, a Nitro plugin in Nuxt, `hooks.server.ts` in SvelteKit, or before `listen()` in a Node server. The runtime is `@noverax/courier-studio-core`'s; its README describes the options `attachRuntime()` takes.

The Playground runs a call in Proxy mode from this runtime, through your app's own proxy route, at the app's origin. Under Vite and Nuxt, the integration that connects the browser also tells the server where the dev server listens, and Next.js records it as it starts; elsewhere the app learns it from the first request a Courier route serves — see [How the app learns its origin](/docs/api/config#app-origin).

Studio keeps the latest 2000 requests your app makes in memory, so a Studio page that opens or reloads shows the ones made before it; clearing the Timeline clears them there too, except the pinned ones when **Preserve log** is on. A request or stream still open when its app stops, or loses its connection to Studio, is shown as failed: the app can no longer say how it ended.

## Host a public demo

Studio can show your project to people who do not have it: a demo of your API on a public address. Run it from code with `demo`:

```ts
import { createStudioServer } from "@noverax/courier-studio/server"

createStudioServer({
  host: "0.0.0.0",
  port: 6280,
  demo: { resetsAt: Date.now() + 60 * 60_000 },
  extraCorsOrigins: ["https://demo.example.com"],
})
```

A visitor — a page opened without the token — sees everything Studio shows, the credentials of live traffic redacted, and may also send from the Playground and drive the mocks: start and stop them, set their latency and failures, apply scenarios, change their data. Limits keep one visitor from crowding out the rest, counted every ten seconds for each page, for each visitor's address — so opening more pages does not widen a share — and for all visitors together:

- **Playground:** ten calls from a page, twenty from an address and sixty from all, with four open at once from a page.
- **Mocks:** twenty commands and five starts or stops from a page, thirty and ten from an address; a seed of up to 200 records, records added to a collection only while it holds fewer than a thousand, and a latency of up to ten seconds.
- **Studio's API:** twenty requests from an address and sixty from all, of any kind that sends something; of the large ones, with a body past 256 KB — a specification read, its code generated, a log exported — two at work at once for all and one for an address, while the page's everyday requests count by their rate alone. A large request counts until the work it set going has ended, even once its visitor hangs up, which stops that work; a visitor that sends nothing for ten seconds while its request comes in, or reads nothing of its answer, is cut off.
- **The page itself:** six hundred requests from an address, six thousand from all; ten sockets open for an address and two hundred in all, ten opened every ten seconds for an address and forty in all; a thousand connections at most, each closed when its request's headers take more than ten seconds.
- **Size:** nothing larger than 64 KB at a time from a visitor; a socket reads no message larger than 512 KB, and each page is sent the newest 2 MB of each log as it connects.

Past a limit, the visitor is told which one and when to try again: a request is answered with 429 and `Retry-After`. A visitor's clears stay in their own page, and their Playground history is their own. Studio still writes no file: importing an API, generating mocks and saving a session answer that the demo does not, and nothing every visitor sees is cleared. A visitor's import reads a specification pasted, or fetched from the public internet only — never from the machine Studio runs on or its network, its host's name looked up from the name servers this machine uses, asked directly — and it checks no import for updates, previews no change to an import or to `courier.config.ts`, and is shown none of the project's code. Studio fetches four specifications at once for all its visitors, holding 32 MB of them between them and giving each 15 s, and reads, generates or compares each in a process of its own within 20 s and 256 MB of heap, 512 MB for the whole process, two at once, a specification read being at most 15 MB — some 8 MB of JSON; past that a visitor is told Studio is busy, with 503, or that the specification is too large, with 413. A demo's Studio needs about 1.5 GB of memory at the most: its own, and up to 512 MB for each of the two visitors' jobs it runs at once. The page opened with the token keeps every right.

> **Behind a proxy that reports the visitor**
>
> Studio counts a visitor by the address the proxy in front of it reports last in `X-Forwarded-For`, believed only from a proxy on this machine or a private network. Traefik, Caddy and nginx's usual configuration send it. Behind a proxy that does not, every visitor shares the proxy's address and its limits.
>
> Behind a CDN or a DDoS filter as well, name its addresses or ranges in `trustedProxies`, such as `demo: { trustedProxies: ["198.51.100.0/24", "2001:db8::/32"] }`: Studio reads past their entries to the address the first of them saw. The proxy in front of Studio must pass those entries on rather than replace them with the filter's address, as Traefik does for the addresses in its entry point's `forwardedHeaders.trustedIPs` and Caddy for its `trusted_proxies`. A filter that also names the visitor in a header of its own — DDoS-Guard's `DDG-Connecting-IP`, Cloudflare's `CF-Connecting-IP` — needs no change to that proxy: name the header in `addressHeader` too, and Studio reads it from every request that came through the filter, and from no other. Name only your own proxies and your provider's published ranges: Studio believes whatever address they report.

The Playground calls the project's own sources only, through the runtime that serves them, so point those sources at servers anyone may call. `resetsAt` is when you start the demo again from a clean state; each page counts down to it. `extraCorsOrigins` names the address visitors open when Studio sits behind a proxy on a domain of its own. Search engines leave the demo out of their results, as they do any Studio: every answer says `X-Robots-Tag: noindex, nofollow`, and its `robots.txt` lets a crawler read that. [`examples/demo-shop`](https://github.com/NoveraX/courier/tree/master/examples/demo-shop) is a complete demo: two backends, a server, traffic, two mocks, a reset every hour, and the container it runs in.

## Next steps

- [Import an API](/docs/studio/import-api) — turn an OpenAPI, Swagger or Postman specification into typed API code
- [Mock servers](/docs/studio/mock-servers) — declare mocks Studio runs, and add them from its UI
- [Network conditions](/docs/studio/network-conditions) — make a mock slow or failing, from the Network tab or in code, and halt a run on its own
- [How Studio loads the project](/docs/studio/project-loading) — reloads, failures, the transform cache, and running Studio from code
- [VS Code extension](/docs/studio/editor) — Studio's figures above each call in your code, and the call one click from Studio
