rezona
v0.2.0
Published
Rezona Lab CLI + MCP server: generate game assets, upload builds, get a playable link
Readme
rezona
English | 中文
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
- Quick start
- What
initwrites - One package, two identities
- Where it sits
- Tool surface
- Generation parameters
- The asynchronous contract
- Where assets land
- Uploading a build
- Publishing to the Rezona App
- Credentials
- Error contract
- Credits and billing
- Troubleshooting
- Development
- Releasing
- 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.
Quick start
cd your-game-project
npx rezona@latest initThat 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:
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 --helpinit 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.
- Credentials. If
~/.rezona/credentials.jsonhas no usable token,initruns the login flow first and stops on failure. Nothing is written to the project before you are signed in. - 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. - 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 becauseinitoverwrites them on every run, and mixing them into your ownskills/ortemplates/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 everynpxrun. - 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. - 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 backendsGenerated files land in your project directory. Nothing else on your machine is touched, and the delivery path enforces that (see 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/listis 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_ids. 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/*_urlsparameter must be a publichttpsURL. The server fetches them; private and loopback addresses are rejected server-side. source_task_idonsubmit_rig3d_generationandsubmit_retexture_generationis thegtask-…id of your own completedsubmit_model3d_generationtask. Raw provider task ids are rejected.animationsonsubmit_rig3d_generationis 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 inextra.extrais a passthrough for advanced provider parameters (seed,watermark,face_limitand 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 noextrafield and would silently drop a nested object. A key inextrathat 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 inignored_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)- Submit. Returns a
task_idimmediately, plus theoutput_paththe server actually assigned. No file has been written anywhere at this point. - Poll.
check_generation_taskstakes up to 100task_ids 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 isreadyorfailed; do not busy-wait. - Fetch.
fetch_generated_assetis 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
/playshare 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 existsFetching 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_pathin the submit response;asset_pathin thecheck_generation_tasksresponse.
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 whereinithas 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_pathmust 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, nothero-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 (
.envand variants,*.pem,*.p12,id_rsa,.npmrc,.netrc) — the version directory is publicly readable — and anything under the server's reserved top-level segmentsv{n}/,current/andassets/. - 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_urlIt 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_.
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 keyThe 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 mode0600; - 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.
statusshows 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.
npx rezona@latest status
# credential : rz_live_ab12…
# endpoint : https://devlab.rezona.ai/game/pgcserver
# projects : 3 reachable
# credits : 12500 balance / 12000 spendableError 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:
balanceis the credit balance;spendableis 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
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/listAn 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:
RZLAB_PAT_DEV=<pat> node scripts/smoke-dev.mjsCI 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_generationin 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.mdis shallow-checked-out and every documented code must be classified insrc/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
initializeandtools/list, emit nothing but JSON-RPC on stdout, and keepreadOnlyHint: falseon everysubmit_*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.
npm version patch # or minor / major
git push --follow-tags # v0.1.4 → npm dist-tag latestPre-releases go to the next dist-tag and must never move latest:
npm version 0.2.0-beta.1 && git push --follow-tags # → dist-tag nextThe 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.
