# rezona

[![License](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
[![Language](https://img.shields.io/badge/language-TypeScript-3178c6.svg)](https://www.typescriptlang.org/)
[![Node](https://img.shields.io/badge/node-%3E%3D18-339933.svg)](https://nodejs.org/)

English | [中文](./README_ZH.md)

Rezona Lab's CLI and MCP server. It gives any MCP-capable coding agent a fixed set of tools for
generating game assets — images, sprite sheets, video, audio, 3D models, rigs, retextures — for
writing the finished bytes into the project directory the agent is already working in, and for
uploading the built game so the user gets a playable link.

The repository is `rezonalab-mcp-cli`; the npm package is `rezona`. The package name is short
because you type it; the repository name is qualified so it is placeable in the org listing.

## Contents

- [What this is for](#what-this-is-for)
- [Quick start](#quick-start)
- [What `init` writes](#what-init-writes)
- [One package, two identities](#one-package-two-identities)
- [Where it sits](#where-it-sits)
- [Tool surface](#tool-surface)
- [Generation parameters](#generation-parameters)
- [The asynchronous contract](#the-asynchronous-contract)
- [Where assets land](#where-assets-land)
- [Uploading a build](#uploading-a-build)
- [Publishing to the Rezona App](#publishing-to-the-rezona-app)
- [Credentials](#credentials)
- [Error contract](#error-contract)
- [Credits and billing](#credits-and-billing)
- [Troubleshooting](#troubleshooting)
- [Development](#development)
- [Releasing](#releasing)
- [License](#license)

## What this is for

The audience is someone building a game inside a coding agent — Claude Code, Codex, or any other
MCP client — who needs art, audio and 3D assets while the code is being written.

Without this package the loop is manual: leave the editor, open a generation web UI, describe the
asset, wait, download it, drop it into `assets/`, and rename it so the code can find it. The agent
cannot see any of that, so it also cannot iterate on it.

This package closes the loop. The generation backend is exposed as MCP tools, so the agent submits
the request, polls for completion, and writes the file into the project itself. The asset lands as a
real file on disk at a path the agent chose, which means the same agent can immediately reference it
from code. When the game builds, the same agent can upload the build and hand back a link the user
can open.

One thing is deliberately not in scope: the package does not render, resize or post-process
assets — it moves bytes.

Uploading a playable version and publishing to the Rezona App are two different steps.
`upload_project` produces a private link the user can open; `publish_to_rezona_app` puts the game in
front of other people, under the user's real Rezona identity, and cannot be undone. See
[Publishing to the Rezona App](#publishing-to-the-rezona-app).

## Quick start

```bash
cd your-game-project
npx rezona@latest init
```

That single command authenticates you, writes your client's MCP config, and scaffolds the project.

Do **not** install it globally. The client config this package writes runs
`npx -y rezona@latest mcp`, so a globally installed `rezona` on `PATH` is a second copy that can sit
at a different version than the one the client actually spawns — and the version the client spawns
is the one that defines the tool surface.

After `init` finishes, restart your client so it picks up the new MCP server. Then describe what you
need in plain language ("a standing sprite for the hero, transparent background"), and the agent
drives the tools.

The rest of the command line:

```bash
npx rezona@latest init      # authenticate + write client config + scaffold
npx rezona@latest login     # refresh credentials only; does not touch project files
npx rezona@latest status    # who you are, which environment, what is reachable
npx rezona@latest mcp       # the stdio MCP server (clients call this, not humans)
npx rezona@latest --version # the version actually running
npx rezona@latest --help
```

`init` accepts `--claude` / `--codex` and their `--no-` forms (see below) and passes `--paste` through to the login step.
`login` accepts `--paste` and `--no-browser`.

## What `init` writes

`init` is one command because every step of it is required before the first tool call can succeed,
and any step left to the user is a step where people drop out.

1. **Credentials.** If `~/.rezona/credentials.json` has no usable token, `init` runs the login flow
   first and stops on failure. Nothing is written to the project before you are signed in.
2. **The project marker.** `.rezona/` in the current directory. Asset delivery refuses to write
   into a directory that has no marker, so this is what makes the project a valid target.
3. **Spec material.** The bundled game-kit (scripts, skills, 2D/3D templates) and five user skills
   (`brainstorm`, `char-ref`, `game-plan`, `gen-sprite-animation`, `gen-variants`) are copied into
   `.rezona/spec/`. They go under the marker directory rather than the project root because `init`
   overwrites them on every run, and mixing them into your own `skills/` or `templates/` would make
   it impossible to tell later whose files they are. The heavy AR/VR templates are not bundled —
   they carry mediapipe wasm/model binaries that would be re-fetched on every `npx` run.
4. **A managed block in `AGENTS.md`.** It tells the agent which tools exist, which ones cost
   credits, and how the poll-then-fetch sequence works. The block is delimited by
   `<!-- BEGIN rezona ... -->` / `<!-- END rezona -->` markers and is rewritten in place on every
   run; anything you write outside those markers is preserved verbatim. If the markers are missing
   from an existing file, the block is appended rather than overwriting the file.
5. **Client MCP config**, according to what is actually installed on the machine.

### The registration is global; permission to touch a directory is not

| Client | File | When |
|---|---|---|
| Claude Code | `~/.claude.json`, user scope (written via `claude mcp add --scope user`) | when `~/.claude` exists, or when `init` is itself running inside Claude Code |
| Codex | `~/.codex/config.toml`, table `[mcp_servers.rezona]` | when `~/.codex` exists, or when `init` is itself running inside Codex |

Both entries run the same command: `npx -y rezona@latest mcp`. The Codex table additionally sets
`startup_timeout_sec = 60`, because Codex's default is 10s and an `npx` cold start routinely exceeds
it — the symptom is "the MCP server won't start", with nothing pointing at a timeout.

The tool surface is account-scoped, not repository-scoped: the credential lives in
`~/.rezona/credentials`, `project_id` is a tool argument, and the server needs no in-project state to
boot. Registering per project would mean every new game directory needs an `init` plus a client
restart before the tools appear at all — and the actual workflow is a new directory per game. Under
Claude Code it would also cost an approval prompt on first use, which user scope does not.

**This does not weaken anything, because the gate is not the registration.** Both the write path
(`fetch_generated_asset`) and the read path (`upload_project`) require a `.rezona/` marker in the
directory (see `requireProjectDir`). So a global entry makes the tools *visible* everywhere, while
"may read and write this directory" stays granted per project. **Run `init` once in every project you
intend to build in** — not to register the server, which is already done, but to place that marker
(and the `AGENTS.md` block and the spec material).

Detection is by directory: `~/.claude` and `~/.codex` only exist once that client has actually run,
so their presence means you use it. `init` also treats "this command is being run by X"
(`CLAUDECODE=1`; `CODEX_SANDBOX` / `CODEX_SANDBOX_NETWORK_DISABLED`) as a positive signal, since the
directory may not exist yet on a first run. Those signals only add; their absence never removes.

Four explicit switches override detection:

- `--claude` / `--codex` — write that client's config even though it was not detected.
- `--no-claude` / `--no-codex` — never touch that global file. These win over the positive form.

`init` no longer creates a project-level `.mcp.json`. Two places claiming to configure the same
server is a bad trade when the project-level one silently wins: you change the global entry, nothing
happens, and there is no obvious reason why. An existing `.mcp.json` is **not** deleted — that is
your file — but if it already carries a `rezona` entry, `init` keeps it in sync with the global one so
an old project is not pinned to a stale command. A `.mcp.json` holding only other people's servers is
left untouched.

Re-running `init` is safe. `~/.claude.json` is edited by shelling out to `claude` itself, because it
is that client's live state file and a running client rewrites it — reading it whole and writing it
whole back would swallow whatever it changed in between. Only if `claude` is unavailable does `init`
merge the JSON directly, and then it touches exactly `mcpServers.rezona`, via a temp file plus rename
so a crash can never leave half a JSON document behind. The temp file inherits the original's mode
(`0600` for a new one): MCP entries can carry `env`, other people put tokens there, and the default
`0666 & ~umask` would quietly turn a `0600` config into a world-readable `0644` one. The Codex table is replaced without touching
neighbouring tables, and the `AGENTS.md` block is rewritten only between its markers. An unparseable
config is an error rather than an overwrite.

## One package, two identities

| Command | Who runs it |
|---|---|
| `rezona init` / `login` / `status` | a human, in a terminal |
| `rezona mcp` | the stdio MCP server your client spawns — not meant to be typed |

Both live in one binary, dispatched on `argv`. They are not split into two packages because the two
halves share the credential format, the API client and the error classification table, and two
packages would drift in version immediately. The tool surface has to evolve in lockstep with the
server contract, and a version skew between "the CLI that logged you in" and "the server the client
spawns" is not observable from either side.

One consequence matters when reading the code: in `mcp` mode nothing may be written to stdout except
JSON-RPC frames. A stray log line breaks the client's parser. All diagnostics in that path go to
stderr.

## Where it sits

The MCP server runs on your machine as a child process of your editor. It is not a remote service.

```
Claude Code / Codex / other MCP client
     │  stdio JSON-RPC (local child process, no port, no inbound network)
     ▼
npx rezona mcp                        ← this package
     │  HTTPS + PAT (Authorization: Bearer)
     ▼
Rezona Lab pgc server                 ← projects, generation tasks, asset bytes, versions, credits
     │
     ▼
generation providers                  ← image / video / audio / 3D backends
```

Generated files land in your project directory. Nothing else on your machine is touched, and the
delivery path enforces that (see [Where assets land](#where-assets-land)). Traffic in the other
direction — a built game uploaded as a new version — is packed from that same directory.

Running locally is not an implementation detail. Half of the tool surface touches your files, and a
remote server has no hand on your disk. It also keeps the PAT on your machine instead of having us
custody a token for every user.

Two constraints follow from the MCP specification and from client behaviour, and they explain most
of the design:

- `tools/list` is constant for the lifetime of the process. The tool schemas are hand-written into
  the package rather than fetched from a server manifest, because most clients never re-read a
  changed tool list.
- Startup performs no network calls and does not read credentials. The server must come up and list
  its tools even when you are signed out; a server that fails to start shows up as "MCP server is
  red", which gives the user no hint that the fix is to log in. Credentials are read on the first
  tool call instead, and an unauthenticated call returns an error that names the login command.

## Tool surface

Fourteen tools, constant across the process lifetime.

| Tool | What it does | Costs credits |
|---|---|---|
| `create_project` | Creates a Rezona Lab game project and returns its `public_id`. Call once per game and keep the id. | no |
| `list_projects` | Lists the signed-in user's projects (`public_id`, `name`, `status`). | no |
| `submit_image_generation` | Submits a background task that generates an image. | **yes** |
| `submit_video_generation` | Submits a background task that generates a video clip. | **yes** |
| `submit_audio_generation` | Submits a background task that generates speech, music or a sound effect. | **yes** |
| `submit_model3d_generation` | Submits a background task that generates a 3D model. | **yes** |
| `submit_sprite_generation` | Submits a background task that generates a sprite sheet. | **yes** |
| `submit_rig3d_generation` | Submits a rig plus animation retarget for an existing 3D model. | **yes** |
| `submit_retexture_generation` | Submits a new texture for an existing 3D model. | **yes** |
| `check_generation_tasks` | Checks status for up to 100 `task_id`s. Read-only. | no |
| `fetch_generated_asset` | Downloads one ready asset into the project directory and returns its absolute path. | no |
| `upload_project` | Packs the project directory, uploads it as a new version, and returns the play and workbench links. | no |
| `open_workbench` | Opens the project's workbench page in a browser and returns the URL either way. | no |
| `publish_to_rezona_app` | Publish a built version to the Rezona App. **Irreversible.** | no |

Every `submit_*` tool is annotated `readOnlyHint: false` and `idempotentHint: false`, so clients that
ask for confirmation before side-effecting calls will ask before spending credits. The CI smoke test
fails if a `submit_*` tool ever loses that annotation. `upload_project` carries the same
non-read-only annotation because it creates a version on the server.

`create_project` and `list_projects` are free, but the `project_id` they produce is required by every
other tool except `upload_project`, which can create the project itself.

Sprite generation is its own tool rather than a flag on image generation because the server treats it
as a separate generation type with its own provider path and its own output shape (a sheet plus a
companion JSON).

## Generation parameters

Each `submit_*` tool takes `project_id`, `output_path`, and the named parameters for its type. Only
common, stable parameters are named; the full provider supersets stay reachable through `extra`.

| Tool | Named parameters |
|---|---|
| `submit_image_generation` | `prompt`, `model`, `size`, `n`, `transparent`, `source_urls`, `extra` |
| `submit_video_generation` | `prompt`, `model`, `seconds`, `resolution`, `ratio`, `source_url`, `last_frame_url`, `ref_image_urls`, `extra` |
| `submit_audio_generation` | `prompt`, `kind`, `model`, `voice_id`, `output_format`, `duration`, `extra` |
| `submit_model3d_generation` | `prompt`, `source_url`, `texture`, `pbr`, `texture_quality`, `extra` |
| `submit_sprite_generation` | `prompt`, `model`, `transparent`, `extra` |
| `submit_rig3d_generation` | `source_task_id`, `rig_type`, `animations`, `extra` |
| `submit_retexture_generation` | `source_task_id`, `text_prompt`, `image_prompt_url`, `style_image_url`, `texture_quality`, `extra` |

Notes that are easy to get wrong:

- Every `*_url` / `*_urls` parameter must be a **public** `https` URL. The server fetches them;
  private and loopback addresses are rejected server-side.
- `source_task_id` on `submit_rig3d_generation` and `submit_retexture_generation` is the `gtask-…`
  id of **your own completed `submit_model3d_generation` task**. Raw provider task ids are rejected.
- `animations` on `submit_rig3d_generation` is billed **per animation**, on top of the rig itself.
- `texture_quality: "detailed"` roughly doubles the cost of a retexture, which is why it is a named
  parameter rather than something buried in `extra`.
- `extra` is a passthrough for advanced provider parameters (`seed`, `watermark`, `face_limit` and
  similar). Its keys are merged up into the request under their server-side names before the request
  is sent, because the server's parameter models have no `extra` field and would silently drop a
  nested object. A key in `extra` that duplicates a named parameter is rejected with an error rather
  than one side silently winning. Parameters the server does not support are ignored and echoed back
  in `ignored_params`.

## The asynchronous contract

Generation takes tens of seconds to several minutes, which is longer than most clients' tool
timeouts. The tools therefore never block on a result. The sequence is always three steps:

```
submit_*_generation  ──►  task_id            (nothing exists on disk)
check_generation_tasks ──►  status: ready     (still nothing on disk)
fetch_generated_asset  ──►  absolute_path     (the file now exists)
```

1. **Submit.** Returns a `task_id` immediately, plus the `output_path` the server actually assigned.
   No file has been written anywhere at this point.
2. **Poll.** `check_generation_tasks` takes up to 100 `task_id`s at once. Unknown ids and other
   users' ids are silently skipped, so the result may be shorter than the request — a missing item
   is not the same as a failed one. Poll until each item is `ready` or `failed`; do not busy-wait.
3. **Fetch.** `fetch_generated_asset` is what puts bytes on disk and returns the absolute path.

### Generating from an asset you already made

Image-to-image, a video's first frame and a 3D reference all take a **public https URL**, and every
asset produced here already has one. `fetch_generated_asset` returns it as `public_url`, and
`check_generation_tasks` attaches it to each `ready` item when you pass `project_id`. Feed that value
straight back as `source_url` / `source_urls`.

This was worth fixing because the failure was silent and expensive: the tools only ever returned
`output_path` and a local absolute path, so an agent asked to "use this image as a reference"
reasonably concluded no URL existed, stopped, and asked the user to supply one. The link had been
there the whole time.

Two properties of that URL matter:

- **It points at the server, not the bucket.** `/pv/...` streams the bytes rather than redirecting to
  storage, so no bucket name, object key or signed URL is exposed, and nothing expires.
- **It is unauthenticated.** Anyone holding it can read that asset. That is the same exposure as the
  `/play` share links, which are meant to be handed out — but since these URLs now flow through
  model context, treat them as shareable rather than secret.

A link is only attached once the asset is `ready`. While a task is still generating that address is a
404, and handing the model a link that does not resolve yet is worse than handing it nothing.

### Use the `output_path` from the response

The server appends a generation segment to the path you submit, so that the same logical path can be
regenerated repeatedly without overwriting earlier attempts:

```
submitted:  assets/hero.png
assigned:   assets/hero-g1.png     ← this is the one that exists
```

Fetching with the path you sent will not find the asset. The correct value comes back in two places,
under two different field names for the same value:

- `output_path` in the submit response;
- `asset_path` in the `check_generation_tasks` response.

Either one can be passed as `output_path` to `fetch_generated_asset`.

## Where assets land

`fetch_generated_asset` resolves its target directory in this order: the `dir` argument, then
`CLAUDE_PROJECT_DIR`, then the current working directory. Because `dir` is a tool argument, it is
model-controlled, so the write path is validated on both halves:

- The directory must contain a `.rezona/` marker, i.e. it must be a project where `init` has been
  run. Delivery into an arbitrary directory is refused.
- Sensitive directories are refused outright even if they would otherwise be inside the target:
  `~/.rezona`, `~/.claude`, `~/.codex`, `~/.config`, `~/.ssh`, `~/.aws`, `~/.gnupg`.
- `output_path` must be relative, must not contain `..`, must resolve inside the project directory
  after symlinks are resolved, and must include a leading namespace segment (`assets/hero-g1.png`,
  not `hero-g1.png`).

The namespace segment is not cosmetic. The authenticated read endpoint only serves `assets`,
`current` and `v{n}`, so an asset written anywhere else cannot be fetched back.

Downloads are streamed to disk rather than buffered in memory, written to a `.part` file and renamed
on success, so an interrupted transfer never leaves a short file that looks complete. A broken
stream is retried a small number of times (three attempts total); the server does not support
`Range`, so every retry restarts from zero, which is why the count is low. HTTP errors are not
retried at this layer. There is a 256 MiB local size cap per asset, enforced both from
`Content-Length` and while streaming.

## Uploading a build

`upload_project` sends the built game to Rezona Lab as a new version and returns the links.

| Parameter | Meaning |
|---|---|
| `dir` | Directory to upload. Defaults to `CLAUDE_PROJECT_DIR`, then the working directory. |
| `project_id` | Existing project to add a version to. Omit to create a new project. |
| `name` | Name for the new project. Used only when `project_id` is omitted; defaults to the directory's name. |

The response carries `project_id`, `version_id`, `version_status`, `play_url`, `workbench_url`,
`file_count`, `packed_files` and `skipped`.

The build has to exist first. If the directory has no `dist/index.html`, the tool refuses and asks
for a build instead of uploading. The server would accept such an archive, record it as
`needs_build`, and return a `null` play URL — an upload that reports success while producing
something nobody can open, with nothing in the result a model could act on.

The directory is packed as a `.tar.gz` in-process. Tar is used because it is the archive format the
Node standard library can actually produce, and every dependency this package takes is paid again on
every `npx` cold start.

Two groups of files are left out, and both are listed in the response's `skipped` array with a
reason rather than dropped silently:

- **Files the server would reject the whole archive for.** Secret-looking files (`.env` and
  variants, `*.pem`, `*.p12`, `id_rsa`, `.npmrc`, `.netrc`) — the version directory is publicly
  readable — and anything under the server's reserved top-level segments `v{n}/`, `current/` and
  `assets/`.
- **Files with no value in a build upload**, which would also push the archive toward the server's
  size limit: `node_modules`, `.git`, `.rezona`, `.next`, `.turbo`, `.cache`, `coverage`, `.venv`,
  `__pycache__`, `.idea`, `.vscode`.

`open_workbench` takes a `project_id` and opens the project page. It always returns
`workbench_url`, including when the browser opened, because agents routinely run where no browser
exists and the return value is the only thing they can relay to the user. Pass `open: false` to get
the URL without launching anything. The workbench URL is derived from the API base URL, so a dev
credential produces a dev link.

## Publishing to the Rezona App

`upload_project` gives the user a private playable link. `publish_to_rezona_app` is the other step:
it puts the game in front of other people, in the Rezona App, under the user's real Rezona identity.

```
upload_project  →  version_id ("v1", status ready)  →  publish_to_rezona_app  →  share_url
```

**It cannot be undone.** Three separate reasons, and they compound:

- A work that other people have already seen cannot be un-seen, whatever you do to it afterwards.
- The **first** publish also creates a permanent binding between the Lab account and a real Rezona
  account. If no Rezona account is bound yet, one is found or created on the spot. The product does
  not support unbinding, and does not support moving the binding to a different account.
- Publishing costs no credits, so "it spends money, therefore the client will ask first" — the thing
  that protects the seven `submit_*` tools — does not apply here at all.

Because of that last point the tool is annotated `destructiveHint: true`, so a **conforming** client
confirms with the user before the call rather than after. A test asserts the annotation is still
there, because losing it silently removes the only in-band signal a client has.

Be clear about what that annotation is not: MCP annotations are advisory. A client is free to ignore
them, and one that auto-approves anything free will call straight through. **The enforceable gate is
server-side** — whether a PAT can reach the publish endpoint at all is controlled by a switch that
defaults to off and is flipped by whoever operates the environment, not by the agent. That is where a
gate can actually be enforced, so that is where it lives.

A prepare-then-confirm token exchange inside this server would not help: the model holds both ends,
calling `prepare` and then passing the token to `publish` with no human anywhere in the loop. It adds
ceremony, not consent.

The tool also **defaults to private** and sends `public` explicitly on every call. The server's own
default for that field is `true`, which is right for the web UI — there is a checkbox and a human
looking at it — and wrong here, where the caller filling in fields is a model. A field nobody asked
about must not make someone's work public. Pass `public: true` only when the user actually said so.

`version_id` must be a built, stable `v{n}`. `current` is refused, a version with no
`dist/index.html` is refused with `REZONA_VERSION_NOT_BUILT`, and both refusals happen before
anything is created upstream — so a version that was never going to work does not get the user an
auto-created account as a side effect.

No `idempotency_key` is sent. The server derives a stable one from
`(user, project, version, content fingerprint)`, which is strictly better than a key invented here:
the failure mode of a client-side key is "the retry used a different key", and that publishes one
version as two works, irreversibly.

`REZONA_PUBLISH_FAILED` (502) is classified **terminal** even though the derived idempotency key
makes a retry safe. Safe is not the same as useful: the outcome is unknown, the action is
irreversible, and an unattended agent looping on it is worse than one that stops and asks.

`REZONA_PUBLISH_IN_PROGRESS` (409) is **transient** — that publish really is running, so waiting
returns its own result rather than a second publish. But "transient" must not be read as "retry
forever": the server does not reclaim a running publish job, and the default key is derived from the
content, so if the worker holding that publish died, this 409 **never clears on its own**. The model
hint for this code therefore says BOUNDED, and the tool takes an optional `idempotency_key` purely as
the escape hatch from that state. Leave it out otherwise — a different key publishes the same version
again as a second, separate work.

## Credentials

Authentication uses a personal access token (PAT), prefix `rz_live_`.

```bash
npx rezona@latest login                # default: opens a browser
npx rezona@latest login --no-browser   # SSH / containers: prints the URL, approve elsewhere
npx rezona@latest login --paste        # paste an existing key
```

The browser flow requests an authorization, prints a confirmation code, opens the verification page,
and polls until you approve. Check that the code on the page matches the one in your terminal before
approving. Claiming the token additionally requires a secret that exists only in that CLI process's
memory — never in a URL, on the page, or in a log — so seeing the confirmation code is not enough to
claim the token.

The token is stored in `~/.rezona/credentials.json`:

- directory mode `0700`, file mode `0600`;
- written to a temporary file and renamed into place, so a failed login cannot truncate an existing
  credential file;
- alongside the API base URL the token belongs to, since a dev token against production only ever
  produces "invalid token";
- never printed. `status` shows only the public prefix (`rz_live_ab12…`).

### Environment overrides

| Variable | Effect |
|---|---|
| `REZONA_PAT` | Used instead of the credential file. Intended for CI. |
| `REZONA_API_BASE` | Overrides the API base URL used by `login` and by the env-token path. |

`REZONA_PAT` is for CI, not for daily use. The MCP server is a child process of your client, so it
inherits the **client's** environment, not your interactive shell's. A token exported in `.zshrc`
makes the CLI work while the tools keep reporting "not authenticated", and that asymmetry is very
hard to diagnose from the inside.

```bash
npx rezona@latest status
# credential : rz_live_ab12…
# endpoint   : https://devlab.rezona.ai/game/pgcserver
# projects   : 3 reachable
# credits    : 12500 balance / 12000 spendable
```

## Error contract

Failures are classified as **terminal** or **transient** by the server's `code` field, never by the
HTTP status. The classification is what the tool result tells the model, and it decides whether the
model retries.

Status codes cannot carry that decision. The server returns `503` for several conditions that are
permanent — `PAT_DISABLED`, `MCP_GENERATION_DISABLED`, `MCP_UPLOAD_DISABLED`,
`MCP_BILLING_NOT_CONFIGURED` — all of which mean "this environment does not offer this capability".
Retrying them a thousand times changes nothing. `PAT_INTROSPECT_UNAVAILABLE` is also `503` and is
genuinely transient. An unattended agent classifying by status would loop forever on a call that can
never succeed, and each loop burns the user's own model quota. The opposite mistake is cheaper but
still bad: treating a transient blip as terminal makes the user think they need to log in again.

The authoritative table lives in `src/api/errors.ts`, which mirrors the server's `docs/api/mcp.md`.
Broadly:

| Class | Examples | What the model is told |
|---|---|---|
| Terminal, capability off | `PAT_DISABLED`, `MCP_GENERATION_DISABLED`, `SPRITE_DISABLED`, `RIGGING_DISABLED` | do not retry; tell the user |
| Terminal, credentials | `UNAUTHORIZED`, `PAT_INACTIVE`, `PAT_MALFORMED`, `PAT_SIGNATURE_INVALID` | do not retry; ask the user to run `npx rezona@latest login` |
| Terminal, input or contract | `VALIDATION_ERROR`, `INVALID_PATH`, `SOURCE_URL_REJECTED`, `PROJECT_NOT_FOUND`, `FILE_NOT_FOUND`, `CREDIT_INSUFFICIENT` | do not retry; change the request |
| Terminal, archive (from `upload_project`) | `ARCHIVE_INVALID`, `ARCHIVE_TOO_LARGE`, `ARCHIVE_UNSAFE_PATH`, `UPLOAD_RESERVED_PATH`, `UPLOAD_SENSITIVE_FILE` | do not retry the same archive |
| Transient | `PAT_INTROSPECT_UNAVAILABLE`, `UPSTREAM_UNAVAILABLE`, `CREDIT_RESERVE_FAILED`, `GENERATION_RATE_LIMITED`, `GENERATION_TOO_MANY_IN_FLIGHT`, `UPLOAD_VERSION_CONFLICT` | back off and retry |

Unrecognised codes default to **terminal**. The direction is deliberate: treating an unknown failure
as transient makes an unattended agent retry something nobody has ever seen, while treating it as
terminal makes it stop and ask a human. The single exception is an unregistered code on a `5xx`,
which is more likely to be infrastructure noise and gets one transient benefit of the doubt.

There is a second layer for tasks that reached a **failed** end state. The server's
`failure.retryable` is `true` for `input_rejected`, which is correct for a web UI — a person can edit
their input and press the button again. A model reads the same `true` as "resend as-is", and a source
URL that returns 404 would be resubmitted forever. So `check_generation_tasks` adds a `retry_advice`
field to each failed item, and failures in the `input_rejected` or `content_policy` categories are
reported as terminal regardless of the server's flag. The server-side field is intentionally left
alone, since the web UI's retry button depends on it.

CI keeps the two sides aligned: a drift probe shallow-checkouts the server's `docs/api/mcp.md` and
asserts that every documented code is classified here. When the document cannot be fetched, the test
skips rather than passing silently.

## Credits and billing

Generation is billed in credits: **$1 USD buys 1000 credits**. Only `submit_*` calls spend them.
Creating and listing projects, polling task status, and downloading finished assets are all free.

`npx rezona@latest status` prints the balance:

- `balance` is the credit balance;
- `spendable` is what can be committed right now.

The gap between them is the amount reserved by in-flight generations that have not settled yet.
Both numbers are shown because that gap is the usual explanation for "my balance is large but the
call said insufficient credits" — an agent submitting several generations at once opens it. When the
upstream returns no figure, `status` prints `unknown` instead of `0`, since displaying `0` would look
like an empty account.

The balance is a snapshot and is deliberately not exposed as a tool. Under concurrency it is stale by
the time it is read; the real decision happens when the server reserves credit at submit time, which
is where `CREDIT_INSUFFICIENT` (terminal) and `CREDIT_RESERVE_FAILED` (transient) come from.

Two levers change what a call costs, both named parameters so the model can weigh them:
`animations` on `submit_rig3d_generation` (billed per animation) and
`texture_quality: "detailed"` on retexture and 3D model generation.

## Troubleshooting

**Codex reports that the MCP server failed to start.** Codex's default `startup_timeout_sec` is 10
seconds, and an `npx` cold start — where the package is downloaded before it runs — regularly takes
longer. `init` writes `startup_timeout_sec = 60` into `[mcp_servers.rezona]` for exactly this reason.
If the entry was written by hand, add that line.

**Tools do not appear after `init`.** Restart the client. MCP servers are read at client startup.

**The CLI works but the tools say "not authenticated".** The token is in your shell environment
rather than in `~/.rezona/credentials.json`. The client spawns the server with the client's
environment. Run `npx rezona@latest login` so the credential is written to disk.

**`login` fails with a message about the verification page.** The server returns a `verification_url`
whose host must resolve publicly. When it does not, `login` fails immediately and points at
`--paste`, instead of polling for nine and a half minutes against a page that cannot be opened. Sign
in with `npx rezona@latest login --paste` using a key from the web UI.

**A download is slow, stalls, or repeatedly breaks.** Node does not read `HTTPS_PROXY` in either
`fetch` or `node:https`, but `curl` does, which is why "curl can download it, the package cannot" is
a recurring shape. The download path therefore reads the proxy environment itself and opens a
`CONNECT` tunnel for `https` targets:

| Variable | Effect |
|---|---|
| `HTTPS_PROXY` / `https_proxy` | proxy for `https` targets (falls back to `HTTP_PROXY` / `http_proxy`) |
| `HTTP_PROXY` / `http_proxy` | proxy for `http` targets |
| `NO_PROXY` / `no_proxy` | comma-separated hosts to bypass; `*` disables proxying entirely |

**A 3D download fails with `ASSET_TOO_LARGE`.** The local cap is 256 MiB per asset. Textured GLB
files in the tens of megabytes are normal, so this is a real ceiling rather than a theoretical one,
but a single asset above it has to be retrieved another way.

**A download fails with `ASSET_DOWNLOAD_INTERCEPTED`.** The response was an HTML page rather than
asset bytes, which means something between you and the API answered instead of it — a gateway
fallback, a captive portal, or a proxy error page. This check exists because such a response arrives
as a valid `200` and was previously written to disk as a plausible-looking asset file.

**`fetch_generated_asset` refuses to write.** The target directory has no `.rezona/` marker. Run
`npx rezona@latest init` there, or pass `dir` pointing at the project that was initialized.

**`upload_project` refuses because there is no `dist/index.html`.** Run the project's build first
(`npm run build` in the bundled templates), then upload again. The check is deliberate: the server
would have accepted the archive and produced a version with no play URL.

**Fetching says the asset does not exist.** The submitted path was used instead of the assigned one.
Use `output_path` from the submit response or `asset_path` from the poll response, which include the
`-g{n}` segment.

## Development

```bash
npm install
npm run typecheck
npm test                            # unit tests + tool-name consistency + error-code drift probe
npm run build
npm run dev -- status               # run from source via tsx
node scripts/handshake-smoke.mjs    # credential-free startup + initialize + tools/list
```

An end-to-end smoke against dev (create project → submit → poll → write to disk) is available
separately; it reads the token from the environment and never prints it:

```bash
RZLAB_PAT_DEV=<pat> node scripts/smoke-dev.mjs
```

CI runs the test matrix on Node 18 and 22. `engines.node >= 18` is the bar for **user** machines,
because the client spawns this package on the user's Node, so the lowest supported version has to be
exercised for real.

Three checks in CI are worth knowing about before changing anything:

- **Tool-name consistency.** The bundled spec material names tools such as
  `submit_image_generation` in instructions the model reads. Renaming or removing a tool without
  updating that material produces a failure mode where the model follows a skill and calls a tool
  that does not exist. The test extracts the registered tool names from a real server instance
  rather than from a hand-copied list.
- **Error-code drift.** The server's `docs/api/mcp.md` is shallow-checked-out and every documented
  code must be classified in `src/api/errors.ts`. It skips, rather than passes, when the document is
  unavailable.
- **Handshake smoke.** The server is started with no credentials at all and must still complete
  `initialize` and `tools/list`, emit nothing but JSON-RPC on stdout, and keep
  `readOnlyHint: false` on every `submit_*` tool.

## Releasing

Publishing is triggered by a tag. CI re-runs typecheck, tests, build and the credential-free
handshake before publishing, and verifies that the tag matches `package.json`'s version.

```bash
npm version patch          # or minor / major
git push --follow-tags     # v0.1.4 → npm dist-tag latest
```

Pre-releases go to the `next` dist-tag and must never move `latest`:

```bash
npm version 0.2.0-beta.1 && git push --follow-tags   # → dist-tag next
```

The rule is enforced by the tag shape: `vX.Y.Z` publishes to `latest`, `vX.Y.Z-*` publishes to
`next`. It matters because `init` writes `rezona@latest` into every user's client config, so moving
`latest` silently upgrades everyone the next time their client spawns the server.

Publishing requires the repository secret `NPM_TOKEN` (an npm automation token). The workflow does
not pass `--provenance`: npm's provenance only accepts public source repositories and rejects private
ones after the tarball has already been uploaded. Add the flag back when the repository becomes
public.

## License

MIT. See [LICENSE](./LICENSE).
