---
title: "arkor dev"
description: "Launch Arkor Studio locally."
---

Boots [Studio](/docs/framework/concepts/studio), the local web UI, on `http://localhost:4000`. Studio is where you click **Run training** to spawn `arkor start` against your `src/arkor/index.ts`, watch the run stream in, and chat with the resulting adapter in the Playground.

`arkor dev` itself does **not** start a training run; it only serves the UI plus a small loopback API the SPA talks to.

## Synopsis

```
arkor dev [options]
```

<CodeGroup labels={["pnpm","npm","yarn","bun"]}>

```bash
pnpm dev
```

```bash
npm run dev
```

```bash
yarn dev
```

```bash
bun dev
```

</CodeGroup>

## Options

| Flag | Default | Description |
| --- | --- | --- |
| `-p, --port <port>` | `4000` | Port to bind. The displayed URL uses `localhost`, but the listener binds `127.0.0.1` directly so it cannot end up IPv6-only on hosts where `/etc/hosts` lists `::1` before `127.0.0.1`. The CLI parses the value as `Number(opts.port) || 4000`, so falsy results (`0`, non-numeric) normalize to `4000`. **Truthy invalid values pass through unsanitized**: a negative port or a port above `65535` reaches the listener as-is and surfaces as a `serve()` failure (e.g. `RangeError`). Stick to the standard 1 to 65535 range yourself. If you omit `--port` and the default `4000` is already taken, `arkor dev` automatically tries the next few ports (`4001`, `4002`, ...) instead of failing, logging a warning for each retry. See "Port collision" below. |
| `--open` | off | Open the Studio URL in a browser after the server is up. |

## Behavior

### Launch sequence

1. **Credential bootstrap.** If `~/.arkor/credentials.json` does not exist, the CLI always tries to bootstrap an anonymous session: it calls `/v1/auth/cli/config`, then requests an anonymous token from `/v1/auth/anonymous`. The pre-bootstrap line depends on whether the deployment advertises OAuth: when OAuth is configured the CLI prints ``No credentials on file. Bootstrapping an anonymous session. Run `arkor login --oauth` to sign in to your account instead.`` so you can upgrade to a real account whenever you want; on anon-only deployments it prints `No credentials on file. Requesting an anonymous token.` instead, omitting the OAuth hint because `arkor login --oauth` would fail there. Either way, it never auto-launches the OAuth flow. Once the token lands, `arkor dev` prints ``Anonymous id: <id>. Arkor Cloud uses this id to recognise this client across sessions. Keep `<home>/.arkor/credentials.json` to stay signed in as the same anonymous identity.`` (the path is the resolved `credentialsPath()`, typically `~/.arkor/credentials.json` on Linux and macOS). Only when the deployment advertises OAuth, a follow-up warn (``Anonymous sessions aren't guaranteed to persist. Sign in with `arkor login --oauth` to tie future work to your Arkor Cloud account.``) fires alongside the success line so the upgrade hint is visible at issuance time. On anon-only deployments the warn is suppressed because pointing at `arkor login --oauth` would surface a command that fails. Transport failures (`fetch failed`) are handled differently depending on when they hit. If `/v1/auth/cli/config` already succeeded and `/v1/auth/anonymous` then fails the same way, the CLI warns and continues; the Studio server retries on the first `/api/credentials` hit. If `/v1/auth/cli/config` itself is unreachable, the same transport error is rethrown and `arkor dev` exits fast (restore connectivity and re-run). If `/v1/auth/anonymous` is rejected with a 4xx (for example because anonymous sign-in is disabled on this deployment), it surfaces an error wrapping the HTTP status and pointing at `arkor login --oauth` (full message: ``Failed to bootstrap an anonymous session (HTTP <status>). This deployment may require sign-in. Run `arkor login --oauth` and try again.``).
2. **CSRF token.** A 32-byte token (base64url, ~43 chars) is generated for this launch. It is injected into `index.html` as `<meta name="arkor-studio-token">` so the same-origin SPA can read it. Cross-origin tabs cannot read the meta and are rejected by the `/api/*` middleware.
3. **Listener.** Hono on `127.0.0.1:<port>`. The `Host` header guard accepts both `127.0.0.1` and `localhost`, so the URL the CLI prints (`http://localhost:<port>`) works without surprising DNS-rebinding fallout.
4. **Token persistence (best-effort, after a successful bind).** Once the listener is up, the token is written to `~/.arkor/studio-token` (mode `0600`) so the studio-app Vite dev server (`pnpm --filter @arkor/studio-app dev`) can pick it up. Binding first means a doomed second launch on a busy port never touches the file (see "Port collision" below). If writing fails (read-only `$HOME`, locked-down umask), `arkor dev` continues; only the standalone Vite dev workflow is affected.

When the process exits (normal exit, `SIGINT`, `SIGTERM`, or `SIGHUP`) the studio-token file is removed on a best-effort basis. A crash can leave the file on disk; the next `arkor dev` rotates it.

### Loopback and CSRF model

The Studio server enforces three checks on every `/api/*` request:

1. The `Host` header must be `127.0.0.1` or `localhost` (defense against DNS rebinding).
2. The CSRF token must be present as the `X-Arkor-Studio-Token` header. The job-event stream also accepts `?studioToken=...` because `EventSource` cannot send custom headers; mutation routes do not accept query-string tokens. Token comparison is `timingSafeEqual`.
3. CORS is intentionally not configured: the SPA is same-origin so CORS adds no value, and reflecting `*` would let "simple" cross-origin POSTs (`text/plain`, `urlencoded`) skip preflight. Without a token, the middleware rejects them.

This means `arkor dev` is safe on a shared dev machine: another tab cannot read the meta, a stale tab from a previous launch holds an old token that no longer matches, and an attacker page in a different origin cannot forge requests.

### Port collision

Behavior depends on whether you passed `--port` explicitly.

- **No `--port` (using the `4000` default).** If `4000` is taken (another `arkor dev` left running, an unrelated dev server, etc.), `arkor dev` retries on `4001`, `4002`, and so on, up to 10 attempts total (counting the initial attempt on the default port). Before each retry it logs a warning, ``Port <port> is in use, trying <port + 1> instead. Pass --port to pin a specific one.``, and once bound it prints the port it actually landed on: ``Arkor Studio running on http://localhost:<port>``. If every port in that range is busy, it exits non-zero with ``Port <requested> is already in use, and no free port was found in <requested>-<last>. Pass --port to choose one explicitly.``
- **Explicit `--port <n>`.** No fallback happens. If `<n>` is taken, `arkor dev` exits non-zero immediately with ``Port <n> is already in use. Another `arkor dev` may be running; pass --port to choose a different one.`` This keeps `--port` predictable: if you asked for a specific port, `arkor dev` never silently substitutes a different one.

The token file (`~/.arkor/studio-token`) is written only **after** the port binds successfully, so a failed launch attempt on a busy port (in either case above) never overwrites or deletes the token a healthy already-running instance depends on.

## Errors

| Symptom | What it means | Fix |
| --- | --- | --- |
| ``Port <n> is already in use. Another `arkor dev` may be running; pass --port to choose a different one.`` | You passed `--port <n>` explicitly and it's taken (the underlying error is `EADDRINUSE`). No fallback happens for an explicit port. | Stop whatever else is bound to it, or use `--port <other>`. |
| ``Port <requested> is already in use, and no free port was found in <requested>-<last>. Pass --port to choose one explicitly.`` | You didn't pass `--port`, and every port from the `4000` default through the 10th attempt was taken. | Stop one of the competing processes, or pass `--port <other>` explicitly. |
| `Could not reach <baseUrl> (fetch failed). Studio will keep running and retry on first /api/credentials hit.` | `/v1/auth/cli/config` already succeeded, but the follow-up `/v1/auth/anonymous` hit a transport error. The Studio server starts and will retry. | Bring connectivity back; the SPA recovers on its next `/api/credentials` poll without restarting `arkor dev`. |
| `TypeError: fetch failed` (or an equivalent transport error that exits `arkor dev` immediately) | `/v1/auth/cli/config` itself was unreachable, so the deployment mode could not be determined and the CLI fails fast. | Restore connectivity and re-run `arkor dev`. |
| ``No credentials on file. Bootstrapping an anonymous session. Run `arkor login --oauth` to sign in to your account instead.`` | First `arkor dev` on this machine when the deployment advertises OAuth. The CLI bootstraps anonymous so Studio can start immediately; the message is informational, not an error. | Nothing required. To upgrade to a real account, run `arkor login --oauth` separately (it overwrites `~/.arkor/credentials.json`) and refresh Studio. |
| `No credentials on file. Requesting an anonymous token.` | Same as above on anon-only deployments (no OAuth advertised in `/v1/auth/cli/config`). The CLI omits the `arkor login --oauth` hint because that command would fail there. | Nothing required. |
| ``Anonymous id: <id>. Arkor Cloud uses this id to recognise this client across sessions. Keep `<home>/.arkor/credentials.json` to stay signed in as the same anonymous identity.`` | Informational follow-up after the anonymous bootstrap completes. Surfaces the cloud-side identifier and where it lives (the path is the resolved `credentialsPath()`, typically `~/.arkor/credentials.json` on Linux and macOS). | Nothing required. Back up the credentials file if you want to keep using the same anonymous identity from another machine. |
| ``Anonymous sessions aren't guaranteed to persist. Sign in with `arkor login --oauth` to tie future work to your Arkor Cloud account.`` | Persistence nudge fired alongside the success message when the deployment is known to support OAuth. Anonymous work has no SLA on the cloud-api side, so the CLI surfaces the upgrade path before you invest real work. Suppressed on anon-only deployments. | Optional: run `arkor login --oauth` to tie future work to your account. Existing anonymous work stays under its current id; there is no migration path today. |
| ``Failed to bootstrap an anonymous session (HTTP <status>). This deployment may require sign-in. Run `arkor login --oauth` and try again.`` | `/v1/auth/anonymous` rejected the request with a 4xx, so anonymous bootstrap cannot proceed. | Run `arkor login --oauth` to complete the browser flow, then re-run `arkor dev`. |
| `Could not write ~/.arkor/studio-token (...). The Studio at http://localhost:<port> is unaffected, but the Vite SPA dev workflow will see 403s on /api/*.` | `$HOME` is read-only or umask blocks `0600`. The bundled Studio still works; only the standalone Vite dev workflow is affected. | Run from a writable home, or only use the bundled Studio served by `arkor dev`. |
| HTTP 403 with `{ "error": "Studio API is loopback-only" }` (in browser devtools) | The `Host` header is something other than `127.0.0.1` / `localhost`. | Reach Studio via `http://localhost:<port>` or `http://127.0.0.1:<port>`. Reverse proxies or `0.0.0.0`-bound shells will be rejected by design. |
| HTTP 403 with `{ "error": "Missing or invalid studio token" }` (in browser devtools) | The CSRF token in the page does not match the current launch. Usually a stale tab from a previous `arkor dev`. | Reload the tab. Token rotates on every launch. |

## Examples

Default port:

<CodeGroup labels={["pnpm","npm","yarn","bun"]}>

```bash
pnpm dev
```

```bash
npm run dev
```

```bash
yarn dev
```

```bash
bun dev
```

</CodeGroup>

Custom port plus auto-open:

<CodeGroup labels={["pnpm","npm","yarn","bun"]}>

```bash
pnpm dev --port 5000 --open
```

```bash
npm run dev -- --port 5000 --open
```

```bash
yarn dev --port 5000 --open
```

```bash
bun dev --port 5000 --open
```

</CodeGroup>

## See also

- [Studio concept](/docs/framework/concepts/studio) for what the UI actually does
- [`arkor login`](/docs/framework/cli/auth) for upgrading from an anonymous session to OAuth
- [Project structure](/docs/framework/concepts/project-structure) for the `~/.arkor/` and `.arkor/` layout


---

This page is licensed under the MIT License (Copyright (c) 2026 Arkor). Include the copyright notice and the permission notice in all copies or substantial portions.

Full text: https://github.com/arkorlab/arkor/blob/main/LICENSE.md

Scope and exceptions: https://www.arkor.ai/license
