Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

rbx-cli

Unified Roblox Open Cloud CLI. One binary, one install, every tool as a subcommand. The command is rbx, short because you type it all day.

This site is a rendering of the docs/ directory of the rbx-forge/rbx-cli repository. Every page is the file the repository already serves, built by CI on every push to main, so the site and the repository cannot disagree. The “Suggest an edit” link at the top right of each page opens the exact file it came from.

Two tools, one backbone

The command surface looks broad because it is two products that happen to share a spine. The spine is the environment model: one rbxplace.toml maps env names to universes and places, and every command resolves --env through it.

Declarative, Terraform for Roblox. init, env, apikey, place, meta, config, shop. You write the desired state into a TOML file you commit, and the tool reconciles Roblox to match it. Diffable, reviewable, safe to run on every push, idempotent by construction.

Operational, kubectl for Roblox. servers, analytics, ban, restart, data, memorystore, message. These act on state that only exists while the game is running, and no TOML file can describe it. Banning a player is a consequence of what happened in your game last night, not a checked-in intention.

Comparable tools have the first pillar. Mantle never had the second, and nothing else does either: you are otherwise clicking through the Creator Hub or writing your own Open Cloud scripts. The second pillar is the difference between deploying a game and running one, and it is why the surface is wide on purpose rather than by accretion.

Every command

CommandWhat it does
initCreate the group, universe and places, and write them into rbxplace.toml
importAdopt a universe that already exists: every config and lockfile, from what is live
envRead rbxplace.toml: list envs, print one id, generate a module for game code
apikeyDeclare Open Cloud keys and their scopes, create and rotate them
doctorProve the loaded key works, with one real read rather than a syntax check
checkEvery configured tool’s check in one pass, one exit code. status is the same engine for a human
placePlace files: upload, download, promote between envs, roll back
metaUniverse and place metadata: name, icon, thumbnails, devices, visibility
configThe live in-experience config, with revisions and rollback
shopGame passes, badges and developer products, with typed Luau codegen
serversLive. Servers up now, how the stopped ones ended, and what a crashed one logged
analyticsLive. Players, retention, revenue per payer. CSV for charting elsewhere
banLive. Inspect and change player restrictions
restartLive. Forecast and launch a rolling server restart
dataLive. Read, overwrite, copy and recover one data store entry
memorystoreLive. Write cache values servers read through MemoryStoreService
messageLive. Push a MessagingService message to every running server
adsLive. Launch and steer ad campaigns. Spends money, reads no results
probeLive. A raw authenticated request to any Open Cloud path
openLaunch Studio at a place, by name or by id
downloadFetch a Roblox asset by id
completionsShell completions that read your rbxplace.toml at TAB time

Everything marked Live acts on a running game and shares one safety model: dry run by default, --apply to write, --env all refused. That model, and the keys it wants, are on Live operations.

Install

Add it to your project’s rokit.toml, then run rokit install. Pin the version you want from the releases page:

[tools]
rbx = "rbx-forge/rbx-cli"

Or let Rokit write that entry for you. Pass the alias explicitly, or you get an rbx-cli command instead of rbx:

rokit add rbx-forge/rbx-cli --alias rbx

The precompiled binaries attached to every release need no Rust toolchain. Building from source does, at the MSRV declared in the workspace manifest.

Where to start

Two ways in, depending on whether the experience exists yet:

  • Nothing on Roblox yet. rbx init creates the group, universe and places, and writes the rbxplace.toml that everything else reads.
  • A universe that already exists. rbx import adopts it: every config and lockfile written from what is live, in one command.

Both paths converge on the same three pages. rbx env explains the rbxplace.toml they produce and how --env resolves through it, rbx apikey manages the Open Cloud keys declaratively, and rbx doctor proves those keys work with one real read rather than a syntax check.

From there:

  • Putting it in CI. rbx check runs every configured tool’s check in one pass with one aggregated exit code. That page is also where rbx status, the same engine for a human rather than a pipeline, is documented.
  • Shipping a build. rbx place uploads, downloads, promotes between envs and rolls back to a past version.
  • Everything that is not the place file. rbx meta for universe and place metadata, rbx config for in-experience live configs, rbx shop for passes, badges and developer products with typed Luau codegen.
  • Acting on a running game. Live operations is the entry point and the safety model: dry run by default, --apply to write, --env all refused.
  • More than one person on the repository. Working in a team is the lockfile conflict procedure. Worth reading before you need it, because a badly resolved rbxshop.lock.toml creates a second paid game pass that this tool cannot delete.

Not on this site

Documentation about the repository rather than the tool stays in the repository: README for the full command table and the stability policy, ARCHITECTURE.md for the crate layout, CONTRIBUTING.md for setup and what makes a PR mergeable, CHANGELOG.md for what changed in a release, and SECURITY.md for where vulnerability reports go, which is never the public tracker.

rbx-cli is a community tool under MPL-2.0. It is not affiliated with, endorsed by, or sponsored by Roblox Corporation.

rbx init

Bootstrap Roblox resources from the command line: create groups, universes, and places, then list IDs to plug into the other rbx subcommands.

rbx init covers the missing first step in a Roblox project bootstrap. You start with nothing and you need a group, a universe, and a few places before rbx place, rbx meta, rbx config, or rbx shop have anything to point at. It hits Roblox’s authenticated creation endpoints (cookie-based, since Open Cloud doesn’t expose these yet) and the listing endpoints so you can pipe fresh IDs straight into your other configs.

Features

  • Create a group: rbx init create-group --name ... --icon icon.png
  • Create a universe: rbx init create-universe [--group <id>], returns the universe ID and the root place ID in one call
  • Create a place inside an existing universe: rbx init create-place --universe-id <id>
  • Auto-record into rbxplace.toml: both creates append their new ids to the shared env map, prompting for the env/place name — comments and formatting in the file are preserved
  • Rename a place / universe by id: rbx init rename-place / rbx init rename-universe
  • List your groups: rbx init list-groups (cookie required)
  • List a group’s universes: rbx init list-universes --group <id> (no credential needed; see what these listings expose)
  • List a universe’s places: rbx init list-places --universe-id <id> (no credential needed)
  • Cookie auto-detect: offers to use the .ROBLOSECURITY of a local Roblox Studio install when no cookie was supplied. Opt-in: an interactive run is asked once, a run with nowhere to ask declines and says so. --auto-cookie is the standing yes. See docs/cookie.md
  • Friendly errors: common cases (name already taken, moderation rejection) are surfaced with a clear message instead of raw HTTP

Quick start

Bootstrap a brand-new project with a group, a universe, and an extra lobby place:

# 1. Create the group (returns id 123456789)
rbx init create-group --name "My Studio" --icon assets/group-icon.png --public

# 2. Create a universe under that group (returns universe_id + root_place_id)
rbx init create-universe --group 123456789 --name "[TEST] My Game" --env test

# 3. Add a second place inside the universe
rbx init create-place --universe-id 987654321 --name "Lobby" --place lobby

Steps 2 and 3 record their ids in rbxplace.toml as they go, so there’s nothing to copy by hand. Omit --env/--place/--name and you’ll be asked for them instead.

Starting from an existing group with universes already created? rbx init list-universes --group <id> prints their ids; write the [<env>] sections yourself, then check the result with rbx env list. See the field reference for what goes where.

Commands

rbx init create-group

Create a new Roblox group. An icon is required by Roblox (PNG or JPEG).

FlagRequiredDescription
--nameYesGroup display name
--iconYesPath to a PNG/JPEG icon file
--descriptionNoGroup description (default: empty)
--publicNoMake the group publicly joinable (default: invite-only)
--yes / -yNoSkip the confirmation prompt

Roblox requires the authenticated user to be eligible to create groups (verified email + minimum account age). If the chosen name is already taken, rbx init prints a clear message instead of a raw HTTP error.

rbx init create-universe

Create a new universe with a root place. The default template is Roblox’s empty baseplate; override with --template-place-id to clone from a specific place you own.

The new universe is recorded in rbxplace.toml as a new [<env>] block, so you don’t have to copy ids by hand afterwards.

FlagRequiredDescription
--groupNoGroup ID to create the universe under
--userNoUser ID to create the universe under. Mutually exclusive with --group
--template-place-idNoTemplate place ID to clone from (defaults to Roblox’s empty baseplate)
--nameNoRename the universe’s root place to this name after creation (Roblox displays the root place name as the universe name). Prompted for when omitted
--envNoEnv name to record the universe as. Prompted for when omitted. Refused with --no-record
--placeNoPlace key for the root place (default main). Refused with --no-record
--no-recordNoDon’t touch rbxplace.toml. Refused alongside --env or --place, which would be asking for the record and refusing it in one command
--yes / -yNoSkip the confirmation prompt. Whether the universe is still recorded depends on --env — see below

Owning it without a group. Omitting both --group and --user is not an error and does not require a group: the owner falls back to [owner] in rbxplace.toml, and with no [owner] either, to your own user account. So a personal universe is rbx init create-universe with neither flag.

Run it bare and it asks for what it needs, then confirms everything in one line:

$ rbx init create-universe
  Universe name (empty to keep the template's): My Test Game
  Env name in rbxplace.toml: (my_test_game) test
⚠ Create universe 'My Test Game' under group 1234567 and record it as [test]? [y/N] y
Creating universe under group 1234567 ...
Renaming root place 111222333 to My Test Game ...
Created universe (id 9876543299) with root place (id 111222333)
  name: My Test Game
Added [test] to rbxplace.toml

Or name everything up front for an unattended run:

rbx init create-universe --name "[TEST] My Game" --env test -y

Both prompts appear only when the corresponding flag is missing and stdin is a terminal.

Whether anything is recorded follows one rule, and --env is what decides it:

The runRecorded?
--no-recordNever, whatever else is passed
--env <name> givenYes, including under --yes and off a terminal. A missing rbxplace.toml or an env that already exists is an error, not a silent skip
neither, on a terminal, with the file presentYes, after asking for the env name
neither, under --yes, off a terminal, or with no rbxplace.tomlNo, silently

The middle row is the one worth knowing: --env is a request, so it is honoured rather than suppressed by --yes. That is what makes the unattended example above record. Without --env, --yes means “ask me nothing”, and since recording is driven by the prompt it is skipped rather than guessed at.

This command extends an existing rbxplace.toml; it does not create one.

If --name is given, the env name is suggested from it: a [TEST] ... prefix becomes test, otherwise the name is slugified.

Every question is asked before the universe is created. Creating one is irreversible, so aborting at a prompt costs nothing more than a re-run.

rbx init create-place

Add a new place inside an existing universe.

The new place is recorded under the env whose universe_id matches --universe-id, so there’s no env to pick: only the key name is asked for.

FlagRequiredDescription
--universe-idYesUniverse ID to create the place in
--template-place-idNoTemplate place ID to clone from (defaults to Roblox’s empty baseplate)
--nameNoRename the new place to this name after creation. Prompted for when omitted
--placeNoPlace key to record in rbxplace.toml. Prompted for when omitted (suggested from --name). Refused with --no-record
--no-recordNoDon’t touch rbxplace.toml. Refused alongside --env or --place
--yes / -yNoSkip the confirmation prompt. Same recording rule as create-universe
$ rbx init create-place --universe-id 9876543299
  Place name (empty to keep the template's): Lobby
  Place name under [test.places]: (lobby) 
⚠ Create place 'Lobby' under universe 9876543299 and record it as [test].places.lobby? [y/N] y
Created place (id 444555666) in universe 9876543299
  name: Lobby
Added places.lobby to [test] in rbxplace.toml

Same skip rules as create-universe. If no env points at --universe-id, recording is skipped silently — unless you asked for it explicitly with --env/--place, in which case it’s an error. If several envs point at the same universe, pass --env to disambiguate.

rbx init rename-place

Rename a place by id.

FlagRequiredDescription
--placeYesPlace ID to rename, not a place name
--nameYesNew display name
--yes / -yNoSkip the confirmation prompt

--place means something different here than anywhere else in rbx. Everywhere else it is a key from [<env>.places]; on this one subcommand it shadows that with a raw place id, so --place lobby fails to parse rather than resolving. Pass the number.

rbx init rename-universe

Rename a universe by id. Roblox stores the display name on the root place; this resolves the universe’s root place and renames it.

FlagRequiredDescription
--universe-idYesUniverse ID
--nameYesNew display name
--yes / -yNoSkip the confirmation prompt
rbx init list-groups

List every group the authenticated user belongs to, with role and rank. Cookie required.

rbx init list-universes

List the universes owned by a group, published or not. No credential is required and a cookie adds nothing to the result: see the listings need no credential.

FlagRequiredDescription
--groupYesGroup ID
rbx init list-places

List every place inside a universe.

FlagRequiredDescription
--universe-idYesUniverse ID

Authentication

rbx init only uses cookie auth. Roblox does not expose group, universe, or place creation through Open Cloud, so there’s no API key option. The cookie is supplied via the global --cookie flag, the RBX_COOKIE env var, or a local Roblox Studio install.

That last one is opt-in: finding a signed-in Studio is not the same as being allowed to send its session. --auto-cookie is the standing yes, an interactive run is asked once, and a run with nowhere to ask — CI, a pipe, a cron job — declines and says so. --no-auto-cookie is the standing no.

This is the command with the least choice about it, so it is worth knowing what you are handing over: see docs/cookie.md for the resolution order in full, the stderr notice on auto-detection, and why the cookie never reaches disk.

CommandCookie required?
create-groupYes
create-universeYes
create-placeYes
rename-placeYes
rename-universeYes
list-groupsYes
list-universes --group <id>No. The listing answers in full without one
list-places --universe-id <id>No. The listing answers in full without one

The listings need no credential

list-universes and list-places are the two read commands here, and neither one is gated. Measured against a private universe that has never had a player, with no cookie, no API key and no session:

GET develop.roblox.com/v1/universes/{id}/places  → 200, every place, with names
GET games.roblox.com/v2/groups/{id}/gamesV2      → 200, every game

The second one is worth being precise about, because the query parameter looks like a permission and is not. Measured on one group, anonymously:

RequestGames returned
accessFilter=20
accessFilter=14
no accessFilter4

accessFilter=2 is the public filter. 1, and omitting it, are the unfiltered form, and unfiltered means unfiltered for anybody. rbx init sends 1, so it sees a group’s staging copies and unreleased projects, and so does anyone else who asks.

Roblox treats the existence, id and name of a universe or place as public. What stays behind a session is the content: develop.roblox.com/v1/places/{id} answers 404 anonymously, and whether a place is playable is not in these listings at all.

Two practical consequences. Do not rely on a universe being unlisted to keep a project quiet before announcing it. And rename test places before creating them under a real account: Roblox’s default place names embed the owner’s username, and those names come back to an anonymous caller.

How it works

  • Group creation hits groups.roblox.com/v1/groups/create (multipart upload with the icon).
  • Universe creation hits apis.roblox.com/universes/v1/universes/create with a templatePlaceId.
  • Place creation hits apis.roblox.com/universes/v1/user/universes/{id}/places.
  • Listing a group’s universes uses games.roblox.com/v2/groups/{id}/gamesV2?accessFilter=1. accessFilter=2 is the public-only filter; 1 is unfiltered, for any caller (see above).
  • Listing a universe’s places uses develop.roblox.com/v1/universes/{id}/places.

All write endpoints transparently handle CSRF: a 403 response with an x-csrf-token header caches the token and retries once. Listing endpoints retry on 429 / 5xx with exponential backoff (max 3 attempts).

rbx import

Adopt an existing universe in one command. rbx import resolves a universe you already have on Roblox, writes the env into rbxplace.toml, and brings its passes, badges, products, metadata and live config into the TOML files each tool manages — with the lockfiles that make the immediately-following check green.

It is the command for a game that predates this toolkit, which is nearly every game. Without it, adoption means copying ids out of the Creator Hub by hand: the class of transcription where one wrong digit points a sync at somebody else’s universe.

Features

  • One gesture - Universe, places, owner, monetization, metadata and live config, in one pass
  • Composition, not a second implementation - Each domain is imported by the command that already owns it, so nothing here can disagree with what sync and check expect
  • Safe on an existing file - Other envs, [owner], [codegen] and every comment in rbxplace.toml survive an import verbatim
  • Repeatable - A second --env layers a second universe onto the same files, in the differential overlay layout the tools already use
  • Nothing hidden - What could not be imported is named at the end, with the reason and the fix

Usage

rbx import --universe-id 123456789 --env prod       # Adopt a universe as "prod"
rbx import --universe-id 987654321 --env staging    # Add a second, beside the first
rbx import --universe-id 123456789 --env prod --dry-run    # Resolve and report, write nothing
rbx import --universe-id 123456789 --env prod --only shop  # One domain
rbx import --universe-id 123456789 --env prod --dir ./game # Write somewhere other than .
rbx import --universe-id 123456789 --env prod --strict     # Fail instead of skipping a domain

What it writes

FileWritten byContains
rbxplace.tomlimport itself[<env>] with universe_id and every place, plus [owner] if the file had none
rbxshop.toml + .lockrbx shop init --from-remote / rbx shop pullGame passes, badges, developer products, with icons downloaded
rbxmeta.toml + .lockrbx meta init --from-remote then rbx meta pull --accept-remoteName, description, devices, social links, private servers, icon and thumbnails
rbxconfig.toml + .lockrbx config pullThe live in-experience config, live being authoritative

import computes none of those lockfile entries. It runs each tool’s own import path, which is what makes the zero-drift guarantee reachable at all — the lockfile is written by the same code that later reads it.

The acceptance criterion

import then check is green, with nothing in between.

rbx import --universe-id 123456789 --env prod
rbx check --env prod

rbx check discovers the tools from the files the import just wrote and returns one exit code for all of them: 0 clean, 2 drift, 1 a check that could not answer. See docs/check.md.

If there is drift straight after an import, the import is wrong. import prints that exact command line when it finishes, --dir included when it was passed.

Running it twice

The second import is what decides whether the command is usable, and it is the case the implementation is shaped around.

rbx import --universe-id 111 --env prod
rbx import --universe-id 222 --env staging

The second run:

  • appends [staging] to rbxplace.toml; [prod], [owner], [codegen] and every comment are untouched, byte for byte;
  • pulls rather than re-initialises each domain, so rbxshop.toml gains an [envs.staging.*] overlay for the fields that differ from base rather than being overwritten.

Which of init --from-remote and pull runs is decided by whether the config file exists, not by whether this is your first import — a directory can already be under management for reasons that have nothing to do with this command.

Those pulls run with --accept-remote --yes, because the live game is what an import is adopting. The consequence is worth knowing before you run it a second time: a local edit you have not synced is resolved to the remote value without a prompt. import names the files it is about to layer onto, on the real run and under --dry-run alike:

! rbxshop.toml, rbxmeta.toml already exist — this env is layered onto them, and a
  local edit that disagrees with Roblox is resolved to the remote value without
  asking. Commit or sync local edits first if you have any.

Commit first, or run the domain’s own sync, if you have edits you meant to keep.

An env that is already in rbxplace.toml is left exactly as it is. If its universe_id disagrees with the one you passed, import says so and keeps the file’s:

! [prod] already points at universe 999 — kept. Nothing was retargeted; use a
  different --env if you meant to add this universe.

What it cannot import

Reported at the end of every run, because a directory that looks adopted but quietly omits something is worse than one that failed.

! 1 thing could not be imported:
  meta server fill, copying permission, beta mode: these live only on legacy
      endpoints that need a Roblox session cookie
    -> re-run with --cookie, or set them by hand in rbxmeta.toml

Two categories:

  • Cookie-only metadata. server_fill, allow_copying and beta_mode have no Open Cloud endpoint. rbx meta models them and sync can write them, but nothing can read them back without a session cookie — so an import that resolves no cookie at all leaves them unset and says so. The test is what the meta step resolves, not what you typed: it auto-detects like any other rbx meta run, so on a machine with Studio signed in these fields are read and this line does not appear. Without that line, the first meta sync after an import looks like it is inventing changes.
  • A domain that failed. By default a domain that errors — usually a key missing one scope — is skipped and reported rather than aborting the run, because a half-written directory with no explanation is the worse outcome. --strict inverts that, which is what you want in CI.

Flags

FlagDefaultMeaning
--universe-id <id>requiredThe universe to adopt
--env <name>requiredName for its env in rbxplace.toml. all, owner and codegen are reserved and refused
--dir <path>.Where to write the config files
--dry-runoffResolve and report; write nothing
--strictoffFail on a domain error instead of skipping and reporting it
--only <domains>allComma-separated: shop, meta, config

--places <path> (global) points at a shared rbxplace.toml outside --dir; otherwise the file is written next to everything else. rbx check --dir resolves the same default the same way, so rbx check --dir <path> reads back exactly what rbx import --dir <path> wrote.

Required API scopes

The import calls each tool’s own read paths, so it needs the read half of what those tools need:

StepScopes
Resolve the universeuniverse:read
List placesnone — the legacy develop host answers without any credential
Shopgame-pass:read, developer-product:read, legacy-badge:manage to list badges, legacy-asset:manage for icon downloads. Without that last one icons still arrive, from the public thumbnail service, but rescaled rather than as stored
Metauniverse:read, universe.place:read. Icons and thumbnails need nothing: pull reads them from thumbnails.roblox.com, the public service, with no key attached
Configuniverse:read

rbx doctor --universe-id <id> answers whether the loaded key carries them, before you run this.

Where the place list comes from

Open Cloud can read one place (/cloud/v2/universes/{id}/places/{p}) but cannot enumerate them, so the place list is fetched from develop.roblox.com, the same host rbx init list-places uses.

That listing needs no credential, and a private universe is no exception: it answers in full to an anonymous caller. So this step never fails for want of a cookie, and passing one widens nothing. See the listings need no credential for what was measured.

The call does not auto-detect a Studio cookie, and now that the listing is known to be open there is nothing for auto-detection to buy here. The meta step that runs afterwards is an ordinary rbx meta invocation and resolves the cookie the usual way, which is where a cookie genuinely changes the outcome. docs/cookie.md has the full order.

The root place is always written as places.main, whatever Roblox calls it, because main is the key the rest of the toolkit resolves to when --place is omitted. Other places take a slugified form of their display name, suffixed if two collide.

  • docs/place.md — the rbxplace.toml this writes, and what reads it
  • docs/shop.md — what lands in rbxshop.toml, and the overlay layout a second import produces
  • docs/meta.md — the metadata fields, including the cookie-only ones
  • docs/cookie.md, the trust model: what the cookie is for, how it is resolved, and why it never reaches disk
  • docs/config.md — live config, where live is authoritative
  • docs/doctor.md — check the key before importing with it

rbx env

Read rbxplace.toml — the shared file that maps env names to universe and place ids.

Every other subcommand resolves --env against this file. rbx env is the read side of it: it answers “what id will --env prod actually target?” without opening the TOML by hand, and without calling Roblox. It is fully offline — no API key, no cookie.

Features

  • List - See every env, its universe id, and its places, rendered in the file’s own TOML shape
  • Get - Print one bare value to stdout, ready for $(...) capture in scripts and CI
  • Gen-module - Export the whole map as a Luau/Lua/JSON/TypeScript module for your game code, with --check to prove the committed copy was never hand-edited
  • JSON - --json on list and get writes one document to stdout and nothing else, with documented field names, for jq and CI
  • Completions - The env and place names in this file are what --env <TAB> and --place <TAB> offer, in bash, zsh, fish and PowerShell
  • Same resolution as everything else - Delegates to the shared resolver, so rbx env get place-id --env prod prints the exact id rbx place upload --env prod would write to
  • Offline - Reads the local file only

Quick start

rbx env list                            # everything in rbxplace.toml
rbx env get universe-id --env prod      # 9876543211

Commands

rbx env list

Show the envs defined in rbxplace.toml. Pass the global --env <name> to show a single one.

rbx env list
rbx env list --env prod       # just this env
rbx env list --names          # env names only, one per line
rbx env list --place-names    # place names only, one per line
rbx env list --json           # one JSON document on stdout
FlagDescription
--namesPrint env names only, one per line, no colors — for scripts and completion helpers
--place-namesPrint place names only, one per line. Every env’s, deduplicated, unless --env narrows it
--jsonWrite the envs to stdout as one JSON document. Rejected together with either name listing
--env <name>Show only this env (global flag). --env all is the same as omitting it
--places <path>Path to rbxplace.toml (global flag, default rbxplace.toml)

Output mirrors the file so it maps back onto what you’d edit:

rbxplace.toml
owner = group 1234567

[dev]
universe_id  = 9876543210
places.lobby = 987654321
places.main  = 123456789012345

[prod]  confirm
universe_id = 9876543211
owner       = user 42
places.main = 234567890123456

confirm next to the env header means that env has confirm = true and will prompt before write operations. A per-env owner line appears only when [<env>.owner] overrides the top-level [owner].

--json

One JSON document on stdout, nothing else. Diagnostics — the unknown-key warning in particular — stay on stderr, so the document parses even when the file has something wrong with it.

{
  "schema_version": 1,
  "places_file": "rbxplace.toml",
  "owner": { "type": "group", "id": 1234567 },
  "envs": [
    {
      "name": "dev",
      "universe_id": 9876543210,
      "confirm": false,
      "codegen": true,
      "places": { "lobby": 987654321, "main": 123456789012345 }
    },
    {
      "name": "prod",
      "universe_id": 9876543211,
      "env": "production",
      "owner": { "type": "user", "id": 42 },
      "confirm": true,
      "codegen": true,
      "places": { "main": 234567890123456 }
    }
  ]
}
FieldTypeMeaning
schema_versionintegerDocument format, shared with rbx check --json. 1 today. Refuse a version you do not understand
places_filestringThe rbxplace.toml this was read from, as given or defaulted
ownerobjectThe top-level [owner]. Absent when the file sets none
owner.type / .idstring / integeruser or group, and the id
envsarray of objectsOne per env, in name order. Narrowed to one entry by --env <name>
envs[].namestringThe section name — what --env takes
envs[].universe_idintegeruniverse_id of the env
envs[].envstringThe env rename. Absent when unset, in which case name is the answer
envs[].ownerobjectThe per-env [<env>.owner] override. Absent when the env inherits the top-level one
envs[].confirmbooleanWhether writes to this env prompt first
envs[].codegenbooleanWhether rbx env gen-module emits this env
envs[].placesobjectPlace name to place id. Empty for envs used only at universe scope

envs[].owner is the override as the file spells it, never the resolved value, so .envs[].owner // .owner reproduces the fallback and “inherited” stays distinguishable from “overridden”. Optional fields are omitted rather than emitted as null, so has("owner") is a usable test.

A file with no envs is an empty envs array and exit 0, matching --names rather than the human listing, which errors. Both are read by scripts; the error is for the person who asked to see the file.

rbx env list --json | jq -r '.envs[] | select(.confirm) | .name'   # envs that prompt
rbx env list --json | jq -r '.envs[].places | keys[]' | sort -u    # every place name

The two name listings

Two listings meant to be read by something other than a person. Both write one bare value per line to stdout, nothing else, no colors, and both exit 0 on a file that simply has nothing to list:

rbx env list --names          # dev
                              # prod
rbx env list --place-names    # lobby
                              # main

These are a supported surface, not debug output. The shell completions generated by rbx completions call exactly these two commands, and so can your scripts — the format is one value per line and will not grow columns, headers or color.

--place-names answers across every env by default, deduplicated: a place name is a role, and main defined in three envs is one candidate, not three. Narrow it with the global --env:

rbx env list --place-names --env prod

The union is the default because the question is usually asked before an env has been chosen — a completion for --place cannot wait for --env to be typed. The cost is that in a file where envs hold genuinely different places, the unnarrowed list offers names that the env you eventually pick does not have; the command you run then says so.

Both listings fail with an empty stdout when rbxplace.toml is missing or does not parse. The diagnostic goes to stderr, so 2>/dev/null is all a caller needs to get silence instead of an error.

rbx env get

Print a single value. The value goes to stdout bare — no label, no color, no trailing decoration — so it can be captured directly.

UNIVERSE=$(rbx env get universe-id --env prod)
PLACE=$(rbx env get place-id --env prod --place lobby)

rbx env get owner-id                       # top-level [owner], no --env needed
rbx env get universe-id --env all          # one "<env><TAB><value>" line per env
FieldValueNeeds --env
universe-iduniverse_id of the envyes
place-idPlace id from [<env>.places], honoring --placeyes
owner-idOwner id: [<env>.owner] if set, else top-level [owner]no
owner-typeuser or group, resolved the same wayno

The file’s own snake_case spellings are accepted as aliases (universe_id, place_id, …), so you can type what you see in the TOML.

Without --place, place-id follows the same defaulting rule as every other subcommand: main if it exists, otherwise the only entry, otherwise an error listing the available names.

With --env all, output is tab-separated so it pipes cleanly:

rbx env get universe-id --env all | cut -f2

Missing envs, missing places, and a missing [owner] are errors (exit code 1) with the available options listed on stderr — never a silent empty value, so a failed lookup can’t quietly become an empty shell variable.

--json

Same answer, wrapped so a script can tell which field it asked for and which env replied. One JSON document on stdout, nothing else.

rbx env get universe-id --env prod --json
{
  "schema_version": 1,
  "field": "universe-id",
  "value": "9876543211",
  "results": [{ "env": "prod", "value": "9876543211" }]
}
rbx env get universe-id --env all --json
{
  "schema_version": 1,
  "field": "universe-id",
  "results": [
    { "env": "dev", "value": "9876543210" },
    { "env": "prod", "value": "9876543211" }
  ]
}
FieldTypeMeaning
schema_versionintegerDocument format, shared with rbx check --json. 1 today
fieldstringThe field asked for, in its canonical spelling: universe-id, place-id, owner-id, owner-type — the alias you typed is normalized
valuestringThe answer, when one env was targeted. Absent under --env all
resultsarray of objectsOne entry per env answered, in the same order the human form prints. Always present
results[].envstringThe env. Absent when the lookup needed none — owner-id and owner-type answer from the top-level [owner] without --env
results[].valuestringThe value

Values are always strings, including the ids: it is the same text the bare form prints, one filter reads owner-type and universe-id alike, and a 64-bit place id is never handed to a consumer that would round it.

Which of value and results you get is decided by the invocation, never by the data. --env all omits value even against a file with exactly one env, so a filter cannot start working by accident and break when a second env lands.

UNIVERSE=$(rbx env get universe-id --env prod --json | jq -r .value)
rbx env get place-id --env all --json | jq -r '.results[] | "\(.env)=\(.value)"'
rbx env gen-module

Export the env map as a module your game code can import, so runtime code branches on the env it’s running in instead of hardcoding ids.

rbx env gen-module --out src/types/EnvironmentInfo.luau
rbx env gen-module --out src/environments.lua
rbx env gen-module --out config/environments.json
rbx env gen-module --out src/types/EnvironmentInfo.ts
FlagDescription
--outOutput file path. Format inferred from the extension: .lua, .luau, .json, or .ts. Optional when [codegen].output is set
--checkCompare the existing file against what would be generated instead of writing it. Exits 2 on a difference

Declaring the path once

Rather than repeating --out in your shell, your hook and your CI job, put it in rbxplace.toml:

[codegen]
output = "src/shared/Envs.luau"   # relative to rbxplace.toml

[dev]
universe_id = 9876543210
rbx env gen-module           # writes src/shared/Envs.luau
rbx env gen-module --check   # verifies the same file

This is the form to prefer wherever the check runs. A --check spelled with a different path than the generator passes green while verifying a file nobody consumes — the one failure mode a drift guard cannot afford. An explicit --out still wins when passed.

[codegen] is a reserved section, like [owner]: it is not read as an env, and every tool that rewrites rbxplace.toml preserves it.

Checking the committed copy

--check re-renders in memory and asserts the file on disk still matches rbxplace.toml, so a module that was edited by hand — or left stale after an env changed — fails instead of shipping. It stays offline, so it runs in a pre-commit hook and in CD. See Guarding generated files for the hook and CI snippets, and for the one thing that breaks the comparison.

The output is an array of environment objects, each with env, universeId, and placeIds (an array of { name, id }). Luau and TypeScript additionally get a union type of every env name:

export type EnvironmentType = "dev" | "prod"

export type EnvironmentInfo = {
  env: EnvironmentType,
  universeId: number,
  placeIds: { { name: string, id: number } },
}

local envs: { EnvironmentInfo } = { ... }
return envs

The optional env key in rbxplace.toml overrides the name game code matches on (it defaults to the section name):

[dev]
universe_id = 1234567890
env = "Dev"        # what EnvironmentType will contain
[dev.places]
main = 111111111

It renames an env; it does not alias two envs onto one. Two sections resolving to the same name — whether through two env fields or an env that collides with another section’s name — is rejected when the file loads, with both sections named. Env names are what game code matches on, so a duplicate would make every lookup by name ambiguous.

Envs and places are emitted in name order, so regenerating from an unchanged rbxplace.toml produces a byte-identical file — safe to commit and to run in a pre-commit hook.

rbx env rm

Remove an env from rbxplace.toml and from every file keyed by it.

rbx env rm staging --dry-run   # list what would go
rbx env rm staging             # asks before writing
rbx env rm staging --yes       # for scripts
FlagDescription
--dry-runList what would be removed without writing anything
-y, --yesSkip the confirmation prompt
--places <path>Path to rbxplace.toml (global flag, default rbxplace.toml)

The env is named as a positional argument, not read from the global --env. This is the one command where naming the wrong env deletes something, and --env is a flag people leave set in a shell for a whole session.

What it touches, when the file exists:

FileWhat goes
rbxplace.tomlThe [<env>] block
rbxmeta.tomlThe [envs.<env>] overlay
rbxmeta.lock.tomlThe [envs.<env>] section
rbxshop.tomlThe [envs.<env>] overlay
rbxshop.lock.tomlThe [envs.<env>] section
rbxconfig.lock.tomlThe [envs.<env>] section
rbxapikey.lock.tomlThe [envs.<env>] section
rbxapikey.tomlThe env’s name, out of every list that holds it
<codegen.output>/<env>.luauThe per-env module rbx shop codegen wrote

rbxapikey.toml is the odd one. Every other file gives an env a table of its own, removed whole; this one names envs inside arrays[settings] default_envs, and each key’s envs, itself either one list or one list per named group. All of them are walked. Leaving a name behind would not be untidiness: an env that rbxplace.toml no longer defines is an error to the api key commands, not something they skip, so the next rbx apikey run would fail on a file you never edited.

Emptying one of those lists is reported rather than done quietly, because it changes what a key targets. A key whose own envs is empty falls back to [settings] default_envs, so a key that named only the removed env may now reach envs it never named — a removal widening something. An emptied group is the same problem from the other side: group names are key identity, so what is left is a key declaration targeting nothing. The command prints each list it emptied and leaves the decision to you.

Everything is planned before anything is written, so a file that fails to parse stops the run rather than leaving the project half-edited. Comments and key order survive: the files are edited as documents, not reserialised through the config model.

The aggregate generated files — init.luau, the type module, whatever rbx env gen-module writes — are regenerated, not deleted, so the command names them at the end instead of touching them.

Nothing is deleted on Roblox, and nothing could be. A game pass or a developer product cannot be deleted there at all, only taken off sale; a badge can only be disabled; a universe can be deactivated and is still there. A command called destroy would be describing something it does not do, on resources people paid money for. This removes the env, which is the part that really can be removed.

An env that is not in rbxplace.toml is refused, and the error names the ones that are — a typo must not report success having done nothing. [owner] and [codegen] are top-level tables and not envs, so they are refused too.

Every field, and where it goes

rbxplace.toml has exactly two reserved top-level tables. Every other top-level table is an env, named by its key.

# ── reserved ────────────────────────────────────────────────
[owner]                              # who owns this project
type = "group"                       # "user" or "group"
id = 1234567

[codegen]                            # where `rbx env gen-module` writes
output = "src/shared/Envs.luau"      # relative to this file

# ── everything below is an env ──────────────────────────────
[prod]
universe_id = 9876543211
confirm = true                       # prompt before writes to this env
env = "Production"                   # what game code matches on
owner = { type = "user", id = 42 }   # overrides the top-level [owner]
[prod.places]
main = 234567890123456
lobby = 234567890999999

[ci]
universe_id = 555
codegen = false                      # tooling env: keep it out of the module

Reserved tables

TableFieldTypeDefaultMeaning
[owner]type"user" | "group"Who owns the project. Tools without their own owner field fall back to this
[owner]idintegerThe user or group id
[codegen]outputpathWhere rbx env gen-module writes, relative to this file. Omit and --out becomes required

Env fields

FieldTypeDefaultMeaning
universe_idintegerrequiredThe universe this env targets
placestable{}Place name → place id. main is the default when --place is omitted
confirmboolfalsePrompt before write operations on this env (upload, sync, rollback, promote)
envstringthe section nameWhat game code matches on. A rename, not an alias: two envs resolving to the same name is an error
ownertablethe top-level [owner]Per-env owner override, for the rare env living under a different account
codegenbooltruefalse keeps the env out of the generated modules — see below

Where a field carries a (X.Y.Z+) tag, it needs at least that release. This page describes main, which is where a feature lands before it ships — and /blob/main/docs/env.md is the URL links and search results hand you — so a tagged field is newer than whatever rokit.toml pins until you check rbx --version. Nothing in the table above is tagged today: every field here is in the latest release.

codegen = false

For an env that exists for tooling and never ships: a universe you upload to from CI, for instance. It stays a normal env everywhere else — --env resolves it, --env all includes it, list and get report it. It is only kept out of the generated modules, so adding one does not widen EnvironmentType and force game code to acknowledge an env it never runs in.

The trade is real and worth stating: nothing then maps that universe back to an env at runtime. If the game boots there and your code resolves its env from game.GameId, it will not find one — and rbx shop’s dispatcher errors outright rather than guessing. Correct for a universe that only ever receives uploads; wrong for one that runs gameplay.

Marking every env codegen = false is refused rather than emitting a module whose type union has nothing in it.

Unrecognised keys

A key no table in the list above claims is ignored, and named on stderr:

warning: rbxplace.toml: 1 unrecognised key, ignored by rbx 0.2.0:

  [assets] codgen
    known keys: universe_id, env, places, owner, confirm, codegen

An ignored key changes nothing. Either it is misspelled, or it comes from a
release newer than the one you are running — check the changelog for the
version that introduces it before assuming it took effect.

It stays a warning rather than an error on purpose. Every tool in the suite reads this one file into its own narrower struct, and a key must survive an rbx older than the release that introduced it — otherwise adopting a new field would mean upgrading every machine in the same instant. What it must not do is pass for applied: from the outside, an ignored key and an honoured one produced the same silent exit 0.

gen-module --check carries the same fact into its failure. Its normal advice — regenerate and commit — assumes the committed module is the stale side. When a key was ignored, the check itself is reading the inputs wrong, the committed file may be the correct one, and regenerating would bake the misreading in. So the check names that possibility instead of stating the fix unconditionally:

1 generated file no longer matches rbxplace.toml. Run `rbx env gen-module`
and commit the result, unless one of the following applies.

1 key in rbxplace.toml was ignored (listed above). If one of them was meant
to change what is generated, this check is reading the wrong inputs and the
committed file may be the correct one — regenerating would bake the
misreading in. Upgrade rbx, or fix the spelling, before running the fix.

Place names under [<env>.places] are data, not keys, and are never reported.

Shell completions for --env and --place

rbx completions <shell> writes a script that completes both with the names in the rbxplace.toml of the directory you are standing in. It calls rbx env list --names and --place-names at TAB time rather than baking the values in, which is why those two listings are a supported surface rather than debug output.

The install paths, the four shells, and what happens outside a project are on its own page.

Where the file comes from

rbx env only reads it. One command writes it:

  • rbx place fetch --env <name> --write — refresh one env’s places from the live universe

Otherwise it is yours to write. A minimal file is one [<env>] section with a universe_id; rbx init list-universes prints the ids to put in it, and rbx init create-universe appends a section for a universe it creates.

Nothing generates this file wholesale, and that is deliberate. Every writer here only inserts lines. Reserializing the document through serde would drop comments, reorder keys, and silently delete any field it does not model, env overrides included.

See also

  • rbx init — create the universes and places this file points at
  • rbx place — upload, download, and promote place files across these envs
  • rbx open — launch Studio at one of these places

rbx apikey

Manage Roblox Open Cloud API keys declaratively from rbxapikey.toml. Supports multiple secret backends (lockfile, custom file) and automatic scope validation against Roblox’s live API.

Features

  • Declarative configuration - Define all keys in rbxapikey.toml with explicit scopes
  • Multi-backend secrets - Store API key secrets in lockfile (default) or custom file
  • Scope validation - Embedded catalog with warnings (not errors) for unknown scopes, enabling durability as Roblox adds new scopes
  • State reconciliation - status command detects drift between config, lockfile, and Roblox
  • Auto-introspection - Verify key creation succeeded and detect configuration drift
  • Datastore granularity - Restrict universe-datastore scopes to specific datastore names

Usage

Create a key

rbx apikey create <key>              # Create one key from rbxapikey.toml
rbx apikey create --all              # Create all keys
rbx apikey create <key> --no-ip      # Allow all IPs (no CIDR restriction)
rbx apikey create <key> --force      # Overwrite existing lockfile entry
rbx apikey create <key> --no-verify  # Skip post-create introspection

Manage keys

# List and inspect
rbx apikey list                      # Overview of all keys and expiry
rbx apikey list --expiry-only        # Compact view: just name and expiration dates
rbx apikey list --sort expiry        # Sort keys by expiration date (nearest first)
rbx apikey list --remote             # Every key on the account, tracked or not
rbx apikey list --remote --group-id 445566778   # A group's keys instead of yours
rbx apikey list --json               # One JSON document on stdout, never a secret

# Clean up the account
rbx apikey prune --dry-run           # Show what would be offered, delete nothing
rbx apikey prune --untracked-only    # Only offer keys this project does not track
rbx apikey prune --expired-only      # Only offer keys past their expiry

# Status and reconciliation
rbx apikey status                    # Compare config vs lockfile vs Roblox
rbx apikey status --remote           # Also query live Roblox API for drift detection
rbx apikey status --json             # One verdict per key, as a JSON document

# Update and regenerate
rbx apikey update <key>|--all        # Apply TOML configuration to Roblox
rbx apikey update <key> --no-ip      # Update without CIDR restriction
rbx apikey regenerate <key>|--all    # Rotate the API key secret

# Delete and introspect
rbx apikey delete <key>|--all        # Delete key from Roblox and lockfile
rbx apikey delete <key> --yes        # Skip confirmation prompts
rbx apikey delete <key> --clean-files  # Also delete secret_file without asking
rbx apikey introspect <key>          # Show what Roblox has stored (key < 1h old)
rbx apikey resolve <key>             # Print the raw secret (for scripts)

# Scope catalog
rbx apikey scopes list               # All known scopes grouped by target type
rbx apikey scopes show <scopeType>   # Details for one scope type
rbx apikey scopes show universe --json          # The same, as a JSON document
rbx apikey catalog regenerate [url]  # Regenerate scope catalog from openapi.json

Commands explained

list command:

  • rbx apikey list - Show all keys with full details (alphabetical order)
  • rbx apikey list --expiry-only - Compact view: just key names and expiration dates
  • rbx apikey list --sort expiry - Sort by expiration date (nearest first)
  • rbx apikey list --expiry-only --sort expiry - Compact + sorted by expiry

Expiration colors:

  • Green - Expires in >30 days (healthy)
  • Yellow - Expires in <7 days (rotate soon)
  • Red - Already expired or missing secret

update vs status:

  • rbx apikey update <key>|--all - One-way sync: apply your TOML configuration to Roblox. Updates metadata (enabled, expiry, IP restrictions) and scopes on existing keys.
  • rbx apikey status - Compare: shows differences between TOML (desired), lockfile (tracked), and Roblox (actual). Does not modify anything.
  • rbx apikey status --remote - Same as above, but also queries the live Roblox API to detect drift.

Key creation workflow:

  1. Define keys in rbxapikey.toml
  2. Run rbx apikey create <key> to generate the key on Roblox and save the secret
  3. Later, edit rbxapikey.toml and run rbx apikey update <key> to re-apply the config

Configuration

Create rbxapikey.toml in your project root. Keys target Roblox universes by env name (declared in rbxplace.toml), not raw universe ids — the same source of truth used by rbxmeta, rbxconfig, and the rest of the suite.

[settings]
default_enabled = true                       # optional
default_expiration_months = 3                # optional (omit for no default expiry)
default_allowed_cidrs = []                   # optional
default_envs = ["prod"]                      # optional; fallback for keys that omit `envs`
name_prefix = "mygame_"                      # optional; prepended verbatim (e.g. "mygame_deploy")
default_secret_file = ".secrets/{name}.env"  # optional; {name} is the key name, {env_group} its group

[keys.mykey]
envs = ["dev", "prod"]                       # resolves to universe_ids via rbxplace.toml
scopes = ["asset:read,write", "universe:read"]
expiration_months = 3                        # 3 months from now
# or: expiration_days = 90                   # 90 days from now (more precise)
# or: expires_at = "2025-12-31T00:00:00Z"    # exact date (ISO 8601)

One key per environment

envs = ["dev", "staging", "prod"] declares one key that can reach all three universes. That is right for a read-only observability key and wrong for everything else: the safety model this tool is built on is that scopes and universes are bound at creation, so the blast radius of a key is whatever you gave it on the day you made it.

Writing envs as a table of named groups instead declares one key per group:

[keys.deploy]
scopes = ["universe-places:write"]

[keys.deploy.envs]
ci   = ["dev", "staging"]   # → key deploy_ci, scoped to the dev + staging universes
prod = ["prod"]             # → key deploy_prod, scoped to the prod universe only

Two keys, one declaration. Everything except the envs — scopes above all — is written once, so a scope added for production cannot be forgotten for CI. That is the whole point: the alternative is three near-identical [keys.*] blocks, and a scope added to one and not the others compiles fine, syncs fine, and desyncs your environments silently.

TOML distinguishes an array from a table on its own, so there is no flag to set and the array form means exactly what it always meant.

The group name is identity, not decoration. It names four things at once:

with name_prefix = "mygame_"
the key you name on the command linedeploy_ci
its display name on Robloxmygame_deploy_ci
its secret file under .secrets/{name}.env.secrets/deploy_ci.env
its lockfile entry[keys.deploy_ci]

So rbx apikey create deploy_ci, status, regenerate, delete and the rest all work on a generated key exactly as they do on a hand-written one. Naming the declaration (rbx apikey create deploy) matches no key, and says so, listing the keys it did produce.

  • Adding an env to a group extends that key. ci = ["dev", "staging", "qa"] widens deploy_ci on the next update, which is an ordinary change surfaced by drift detection.
  • Renaming a group renames a key. cibuild makes deploy_ci disappear from the config and deploy_build appear. status reports the first as ORPHAN_LOCK and the second as PENDING, which is what renaming a hand-written key already does. Roblox does not learn the new name until you create it; delete the old key rather than leaving it live.
  • {env_group} is available in secret_file and default_secret_file for layouts that want the group as a path segment of its own: .secrets/{env_group}/{name}.env. It expands to nothing for a key that does not fan out. You rarely need it, because {name} already carries the group.

Two shapes are refused at load rather than at create time, both because they produce keys that cannot be told apart:

  • a group whose generated name collides with another key in the file;
  • a fan-out whose keys would all write their secret to one path, because secret_file was given a literal path with no placeholder in it. The last create would overwrite the previous key’s secret, leaving a live key on Roblox that nothing local can authenticate as.

An empty group (ci = []) is refused too. A key targeting no universe is scoped to all of them, which is the opposite of what the syntax is for.

Duplicate scopes

A scope written twice is collapsed on the way in, at both levels — ["asset:read", "asset:read"] and ["asset:read,read"] alike — and the collapse is reported on stderr, naming the table and the entry. First-seen order survives, so what you read back from apikey introspect is in the order your file wrote.

Collapsing is safe: a duplicate grants nothing extra, it only makes the payload Roblox is asked to store redundant. It is reported rather than dropped in silence because it is almost always a merge artefact, and the line it was meant to be is worth a look.

Two entries sharing a scope type but listing different operations (asset:read next to asset:write) are not duplicates and are left alone. Folding them into their union is a normalisation, not a deduplication.

Drift validation

Each create/update/regenerate operation writes the resolved universe_ids into the lockfile as a snapshot of what’s actually deployed. On the next run, the tool re-resolves the envs and compares against the snapshot. When they differ, the tool refuses to proceed and prints the discrepancy. This catches accidental retargeting (e.g. someone edited rbxplace.toml to point env dev at a fresh universe) before the change is silently pushed to Roblox.

To acknowledge an intentional change, delete that key’s entry from rbxapikey.lock.toml and re-run the operation. The next run will re-resolve, re-create owner cache, and write a fresh snapshot.

Global settings

  • default_enabled - (optional) Whether keys are enabled by default (defaults to true)
  • default_expiration_months - (optional) Default months until key expiry; omit to allow keys without expiry
  • default_allowed_cidrs - Default IP CIDR blocks for keys that set no allowed_cidrs of their own. Not optional in practice: with no allowlist from either place, create and update refuse and name the three ways out — set it here, set allowed_cidrs on the key, or pass --no-ip to allow every address. Nothing is inferred; neither this tool nor Roblox fills in your address for you
  • default_envs - (optional) Default env list used when a key has no envs field of its own. Each name must exist in rbxplace.toml.
  • name_prefix - (optional) Prepended verbatim to every key’s display name on Roblox. You control the separator: use mygame_ for underscore, mygame- for dash, etc. Useful when the same rbxapikey.toml is reused across games so the Creator Hub distinguishes same-named keys (e.g. mygame_deploy, othergame_deploy). Does not change the local TOML key.
  • readonly - (optional) Refuse to load this file if any key in it asks for an operation other than read or list. See Read-only by declaration
  • default_secret_file - (optional) Path template used when a key has no explicit secret_file. The literal {name} is replaced with the TOML key and {env_group} with the key’s env group, if it has one. Example: .secrets/{name}.env makes [keys.deploy] write to .secrets/deploy.env by default, and a fan-out of deploy write to .secrets/deploy_ci.env and .secrets/deploy_prod.env. Explicit secret_file on a key always wins.

Key attributes

  • name - (optional) Display name on Roblox Creator Hub (defaults to key ID)
  • description - (optional) Description on Roblox Creator Hub; auto-generated if omitted
  • envs - Either a list of env names from rbxplace.toml (one key, targeting every one of them), or a table of named groups (one key per group — see One key per environment). The list form falls back to settings.default_envs when omitted or empty; the table form always names its envs.
  • group_ids - (optional) List of group IDs for group-target scopes
  • user_ids - (optional) List of user IDs for user-target scopes
  • scopes - List of scope strings in format "scopeType:operation1,operation2". A scope listed twice is collapsed and reported, at both levels — see Duplicate scopes.
  • enabled - (optional) true/false to enable/disable the key (defaults to settings.default_enabled)
  • expiration_months - (optional) Months until key expires
  • expiration_days - (optional) Days until key expires (more precise than months)
  • expires_at - (optional) Exact expiration date in ISO 8601 format (e.g., "2025-12-31T00:00:00Z"); all three expiration fields are mutually exclusive (priority: expires_at > expiration_days > expiration_months > global default); omit all for keys that never expire
  • allowed_cidrs - (optional) List of IP CIDR blocks allowed to use this key (defaults to settings.default_allowed_cidrs)
  • secret_file - (optional) Custom file path to store the secret (defaults to lockfile). Templated like default_secret_file: {name} is the key name, {env_group} its env group. A fan-out declaration needs one of the two in the path, or its keys would share one file.
  • datastores - (optional) Array of datastore restrictions (fine-grained datastore scopes by universe and name)

Read-only by declaration

[settings]
readonly = true          # nothing in this file may ask for more than read or list

[keys.viewer]
readonly = true          # or just this one, in a file that is not

Loading fails, naming the key and the scope, if either is set and a scope asks for anything else:

[keys.viewer] is readonly and asks for `universe:write`. A readonly key may only use
read and list. Drop the operation, or drop `readonly` — but if this file is the one
that is not supposed to hold write scopes, dropping `readonly` is the change to think
twice about.

Why this is a check rather than a comment. Roblox binds a key to its scopes when it is created, and it is tempting to conclude that a key declared read-only cannot be made to write. Half of that is true: nothing widens a key at runtime, so whatever holds the secret cannot escalate it. The other half is false, and was disproved by running rbx apikey update on an already-created key and watching Roblox accept a write scope. update is a verb this tool ships, and the file that declares the rule is the same file somebody would edit to break it.

So the guard sits at config load, which is the one place that sees the declaration before anything reaches Roblox.

It is an allow-listread and list, nothing else — rather than a list of forbidden writes. Roblox adds operations whenever it likes, and a deny-list would quietly let each new one through, which is the failure this exists to close.

[settings] readonly and a per-key readonly add to each other; neither turns the other off. A key produced by fan-out is checked and named by its generated name, since that is the name it would be created under.

Datastore granularity

Restrict universe-datastore scopes to specific datastore names:

[keys.datastores_key]
envs = ["prod"]
scopes = ["universe-datastores.objects:read"]

[[keys.datastores_key.datastores]]
universe_id = 9876543210
name = "UserData"
operations = ["read"]

[[keys.datastores_key.datastores]]
universe_id = 9876543210
name = "GameState"
operations = ["read", "write"]

Secret storage

By default, secrets are stored in rbxapikey.lock.toml, which you have to gitignore: nothing in this tool writes into your .gitignore. rbx apikey create checks before it creates anything and refuses if git is not ignoring that file, naming the line to add.

Custom file backend

[keys.mykey]
secret_file = "/path/to/secret"

The secret will be stored in /path/to/secret instead of the lockfile.

Workflow

All permanent configuration lives in rbxapikey.toml. To make changes:

  1. Edit the TOML - Add/remove/modify keys and their settings
  2. Run rbx apikey update <key>|--all - Apply the TOML to Roblox

Example:

# Disable a key
vim rbxapikey.toml    # Set enabled = false
rbx apikey update mykey

# Rotate expiry
vim rbxapikey.toml    # Change expiration_months
rbx apikey update mykey

The TOML is the source of truth. The lockfile and Roblox are the applied state.

Authentication

rbx apikey uses cookie auth (the API key admin endpoints aren’t on Open Cloud yet). The .ROBLOSECURITY cookie is supplied via the global --cookie flag, the RBX_COOKIE env var, or a local Roblox Studio install.

Studio detection is opt-in: --auto-cookie is the standing yes, an interactive run is asked once, --no-auto-cookie is the standing no, and a run with nowhere to ask declines by itself.

In CI, that last rule means --auto-cookie is not the answer — there is no Studio on a runner, and a runner that happens to have one must not reach into it. Every write verb here (create, update, regenerate, delete, prune) requires a cookie, so a scheduled rbx apikey create --all needs RBX_COOKIE from a secret store. Prefer arranging the pipeline so none of these run there at all: the keys they mint are the credential CI should be using, not making.

A session cookie is a full-account credential, strictly more powerful than the scoped keys this command creates. docs/cookie.md is the trust model: the full resolution order (including RBXAPIKEY_COOKIE, the per-tool variable that survived the merge into one binary), what an auto-detected cookie prints on stderr, and why it is never written to disk.

rbx apikey can-manage

Can you create a key for this experience at all? For a group-owned universe the answer depends on your group role, which rbxapikey.toml knows nothing about.

rbx apikey can-manage --universe-id 5544332211
rbx apikey can-manage --place-id 55443322110099   # resolved to its universe
rbx apikey can-manage --env prod
place 55443322110099 is in universe 5544332211
universe 5544332211: can create keys yes

It authenticates with your Studio cookie, not with an API key, and that is the design. Asking with a key is circular: a key is bound to its universes at creation, so a key for universe A answers Forbidden about universe B.

--place-id is repeatable here, and this is the command it is repeatable for:

rbx apikey can-manage --place-id 55443322110099 --place-id 66778899001122

It is the global flag, the same one rbx open and rbx place download take. Everywhere else acts on one place and refuses a repeated flag by name rather than taking the first.

There is no positional id. A place id and a universe id are both plain integers and the two spaces overlap: 5544332211 is one game’s universe id and a valid place id belonging to a different universe. Say which you mean.

Why it is worth running

Because create tells you nothing. Measured against a universe belonging to somebody else:

  • can-manage said no
  • rbx apikey create succeeded, and introspection confirmed the scopes. Roblox does not check ownership when a key is made.
  • the key then failed at first use: The authorized user does not have sufficient permissions

So a created key proves nothing. This is the only place the answer exists beforehand.

How far to trust it

A no was verified end to end, through to the failed call. A yes was right twice, but it says nothing about the scopes you will request or about your IP allowlist, the two other ways a created key ends up unusable.

Two caveats. canManage means “can administer this experience”, and that it also means “can use API keys here” is an inference from three observations, not something Roblox documents. And the endpoint is develop.roblox.com, legacy rather than Open Cloud, with no Open Cloud equivalent: if Roblox retires it, this command goes with it.

rbx apikey list --remote and rbx apikey prune

Three commands answer three different questions, and only the last one can see a key this project never made:

CommandQuestionSource
listWhat does this project declare?rbxapikey.toml + lockfile
status --remoteIs what this project declares still there?one GET per lockfile entry
list --remoteWhat does the account actually hold?one listing call, everything

The gap the third one fills is the one nobody expects to be wide. An account used for any length of time accumulates keys from other checkouts, from other tools, and from clicking through the Creator Hub, and the project you are standing in tracks none of them. Nothing else in the CLI can see them.

API keys on Roblox for user 1234567890 (12 total):
  ✓ myproject_viewer         AAAAAAAAAA…  created 2026-08-03  in 90d           tracked → viewer
  ✓ otherproject_rbxshop     BBBBBBBBBB…  created 2026-06-18  in 44d           untracked
  ✗ oldgame_rbxshop          CCCCCCCCCC…  created 2026-05-18  EXPIRED 17d ago  untracked

1 tracked by this project, 11 untracked (3 expired, 1 disabled).

The fourth column is Roblox’s own secret preview, the same one the Creator Hub shows — the first characters of the secret and nothing more. It is enough to recognise a key you already hold without the tool ever storing the secret. The values above are placeholders: real previews are fragments of live credentials and do not belong in documentation.

Names are not identity

The join between the account and your lockfile is on cloud_auth_id, never on the name, for two measured reasons:

  • your lockfile calls a key viewer while Roblox calls it myproject_viewer (name_prefix does this on purpose);
  • two different accounts can each hold a key called deploy.

Matching on names would report your own key as untracked and offer it for deletion, or worse, tie your lockfile entry to a stranger’s key.

prune is deliberately awkward

prune lists the account, you select with space, and it deletes what you picked.

  • Nothing is ever preselected. A prune where the safe answer is “press enter” eventually deletes a production key.
  • There is no --all. delete --all is bounded by your lockfile; a prune --all would not be.
  • Selecting a tracked key routes through the ordinary delete path, so the lockfile entry and stored secret go with it. An untracked key is deleted on Roblox only — the tool never had its secret.
  • --dry-run prints the candidates and exits, which is the only non-interactive mode. Deleting other people’s keys from a script is not a workflow this supports.

Output starts with the account id for a reason: switching the signed-in Studio account changes which account answers, and the same key names recur across accounts. If the header says a user id you did not expect, stop before selecting anything.

Group-owned keys are not included by default. Pass --group-id. An empty result for a group you belong to means the listing was not asked for that group, not that the group has no keys.

The endpoint

Undocumented, and worth recording because nothing about it is guessable:

POST https://apis.roblox.com/cloud-authentication/v1/apiKeys
{"cursor": "", "limit": 100, "reverse": false, "groupId": <optional>}
→ {"cloudAuthInfo": [...], "nextCursor": "id_…", "previousCursor": "id_…"}

It is a POST that reads, and the resource is plural. Every GET spelling returns 404, and GET /v1/apiKey/list answers Malformed CloudAuthId because list lands in the by-id route. Cookie auth plus CSRF, like the rest of this crate. Found in the Creator Hub’s own bundle (getApiKeysv1ApiKeysPost) and confirmed against the live API and the browser’s network log.

Machine-readable output

--json on the three reads — list, status and scopes show — writes one JSON document to stdout and nothing else. Everything that is not the result (the “N item(s) need attention” line, the status summary and its tip, the auto-detected-cookie notice, the unknown-key warning from rbxplace.toml) goes to stderr, so jq reads the pipe and a human still reads the terminal.

Nothing that writes takes it. create, update, regenerate, delete and prune all stop and ask before they act, and a format that owns stdout cannot stop and ask. resolve is excluded for a harder reason: it prints the raw secret, so there is no document that could carry its answer.

No secret, and no piece of one

This is the command that holds live Open Cloud credentials, and a document goes into a pipe, a CI log, an artifact upload. So the rule is absolute rather than careful: no field in any of these documents carries a secret or any part of one. Two consequences are worth stating, because both are things the human form does print:

  • list --remote --json has no secret preview. The fourth column of the human listing is Roblox’s own preview, the first characters of a live secret, and it is there so a person can recognise a key on their own screen. A prefix is still credential material and a document is not a screen.
  • list --json carries no path to the secret file. The human form prints set (file: .secrets/deploy.env) because the person reading it is standing in that directory. The document says secret_backend (lockfile or file) and secret_present, which is what a script needs; where on disk a credential lives is not something to publish next to a report about it.

Two more omissions, for the same reason applied one step further out:

  • status --json carries no free-text detail. The human form’s trailing sentence is advice for a person, and one of its branches names the secret file. The status word says the same thing, so the document keeps the word and drops the sentence.
  • list --remote --json carries no cloud_auth_id. The account listing prints a name, a preview, dates and a tracked tag, never the id, and most of the keys it returns belong to other checkouts and other tools. The local listing does print id: for the keys this project created, so the local document carries id.

rbx apikey list --json

What this project declares, joined to what it created: rbxapikey.toml and the lockfile, and nothing from Roblox.

{
  "schema_version": 1,
  "sort": "name",
  "count": 2,
  "keys": [
    {
      "name": "deploy",
      "declared": true,
      "created": true,
      "id": "f58b4055-cafe-4e2f-9c2a-000000000001",
      "creator_id": "1234567890",
      "universe_ids": ["5544332211"],
      "expires_at": "2027-08-01T10:00:00.000Z",
      "days_until_expiry": 351,
      "secret_present": true,
      "secret_backend": "file"
    },
    { "name": "newkey", "declared": true, "created": false, "universe_ids": [] }
  ]
}

declared and created are the two tags the human listing prints, kept as separate booleans rather than folded into one word: a declared key with no lockfile entry is pending, a lockfile entry with no declaration is an orphan, and they are fixed in opposite directions. Everything the lockfile would have said — id, creator_id, expires_at, both secret fields — is absent for a key that was never created.

days_until_expiry is negative once it has passed, and absent both when there is no expiry and when the timestamp could not be parsed, which is the (unparseable) the human listing marks. expires_at stays at full precision where the listing shortens it to the date.

sort says which order keys is in, because the order of an array is meaningful and a stored document should say which one it got. --expiry-only is refused alongside --json: it is a narrower rendering of the same rows, and a document has no narrower rendering. --sort is fine.

rbx apikey list --remote --json

What the account holds, which is mostly not this project’s doing. Same call as the human listing, so it costs no extra round trip: the users/authenticated request it already makes for the account line is the one that proves the session is live.

{
  "schema_version": 1,
  "owner": { "kind": "user", "id": "1234567890" },
  "totals": { "total": 12, "tracked": 1, "untracked": 11, "expired": 3, "disabled": 1 },
  "keys": [
    {
      "name": "myproject_viewer",
      "state": "active",
      "tracked": true,
      "tracked_as": "viewer",
      "created_time": "2026-08-03T09:15:00.123Z",
      "expiration_time": "2026-11-01T09:15:00.123Z",
      "days_until_expiry": 90
    },
    { "name": "otherproject_rbxshop", "state": "active", "tracked": false }
  ],
  "missing_on_account": []
}

owner is there for the same reason the human listing starts with the account id: switching the signed-in Studio account changes which account answers and the same key names recur across accounts. tracked_as is the lockfile’s name for a key, which is not the name Roblox has for it — name_prefix makes viewer into myproject_viewer on purpose — and it is absent when tracked is false. state is active, expired or disabled. missing_on_account lists lockfile names Roblox no longer has, the same warning the human form prints.

rbx apikey status --json

{
  "schema_version": 1,
  "remote": false,
  "count": 3,
  "issues": 1,
  "keys": [
    { "name": "deploy", "status": "HEALTHY", "healthy": true, "days_until_expiry": 351 },
    { "name": "newkey", "status": "PENDING", "healthy": false }
  ]
}

status is one of HEALTHY, PENDING, EXPIRED, EXPIRING_SOON, ORPHAN_LOCK, ORPHAN_REMOTE, SECRET_MISSING, DISABLED, CHECK_FAILED — the word the human form prints beside the glyph. healthy is true only for the first, so a gate reads one field instead of enumerating eight spellings of “no”. remote says whether Roblox was asked: ORPHAN_REMOTE cannot be reported without it, so a consumer that sees no orphan needs to know which of the two runs it is reading. issues is the count the human form prints as “N key(s) need attention”.

A drift between the lockfile and rbxplace.toml still fails the command outright, before any document is written. Empty stdout next to a non-zero exit says nothing was read.

rbx apikey status --json | jq -r '.keys[] | select(.healthy | not) | "\(.name): \(.status)"'

rbx apikey scopes show --json

{
  "schema_version": 1,
  "scope_type": "universe",
  "known": true,
  "catalog_version": "2026-05-13",
  "target_type": "universe",
  "operations": ["read", "write"]
}

The catalog is advisory: an unknown scope is a warning and never an error, and rbxapikey.toml forwards any string Roblox will take. So an unknown scope is a document with "known": false and exit 0, not a failure — and target_type and operations are absent there, because the catalog has no answer rather than an empty one. catalog_version is what tells “Roblox does not have this scope” from “this catalog is older than that scope”.

rbx doctor

Answer “why doesn’t it work” in one command. rbx doctor reads the credential that is actually loaded, asks Roblox what it holds for that key, compares it against the tools this directory configures, and makes one real authenticated call. Every check is read-only: doctor never creates, updates or deletes anything. Nothing it does contacts anyone but Roblox, unless you pass --check-ip — see the IP allowlist check.

Features

  • Credential provenance - Which API key is live and where it came from. RBX_API_KEY is one variable shared by every tool, so the loaded key is often not the one you think
  • Session validity - Whether Roblox still accepts the Studio cookie, and which account it signs in as
  • Key validity - Enabled, expired, expiring soon, with the date and the time left
  • IP allowlist - The CIDRs Roblox stores for the key, which fail as an opaque 401 when stale. With --check-ip, compared against this machine’s public address
  • Scope coverage - For each config file present, which of that tool’s operations the key can and cannot run
  • Read probe - One GET against a universe, to prove the whole chain works end to end
  • Actionable failures - Every failing line carries what to do about it. A check that could not run says so, and is never reported as a pass

Usage

rbx doctor                          # Diagnose whatever RBX_API_KEY holds
rbx doctor --env prod               # Resolve the probe target from rbxplace.toml
rbx doctor --universe-id 123        # Name the probe target directly
rbx doctor --key deploy             # Diagnose a key declared in rbxapikey.toml
rbx doctor --no-probe               # Skip the authenticated read
rbx doctor --check-ip               # Also compare the IP allowlist (asks an echo service)

Output

rbx doctor

Credentials
  ✓ API key         RBX_API_KEY (environment)
  ! Studio cookie   auto-detected from a local Studio install
      → A session cookie is a full-account credential, more powerful than any scoped key. Pass
        --no-auto-cookie or set RBX_COOKIE= if you did not intend it.
  · rbxapikey.toml  3 key(s) declared, 3 created
  ✓ session         live — signed in as builderman (156)

Key validity
  · identified as  "deploy" (this project) — opsdev_deploy on Roblox
  ✓ enabled        yes
  ✓ expiry         2026-11-12T09:00:00Z (in 90d)

IP allowlist
  · allowed CIDRs  203.0.113.7/32
      → Not compared against this machine's public IP. Doing that means asking a third-party
        echo service (https://api.ipify.org) for an address this machine cannot read off its
        own interfaces, so it is opt-in: re-run with `--check-ip`. A stale entry here fails as
        an opaque 401 that looks exactly like a wrong key, so check it first when a call that
        should work does not. See docs/doctor.md.

Scope coverage
  · rbx place                        rbxplace.toml is here
  ✓ rbx place places                 covered
  ✗ rbx place upload / promote       missing universe-places:write
      → rbxplace.toml is here, so this is a call you can expect to make. Add
        universe-places:write to the key's `scopes` in rbxapikey.toml and run
        `rbx apikey update <key>`.

Read probe
  · target              universe 5544332211 (--env prod_readonly)
  ✓ authenticated read  200 — read "My Game"

1 problem(s) found.

Symbols

SymbolMeaning
Checked, and fine
!Checked, fine, but worth knowing
Checked, and broken. Always followed by , what to do about it
·Not a check: a fact needed to read the rest
-Could not be checked, and why. Never counted as a pass

Exit status

0 when nothing is broken, 1 when a check failed. A check that could not run does not change the exit status, but the summary line says how many there were — the difference between “your scopes are fine” and “your scopes were never looked at” is the point of the command.

The checks

1. Which credential is active

The API key comes from --api-key, from RBX_API_KEY, or — with --key <name> — from the secret backend of a key declared in rbxapikey.toml. doctor says which, because RBX_API_KEY is a single variable shared by every tool in this suite and by anything else in your shell, so a key left over from another project is a routine cause of a refusal that looks like a scope problem.

The Studio cookie is reported separately: explicit (--cookie / RBX_COOKIE), auto-detected, or absent. An auto-detected cookie is a ! rather than a on purpose — a session cookie is a full-account credential, strictly more powerful than any scoped API key.

Its session is then checked, with one call to users.roblox.com/v1/users/authenticated: where the cookie came from is not the same question as whether Roblox still accepts it, and a cookie that has expired refuses every command that needs one. A live session passes and names the account it signs in as. A refused one fails, with both ways to renew it. No cookie is skipped rather than failed — most commands never need one — and so is a check that could not run at all, because a service that did not answer is not a session that was refused. See docs/cookie.md for which commands make the same check before writing.

2. Key validity

doctor identifies the loaded secret against the keys the signed-in account holds, matching on the secret preview Roblox publishes with each key — the same string the Creator Hub shows in its “Key” column. That is what makes the check work for a key you have been using for a month; rbx apikey introspect is authoritative but only while the JWT inside the secret is valid, roughly an hour after create or regenerate.

It then reports enabled, and expiry with the time remaining. A key inside two weeks of lapsing warns without failing.

This check needs the cookie, since Roblox’s key-administration endpoints are not on Open Cloud. Without one it is skipped, with the reason given, rather than passed.

3. IP allowlist

doctor always prints the CIDRs Roblox stores for the key. A stale entry fails as an opaque 401 that looks exactly like a wrong key, so seeing the allowed addresses next to the probe’s result is often enough to spot it.

Comparing them against this machine’s address is opt-in, behind --check-ip.

Why it is opt-in

A machine behind NAT cannot read its own public address off its interfaces. The only way to learn it is to ask something on the outside, which means telling a third party where you are — in a tool whose whole pitch is least privilege. So rbx doctor does not do it on its own initiative:

  • Without --check-ip, no packet leaves for anyone but Roblox. The allowlist is printed with a line saying the comparison was not made, and the flag that would make it.
  • With --check-ip, exactly one request goes out, to https://api.ipify.org. That service is named on the line that reports your address, not only here, so nobody finds out afterwards that a third party saw it.
  • No request is made when the answer is already known. An empty allowlist, an allowlist containing 0.0.0.0/0, and a key whose configuration could not be read all answer the question without asking anyone, flag or no flag.

https://api.ipify.org was chosen for what it does not do: it answers GET / with the caller’s address as bare text and nothing else. No API key, no registration, no query string carrying anything about this machine beyond the connection itself.

What it reports

IP allowlist
  · allowed CIDRs  203.0.113.0/24, 198.51.100.7/32
  · public IP      203.0.113.9 — asked https://api.ipify.org, which therefore saw it
  ✓ this machine   203.0.113.9 is inside 203.0.113.0/24

and when it is not:

  ✗ this machine   198.51.100.42 is in none of the allowed CIDRs
      → Every call with this key is refused as an opaque 401 until the allowlist covers this
        address. Add 198.51.100.42/32 to the key's `allowed_cidrs` in rbxapikey.toml and run
        `rbx apikey update <key>`, or pass `--no-ip` to that command to drop the restriction.
        A home connection's address usually changes, so a host route written today is next
        month's 401.

What it never does

An unresolved address is never reported as a mismatch. Offline, service down, timed out, a captive portal answering with a login page — every one of those is a check that could not run (-), with the reason given, and none of them changes the exit status. A false “you are locked out” is expensive: it sends you to edit a key that is fine.

Three things produce a - rather than a or a :

SituationWhy not an answer
The echo service did not answerNothing was resolved. Whether you are inside the allowlist is simply unknown
The allowlist holds no entry of your address’s familyA v6 answer against a v4-only list compares nothing. Roblox may still see this machine at a v4 address the list covers
An entry could not be read as a CIDR, and nothing else matchedThe entry that would have covered you might be the one that did not parse

The lookup times out in 3 seconds, far below the 60s the rest of the suite allows. rbx doctor is what you run when the network is already misbehaving, so it stays usable offline: a caller with no connectivity gets their report back rather than a spinner.

4. Scope coverage

For each of rbxplace.toml, rbxmeta.toml, rbxconfig.toml and rbxshop.toml that is present, doctor lists that tool’s operations and whether the key carries the scopes each one needs. The requirements are the “Required API scopes” tables from each tool’s own doc.

Two limits worth knowing:

  • Presence, not parsing. The unit of detection is the config file existing. doctor does not read rbxshop.toml to find out whether you actually declare badges, so a repo that only uses game passes is still told it cannot manage badges. Each line names the operation and the missing scope, so an operation you never run is visibly not your problem. The alternative — parsing every tool’s config properly — means depending on every domain crate in the workspace.
  • Scope type and operation, not target. A scope’s targetParts name universes, datastores or creators, and deciding whether a given target covers a given call means resolving your env, which doctor would have to guess at. It answers the narrower question honestly: a key that lacks the scope outright is the failure people actually hit.

5. Read probe

One GET /cloud/v2/universes/{id} with the key. It needs universe:read, changes nothing, costs nothing, and is the same request rbx meta opens with — so a failure here is a failure you were going to hit anyway.

The target comes from --universe-id, from --env <name> against rbxplace.toml, or, when rbxplace.toml defines exactly one env, from that one. Which was used is printed before the result. With no target the probe is skipped rather than guessed at, and --no-probe skips it outright.

A refusal is read for you:

StatusWhat it means
401The key was rejected before permissions were considered. Either the secret is wrong, or the IP allowlist no longer contains this machine — the two fail identically, and the second is the one nobody guesses. --check-ip tells them apart
403The key is valid but not allowed to make this call: a missing scope, or a scope whose target does not cover this universe
404The universe id does not exist, or the key’s owner cannot see it
  • docs/apikey.md — declaring and creating the keys doctor reports on
  • docs/ops.md — the safety model behind least-privilege keys

rbx check and rbx status

One command that runs every configured tool’s check and returns a single exit code. It is the CI contract: nothing to configure, nothing interactive, and the exit code is the whole answer.

rbx check                 # the standalone config blocks
rbx check --env all       # every env in rbxplace.toml
rbx check --offline       # skip anything that needs the network

rbx status is the same engine with the opposite contract: the human overview, grouped by environment, that always exits 0. See rbx status below.

rbx checkrbx status
answersmay this build continuewhere does this project stand
shapeone line per checkone block per environment
exit code0 / 2 / 1always 0
written forCIyou, at a terminal

Exit codes

This is the part scripts depend on, so it is the part to read carefully.

CodeMeaningWhat to do
0every check that ran came back cleannothing
2at least one check found drift, and none failedrun the sync or codegen the summary names, and commit
1at least one check failedread the message; a check could not answer

Aggregation is error beats drift beats clean. A run that both fails one check and finds drift in another exits 1, because “something is broken” and “something is stale” ask different things of whoever reads the log, and the broken one is the one that has to be read first.

Skipped checks never raise the exit code. A repo without rbxshop.toml is not a repo with a broken shop, and --offline is a deliberate narrowing, not a failure.

In GitHub Actions:

- run: rbx check --env all
  # fails the job on 1 and on 2 alike; use `continue-on-error` plus a
  # conditional on the outcome if drift should be a warning rather than a stop.

What it runs

A tool is checked when its config file is present in the working directory. There is no [check] block to maintain: the list of tools a repo uses is already on disk.

--dir moves that lookup, rbxplace.toml included, and an explicit --places overrides it for that one file. It is the same rule rbx import --dir writes by, so rbx import --dir game followed by rbx check --dir game reads the files the import just wrote:

rbxplace.toml read from
rbx check./rbxplace.toml
rbx check --dir gamegame/rbxplace.toml
rbx check --dir game --places shared/envs.tomlshared/envs.toml
Config fileCheckNetwork
rbxplace.tomlenv/gen-module — the committed env module still matchesno
rbxshop.tomlshop/lockfile — declared passes/badges/products against the lockfileno
rbxshop.tomlshop/codegen — the committed shop modules still matchno
rbxmeta.tomlmeta/lockfile — declared universe/place metadata against the lockfileno
rbxconfig.tomlconfig/live — local entries against the live config on Robloxyes
rbxapikey.tomlapikey/status — not yet wired, see below

Only config/live needs credentials, so --offline is a small cut: everything else compares committed files against committed files. That makes the offline mode usable from a pre-commit hook, which is the point of having it.

The key is only demanded once config/live has something to compare. With no --env, or with an env rbxconfig.toml never declares, the row is skipped and a keyless run still exits 0 without --offline.

Rows are named tool/check [env]. Per-env checks produce one row per env; with no --env, each tool falls back to its standalone block and the row is labelled [default], matching the [envs.default] section those tools already write.

Why this is not a wrapper around the per-tool checks

All five per-tool checks now agree on the contract — 0 clean, 2 drift, 1 error — so rbx check and rbx shop check no longer disagree on exit code for the same repo. Either can be trusted in CI; rbx check runs all of them at once, which is the only difference.

It still does not call the per-tool commands, for a reason that is about stdout rather than exit codes: those commands print as they decide, and under --json stdout belongs to the document — a probe that shelled into them would emit something jq cannot read. So each check rebuilds the comparison from the same public pieces the command itself uses — the renderers and plan builders, not a re-description of them — without touching any check body.

apikey is discovered but not checked

rbx apikey status classifies key health — expiry, orphan lockfile entries, missing secrets — and returns success whatever it finds, which the other per-tool checks no longer do. Reaching that classification from here would mean either widening rbx-apikey internals or writing a second copy of the rules, and a second copy of a rule that drifts from the first is the exact failure mode this command exists to catch.

So the row is reported as skipped, by name, with the command to run by hand:

- apikey/status    not yet wired — run `rbx apikey status`

Wiring it up properly means giving apikey status a structured result to return. That is worth doing and is not this change.

Flags

FlagEffect
--offlineskip checks that need network access and credentials
--jsonwrite the report to stdout as one JSON document
--dir <path>look for config files here instead of the working directory, rbxplace.toml included
--env <name>check one env; --env all expands through rbxplace.toml
--places <path>where rbxplace.toml lives, overriding --dir for that file

rbx check is non-interactive by construction: it never prompts, so it is safe to run with no TTY.

--json

rbx check --env all --json

One JSON document on stdout, nothing else. Diagnostics stay on stderr and the exit code is unchanged, so a consumer can read the document, the stream, or the status — whichever suits — and get the same answer.

{
  "schema_version": 1,
  "outcome": "drift",
  "exit_code": 2,
  "totals": { "total": 4, "clean": 0, "drift": 2, "error": 0, "skipped": 2 },
  "checks": [
    {
      "tool": "env",
      "check": "gen-module",
      "outcome": "skipped",
      "summary": "no [codegen].output in rbxplace.toml"
    },
    {
      "tool": "meta",
      "check": "lockfile",
      "env": "prod",
      "outcome": "drift",
      "summary": "2 pending changes — run `rbx meta sync`",
      "details": ["name: (unset) → My Game (Live)", "server size: (unset) → 50"]
    }
  ]
}

Fields

These names are the contract. Adding a field is not a breaking change — consumers are expected to ignore what they do not recognise — but a field changing meaning or disappearing bumps schema_version.

FieldTypeMeaning
schema_versionintegerDocument format. 1 today. Refuse a version you do not understand.
outcomestringThe aggregate: clean, drift, error, skipped.
exit_codeintegerThe exit code rbx check returns: 0, 2, or 1. Always agrees with the process under check; under status, which always exits 0, it is what check would return.
totals.totalintegerHow many checks ran, including skipped ones.
totals.clean / .drift / .error / .skippedintegerCounts by outcome.
checksarray of objectsOne entry per check, in run order.
checks[].toolstringenv, shop, meta, config, apikey.
checks[].checkstringWhich check within the tool: gen-module, lockfile, codegen, live, status.
checks[].envstringThe env. Absent on checks that are not per-env.
checks[].outcomestringclean, drift, error, skipped.
checks[].summarystringOne line, the same text the human renderer shows.
checks[].detailsarray of stringsPer-change or per-file lines. Absent when empty.

Optional fields are omitted rather than emitted as null, so has("env") is a usable test. Every row is an object keyed by name, never a positional array: a consumer survives a field being added, and does not survive a column shifting.

In GitHub Actions

rbx check --env all --json > check.json || true
jq -r '.checks[] | select(.outcome == "drift")
       | "::warning title=rbx drift::\(.tool)/\(.check) \(.env // "") — \(.summary)"' check.json
exit "$(jq -r '.exit_code' check.json)"

Scope

--json covers every read in the JSON issue, not just the check family: check and status, env list/get, servers list/versions/logs, analytics query/metrics, ads list/get/status, place versions/places and the receipts from place upload/promote/rollback, data get/list/revisions/diff, memorystore get/list, shop list/show, config list/get/versions, ban list/status, apikey list/status and apikey scopes show, plus the receipt from publish. Per-command field names are documented alongside each command.

What every one of them shares is the helper — rbx_core::output is the only place in the tree that serializes to stdout, which is what keeps --json meaning the same thing everywhere: one document, notes and warnings on stderr, optional fields omitted rather than null, and no prompt, ever. Commands that stop and ask do not carry the flag at all.

Example

$ rbx check --env all --offline
rbx check
  - env/gen-module        no [codegen].output in rbxplace.toml
  ✓ shop/lockfile [dev]   everything in sync
  ! shop/lockfile [prod]  1 to create, 0 to update — run `rbx shop sync`
  ✓ shop/codegen          generated modules match rbxshop.toml
  ! meta/lockfile [prod]  2 pending changes — run `rbx meta sync`
      name: (unset) → My Game (Live)
      server size: (unset) → 50
  - config/live           --offline: comparing against Roblox needs an API key
  - apikey/status         not yet wired — run `rbx apikey status`

! 2 checks found drift (2 clean, 3 skipped). Exit code 2.

The checks that compose an existing command (env/gen-module, shop/codegen) print their own per-file detail above this summary, since that detail is what tells you which generated file went stale.

rbx status

The human half. Same discovery, same checks, same rows — regrouped by environment and stripped of the exit-code contract.

rbx status                  # the standalone config blocks
rbx status --env all        # every env in rbxplace.toml
rbx status --offline        # the overview you can get with no key and no network
rbx status --json           # the document below, identical in shape to check's
$ rbx status --env all --offline
rbx status

  - repository
      - env/gen-module  no [codegen].output in rbxplace.toml

  ! dev
      ! meta/lockfile   1 pending change — run `rbx meta sync`
          name: (unset) → My Game (dev)

  ✓ prod
      ✓ meta/lockfile   everything in sync

! 1 check out of sync. Re-run the tool's own sync, or `rbx check` for the CI verdict.
rbx status always exits 0; rbx check here would exit 2.

Always exit 0 is the point. A status command that fails a script is a check command with worse output — so rbx status is safe under set -e, in a shell prompt, in a watch, and in the first line of a Makefile target that then does something else. When you want the verdict, that is what rbx check is for, and the last line says which one it would be. A repository it cannot read at all is no exception: an unreadable or env-less rbxplace.toml prints as an env/discovery error row, and the command still exits 0.

The repository block holds the checks that are not per-env (env/gen-module compares a generated file, apikey/status answers for the credential). Environments follow it in alphabetical order, which is the order --env all expands them in.

It reads nothing it does not read for check, writes nothing, and is useful with no API key — --offline renders the local half and marks the live rows skipped rather than refusing to run.

rbx status --json

The same document rbx check --json emits, field for field, so a consumer can read either. One value is worth naming: exit_code is what rbx check would return for this repository, since rbx status itself always exits 0. It is the field to branch on if you want the verdict without the check’s exit code:

rbx status --env all --json > status.json   # always exits 0
jq -r '.checks[] | select(.outcome != "clean") | "\(.env // "repo") \(.tool)/\(.check): \(.summary)"' status.json
  • docs/env.mdrbx env gen-module --check and the ignored-key policy
  • docs/ops.md — which commands touch live state

rbx place

Upload, download, and rollback Roblox place files via the Open Cloud API.

rbx place manages .rbxl files across multiple environments (prod, staging, dev) defined in a shared rbxplace.toml. It handles Team Create locks gracefully and can require confirmation before writing to sensitive environments.

Features

  • Multi-environment - Define prod, staging, dev (and any others) in a single rbxplace.toml
  • Upload - Push a .rbxl to one place or all places in an environment at once
  • Download - Fetch the latest or a specific version of a place file
  • Promote - Copy a place from one environment to another, optionally broadcasting to all target places
  • Rollback - Revert a place to a previous version with an interactive selector
  • Version history - List recent versions with published status and timestamps
  • Team Create detection - Clear error when a place is locked by an active Studio session
  • Confirmation guard - Per-environment confirm = true prompts before write operations
  • Fetch - Auto-populate rbxplace.toml from live Roblox universe
  • JSON - --json on versions, places, upload, promote and rollback writes one document to stdout and nothing else, with documented field names, for jq and CI

Generating the env module lives in rbx env gen-module, the command that owns rbxplace.toml.

Quick start

Create a rbxplace.toml at the root of your project:

[prod]
universe_id = 9876543210
confirm = true
places.main = 123456789012345

[staging]
universe_id = 9876543211
places.main = 234567890123456

[dev]
universe_id = 9876543212
places.main = 345678901234567

Then upload a build:

rbx place upload --env staging --file build.rbxl
rbx place upload --env prod --file build.rbxl   # prompts for confirmation

Commands

rbx place upload

Upload a .rbxl file to one or all places in an environment. By default, uploads are saved as drafts (not published live).

rbx place upload --env staging --file build.rbxl           # save as draft
rbx place upload --env prod --place lobby --file build.rbxl --published  # publish live
rbx place upload --env prod --all-places --file build.rbxl
FlagDescription
--envTarget environment (required)
--filePath to the .rbxl file (required)
--placePlace name to upload to (defaults to the only place if unambiguous)
--all-placesUpload to every place defined in the environment
--publishedPublish immediately (default: save as draft)
--jsonWrite the result to stdout as one JSON document instead of the progress lines

By default, uploads are saved as drafts. Use --published to publish live.

If the target place has an active Team Create session, the upload fails immediately with a clear message rather than returning a generic error.

If the environment has confirm = true, a confirmation prompt is shown before uploading.

--json

rbx place upload --env staging --file build.rbxl --json
{
  "schema_version": 1,
  "command": "upload",
  "ok": true,
  "env": "staging",
  "universe_id": "9876543211",
  "published": false,
  "place_id": "234567890123456",
  "version": "173",
  "results": [{ "place": "main", "place_id": "234567890123456", "version": "173" }]
}

The fields are shared with promote and rollback and are described once in Write documents.

--json cannot prompt, so an environment with confirm = true needs --yes; without it the command fails with a message on stderr naming the flag and writes nothing to stdout.

VERSION=$(rbx place upload --env staging --file build.rbxl --json | jq -r .version)
rbx place download

Download a place file from Roblox.

rbx place download --env prod
rbx place download --env prod --version 42 --out backup.rbxl
rbx place download --env staging --published
rbx place download --env staging --saved
FlagDescription
--envTarget environment (required unless --place-id is given)
--placePlace name (defaults to the only place if unambiguous)
--place-idA place id instead of an env and a name. Skips rbxplace.toml; global flag
--versionSpecific version number to download (default: latest)
--publishedDownload the latest published version specifically
--savedDownload the latest saved (draft) version specifically
--outOutput path (default: <place_id>.rbxl)
rbx place promote

Promote a place from one environment to another. Downloads the source place in-memory and uploads it to the target. Without --all-places, the same-named place is targeted in the destination environment.

rbx place promote --from staging --to prod                         # latest → matching place
rbx place promote --from staging --to prod --all-places            # latest → every place in prod
rbx place promote --from staging --to prod --from-published        # latest published version
rbx place promote --from dev --to staging --version 42 --published # specific version, publish live
rbx place promote --from staging --to prod --log deploy.json       # write traceability log
FlagDescription
--fromSource environment (required)
--toTarget environment (required)
--placeSource place name (defaults to the only place if unambiguous)
--all-placesUpload to every place defined in the target environment
--versionSpecific source version to promote
--from-publishedPromote the latest published version from the source
--from-savedPromote the latest saved (draft) version from the source
--publishedPublish immediately on the target (default: save as draft)
--logPath to a JSON file for traceability logging (merged, not overwritten)
--jsonWrite the result to stdout as one JSON document instead of the progress lines

If the target environment has confirm = true, a confirmation prompt is shown before uploading.

--all-places is a broadcast, not a plural

Without it, promote maps by name: the source place is resolved, and the same key is looked up in the target env. main goes to main, lobby goes to lobby, and a target env missing that name is an error. That is the default and it is what people usually mean.

--all-places does something else. Every place in the target env receives the same bytes, downloaded once from the single source place:

# prod's main, lobby and arena all become copies of staging's main
rbx place promote --from staging --to prod --all-places

There is no name matching in that path. It is occasionally what you want — several places that really are the same file — and it is unrecoverable when it is not: each target gets a new version, those version numbers are real, and undoing it is one rollback per place.

So the confirmation says it outright rather than only listing the targets:

⚠ Promote staging/main v172 → prod (arena, lobby, main)? This will save as draft.
  Every one of them is overwritten with staging/main, not with its own counterpart.

If what you want is “promote every place to its same-named counterpart”, that is a different operation and this flag is not it. Run promote once per place, or leave the flag off and let the name mapping do it.

When --log is provided, a JSON file is written (or updated) after a successful promote. Only the promoted places are updated in the file; other entries are preserved:

{
  "main": {
    "deployedAt": "2026-05-14T15:30:00+01:00",
    "staging": { "universeId": 9876543210, "placeId": 123456789012345, "version": 172 },
    "production": { "universeId": 9876543211, "placeId": 234567890123456, "version": 27 }
  }
}

--json

The same information --log files away, on stdout, without a file. --log is still honored when both are given; the “Log written” line moves to stderr so the document stays parsable.

rbx place promote --from staging --to prod --from-published --published --yes --json
{
  "schema_version": 1,
  "command": "promote",
  "ok": true,
  "env": "prod",
  "from_env": "staging",
  "universe_id": "9876543211",
  "published": true,
  "source_place": "main",
  "source_place_id": "123456789012345",
  "source_version": "172",
  "place_id": "234567890123456",
  "version": "27",
  "results": [{ "place": "main", "place_id": "234567890123456", "version": "27" }]
}

source_version is the version that was actually promoted, resolved before anything is downloaded, so --from-published and a bare latest both report the number they picked rather than the flag that picked it. See Write documents for the rest of the fields.

rbx place rollback

Roll back a place to a previous version. Without --version, shows an interactive selector with recent versions.

rbx place rollback --env prod               # interactive selector
rbx place rollback --env prod --version 42  # direct rollback
rbx place rollback --env prod --count 20    # show 20 versions in selector
FlagDescription
--envTarget environment (required)
--placePlace name (defaults to the only place if unambiguous)
--versionVersion to roll back to (skips interactive selector)
--countNumber of recent versions to show in selector (default: 10)
--jsonWrite the result to stdout as one JSON document instead of the progress lines

Rollback creates a new version on Roblox (it does not modify history). If the place is locked by Team Create, the operation fails immediately with a clear message.

If the environment has confirm = true, a confirmation prompt is shown before rolling back.

--json

rbx place rollback --env prod --version 37 --yes --json
{
  "schema_version": 1,
  "command": "rollback",
  "ok": true,
  "env": "prod",
  "universe_id": "9876543210",
  "published": true,
  "source_version": "37",
  "place_id": "123456789012345",
  "version": "44",
  "results": [{ "place": "main", "place_id": "123456789012345", "version": "44" }]
}

Both versions are reported: source_version is the one restored, version is the new one Roblox created for it. published is always true, because rolling back republishes live.

--version is required under --json: the interactive selector is a prompt, and --json cannot prompt. Without it the command fails before fetching anything, with a message on stderr naming the flag.

rbx place versions

List recent versions of a place.

rbx place versions --env prod
rbx place versions --env staging --count 50
rbx place versions --env prod --filter published
rbx place versions --env prod --filter saved
FlagDescription
--envTarget environment (required)
--placePlace name (defaults to the only place if unambiguous)
--countNumber of versions to show (default: 20, or 3 when --filter is published/saved)
--filterFilter by version type: all (default), published, or saved
--jsonWrite the versions to stdout as one JSON document instead of the listing

--json

One JSON document on stdout, nothing else. Diagnostics, the unknown-key warning in particular, stay on stderr, so the document parses even when rbxplace.toml has something wrong with it.

{
  "schema_version": 1,
  "env": "prod",
  "place": "main",
  "place_id": "123456789012345",
  "filter": "all",
  "count": 20,
  "count_reached": false,
  "versions": [
    { "version": "173", "published": true, "create_time": "2024-01-15T14:30:00Z" },
    { "version": "172", "published": false, "create_time": "2024-01-14T09:02:00Z" }
  ]
}
FieldTypeMeaning
schema_versionintegerDocument format, shared with rbx check --json. 1 today. Refuse a version you do not understand
envstringThe environment asked for
placestringThe rbxplace.toml place name, after the --place defaulting rule
place_idstringThe place id, as a string
filterstringall, published, or saved: the --filter in force
countintegerThe --count in force. A maximum, not a promise
count_reachedbooleanTrue when the walk stopped at --count rather than running out of versions. Raise --count to see the rest
versionsarray of objectsNewest first, the order the listing prints
versions[].versionstringThe version number. --version takes it back verbatim
versions[].publishedbooleanLive, as opposed to a saved draft
versions[].create_timestringExactly what Roblox sent, RFC 3339. The listing rewrites this into 2024-01-15 14:30:00 UTC; that is a rendering, and the document keeps the original

A place with no versions is an empty versions array and exit 0, not an error: a consumer reads a zero off it.

rbx place versions --env prod --json | jq -r '.versions[] | select(.published) | .version' | head -1
rbx place places

List all places in a universe. Shows which places are configured vs missing from rbxplace.toml if using --env.

rbx place places --env prod                    # list places, show which are in toml
rbx place places --universe-id 9876543210     # list places without config
FlagDescription
--envEnvironment name (reads universe from toml, shows config status)
--universe-idUniverse ID override (one-shot listing without toml)
--jsonWrite the places to stdout as one JSON document instead of the listing

--json

{
  "schema_version": 1,
  "env": "prod",
  "universe_id": "9876543210",
  "places": [
    {
      "place_id": "123456789012345",
      "display_name": "Main Place",
      "max_player_count": 50,
      "place": "main",
      "configured": true
    },
    { "place_id": "987654321", "display_name": "Test Arena", "configured": false }
  ]
}
FieldTypeMeaning
schema_versionintegerDocument format, shared with rbx check --json. 1 today
envstringThe environment whose entry named the universe. Absent under a bare --universe-id
universe_idstringThe universe listed, as a string
placesarray of objectsOne per place Roblox reports, in the order it returned them
places[].place_idstringThe place id. Absent when Roblox returned a path it could not be read out of, which the listing renders as ?
places[].display_namestringThe name Roblox shows, which is not the rbxplace.toml key
places[].max_player_countintegerAbsent when Roblox did not report one
places[].placestringThe rbxplace.toml key this place is mapped to, the name --place takes. Absent when the file does not have it
places[].configuredbooleanWhether the file has this place, the fact the listing marks NOT in toml. Absent, rather than false, under a bare --universe-id: with no config in play the question has no answer
rbx place places --env prod --json | jq -r '.places[] | select(.configured | not) | .place_id'
rbx place fetch

Fetch all places from a universe and update rbxplace.toml. Existing place keys are preserved where the ID already matches. New places get keys generated from their Roblox display names.

rbx place fetch --env prod              # dry-run: shows what would be written
rbx place fetch --env prod --write      # writes to rbxplace.toml
rbx place fetch --env prod --universe-id 9876543210 --write  # override universe
FlagDescription
--envEnvironment section to update in rbxplace.toml (required)
--universe-idUniverse ID override (uses rbxplace.toml value if omitted)
--writeWrite changes to rbxplace.toml (default: dry-run)

Write documents

upload, promote, and rollback share one --json envelope. It is a receipt: it reports what was written, in the order it was written, with the version number Roblox assigned to each place.

FieldTypeMeaning
schema_versionintegerDocument format, shared with rbx check --json. 1 today
commandstringupload, promote, or rollback, so a mixed stream of receipts can be dispatched on
okbooleanFalse when a target failed. The exit code says the same thing; this is here so a consumer that captured stdout does not have to plumb $? through as well
envstringThe environment written to. For promote, the target
from_envstringThe source environment of a promote. Absent otherwise
universe_idstringThe universe written to
publishedbooleanWhether the new versions are live. Always true for rollback
source_place / source_place_idstringWhere a promote read its bytes, after --place defaulting. Absent otherwise
source_versionstringThe version the new one was made from: the promoted source version, or the version rolled back to. Absent for upload, whose source is a local file
place_id / versionstringThe single-target shortcut: the place written and the version it received. Absent under --all-places, and absent when nothing was written
resultsarray of objectsOne entry per place that got a new version, in write order. Empty when the first target failed
results[].placestringThe rbxplace.toml key
results[].place_idstringThe place id
results[].versionstringThe version Roblox assigned to this write
errorstringWhy the run stopped. Absent when ok is true. The same text is on stderr, where it is the process’s error message

Three rules are worth stating outright, because scripts depend on them:

A run that fails partway still emits a document. place upload --all-places can write two places and then hit a Team Create lock on the third. Those two versions exist and cannot be taken back, so results reports them, ok is false, error says what stopped it, and the process still exits non-zero. A deploy log that loses a write that happened is worse than no log.

A failure before the first write, on the other hand, writes nothing to stdout at all: an unknown environment, a refused confirmation, a source version that does not exist. Nothing happened, and an empty stdout next to a non-zero exit says so without ambiguity.

The shape follows the invocation, never the data. A single-target run fills the place_id and version shortcuts next to results; an --all-places run fills results only, even against an environment with exactly one place. This is the rule rbx env get --json uses for value, and it exists so a filter cannot start working by accident and break when a second place is added.

Ids and version numbers are strings. They identify an asset rather than count anything, a place id exceeds 2^53, and a consumer that parses them as JSON numbers would round them. Keeping versions in the same form means the output of one command feeds the input of the next without a conversion:

VERSION=$(rbx place upload --env prod --file build.rbxl --published --yes --json | jq -r .version)
rbx servers list --env prod --version "$VERSION" --json

--json never prompts. Every write here has a point where it would stop and ask, and under --json that question fails instead, with a message on stderr naming the flag that answers it: --yes for a confirm = true environment, --version for the rollback selector.

Configuration

rbx place reads rbxplace.toml in the working directory (override with the global --places <path>). You can configure place IDs manually or use rbx place fetch to auto-populate them from a Roblox universe.

[prod]
universe_id = 9876543210
confirm = true                  # prompt before upload or rollback
places.main   = 123456789012345
places.lobby  = 987654321

[staging]
universe_id = 9876543211
places.main = 234567890123456

[dev]
universe_id = 9876543212
places.main = 345678901234567
Environment fields
FieldTypeRequiredDescription
universe_idu64YesRoblox universe ID
envstringNoEnvironment type name for code generation (defaults to section name)
confirmboolNoRequire confirmation before write operations (default: false)
places.<name>u64NoPlace ID mapped to a name

The rbxplace.toml file is shared with every other subcommand: they all resolve environment names to universe IDs from it.

Working without rbxplace.toml

The global --place-id names a place directly, the way --universe-id names a universe, and skips the config file. It reaches the reads:

rbx place versions --place-id 123456789012345
rbx place download --place-id 123456789012345 --out backup.rbxl

The writes refuse it, with a message saying why:

`--place-id` names a place but no env, and `rbx place upload` needs one: the confirm
guard and the --json receipt are both env-scoped. Pass --env <name> ...

Two things are genuinely env-scoped and neither survives an id on its own. confirm = true is declared on an env, so an env-less write would walk past a guard somebody set on purpose. And the --json receipt carries env as a documented field, so an env-less write would emit a document missing something consumers were told to expect. Refusing beats either, and beats accepting the flag and ignoring it.

Required API scopes

OperationScopeNotes
Upload / Promote (write)universe-places:write
Download / Promote (read)legacy-asset:manage
Version listasset:readAlso used by --from-published, --from-saved, --published, --saved
Rollbackasset:write
List placesuniverse:readFor places command

rbx meta

Declaratively manage Roblox game/universe metadata from a single TOML config: name, description, icon, thumbnails, devices, social links, private servers, server fill mode, copying permission, visibility, Studio API access, and Beta mode. Multi-env aware via a shared rbxplace.toml.

rbx meta syncs your local metadata to Roblox, tracks remote state in a per-env lockfile, detects media changes with BLAKE3 hashing, and uses the Open Cloud API by default with an optional .ROBLOSECURITY cookie fallback for fields Open Cloud doesn’t expose.

Features

  • Declarative config - Define every metadata field in a single rbxmeta.toml
  • Multi-environment - Manage dev / staging / prod from one toml, with [envs.<name>] overlays merged on top of base
  • Differential pull - Writes only what diverges from your base config so the toml stays minimal
  • Two-way sync - Push local config to Roblox or pull remote state back into your toml + lockfile (comments preserved via toml_edit)
  • Open Cloud first - Runs in CI with just an API key; cookie only needed for the handful of cookie-only fields
  • Cookie fallback - Auto-detects .ROBLOSECURITY from a local Roblox Studio install for server_fill, allow_copying, visibility, studio_access_to_apis_allowed, and beta_mode
  • Smart visibility ordering - When toggling private→public, rbx meta activates the experience first so dependent patches (e.g. paid private servers) don’t 500
  • Preflight validations - Refuses obviously-invalid combinations (e.g. private_server.price 1-9 Robux, or paid private servers on a private experience) before sending a request
  • Per-env media namespacing - pull --accept-remote --env dev saves to <media.dir>/dev/icon.png so envs never overwrite each other on disk
  • Crash-safe lockfile - Saved after every successful API call so a mid-sync crash never leaves remote and lockfile in disagreement
  • Smart media diff - Icons and thumbnails are hashed with BLAKE3 so re-uploads only happen when bytes actually change
  • Thumbnail ordering - Preserves the order from your TOML, auto-reorders on Roblox as needed
  • Alpha bleed - Applies alpha bleeding to icons and thumbnails before uploading (enabled by default)
  • CSRF handled - Cookie requests transparently retry on 403 with a fresh token

Quick start

If you already use rbx place / rbx config, you have a rbxplace.toml like:

[dev]
universe_id = 9876543210
[dev.places]
main = 123456789012345

[prod]
universe_id = 9876543211
[prod.places]
main = 234567890123456

Pull each env once to populate rbxmeta.toml:

rbx meta init                                          # commented template
rbx meta pull --env dev --accept-remote --api-key KEY  # fills [game], [media] + [envs.dev] deltas
rbx meta pull --env prod --accept-remote --api-key KEY # adds only diverging fields to [envs.prod]
rbx meta sync --env dev                                # apply changes back to dev

Standalone (no rbxplace.toml)

rbx meta init --from-remote --universe-id 123456789 --place-id 987654321 --api-key KEY
rbx meta sync --api-key KEY

This embeds [experience] in rbxmeta.toml and uses it as the implicit “default” env (lockfile section: [envs.default]).

Hybrid: –from-remote with –env

rbx meta init --from-remote --env dev --api-key KEY

Resolves universe_id / place_id from rbxplace.toml and writes the lockfile under [envs.dev]. Useful for starting fresh on an existing experience without copy-pasting IDs.

Multi-environment

Three concepts:

  1. Base ([game] + [media]): the values shared across every env.
  2. Overlay ([envs.<name>], [envs.<name>.devices], [envs.<name>.media], etc.): per-env diffs layered on top of base.
  3. Resolution: --env <name> resolves (universe_id, place_id) from rbxplace.toml and merges base + overlay for that env.

sync --env dev applies [game] + [envs.dev] to dev. sync --env prod applies [game] + [envs.prod] to prod. Same rbxmeta.toml, different effective state.

Pull behavior (differential)

For each field, when you pull --env <name>:

StateRemote action
Base unsetWrite remote to base, clear overlay
Remote == baseClear overlay (no-op if absent)
Remote != baseWrite remote as overlay

Concrete example: starting from an empty config, pull --env prod (visibility=public) writes [game] visibility = "public". Then pull --env dev (visibility=private) only writes [envs.dev] visibility = "private". Subsequent pulls are idempotent.

Pull never auto-promotes overlays back into base. To DRY, edit the toml manually to move a shared value into [game]; the next pull will detect the match and remove the now-redundant overlay.

Place selection

When the env in rbxplace.toml has multiple places ([prod.places.lobby], [prod.places.world]), pass --place <name>. Defaults to main if present, otherwise the only entry.

One place per env, and the tool enforces it. rbxmeta.lock.toml keys its sections by env and holds a single place_id in each, while name, description and server_size are place-level fields written to that place. So syncing --place lobby and then --place main under one env would leave [envs.prod] recording one place’s metadata under the other’s id — and every later diff, which is what decides whether a field gets sent at all, would be computed against the wrong baseline.

sync and pull therefore refuse a place that disagrees with the one the section already tracks:

Lockfile env 'prod' tracks place_id 234567890123456 but the resolved target is 234567890999999.
This env has more than one place and the lockfile holds one, so writing the second would record
its metadata under the first one's id. Use one place per env, or delete the [envs.prod] section
if you meant to move it.

If you genuinely manage metadata on two places, give them an env each in rbxplace.toml pointing at the same universe_id. Universe-level fields (voice_chat, devices, private_server, social_links, visibility) apply to the whole experience either way, so declare those in [game] and keep the per-env overlays to the place-level ones.

Commands

rbx meta init

Initialize a new config file. Without flags, writes a commented template.

FlagDescription
--from-remotePopulate config from live universe/place state
--universe-idUniverse ID (standalone mode; requires --place-id)
--place-idRoot place ID (standalone mode; requires --universe-id)

With --from-remote and --env <name> instead of --universe-id/--place-id, init resolves IDs from rbxplace.toml and writes the lockfile under [envs.<name>] (no [experience] block in the toml).

rbx meta sync

Apply the config (base + env overlay) to Roblox. Diffs against the lockfile’s [envs.<name>] section to only send changed fields, then updates that section after each successful API call.

FlagDescription
--dry-runShow what would change without applying
--yes / -ySkip the confirmation prompt. What CI passes; see below

sync prompts before applying when the env has confirm = true in rbxplace.toml. --yes answers it in advance, which is how a pipeline gets through — and is the only thing standing between an unattended run and a write, so it belongs in the job that was reviewed rather than in a shell alias.

rbx meta check

Validate the config and print the diff against the lockfile for the targeted env. Read-only.

Exit codes: 0 nothing pending, 2 the config no longer matches the lockfile, 1 the check could not answer. Drift sits on its own code so a CI step can gate on the status alone.

rbx meta pull

Pull remote state for the targeted env into the config and lockfile. Differential: writes only what diverges (see Multi-environment above for the algorithm). Comments are preserved via toml_edit.

FlagDescription
--dry-runShow what would change without writing the lockfile or config
--accept-remoteDownload the current icon and thumbnails into media.dir (or media.dir/<env> for named envs) and update the config paths
--accept-localClear media hashes (next sync re-uploads local icon and thumbnails)
--yes / -ySkip the confirmation prompt

Media downloads use the public thumbnails.roblox.com service (512x512 for icon, 768x432 for thumbnails). The downloaded image is what Roblox serves now, not necessarily the original upload. Without --accept-remote / --accept-local, pull leaves media hashes untouched and prints a hint.

Per-env path namespacing: when --env <name> is passed (anything other than the implicit standalone “default” env), downloaded files go to <media.dir>/<env>/icon.png and <media.dir>/<env>/thumbnail_NN.png, and the resulting path is written as an overlay under [envs.<name>.media], never to the [media] base. This guarantees that pulling dev then prod doesn’t overwrite a shared assets/icon.png. The [media] base is reserved for paths you manage manually.

Skip if unchanged: pull skips re-downloading any icon or thumbnail whose image_id matches the lockfile and whose local file still exists on disk. Delete the file (or clear the lockfile entry) to force a re-download.

Per-tool flags

FlagDescription
--config <path>Path to rbxmeta.toml (default rbxmeta.toml)

Configuration

rbx meta requires a rbxmeta.toml file in the working directory (or specify with --config).

[experience] is optional and only used in standalone mode (no --env). When you pass --env <name>, universe_id / place_id come from rbxplace.toml. Per-env overrides go under [envs.<name>] and are layered on top of [game] / [media].

[experience]                 # optional, omit if you always pass --env
universe_id = 123456789
place_id = 987654321         # root place, name/description/server_size go here

[game]
name = "My Awesome Game"
description = "A really fun multiplayer game."
server_size = 50             # max concurrent players per server
voice_chat = false
genre = "adventure"          # cookie-only, legacy genre list
engine_avatar_settings = "avatar-settings.toml"   # cookie-only, opaque passthrough

# The fields below have no Open Cloud endpoint, so a `sync` whose plan touches
# one needs a session cookie. Leave them out and the rest of this file syncs
# with an API key alone. See "Cookie-only fields" further down.
visibility = "public"                   # "public" | "private", write requires cookie
studio_access_to_apis_allowed = true    # cookie-only, Studio can call DataStore/Open Cloud
beta_mode = false                       # cookie-only, true = hides from Home Recommendations

[game.private_server]
price = 100                  # Robux. 0 = free, >= 10 = paid. Omit table to disable private servers.

[game.devices]
desktop = true
mobile = true
tablet = true
console = false
vr = false

[game.server_fill]
mode = "custom"              # cookie-only
reserved_slots = 5           # only with mode = "custom"

[game.social_links.discord]
title = "Join our Discord"
url = "https://discord.gg/example"

# All four keys or none — Roblox takes this object whole. Cookie-only, and
# write-only: no Roblox endpoint returns it, so `pull` cannot adopt it.
[game.permissions]
third_party_teleport = false
third_party_asset = false
third_party_purchase = false
client_teleport = true

[game.avatar]
type = "player_choice"               # "r6" | "r15" | "player_choice"
animation = "player_choice"          # "standard" | "player_choice"
collision = "outer_box"              # "inner_box" | "outer_box"
joint_positioning = "artist_intent"  # "standard" | "artist_intent"

# Both scale tables need all five keys. Write-only, like [game.permissions].
[game.avatar.min_scale]
height = 0.9
width = 0.7
head = 0.95
body_type = 0.0
proportion = 0.0

# All ten slots or none, for the same reason as [game.permissions]: Roblox
# replaces the array rather than merging into it. Write-only.
[game.avatar.asset_overrides]
face = "player_choice"
head = "player_choice"
torso = "player_choice"
left_arm = "player_choice"
right_arm = "player_choice"
left_leg = "player_choice"
right_leg = "player_choice"
t_shirt = "player_choice"
shirt = "player_choice"
pants = 12345678            # an asset id forces that slot

# Omit this table to leave paid access unmanaged. `mode = "free"` is an
# instruction to turn it off, which is not the same thing.
[game.paid_access]
mode = "paid"                # "free" | "paid"
price = 25                   # Robux, only with mode = "paid"

[media]
icon = "assets/icon.png"
thumbnails = ["assets/thumb1.png", "assets/thumb2.png"]
dir = "assets"               # destination for `pull --accept-remote` downloads
bleed = true
language_code = "en_us"

# Per-env overrides (optional). Layered on top of [game] / [media] when
# --env <name> is passed.
[envs.dev]
visibility = "private"
[experience]
FieldTypeRequiredDescription
universe_idu64YesYour Roblox universe ID
place_idu64YesRoot place ID - destination for name, description, server_size
[game]

Scalar fields live directly under [game]. Grouped multi-field settings (devices, social links, etc.) have their own sub-tables below.

FieldTypeAPIDescription
namestringOpen CloudDisplay name (written to the root place)
descriptionstringOpen CloudExperience description (written to the root place)
server_sizeu32Open CloudMax concurrent players per server
voice_chatboolOpen CloudEnable in-experience voice chat
allow_copyingboolCookieLet anyone take a copy of this place from its Roblox page. Defaults to false and the only interesting value is true, for a place published deliberately as a template or as open source. It is not a protection: it governs a button on a page, not who can reach the file, so setting it to false hardens nothing that was not already the default
visibilitystringOpen Cloud read / Cookie write"public" or "private"
studio_access_to_apis_allowedboolCookieAllow Studio scripts to call Open Cloud / data store APIs
beta_modeboolCookieEnable Experience Beta mode (hides from Home Recommendations)
engine_avatar_settingsstringCookiePath to a .toml or .json file holding the modern avatar rules, relative to this config file. Passed through opaquely — see the section below
genrestringCookieLegacy genre. One of all, tutorial, scary, town_and_city, war, funny, fantasy, adventure, sci_fi, pirate, fps, rpg, sports, ninja, wild_west. Legacy in Roblox’s own sense — discovery moved to experience types and tags years ago — but the field still round-trips, so a config that does not model it loses whatever it was set to on the next pull
[game.private_server]

Omit this table entirely to disable private servers.

FieldTypeRequiredDescription
priceu64YesPrice in Robux. 0 = free private servers, >= 10 = paid. Values 1-9 are rejected by Roblox (and by rbx meta’s preflight). Paid private servers (> 0) also require visibility = "public".
[game.devices]

Omit a field to leave that device unchanged on Roblox.

FieldTypeDescription
desktopboolAllow desktop players
mobileboolAllow phone players
tabletboolAllow tablet players
consoleboolAllow console players
vrboolAllow VR players
[game.server_fill]

Server fill mode. Requires cookie: not exposed by Open Cloud.

FieldTypeRequiredDescription
modestringYes"automatic", "empty", or "custom"
reserved_slotsu32Only with mode = "custom"Number of slots reserved per server
[game.permissions]

What the experience lets other experiences and the client do to it. Requires cookie.

All four fields are required. That is the API’s doing, not a style choice: Roblox takes permissions as one object, so writing one flag writes all four, and it exposes no endpoint that returns them. There is no way to fill in the flags a partial table left out — not from Roblox, and not from a first-run lockfile. A table with three of the four keys is a load error rather than a write whose result nobody can predict.

The same absence means pull and init cannot adopt these. The lockfile records what rbx meta last wrote, which is what check and sync compare against; a change made in the Creator Dashboard will not be noticed until the next sync overwrites it.

FieldTypeRequiredDescription
third_party_teleportboolYesWhether another experience may teleport players into this one
third_party_assetboolYesWhether this experience may load assets it does not own
third_party_purchaseboolYesWhether this experience may prompt purchases for another creator’s products
client_teleportboolYesWhether client-initiated teleports are allowed
[game.avatar]

Avatar rules. Requires cookie. The four mode fields are read back by pull; the two scale tables are not (see below).

FieldTypeValuesDescription
typestringr6, r15, player_choiceWhich rig players get
animationstringstandard, player_choiceWhether players keep their own animations
collisionstringinner_box, outer_boxThe shape of an avatar’s collision box
joint_positioningstringstandard, artist_intentHow avatar joints are positioned
[game.avatar.min_scale] / [game.avatar.max_scale]

The scale range players are held to. Requires cookie, and write-only: Roblox returns neither table from any endpoint, so pull leaves whatever the config says rather than inventing a range.

All five fields are required in each table, for the same reason as [game.permissions]: Roblox takes each table as one object, and a table with three keys is an object it reads as “the other two are zero”.

FieldTypeRequiredDescription
heightfloatYesHeight multiplier
widthfloatYesWidth multiplier
headfloatYesHead multiplier
body_typefloatYesBody-type (“rthro”) multiplier, 0 to 1
proportionfloatYesProportions multiplier, 0 to 1

Roblox’s model declares a sixth field, depth, and this sends five. That is on precedent rather than principle: Mantle carries the same five and wrote avatar scales against real experiences for years, and depth appears in no avatar scaling UI to compare against. It is the strongest evidence available, and it is not proof — nothing here has sent this object to Roblox yet. If a synced experience comes back with squashed avatars, this is the first place to look.

[game.avatar.asset_overrides]

Forces what players wear in each of the ten slots Roblox exposes. Requires cookie, and write-only.

All ten slots are required. Roblox takes universeAvatarAssetOverrides as one array and replaces it wholesale, so a table naming three slots is a request to reset the other seven — and since no endpoint returns the array, nothing could fill in the missing seven either.

Each slot is one of two things:

  • an asset id, forcing every player into that asset for the slot
  • the string "player_choice", leaving the slot to the player
[game.avatar.asset_overrides]
face = "player_choice"
head = "player_choice"
torso = "player_choice"
left_arm = "player_choice"
right_arm = "player_choice"
left_leg = "player_choice"
right_leg = "player_choice"
t_shirt = "player_choice"
shirt = 987654321
pants = 12345678

Anything other than an id or "player_choice" is a load error naming the valid value, rather than a slot silently skipped.

game.engine_avatar_settings

Path to a file holding the modern avatar rules — animation rules, clothing rules, accessory rules, collision rules, body rules. Requires cookie, and write-only.

TOML or JSON, decided by the extension. Roblox’s field is a JSON string, so anything dumped out of Studio or copied from someone’s example is already JSON and refusing it would mean hand-converting a hundred and fifty keys. But a project whose every other config file is TOML should not have to grow one that is not. Both are accepted; both land on the same document before it is hashed and sent, so rewriting avatar.toml as avatar.json with the same content changes nothing and re-sends nothing.

Measured 2026-08-17, against a live test universe. This document is currently a write-only, unverifiable channel, and that is worth knowing before you rely on it.

Three things were established by sending real documents:

  • The PATCH accepts any inner content. To that endpoint the field is an opaque string, so it validates nothing — a 200 says the request was transported, not that Roblox understood the document.
  • Roblox returns no echo. The specification says the response carries engineAvatarSettings back; it does not, so a misspelled key cannot be reported (see below).
  • The resulting settings were not visible in the Creator Hub. They live in Studio’s Game Settings → Avatar, and even there the mapping to this document has not been confirmed.

The practical consequence: nothing — not this tool, not the dashboard — can currently tell you that an avatar document took effect. Treat it as fire-and-forget, keep the document small, and verify in game rather than by reading a setting back. This is also why schemas/rbxavatar.schema.json exists: an editor catching a typo before the write is the only check available anywhere in the loop.

It is not an extra layer on top of the avatar fields — it is the same settings written another way, and sending both is refused.

AvatarBodyRules in this document carries CustomHeightScale = { min, max }, which is game.avatar.min_scale.height and game.avatar.max_scale.height in one place. Its per-slot Custom*Id keys are game.avatar.asset_overrides. Sending both in one sync tells Roblox the same thing twice, in two shapes, with nothing making them agree:

Error: `engine_avatar_settings` describes the same settings as these fields,
and this sync would send both:

  game.avatar.min_scale  ·  also set by AvatarBodyRules in the document

Keep whichever one you maintain and remove the other from rbxmeta.toml.

This refuses rather than warns for a reason specific to this field: no read returns either side. A project that writes a contradiction cannot discover it from this tool, from the API, or from the Creator Hub. It surfaces the next time somebody opens Studio, as AvatarSettings Error: Failed to deserialize properties — which is how the overlap was found, on a test universe that had been sent both.

The check is per field, not blanket: a document that describes only collisions does not conflict with the scales, and either channel alone is the ordinary case.

A key Roblox did not understand is reported after the sync. This is the one place Roblox says anything about the inside of the document: the PATCH responds with the configuration it ended up with, engineAvatarSettings included, so sync compares that echo against what it sent.

  ✓ legacy universe config patched
  ! Roblox did not keep 1 avatar key — it was not applied:
      AvatarRules.AvatarTpye
    A misspelling is the usual cause. The rest of the document applied.

As measured, this does not currently fire: the response carried no engineAvatarSettings and a deliberately misspelled key went unreported. The sync now says so — Roblox returned no avatar echo to check against (N bytes) — rather than staying silent and letting a reader assume the document was verified. The byte count is the diagnosis, and the check is kept because it costs a comparison on a response that was already arriving.

When it does fire, it is a warning and not an error, because by the time there is an echo to read the write has already landed — failing then would report an error for something that succeeded. Keys Roblox filled in are reported too, more quietly: that is the normal completion of a partial document, and it is how you learn the full shape without guessing.

schemas/rbxavatar.schema.json describes this document, so an editor completes the key names and shows what each numeric mode means on hover. Name the file rbxavatar.toml and the associations in the README match it without editing. The schema is guidance, not a gate: additionalProperties is open everywhere, so a key Roblox adds tomorrow is one your editor stays quiet about and rbx meta sends anyway — the same reason the document is not modelled in the first place.

Watch one trap the schema calls out on hover: AvatarRules.AvatarType here runs 0 = R6, 1 = R15, 2 = both, while [game.avatar] type is the older universeAvatarType and runs 1 = R6, 2 = player choice, 3 = R15. Same idea, two endpoints, different integers.

An extension that is neither is refused by name rather than sniffed — guessing from the content would let a .txt through and turn a typo in the path into a silent success. The one thing TOML cannot express is null; nothing in the documents Roblox accepts here uses one, but a document that needed it would have to be the JSON form.

This tool does not model what is in the file. It reads it, checks it parses as JSON, and sends it. That is a deliberate limit rather than a shortcut, and the reason is in Roblox’s own specification: the field is typed as a JSON string, and it is annotated “This is an experimental field which may be changed or removed in future.” Modelling its structure would be inventing a contract nobody offered, and would break the day Roblox redefined a key. A file you control, versioned next to the rest of the config, keeps working whatever happens inside it.

The trade-off is stated plainly: a typo in a key name reaches Roblox. What is checked locally is that the file exists and parses — a malformed file fails before a cookie-authenticated write, not as an opaque 400 after it.

[game]
engine_avatar_settings = "avatar-settings.toml"
# avatar-settings.toml — the same document the JSON form would carry
version = 1

[AvatarRules]
AvatarType = 1

[AvatarCollisionRules]
CollisionMode = 1
SingleColliderSize = [2, 3, 1]

Roblox’s semantics line up with this file’s: an absent or empty value is not written, so omitting the key leaves the settings alone. A file containing {} is how you clear them, and that reaches the wire rather than being read as “nothing to send”.

The diff is on a hash of the canonical serialisation, recorded in the lockfile as engine_avatar_settings_hash. Reindenting the file or reordering its keys is therefore not a change to re-send; editing a value is.

To get a starting document, the most complete public example is Phoenix-CLI’s Test/ConfigToFile.luau, which spells out every key with a comment on what it does.

[game.paid_access]

Whether players pay to enter. Requires cookie.

Omitting the table leaves paid access unmanaged; mode = "free" actively turns it off. Those are different states, which is why this is a tagged table rather than a bare price — a price of zero means neither.

isForSale and price are sent together, because Roblox ignores a price on an experience that is not for sale, and an experience switched on for sale with no price is free by accident.

FieldTypeRequiredDescription
modestringYes"free" or "paid"
priceu64Only with mode = "paid"Price in Robux
[game.social_links.<platform>]

Omit a section to remove that link from Roblox. Available platforms: facebook, twitter, youtube, twitch, discord, roblox_group, guilded.

FieldTypeRequiredDescription
titlestringYesDisplay title
urlstringYesLink URL
[media]
FieldTypeDefaultDescription
iconstring(unset)Path to a PNG icon (relative to the config file)
thumbnailsstring[][]Up to 10 PNG thumbnail paths, displayed on Roblox in this order
dirstring(unset)Destination directory used by pull --accept-remote to save downloaded icon and thumbnails
bleedbooltrueApply alpha bleed to PNGs before upload
language_codestring"en_us"Locale used for icon and thumbnail upload

Field coverage

FieldAPINotes
game.name, game.descriptionOpen CloudWritten to the root place
game.server_sizeOpen CloudMax players per server
game.voice_chatOpen Cloud
game.private_server.priceOpen CloudOmit table to disable
game.devices.*Open Clouddesktop / mobile / tablet / console / vr
game.social_links.*Open Cloud7 platforms
media.iconOpen CloudLocalized via legacy-game-internationalization
media.thumbnails[]Open CloudUp to 10, ordered
game.server_fillCookiesocialSlotType + customSocialSlotsCount
game.allow_copyingCookiecopyingAllowed
game.visibilityOpen Cloud read / Cookie writeLegacy activate / deactivate
game.studio_access_to_apis_allowedCookieLegacy /v2/universes/{id}/configuration
game.beta_modeCookieapis.roblox.com/experience-releases/.../release_status
game.genreCookieLegacy /v2/universes/{id}/configuration, read back from /v1/.../configuration
game.avatar.type, .animation, .collision, .joint_positioningCookieSame pair of endpoints. Sent as the integers Roblox uses
game.avatar.min_scale, .max_scaleCookie, write-onlyuniverseAvatarMinScales / MaxScales. Not returned by any GET, so pull leaves them alone
game.avatar.asset_overridesCookie, write-onlyuniverseAvatarAssetOverrides. Sent whole, ten slots
game.engine_avatar_settingsCookie, write-onlyengineAvatarSettings, a JSON string. Read from a .toml or .json file and passed through unmodelled
game.paid_accessCookieisForSale + price, sent together
game.permissions.*Cookie, write-onlyThe permissions object. Not returned by any GET — see below

Not supported

Open Cloud does not expose these fields and rbx meta does not (yet) handle them via cookie:

  • Friends-only visibility (only public / private supported; the API field is isFriendsOnly)
  • Age rating (write)
  • optInRegions / optOutRegions. Declined rather than pending; the reasoning is in TODO.md. In short: the enum has one real value (China), it is write-only like the fields above, and whether an experience is actually available there is decided by a Roblox moderation status that no config file can set. A key that looked like a switch would be a request
  • Badges, game passes, developer products - use rbx shop instead

Write-only fields

A field being cookie-only means the API key alone cannot write it. A field being write-only means something stronger: Roblox exposes no request that returns it, so nothing can read it back.

  • game.permissions.*
  • game.avatar.min_scale, game.avatar.max_scale
  • game.avatar.asset_overrides
  • game.engine_avatar_settings

The read used by pull and init is GET /v1/universes/{id}/configuration, and it carries neither. The endpoint that does — /v2/universes/{id}/configuration — answers to PATCH only.

The consequence, in one sentence: pull never touches these, and check compares them against the lockfile rather than against Roblox. A pull keeps whatever the previous lockfile recorded for them rather than adopting what the config asks for — taking the config’s word would make the lockfile assert that Roblox holds a value nobody checked, and the next sync would then send nothing while check reported agreement. Setting one in the Creator Dashboard will not show up as drift; the next sync that touches the field will simply overwrite it.

The Open Cloud API doesn’t expose every metadata field. These fields require a cookie:

  • game.server_fill
  • game.allow_copying
  • game.visibility (write only; read is via Open Cloud)
  • game.studio_access_to_apis_allowed
  • game.beta_mode
  • game.genre
  • game.avatar.*
  • game.engine_avatar_settings
  • game.paid_access
  • game.permissions.*

The cookie is provided via the global --cookie flag, the RBX_COOKIE env var, or a local Roblox Studio install (Windows registry, macOS plist) — that last one opt-in, asked once or declined where there is nobody to ask. --auto-cookie is the standing yes and --no-auto-cookie the standing no. pull and init skip these fields and say so when no cookie is available; sync stops before applying anything if the plan touches one.

A cookie that exists is not a cookie that still works, so when the plan touches one of these fields sync also asks Roblox once, before the confirmation prompt and before the first write, whether the session is still valid. An expired one refuses the whole run rather than applying the Open Cloud half and failing on the legacy half. See what is checked.

The credential itself is documented once, in docs/cookie.md: the full resolution order, what an auto-detected cookie prints on stderr, and why it is never written to disk.

Tip: pipe the cookie from Lune without touching Studio’s local files yourself:

# helper script (Lune)
echo 'local roblox = require("@lune/roblox"); io.write(roblox.getAuthCookie(true) or "")' > get-cookie.luau

# bash
export RBX_COOKIE=$(lune run get-cookie)
# PowerShell
$env:RBX_COOKIE = (lune run get-cookie)

Required API scopes

ResourceScopesDocumentation
Universeuniverse:read, universe:writeUniverse API
Placeuniverse.place:read, universe.place:writePlace API
Icons & thumbnailsnone to read; see note to writepull reads them from thumbnails.roblox.com, the public service, with no key attached. sync uploads through legacy-game-internationalization, whose scope is not in the catalog and has not been established here — if an upload is refused, that is the thing to look for

Lockfile

rbx meta generates a rbxmeta.lock.toml next to the config that tracks the last-applied state per env:

version = 1

[envs.dev]
universe_id = ...
place_id = ...
[envs.dev.game]
# ... mirror of [game] + [envs.dev] resolved state
[envs.dev.media.icon]
hash = "..."
image_id = ...

[envs.prod]
# ...

Standalone mode (no --env) writes under [envs.default]. Commit the lockfile to version control.

sync --env <name> is idempotent: only fields differing between the resolved (base + overlay) config and [envs.<name>] in the lockfile are sent. Media re-uploads happen only when the local file hash differs from the lockfile hash.

How it works

sync --env <name> resolves (Game, MediaConfig) for that env (base + overlay), builds a SyncPlan against [envs.<name>] in the lockfile, and applies it in this order:

  1. Check the session (cookie), only when the plan contains a cookie-only field. One call, before anything is sent, so a dead session changes nothing at all.
  2. Activate (legacy / cookie) if visibility is going from private to public. Must be first so dependent patches (like paid private servers) don’t 500.
  3. PATCH universe (Open Cloud): voice chat, private server price, devices, social links
  4. PATCH place (Open Cloud): name, description, server size
  5. PATCH place legacy (cookie): server_fill, allow_copying
  6. PATCH universe configuration legacy (cookie): studio_access_to_apis_allowed
  7. POST experience-releases (cookie): beta_mode toggle
  8. Deactivate (legacy / cookie) if visibility is going from public to private. Last so the universe stays in its permissive state until everything else is patched.
  9. Upload icon if its BLAKE3 hash differs from the lockfile
  10. Delete thumbnails removed from config, upload new ones, reorder to match the toml order

The lockfile is saved after every successful API call (including each individual thumbnail delete and upload) so a crash mid-sync never leaves remote and lockfile in disagreement.

Preflight validations

Before sending any request, rbx meta validates locally:

  • private_server.price is 0 or >= 10 (Roblox rejects 1-9 Robux)
  • visibility = "private" with private_server.price > 0 is invalid (Roblox requires public)
  • Referenced media.icon and media.thumbnails[] paths exist on disk

When a Roblox call still fails for a known reason rbx meta couldn’t detect locally (e.g. the 60-day cooldown on private server price changes), the error message includes a hint pointing to Creator Hub for the real diagnostic.

Retries

429 and 5xx responses are retried with exponential backoff (max 3 attempts). 403 responses carrying x-csrf-token (cookie flow) are retried transparently after caching the new token.

Attributions

The alpha bleeding implementation, used through rbx shop, is adapted from Asphalt (MIT), which itself adapted it from Tarmac (MIT). Thank you to both. The license notices are in THIRD-PARTY-NOTICES.md.

rbx config

Manage Roblox in-experience live configs via the Open Cloud Configs API.

rbx config keeps a local rbxconfig.toml as the canonical source of truth for your in-experience tunables and syncs it to Roblox. It targets environments defined in a shared rbxplace.toml, shows diffs before publishing, and supports gradual rollout.

Features

  • Declarative config - All tunables in a single rbxconfig.toml, organized per environment
  • Diff preview - check and sync --dry-run show exactly what changes before any publish
  • Full sync - rbxconfig.toml is the canonical state: missing keys are removed from live
  • Pull - Mirror the live config back into rbxconfig.toml, preserving local descriptions
  • Revision history - versions and rollback to inspect and revert past publishes
  • Gradual rollout - Optional GradualRollout deployment strategy (~15 min propagation)
  • Multi-environment - Targets environments defined in rbxplace.toml (shared with rbx place)
  • JSON - --json on get, list and versions writes one document to stdout and nothing else, with documented field names, for jq and CI

Quick start

# Bootstrap a template
rbx config init

# Or pull the existing live config into a local file
rbx config pull --env dev --api-key YOUR_API_KEY

# Edit rbxconfig.toml, then preview + publish
rbx config check --env dev --api-key YOUR_API_KEY
rbx config sync  --env dev --api-key YOUR_API_KEY

--env is required on all commands except init. It is resolved against rbxplace.toml (override with the global --places).

Commands

rbx config init

Write a commented template rbxconfig.toml in the current directory. Bails if the file already exists. Override the target path with --config <path>, which belongs to rbx config itself and so goes before the subcommand.

rbx config init
rbx config --config configs/rbxconfig.toml init
rbx config get [<key>]

Print the live published config. If a key is provided, prints only that key’s value.

rbx config get --env dev
rbx config get "features.new_xp_popup" --env dev

--json

One JSON document on stdout, nothing else. Diagnostics stay on stderr, so the document parses whatever else the run had to say.

This document is a snapshot of the published config: what Roblox is serving right now. It does not read rbxconfig.toml, and says so by having no config_file field. Whether the local file agrees with live is a different question, and rbx config check is what answers it — under rbx check --json that row is config/live and it carries outcome, summary and details. None of those three words appears here, so a filter written for one cannot half-read the other.

rbx config get "ops.teleport_place_id" --env dev --json
{
  "schema_version": 1,
  "env": "dev",
  "universe_id": 9876543210,
  "config_version": 14,
  "key": "ops.teleport_place_id",
  "value": 12345,
  "entries": {
    "ops.teleport_place_id": { "type": "number", "value": 12345 }
  }
}

Without a key, the whole published config comes back in the same envelope — the identical document rbx config list --json emits, so one filter reads both.

FieldTypeMeaning
schema_versionintegerDocument format, shared with rbx check --json. 1 today. Refuse a version you do not understand
envstringThe env named on the command line. Absent under a bare --universe-id, where the human form prints a <universe-id> placeholder that is a label and not an env name
universe_idintegerThe universe this snapshot is from
config_versionintegerRoblox’s configVersion for this snapshot, in snake case like every other field
keystringThe key asked for. Absent when none was
valueanyThat key’s value, raw, exactly what the bare form prints. Absent whenever key is
entriesobjectKeyed by config key: one entry when key is set, all of them otherwise. Always present
entries.<key>.typestringbool, number, string, array, object, null — the words the listing prints in its type column
entries.<key>.valueanyThe published value

There is no totals object. rbx check --json has one and it counts outcomes; one here would count keys under the same name. .entries | length is the count, and it cannot be misread.

Which of value and entries you get is decided by the invocation, never by the data: a keyless read omits value even against a config holding exactly one key, so a filter cannot start working by accident and break when a second key is published. An unknown key stays an error (exit 1), never a document with a null value — “not published” and “published as nothing” are different facts.

PLACE=$(rbx config get "ops.teleport_place_id" --env dev --json | jq -r .value)
rbx config get --env dev --json | jq -r '.entries | to_entries[] | select(.value.type == "bool") | .key'
rbx config list

List all published config keys with their type and a compact value preview.

rbx config list --env dev

Output example:

Live config keys - env: dev (configVersion 14)
  balance.speed_multipliers  [object]  {"tier_1":1.5,"tier_2":2}
  features.new_xp_popup      [bool]    true
  ops.teleport_place_id      [number]  12345

--json

The same snapshot as one JSON document on stdout, nothing else — and it is the same document rbx config get --json emits without a key, envelope and all, so one filter reads both. See the field table under rbx config get for what each field means.

rbx config list --env dev --json
{
  "schema_version": 1,
  "env": "dev",
  "universe_id": 9876543210,
  "config_version": 14,
  "entries": {
    "balance.speed_multipliers": { "type": "object", "value": { "tier_1": 1.5, "tier_2": 2 } },
    "features.new_xp_popup": { "type": "bool", "value": true },
    "ops.teleport_place_id": { "type": "number", "value": 12345 }
  }
}

A universe with nothing published yet is an empty entries object and exit 0, not a missing document: .entries | length == 0 has to be answerable.

rbx config list --env dev --json | jq -r '.entries | keys[]'
rbx config list --env prod --json | jq -r .config_version
rbx config check

Show the diff between local rbxconfig.toml and the live published config. Read-only - no draft, no publish, no confirmation prompt.

Exit codes: 0 local matches live, 2 entries differ, 1 the check could not answer. Drift sits on its own code so a CI step can gate on the status alone.

rbx config check --env dev
rbx config sync

Push rbxconfig.toml as the canonical state for the target env. Uses PUT /draft:overwrite so keys absent from the file are removed from live. Always shows the diff first. Writes the published configVersion and entries to rbxconfig.lock.toml on success.

rbx config sync --env dev --dry-run    # preview only
rbx config sync --env dev              # prompts for confirmation
rbx config sync --env dev --yes        # skip confirmation
rbx config sync --env dev --strategy gradual-rollout
FlagDescription
--message / --no-messagePublish message, or publish without one

--yes answers the message question too. sync asks twice on a terminal: once to confirm, once for a publish message. --yes means “do not ask me anything”, so it covers both and publishes with an empty message — pass --message alongside it if the message matters.

Off a terminal with none of the three, the run refuses and names them. It used to reach the prompt anyway and fail with not a terminal, which is a fact about the stream rather than about the flag that fixes it. | --strategy | immediate (default) or gradual-rollout | | --dry-run | Show diff without publishing | | --yes | Skip confirmation prompt |

rbx config pull

Fetch the live published config and write it to rbxconfig.toml under the target env. Preserves any local description annotations on keys that still exist. Other envs in the file are left untouched. The published configVersion and timestamp are recorded in rbxconfig.lock.toml.

rbx config pull --env dev
rbx config pull --env dev --yes       # overwrite without confirmation
rbx config --config staging.toml pull --env dev   # write to a different file
FlagDescription
--yesOverwrite without confirmation if file exists
rbx config versions

List the revision history for the target env’s universe. The current revision is tagged [published].

rbx config versions --env dev
rbx config versions --env dev --count 50
FlagDescription
--countNumber of revisions to show (default: 20)
--jsonWrite the revisions to stdout as one JSON document

--json

One JSON document on stdout, nothing else. The progress line the human form prints is not part of the history, so under --json it is simply not printed.

rbx config versions --env dev --json
{
  "schema_version": 1,
  "env": "dev",
  "universe_id": 9876543210,
  "count": 20,
  "count_reached": false,
  "revisions": [
    {
      "revision_id": "aaaaaaaa-1111-4000-8000-000000000001",
      "version": 14,
      "time": "2026-08-15T09:30:00Z",
      "message": "raise the cap",
      "changed_keys": ["balance.speed_multipliers", "ops.teleport_place_id"],
      "published": true
    },
    {
      "revision_id": "bbbbbbbb-2222-4000-8000-000000000002",
      "version": 13,
      "time": "2026-08-14T09:30:00Z",
      "changed_keys": [],
      "published": false
    }
  ]
}
FieldTypeMeaning
schema_versionintegerDocument format, shared with rbx check --json. 1 today
envstringThe env named on the command line. Absent under a bare --universe-id
universe_idintegerThe universe whose history this is
countintegerThe --count in force for this run
count_reachedbooleanTrue when the run stopped because it hit --count rather than because it ran out of revisions. Raise --count to see further back
revisionsarray of objectsNewest first, the order Roblox returns and the listing prints
revisions[].revision_idstringFull id, not the eight-character prefix the listing shows. This is what rbx config rollback takes
revisions[].versionintegerThe configVersion this publish produced
revisions[].timestringThe timestamp Roblox sent, untouched ISO. The listing rewrites it to be read; a consumer wants it back
revisions[].messagestringThe publish message. Absent when there was none, which is not the same fact as an empty one — the listing renders both as (no message)
revisions[].changed_keysarray of stringsThe keys this revision changed, sorted so the same history renders the same bytes twice running. The listing prints only the count, which is length. Always an array, empty included
revisions[].publishedbooleanTrue for the revision currently serving players — the one the listing tags [published]. Stated rather than inferred from the position, so a consumer that sorted the array still knows

This is history, not state: it shares nothing with the get/list document beyond the envelope, and nothing at all with the config/live row of rbx check --json, which is a verdict rather than a record.

rbx config versions --env prod --json | jq -r '.revisions[] | select(.published) | .revision_id'
rbx config versions --env prod --count 50 --json | jq -r '.revisions[].changed_keys[]' | sort | uniq -c
rbx config rollback [<revision_id>]

Roll back to a previous revision. Restores the chosen revision into the draft and publishes it as a new version. If revision_id is omitted, an interactive picker lists recent revisions (current tagged [published]).

rbx config rollback --env dev                   # interactive picker
rbx config rollback --env dev <revision_id>     # direct
rbx config rollback --env dev --count 30        # picker with more entries
FlagDescription
--countNumber of revisions to show in the picker (default: 10)

Per-tool flags

FlagDefaultDescription
--configrbxconfig.tomlPath to the local config file
--universe-idnoneBypass rbxplace.toml lookup. --env is still required to name the section in rbxconfig.toml for commands that read/write it

Configuration

rbxplace.toml

rbx config resolves environment names to universe IDs from rbxplace.toml (shared with rbx place):

[prod]
universe_id = 9876543210
places.main = 123456789012345

[staging]
universe_id = 9876543211
places.main = 234567890123456

Pass a different path via the global --places, or skip the lookup entirely with --universe-id <id>. Each env section may set confirm = true to force interactive confirmation before any write to that env.

rbxconfig.toml

The local source of truth for your tunables, organized per environment. Every entry under [<env>.entries."key"] is synced as a config key. Scalars become scalar values; tables become JSON objects. Each entry has a required value and an optional description (local-only - not sent to Roblox).

[prod.entries."features.new_xp_popup"]
value = false
description = "Disabled in prod until stable"

[prod.entries."ops.teleport_place_id"]
value = 12345

[prod.entries."balance.speed_multipliers"]
value = { tier_1 = 1.5, tier_2 = 2.0 }

[staging.entries."features.new_xp_popup"]
value = true
description = "Testing new popup - remove in v2"

Dotted key names (e.g. "features.new_xp_popup") are preserved verbatim as the Roblox config key. In-game, read them with:

ConfigService:GetConfigAsync():GetValue("features.new_xp_popup")

rbxconfig.lock.toml

Written automatically by pull and sync, next to rbxconfig.toml. Records, per environment, the last published revision_id (the v{N} configVersion), the synced_at timestamp, and a snapshot of the entries that were pushed or pulled. Informational only - not sent to Roblox. Commit it if you want to track sync history alongside your config.

version = 1

[envs.prod]
revision_id = "v14"
synced_at = "2024-01-15T14:30:00Z"

[envs.prod.entries]
"features.new_xp_popup" = false
"ops.teleport_place_id" = 12345
"balance.speed_multipliers" = { tier_1 = 1.5, tier_2 = 2.0 }

Required API scopes

OperationScope
Read live config (get, list, check, pull, sync diff)universe:read
Write config (sync, rollback)universe:write

Deployment strategies

StrategyFlagPropagation
Immediate--strategy immediate~5 minutes
Gradual rollout--strategy gradual-rollout~15 minutes

Gradual rollout incrementally applies the config across servers, reducing the blast radius of a bad config push.

rbx shop

Declaratively manage Roblox game passes, badges, and developer products from a single TOML config file - with first-class multi-environment support (dev/staging/prod universes from one source).

rbx shop syncs your local configuration to Roblox, tracks remote state in a per-env lockfile, detects icon changes with BLAKE3 hashing, and generates a typed Luau module folder that resolves the right asset IDs at runtime via game.GameId.

Features

  • Declarative config: define all your passes, badges, and products in a single rbxshop.toml
  • Multi-env overlays: [envs.<name>] overlays layered on top of base; one config drives every universe
  • Two-way sync: push local changes to Roblox or pull remote state into config + lockfile
  • --env all: operate on every env defined in rbxplace.toml in one command
  • Auto overlay writes: pull writes diverging fields to [envs.<name>], clears them when remote matches base
  • Typed codegen: generates a folder with an init.luau dispatcher (exported GameIds type) and one module per env, dispatching on game.GameId at runtime
  • Offline regeneration + drift guard: rbx shop codegen rebuilds that folder without credentials, and --check proves the committed copy still matches its inputs (see Guarding generated files)
  • Icon management: upload icons, detect changes via BLAKE3 hashing, download remote icons
  • Conflict detection: detects when remote icons differ from local and asks which to keep
  • TypeScript output: optional init.d.ts for roblox-ts consumers
  • Alpha bleed: applied to icons before upload (enabled by default)
  • Duplicate detection: when two remote resources share a name, asks which key the second should take (see Duplicate names)
  • Gift products: create_gift = true on a pass or product derives a matching “GiftX” developer product automatically (see Gift products)
  • JSON: --json on show and list writes one document to stdout and nothing else, with documented field names, for jq and CI. show is the declared side, list the remote one, and neither claims they agree — that is rbx check --json

Quick start

If you already use rbx place / rbx config, you have a rbxplace.toml like:

[dev]
universe_id = 9876543210

[prod]
universe_id = 9876543211

Initialize from one of the envs, then layer the other:

rbx shop init --from-remote --env dev --api-key KEY      # populates base from dev
rbx shop pull --env prod --api-key KEY                    # writes [envs.prod] overlay for diverging fields
rbx shop sync --env dev --api-key KEY                     # apply config back to dev
rbx shop sync --env all --api-key KEY                     # apply to every env in one shot

Standalone (no rbxplace.toml)

rbx shop init --from-remote --universe-id 123456789 --api-key KEY
rbx shop sync --api-key KEY

This embeds [experience].universe_id in rbxshop.toml and treats it as the implicit default env.

Multi-environment

Three concepts:

  1. Base ([passes.X], [badges.X], [products.X]): the logical schema shared across every env.
  2. Overlay ([envs.<name>.passes.X], etc.): per-env diffs layered on top of base.
  3. Resolution: --env <name> resolves universe_id from rbxplace.toml, merges base + overlay, and uses the result as the effective config.

sync --env dev applies (base + envs.dev) to dev. sync --env prod applies (base + envs.prod) to prod. Same rbxshop.toml, different effective state per env.

Pull behavior (differential)

For each field on each resource, when you pull --env <name>:

StateResulting action
Resource missing in base, env is defaultAdd to base
Resource missing in base, env is namedAdd to [envs.<name>] overlay
Remote field == base fieldClear that field from overlay (no-op if absent)
Remote field != base fieldWrite the diverging field to the overlay

Pull never auto-promotes overlays back into base. If you notice a field is the same across every env, edit the toml manually to lift it into base - the next pull will detect the match and remove the now-redundant overlay entries.

What a write-back preserves

pull and rename edit rbxshop.toml in place rather than regenerating it, so a write-back keeps:

  • your comments, including the ones attached to a resource that was edited, and to one that was renamed;
  • your key order, so the diff of a pull is the fields that actually changed;
  • keys rbx does not model, whether a whole top-level table or a stray field inside a [passes.*] entry.

Only the fields rbx owns are touched. A default value is written out only where you already wrote it, or where the value diverges from the default - a pull does not sprinkle for_sale = true through a file that never mentioned it.

Both commands only insert and update lines, never reserialize the document. Round-tripping it through serde would drop comments, reorder keys, and silently delete any field it does not model — the same rule rbxplace.toml follows, see rbx env.

Unrecognised keys

A top-level key rbx does not read is kept, not deleted - but it is named on stderr at load, because from the outside an ignored key looks exactly like an honoured one:

warning: rbxshop.toml: 1 unrecognised top-level key, ignored by rbx 0.2.0:

  pases
    known keys: experience, owner, codegen, icons, gifts, include, passes, badges, products, envs

An ignored key changes nothing. Either it is misspelled, or it comes from a release newer than the one you are running.

Env-exclusive resources

A resource defined only in [envs.<name>.passes.X] is treated as exclusive to that env. In the codegen output, missing entries are stubbed to 0 in every other env’s module (a 0 asset ID is silently a no-op when passed to MarketplaceService / BadgeService, so the same code can run safely across envs).

Gift products

Roblox has no built-in way to gift a game pass or developer product to another player. The common workaround is a second developer product whose purchase, once handled server-side, grants the original item to a friend instead of the buyer. Setting create_gift = true on a [passes.X] or [products.X] entry provisions that twin product for you:

[gifts]
label = "[GIFT] "   # prefixed to the source's display name

[passes.VIP]
name = "VIP Pass"
price = 499
description = "VIP access to exclusive areas"
icon = "icons/vip.png"
create_gift = true

sync will create (and keep in sync) a developer product named "[GIFT] VIP Pass" with the same price, description, and icon, resolved under the key GiftVIP (source key prefixed with Gift - a prefix, not a suffix, so every gift twin autocompletes together when a Luau dev types Gift...). You’ll find it in the generated module at GameIds.products.GiftVIP alongside your other products.

The gift twin is entirely derived - it is never written into rbxshop.toml as its own [products.GiftVIP] entry. This is the key property: you only ever edit the source ([passes.VIP]), and the twin’s price/description/icon/name follow automatically on the next sync. There is no second entry to remember to keep in sync.

A few consequences worth knowing:

  • Icons are re-uploaded, not shared. Roblox’s product/pass APIs only accept raw image bytes on create/update, not a reference to an existing asset ID, so the gift’s icon costs a separate upload each time the source icon changes.
  • Renaming the source renames the twin. rbx shop rename passes VIP vip_pass also renames the twin’s lockfile entry (GiftVIP -> Giftvip_pass) so the next sync updates the existing remote product instead of creating a duplicate.
  • Turning create_gift off doesn’t delete anything remotely (Roblox products can’t be deleted via the API). The twin just stops appearing in the resolved config; check/sync will warn that it “exists in lockfile but not in resolved config” - the same warning any other removed resource gets.
  • create_gift can be overlaid per env just like any other field ([envs.dev.passes.VIP] create_gift = false), including on env-exclusive resources.
  • Collisions are rejected. If the derived key (Gift<key>) collides with a real [products.*] entry, or two gift-enabled sources derive the same key, sync/check fail with a clear error rather than silently merging them.
  • pull won’t re-import the twin. It recognizes the remote gift product by its derived key and skips writing it back into rbxshop.toml.

Adopting create_gift on an existing game

Roblox has no relationship between a pass/product and its gift twin - that link only exists in rbxshop.toml, once you declare it. So if a game already has manually-created gift products (built before ever using rbx shop, or by a different tool), plain init --from-remote or pull cannot know that “GIFT - VIP Pass” is meant to be the twin of “VIP Pass” - they import it as its own independent, literal entry.

rbx shop init --from-remote --gift-label "<label>" closes that gap by scanning the freshly-imported resources for the pattern once, at import time:

rbx shop init --from-remote --env dev --gift-label "[GIFT] " --dry-run   # preview first
rbx shop init --from-remote --env dev --gift-label "[GIFT] "             # then for real

For every pass/product, it looks for a developer product named exactly label + <source's name>. A match is only folded into the create_gift convention automatically when the price also matches - a name that happens to fit the pattern with a different price is left untouched and reported instead of merged, since that’s a much weaker signal of an actual twin:

✓ Detected gift twin: pass 'VIP' <- product '[GIFT] VIP Pass' (now `create_gift = true`, tracked as 'GiftVIP')
! Product '[GIFT] Coins100' looks like a gift twin of 'Coins100' but its price differs (149 vs 99) — left as a separate entry, review manually.

On a merge, create_gift = true is set on the source, the twin’s literal config entry is dropped, and its lockfile entry is rekeyed to the derived Gift<key> - so the very first sync afterward recognizes the existing remote product and updates it instead of creating a duplicate. --gift-label requires --from-remote; without it, nothing about existing gift products is touched.

--dry-run (also --from-remote-only) previews the whole import - counts, and every detected merge/mismatch - without downloading icons or writing rbxshop.toml/the lockfile, so you can review the plan before committing to it.

If you’d rather do it by hand for a single item instead of scanning everything, or --gift-label didn’t pick something up (e.g. the price genuinely diverges and you want to force it anyway), the manual recipe is:

  1. rbx shop rename products "GIFT - VIP Pass" GiftVIP - aligns the existing product’s key with what create_gift would derive, keeping its remote ID in the lockfile.
  2. Delete the now-redundant [products.GiftVIP] block from rbxshop.toml by hand (keep the lockfile entry).
  3. Add create_gift = true to [passes.VIP].

The next sync will then update the existing remote product rather than create a new one.

Duplicate names

Roblox does not require game pass, badge or developer product names to be unique. init --from-remote and pull key a newly discovered resource by its display name, because that is the only human-meaningful handle the API offers — so two passes both called “VIP” want the same key.

On a terminal, you are asked which key the second should take:

! Two passes are named 'VIP': id 111 already has the key, and id 222 does not
  Give this one its own key, or leave it empty to skip it.
  Key for pass 222: VIP_2

The default is a suggestion, not a decision — type vip_premium if that is what it is. Leaving it empty skips the resource, which is the old behaviour kept as a deliberate choice rather than a default.

Off a terminal — CI, a pipe, a cron job — nothing prompts. A command that stops on a question nobody will answer is worse than one that skips loudly. Instead you get the ids and the entry that fixes it permanently:

! Duplicate pass name 'VIP' — skipping id 222 (id 111 keeps the key).
  To manage it, add an entry naming its id, then re-run:

      [passes.<your_key>]
      id = 222

A resource filed under a key that is not its name keeps its real name in the config. name is normally omitted, meaning “the key is the display name”. When you file id 222 under vip_premium, name = "VIP" is written alongside it — without that, the next sync would read the key as the name you wanted and rename the live pass.

The tool never invents the key itself. A generated VIP_2 is an identifier you would live with for as long as the resource exists, chosen by something that has no idea which “VIP” is the premium one.

Only newly discovered resources can collide. Anything already tracked is keyed by its id, so pull never displaces a resource the config is already managing.

The other direction: a lockfile that went missing

Everything above is about reading duplicates. The costlier mistake is creating one, and it has a single ordinary cause: rbxshop.lock.toml was never committed.

sync decides to create a resource from one fact — the key is absent from the lockfile. On a clean checkout with no lockfile, that is every resource in the config, and they all already exist. The duplicates that run would mint cannot be undone: Roblox has no delete for a game pass or a developer product, and the best available repair is setting the accidental twin to for_sale = false, which leaves it in the experience forever, visible to everyone who already owns it.

So before creating anything, sync lists the experience’s existing passes, badges and products and stops if a name it is about to create is already taken:

Error: 2 resources would be created under a name that already exists on Roblox:

  pass 'VIP' (key 'VIP') — already id 111
  product '100 Coins' (key 'Coins') — already id 333

The usual cause is a rbxshop.lock.toml that was never committed, which makes every
resource look new.

Adopt what already exists, then sync:
    rbx shop pull --env prod

If these really are meant to be new resources with the same names, re-run with
--allow-duplicate-names. Passes and products cannot be deleted once created.

Details worth knowing:

  • It only runs when the plan contains a create. A sync that only updates resources asks Roblox nothing, so a write-only API key that worked yesterday still works.
  • It stops the whole run, not just the colliding resource. A partial sync would leave the lockfile describing an env that was half applied.
  • Matching is case-insensitive, on the resolved display name rather than the config key. A name differing only in case is far more likely to be the resource the lockfile lost track of than a deliberate second one, and the two mistakes do not cost the same: a false stop is a flag away, a false create is permanent.
  • --allow-duplicate-names is the escape hatch for a duplicate you mean. It does not skip the listing, so the run still prints what it matched.

Commands

rbx shop init

Initialize a new config file.

FlagDescription
--from-remotePopulate config and lockfile from existing remote resources
--universe-idUniverse ID (standalone mode; cannot be combined with --env)
--gift-label <label>Requires --from-remote. Detect pre-existing gift-twin products (name == label + source name, same price) and mark create_gift = true automatically — see Adopting create_gift on an existing game
--dry-runRequires --from-remote. Preview passes/badges/products (and gift merges) without downloading icons or writing any files

With --from-remote --env <name>, init resolves universe_id from rbxplace.toml, fetches remote, writes the lockfile under [envs.<name>], and skips the [experience] section in the config. With --from-remote --universe-id <id>, the standalone path is taken and [experience] is written.

rbx shop sync

Apply the resolved (base + overlay) config to Roblox.

FlagDescription
--dry-runShow what would change without applying
--onlyOnly sync specific types: passes, badges, products (comma-separated)
--badge-costExpected cost in Robux when creating a badge (default: 0)
--yes / -ySkip the confirmation prompt. What CI passes
--allow-duplicate-namesCreate resources even when Roblox already has one by that name. See Duplicate names

--yes is worth thinking about once rather than reaching for. sync is the command that issues Create, Roblox has no delete verb for passes, badges or products, and badge creation spends Robux — so this flag is what turns a reviewed plan into an unattended one. Put --dry-run in the pull request and --yes in the job that was approved, not the other way round. See Working in a team.

Use --env <name> for a specific env, --env all for every env in rbxplace.toml, or no flag to fall back to [experience].

rbx shop pull

Pull remote state into the config and lockfile. Differential overlay writes (see Multi-environment).

FlagDescription
--dry-runShow what would change without writing anything
--accept-remoteDownload remote icons and update local files
--accept-localKeep local icons and re-upload on next sync
rbx shop check

Validate the config and report sync state for the targeted env(s). Read-only.

Exit codes: 0 every env is in sync, 2 at least one env has resources to create or update, 1 the check could not answer. Drift sits on its own code so a CI step can gate on the status alone.

rbx shop codegen

Regenerate the codegen folder from rbxshop.toml + rbxshop.lock.toml. Offline — no API key, no network.

sync already does this at the end of a successful run. This command exists so that regenerating does not require credentials: rebuild after a git pull, or after changing style / paths / extra, without touching Roblox.

rbx shop codegen           # write the folder
rbx shop codegen --check   # compare instead; exits 2 on a difference
FlagDescription
--checkCompare the folder against what would be generated instead of writing it. Exits 2 on a difference

Writing also prunes modules the current lockfile no longer produces — delete an env and its <env>.luau goes with it. Only files carrying the @generated header are ever removed, so anything you wrote yourself in that folder is left alone. --check reports those leftovers as drift rather than ignoring them: a dead module still looks generated and can still be required.

See Guarding generated files.

rbx shop rename <resource> <old_key> <new_key>

Rename a resource key across the base, every env overlay, and every env lockfile section. The display name is preserved automatically.

rbx shop rename passes VIP vip_pass
rbx shop list <resource>

List remote resources for a single env (does not support --env all).

--json

One JSON document on stdout, nothing else. Diagnostics — the unrecognised-key warning in particular — stay on stderr, so the document parses even when rbxshop.toml has something wrong with it.

This is the remote side: what Roblox has right now. rbx shop show --json is the declared side. Neither says whether the two agree; that is rbx check --json, which reports this domain as shop/lockfile and shop/codegen and is the only one of the three that carries an outcome.

rbx shop list passes --env prod --json
{
  "schema_version": 1,
  "env": "prod",
  "universe_id": "9876543211",
  "resource": "passes",
  "resources": [
    {
      "id": "987654321",
      "name": "VIP Pass",
      "description": "the good one",
      "price": 199,
      "for_sale": true,
      "icon_asset_id": 123456789
    },
    { "id": "987654322", "name": "Starter Pack", "for_sale": false }
  ]
}
FieldTypeMeaning
schema_versionintegerDocument format, shared with rbx check --json. 1 today. Refuse a version you do not understand
envstringThe env named on the command line. Absent when there was none and the target came from [experience] instead
universe_idstringThe universe that was queried
resourcestringThe kind asked for, in its CLI spelling: passes, badges, products. On the envelope, not repeated on every row: one invocation lists one kind
resourcesarray of objectsOne per remote resource, in the order Roblox returned them
resources[].idstringThe Roblox id. The handle for a remote resource, the way the TOML key is the handle for a declared one
resources[].name / .descriptionstringAs Roblox holds them
resources[].priceintegerRobux. Absent when Roblox reported no price — Free for a pass, - for a product in the table
resources[].for_salebooleanPasses and products
resources[].enabledbooleanBadges only
resources[].store_pagebooleanDeveloper products only
resources[].icon_asset_idstringThe icon asset, which the table has no column for

Fields that do not apply to a kind are simply absent, as is anything Roblox did not send, so has("price") is a usable test and nothing is ever null.

rbx shop list passes --env prod --json | jq -r '.resources[] | "\(.id)\t\(.name)"'
rbx shop list badges --env prod --json | jq '[.resources[] | select(.enabled | not)] | length'
rbx shop show

Pretty-print the local rbxshop.toml with defaults filled in, so you see what sync would actually resolve rather than what you typed. Read-only, touches nothing remote.

rbx shop show
rbx shop show --sort price     # name (default), price, or key
rbx shop show --flat           # one global list with a type column

--sort price puts entries without a price last. --flat merges passes, badges and products into a single sorted list instead of grouping by section — the view for “what is the cheapest thing in this game”, which the grouped one cannot answer at a glance.

--json

The same resolved state as one JSON document on stdout, nothing else. Warnings and the per-env overlay hint move to stderr, so the document parses even when there is something to say about the file.

This is the declared side: rbxshop.toml with defaults filled in and the --env overlay applied, which is what sync would resolve. rbx shop list --json is the remote side. Whether the two agree is a third question, and rbx check --json is the command that answers it — its rows for this domain are shop/lockfile and shop/codegen, and they carry outcome, summary and details. None of those three words appears here, so a filter written for one document cannot half-read the other.

rbx shop show --json
rbx shop show --env prod --json
{
  "schema_version": 1,
  "config_file": "rbxshop.toml",
  "env": "prod",
  "experience": { "universe_id": "9876543210" },
  "passes": {
    "VIP": {
      "name": "VIP Pass",
      "price": 299,
      "for_sale": true,
      "regional_pricing": false,
      "create_gift": true,
      "description": "the good one",
      "icon": "icons/vip.png"
    },
    "starter": { "for_sale": false, "regional_pricing": false, "create_gift": false }
  },
  "badges": {
    "first_win": { "name": "First Win", "enabled": true }
  },
  "products": {
    "coins_100": {
      "price": 50,
      "for_sale": true,
      "regional_pricing": false,
      "store_page": true,
      "create_gift": false
    }
  }
}
FieldTypeMeaning
schema_versionintegerDocument format, shared with rbx check --json. 1 today

Every id here is a string, and every price is a number. Ids identify rather than count, a place id already exceeds 2^53, and a consumer parsing JSON with doubles would round one. Robux is a quantity, so it stays a number and arithmetic on it keeps working. Same rule in every document this tool writes. | config_file | string | The rbxshop.toml this was read from, as given or defaulted. rbx shop list --json has no such field, because it reads no local file | | env | string | The env whose overlay was applied. Absent for the base view — no --env, or --env all, which has no single overlay to resolve. Same omission rule rbx check --json uses for its own env | | experience | object | The [experience] section as the file spells it. Absent when there is none. Nested rather than a bare universe_id, because it is a declared fallback target and not necessarily the universe --env resolves to | | passes / badges / products | object | Keyed by TOML key: the handle --env overlays, rename moves and codegen emits. Empty objects when nothing is declared | | *.name | string | The name override. Absent when unset, in which case the key is the display name | | passes.*.price | integer | Robux. Absent when the file sets none, which for a pass means free | | products.*.price | integer | Robux. Always present: the field is required | | *.for_sale, *.regional_pricing, *.create_gift, products.*.store_page, badges.*.enabled | boolean | Always present, with the serde default filled in | | *.description / *.icon / *.path | string | As the file spells them. Absent when unset |

There is no totals object anywhere in these two documents. rbx check --json has one and it counts outcomes; one here would count rows under the same name. .passes | length is the count, and it cannot be misread.

name is the override and never the resolved fallback, so .passes | to_entries[] | (.value.name // .key) reproduces what the table prints while “renamed” stays distinguishable from “named by its key”. Products derived by create_gift appear in products exactly as the human view shows them, since both read the same resolved state.

--json is rejected together with --sort and --flat: both are layouts over a listing, and the document is an object keyed by TOML key, which has neither an order to pick nor a flat variant to ask for.

rbx shop show --json | jq -r '.passes | to_entries[] | select(.value.for_sale) | .key'
rbx shop show --env prod --json | jq '[.products[].price] | add'   # full-price basket

Per-tool flags

FlagDescription
--config <path>Path to rbxshop.toml (default rbxshop.toml)

Configuration

# Standalone fallback. Optional - omit if you always use --env.
[experience]
universe_id = 123456789

# Who owns this project. Global to the config (same for every env), and only
# consulted when a badge is created: ownership is what decides which balance
# Roblox charges. Omit it and [owner] in rbxplace.toml answers instead.
[owner]
type = "group"         # "user" or "group"
id = 123456

[codegen]
output = "src/shared/GameIds"   # FOLDER path - will contain init.luau + per-env modules
# typescript = false           # Also generate init.d.ts inside the folder
# style = "flat"               # "flat" (default) or "nested"

[icons]
bleed = true           # Apply alpha bleed before uploading (default: true)
dir = "icons"          # Directory for downloaded icons (default: "icons")

[gifts]
label = "[GIFT] "      # prefixed to the source's display name for derived gift products
key_prefix = "Gift"    # prefixed to the source's TOML key for the codegen/lockfile key (default)
# capitalize_key = false  # true: "gift" + "vipPass" -> "giftVipPass" instead of "giftvipPass"

# Split passes/badges/products across extra files if this one gets unwieldy (optional)
# [include]
# files = ["rbxshop.badges.toml"]

[passes.VIP]
name = "VIP Pass"      # explicit display name on Roblox (defaults to key)
price = 499
description = "VIP access to exclusive areas"
icon = "icons/vip.png"
create_gift = true     # also provisions a "[GIFT] VIP Pass" developer product - see below

[badges.Welcome]
description = "Welcome to the game!"
icon = "icons/welcome.png"
enabled = true

[products.Coins100]
price = 99
description = "100 coins"
icon = "icons/coins.png"

# Per-env overrides. Layered on top of base when --env <name> is passed.
[envs.prod.passes.VIP]
price = 999                    # VIP costs more in prod

[envs.dev.passes.BetaPass]     # pass exclusive to dev (0-stubbed in prod's module)
price = 0
description = "Beta tester pass"
icon = "icons/beta.png"
[experience]
FieldTypeRequiredDescription
universe_idu64Yes (if section present)Your Roblox universe ID

The whole section is optional in multi-env mode: with --env <name>, universe_id is resolved from rbxplace.toml instead, and [experience] is never consulted. It only matters for standalone mode (no --env).

[owner]

The one thing it decides is the payment source for badge creation. It is sent as paymentSourceType on the badge-create call, beside expectedCost; the scope Roblox wants there is named legacy-universe.badge:manage-and-spend-robux. Nothing else in rbx shop reads it.

It is called owner because the payer is the owner, necessarily. Roblox charges a group-owned game’s badge to group funds and a user-owned game’s to the user’s, with no way to cross them — paying for a group game’s badge with personal Robux has been an open feature request since 2018 and is still not possible. So there is no second party to name, and a field called payer, or the creator this used to be, would both imply a choice that does not exist.

You probably do not need to write it at all. Roblox already knows who owns the universe, and sync asks — one GET /cloud/v2/universes/{id} before creating a badge, which universe:read covers. Ownership is not something a config should have to restate, and a config that restates it can be wrong: type = "user" on a group-owned game is a create Roblox refuses, and nothing local would have caught it.

The declaration is the fallback, not the source. When the call cannot answer — a key without universe:read, or Roblox reporting neither field — sync falls back to [owner] here, then to rbxplace.toml: the env’s own [<env>.owner] first, then the top-level one. That is what keeps universe:read off the required list for every key that syncs a shop.

Same shape as [owner] in rbxplace.toml, because it is the same fact.

FieldTypeRequiredDescription
typestringYes (if section present)"user" or "group"
idu64Yes (if section present)Roblox user or group ID
[codegen]
FieldTypeDefaultDescription
outputstringFolder path for the generated module (omit to disable codegen). Must not end in .lua/.luau - it’s a folder, not a script; sync rejects it with a suggested fix if it does
typescriptboolfalseAlso generate init.d.ts
stylestring"flat""flat" or "nested" (see Code generation)
[codegen.paths]

Override the default section name for each resource type. Dot-separated segments become either a prefix (flat) or nested tables (nested).

FieldTypeDefaultDescription
passesstring"passes"Path for game passes
badgesstring"badges"Path for badges
productsstring"products"Path for developer products
[codegen.extra]

Inject asset IDs into every env’s generated module. Useful for manually managed assets or assets from other universes.

[codegen.extra]
"passes.legacy_vip" = 1234567
"products.starter_pack" = 9876543
[icons]
FieldTypeDefaultDescription
bleedbooltrueApply alpha bleed before uploading. Changing this won’t reupload existing images
dirstring"icons"Directory for icons downloaded by pull --accept-remote
[passes.<name>]
FieldTypeRequiredDescription
namestringNoDisplay name (defaults to the TOML key)
priceu64NoPrice in Robux (omit for free)
descriptionstringNoPass description
iconstringNoPath to icon file
for_saleboolNoWhether the pass is for sale (default: true)
regional_pricingboolNoEnable regional pricing (default: false)
create_giftboolNoDerive a “Gift<name>” developer product twin (default: false) - see Gift products
pathstringNoOverride the codegen path for this item
[badges.<name>]
FieldTypeRequiredDescription
namestringNoDisplay name (defaults to the TOML key)
descriptionstringNoBadge description
iconstringNoPath to icon file
enabledboolNoWhether the badge is active (default: true)
pathstringNoOverride the codegen path for this item
[products.<name>]
FieldTypeRequiredDescription
namestringNoDisplay name (defaults to the TOML key)
priceu64YesPrice in Robux
descriptionstringNoProduct description
iconstringNoPath to icon file
for_saleboolNoWhether the product is for sale (default: true)
regional_pricingboolNoEnable regional pricing (default: false)
store_pageboolNoShow on the store page (default: false)
create_giftboolNoDerive a “Gift<name>” developer product twin (default: false) - see Gift products
pathstringNoOverride the codegen path for this item
[gifts]
FieldTypeDefaultDescription
labelstring"[GIFT] "Prefixed to the source’s resolved display name for every derived gift product
key_prefixstring"Gift"Prefixed to the source’s TOML key to build the resolved/codegen key. Must be non-empty. E.g. with key_prefix = "Gift": VIP -> GiftVIP, vip_pass -> Giftvip_pass
capitalize_keyboolfalseUppercase the first letter of the source key in the derived key only (never in the source’s own TOML key). With key_prefix = "gift": vipPass -> giftVipPass instead of the default giftvipPass

Note the three are independent: label controls the name shown on Roblox, key_prefix/capitalize_key control the identifier in the generated Luau/TS module. None of them transforms the source’s own key or name in rbxshop.toml - only the derived copies. capitalize_key exists because a lowercase key_prefix run directly into a lowercase-starting key (giftvipPass) reads as broken rather than as a compound identifier; capitalizing just that derived copy fixes it without touching how you write your own keys.

[include]

Split passes/badges/products across extra files, merged in at load time - only useful if a single rbxshop.toml becomes unwieldy. Optional; a single file is the default and requires nothing here.

# rbxshop.toml
[include]
files = ["rbxshop.badges.toml", "rbxshop.subscriptions.toml"]
FieldTypeDefaultDescription
filesstring[][]Paths (relative to this file) to merge in

Rules:

  • Only meaningful in the main file (the one passed via --config); an included file’s own [include] is rejected.
  • Included files may only contain [passes.*], [badges.*], [products.*], and their [envs.<name>.*] overlays — nothing else. Any experience/owner/codegen/icons/gifts/include section in an included file is rejected with a clear error. So [envs.prod.passes.VIP] can live in rbxshop.passes.toml right alongside [passes.VIP] — but a (env, key) pair still can’t be declared in more than one file.
  • The same resource key can’t appear in more than one file (main or included) - sync/check/show fail with a clear error naming the file and the key.
  • pull and rename write back to whichever file currently owns the entry. Updating an existing pass/badge/product (or its [envs.<name>.*] overlay) rewrites it in place, wherever it happens to live - never duplicated into the main file. A brand-new entry pull discovers, or a new overlay for a resource that doesn’t have one yet, is written to the main file by default (a new overlay is instead co-located next to its base resource’s file, if that base lives in an included file). If you want something to live in a particular file going forward, move it there yourself - pull/rename never relocate an existing entry across files, only update it in place.
[envs.<name>.*]

Per-env overlays. Each section mirrors a base resource section ([envs.dev.passes.VIP] overrides fields of [passes.VIP] for env dev). All fields are optional - unset fields inherit from the base.

A resource defined only in an overlay (not in base) is treated as env-exclusive: it appears in that env’s generated module and is 0-stubbed elsewhere.

Required API scopes

ResourceScopes
Game Passesgame-pass:read, game-pass:write
Developer Productsdeveloper-product:read, developer-product:write
Badgeslegacy-badge:manage to list and read, legacy-universe.badge:write and legacy-universe.badge:manage-and-spend-robux to create
Assets (icons)legacy-asset:manage to read the asset as stored. Without it the public thumbnail service answers instead, at a rescaled size. Uploading a badge icon goes to legacy-publish, covered by the badge scopes above
Badge payment sourceuniverse:read — optional. sync uses it to read who owns the universe before creating a badge, and falls back to [owner] in the config when the key lacks it

Code generation

When codegen.output is set, rbx shop writes a folder at that path after every sync. The folder follows Rojo’s init.luau convention and uses two patterns for compile-time safety: an identity wrapper function (validates each env’s shape at definition site) and an exhaustive match (fails to compile if a new env is added without an accompanying branch in the dispatcher).

src/shared/GameIds/
├─ init.luau          -- dispatcher with if/elseif + exhaustiveMatch
├─ GameIdsType.luau   -- type GameIds + gameIds(x) identity wrapper
├─ dev.luau           -- Types.gameIds({...})
└─ prod.luau          -- Types.gameIds({...})

The variable name (GameIds), the wrapper function (gameIds), and the type module file (GameIdsType.luau) are derived from the folder name in codegen.output. Point it at src/shared/Assets and you get Assets / assets(x) / AssetsType.luau.

Example output (nested style)

GameIdsType.luau holds the shape contract and the wrapper :

-- This file is automatically @generated by rbx shop.
-- It is not intended for manual editing.

export type GameIds = {
    passes: {
        VIP: number,
        BetaPass: number,
    },
    badges: {
        Welcome: number,
    },
    products: {
        Coins100: number,
    },
}

local function gameIds(x: GameIds): GameIds
    return x
end

return {
    gameIds = gameIds,
}

dev.luau wraps its data through the identity function so Luau strict mode validates the literal at this exact spot. A missing badge, an extra key, a wrong type, anything off: error here, not somewhere downstream.

local Types = require(script.Parent.GameIdsType)

return Types.gameIds({
    passes = {
        VIP = 67890,
        BetaPass = 11111,
    },
    badges = {
        Welcome = 98765,
    },
    products = {
        Coins100 = 22222,
    },
})

prod.luau (BetaPass stubbed to 0 since it only exists in dev) :

local Types = require(script.Parent.GameIdsType)

return Types.gameIds({
    passes = {
        VIP = 99999,
        BetaPass = 0,
    },
    badges = {
        Welcome = 88888,
    },
    products = {
        Coins100 = 77777,
    },
})

init.luau is the dispatcher. It uses a string-literal union for env names and an exhaustiveMatch(value: never): never helper so adding a new env to the union without a matching elseif branch fails at compile time, not at runtime.

-- This file is automatically @generated by rbx shop.
-- It is not intended for manual editing.

local Types = require(script.GameIdsType)
export type GameIds = Types.GameIds
export type EnvName = "dev" | "prod"

local UNIVERSE_TO_ENV: { [number]: EnvName } = {
    [9876543210] = "dev",
    [9876543211] = "prod",
}

local function exhaustiveMatch(value: never): never
    error(`rbx shop: unhandled env in dispatcher: {value :: any}`)
end

local env = UNIVERSE_TO_ENV[game.GameId]
if not env then
    error(`rbx shop: unknown universe {game.GameId}`)
end

if env == "dev" then
    return require(script.dev)
elseif env == "prod" then
    return require(script.prod)
else
    exhaustiveMatch(env)
    error("luau")
end

Consumer:

local GameIds = require(ReplicatedStorage.shared.GameIds)
MarketplaceService:PromptGamePassPurchase(player, GameIds.passes.VIP)

Autocomplete and strict typing work because the dispatcher returns GameIds for every branch (inferred from the wrapper, no :: cast needed).

Styles

StyleOutputWhen to pick
nestedNested tables, full per-field typesBest for Luau (direct access, full autocomplete)
flatDot-separated string keys (GameIds["passes.VIP"])Good for roblox-ts (string-literal keys play nice with TS)

Switched-off resources

A pass taken off sale, or a badge that was disabled, still appears in the generated module with its real id — and carries a comment saying so:

return Types.gameIds({
    passes = {
        VIP = 67890,
        LegacyFounder = 11111, -- not for sale
    },
    badges = {
        Welcome = 98765, -- disabled
    },
})

Keeping the id is deliberate. A pass that is off sale still has owners, so game code needs the id to answer “does this player own it”. Filtering those out would break ownership checks on exactly the passes somebody retired.

What was missing was any way to tell. VIP = 67890 reads identically whether the pass is on sale or was retired six months ago, so a prompt that silently never opens looks like a bug in the prompt. The annotation carries the answer as far as the module.

The state comes from the lockfile — for_sale for passes and products, enabled for badges — so it describes what Roblox has as of the last sync, not what the config would like. Ids under [codegen.extra] are never annotated: they belong to resources this tool does not manage, so there is nothing to know about them.

Stubbing semantics

When a resource exists in one env but not another, the missing env’s module gets a 0 stub. MarketplaceService:PromptGamePassPurchase(player, 0) is silently a no-op, so the same code runs safely across envs - but prompting purchase of a 0-stubbed ID will silently do nothing. Generated files include a comment header to flag this.

TypeScript

With typescript = true, an init.d.ts file lives alongside init.luau:

// This file is automatically @generated by rbx shop.
// It is not intended for manual editing.

declare const GameIds: {
    passes: { VIP: number; BetaPass: number }
    badges: { Welcome: number }
    products: { Coins100: number }
}

export = GameIds

Guarding generated files

The generated modules carry an It is not intended for manual editing header, but a header is a request, not a guarantee. --check is the enforcement: it re-renders the files in memory and asserts the committed copies still match their inputs.

rbx shop codegen --check                        # rbxshop.toml + rbxshop.lock.toml
rbx env gen-module --out src/Envs.luau --check  # rbxplace.toml

Both are offline — no API key, no network — which is what makes them usable from a git hook and from CD.

Exit codes: 0 clean, 2 drift, 1 the command itself failed. Drift sits on its own code so a pipeline can tell “regenerate and commit” from “something broke”.

In lefthook

pre-commit:
  parallel: true
  commands:
    codegen:
      glob: "{rbxshop.toml,rbxshop.lock.toml,rbxplace.toml,src/shared/GameIds/*}"
      run: rbx shop codegen --check
      fail_text: "Generated ids are stale or hand-edited. Run `rbx shop codegen` and re-stage."

In CI

- name: Generated files match their inputs
  run: |
    rbx shop codegen --check
    rbx env gen-module --out src/shared/Envs.luau --check

What it does and does not prove

It proves the committed files equal f(config, lockfile). It does not prove the lockfile matches Roblox. Neither does rbx shop check, which compares the config against the lockfile and is offline too. The command that asks Roblox is rbx shop pull --dry-run, which needs credentials and a network. The three answer different questions and are worth running in different places; see docs/teams.md.

It also only helps if the generated files are committed. If yours are gitignored, this belongs in CD before the build, not in a pre-commit hook.

Keep formatters off the generated path

The comparison is byte for byte (modulo CRLF, which is normalized — a Windows checkout and a Linux CI runner agree). That only holds while the generator is the single producer of those files. Point stylua or prettier at them and every check fails forever, because two tools are writing the same bytes differently and regenerating cannot settle it.

So exclude the generated folder from your formatters:

# .styluaignore
src/shared/GameIds/

The same goes for an editor set to trim trailing whitespace on save. When every difference turns out to be whitespace, the check says so explicitly instead of printing a diff that regenerating would not fix.

Collapsing the diffs on GitHub

The generated files carry an @generated header, which several review tools recognize. GitHub’s own mechanism is .gitattributes:

src/shared/GameIds/** linguist-generated=true

That folds the folder in pull request diffs and excludes it from language stats. rbx shop does not write this for you — how your repo is configured is your call.

Lockfile

rbx shop generates a rbxshop.lock.toml (sectioned by env) tracking remote state - asset IDs, icon hashes, and metadata per env:

version = 2

[envs.dev]
universe_id = 9876543210
[envs.dev.passes.VIP]
id = 67890
name = "VIP"
icon_asset_id = ...
icon_hash = "..."

[envs.prod]
universe_id = 9876543211
[envs.prod.passes.VIP]
id = 22222
# ...

Standalone mode (no --env) writes under [envs.default]. Commit the lockfile to version control.

Icon conflict resolution

When you run pull and a remote icon differs from what’s in the lockfile:

! [prod] pass 'VIP': icon differs from remote
  Local:  icons/vip.png (blake3: a1b2c3d4e5f6...)
  Remote: asset 987654321012345

Resolve with:

  • --accept-remote: downloads the remote icon to your local path
  • --accept-local: keeps your local icon and re-uploads it on next sync

Attributions

The alpha bleeding implementation is adapted from Asphalt (MIT), which itself adapted it from Tarmac (MIT). Thank you to both. The license notices are in THIRD-PARTY-NOTICES.md.

Live operations

Acting on a running Roblox experience: what its servers are doing, how the ones that stopped ended, what its analytics say, and who is allowed in.

These are subcommands of rbx, listed last in rbx --help and prefixed Live: in their descriptions. They share the rbxplace.toml, the --env model and the HTTP layer with everything else.

SubcommandWhat it doesDocs
serversLive and terminated servers, and the logs of one that crashed.ops/servers.md
analyticsYour own metrics: players, retention, ARPPU. CSV for charting elsewhere.ops/analytics.md
banInspect and change player restrictions.ops/ban.md
restartForecast and launch a rolling server restart.ops/restart.md
dataRead, overwrite, copy and recover a data store entry; data ordered for leaderboards.ops/data.md
memorystoreWrite cache values servers read through MemoryStoreService.ops/memorystore.md
publishPush a MessagingService message to every running server.ops/message.md
adsLaunch and steer ad campaigns. Spends money, reads no results.ops/ads.md
probeRaw authenticated request to any Open Cloud path. Hidden from --help.ops/probe.md

Why they are marked out

They do a different kind of work from the rest of rbx, and mixing them up is how accidents happen.

the rest of rbxlive operations
Acts onstate declared in your repostate that only exists at runtime
Source of trutha TOML file you committhe live game
Runs in CIyes, on every pushno
A mistake costsa failed deploy, retried in a minuteplayer data, irreversibly

Banning a player has no desired state in a TOML file: it is a consequence of what happened in your game last night. So it is not something a tool reconciling your repo against Roblox should be doing on a push.

A second binary would make the boundary visible in the command name, and it is not on offer. Rokit resolves one artifact per repository, so of two published binaries only one could ever be installed through it. Dispatching on the name the binary was invoked by does not rescue it either: Rokit’s shim passes the stored binary’s own path as argv[0], identically for every alias. Measured 2026-08-13 by replacing the stored binary with a probe.

It would not be the boundary that holds in any case. Roblox binds an API key to its scopes and universes when you create it, so a deploy key cannot ban anybody whichever binary calls it.

Keep read and write in separate keys. That is the boundary that holds, and it is the one worth arranging your rbxapikey.toml around. The same argument splits a key per environment, and envs written as a table of named groups does that from one declaration rather than three copied blocks — see One key per environment.

Install

Nothing separate to install. rbx carries these commands:

# rokit.toml
[tools]
rbx = "rbx-forge/rbx-cli@0.2.0"
rokit install
rbx servers list --env prod

One binary, one archive per platform, which is what makes it installable at all — see why they are marked out for what the old second binary cost.

Getting a key

These commands authenticate with an Open Cloud API key, exactly like the rest of rbx. Declare it with rbx apikey rather than clicking through the Creator Hub, so the scopes are written down and reviewable.

# rbxapikey.toml
[settings]
default_envs = ["prod"]
default_secret_file = ".secrets/{name}.env"
name_prefix = "myproject_"

[keys.viewer]
description = "Read-only key for server history and analytics."
scopes = [
    "universe:read",             # servers, server logs, restart status
    "universe.analytics:read",   # analytics queries
]
rbx apikey create viewer
export RBX_API_KEY="$(rbx apikey resolve viewer)"

Scopes by subcommand:

SubcommandScopeRead or write
serversuniverse:readread
analyticsuniverse.analytics:readread
ban status / list / logsuniverse.user-restriction:readread
ban add / removeuniverse.user-restriction:writewrite
restart forecast / statusuniverse:readread
restart launchuniverse:writewrite
data readsuniverse-datastores.objects:read,listread
data writesuniverse-datastores.objects:create,update + universe-datastores.control:createwrite
data revisions / restoreuniverse-datastores.versions:list,readread
data ordered readsuniverse.ordered-data-store.scope.entry:readread
data ordered writesuniverse.ordered-data-store.scope.entry:writewrite
memorystore get / listmemory-store.sorted-map:readread
memorystore set / deletememory-store.sorted-map:writewrite
publishuniverse-messaging-service:publishwrite
probewhatever the path you probe needsdepends

Keep read and write in separate keys. The read key is the one that ends up in a shell history during a debugging session, and it should be the one that cannot ban anybody.

Safety model

Three rules, all structural rather than conventions to remember.

Writes are dry-run by default. Any operation that changes something describes what it would do and stops. --apply performs it, and prompts. The safe outcome is what happens when you forget a flag.

--env all is refused. For rbx shop sync it makes sense to fan out over every environment. For anything touching live players it does not: each env is a different experience, and a command that quietly acted on production because it matched a glob is a command nobody can trust.

Scopes are the real boundary. Roblox binds a key to its scopes and universes at creation. A read-only key is read-only no matter what calls it.

The .ROBLOSECURITY cookie is the one credential in this tool that the section above does not cover, and no live-ops command accepts it. servers, analytics, ban, restart, data, memorystore, publish, ads and probe take an API key and nothing else, with no cookie path to fall back to. That is on purpose: these are the operations that act on players, so they stay behind scoped keys where the scope list is the audit trail.

A session cookie is a complete account identity. It is not scoped to a universe, not scoped to an operation, and not revocable per tool, so it is strictly more powerful than any key rbx will ever ask you for. A handful of commands outside this page do need one, because Open Cloud publishes no equivalent endpoint.

The trust model lives in one place: docs/cookie.md. What the cookie is used for and never used for, the resolution order behind --cookie, RBX_COOKIE, RBXAPIKEY_COOKIE and --no-auto-cookie, the stderr notice when it is auto-detected, and why it is never written to disk.

The development configs, and why they are not in git

Two directories in this repository hold configs for real Roblox universes: testenv/ for the throwaway experience the ops subcommands are developed against, and prodread/ for read-only access to the live one.

Their rbxapikey.toml and rbxplace.toml are gitignored. What is committed is a .example next to each:

testenv/rbxapikey.example.toml    prodread/rbxapikey.example.toml
testenv/rbxplace.example.toml     prodread/rbxplace.example.toml

To work against real Open Cloud, copy each one and fill it in:

cd testenv
cp rbxapikey.example.toml rbxapikey.toml
cp rbxplace.example.toml rbxplace.toml

The values to fill in are in .local/real-ids.toml, which is gitignored too: the public IP for the key allowlists, and the live universe and place ids.

Why not just commit them with placeholders. They were, and it worked, but only by discipline. These files are useless without real values, so the first thing anyone does is paste their own IP into default_allowed_cidrs to get a call to stop returning 401 — and now a tracked file holds personal data, one git commit -a away from being published. A public IP in an allowlist is personal data and an operational disclosure: it announces which address is authorised on the Open Cloud keys. Untracked paths remove the accident instead of asking people not to have it.

Edit the .example only for changes worth sharing: a new key, a scope decision, a comment. Those comments are the valuable part — they record why each scope was chosen, and the rule that nothing in prodread/ may ever hold a write scope.

A local overlay file the tools read directly (rbxapikey.local.toml layered over the committed one) would remove the copy step rather than only making it safe. That is a change in how the tools load config, so it is not done here.

Testing and fixtures

Every client here is tested against recorded production responses, in crates/rbx-*/tests/fixtures/. Everything is byte for byte what Roblox sent, except four things replaced with synthetic values: playerIds, jobId, pagination tokens and analytics operation paths.

The last two are not obvious and were missed on the first pass. A pagination token is an opaque base64 blob, but decoding one shows Roblox packs real data inside it, including a LastGameId that is a live server’s job id. Replacing only the jobId field left a copy of it in the cursor. The tests only ever ask whether a token exists, never what is in it, so an opaque placeholder costs nothing.

Place versions are kept as recorded, and that is a decision rather than an oversight. A version number is a publish counter, so on its own it says how often some experience shipped — but the universe and place ids in these files are placeholders, so it is attached to nothing. Replacing them would cost the property the recordings exist for, since the ordering and the numeric-versus-string sorting of real version numbers is exactly the kind of detail a hand-written fixture gets wrong. Documentation is the opposite case: every figure on these pages is invented, because a page is read far more often than a fixture and nothing there needs to be real to make its point.

Recordings rather than hand-written JSON, because the specification is wrong or silent about several fields, and a hand-written fixture would encode the specification and agree with the bug. Caught this way:

  • nextPageToken is "" on cloud/v2 and null on server-management. Reading "" as a token requests the same page forever.
  • uptime is a .NET TimeSpan, [d.]hh:mm:ss[.fffffff], not an ISO 8601 duration. All three forms occur.
  • frameRate is null on a new server and 0 on a stopped one.
  • dataPoints needs camelCase renaming; without it every analytics query returns an empty series and reports “no data”.

Re-record when Roblox changes something and a test starts failing:

python scripts/capture_fixtures.py

It only ever issues GET requests, and needs the read-only keys described above.

rbx servers

Live and terminated servers for an experience, and what one of them logged before it stopped.

Roblox keeps a rolling 30-day window of terminated servers, then discards them. That window is the whole argument for pulling this on a schedule rather than looking at it after something has already gone wrong.

See ops.md for install, keys and the safety model. Everything here needs only universe:read.

Finding a version first

ListGameServers takes a place version in its path and offers no “all versions” form, so you cannot query it without knowing a version number. versions is how you find one:

rbx servers versions --env prod
place versions with servers (newest first)
  * 412
    407

The * marks the default list uses when you do not pass --version.

--json

rbx servers versions --env prod --json
{
  "schema_version": 1,
  "default_place_version": "412",
  "place_versions": ["412", "407"]
}
FieldTypeMeaning
schema_versionintegerDocument format, shared with rbx check --json. 1 today
default_place_versionstringThe version list and logs use without --version, i.e. the one marked *. Absent when no version has servers
place_versionsarray of stringsEvery version that has servers, newest first

The default is named rather than left as “element 0”, so a script does not have to know the ordering to pick the right one:

version=$(rbx servers versions --env prod --json | jq -r '.default_place_version // empty')
[ -n "$version" ] && rbx servers list --env prod --version "$version" --json

An experience that has run nothing in thirty days gives an empty place_versions and no default_place_version, with the sentence explaining it on stderr. Exit 0 either way: nothing has run is not a failure.

Listing servers

rbx servers list --env prod
STATUS      JOB                  UPTIME   MEMORY     FPS  PLAYERS
active      9680282c                16s   569 MB      60  2/7
active      9622e310                28s   574 MB      60  2/7
active      d093f1bf                42s   572 MB      60  1/7

3 rows for place version 412, 24610 exist (--limit 3 reached)
FlagMeaning
--version <n>A specific place version. Defaults to the newest that has servers.
--status <s>Only this status.
--limit <n>Rows to fetch. Default 50.
--fullShow whole job ids instead of the first eight characters.
--csvCSV instead of a table.
--jsonOne JSON document instead of a table. Rejected together with --csv or --full.

The default limit is small on purpose. A busy experience can have tens of thousands of rows for one place version, which at the maximum page size is hundreds of requests. A command that quietly does that is not one you can run casually.

Statuses: active, shut_down, restarted, roblox_restarted, crashed, out_of_memory, moderated.

crashed and out_of_memory mean something went wrong; they are highlighted and counted separately. The others are normal lifecycle.

--json

rbx servers list --env prod --version 412 --json

One JSON document on stdout, nothing else. Warnings — the partial-page one below in particular — stay on stderr, so a monitoring script’s input parses even on the run where something was wrong.

{
  "schema_version": 1,
  "place_version": "412",
  "partial": false,
  "limit": 50,
  "limit_reached": false,
  "totals": { "returned": 3, "failed": 1, "available": 24610 },
  "servers": [
    {
      "job_id": "aba9aeae-bc55-49c8-bb0e-6363ee6ba820",
      "status": "crashed",
      "failure": true,
      "place_id": "234567890123456",
      "place_version": "412",
      "engine_version": "0.700.0.7000000",
      "create_time": "2025-08-14T02:53:11Z",
      "termination_time": "2025-08-14T13:46:53Z",
      "uptime_seconds": 39222,
      "memory_bytes": 1165064601,
      "frame_rate": 60.0,
      "occupancy": 0,
      "max_occupancy": 7,
      "full": false,
      "shut_down": true,
      "type": 1,
      "player_count": 0,
      "player_ids": []
    }
  ]
}
FieldTypeMeaning
schema_versionintegerDocument format, shared with rbx check --json. 1 today. Refuse a version you do not understand
place_versionstringThe version the rows are for. Absent only when no version has servers at all
partialbooleanRoblox answered 200 while reporting a fetch error for one of its sources. Rows are missing; any rate is a lower bound
limitintegerThe --limit in force
limit_reachedbooleanThe run stopped at --limit, not at the end of the data. Raise it to see the rest
totals.returnedintegerRows in servers, after --status filtering
totals.failedintegerHow many of those ended in a crash or out-of-memory
totals.availableintegerHow many exist for this version before --status and --limit. Absent when Roblox did not say
serversarray of objectsOne per server, in the order Roblox returned them
servers[].job_idstringFull job id — what servers logs takes. Never truncated here
servers[].statusstringactive, shut_down, restarted, roblox_restarted, crashed, out_of_memory, moderated, or unknown for a status this build has not seen. Same spelling --status takes
servers[].failurebooleanTrue for crashed and out_of_memory, so a consumer does not keep its own list
servers[].place_id / .place_version / .engine_versionstringAs Roblox sends them. Ids stay strings: they exceed 2^53 and a JSON number would round
servers[].create_time / .termination_timestringTimestamps. termination_time is absent on a live server
servers[].uptime_secondsintegerSeconds, not the 00:05:02.0020000 .NET text Roblox sends
servers[].memory_bytesintegerMemory in use
servers[].frame_ratenumberAbsent when Roblox reported none. A present 0 means measured zero
servers[].occupancy / .max_occupancyintegerPlayers now, and the cap
servers[].full / .shut_downbooleanAs reported
servers[].typeintegerSpec enum 0..5, named nowhere. Passed through raw rather than guessed at
servers[].player_countintegerLength of player_ids
servers[].player_idsarray of integersThe ids themselves, which CSV drops for width. Absent when Roblox sent no list

Optional fields are omitted rather than emitted as null, so has("frame_rate") distinguishes “never measured” from “measured zero” — the same distinction the table draws with -. Every row is an object keyed by name, never a positional array.

A version with no servers is an empty servers array and exit 0, not an error and not silence: .servers | length answers either way.

# crashed servers in the last window, newest first
rbx servers list --env prod --limit 500 --json \
  | jq -r '.servers[] | select(.failure) | "\(.termination_time) \(.job_id)"' | sort -r

# refuse to compute a rate off a page Roblox admits is incomplete
rbx servers list --env prod --limit 500 --json > servers.json
jq -e '.partial | not' servers.json > /dev/null \
  && jq '.totals.failed / .totals.returned' servers.json

Investigating a crash

Two steps. Find the server, then read what it was doing.

rbx servers list --env prod --status crashed --limit 500 --full
STATUS      JOB                                     UPTIME   MEMORY     FPS  PLAYERS
crashed     aba9aeae-bc55-49c8-bb0e-6363ee6ba820   10h 53m  1111 MB      60  0/7
crashed     05c2e867-9226-4123-a30f-aa168ede611e   15h 20m  1353 MB      60  5/7

2 rows for place version 407, 1830 exist
2 ended in a crash or out-of-memory

--full because the next command needs the whole job id, and the truncated form only exists because a uuid per row makes the table unreadable.

rbx servers logs aba9aeae-bc55-49c8-bb0e-6363ee6ba820 --version 407 --env prod
13:46:53  error    ServerScriptService.Gameplay.RoundService:88: attempt to index nil with 'Name'
              Stack Begin
              Script 'ServerScriptService.Gameplay.RoundService', Line 88
              Stack End
13:59:21  error    ServerScriptService.Gameplay.RoundService:88: attempt to index nil with 'Name'
FlagMeaning
--version <n>The version the server ran. Required by the API; defaults to the newest.
--severity <s>output, info, warn, error.
--limit <n>Lines to fetch. Default 200.
--csvCSV instead of formatted lines. Stack traces are quoted, so the newlines survive.
--jsonOne JSON document instead of formatted lines. Rejected together with --csv.

The version has to match the row the job id came from. A job id from version 407 queried against 412 returns nothing, with no error to say why, which is why an empty result says so explicitly.

Stack traces are never truncated. After a crash they are the entire reason for running this.

--json

One document per run, not one object per line. This is the only command here where you might reasonably expect the other thing, so it is worth being explicit: rbx servers logs --json emits a single JSON document, the same as every other --json in the tool.

That is a choice about what this command is. It reads a bounded slice of a log Roblox has already finished writing — there is no --follow, the server is usually one that stopped hours ago, and nothing can be printed until pagination has stopped at --limit. Streaming would therefore produce no output any earlier, and would cost the envelope: which job id, which place version, which severity filter, and whether --limit cut the answer short are facts about the run that a line has nowhere to carry. So jq reads it like every other document here, and jq -c '.lines[]' turns it into JSON Lines if that is what you are feeding:

rbx servers logs <jobId> --version 407 --env prod --json | jq -c '.lines[]' >> logs.ndjson

The day a --follow mode exists, JSON Lines is what it should emit. That would be a new mode, not a change to this one.

rbx servers logs aba9aeae-bc55-49c8-bb0e-6363ee6ba820 --version 407 --env prod --json
{
  "schema_version": 1,
  "job_id": "aba9aeae-bc55-49c8-bb0e-6363ee6ba820",
  "place_version": "407",
  "limit": 200,
  "limit_reached": false,
  "totals": { "returned": 2, "errors": 1 },
  "lines": [
    {
      "time": "2025-08-14T13:46:53.481Z",
      "severity": "error",
      "severity_code": 3,
      "error": true,
      "message": "ServerScriptService.Gameplay.RoundService:88: attempt to index nil with 'Name'",
      "stack_trace": "Stack Begin\nScript 'ServerScriptService.Gameplay.RoundService', Line 88\nStack End",
      "job_id": "aba9aeae-bc55-49c8-bb0e-6363ee6ba820",
      "place_version": "407"
    }
  ]
}
FieldTypeMeaning
schema_versionintegerDocument format, shared with rbx check --json. 1 today
job_idstringThe server asked about, in full. Present even when nothing came back
place_versionstringThe version the logs were read from, whether given or defaulted
severity_filterstringThe --severity in force, canonicalised: output, info, warn, error. Absent when none was asked for
limitintegerThe --limit in force
limit_reachedbooleanThe run stopped at --limit, not at the end of the log. The last line you have is then not the last line there was
totals.returnedintegerRows in lines, after --severity filtering
totals.errorsintegerHow many of those are errors
linesarray of objectsOne per line, in the order Roblox returned them
lines[].timestringTimestamp as Roblox sends it, RFC 3339 despite the messageTimestampMs name on the wire. Absent when a line carried none
lines[].severitystringoutput, info, warn, error, or unknown. Same spelling --severity takes
lines[].severity_codeintegerThe raw code. Absent when the line carried none, which tells “Roblox added a severity” apart from “this line had none”
lines[].errorbooleanTrue for error alone, so a consumer does not keep its own list
lines[].messagestringThe line itself. Absent when there was none
lines[].stack_tracestringReal newlines inside the JSON string, never truncated. Absent when the line carried none
lines[].job_id / .place_versionstringAs Roblox reports them per line, the same columns CSV carries

A server with no logs is an empty lines array and exit 0, with the “check the job id and the version” advice on stderr. The document still names the job id and version it answered about, which is what makes the empty answer readable.

# every stack trace from a crash, in order
rbx servers logs <jobId> --version 407 --env prod --severity error --json \
  | jq -r '.lines[] | select(has("stack_trace")) | .stack_trace'

# refuse to conclude anything from a slice cut short by --limit
rbx servers logs <jobId> --version 407 --env prod --json > logs.json
jq -e '.limit_reached | not' logs.json > /dev/null || echo "raise --limit"

Keeping the data

Roblox discards a terminated server after thirty days and nothing brings it back, so anything you want history for has to be exported before then.

rbx servers list --env prod --version 412 --limit 500 --csv > servers.csv
rbx servers logs <jobId> --version 412 --csv --env prod > logs.csv

CSV carries every field Roblox returns, not the six the table shows: engineVersion, createTime, terminationTime, type, full, playerCount and the rest. Two deliberate choices in the conversion:

  • uptimeSeconds is a number, not the 00:05:02.0020000 text Roblox sends. A spreadsheet can total seconds and cannot total a .NET TimeSpan.
  • frameRate is left empty when Roblox reported nothing, rather than 0, so the difference between “never measured” and “measured zero” survives the export too.

--json carries the same fields, plus the player ids CSV drops for width and the page-level facts a flat row cannot hold (partial, limit_reached, totals). Use CSV for a spreadsheet and JSON for anything that pipes.

The same holds for logs: --json is a superset of --csv there too, and a stack trace keeps real newlines inside a JSON string instead of the quoted multi-line cell CSV has to make of it.

rbx servers logs <jobId> --version 412 --json --env prod > logs.json

Reading the output honestly

Two columns mean less than they appear to.

FPS shows -, not 0, when Roblox reported nothing. A server too young to have measured a frame rate reports null; a stopped server reports a real 0. Those are different facts and the tool refuses to conflate them.

A warning above the table is not cosmetic. Roblox can answer 200 OK while telling you, in two fields of the response body, that it failed to fetch one of its two data sources. The page is then a partial slice, so any rate computed from it is wrong. The warning is printed before the numbers rather than after.

It goes to stderr in every format, --json included, where the same fact is also in the document as partial. A script that never reads stderr still has no excuse for computing a rate off half a page.

rbx analytics

Your own metrics: players, retention, revenue per payer.

See ops.md for install, keys and the safety model.

The Creator Dashboard already charts these. The reasons to pull them through an API are the three things it cannot do: keep history past Roblox’s window, join the numbers with data of your own, and alert on a change without a human looking at a page.

Which metrics exist

Roblox publishes no list anywhere. These were confirmed by querying a live experience:

rbx analytics metrics
Visits                       Sessions started
DailyActiveUsers             Distinct players per day
MonthlyActiveUsers           Distinct players over the trailing month
D1Retention                  Share of new players returning the next day
D7Retention                  Share of new players returning within seven days
D30Retention                 Share of new players returning within thirty days
AverageRevenuePerPayingUser  ARPPU, in Robux

--metric accepts any string, so a metric Roblox adds later works without waiting for a release. An unknown name comes back as a readable error naming it.

--json writes the same list as one document on stdout:

{
  "schema_version": 1,
  "exhaustive": false,
  "metrics": [
    { "name": "Visits", "description": "Sessions started" },
    { "name": "DailyActiveUsers", "description": "Distinct players per day" }
  ]
}

exhaustive is always false, and it is in the document rather than left to this page: the list is what was confirmed by probing, not what exists. Do not validate a metric name against it.

Querying

rbx analytics query --metric DailyActiveUsers --days 7 --env prod
DailyActiveUsers (total)
  2026-07-27       1200.00
  2026-07-28       1245.00
  2026-07-29       1310.00
  2026-07-31       1180.00
  2026-08-02       1402.00
FlagMeaning
--metric <name>Required.
--days <n>How far back. Default 30.
--granularity <g>one-minute, half-hour, one-hour, one-day (default), one-week, one-month, none.
--breakdown <dim>Split into series by a dimension. Repeatable.
--filter <Dim=v[,v]>Narrow to particular values. Repeatable.
--csvCSV instead of a table.
--jsonOne JSON document instead of a table. Rejected together with --csv.

A wide range comes back queued rather than answered — Roblox hands over an operation to poll. That is handled: the command says so and waits. It gives up after a minute and tells you to narrow --days.

--json

rbx analytics query --metric DailyActiveUsers --days 7 --env prod --json

One JSON document on stdout, nothing else. The waiting note above and every warning stay on stderr, so a scheduled job’s input parses even on the run where Roblox queued the query.

{
  "schema_version": 1,
  "metric": "DailyActiveUsers",
  "granularity": "one-day",
  "days": 7,
  "start_time": "2026-07-27T09:12:04Z",
  "end_time": "2026-08-03T09:12:04Z",
  "breakdown": [],
  "filters": [],
  "queued": false,
  "totals": { "series": 1, "points": 3, "missing": 1 },
  "series": [
    {
      "label": "total",
      "dimensions": {},
      "points": [
        { "time": "2026-07-27T00:00:00+00:00", "value": 0 },
        { "time": "2026-07-28T00:00:00+00:00" },
        { "time": "2026-07-30T00:00:00+00:00", "value": 1288 }
      ]
    }
  ]
}
FieldTypeMeaning
schema_versionintegerDocument format, shared with rbx check --json. 1 today. Refuse a version you do not understand
metricstringThe metric asked for, as --metric spelled it
granularitystringThe bucket size in the spelling --granularity takes (one-day), not the OneDay form sent on the wire, so it goes straight back onto a command line
daysintegerThe --days in force
start_time / end_timestringThe range actually queried, RFC 3339 UTC. Start inclusive, end exclusive
breakdownarray of stringsThe dimensions --breakdown asked for, in order. Empty when none
filtersarray of objectsThe --filter clauses, parsed: dimension, operation, values
queuedbooleanRoblox did not answer inline and handed back an operation to poll. The same fact the waiting note reports, kept here because that note is on stderr
totals.seriesintegerEntries in series
totals.pointsintegerPoints across every series — what Roblox returned, not what a dense range would hold
totals.missingintegerHow many of those came back with no value. Non-zero means the series has holes that are not zeros
seriesarray of objectsOne per series, in the order Roblox returned them. A single series when nothing was broken down
series[].labelstringThe short label the table and the CSV print: total, or the dimension values joined with /
series[].dimensionsobjectThe dimension values identifying this series, keyed by dimension name rather than positional. Empty without a --breakdown
series[].pointsarray of objectsThe buckets Roblox returned, in its order. Empty is a real answer
series[].points[].timestringStart of the bucket. Absent in the one case Roblox sends a point with no time, which the table prints as -
series[].points[].valuenumberThe measurement. Absent when the bucket carries none

Every row is an object keyed by name, never a positional array, and every --json field name here is the compatibility surface.

Holes in a series

Series are not dense, and three things that look alike have to stay apart. An alert that reads a hole as a zero reports “nobody played” when what happened is “the pipeline stopped reporting”.

What you seeWhat it means
"value": 0Measured zero
no value keyRoblox returned the bucket and put no number in it
no point for that timestampRoblox returned nothing for that bucket at all

has("value") is the test, in line with the rule the other --json commands follow: an optional field is omitted rather than emitted as null. The - the table prints covers the first two cases; the document does not.

Missing buckets are never synthesised. The CLI does not know Roblox’s calendar for every granularity — funnel metrics accept --granularity none only, and a breakdown can be ragged across series — so an invented bucket would be a guess presented as data. Reindex against start_time and end_time, which the document carries for exactly that:

# refuse to average a series that has holes in it
rbx analytics query --metric DailyActiveUsers --days 30 --env prod --json > dau.json
jq -e '.totals.missing == 0' dau.json > /dev/null \
  && jq '[.series[].points[].value] | add / length' dau.json

# the days that reported nothing, as opposed to the days that reported zero
jq -r '.series[].points[] | select(has("value") | not) | .time' dau.json

An empty range is an empty document and exit 0, not an error and not silence: .totals.points answers either way, and the “no data points” line goes to stderr.

Breakdown or filter

They are not two spellings of the same thing, and Roblox enforces the difference:

  • --breakdown splits one answer into several series, one per value.
  • --filter narrows to the values you name, keeping a single series.

Some dimensions are filter-only. Ask to break down by one and Roblox refuses outright:

Dimension FunnelName is filter-only for metric FunnelUserTotalCount and cannot be
used as a breakdown. Please use dimension-values to obtain available values.

Filtering to one platform, where the point is the size of the gap rather than either number:

rbx analytics query --metric DailyActiveUsers --filter Platform=Console --days 5 --env prod
DailyActiveUsers (total)
  2026-08-01         48.00     # against 1390.00 unfiltered

Finding the values to filter on

You cannot filter on a funnel name you do not know. dimensions lists what a dimension actually contains:

rbx analytics dimensions --metric DailyActiveUsers --dimension Platform --days 14 --env prod
Platform
  Phone
  Tablet
  Computer
  Console
  VR
  TV

Where a value is an opaque id — funnel steps are — the readable label is printed beside the raw value, and the raw one is what --filter takes.

Tutorial funnels

Roblox exposes the funnels your game logs with AnalyticsService:LogFunnelStepEvent, so “how many players reached step 3” is answerable here. The metrics are FunnelUserTotalCount, FunnelUserStepCompletionRate, FunnelUserChurnRate, FunnelUserOverallCompletionRate and their session-level twins (FunnelStep*), plus the cohort ones.

Two steps, because FunnelName is filter-only:

# 1. which funnels does this game log?
rbx analytics dimensions --metric FunnelUserTotalCount --dimension FunnelName --days 90 --env prod

# 2. players per step of one of them
rbx analytics query --metric FunnelUserTotalCount \
  --filter FunnelName=Tutorial --breakdown FunnelStep \
  --granularity none --days 90 --env prod

Two constraints worth knowing before you debug an empty result. Funnel metrics accept --granularity none only, which is why wide ranges are their normal case and why the queued-query handling above matters. And an empty answer usually means the game never logged the events: nothing appears here that LogFunnelStepEvent did not put there.

Do not build charts here

rbx analytics query --metric DailyActiveUsers --days 90 --csv --env prod > dau.csv
time,series,metric,value
2026-07-30T00:00:00+00:00,total,DailyActiveUsers,1288

Point a dashboard tool or a spreadsheet at that file. An evening of that beats weeks of writing a dashboard, and the result is better.

--csv and --json answer two different questions and the command rejects them together rather than picking one. CSV is for whatever reads a file: a spreadsheet, a charting tool, a load into a table. JSON is for whatever makes a decision: it carries the query alongside the numbers, keeps a breakdown as series instead of flattening it into a column, and distinguishes a hole from a zero, which the CSV cannot — an empty last field there is both.

The other thing worth automating is the alert, not the chart: a scheduled job that queries D1Retention, compares it against the previous week, and posts to Discord when it moves. A dashboard you have to remember to open tells you nothing.


rbx ban

Who is allowed into the experience. Reading is free; writing is deliberately awkward.

See ops.md for install, keys and the safety model.

Naming a player

Every subcommand here accepts any of these, mixed freely:

156                                        a user id
builderman                                 a username
name:12345                                 a username that looks like an id
@12345                                     the same, but see the PowerShell note
https://www.roblox.com/users/156/profile   a link pasted from a report

On PowerShell, use name: and not @. @ is the splatting operator there, so a bare @builderman expands to a variable that does not exist and the argument vanishes before the program is reached, which shows up as the following required arguments were not provided. "@builderman" quoted works too. name: needs no quoting in any shell.

Bare digits mean an id, because that is what they almost always are. Roblox does allow all-digit usernames, so @ forces the name reading.

A username that does not exist is an error, never a silent skip:

Error: no Roblox user is named: ThisNameDoesNotExist99999x. Usernames are not
display names; pass a user id if you are unsure.

That matters more than it looks. Roblox’s lookup endpoint simply omits names it cannot find, so asking about three players and hearing about two is the only signal that one was wrong. And the name people paste from a Discord report is usually a display name, which is not unique and is not what this resolves.

Checking someone

rbx ban status builderman --env prod
builderman (156)
  https://www.roblox.com/users/156/profile
  RESTRICTED  for 7d
  since        2026-08-01T10:12:03Z
  your note    exploit: fly hack, clip of 3 Aug
  player sees  Banned 7 days for cheating

Two reasons are stored: your note is private and for your records, player sees is shown to them on their next join attempt.

Listing and auditing

rbx ban list --env prod              # everyone currently restricted
rbx ban list --env prod --include-inactive
rbx ban logs --env prod              # the audit trail

list returns ids, not names: the endpoint does not send names, and resolving every row would be one extra request per player. Run ban status <id> on a row you care about.

Restricting

rbx ban add builderman \
  --reason "exploit: fly hack, clip of 3 Aug" \
  --display-reason "Banned 7 days for cheating" \
  --duration 7d \
  --env prod

Nothing is sent. You get the resolved account, links to check it, and the exact payload:

restrict 1 player(s):
  builderman (156)
    https://www.roblox.com/users/156/profile

{
  "gameJoinRestriction": {
    "active": true,
    "duration": "604800s",
    "privateReason": "exploit: fly hack, clip of 3 Aug",
    "displayReason": "Banned 7 days for cheating"
  }
}

Nothing sent. Re-run with --apply to perform it.

Add --apply and you also get a y/N prompt. --yes skips the prompt, for scripts.

Two gates rather than one, because the input to this command is a name typed by a person under pressure and the output is a real player locked out. The links are the point: open the profile, confirm it is the right account, then apply. There is more than one Builderman.

FlagMeaning
--reasonRequired. Private, 1000 characters. A ban you cannot explain in six months is a ban you cannot defend.
--display-reasonShown to the player, 400 characters.
--duration30m, 12h, 7d, 2w. Omit for permanent.
--allow-altsLet alt accounts through. Roblox propagates a restriction to linked alts by default, which is what you want for an exploiter; this turns that off. Named for what it does, not after Roblox’s excludeAltAccounts field, where true means “do not propagate”.
--applyActually send it.
--yesSkip the prompt.

Permanent is expressed by leaving --duration out, and --duration permanent is rejected on purpose: the harshest outcome should be reachable by deliberately omitting something, not by a word you could mistype.

Lifting a restriction

rbx ban remove builderman --env prod --apply

Machine-readable output

--json on the two reads — status and list — writes one JSON document to stdout and nothing else. Everything that is not the result (the count, the “names are not returned” note, the unknown-key warning from rbxplace.toml) goes to stderr, so jq reads the pipe and a human still reads the terminal.

add and remove do not take it. Both stop and ask before they act, and a format that owns stdout cannot stop and ask: the prompt would land in the document, or in a pipeline where nobody can answer it. So the flag is not there to be refused at runtime, it does not exist on those subcommands at all. logs has no document yet either; the audit trail is worth one and nobody has asked.

Permanent is stated, never implied

Roblox expresses a permanent restriction by sending no duration at all. That is the one place where a missing field means the worst outcome rather than “nothing to report”, and a consumer reading .duration // "none" would report a permanent ban as no ban. So every restriction carries permanent, and duration — Roblox’s own 604800s, not the 7d the table renders — is absent exactly when permanent is true.

rbx ban list --json

{
  "schema_version": 1,
  "env": "prod",
  "universe_id": "5544332211",
  "include_inactive": false,
  "limit": 100,
  "limit_reached": false,
  "count": 2,
  "restrictions": [
    {
      "user_id": "156",
      "active": true,
      "permanent": false,
      "duration": "604800s",
      "private_reason": "exploit: fly hack, clip of 3 Aug"
    },
    { "user_id": "881", "active": true, "permanent": true }
  ]
}
FieldTypeMeaning
schema_versionintegerDocument format, shared with rbx check --json. 1 today. Refuse a version you do not understand
envstringThe env that named the universe. Absent under a bare --universe-id
universe_idstringThe experience this is a listing of
include_inactivebooleanWhether entries that exist without locking anybody out were included
limitintegerThe --limit in force, a maximum and not a promise
limit_reachedbooleanThe walk stopped at --limit rather than at the end of the listing. Raise it to see the rest
countintegerRows in restrictions
restrictions[].user_idstringAbsent when the id could not be read out of the resource path, which the table prints as ?
restrictions[].activebooleanFalse only under --include-inactive; without this field the two kinds of row are indistinguishable
restrictions[].permanentbooleanNothing will lift this restriction
restrictions[].durationstringAs Roblox sent it. Absent when permanent
restrictions[].private_reasonstringYour note, the REASON column. Absent when there is none

No names: the endpoint does not send them, which is the same reason the table prints ids. Nobody restricted is "count": 0, an empty array and exit 0, never silence.

rbx ban status --json

One document for every player asked about, in the order they were given.

{
  "schema_version": 1,
  "env": "prod",
  "universe_id": "5544332211",
  "count": 1,
  "players": [
    {
      "user_id": "156",
      "username": "builderman",
      "display_name": "builderman",
      "profile_url": "https://www.roblox.com/users/156/profile",
      "restricted": true,
      "permanent": false,
      "duration": "604800s",
      "start_time": "2026-08-01T10:12:03Z",
      "private_reason": "exploit: fly hack, clip of 3 Aug",
      "display_reason": "Banned 7 days for cheating"
    }
  ]
}

permanent, duration, start_time and both reasons are absent for a player who is not restricted. permanent in particular is absent rather than false there: false would read as “restricted, but not for ever”. A lifted restriction leaves its record behind on Roblox, reasons included, and reports only that it is lifted, which is what the human form prints too.

What these documents do not say

They are about real players locked out of a real game, so they say no more than the human form already says out loud.

list carries no display_reason and no start_time. The listing prints neither, and the text a banned player is shown is not a field a monitoring job asked for. status prints both, under player sees and since, and carries both.

inherited and exclude_alt_accounts are in neither. They are on every restriction Roblox returns and nothing here has ever printed them, so nothing promises them. path and update_time are absent for the duller version of the same reason: unprinted today, so unpromised today.

Scopes

SubcommandScope
status, list, logsuniverse.user-restriction:read
add, removeuniverse.user-restriction:write

Put the write scope on a separate key from everything else. The read key is the one that ends up in your shell history.

Retries are safe: each write carries an idempotency key, so a request that times out and is retried is recognised by Roblox as the same operation rather than applied twice.


rbx restart

Push a published version out to servers still running the old one, without waiting for them to cycle.

Publishing does not restart anything. Servers already up keep running the code they started with until they empty out, which on a busy experience takes hours. Fine for a feature, not for a fix.

See ops.md for install, keys and the safety model. Reading needs universe:read, launching needs universe:write.

The dry run is not a simulation

Roblox has a forecast endpoint that answers how many real players would be kicked right now. So restart launch without --apply shows that number and stops. You decide against a fact rather than against a guess.

rbx restart forecast --env prod
PLACE                      PLAYERS HIT       INSTANCES HIT  NEWEST VERSION
55443322110099                   0/840                0/210  412

0 player(s) would be disconnected, 0 instance(s) closed.
Hit is not total: a server already on the newest version is left alone.

Read that carefully: 840 players are online, and 0 would be affected, because every server already runs 412. “Hit” and “total” are different numbers and only the first one costs you anything.

Launching

# shows the forecast, sends nothing
rbx restart launch --env prod

# for real: prompts, then schedules
rbx restart launch --env prod --bleed-off 30 --apply
FlagMeaning
--bleed-off <minutes>Delay before servers begin closing. Default 30, Roblox accepts 1 to 240.
--applyActually launch it.
--yesSkip the prompt.

Bleed-off is the kind part. During that window players stop being matchmade to the servers due for restart, so most of them leave on their own and are never kicked. Longer is gentler. The forecast number is what happens with no bleed-off at all, so the real impact is lower.

Nothing to restart short-circuits: if every server is already on the newest version, the command says so and does not prompt.

Watching it

rbx restart status --env prod

States are DELAYING (the bleed-off, nothing closed yet), RESTARTING (servers closing), SUCCEEDED.

Not verified end to end

forecast and status have been exercised against a live experience. launch --apply never has been, and cannot be without restarting production for real. It is covered by mocks, including the test that matters most: a launch without --apply fetches the forecast and never POSTs.

Treat the first real launch as the test it is: run forecast first, pick a quiet hour, and use a long bleed-off.

rbx data

Read, overwrite, copy and recover one data store entry.

Deliberately narrow. Browsing, diffing revisions visually and restoring by clicking are better done in DataStoria’s editor than in a terminal, and this does not try to replace it. What it covers is the scriptable half, plus the two things a single-universe GUI cannot do at all: copying between environments, and running from a script.

See ops.md for install, keys and the safety model.

Needs universe-datastores.objects:read,update,create,list, plus universe-datastores.control:create,list (the control scopes govern the store, objects govern its entries, and creating an entry in a store that does not exist yet needs both), plus universe-datastores.versions:list,read for revisions and restore, plus universe-datastores.control:snapshot for snapshot.

Why overwriting, not deleting

Measured against a live universe, and the two are not symmetric:

after set / resetafter DELETE
a normal readthe new valuenothing, 404
the entry in a listingpresentpresent with --show-deleted
the previous valuegone at oncereadable for 30 days

Deleting is the obvious way to reset a player and the wrong one: the game then reads nothing, which behaves however your wrapper decides, and the old value stays readable for a month anyway.

The surprising half is the last row. Writing an entry four times leaves only the fourth: listRevisions returns one row and an earlier revision by id answers 404 Entry not found at revision, even though the revision counter did increment.

So by default an overwrite is unrecoverable through the API, and the backup file this writes before every write is not a convenience.

There is one way to change that in advance, and it has to be in advance: see snapshots.

Resetting a player

--template <path> names the profile to write; without it, reset reads playerdata.template.json from the working directory. There is no built-in default profile, because a fresh profile is a fact about your game and inventing one would write a shape the game does not read.

# reads playerdata.template.json by default
rbx data --datastore PlayerData reset Player_156 --env prod

# or point at the one your game actually ships
rbx data --datastore PlayerData reset Player_156 --template assets/new-profile.json --env prod

# for real
rbx data --datastore PlayerData reset Player_156 --env prod --apply

The dry run prints the current value beside the new one. On --apply the current value is written to .rbx/backups/prod/Player_156-20260815T091500Z.json before anything is sent, then you are prompted.

Where the backups go

.rbx/backups/<env>/<entry>-<UTC timestamp>.json

Beside rbxplace.toml, not in the working directory: the copy belongs to the project, so it is in the same place whether you ran the command from the repository root or from three directories down. One subdirectory per env, because the same entry key in staging and in prod are two different player profiles.

The timestamp is not decoration. A fixed name like <entry>.backup.json means resetting the same player twice overwrites the copy of the value the first reset destroyed, and that older file is exactly the one worth having. Two writes inside the same second get a -2 suffix rather than replacing each other.

Gitignore .rbx/. These files are real player data. Add it next to your other ignores:

.rbx/

--keep <n> bounds the pile, defaulting to 10:

# keep the last 3 copies of this entry, delete older ones after the write
rbx data --datastore PlayerData reset Player_156 --env prod --keep 3 --apply

Retention counts the file it just wrote, applies to that entry only — one env’s directory holds every key, and --keep on one player never evicts another’s — and touches nothing it did not write. It is refused with --backup and --no-backup, which leave it nothing to do, and --keep 0 is refused too: “no backup at all” is --no-backup, said plainly.

Nothing prunes on a schedule or in the background. A directory only shrinks during a write to that same entry, so a key you stop touching keeps its history until you delete it yourself.

Skipping the local copy

--backup <path> writes it exactly there instead, creating no directory around it and pruning nothing near it. --no-backup means there is none:

rbx data --datastore PlayerData set Player_156 --value '{}' --no-backup --apply --yes

Two situations justify it. After data snapshot, Roblox keeps the replaced value as a revision for 30 days, so the local copy is redundant for the next write to each key. And in a container with a read-only working directory there is nowhere to put it — without this flag the command fails before it sends anything, because the copy is written first on purpose.

Outside those, it throws away the only way back. The command says so on every run rather than only in this page, and the two flags cannot be combined: one names where the copy goes, the other says there is none.

Reading and writing

rbx data --datastore PlayerData get Player_156 --env prod
rbx data --datastore PlayerData get Player_156 --env prod --out profile.json

rbx data --datastore PlayerData set Player_156 --value '{"coins":0}' --env prod --apply
rbx data --datastore PlayerData set Player_156 --file profile.json --env prod --apply

# atomic, unlike read-then-write: two grants at once both land
rbx data --datastore PlayerData increment Coins_156 --by 500 --env prod --apply

An overwrite keeps the entry’s users and attributes unless you pass --drop-metadata. users is the association Roblox uses to answer a player’s data request, and sending only value would sever it silently.

Finding keys

rbx data --datastore PlayerData list --env prod
rbx data --datastore PlayerData list --prefix Player_ --env prod
rbx data --datastore PlayerData list --show-deleted --env prod

Every subcommand also takes --scope, the data store scope, defaulting to global. Leave it alone unless your game writes with an explicit scope: a key written under one scope is invisible from another, so a wrong --scope reads as “entry not found” rather than as an error.

Snapshots

The one thing that makes an overwrite survivable, and it only works if you do it before the write.

rbx data snapshot --env prod            # dry run
rbx data snapshot --env prod --apply

After a snapshot, the next write to every key in the experience keeps the value it replaced as a revision, guaranteed readable for 30 days. That is exactly the guarantee the table above says you do not normally get. It covers one overwrite per key: the second write of the day replaces the first without keeping it.

Roblox allows one snapshot per experience per UTC day. A second call the same day is not an error — it reports the standing snapshot’s time and changes nothing.

That cap is why this takes --apply even though a snapshot can only ever add recoverability. Spending the day’s snapshot early is not free: one taken at 09:00 protects the values as of 09:00, so a key written at 10:00 and again at 17:00 keeps the 09:00 value, not the 10:00 one. Take it immediately before the risky write, not at the start of the day out of habit.

Experience-wide, so it takes neither --datastore nor --scope. Needs universe-datastores.control:snapshot.

Recovering

rbx data --datastore PlayerData revisions Player_156 --env prod
rbx data --datastore PlayerData revisions Player_156 --revision <id> --env prod
rbx data --datastore PlayerData restore Player_156 --revision <id> --env prod --apply

Expect fewer revisions than you wrote, for the reason above. This is mostly useful after a delete, where the value from before survives, or after a snapshot, which is what puts a revision there to find. To undo an overwrite with neither, use the backup file — ls .rbx/backups/<env>/ lists them newest last, and putting one back is set --file:

rbx data --datastore PlayerData set Player_156 \
  --file .rbx/backups/prod/Player_156-20260815T091500Z.json --env prod --apply

Copying between environments

The one a single-universe tool cannot do: pull a profile from production into staging to reproduce a bug on real data, or onto a test account.

rbx data --datastore PlayerData copy Player_156 --from prod --to staging --apply
rbx data --datastore PlayerData copy Player_156 --from prod --to dev --to-entry Player_999 --apply

Source and destination are both named explicitly and neither falls back to --env, so nothing is copied because a flag was forgotten. The destination’s users is kept, not the source’s: attaching one player’s association to another player’s key is not a copy anybody means to make.

Comparing

rbx data --datastore PlayerData diff Player_156 --revisions <a>,<b> --env prod --open
rbx data --datastore PlayerData diff Player_156 --between prod,staging --open

Both sides are written to files and handed to a diff tool: $RBX_DIFF_TOOL if set, else code --diff, else git diff --no-index. Without --open the two paths are printed for you to open however you like.

That is DataStoria’s best screen, obtained without writing a diff viewer, and it works for people who do not use VS Code.

Ordered data stores

rbx data ordered is the leaderboard resource — a different Open Cloud resource from everything above, not a mode of it. Values are integers, ordering happens on Roblox’s side, and there is no revision history at all.

That last point is why nothing here writes a backup file. The backups the rest of this page insists on exist because an overwrite to a standard store destroys a JSON document that only a local copy can bring back. An ordered entry is one integer, and there is nothing to reconstruct.

--datastore names the store, the same flag as above, and --scope applies.

# The top ten
rbx data ordered list --datastore Highscores --env prod

# The top 100, ascending, only scores between 1000 and 5000
rbx data ordered list --datastore Highscores --limit 100 --asc --min 1000 --max 5000

rbx data ordered get Player_156 --datastore Highscores
rbx data ordered set Player_156 4200 --datastore Highscores
rbx data ordered increment Player_156 -50 --datastore Highscores
rbx data ordered delete Player_156 --datastore Highscores
VerbWhat it does
listThe leaderboard. Descending by default — “the top players” is the reason the resource exists, so ascending is the case that takes a flag
get <entry>One value. A key nobody has written prints a note, not an error
set <entry> <value>Exact value, creating the entry when absent. --no-create refuses instead
increment <entry> <amount>Atomic add. Negative subtracts
delete <entry>Removes the entry. Deleting one that is not there is a no-op, not a failure

--limit, --min and --max are all applied by Roblox, not after the fact: a listing sorted or filtered locally would give the top of page one rather than the top of the store. --min/--max become one filter expression, which is the only comparison grammar the endpoint accepts.

Reach for increment over set whenever more than one writer touches a key. A read-then-set from two places loses one of the two updates; the increment endpoint does not.

set, increment and delete ask before writing, and set and delete name the current value in the prompt — there is no revision history to look it up in afterwards. -y / --yes skips the prompt.

What is deliberately missing: snapshot, revisions, restore, diff. Roblox offers none of them on this resource, and a command answering “not supported” for four of its verbs would be worse than not having them.

Scopes: universe.ordered-data-store.scope.entry:read for list and get, :write for the rest.

Machine-readable output

--json on the four reads — get, list, revisions and diff — writes one JSON document to stdout and nothing else. Everything that is not the result (the revision line, the key count, “No entry”, the unknown-key warning from rbxplace.toml) goes to stderr, so jq reads the pipe and a human still reads the terminal.

The four writes do not take it. set, reset, restore, copy, increment and snapshot all stop and ask before they act, and a format that owns stdout cannot stop and ask: the prompt would land in the document, or in a pipeline where nobody can answer it. So the flag is not there to be refused at runtime — it does not exist on those subcommands at all.

The stored value is nested, not escaped

A player profile is already JSON, and it goes into the document as JSON, under a value key:

{
  "schema_version": 1,
  "datastore": "PlayerData",
  "scope": "global",
  "entry": "Player_156",
  "found": true,
  "deleted": false,
  "revision_id": "08DEF1A1B5E3ADA9.0000000002.01",
  "value": {
    "coins": 500,
    "level": 12,
    "inventory": ["hat", "sword"]
  }
}

So jq .value.coins works, and a profile stored as 500 reads back as the number 500 rather than the string "500".

The worry about nesting arbitrary data is a worry about spreading it. Nothing of ours lives inside value, so a profile with a schema_version key of its own is .value.schema_version and collides with nothing. Escaping the value into a string would have bought back a namespace that was never at risk, at the cost of jq -r .value | jq .coins and of every stored number becoming quoted.

What these documents do not say

They read real player data, so they say no more than the human form already says out loud.

users and attributes are not in any of them. users is the users/156 association Roblox answers a player’s data request from. It is on every entry this command fetches, data get has never printed it, and a second player identifier landing in whatever collects your CI output is not a field anybody asked for. path, etag and createTime are absent for the duller version of the same reason: unprinted today, so unpromised today.

diff carries paths, not values. Both sides are already written to files; putting two profiles through the pipe as well would say more than the human form ever has.

data get --json

FieldTypeMeaning
schema_versionintegerDocument format, shared with rbx check --json. 1 today. Refuse a version you do not understand
datastore / scopestringThe store this was read from, as --datastore and --scope named it
entrystringThe key that was asked for
foundbooleanFalse when there is no such key. Exit code is 0 either way, the same non-event the human form prints as “No entry”
deletedbooleanTrue when the entry is soft-deleted and still readable. Roblox purges it thirty days after the delete
revision_idstringThe revision the value came from. Absent when Roblox did not say, and when there is no entry
valueanyThe stored value, nested. Absent under --out and when there is no entry. A present null is a real answer: a stored null and an entry with no value cannot be told apart, and the game cannot tell either
outstringWhere --out wrote the value. Absent without --out

data list --json

{
  "schema_version": 1,
  "datastore": "PlayerData",
  "scope": "global",
  "prefix": "Player_",
  "show_deleted": false,
  "limit": 100,
  "limit_reached": false,
  "count": 2,
  "entries": [{ "id": "Player_156" }, { "id": "Player_881" }]
}

prefix is absent when the listing was unfiltered, which is not the same as a prefix of "". limit_reached says the run stopped at --limit rather than at the end of the store, so a script knows to raise it instead of concluding the store is small. A prefix that matches nothing is an empty entries array and exit 0, never silence.

Entries are objects rather than bare strings, so .entries[].id keeps working the day a listing carries a second field.

data revisions --json

Two documents, and --revision is what picks between them. Without it, the list:

{
  "schema_version": 1,
  "datastore": "PlayerData",
  "scope": "global",
  "entry": "Player_156",
  "count": 2,
  "revisions": [
    {
      "revision_id": "08DEF1A1B5E3ADA9.0000000002.01",
      "create_time": "2026-08-15T09:15:00.1234567Z",
      "state": "DELETED",
      "deleted": true
    },
    {
      "revision_id": "08DEF1A1B5E3ADA9.0000000001.01",
      "create_time": "2026-08-14T11:02:33.0000000Z",
      "state": "ACTIVE",
      "deleted": false
    }
  ]
}

create_time keeps Roblox’s full precision, where the table shortens it to the second. deleted is derived from state so a consumer does not keep its own list of spellings.

With --revision <id>, that revision’s value instead, under the same value rule as get:

{
  "schema_version": 1,
  "datastore": "PlayerData",
  "scope": "global",
  "entry": "Player_156",
  "revision_id": "08DEF1A1B5E3ADA9.0000000001.01",
  "value": { "coins": 500 }
}

data diff --json

{
  "schema_version": 1,
  "datastore": "PlayerData",
  "scope": "global",
  "entry": "Player_156",
  "left": {
    "label": "prod-Player_156",
    "path": "/tmp/prod-Player_156.json",
    "env": "prod"
  },
  "right": {
    "label": "staging-Player_156",
    "path": "/tmp/staging-Player_156.json",
    "env": "staging"
  }
}

Each side carries revision under --revisions and env under --between, exactly one of the two, so a consumer reads which comparison it got rather than parsing label apart.

--json and --open are refused together. --open hands stdout to git diff --no-index and the terminal to code --diff, either of which would write somebody else’s output into the document.

# hand the pair to your own tool
rbx data --datastore PlayerData diff Player_156 --between prod,staging --json \
  | jq -r '.left.path, .right.path' | xargs delta

Exporting

get --out writes one value. For history, list the keys and get each: entry listings only carry ids, so reading values is one call per entry and this makes no attempt to hide that cost.

rbx data --datastore PlayerData list --prefix Player_ --limit 500 --env prod --json \
  | jq -r '.entries[].id' \
  | while read -r key; do
      rbx data --datastore PlayerData get "$key" --env prod --json > "backup-$key.json"
    done

rbx memorystore

Read and write memory store sorted map items.

The one Open Cloud storage surface a game server can read without an HTTP call. Something outside Roblox — a VPS, a cron job, a dashboard — writes a value here, and every running server picks it up through MemoryStoreService:GetSortedMap() without paying a data store round trip for it.

See ops.md for install, keys and the safety model.

This page describes main. Check rbx --version against what your rokit.toml pins.

Needs memory-store.sorted-map:read to read and :write to write. Those scopes are universe-targeted, like the data store ones — a key restricted to one experience stays restricted to it.

Sorted maps only

Queues are the other half of the memory store and a different shape of problem: ordering, claiming, discarding, visibility timeouts. Nothing has needed them yet, and inventing a queue CLI before there is a queue to drive is how you ship the wrong verbs. When one is needed it belongs here as a second mode.

flush — which empties an experience’s entire memory store — is also absent. It is a single irreversible call affecting every map and queue at once, and it is not part of writing a cache value.

Writing a value

# described, not sent
rbx memorystore --map Cache set rotation \
  --value '{"map":"desert","weight":3}' --ttl 300s --env prod

# for real
rbx memorystore --map Cache set rotation \
  --value '{"map":"desert","weight":3}' --ttl 300s --apply --env prod

--file path.json reads the value from a file instead, which is easier than quoting JSON in a shell.

set is an upsert. Creating and updating are the same request, so the first write to a new item is not a special case and two writers racing both land as writes. There is no separate “create map” step either: naming a map that has never existed is normal, and the map comes into being on its first write.

TTL

--ttl 300s    --ttl 10m    --ttl 2h

Omit it and the item stays until something removes it. For a cache that is usually wrong — a TTL is what stops a stale value outliving whatever was producing it. The response reports the computed expiry:

✓ wrote "rotation"
  expires 2026-08-13T09:43:49Z

Sort keys

--sort-key <number> or --string-sort-key <text> set the ordering used by list. They are part of the sorted-map model rather than an extra: a map with no sort key is still a sorted map, it just has nothing to sort by.

Reading

rbx memorystore --map Cache get rotation --env prod
rbx memorystore --map Cache get rotation --out value.json --env prod

get prints the value as JSON on stdout and the expiry on stderr, so a pipe gets the value alone. A missing item is an error rather than null, so a script that reads a key which was supposed to be there stops instead of carrying on with nothing.

rbx memorystore --map Cache list --env prod
rbx memorystore --map Cache list --values --limit 500 --env prod

list prints ids with their sort keys and expiry; --values adds each value. It follows pages up to --limit.

An empty listing is not proof the map is empty. A map that has never been written to answers exactly the same way as one whose items have all expired. There is no way to tell them apart, and no reason to need to.

Machine-readable output

--json on get and list writes one JSON document to stdout and nothing else. The expiry line, the item count and the “map is empty” line go to stderr, where they cannot corrupt it.

The writes do not take it. set and delete report what they did in prose, and nothing in this crate needs to script off that.

rbx memorystore --map Cache get rotation --env prod --json
{
  "schema_version": 1,
  "map": "Cache",
  "item": "rotation",
  "expire_time": "2026-08-15T09:43:49Z",
  "value": { "map": "desert", "weight": 3 }
}

The cached value is nested as JSON under value, not escaped into a string: jq .value.map works and a stored number stays a number. expire_time is absent when the item has no TTL, which is a different fact from an expiry of null. A missing item is still an error rather than a document saying so, exactly as in the human form: a script reading a key that was supposed to be there should stop. etag and path are on every item Roblox returns and in neither document, because the human form has never printed them.

rbx memorystore --map Cache list --values --limit 500 --env prod --json
{
  "schema_version": 1,
  "map": "Cache",
  "limit": 500,
  "limit_reached": false,
  "count": 2,
  "items": [
    {
      "id": "rotation",
      "numeric_sort_key": 3.5,
      "expire_time": "2026-08-15T09:43:49Z",
      "value": { "map": "desert" }
    },
    { "id": "banner", "string_sort_key": "zulu", "value": "hello" }
  ]
}
FieldTypeMeaning
schema_versionintegerDocument format, shared with rbx check --json. 1 today. Refuse a version you do not understand
mapstringThe sorted map, as --map named it
limitintegerThe --limit in force
limit_reachedbooleanThe run stopped at --limit, not at the end of the map. Raise it to see the rest
countintegerRows in items
items[].idstringThe item id. Absent when Roblox sent none, which the human listing prints as <no id>
items[].numeric_sort_key / .string_sort_keynumber / stringAt most one of the two, since the flags that set them are exclusive
items[].expire_timestringAbsent when the item has no TTL
items[].valueanyAbsent without --values. The same flag decides it in both formats, so nothing reads a value it did not ask for

An empty map is an empty items array and exit 0, not silence, so .count reads the same whether the command found nothing or printed nothing. It still cannot tell a map that was never written to from one whose items have all expired, for the reason above: nothing can.

# the ids about to expire in the next five minutes
rbx memorystore --map Cache list --limit 500 --env prod --json \
  | jq -r --arg t "$(date -u -d '+5 min' +%Y-%m-%dT%H:%M:%SZ)" \
      '.items[] | select(.expire_time != null and .expire_time < $t) | .id'

Deleting

rbx memorystore --map Cache delete rotation --apply --env prod

For removing a value before its TTL runs out. Without --apply it describes what it would delete.

Why there is no confirmation prompt

rbx data prompts before overwriting and writes a backup file first, because a player profile is irreplaceable and an overwrite destroys the previous value. None of that holds here: these items are a cache, they carry a TTL, they are rebuilt from whatever produced them, and the thing writing them is a script on a schedule rather than a person at a terminal. A prompt would sit exactly where nobody can answer it.

Writes still need --apply, because that is the rule for every live-operations command and a rule with exceptions is not one you can rely on when you are tired.

Servers do not learn about a write until they look

Nothing wakes a running server when a value changes. A server reads the map when its own code decides to, so a value written here appears on whatever polling interval the experience already has.

That is a fact about the servers, not about this command: set and delete above write through memory-store.sorted-map:write, and they write immediately.

Pushing the change to servers immediately is MessagingService’s job, and rbx message sends that message. The pairing is a memory store item for the value and a publish for the nudge:

rbx memorystore --map Cache set rotation --file rotation.json --ttl 1h --apply --env prod
rbx message --topic cache --payload '{"key":"rotation"}' --apply --env prod

Publish a reference rather than the value itself. The message is capped at 1114 bytes, the item is not, and a server that missed the message still finds the value on its next read.

Two API details worth knowing

Both cost a request to discover, and both are handled for you:

The item id is a query parameter. POST .../items with {"id": ...} in the body answers 400 INVALID_ARGUMENT "The id field is required." — an error naming the field you just sent. The id belongs in the URL.

Roblox signals “no more pages” with an empty string here, where the data store endpoints use null. Treating "" as a real page token fetches the same page forever.

rbx message

Send one MessagingService message to every running server.

Not rbx publish. “Publish” already means a deploy everywhere else here — place upload --published publishes a place, config sync publishes a config document, place rollback republishes live. A command called publish that sends an IPC message is the one somebody finds when they search for how to publish their place, and rbx publish --topic cache --message reload is plausible enough that they might not notice.

The push half of the pair memorystore is the pull half of. A memory store item is read when a server’s own code decides to look, so a value written from outside appears on whatever polling interval the experience already has. This is what tells the servers to look now.

See ops.md for install, keys and the safety model.

This page describes main. Check rbx --version against what your rokit.toml pins.

Needs universe-messaging-service:publish, which is universe-targeted and not on most existing keys — a 403 here usually means the scope, not the key.

Why an ops CLI sends these at all

Publishing is normally server-to-server IPC and belongs in game code, which is why this was left out at first. That reasoning assumes the publisher is a game server. It is not, in the case this exists for: the publisher is a VPS, a cron job or a deploy step that has just changed something and wants the running servers to notice. There is no in-experience way to originate that.

Sending

# described, not sent
rbx message --topic cache --message reload --env prod

# for real
rbx message --topic cache --message reload --apply --env prod

--topic is the name the game passes to MessagingService:SubscribeAsync.

The message is a string, not JSON

Roblox types message as a string. A structured payload therefore travels as text and is decoded in-experience:

rbx message --topic cache --payload '{"key":"rotation"}' --apply --env prod

--payload parses the value here and sends its serialisation, so a malformed payload fails before the publish rather than inside HttpService:JSONDecode on a live server, where it is a runtime error in game code with no obvious origin. --message sends whatever you give it, untouched.

--json is not this flag. On this command as on every other, --json writes the result as a document, and a payload flag that also took a value would have kept that name occupied — leaving publish the one command that cannot report what it did.

The size limit is 1114 bytes, not 1 KB

Measured against the live API rather than read off the documentation, which says 1 KB. 1114 is accepted, 1115 answers:

400 The length of published message must be between 1 and 1114.

Both bounds are checked here, before the request, so the failure names the size instead of arriving as a 400 from inside a deploy. The floor matters too: an empty message is refused, so publishing "" as a bare “something changed” signal fails — send a single character instead.

Publish a reference, not a payload. The pattern this is built for is: write the value into a memory store sorted map, then publish the key. The message stays small, the value can be any size, and a server that missed the message still finds the value on its next read.

rbx memorystore --map Cache set rotation --file rotation.json --ttl 1h --apply --env prod
rbx message --topic cache --payload '{"key":"rotation"}' --apply --env prod

--json

The receipt. A publish cannot be recalled and Roblox reports nothing about who received it, so this is the only record that the call went out.

rbx message --topic cache --message reload --apply --json --env prod
{
  "schema_version": 1,
  "topic": "cache",
  "universe_id": "5544332211",
  "bytes": 6,
  "applied": true
}
FieldTypeMeaning
schema_versionintegerBumped when a documented field changes meaning or disappears.
topicstringThe topic, as the experience passes it to SubscribeAsync.
universe_idstringA string, not a number: universe ids exceed 2^53.
bytesintegerEncoded length of the message, which is what the limit applies to.
appliedbooleantrue once it was sent. false on a dry run, which is the default.
messagestringThe body that would be sent. Present on a dry run, absent once it has been.

--json is a format, not an --apply: without --apply it still sends nothing and answers applied: false.

message follows the invocation rather than the data. A dry run prints the body it would send, so the document carries it; --apply prints only that it went, so the document does not. Echoing a published payload back to stdout would put it in whatever captured the log, for a line the command itself decided not to print.

A run that fails writes nothing to stdout: a malformed payload, an oversized message, an env that resolves to no universe, or a publish Roblox refuses. An empty stdout next to a non-zero exit says the publish did not happen without a consumer having to read a field to find out.

What it cannot tell you

Whether anybody heard.

The call answers 200 once Roblox has accepted the message for delivery. There is no count of servers reached and no delivery receipt, and an experience with no running servers accepts a publish exactly like a busy one. The command says so on every success rather than letting a green tick imply more than it means.

Anything needing confirmation needs the servers to write back somewhere — which is what a memory store map is for.

rbx ads

Launch and steer Roblox ad campaigns.

See ops.md for install, keys and the safety model.

This page describes main. Check rbx --version against what your rokit.toml pins.

This command spends real money. Every write is dry-run by default and needs --apply, and launch prints what a run will cost before it asks.

Roblox ships this API as an experiment. Its announcement says request and response shapes can change and that it should not carry production-critical automation yet. The rbx-spec-drift test is the alarm for the day a path moves.

What it cannot do

Read results. /ads-management/v1 has no reporting endpoint, and that is deliberate rather than an oversight:

Reporting is deliberately not in v1 of the Ads API. That will be available soon, for now Ads Manager remains the place to read campaign performance. You’ll get it soon through the Analytics API.

So impressions, clicks and spend are read in Ads Manager. When Roblox delivers reporting through the Analytics API, it lands in analytics next door rather than here.

That constraint shapes everything below.

Testing an icon or a thumbnail

The reason this exists. One command creates one campaign per image, identical in every other respect.

rbx ads launch \
  --creative 18234567890 --creative 18234567891 --creative 18234567892 \
  --name "icon test" --budget 25 --days 14 --env prod

That prints three campaigns and a total, and sends nothing. Add --apply to create them.

Each campaign is named icon test [18234567890]. The asset id is in the name on purpose: the numbers are read by a human in Ads Manager, and the name is the only thread tying a row in that page back to the image it carried.

Why not one campaign with three images

A campaign accepts up to ten creatives and Roblox distributes them evenly across players, which is a fair experiment. But Ads Manager reports per campaign, so three images in one campaign give you one number for the three. You would know the campaign’s click-through rate and not which image earned it.

One campaign per image is the only shape whose results can be told apart.

Internal competitionNumbers per image
One campaign, up to 10 creativesnoneno
One campaign per creativesomeyes

What that costs

Identical campaigns chase the same impressions, so they compete with each other and each dollar buys a little less. Worth knowing, and not worth avoiding: the inflation hits every variant equally, so the ranking survives even when the absolute numbers suffer. A ranking is what a test is for.

Do not try to dodge it by splitting the targeting, one image on phones and another on desktop. That removes the overlap and destroys the test: you would be comparing two audiences, not two images.

Before you spend anything on thumbnails

Roblox has a free A/B test for thumbnails in the Creator Dashboard, measured on the real discovery surface with QPTR. If thumbnails are what you are testing, use that instead: it is free, and an ad’s click-through rate is not your game tile’s click-through rate.

There is no such test for the icon, which is what makes paid campaigns a reasonable workaround there.

Spending twice by accident

Roblox requires an x-idempotency-key on create, and this tool derives it from the campaign’s own definition rather than at random. Two consequences, both wanted:

  • A retry cannot double-charge. The HTTP layer resends on a timeout or a 429, and every resend carries the same key.
  • Running the same launch twice resolves to the campaigns that already exist instead of buying a second set.

To deliberately run a second test with the same images, change --name. You would want to anyway, to tell the two apart in Ads Manager.

Commands

CommandWhat it does
launchOne campaign per --creative, identical otherwise. Needs --apply.
listCampaigns on the account, every page of them. --json available.
get <id>One campaign in full. --json available.
status <id>...Serving, in review, or blocked, for several ids in one call. --json available.
pause [id]Stop campaigns. Needs --apply.
resume [id]Start them again. Needs --apply.
cancel [id]End them for good. Needs --apply.
budget [id] --amountChange the budget. Needs --apply.
rename <id> --nameChange the name. Needs --apply.
creativesImages available as creatives, and their moderation state. --archived lists archived ones instead of live.
universesExperiences this account may advertise.
accountsBilling accounts this key can spend from.
optionsFormats, objectives, payment types, targeting, and eligibility.

Naming a campaign, and not remembering its id

--name on launch is free text. icon test is only the example above; whatever you pass becomes <your name> [<asset id>] on every campaign of the group.

That name then does two jobs.

It finds the group again. pause, resume and cancel take --name and act on every campaign whose name starts with it, which is how you stop the five you started with one command:

rbx ads pause --name "icon test" --apply

Campaigns already cancelled are left out rather than asked to cancel twice.

It is how you recognise a campaign you are about to change. Give pause, resume, cancel or budget no id at all, on a terminal, and they list the campaigns and let you pick:

? Which campaign should I pause?
> icon test [18234567890] · ACTIVE · SERVING · $25.00
  icon test [18234567891] · ACTIVE · IN_REVIEW · $25.00
  summer push · PAUSED · NOT_SERVING · $100.00

The same applies to the confirmation: it names the campaign and its state rather than echoing an id back at you. c_8f3a91 is not something anyone can check.

Off a terminal, a missing id is an error naming the flags to pass, not a prompt a script would hang on.

Got the name wrong at launch? rename fixes it without touching delivery or budget.

Picking the images

Omit --creative on a terminal and launch asks, rather than making you copy asset ids out of ads creatives:

? Which images should this test compare? (space to pick, enter to confirm)
> [x] 18234567890 · 512x512 · APPROVED · icon-red
  [x] 18234567891 · 512x512 · APPROVED · icon-blue
  [ ] 18234567892 · 512x512 · PENDING_REVIEW · icon-green

Fewer than two picks is refused: one image compared against nothing is not a test.

launch

FlagMeaning
--creative <ASSET_ID>Image to test. Repeat once per variant. Listing one twice is refused.
--nameBase name. Each campaign gets it plus its asset id.
--budgetDollars per campaign. Five variants at 25 is 125 dollars.
--budget-typeDAILY or LIFETIME.
--daysHow long to run.
--startRFC 3339. Defaults to as soon as review clears.
--paymentCREDIT_CARD, ADS_CREDIT or INVOICE. See ads options.
--country, --age, --deviceNarrow the audience. Repeatable.
--applyActually create them.
--yes / -ySkip the confirmation. See below before you use it.

--yes on a command that spends money

--apply is what makes a write real; --yes is what removes the question in front of it. Every write here takes both — launch, pause, resume, cancel, budget and rename — and only launch prints a cost first.

Together they are an unattended charge. That is a reasonable thing to want in a scheduled job whose budget was decided when the job was written, and it is not a reasonable default for a terminal: the prompt is the last place a mistyped --budget is catchable, and a campaign cannot be un-bought. --apply without --yes is the ordinary way to run these by hand.

Budgets are typed in dollars and sent in micro-USD. 25.50 becomes 25500000, read digit by digit rather than through a float, because 0.07 has no exact binary representation and a budget arriving as 69999 micros is the kind of defect nobody looks for.

Objective and bid strategy are not flags. The API accepts one value for each today, ENGAGEMENT and AUTOMATED, so there is nothing to choose.

Money that moves later

An increase to a budget takes effect immediately. A decrease on a running campaign lands at the next midnight in the account’s time zone, and the campaign keeps spending the higher figure until then. budget says so after it applies one.

--json

list, get and status take --json and write one JSON document to stdout and nothing else. Notes — “No campaigns on this account.” among them — go to stderr, so a pipeline’s input parses on every run.

The write commands have no --json. They spend money, they are dry-run by default, and several of them ask a question when you leave the id out; a command that prompts is not one to hand a pipeline.

list

rbx ads list --json
{
  "schema_version": 1,
  "totals": { "returned": 2, "active": 1 },
  "campaigns": [
    {
      "id": "c_8f3a91",
      "name": "icon test [18234567890]",
      "status": "ACTIVE",
      "delivery_status": "SERVING",
      "delivery_status_reasons": [],
      "budget": { "amount_micros": "25500000", "amount_usd": "25.50", "type": "DAILY" },
      "target_universe_id": "5544332211",
      "creative_asset_ids": ["18234567890"]
    }
  ]
}
FieldTypeMeaning
schema_versionintegerDocument format, shared with rbx check --json. 1 today
totals.returnedintegerRows in campaigns
totals.activeintegerHow many are ACTIVE. Not the same as serving, see below
campaigns[].idstringCampaign id
campaigns[].namestringFree text, and load-bearing: launch writes the asset id into it, and it is the only thread from an Ads Manager row back to the image it carried
campaigns[].statusstringWhat the campaign was asked to do: ACTIVE, PAUSED, CANCELLED
campaigns[].delivery_statusstringWhat it is actually doing: SERVING, IN_REVIEW, NOT_SERVING, REJECTED
campaigns[].delivery_status_reasonsarray of stringsWhy, when Roblox says. Always present, empty when it said nothing
campaigns[].budgetobjectAbsent when Roblox reported none, which is not a budget of zero
campaigns[].budget.amount_microsstringMicro-USD exactly as Roblox sent it. The authoritative figure
campaigns[].budget.amount_usdstringThe same amount in dollars, truncated to the cent. Absent when the micros are not a number
campaigns[].budget.typestringDAILY or LIFETIME
campaigns[].target_universe_idstringThe experience advertised. A string: universe ids exceed 2^53
campaigns[].creative_asset_idsarray of stringsNormally one entry, since launch creates one campaign per creative

Money is never a JSON number. Both forms are strings, for the same reason budgets are parsed digit by digit rather than through an f64: 0.07 has no exact binary representation, and a budget read back as 24.999999 is the kind of defect nobody looks for. Compute on amount_micros and print amount_usd.

status is not delivery_status. A campaign can be ACTIVE and still IN_REVIEW, which means it is spending nothing. An alert that reads only status reports a test that never started as running:

# campaigns that were meant to be running and are not
rbx ads list --json \
  | jq -r '.campaigns[] | select(.status == "ACTIVE" and .delivery_status != "SERVING")
           | "\(.id) \(.delivery_status) \(.name)"'

# total daily exposure, in micros, so nothing rounds
rbx ads list --json \
  | jq '[.campaigns[] | select(.status == "ACTIVE" and .budget.type == "DAILY")
         | .budget.amount_micros | tonumber] | add'

An account with no campaigns is an empty campaigns array and exit 0.

get

rbx ads get c_8f3a91 --json

The campaign sits under campaign, which is the same object list puts in campaigns, so one filter reads either:

{ "schema_version": 1, "campaign": { "id": "c_8f3a91", "…": "…" } }

status

rbx ads status c1 c2 c3 --json
{
  "schema_version": 1,
  "totals": { "requested": 3, "returned": 2, "failed": 1 },
  "statuses": [
    { "id": "c1", "status": "ACTIVE", "delivery_status": "SERVING", "delivery_status_reasons": [] },
    {
      "id": "c2",
      "status": "ACTIVE",
      "delivery_status": "REJECTED",
      "delivery_status_reasons": ["creative violates policy"]
    }
  ],
  "failures": [{ "id": "c3", "reason": "campaign not found" }]
}
FieldTypeMeaning
totals.requestedintegerIds given on the command line
totals.returnedintegerIds Roblox answered for: rows in statuses
totals.failedintegerIds it refused: rows in failures
statuses[]array of objectsid, status, delivery_status, delivery_status_reasons
failures[]array of objectsid and the reason Roblox gave. Always present, empty when every id came back

Roblox answers 200 with both lists in one body, so an id it could not read is not a failure of the whole call. The two stay in separate arrays on purpose: folded together, an id nobody answered for would read as a campaign that is not serving.

# fail a deploy check if any id went unanswered
rbx ads status c1 c2 c3 --json | jq -e '.totals.failed == 0' > /dev/null

Scopes

ad.campaign:read, ad.campaign:write for everything that touches campaigns, and ad.billing:read for accounts. A key without them gets an error naming the scope rather than a bare 403.

After a launch

Campaigns come back as IN_REVIEW: queued for ad-policy review, not yet serving.

rbx ads status c1 c2 c3

REJECTED comes with reasons attached, which is the one piece of feedback this API does return.

rbx probe

A raw authenticated request to any Open Cloud path, printing the response.

See ops.md for install, keys and the safety model.

Hidden from rbx --help on purpose. This is the tool for working out what an undocumented endpoint returns while writing a typed client for it, and for looking at raw bytes when one starts behaving oddly. It is not part of daily work, and listing it beside servers and ban would suggest otherwise. It is fully supported: rbx probe --help works, and so does everything below.

This exists because several endpoints worth using are in beta and absent from the Open Cloud reference. Writing a typed client against a schema guessed from a forum post is how you ship a parser that silently drops a field. probe fetches the truth first.

rbx probe "cloud/v2/universes/{universe}" --env prod

{universe} is replaced with the universe id of the resolved env, so you rarely need to paste ids.

--universe-id <id> works instead of --env, which is usually what you want here: the endpoints probe exists to explore are the ones no config knows about yet, and often belong to a universe that has no env at all.

rbx probe "cloud/v2/universes/{universe}" --universe-id 66778899001
# A write is described, not performed
rbx probe "cloud/v2/universes/{universe}/user-restrictions/123" \
  -X PATCH -d '{"gameJoinRestriction":{"active":true}}' --env test

# Actually send it
rbx probe ... --apply --env test
FlagMeaning
-X, --methodHTTP method. Anything but GET also needs --apply.
-d, --dataJSON body. Parsed before sending, so a typo fails here rather than as a confusing 400.
--applyActually send a non-GET request.

Gotcha on Git Bash for Windows

probe /cloud/v2/... arrives as C:/Program Files/Git/cloud/v2/.... That is MSYS rewriting anything that starts with / into a Windows path, not a bug in the tool.

Drop the leading slash: probe cloud/v2/... works everywhere. MSYS_NO_PATHCONV=1 also works. PowerShell and real POSIX shells are unaffected.


rbx open

Open Roblox places in Studio directly from the command line, configured via a shared TOML file.

Features

  • Simple CLI - Open places with rbx open <env> <place>
  • Interactive picker - Select environment and place with a menu if needed
  • Shared config - Uses the same rbxplace.toml as other rbx subcommands
  • Cross-platform - Works on Windows, macOS, and Linux
  • Zero dependencies - No API key needed, just launches Studio

Quick start

  1. Create or reuse rbxplace.toml
[prod]
universe_id = 9876543210
places.main = 123456789012345
places.lobby = 987654321

[dev]
universe_id = 9876543212
places.main = 345678901234567
  1. Open a place
# Interactive picker
rbx open

# Open a specific place
rbx open prod main

# Open the only place in an environment
rbx open staging

Usage

rbx open [ENV] [PLACE]

Arguments

  • ENV - Environment name (e.g., prod, staging). Falls back to the global --env flag, then to an interactive picker.
  • PLACE - Place name within the environment (e.g., main, lobby). Falls back to the global --place flag, then to an interactive picker (auto-picks when the env has exactly one place).

Without a project

rbx open --place-id 123456789

--place-id skips rbxplace.toml entirely, so this works in any directory: a place you have not configured, or somebody else’s game you are helping with. It is the same global flag --universe-id is, and it wins over ENV / PLACE when both are given.

Worth having here more than anywhere: this command builds a roblox-studio: URI out of one number and makes no network call at all, so reading a config file to find that number was the only thing tying it to a project.

Configuration

rbx open reads from rbxplace.toml (override with the global --places <path>). Each top-level section is an environment:

[prod]
universe_id = 9876543210  # Required: Roblox universe ID
places.main = 123456789   # Optional: Map place names to place IDs
places.lobby = 987654321
confirm = true            # Optional: For compatibility with rbx place

[staging]
universe_id = 9876543211
places.main = 234567890

Behavior

  • No arguments - Interactive picker for environment, then place
  • Environment only - If environment has one place, opens directly; otherwise shows picker
  • Environment + place - Opens immediately
  • Unknown environment - Shows error with available options
  • --env all - Rejected; rbx open operates on one place at a time

How it works

rbx open constructs a roblox-studio: URI handler request:

roblox-studio:1+task:EditPlace+placeId:123456789+universeId:0

This is handled by your system’s Roblox Studio installation.

Prior art

ROpen (Luau, MPL-2.0) is where this command comes from: the launcher it was written against, and where the roblox-studio: URI dispatch was learned from, having contributed to it.

That dispatch is older than either of us. rojo-rbx/edit-roblox-place (Rust, MIT) was doing the same thing in August 2019 — one command, one place id, and the same roblox-studio:1+task:EditPlace+placeId:<id> this command sends. Nobody here knew of it until long after rbx open shipped; it is named because the honest version of “prior art” is not “whatever its author met first”.

What rbx open adds to either is the part that belongs to this tool: the place is named rather than numbered. rbx open prod main resolves through rbxplace.toml, so the id nobody remembers stays in the file that already holds it.

No code from either project is reused, and none is owed — see THIRD-PARTY-NOTICES.md.

rbx download

Download Roblox assets by id: images, audio, meshes, models, animations, places, and more.

Features

  • Two backends - Public assetdelivery endpoint by default; Open Cloud automatically when an API key is available
  • Version pinning - --version <n> fetches a specific asset version (Open Cloud)
  • Batch downloads - Pass several ids positionally or read them from a file with --file
  • Type-aware filenames - Asset type is resolved from economy metadata to pick the right extension (.png, .ogg, .rbxm, …)
  • Animation unwrapping - Animation wrappers are dereferenced to their KeyframeSequence (disable with --raw)

Quick start

# Download one asset into ./downloads
rbx download 123456789

# Several at once, into a chosen folder
rbx download 123 456 789 -o assets

# From a file (whitespace/comma separated, # comments allowed)
rbx download --file ids.txt

Usage

rbx download [IDS]... [OPTIONS]

Options

  • -f, --file <path> - Read additional asset ids from a file
  • -o, --output <dir> - Output directory (default: downloads)
  • --public - Force the public assetdelivery backend even when an API key is set
  • --type <id|alias> - Skip the economy metadata lookup by giving the asset type yourself. Aliases: image, audio, mesh, lua, place, model, animation, video, font
  • --version <n> - Pin a specific asset version (Open Cloud only; requires an API key and exactly one id)
  • --raw - Don’t dereference Animation wrappers to their KeyframeSequence

Backend selection

SituationBackend
No API keyPublic assetdelivery.roblox.com (optional Studio cookie, opt-in — see cookie.md)
--api-key / RBX_API_KEY setOpen Cloud asset-delivery-api
--version <n> givenOpen Cloud (requires an API key)
--public givenPublic, always

The public backend can fetch most publicly available assets without auth; a .ROBLOSECURITY cookie (--cookie, or a local Studio install once you have said yes to it) extends reach to assets your account can access. The Open Cloud backend uses your API key’s permissions and is the only one supporting --version.

Only the public backend ever sends the cookie, and it sends it to two hosts: assetdelivery for the bytes and economy for the asset type behind the filename. Pass --no-auto-cookie to fetch strictly what is public. docs/cookie.md has the trust model: resolution order, the stderr notice, and why the cookie is never written to disk.

Filenames

Files are saved as <id>_<sanitized name>.<ext> (or <id>.<ext> when the name is unavailable). The extension comes from the asset’s AssetTypeId via the economy details endpoint, unless --type was given, in which case no metadata lookup happens at all.

rbx completions

Generate a shell completion script. --env and --place complete with the names in the rbxplace.toml of whatever directory you are standing in, so a new env completes without regenerating anything.

rbx completions bash       -o ~/.local/share/bash-completion/completions/rbx
rbx completions zsh        -o "${fpath[1]}/_rbx"
rbx completions fish       -o ~/.config/fish/completions/rbx.fish
rbx completions powershell -o $PROFILE

-o is --output; without it the script goes to stdout, which is what you want for piping into a file yourself.

ShellWhere it goesThen
bash~/.local/share/bash-completion/completions/rbxNew shell, or source the file. Needs bash-completion installed
zshany directory on $fpath, named _rbxNew shell, or compinit. ${fpath[1]} is usually writable; if not, pick your own and add it to fpath in .zshrc before compinit
fish~/.config/fish/completions/rbx.fishPicked up immediately
powershellappended to $PROFILENew session, or . $PROFILE. Use >> rather than -o if the profile already has content

The values are not baked in

When you press TAB the script runs rbx env list --names or rbx env list --place-names in the current directory and offers what comes back. Those two listings are a supported surface for exactly this reason: one bare value per line, no colors, no headers, and they will not grow columns.

So a completion script generated once keeps working as rbxplace.toml changes, and it completes different names in different projects without knowing anything about either.

What it does when there is nothing to complete

Outside a project, or with a file that does not parse, both complete to nothing and print nothing. The completion discards the command’s stderr and ignores its exit status, so a broken rbxplace.toml never lands in the middle of a half-typed line.

One exception: bash 3.2, the version macOS ships, has no compopt and falls back to offering file names.

--place is completed across every env

Unless the file’s envs hold genuinely different places, in which case some of what is offered belongs to another env — the completion does not read the --env you already typed. Doing so would mean parsing the command line in four shell languages to save a case that most rbxplace.toml files do not have. The command you eventually run says so if the place is not in the env.

Turning the dynamic part off

rbx completions bash --no-dynamic

A script that never starts a subprocess, and completes --env with file names. Worth having where a completion must not run a program: a locked-down shell, or a machine where rbx is slow to start because it lives on a network drive.

See also

  • rbx env — the file these names come from, and the two listings the script calls

Working in a team

Every other page here describes one person driving one repository. This one is about two: two humans on diverged branches, or a human and a CI job, both running a sync against the same universe. What git shows you afterwards is a conflict in a lockfile, and how you resolve it decides whether the next sync updates a resource or creates a second one.

Read this before hand-resolving a rbxshop.lock.toml conflict. rbx shop has no delete verb, and neither does the Roblox surface it calls: the client exposes list, get, create and update for passes, badges and products, and nothing else. A duplicate game pass created by a bad merge cannot be removed by this tool. The best you get afterwards is for_sale = false (passes, products) or enabled = false (badges).

The files a second person can collide with

FileWritten byCommittedWhere conflicts come from
rbxshop.lock.tomlshop sync, shop pull, shop init --from-remote, shop rename, importyesa resource added or changed on both sides
rbxmeta.lock.tomlmeta sync, meta pull, meta init --from-remote, importyesthe same field patched on both sides, plus the thumbnail array
rbxconfig.lock.tomlconfig sync, config pull, importyesevery sync, unconditionally (see below)
rbxapikey.lock.tomlthe apikey verbsno, it stores secretsnot applicable once you have gitignored it, which apikey create refuses to proceed without
the codegen foldershop codegen, shop syncusuallynever worth merging; regenerate

rbx place has no lockfile at all, so nothing there can conflict.

Why conflicts are rarer than you would expect

Everything keyed in a lockfile is a BTreeMap, so it serializes in sorted key order: envs, then passes, badges and products within an env. Two people adding differently named resources write to different regions of the file and git merges both without asking. Empty maps and unset optional fields are skipped entirely rather than written as empty tables, so nothing churns just because a field went unused.

The lockfiles are written by the TOML serializer straight through std::fs::write, with no platform newline translation, so a Windows checkout and a Linux CI runner produce the same bytes for the same state. That holds only if git leaves them alone: a checkout with core.autocrlf=true and no .gitattributes hands the working tree CRLF, the next write puts LF back, and a one-line change arrives as a whole-file diff. * text=auto in the consuming repository’s .gitattributes settles it.

Two things are genuinely not stable across runs, and both are worth knowing before you read a diff:

  • rbxconfig.lock.toml rewrites synced_at and revision_id on every config sync and every config pull. synced_at is the wall clock at the moment of the write, revision_id is the configVersion Roblox handed back. Two branches that each synced the same env will always conflict on those two lines, whatever else they did. Those lines carry no decision: pick either side (the one from the later sync, if you can tell) and move on.
  • rbxmeta.lock.toml stores media.thumbnails as an ordered array, not a keyed table. Position is meaningful there because it mirrors the display order on Roblox. A textual merge of two arrays is positional and there is no key to align on, so do not hand-merge it. Take one side whole and let rbx meta pull restate it.

The rule that decides everything: a missing lock entry means “create”

rbx shop builds its plan by walking the resolved config and looking each key up in the lockfile’s env section. No entry for that key is the entire test for “this does not exist yet”, and the action it produces is Create, which is a bare POST to Roblox. Nothing looks for an existing remote resource with the same name first.

Roblox does not make display names unique. rbx shop pull carries a whole duplicate-name resolution path precisely because two passes called VIP in the same universe are a state Roblox is happy to be in. So a Create issued for a resource that already exists does not fail. It succeeds, and you now own two.

Three consequences:

  1. Never resolve a rbxshop.lock.toml conflict by deleting entries. Every entry you drop is one Create on the next sync.
  2. git checkout --ours / --theirs on the whole file is only safe when one side is a strict superset of the other. If each side recorded ids the other lacks, taking one side whole throws away real ids.
  3. Deleting the lockfile and re-running sync is the worst version of the same mistake. For rbx meta and rbx config that is merely wasteful (both re-send state they already had). For rbx shop it recreates every pass, badge and product in the config. Badge creation in particular is a billable operation on Roblox’s side: the create call carries an expectedCost, rbx shop sync exposes it as --badge-cost (default 0), and the scope the call needs is named manage-and-spend-robux. The command that rebuilds a shop lockfile from reality is rbx shop pull, not sync.

Recipe: git reports a conflict in rbxshop.lock.toml

Keep the union of resource entries, then let pull reconcile the values.

# 1. Look at what each side has, entry by entry.
git diff --diff-filter=U rbxshop.lock.toml

# 2. Resolve toward the superset: keep every [envs.<env>.passes.<key>],
#    [envs.<env>.badges.<key>] and [envs.<env>.products.<key>] table that
#    appears on either side. Where both sides have the same key with
#    different field values, either set of values is fine: pull overwrites
#    them from Roblox in the next step. What must survive is the `id`.
git add rbxshop.lock.toml

# 3. Ask Roblox what is actually there. Read it first.
rbx shop pull --env <name> --dry-run
rbx shop pull --env <name>

# 4. Confirm config and lockfile agree again.
rbx shop check --env <name>

rbx shop pull refetches every pass, badge and product in the universe and rebuilds that env’s lockfile section from the answer. It matches a remote resource to a config key by the id already recorded in your lockfile, and falls back to the resource’s display name for anything it does not recognise. It also writes what it found back into rbxshop.toml, as a base entry or an [envs.<name>.*] overlay. That last part is why step 3 has a --dry-run in front of it: pull is not a lockfile-only operation.

The trap in step 2, and why “keep the union” is not optional

Suppose the entry you dropped was for a resource whose config key differs from its display name, which is what setting name = "..." does:

[passes.VIPPass]
name = "VIP Access"
price = 499

With no lock entry, pull has no id to match on. It files the remote resource under its display name, VIP Access, and adds that as a new config entry. Your original VIPPass key still has no lock entry. The next rbx shop sync therefore creates a second pass, and now VIP Access and VIPPass are two paid products in the same universe.

When the config key and the display name happen to be identical, the fallback lands on the right key and pull does reconstruct the entry correctly. That is the common case, and it is exactly why the failure is easy to miss in testing and expensive in production. Do not lean on it.

Which side to keep, per file

FileKeepThen run
rbxshop.lock.tomlthe union of resource entries; never fewer ids than either side hadrbx shop pull --env <name> --dry-run, then without it
rbxmeta.lock.tomleither side whole (it records last-applied metadata, not ids you can lose)rbx meta sync --env <name> --dry-run to see what is now considered pending, or rbx meta pull --env <name> to take Roblox’s word
rbxconfig.lock.tomleither side; synced_at and revision_id are bookkeepingrbx config sync --env <name> --dry-run, which prints the live-versus-local diff before writing anything
the codegen folderneither; take either side to clear the markerrbx shop codegen, then rbx shop codegen --check and re-stage

Generated Luau never needs a semantic merge: rbx shop codegen and rbx env gen-module rebuild it offline from rbxshop.toml, rbxshop.lock.toml and rbxplace.toml. See Guarding generated files.

check is offline for shop and meta, and that changes what green means

Of the checks rbx check runs, only config/live talks to Roblox. shop/lockfile, shop/codegen and meta/lockfile compare committed files against committed files, and rbx shop check and rbx meta check do the same on their own.

So after a bad merge, a clean rbx check means “the config and the lockfile agree with each other”. It does not mean the lockfile agrees with Roblox, and it cannot: it never asked. A lockfile that lost an entry and a config that still declares the resource will not report clean (the resource shows as 1 to create), but a lockfile that lost both the lock entry and the config entry reports perfectly clean while the resource sits unmanaged in the universe.

The commands that compare against Roblox are rbx shop pull --dry-run, rbx meta pull --dry-run and rbx config check. Treat the first two as the audit step after any lockfile merge you were not certain about.

Is concurrent sync safe?

One statement per tool, from what the code does rather than what would be nice.

rbx shop sync: unsafe for creates, benign for updates

The whole workspace is sequential by design (see ARCHITECTURE.md), so there is no concurrency inside one run. Two runs against the same universe are a different matter.

  • Two runs that both plan Create for the same key produce two remote resources. This is the failure that matters. There is no pre-flight existence check, no uniqueness constraint on Roblox’s side, and no way to delete the loser afterwards.
  • Updates are addressed by the id in the lockfile, so they cannot duplicate anything. Two concurrent updates to the same resource are last-writer-wins per call: whichever update_game_pass lands second is the state you keep.
  • Icon upload is not a read-modify-write. The hash is taken from the local file and the new asset id comes back in the response, so concurrent uploads race on the result, not on a counter. Nothing is corrupted; the later upload wins.
  • The lockfile is saved after every single resource, not once at the end, so a run that dies halfway keeps the ids of what it already created. That is crash safety, not concurrency safety: it makes an interrupted run resumable, it does not stop a second run from creating duplicates.
  • rbx shop sync does not refuse when the lockfile’s recorded universe_id disagrees with the env it resolved. It overwrites the recorded value. rbx shop check prints the mismatch, and rbx meta sync and rbx config sync refuse outright, so shop is the odd one out here. After a merge that could have mixed env sections, run rbx shop check before rbx shop sync.

rbx meta sync: last-writer-wins, no duplication

Metadata is a set of PATCH calls against one universe and one place, so two concurrent runs interleave field writes and the last call wins per field. Nothing is created and nothing gets an id, so nothing duplicates. The lockfile is saved after every successful call, and the visibility ordering rule (public first, private last) is enforced within a run.

Thumbnails are the exception worth flagging: a sync issues deletes, then uploads, then a single reorder built from the ids its own lockfile now holds. A concurrent run’s uploads are not in that list. Whether Roblox treats a reorder list that omits existing thumbnails as a reorder or as a truncation is not something this repository establishes, so treat concurrent meta sync runs that both touch thumbnails as unverified rather than safe, and run them one at a time.

rbx config sync: last-writer-wins over the whole document

rbx config sync calls the API’s overwrite-and-publish operation with the full local entry set. It fetches the live config first, but only to print the diff for your approval: nothing compares the lockfile’s revision_id against the live one, and nothing refuses on a mismatch. The revision_id in rbxconfig.lock.toml is written and never read back as a precondition.

The practical consequence: if you sync from a branch that does not have your colleague’s entries, those entries are removed from the live config, silently, because the publish replaces the document rather than merging into it.

Recovery exists and is the reason this is a warning rather than a prohibition: rbx config versions lists past revisions and rbx config rollback restores one. Reach for rbx config pull first if what you want is to bring the branch up to date instead.

rbx apikey

rbxapikey.lock.toml holds key secrets and belongs in your .gitignorerbx apikey create refuses to create a key whose secret would land in a file git is not ignoring — so it does not take part in merges at all. It is also the one lockfile outside the shared version-and-migrate machinery: a version mismatch is refused with “delete the file and re-run” rather than migrated.

Its own guard against a different kind of concurrency already exists: each create, update or regenerate records the resolved universes, and a later run that resolves a different set refuses to proceed. See docs/apikey.md.

Concurrent apikey runs against the same Roblox account were not analysed for this page. Treat them as unverified.

Is there any locking or detection?

No. There is no lock server, no lease, no advisory file lock, and no If-Match style precondition on any write in the reconciling tools. Nothing notices that the lockfile on disk changed while a command was running, and nothing notices that Roblox moved under it.

Whether that stays true is a separate question this page does not answer. What is true today is that the only mitigation the tool offers is the one you apply yourself, which is what the conventions below are.

Habits that keep this from happening

One writer at a time, per env. The cheapest fix by a distance. Everything below is a way of making that easier to hold.

Not “CI owns prod, humans own dev”, which is the version of this rule that does not survive contact with rbx shop. Creating a pass, a badge or a product is a Create Roblox cannot undo, badge creation spends Robux, and the next habit on this list says creates are the reviewed step. A reviewed irreversible purchase is a human act, so the human does sync prod. rbx meta says the same from the other direction: visibility, allow_copying, studio_access_to_apis_allowed and server_fill have no Open Cloud endpoint, so CI cannot write them without a session cookie on the runner, which docs/cookie.md spends a page arguing against.

The distinction that does hold is create versus update. Updates are addressed by id, are reversible by editing the TOML and syncing again, and are the right thing to automate: let CI own them, on every env. Creates are none of those things and belong to a person who read the --dry-run output. Arrange who runs what around that, and when a human does sync prod, make sure nobody else is doing it that hour.

Pull before you sync, and commit the lockfile with the change that caused it. A lockfile change is not incidental noise to be swept into a later commit: it is the record of what the sync did. In the same commit as the rbxshop.toml edit, it reviews as one intention. Committed separately, it is a mystery diff.

Make creates the reviewed step. sync prints N to create, M to update before it does anything, and --dry-run prints it without doing anything. An update is reversible by editing the TOML and syncing again. A create is not. Put rbx shop sync --dry-run in the pull request, not just in the terminal of whoever runs it.

Do not run --env all from two places at once. rbx shop sync --env all walks every env in rbxplace.toml sequentially, holding one lockfile in memory across all of them and saving as it goes. Two such runs multiply every per-env hazard above by the number of envs. (rbx meta and rbx config act on one env per invocation and do not accept --env all at all.)

Never hand-edit a lockfile outside a conflict resolution. They are tool-owned state. The values a human can safely retype are none of them, and the id fields are the ones a typo makes unrecoverable.

Let CI run rbx check on every pull request, and a pull --dry-run on a schedule. rbx check --offline catches config-versus-lockfile drift with no credentials and is fast enough for a pre-commit hook. It does not catch lockfile-versus-Roblox drift, which is what a nightly rbx shop pull --env all --dry-run is for. See docs/check.md.

The Studio cookie

.ROBLOSECURITY is the one credential rbx uses that is not an Open Cloud API key. This page is the single description of what the tool does with it: which commands send it, which commands will never send it, how one gets resolved, and why it is never written anywhere. Every other page that mentions the cookie links here instead of restating it.

Why it gets its own page

Roblox binds an API key to its scopes and its universes at creation, so the worst a leaked key can do is exactly what its scope list says, and you can delete that one key without touching anything else. A .ROBLOSECURITY cookie is a full account session: unscoped, good for everything the account can do including the things rbx has no commands for, and revocable only by signing out everywhere, which kills all your other sessions at the same time.

That asymmetry is the reason the rest of the toolkit is arranged the way it is. Keys are preferred wherever Roblox offers the endpoint, cookie auth is refused outright for anything that touches live players, and an auto-detected cookie announces itself instead of being discovered later.

What it is used for

Only the endpoints Open Cloud does not cover. The table is every place in the codebase that attaches a Cookie: .ROBLOSECURITY=... header.

CommandWhy the cookieRequired?
rbx init create-group, create-universe, create-place, rename-place, rename-universeOpen Cloud can read groups, universes and places but cannot create or rename them. These go to groups.roblox.com and develop.roblox.com, which authenticate a person.required
rbx init list-groupsThe listing is “the groups you are in”, which is a question only a session can answer. It resolves the signed-in account through users.roblox.com first.required
rbx init list-universes, list-placesNothing. Both listings answer in full without a credential, private universes included, so the cookie is accepted and changes no result. See what these listings are not.never needed
rbx meta init, rbx meta pullReads the fields Open Cloud does not expose (server_fill, allow_copying, studio_access_to_apis_allowed, beta_mode) from develop.roblox.com and the experience-releases endpoint. Without a cookie those fields are skipped and reported as skipped, never guessed.optional
rbx meta syncWrites those same fields, plus visibility flips (activate and deactivate). Everything else meta writes goes through Open Cloud with the API key.required for those fields
rbx meta icon and thumbnail readsThe public thumbnails service answers either way; the cookie is attached when one is available.optional
rbx apikey create, update, regenerate, delete, prune, list --remote, status --remote, can-manageRoblox’s key administration endpoints authenticate a person, not a key. Asking a key to describe or create keys is circular: a key only ever covers the universes it was bound to.required
rbx download on the public backendThe legacy assetdelivery and economy endpoints answer for public assets without auth, and reach what your account can see with the cookie attached. The Open Cloud backend (any run with an API key, and every --version run) sends the key and no cookie.optional
rbx importNothing, for its own place listing: it goes to develop.roblox.com, which answers without a credential. The meta step it runs afterwards is where a cookie matters.never needed for the listing
rbx doctorReports which source the cookie came from and whether Roblox still accepts it, then reuses the rbx apikey calls above to identify the loaded key and read its scopes. Without a cookie those checks are marked skipped with the reason, not passed.optional

Two details the table would otherwise flatten:

  • rbx import does not auto-detect for its own call. It passes --cookie / RBX_COOKIE straight through to the place listing. That used to be described here as a limitation; it is not one, because the listing answers without a credential either way. The meta step that import runs afterwards is an ordinary rbx meta invocation and does resolve the cookie normally, auto-detection included.
  • rbx apikey introspect resolves a cookie it does not send. It shares the client constructor with the rest of rbx apikey, but the introspect endpoint authenticates with the key secret itself. So the command can print the auto-detection notice without a cookie ever leaving the process.

This page is about a credential, so it is worth being exact about one thing it is not the gate for. Two of Roblox’s listings are open, and this page used to say otherwise.

Measured against a private universe that has never had a player, with no cookie, no API key and no session at all:

GET develop.roblox.com/v1/universes/{id}/places  → 200, every place, with names
GET games.roblox.com/v2/groups/{id}/gamesV2      → 200, every game

On that second endpoint accessFilter=2 is the public filter and returned zero for the group measured; accessFilter=1, and omitting the parameter, returned four. Unfiltered is unfiltered for everybody.

So the existence, id and name of a universe or place are public information, whatever the experience’s visibility says. A cookie does not reveal them and withholding one does not conceal them. What stays behind a session is the content: develop.roblox.com/v1/places/{id} answers 404 anonymously, and nothing in these listings says whether a place is playable.

The reason this belongs on the trust-model page rather than only on docs/init.md: the table above once described the cookie as what “reveals the private ones your account can see”, and a reader could reasonably have concluded that not passing one kept an unreleased project quiet. It never did. If a name would be a problem to publish, the answer is to change the name, not to withhold a credential.

What it is never used for

No live operation. servers, analytics, ban, restart, data, memorystore, publish, ads and probe take an API key and nothing else. There is no cookie path in any of them to fall back to. Those are the commands that act on players and player data, and keeping them key-only is what makes the scope list on the key the audit trail: a read key cannot ban anybody, whatever calls it. See the safety model.

Never as a fallback for a missing key. place, config, shop, check, env and open are key-only or fully offline. No Open Cloud call in the toolkit is retried with the cookie when the key is rejected: a 403 from Open Cloud means the key is wrong, and answering it by escalating to a full account session would defeat the point of having scoped the key at all.

Never for anything Open Cloud covers. Every cookie call above exists because Roblox publishes no Open Cloud equivalent. When one appears, the cookie call is the thing to delete.

GlobalFlags::resolve_cookie in rbx-core is the only resolver, and it is consulted in this order:

  1. --cookie <value> or RBX_COOKIE. Explicit, and highest priority.
  2. RBXAPIKEY_COOKIE. The one per-tool variable that survived rbx apikey becoming a subcommand. Explicit too, so it beats auto-detection.
  3. --no-auto-cookie. If set, resolution stops here with no cookie.
  4. Auto-detection from a local Roblox Studio install (Windows registry, macOS plist), via the rbx_cookie crate. What it finds is a candidate, not yet a credential: see auto-detection is opt-in.

Four points where the order is load-bearing rather than arbitrary:

  • Steps 1 and 2 come before the --no-auto-cookie check, deliberately. The flag governs auto-detection, which is the only step you did not ask for. It was never meant to suppress a variable you set on purpose.
  • RBXAPIKEY_COOKIE beats auto-detection. It used to lose, because rbx apikey only reached it after the shared resolver returned nothing, which on any machine with Studio installed never happened. A variable set on purpose was silently overridden by a cookie nobody asked for.
  • An empty RBX_COOKIE= is an answer, not an absence. It counts as explicit and stops the Studio lookup. A command that genuinely needs a cookie will then send the empty one and be refused by Roblox, rather than stopping locally with the “no cookie” message. An empty RBXAPIKEY_COOKIE is treated as unset instead: it has no flag to spell “no cookie” with, so an empty one is more likely a leftover in a shell profile than an instruction.
  • The Studio lookup happens in exactly one place. It used to happen in two, and the second site did not know about --no-auto-cookie, so every rbx apikey subcommand read the session cookie with no working way to refuse. An escape hatch that silently does nothing is worse than no escape hatch, because you believe you opted out. Two lookup sites are what made that divergence possible; one cannot diverge from itself.

Auto-detection is opt-in

Finding a signed-in Studio on the machine is not the same as being allowed to send its session. So step 4 produces a candidate, and something has to say yes before it is sent:

The runWhat happens
--auto-cookie passedSent. The standing yes, for a person who has decided once.
A terminal on all three streamsAsked, once, and the answer is remembered for the process.
Anything else — CI, a pipe, a cron job, --json into a fileNot sent, with one line on stderr naming the two ways forward.

The question is drawn on stderr, never stdout, so it cannot land inside a document a command is emitting:

Roblox Studio is signed in on this machine. Send that session cookie? It is a full-account credential, more powerful than any API key. [y/N]

Once per process rather than once per call, because a command that builds three clients would otherwise ask three times for one decision. Anything other than y or yes is a no, including an answer that could not be read at all: an unanswerable question is not consent.

A run with nowhere to ask says so rather than failing silently:

a Roblox Studio session is signed in on this machine and was not used: sending it needs --auto-cookie, or set RBX_COOKIE to a value you chose. Nothing was sent.

This is the property worth having in CI. A runner that happens to have a developer’s Studio profile on it cannot reach into that session by accident, whatever the job does. --no-auto-cookie remains the standing no, and clap refuses the two flags together, so a stray --auto-cookie in one invocation cannot quietly undo a --no-auto-cookie set in a profile.

Auto-detection announces itself

Once the answer is yes, the first cookie produced by step 4 puts one line on stderr:

using the Roblox Studio cookie (--auto-cookie to stop asking; --no-auto-cookie or RBX_COOKIE= to refuse)

It names the yes as well as the two noes, and that is not symmetry for its own sake. Detection is opt-in, so the person reading this line has just been asked and answered; they are exactly who --auto-cookie exists for, and this sentence is the only place they would learn it exists. A notice that lists only the ways to refuse teaches half the control.

Once per process, not once per call, because a single command can build two or three clients and three identical notices read like three separate reads of the credential. Commands that only used the API key print nothing, so the line appearing at all is the signal.

The username is deliberately not in the sentence. Naming it would mean a network round trip to users.roblox.com on every command that touches a cookie, which is a real cost for a nicer sentence, and the notice is printed at resolution — before anything has decided whether this run will talk to Roblox at all. The commands that are about to write with the cookie do make that call, one line later (see what is checked); rbx doctor and rbx apikey list --remote will name the account when you want to know.

Both controls in that sentence work, and both are tested: --no-auto-cookie skips the lookup, and RBX_COOKIE= counts as explicit. In CI the line never appears at all, because a run with nowhere to ask does not reach it. Set RBX_COOKIE there only for the few commands that need it, from a secret store, and prefer arranging the pipeline so that none of them do.

rbx doctor reports which of these you are in: explicit, auto-detected (a !, not a ), or absent.

What is checked, and when

Resolution itself still answers only “is there a cookie”. The check is a separate step, and it happens where an unusable cookie would otherwise do damage: one users.roblox.com/v1/users/authenticated call before the first write that needs the cookie. An expired session becomes a refusal that changes nothing, instead of a failure partway through (#63).

These commands check, because these are the ones that write with it:

CommandWhat the check prevents
rbx meta sync, when the plan contains at least one cookie-only fieldThe Open Cloud half landing and the legacy half not. A plan with nothing cookie-only skips the check: it writes only through Open Cloud.
rbx init create-groupPaying 100 Robux for a call that is about to be refused.
rbx init create-universe, create-placeAn irreversible creation followed by a rename that fails, leaving a resource named after nothing.
rbx init rename-place, rename-universeA read that answers for a public resource being mistaken for proof the rename will be accepted.
rbx apikey create, pruneNothing extra: both already ask who the session belongs to for their own reasons, and that is now the same cached call.
rbx apikey update, regenerate, delete--all walking the list and stopping halfway, with some keys rotated or deleted and the rest not.

These do not check, and pay no round trip for it: rbx meta init, meta pull, meta check, every rbx init list-*, rbx apikey list, status, can-manage, introspect, resolve, rbx download, rbx import. They read, or they attach the cookie only to reveal more of a read, so a refusal costs an output that did not print and leaves nothing behind. rbx doctor does check, and reports it as a line of its own, because “is my cookie still good” is the question it exists to answer.

Four rules the check follows:

  • Once per run. The verdict is cached for the life of the process, keyed on the cookie. A meta sync that checks before the prompt and again before the first write asks Roblox once, and apikey create gets its creator id out of the same answer.
  • No answer is not a refusal. Being offline, a 5xx, a rate limit or anything else that is not a flat rejection leaves the check unanswered: the run prints one warning line and carries on, and the calls themselves report the network for what it is. Turning an unreachable host into “your session expired, sign in again” sends you to re-authenticate a session that was fine.
  • The message names the state and the way out. A refusal says the session expired and how to renew it, in both directions (sign in to Studio again, or supply a fresh --cookie / RBX_COOKIE), rather than quoting a status code you cannot act on.
  • An empty cookie is answered locally. RBX_COOKIE= is a deliberate “no cookie”, so a command that needs one says exactly that instead of sending an empty value and reporting the refusal as an expired session. No request is made.

The confirmation names the account

Every cookie-authenticated write asks its question as somebody:

⚠ As builderman (156) — create universe 'My Game' under group 1234567 and record it as [test]? [y/N]

It costs nothing. The check above has just identified the account, the verdict is cached for the process, and the prompt reads it back rather than asking again. A run with no cookie, or one whose check could not answer, prompts exactly as it did before: the tool never claims an identity nothing established.

This is the cheap half of the identity problem below. Auto-detection follows whichever account Studio is signed into, silently, and the same key names recur across accounts — so “wrong account” is the realistic mistake here, and a question about a group id or a key name cannot catch it. Naming the account turns it into something a person can answer.

It also moved the session check ahead of the prompt everywhere, which is worth having on its own: being asked to approve an irreversible creation and only then learning the session was dead wastes the decision.

Two things are still not checked:

  • Shape. Beyond empty, the value is taken as given; the only normalisation is prefixing .ROBLOSECURITY= when the raw value does not already carry it. The check sends the same header the later calls will send, so a value Roblox reads as nonsense comes back as a refusal rather than as a local guess about its format.
  • Identity. Signing into a different account in Studio changes which account auto-detection finds, silently and for every later command. The check names the account in rbx doctor, but nothing cross-checks that it is the account that owns the key in RBX_API_KEY, so that mismatch still reads as a permission error on the resource rather than as “wrong account”.

It is never written to disk

rbx reads the cookie from one of the four sources above, holds it in memory for the life of the process, and sends it as a Cookie header to the Roblox host of the call that needs it. Nothing else happens to it. Specifically:

  • No file the tool writes ever receives it. What rbx writes is rbxplace.toml, the per-tool config and lockfiles, generated Luau modules, downloaded assets, shell completions, and (for rbx apikey) key secrets into the secret backend you configured. None of those code paths has the cookie in scope, and none of the config formats has a field it could land in. The config files are meant to be committed, which is the reason to keep it that way.
  • Nothing logs it. The workspace has no logging framework, no debug dump of request headers, and the only cookie-related line printed anywhere is the fixed notice above, which contains no value.
  • It stays out of --help. --cookie and --api-key are declared so that clap prints the flag without the current value of the environment variable behind it. Without that, every pasted help output, CI log and screenshot of a help page would carry the credential.
  • No cache, no keychain write, no session file. The tool has no cookie store of its own to populate, and nothing sends the cookie to any host other than the Roblox endpoint of the call being made.

The deliberate contrast is API key secrets, which are written to disk, because that is the point of them: to the secret backend rbxapikey.toml names, with rbxapikey.lock.toml to be gitignored for exactly that reason, which rbx apikey create refuses to proceed without. A key you can write down and rotate per project is the credential this toolkit wants you to be using. See docs/apikey.md.

Turning it off

rbx meta pull --env prod --no-auto-cookie   # skip the Studio lookup for one run
export RBX_COOKIE=                          # skip it for the whole shell

Either way, the commands in the table above that are marked required will fail, and the error names the three ways to supply one rather than leaving you with whatever Roblox said about an unauthenticated request. The ones marked optional carry on and report what they could not read, which is the outcome to prefer: an import or a pull that says “these fields need a cookie” is recoverable, one that silently records defaults is not.

  • docs/ops.md - the API key posture the cookie is the exception to, and the scope table per subcommand
  • docs/apikey.md - the key workflow, and why key administration is cookie-authenticated
  • docs/init.md - the creation commands, per-command cookie requirements
  • docs/meta.md - the exact list of cookie-only metadata fields
  • docs/import.md - what an import without a cookie leaves unset
  • docs/download.md - backend selection, and which one sends what
  • docs/doctor.md - the credential report, cookie source included