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
| Command | What it does |
|---|---|
init | Create the group, universe and places, and write them into rbxplace.toml |
import | Adopt a universe that already exists: every config and lockfile, from what is live |
env | Read rbxplace.toml: list envs, print one id, generate a module for game code |
apikey | Declare Open Cloud keys and their scopes, create and rotate them |
doctor | Prove the loaded key works, with one real read rather than a syntax check |
check | Every configured tool’s check in one pass, one exit code. status is the same engine for a human |
place | Place files: upload, download, promote between envs, roll back |
meta | Universe and place metadata: name, icon, thumbnails, devices, visibility |
config | The live in-experience config, with revisions and rollback |
shop | Game passes, badges and developer products, with typed Luau codegen |
servers | Live. Servers up now, how the stopped ones ended, and what a crashed one logged |
analytics | Live. Players, retention, revenue per payer. CSV for charting elsewhere |
ban | Live. Inspect and change player restrictions |
restart | Live. Forecast and launch a rolling server restart |
data | Live. Read, overwrite, copy and recover one data store entry |
memorystore | Live. Write cache values servers read through MemoryStoreService |
message | Live. Push a MessagingService message to every running server |
ads | Live. Launch and steer ad campaigns. Spends money, reads no results |
probe | Live. A raw authenticated request to any Open Cloud path |
open | Launch Studio at a place, by name or by id |
download | Fetch a Roblox asset by id |
completions | Shell 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 initcreates the group, universe and places, and writes therbxplace.tomlthat everything else reads. - A universe that already exists.
rbx importadopts 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 checkruns every configured tool’s check in one pass with one aggregated exit code. That page is also whererbx status, the same engine for a human rather than a pipeline, is documented. - Shipping a build.
rbx placeuploads, downloads, promotes between envs and rolls back to a past version. - Everything that is not the place file.
rbx metafor universe and place metadata,rbx configfor in-experience live configs,rbx shopfor 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,
--applyto write,--env allrefused. - 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.tomlcreates 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
.ROBLOSECURITYof 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-cookieis 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).
| Flag | Required | Description |
|---|---|---|
--name | Yes | Group display name |
--icon | Yes | Path to a PNG/JPEG icon file |
--description | No | Group description (default: empty) |
--public | No | Make the group publicly joinable (default: invite-only) |
--yes / -y | No | Skip 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 initprints 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.
| Flag | Required | Description |
|---|---|---|
--group | No | Group ID to create the universe under |
--user | No | User ID to create the universe under. Mutually exclusive with --group |
--template-place-id | No | Template place ID to clone from (defaults to Roblox’s empty baseplate) |
--name | No | Rename the universe’s root place to this name after creation (Roblox displays the root place name as the universe name). Prompted for when omitted |
--env | No | Env name to record the universe as. Prompted for when omitted. Refused with --no-record |
--place | No | Place key for the root place (default main). Refused with --no-record |
--no-record | No | Don’t touch rbxplace.toml. Refused alongside --env or --place, which would be asking for the record and refusing it in one command |
--yes / -y | No | Skip 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 run | Recorded? |
|---|---|
--no-record | Never, whatever else is passed |
--env <name> given | Yes, 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 present | Yes, after asking for the env name |
neither, under --yes, off a terminal, or with no rbxplace.toml | No, 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.
| Flag | Required | Description |
|---|---|---|
--universe-id | Yes | Universe ID to create the place in |
--template-place-id | No | Template place ID to clone from (defaults to Roblox’s empty baseplate) |
--name | No | Rename the new place to this name after creation. Prompted for when omitted |
--place | No | Place key to record in rbxplace.toml. Prompted for when omitted (suggested from --name). Refused with --no-record |
--no-record | No | Don’t touch rbxplace.toml. Refused alongside --env or --place |
--yes / -y | No | Skip 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.
| Flag | Required | Description |
|---|---|---|
--place | Yes | Place ID to rename, not a place name |
--name | Yes | New display name |
--yes / -y | No | Skip the confirmation prompt |
--placemeans something different here than anywhere else inrbx. Everywhere else it is a key from[<env>.places]; on this one subcommand it shadows that with a raw place id, so--place lobbyfails 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.
| Flag | Required | Description |
|---|---|---|
--universe-id | Yes | Universe ID |
--name | Yes | New display name |
--yes / -y | No | Skip 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.
| Flag | Required | Description |
|---|---|---|
--group | Yes | Group ID |
rbx init list-places
List every place inside a universe.
| Flag | Required | Description |
|---|---|---|
--universe-id | Yes | Universe 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.
| Command | Cookie required? |
|---|---|
create-group | Yes |
create-universe | Yes |
create-place | Yes |
rename-place | Yes |
rename-universe | Yes |
list-groups | Yes |
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:
| Request | Games returned |
|---|---|
accessFilter=2 | 0 |
accessFilter=1 | 4 |
no accessFilter | 4 |
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/createwith atemplatePlaceId. - 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=2is the public-only filter;1is 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
syncandcheckexpect - Safe on an existing file - Other envs,
[owner],[codegen]and every comment inrbxplace.tomlsurvive an import verbatim - Repeatable - A second
--envlayers 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
| File | Written by | Contains |
|---|---|---|
rbxplace.toml | import itself | [<env>] with universe_id and every place, plus [owner] if the file had none |
rbxshop.toml + .lock | rbx shop init --from-remote / rbx shop pull | Game passes, badges, developer products, with icons downloaded |
rbxmeta.toml + .lock | rbx meta init --from-remote then rbx meta pull --accept-remote | Name, description, devices, social links, private servers, icon and thumbnails |
rbxconfig.toml + .lock | rbx config pull | The 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]torbxplace.toml;[prod],[owner],[codegen]and every comment are untouched, byte for byte; - pulls rather than re-initialises each domain, so
rbxshop.tomlgains 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_copyingandbeta_modehave no Open Cloud endpoint.rbx metamodels them andsynccan 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 themetastep resolves, not what you typed: it auto-detects like any otherrbx metarun, so on a machine with Studio signed in these fields are read and this line does not appear. Without that line, the firstmeta syncafter 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.
--strictinverts that, which is what you want in CI.
Flags
| Flag | Default | Meaning |
|---|---|---|
--universe-id <id> | required | The universe to adopt |
--env <name> | required | Name for its env in rbxplace.toml. all, owner and codegen are reserved and refused |
--dir <path> | . | Where to write the config files |
--dry-run | off | Resolve and report; write nothing |
--strict | off | Fail on a domain error instead of skipping and reporting it |
--only <domains> | all | Comma-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:
| Step | Scopes |
|---|---|
| Resolve the universe | universe:read |
| List places | none — the legacy develop host answers without any credential |
| Shop | game-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 |
| Meta | universe:read, universe.place:read. Icons and thumbnails need nothing: pull reads them from thumbnails.roblox.com, the public service, with no key attached |
| Config | universe: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.
Related
- docs/place.md — the
rbxplace.tomlthis 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
--checkto prove the committed copy was never hand-edited - JSON -
--jsononlistandgetwrites one document to stdout and nothing else, with documented field names, forjqand 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 prodprints the exact idrbx place upload --env prodwould 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
| Flag | Description |
|---|---|
--names | Print env names only, one per line, no colors — for scripts and completion helpers |
--place-names | Print place names only, one per line. Every env’s, deduplicated, unless --env narrows it |
--json | Write 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 }
}
]
}
| Field | Type | Meaning |
|---|---|---|
schema_version | integer | Document format, shared with rbx check --json. 1 today. Refuse a version you do not understand |
places_file | string | The rbxplace.toml this was read from, as given or defaulted |
owner | object | The top-level [owner]. Absent when the file sets none |
owner.type / .id | string / integer | user or group, and the id |
envs | array of objects | One per env, in name order. Narrowed to one entry by --env <name> |
envs[].name | string | The section name — what --env takes |
envs[].universe_id | integer | universe_id of the env |
envs[].env | string | The env rename. Absent when unset, in which case name is the answer |
envs[].owner | object | The per-env [<env>.owner] override. Absent when the env inherits the top-level one |
envs[].confirm | boolean | Whether writes to this env prompt first |
envs[].codegen | boolean | Whether rbx env gen-module emits this env |
envs[].places | object | Place 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
| Field | Value | Needs --env |
|---|---|---|
universe-id | universe_id of the env | yes |
place-id | Place id from [<env>.places], honoring --place | yes |
owner-id | Owner id: [<env>.owner] if set, else top-level [owner] | no |
owner-type | user or group, resolved the same way | no |
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" }
]
}
| Field | Type | Meaning |
|---|---|---|
schema_version | integer | Document format, shared with rbx check --json. 1 today |
field | string | The field asked for, in its canonical spelling: universe-id, place-id, owner-id, owner-type — the alias you typed is normalized |
value | string | The answer, when one env was targeted. Absent under --env all |
results | array of objects | One entry per env answered, in the same order the human form prints. Always present |
results[].env | string | The env. Absent when the lookup needed none — owner-id and owner-type answer from the top-level [owner] without --env |
results[].value | string | The 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
| Flag | Description |
|---|---|
--out | Output file path. Format inferred from the extension: .lua, .luau, .json, or .ts. Optional when [codegen].output is set |
--check | Compare 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
| Flag | Description |
|---|---|
--dry-run | List what would be removed without writing anything |
-y, --yes | Skip 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:
| File | What goes |
|---|---|
rbxplace.toml | The [<env>] block |
rbxmeta.toml | The [envs.<env>] overlay |
rbxmeta.lock.toml | The [envs.<env>] section |
rbxshop.toml | The [envs.<env>] overlay |
rbxshop.lock.toml | The [envs.<env>] section |
rbxconfig.lock.toml | The [envs.<env>] section |
rbxapikey.lock.toml | The [envs.<env>] section |
rbxapikey.toml | The env’s name, out of every list that holds it |
<codegen.output>/<env>.luau | The 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
| Table | Field | Type | Default | Meaning |
|---|---|---|---|---|
[owner] | type | "user" | "group" | — | Who owns the project. Tools without their own owner field fall back to this |
[owner] | id | integer | — | The user or group id |
[codegen] | output | path | — | Where rbx env gen-module writes, relative to this file. Omit and --out becomes required |
Env fields
| Field | Type | Default | Meaning |
|---|---|---|---|
universe_id | integer | required | The universe this env targets |
places | table | {} | Place name → place id. main is the default when --place is omitted |
confirm | bool | false | Prompt before write operations on this env (upload, sync, rollback, promote) |
env | string | the section name | What game code matches on. A rename, not an alias: two envs resolving to the same name is an error |
owner | table | the top-level [owner] | Per-env owner override, for the rare env living under a different account |
codegen | bool | true | false 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,
envoverrides included.
See also
rbx init— create the universes and places this file points atrbx place— upload, download, and promote place files across these envsrbx 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.tomlwith 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 -
statuscommand 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 datesrbx 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:
- Define keys in
rbxapikey.toml - Run
rbx apikey create <key>to generate the key on Roblox and save the secret - Later, edit
rbxapikey.tomland runrbx 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 line | deploy_ci |
| its display name on Roblox | mygame_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"]widensdeploy_cion the nextupdate, which is an ordinary change surfaced by drift detection. - Renaming a group renames a key.
ci→buildmakesdeploy_cidisappear from the config anddeploy_buildappear.statusreports the first asORPHAN_LOCKand the second asPENDING, 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 insecret_fileanddefault_secret_filefor 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_filewas given a literal path with no placeholder in it. The lastcreatewould 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 totrue)default_expiration_months- (optional) Default months until key expiry; omit to allow keys without expirydefault_allowed_cidrs- Default IP CIDR blocks for keys that set noallowed_cidrsof their own. Not optional in practice: with no allowlist from either place,createandupdaterefuse and name the three ways out — set it here, setallowed_cidrson the key, or pass--no-ipto allow every address. Nothing is inferred; neither this tool nor Roblox fills in your address for youdefault_envs- (optional) Default env list used when a key has noenvsfield of its own. Each name must exist inrbxplace.toml.name_prefix- (optional) Prepended verbatim to every key’s display name on Roblox. You control the separator: usemygame_for underscore,mygame-for dash, etc. Useful when the samerbxapikey.tomlis 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 thanreadorlist. See Read-only by declarationdefault_secret_file- (optional) Path template used when a key has no explicitsecret_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}.envmakes[keys.deploy]write to.secrets/deploy.envby default, and a fan-out ofdeploywrite to.secrets/deploy_ci.envand.secrets/deploy_prod.env. Explicitsecret_fileon 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 omittedenvs- Either a list of env names fromrbxplace.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 tosettings.default_envswhen omitted or empty; the table form always names its envs.group_ids- (optional) List of group IDs for group-target scopesuser_ids- (optional) List of user IDs for user-target scopesscopes- 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/falseto enable/disable the key (defaults tosettings.default_enabled)expiration_months- (optional) Months until key expiresexpiration_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 expireallowed_cidrs- (optional) List of IP CIDR blocks allowed to use this key (defaults tosettings.default_allowed_cidrs)secret_file- (optional) Custom file path to store the secret (defaults to lockfile). Templated likedefault_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-list — read 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:
- Edit the TOML - Add/remove/modify keys and their settings
- 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-managesaid norbx apikey createsucceeded, 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:
| Command | Question | Source |
|---|---|---|
list | What does this project declare? | rbxapikey.toml + lockfile |
status --remote | Is what this project declares still there? | one GET per lockfile entry |
list --remote | What 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
viewerwhile Roblox calls itmyproject_viewer(name_prefixdoes 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 --allis bounded by your lockfile; aprune --allwould not be. - Selecting a tracked key routes through the ordinary
deletepath, 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-runprints 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.
The listing is scoped to the active cookie
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 (getApiKeys → v1ApiKeysPost) 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 --jsonhas 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 --jsoncarries no path to the secret file. The human form printsset (file: .secrets/deploy.env)because the person reading it is standing in that directory. The document sayssecret_backend(lockfileorfile) andsecret_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 --jsoncarries 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 --jsoncarries nocloud_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 printid:for the keys this project created, so the local document carriesid.
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_KEYis 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
GETagainst 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
| Symbol | Meaning |
|---|---|
✓ | 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, tohttps://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 ✗:
| Situation | Why not an answer |
|---|---|
| The echo service did not answer | Nothing was resolved. Whether you are inside the allowlist is simply unknown |
| The allowlist holds no entry of your address’s family | A 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 matched | The 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.
doctordoes not readrbxshop.tomlto 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
targetPartsname universes, datastores or creators, and deciding whether a given target covers a given call means resolving your env, whichdoctorwould 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:
| Status | What it means |
|---|---|
| 401 | The 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 |
| 403 | The key is valid but not allowed to make this call: a missing scope, or a scope whose target does not cover this universe |
| 404 | The universe id does not exist, or the key’s owner cannot see it |
Related
- docs/apikey.md — declaring and creating the keys
doctorreports 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 check | rbx status | |
|---|---|---|
| answers | may this build continue | where does this project stand |
| shape | one line per check | one block per environment |
| exit code | 0 / 2 / 1 | always 0 |
| written for | CI | you, at a terminal |
Exit codes
This is the part scripts depend on, so it is the part to read carefully.
| Code | Meaning | What to do |
|---|---|---|
0 | every check that ran came back clean | nothing |
2 | at least one check found drift, and none failed | run the sync or codegen the summary names, and commit |
1 | at least one check failed | read 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 game | game/rbxplace.toml |
rbx check --dir game --places shared/envs.toml | shared/envs.toml |
| Config file | Check | Network |
|---|---|---|
rbxplace.toml | env/gen-module — the committed env module still matches | no |
rbxshop.toml | shop/lockfile — declared passes/badges/products against the lockfile | no |
rbxshop.toml | shop/codegen — the committed shop modules still match | no |
rbxmeta.toml | meta/lockfile — declared universe/place metadata against the lockfile | no |
rbxconfig.toml | config/live — local entries against the live config on Roblox | yes |
rbxapikey.toml | apikey/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
| Flag | Effect |
|---|---|
--offline | skip checks that need network access and credentials |
--json | write 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.
| Field | Type | Meaning |
|---|---|---|
schema_version | integer | Document format. 1 today. Refuse a version you do not understand. |
outcome | string | The aggregate: clean, drift, error, skipped. |
exit_code | integer | The 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.total | integer | How many checks ran, including skipped ones. |
totals.clean / .drift / .error / .skipped | integer | Counts by outcome. |
checks | array of objects | One entry per check, in run order. |
checks[].tool | string | env, shop, meta, config, apikey. |
checks[].check | string | Which check within the tool: gen-module, lockfile, codegen, live, status. |
checks[].env | string | The env. Absent on checks that are not per-env. |
checks[].outcome | string | clean, drift, error, skipped. |
checks[].summary | string | One line, the same text the human renderer shows. |
checks[].details | array of strings | Per-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
Related
docs/env.md—rbx env gen-module --checkand the ignored-key policydocs/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
.rbxlto 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 = trueprompts before write operations - Fetch - Auto-populate
rbxplace.tomlfrom live Roblox universe - JSON -
--jsononversions,places,upload,promoteandrollbackwrites one document to stdout and nothing else, with documented field names, forjqand CI
Generating the env module lives in
rbx env gen-module, the command that ownsrbxplace.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
| Flag | Description |
|---|---|
--env | Target environment (required) |
--file | Path to the .rbxl file (required) |
--place | Place name to upload to (defaults to the only place if unambiguous) |
--all-places | Upload to every place defined in the environment |
--published | Publish immediately (default: save as draft) |
--json | Write 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
| Flag | Description |
|---|---|
--env | Target environment (required unless --place-id is given) |
--place | Place name (defaults to the only place if unambiguous) |
--place-id | A place id instead of an env and a name. Skips rbxplace.toml; global flag |
--version | Specific version number to download (default: latest) |
--published | Download the latest published version specifically |
--saved | Download the latest saved (draft) version specifically |
--out | Output 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
| Flag | Description |
|---|---|
--from | Source environment (required) |
--to | Target environment (required) |
--place | Source place name (defaults to the only place if unambiguous) |
--all-places | Upload to every place defined in the target environment |
--version | Specific source version to promote |
--from-published | Promote the latest published version from the source |
--from-saved | Promote the latest saved (draft) version from the source |
--published | Publish immediately on the target (default: save as draft) |
--log | Path to a JSON file for traceability logging (merged, not overwritten) |
--json | Write 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
| Flag | Description |
|---|---|
--env | Target environment (required) |
--place | Place name (defaults to the only place if unambiguous) |
--version | Version to roll back to (skips interactive selector) |
--count | Number of recent versions to show in selector (default: 10) |
--json | Write 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
| Flag | Description |
|---|---|
--env | Target environment (required) |
--place | Place name (defaults to the only place if unambiguous) |
--count | Number of versions to show (default: 20, or 3 when --filter is published/saved) |
--filter | Filter by version type: all (default), published, or saved |
--json | Write 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" }
]
}
| Field | Type | Meaning |
|---|---|---|
schema_version | integer | Document format, shared with rbx check --json. 1 today. Refuse a version you do not understand |
env | string | The environment asked for |
place | string | The rbxplace.toml place name, after the --place defaulting rule |
place_id | string | The place id, as a string |
filter | string | all, published, or saved: the --filter in force |
count | integer | The --count in force. A maximum, not a promise |
count_reached | boolean | True when the walk stopped at --count rather than running out of versions. Raise --count to see the rest |
versions | array of objects | Newest first, the order the listing prints |
versions[].version | string | The version number. --version takes it back verbatim |
versions[].published | boolean | Live, as opposed to a saved draft |
versions[].create_time | string | Exactly 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
| Flag | Description |
|---|---|
--env | Environment name (reads universe from toml, shows config status) |
--universe-id | Universe ID override (one-shot listing without toml) |
--json | Write 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 }
]
}
| Field | Type | Meaning |
|---|---|---|
schema_version | integer | Document format, shared with rbx check --json. 1 today |
env | string | The environment whose entry named the universe. Absent under a bare --universe-id |
universe_id | string | The universe listed, as a string |
places | array of objects | One per place Roblox reports, in the order it returned them |
places[].place_id | string | The place id. Absent when Roblox returned a path it could not be read out of, which the listing renders as ? |
places[].display_name | string | The name Roblox shows, which is not the rbxplace.toml key |
places[].max_player_count | integer | Absent when Roblox did not report one |
places[].place | string | The rbxplace.toml key this place is mapped to, the name --place takes. Absent when the file does not have it |
places[].configured | boolean | Whether 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
| Flag | Description |
|---|---|
--env | Environment section to update in rbxplace.toml (required) |
--universe-id | Universe ID override (uses rbxplace.toml value if omitted) |
--write | Write 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.
| Field | Type | Meaning |
|---|---|---|
schema_version | integer | Document format, shared with rbx check --json. 1 today |
command | string | upload, promote, or rollback, so a mixed stream of receipts can be dispatched on |
ok | boolean | False 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 |
env | string | The environment written to. For promote, the target |
from_env | string | The source environment of a promote. Absent otherwise |
universe_id | string | The universe written to |
published | boolean | Whether the new versions are live. Always true for rollback |
source_place / source_place_id | string | Where a promote read its bytes, after --place defaulting. Absent otherwise |
source_version | string | The 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 / version | string | The single-target shortcut: the place written and the version it received. Absent under --all-places, and absent when nothing was written |
results | array of objects | One entry per place that got a new version, in write order. Empty when the first target failed |
results[].place | string | The rbxplace.toml key |
results[].place_id | string | The place id |
results[].version | string | The version Roblox assigned to this write |
error | string | Why 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
| Field | Type | Required | Description |
|---|---|---|---|
universe_id | u64 | Yes | Roblox universe ID |
env | string | No | Environment type name for code generation (defaults to section name) |
confirm | bool | No | Require confirmation before write operations (default: false) |
places.<name> | u64 | No | Place 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
| Operation | Scope | Notes |
|---|---|---|
| Upload / Promote (write) | universe-places:write | |
| Download / Promote (read) | legacy-asset:manage | |
| Version list | asset:read | Also used by --from-published, --from-saved, --published, --saved |
| Rollback | asset:write | |
| List places | universe:read | For 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
.ROBLOSECURITYfrom a local Roblox Studio install forserver_fill,allow_copying,visibility,studio_access_to_apis_allowed, andbeta_mode - Smart visibility ordering - When toggling private→public,
rbx metaactivates the experience first so dependent patches (e.g. paid private servers) don’t 500 - Preflight validations - Refuses obviously-invalid combinations (e.g.
private_server.price1-9 Robux, or paid private servers on a private experience) before sending a request - Per-env media namespacing -
pull --accept-remote --env devsaves to<media.dir>/dev/icon.pngso 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
Multi-env (recommended): point at a shared rbxplace.toml
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:
- Base (
[game]+[media]): the values shared across every env. - Overlay (
[envs.<name>],[envs.<name>.devices],[envs.<name>.media], etc.): per-env diffs layered on top of base. - Resolution:
--env <name>resolves(universe_id, place_id)fromrbxplace.tomland 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>:
| State | Remote action |
|---|---|
| Base unset | Write remote to base, clear overlay |
| Remote == base | Clear overlay (no-op if absent) |
| Remote != base | Write 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.
| Flag | Description |
|---|---|
--from-remote | Populate config from live universe/place state |
--universe-id | Universe ID (standalone mode; requires --place-id) |
--place-id | Root 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.
| Flag | Description |
|---|---|
--dry-run | Show what would change without applying |
--yes / -y | Skip 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.
| Flag | Description |
|---|---|
--dry-run | Show what would change without writing the lockfile or config |
--accept-remote | Download the current icon and thumbnails into media.dir (or media.dir/<env> for named envs) and update the config paths |
--accept-local | Clear media hashes (next sync re-uploads local icon and thumbnails) |
--yes / -y | Skip 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
| Flag | Description |
|---|---|
--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]
| Field | Type | Required | Description |
|---|---|---|---|
universe_id | u64 | Yes | Your Roblox universe ID |
place_id | u64 | Yes | Root 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.
| Field | Type | API | Description |
|---|---|---|---|
name | string | Open Cloud | Display name (written to the root place) |
description | string | Open Cloud | Experience description (written to the root place) |
server_size | u32 | Open Cloud | Max concurrent players per server |
voice_chat | bool | Open Cloud | Enable in-experience voice chat |
allow_copying | bool | Cookie | Let 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 |
visibility | string | Open Cloud read / Cookie write | "public" or "private" |
studio_access_to_apis_allowed | bool | Cookie | Allow Studio scripts to call Open Cloud / data store APIs |
beta_mode | bool | Cookie | Enable Experience Beta mode (hides from Home Recommendations) |
engine_avatar_settings | string | Cookie | Path to a .toml or .json file holding the modern avatar rules, relative to this config file. Passed through opaquely — see the section below |
genre | string | Cookie | Legacy 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.
| Field | Type | Required | Description |
|---|---|---|---|
price | u64 | Yes | Price 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.
| Field | Type | Description |
|---|---|---|
desktop | bool | Allow desktop players |
mobile | bool | Allow phone players |
tablet | bool | Allow tablet players |
console | bool | Allow console players |
vr | bool | Allow VR players |
[game.server_fill]
Server fill mode. Requires cookie: not exposed by Open Cloud.
| Field | Type | Required | Description |
|---|---|---|---|
mode | string | Yes | "automatic", "empty", or "custom" |
reserved_slots | u32 | Only 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.
| Field | Type | Required | Description |
|---|---|---|---|
third_party_teleport | bool | Yes | Whether another experience may teleport players into this one |
third_party_asset | bool | Yes | Whether this experience may load assets it does not own |
third_party_purchase | bool | Yes | Whether this experience may prompt purchases for another creator’s products |
client_teleport | bool | Yes | Whether 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).
| Field | Type | Values | Description |
|---|---|---|---|
type | string | r6, r15, player_choice | Which rig players get |
animation | string | standard, player_choice | Whether players keep their own animations |
collision | string | inner_box, outer_box | The shape of an avatar’s collision box |
joint_positioning | string | standard, artist_intent | How 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”.
| Field | Type | Required | Description |
|---|---|---|---|
height | float | Yes | Height multiplier |
width | float | Yes | Width multiplier |
head | float | Yes | Head multiplier |
body_type | float | Yes | Body-type (“rthro”) multiplier, 0 to 1 |
proportion | float | Yes | Proportions 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
PATCHaccepts any inner content. To that endpoint the field is an opaque string, so it validates nothing — a200says the request was transported, not that Roblox understood the document.- Roblox returns no echo. The specification says the response carries
engineAvatarSettingsback; 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.jsonexists: 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.
| Field | Type | Required | Description |
|---|---|---|---|
mode | string | Yes | "free" or "paid" |
price | u64 | Only 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.
| Field | Type | Required | Description |
|---|---|---|---|
title | string | Yes | Display title |
url | string | Yes | Link URL |
[media]
| Field | Type | Default | Description |
|---|---|---|---|
icon | string | (unset) | Path to a PNG icon (relative to the config file) |
thumbnails | string[] | [] | Up to 10 PNG thumbnail paths, displayed on Roblox in this order |
dir | string | (unset) | Destination directory used by pull --accept-remote to save downloaded icon and thumbnails |
bleed | bool | true | Apply alpha bleed to PNGs before upload |
language_code | string | "en_us" | Locale used for icon and thumbnail upload |
Field coverage
| Field | API | Notes |
|---|---|---|
game.name, game.description | Open Cloud | Written to the root place |
game.server_size | Open Cloud | Max players per server |
game.voice_chat | Open Cloud | |
game.private_server.price | Open Cloud | Omit table to disable |
game.devices.* | Open Cloud | desktop / mobile / tablet / console / vr |
game.social_links.* | Open Cloud | 7 platforms |
media.icon | Open Cloud | Localized via legacy-game-internationalization |
media.thumbnails[] | Open Cloud | Up to 10, ordered |
game.server_fill | Cookie | socialSlotType + customSocialSlotsCount |
game.allow_copying | Cookie | copyingAllowed |
game.visibility | Open Cloud read / Cookie write | Legacy activate / deactivate |
game.studio_access_to_apis_allowed | Cookie | Legacy /v2/universes/{id}/configuration |
game.beta_mode | Cookie | apis.roblox.com/experience-releases/.../release_status |
game.genre | Cookie | Legacy /v2/universes/{id}/configuration, read back from /v1/.../configuration |
game.avatar.type, .animation, .collision, .joint_positioning | Cookie | Same pair of endpoints. Sent as the integers Roblox uses |
game.avatar.min_scale, .max_scale | Cookie, write-only | universeAvatarMinScales / MaxScales. Not returned by any GET, so pull leaves them alone |
game.avatar.asset_overrides | Cookie, write-only | universeAvatarAssetOverrides. Sent whole, ten slots |
game.engine_avatar_settings | Cookie, write-only | engineAvatarSettings, a JSON string. Read from a .toml or .json file and passed through unmodelled |
game.paid_access | Cookie | isForSale + price, sent together |
game.permissions.* | Cookie, write-only | The 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/privatesupported; the API field isisFriendsOnly) - Age rating (write)
optInRegions/optOutRegions. Declined rather than pending; the reasoning is inTODO.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_scalegame.avatar.asset_overridesgame.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.
Cookie-only fields
The Open Cloud API doesn’t expose every metadata field. These fields require a cookie:
game.server_fillgame.allow_copyinggame.visibility(write only; read is via Open Cloud)game.studio_access_to_apis_allowedgame.beta_modegame.genregame.avatar.*game.engine_avatar_settingsgame.paid_accessgame.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
| Resource | Scopes | Documentation |
|---|---|---|
| Universe | universe:read, universe:write | Universe API |
| Place | universe.place:read, universe.place:write | Place API |
| Icons & thumbnails | none to read; see note to write | pull 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:
- 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.
- Activate (legacy / cookie) if
visibilityis going from private to public. Must be first so dependent patches (like paid private servers) don’t 500. - PATCH universe (Open Cloud): voice chat, private server price, devices, social links
- PATCH place (Open Cloud): name, description, server size
- PATCH place legacy (cookie):
server_fill,allow_copying - PATCH universe configuration legacy (cookie):
studio_access_to_apis_allowed - POST experience-releases (cookie):
beta_modetoggle - Deactivate (legacy / cookie) if
visibilityis going from public to private. Last so the universe stays in its permissive state until everything else is patched. - Upload icon if its BLAKE3 hash differs from the lockfile
- 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.priceis0or>= 10(Roblox rejects 1-9 Robux)visibility = "private"withprivate_server.price > 0is invalid (Roblox requires public)- Referenced
media.iconandmedia.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 -
checkandsync --dry-runshow exactly what changes before any publish - Full sync -
rbxconfig.tomlis the canonical state: missing keys are removed from live - Pull - Mirror the live config back into
rbxconfig.toml, preserving local descriptions - Revision history -
versionsandrollbackto inspect and revert past publishes - Gradual rollout - Optional
GradualRolloutdeployment strategy (~15 min propagation) - Multi-environment - Targets environments defined in
rbxplace.toml(shared withrbx place) - JSON -
--jsononget,listandversionswrites one document to stdout and nothing else, with documented field names, forjqand 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.
| Field | Type | Meaning |
|---|---|---|
schema_version | integer | Document format, shared with rbx check --json. 1 today. Refuse a version you do not understand |
env | string | The 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_id | integer | The universe this snapshot is from |
config_version | integer | Roblox’s configVersion for this snapshot, in snake case like every other field |
key | string | The key asked for. Absent when none was |
value | any | That key’s value, raw, exactly what the bare form prints. Absent whenever key is |
entries | object | Keyed by config key: one entry when key is set, all of them otherwise. Always present |
entries.<key>.type | string | bool, number, string, array, object, null — the words the listing prints in its type column |
entries.<key>.value | any | The 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
| Flag | Description |
|---|---|
--message / --no-message | Publish 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
| Flag | Description |
|---|---|
--yes | Overwrite 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
| Flag | Description |
|---|---|
--count | Number of revisions to show (default: 20) |
--json | Write 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
}
]
}
| Field | Type | Meaning |
|---|---|---|
schema_version | integer | Document format, shared with rbx check --json. 1 today |
env | string | The env named on the command line. Absent under a bare --universe-id |
universe_id | integer | The universe whose history this is |
count | integer | The --count in force for this run |
count_reached | boolean | True when the run stopped because it hit --count rather than because it ran out of revisions. Raise --count to see further back |
revisions | array of objects | Newest first, the order Roblox returns and the listing prints |
revisions[].revision_id | string | Full id, not the eight-character prefix the listing shows. This is what rbx config rollback takes |
revisions[].version | integer | The configVersion this publish produced |
revisions[].time | string | The timestamp Roblox sent, untouched ISO. The listing rewrites it to be read; a consumer wants it back |
revisions[].message | string | The 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_keys | array of strings | The 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[].published | boolean | True 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
| Flag | Description |
|---|---|
--count | Number of revisions to show in the picker (default: 10) |
Per-tool flags
| Flag | Default | Description |
|---|---|---|
--config | rbxconfig.toml | Path to the local config file |
--universe-id | none | Bypass 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
| Operation | Scope |
|---|---|
Read live config (get, list, check, pull, sync diff) | universe:read |
Write config (sync, rollback) | universe:write |
Deployment strategies
| Strategy | Flag | Propagation |
|---|---|---|
| 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 inrbxplace.tomlin 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.luaudispatcher (exportedGameIdstype) and one module per env, dispatching ongame.GameIdat runtime - Offline regeneration + drift guard:
rbx shop codegenrebuilds that folder without credentials, and--checkproves 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.tsfor 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 = trueon a pass or product derives a matching “GiftX” developer product automatically (see Gift products) - JSON:
--jsononshowandlistwrites one document to stdout and nothing else, with documented field names, forjqand CI.showis the declared side,listthe remote one, and neither claims they agree — that isrbx check --json
Quick start
Multi-env (recommended): point at a shared rbxplace.toml
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:
- Base (
[passes.X],[badges.X],[products.X]): the logical schema shared across every env. - Overlay (
[envs.<name>.passes.X], etc.): per-env diffs layered on top of base. - Resolution:
--env <name>resolvesuniverse_idfromrbxplace.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>:
| State | Resulting action |
|---|---|
Resource missing in base, env is default | Add to base |
| Resource missing in base, env is named | Add to [envs.<name>] overlay |
| Remote field == base field | Clear that field from overlay (no-op if absent) |
| Remote field != base field | Write 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.tomlfollows, seerbx 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_passalso 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_giftoff doesn’t delete anything remotely (Roblox products can’t be deleted via the API). The twin just stops appearing in the resolved config;check/syncwill warn that it “exists in lockfile but not in resolved config” - the same warning any other removed resource gets. create_giftcan 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/checkfail with a clear error rather than silently merging them. pullwon’t re-import the twin. It recognizes the remote gift product by its derived key and skips writing it back intorbxshop.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:
rbx shop rename products "GIFT - VIP Pass" GiftVIP- aligns the existing product’s key with whatcreate_giftwould derive, keeping its remote ID in the lockfile.- Delete the now-redundant
[products.GiftVIP]block fromrbxshop.tomlby hand (keep the lockfile entry). - Add
create_gift = trueto[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-namesis 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.
| Flag | Description |
|---|---|
--from-remote | Populate config and lockfile from existing remote resources |
--universe-id | Universe 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-run | Requires --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.
| Flag | Description |
|---|---|
--dry-run | Show what would change without applying |
--only | Only sync specific types: passes, badges, products (comma-separated) |
--badge-cost | Expected cost in Robux when creating a badge (default: 0) |
--yes / -y | Skip the confirmation prompt. What CI passes |
--allow-duplicate-names | Create 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).
| Flag | Description |
|---|---|
--dry-run | Show what would change without writing anything |
--accept-remote | Download remote icons and update local files |
--accept-local | Keep 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
| Flag | Description |
|---|---|
--check | Compare 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.
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 }
]
}
| Field | Type | Meaning |
|---|---|---|
schema_version | integer | Document format, shared with rbx check --json. 1 today. Refuse a version you do not understand |
env | string | The env named on the command line. Absent when there was none and the target came from [experience] instead |
universe_id | string | The universe that was queried |
resource | string | The kind asked for, in its CLI spelling: passes, badges, products. On the envelope, not repeated on every row: one invocation lists one kind |
resources | array of objects | One per remote resource, in the order Roblox returned them |
resources[].id | string | The Roblox id. The handle for a remote resource, the way the TOML key is the handle for a declared one |
resources[].name / .description | string | As Roblox holds them |
resources[].price | integer | Robux. Absent when Roblox reported no price — Free for a pass, - for a product in the table |
resources[].for_sale | boolean | Passes and products |
resources[].enabled | boolean | Badges only |
resources[].store_page | boolean | Developer products only |
resources[].icon_asset_id | string | The 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
}
}
}
| Field | Type | Meaning |
|---|---|---|
schema_version | integer | Document 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
| Flag | Description |
|---|---|
--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]
| Field | Type | Required | Description |
|---|---|---|---|
universe_id | u64 | Yes (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.
| Field | Type | Required | Description |
|---|---|---|---|
type | string | Yes (if section present) | "user" or "group" |
id | u64 | Yes (if section present) | Roblox user or group ID |
[codegen]
| Field | Type | Default | Description |
|---|---|---|---|
output | string | – | Folder 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 |
typescript | bool | false | Also generate init.d.ts |
style | string | "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).
| Field | Type | Default | Description |
|---|---|---|---|
passes | string | "passes" | Path for game passes |
badges | string | "badges" | Path for badges |
products | string | "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]
| Field | Type | Default | Description |
|---|---|---|---|
bleed | bool | true | Apply alpha bleed before uploading. Changing this won’t reupload existing images |
dir | string | "icons" | Directory for icons downloaded by pull --accept-remote |
[passes.<name>]
| Field | Type | Required | Description |
|---|---|---|---|
name | string | No | Display name (defaults to the TOML key) |
price | u64 | No | Price in Robux (omit for free) |
description | string | No | Pass description |
icon | string | No | Path to icon file |
for_sale | bool | No | Whether the pass is for sale (default: true) |
regional_pricing | bool | No | Enable regional pricing (default: false) |
create_gift | bool | No | Derive a “Gift<name>” developer product twin (default: false) - see Gift products |
path | string | No | Override the codegen path for this item |
[badges.<name>]
| Field | Type | Required | Description |
|---|---|---|---|
name | string | No | Display name (defaults to the TOML key) |
description | string | No | Badge description |
icon | string | No | Path to icon file |
enabled | bool | No | Whether the badge is active (default: true) |
path | string | No | Override the codegen path for this item |
[products.<name>]
| Field | Type | Required | Description |
|---|---|---|---|
name | string | No | Display name (defaults to the TOML key) |
price | u64 | Yes | Price in Robux |
description | string | No | Product description |
icon | string | No | Path to icon file |
for_sale | bool | No | Whether the product is for sale (default: true) |
regional_pricing | bool | No | Enable regional pricing (default: false) |
store_page | bool | No | Show on the store page (default: false) |
create_gift | bool | No | Derive a “Gift<name>” developer product twin (default: false) - see Gift products |
path | string | No | Override the codegen path for this item |
[gifts]
| Field | Type | Default | Description |
|---|---|---|---|
label | string | "[GIFT] " | Prefixed to the source’s resolved display name for every derived gift product |
key_prefix | string | "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_key | bool | false | Uppercase 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"]
| Field | Type | Default | Description |
|---|---|---|---|
files | string[] | [] | 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. Anyexperience/owner/codegen/icons/gifts/includesection in an included file is rejected with a clear error. So[envs.prod.passes.VIP]can live inrbxshop.passes.tomlright 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/showfail with a clear error naming the file and the key. pullandrenamewrite 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 entrypulldiscovers, 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/renamenever 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
| Resource | Scopes |
|---|---|
| Game Passes | game-pass:read, game-pass:write |
| Developer Products | developer-product:read, developer-product:write |
| Badges | legacy-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 source | universe: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
| Style | Output | When to pick |
|---|---|---|
nested | Nested tables, full per-field types | Best for Luau (direct access, full autocomplete) |
flat | Dot-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 nextsync
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.
| Subcommand | What it does | Docs |
|---|---|---|
servers | Live and terminated servers, and the logs of one that crashed. | ops/servers.md |
analytics | Your own metrics: players, retention, ARPPU. CSV for charting elsewhere. | ops/analytics.md |
ban | Inspect and change player restrictions. | ops/ban.md |
restart | Forecast and launch a rolling server restart. | ops/restart.md |
data | Read, overwrite, copy and recover a data store entry; data ordered for leaderboards. | ops/data.md |
memorystore | Write cache values servers read through MemoryStoreService. | ops/memorystore.md |
publish | Push a MessagingService message to every running server. | ops/message.md |
ads | Launch and steer ad campaigns. Spends money, reads no results. | ops/ads.md |
probe | Raw 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 rbx | live operations | |
|---|---|---|
| Acts on | state declared in your repo | state that only exists at runtime |
| Source of truth | a TOML file you commit | the live game |
| Runs in CI | yes, on every push | no |
| A mistake costs | a failed deploy, retried in a minute | player 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:
| Subcommand | Scope | Read or write |
|---|---|---|
servers | universe:read | read |
analytics | universe.analytics:read | read |
ban status / list / logs | universe.user-restriction:read | read |
ban add / remove | universe.user-restriction:write | write |
restart forecast / status | universe:read | read |
restart launch | universe:write | write |
data reads | universe-datastores.objects:read,list | read |
data writes | universe-datastores.objects:create,update + universe-datastores.control:create | write |
data revisions / restore | universe-datastores.versions:list,read | read |
data ordered reads | universe.ordered-data-store.scope.entry:read | read |
data ordered writes | universe.ordered-data-store.scope.entry:write | write |
memorystore get / list | memory-store.sorted-map:read | read |
memorystore set / delete | memory-store.sorted-map:write | write |
publish | universe-messaging-service:publish | write |
probe | whatever the path you probe needs | depends |
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 Studio cookie
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:
nextPageTokenis""oncloud/v2andnullonserver-management. Reading""as a token requests the same page forever.uptimeis a .NETTimeSpan,[d.]hh:mm:ss[.fffffff], not an ISO 8601 duration. All three forms occur.frameRateisnullon a new server and0on a stopped one.dataPointsneeds 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"]
}
| Field | Type | Meaning |
|---|---|---|
schema_version | integer | Document format, shared with rbx check --json. 1 today |
default_place_version | string | The version list and logs use without --version, i.e. the one marked *. Absent when no version has servers |
place_versions | array of strings | Every 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)
| Flag | Meaning |
|---|---|
--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. |
--full | Show whole job ids instead of the first eight characters. |
--csv | CSV instead of a table. |
--json | One 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": []
}
]
}
| Field | Type | Meaning |
|---|---|---|
schema_version | integer | Document format, shared with rbx check --json. 1 today. Refuse a version you do not understand |
place_version | string | The version the rows are for. Absent only when no version has servers at all |
partial | boolean | Roblox answered 200 while reporting a fetch error for one of its sources. Rows are missing; any rate is a lower bound |
limit | integer | The --limit in force |
limit_reached | boolean | The run stopped at --limit, not at the end of the data. Raise it to see the rest |
totals.returned | integer | Rows in servers, after --status filtering |
totals.failed | integer | How many of those ended in a crash or out-of-memory |
totals.available | integer | How many exist for this version before --status and --limit. Absent when Roblox did not say |
servers | array of objects | One per server, in the order Roblox returned them |
servers[].job_id | string | Full job id — what servers logs takes. Never truncated here |
servers[].status | string | active, 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[].failure | boolean | True for crashed and out_of_memory, so a consumer does not keep its own list |
servers[].place_id / .place_version / .engine_version | string | As Roblox sends them. Ids stay strings: they exceed 2^53 and a JSON number would round |
servers[].create_time / .termination_time | string | Timestamps. termination_time is absent on a live server |
servers[].uptime_seconds | integer | Seconds, not the 00:05:02.0020000 .NET text Roblox sends |
servers[].memory_bytes | integer | Memory in use |
servers[].frame_rate | number | Absent when Roblox reported none. A present 0 means measured zero |
servers[].occupancy / .max_occupancy | integer | Players now, and the cap |
servers[].full / .shut_down | boolean | As reported |
servers[].type | integer | Spec enum 0..5, named nowhere. Passed through raw rather than guessed at |
servers[].player_count | integer | Length of player_ids |
servers[].player_ids | array of integers | The 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'
| Flag | Meaning |
|---|---|
--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. |
--csv | CSV instead of formatted lines. Stack traces are quoted, so the newlines survive. |
--json | One 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"
}
]
}
| Field | Type | Meaning |
|---|---|---|
schema_version | integer | Document format, shared with rbx check --json. 1 today |
job_id | string | The server asked about, in full. Present even when nothing came back |
place_version | string | The version the logs were read from, whether given or defaulted |
severity_filter | string | The --severity in force, canonicalised: output, info, warn, error. Absent when none was asked for |
limit | integer | The --limit in force |
limit_reached | boolean | The 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.returned | integer | Rows in lines, after --severity filtering |
totals.errors | integer | How many of those are errors |
lines | array of objects | One per line, in the order Roblox returned them |
lines[].time | string | Timestamp as Roblox sends it, RFC 3339 despite the messageTimestampMs name on the wire. Absent when a line carried none |
lines[].severity | string | output, info, warn, error, or unknown. Same spelling --severity takes |
lines[].severity_code | integer | The raw code. Absent when the line carried none, which tells “Roblox added a severity” apart from “this line had none” |
lines[].error | boolean | True for error alone, so a consumer does not keep its own list |
lines[].message | string | The line itself. Absent when there was none |
lines[].stack_trace | string | Real newlines inside the JSON string, never truncated. Absent when the line carried none |
lines[].job_id / .place_version | string | As 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:
uptimeSecondsis a number, not the00:05:02.0020000text Roblox sends. A spreadsheet can total seconds and cannot total a .NET TimeSpan.frameRateis left empty when Roblox reported nothing, rather than0, 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
| Flag | Meaning |
|---|---|
--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. |
--csv | CSV instead of a table. |
--json | One 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 }
]
}
]
}
| Field | Type | Meaning |
|---|---|---|
schema_version | integer | Document format, shared with rbx check --json. 1 today. Refuse a version you do not understand |
metric | string | The metric asked for, as --metric spelled it |
granularity | string | The 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 |
days | integer | The --days in force |
start_time / end_time | string | The range actually queried, RFC 3339 UTC. Start inclusive, end exclusive |
breakdown | array of strings | The dimensions --breakdown asked for, in order. Empty when none |
filters | array of objects | The --filter clauses, parsed: dimension, operation, values |
queued | boolean | Roblox 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.series | integer | Entries in series |
totals.points | integer | Points across every series — what Roblox returned, not what a dense range would hold |
totals.missing | integer | How many of those came back with no value. Non-zero means the series has holes that are not zeros |
series | array of objects | One per series, in the order Roblox returned them. A single series when nothing was broken down |
series[].label | string | The short label the table and the CSV print: total, or the dimension values joined with / |
series[].dimensions | object | The dimension values identifying this series, keyed by dimension name rather than positional. Empty without a --breakdown |
series[].points | array of objects | The buckets Roblox returned, in its order. Empty is a real answer |
series[].points[].time | string | Start of the bucket. Absent in the one case Roblox sends a point with no time, which the table prints as - |
series[].points[].value | number | The 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 see | What it means |
|---|---|
"value": 0 | Measured zero |
no value key | Roblox returned the bucket and put no number in it |
| no point for that timestamp | Roblox 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:
--breakdownsplits one answer into several series, one per value.--filternarrows 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.
| Flag | Meaning |
|---|---|
--reason | Required. Private, 1000 characters. A ban you cannot explain in six months is a ban you cannot defend. |
--display-reason | Shown to the player, 400 characters. |
--duration | 30m, 12h, 7d, 2w. Omit for permanent. |
--allow-alts | Let 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”. |
--apply | Actually send it. |
--yes | Skip 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 }
]
}
| Field | Type | Meaning |
|---|---|---|
schema_version | integer | Document format, shared with rbx check --json. 1 today. Refuse a version you do not understand |
env | string | The env that named the universe. Absent under a bare --universe-id |
universe_id | string | The experience this is a listing of |
include_inactive | boolean | Whether entries that exist without locking anybody out were included |
limit | integer | The --limit in force, a maximum and not a promise |
limit_reached | boolean | The walk stopped at --limit rather than at the end of the listing. Raise it to see the rest |
count | integer | Rows in restrictions |
restrictions[].user_id | string | Absent when the id could not be read out of the resource path, which the table prints as ? |
restrictions[].active | boolean | False only under --include-inactive; without this field the two kinds of row are indistinguishable |
restrictions[].permanent | boolean | Nothing will lift this restriction |
restrictions[].duration | string | As Roblox sent it. Absent when permanent |
restrictions[].private_reason | string | Your 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
| Subcommand | Scope |
|---|---|
status, list, logs | universe.user-restriction:read |
add, remove | universe.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
| Flag | Meaning |
|---|---|
--bleed-off <minutes> | Delay before servers begin closing. Default 30, Roblox accepts 1 to 240. |
--apply | Actually launch it. |
--yes | Skip 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 / reset | after DELETE | |
|---|---|---|
| a normal read | the new value | nothing, 404 |
| the entry in a listing | present | present with --show-deleted |
| the previous value | gone at once | readable 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
| Verb | What it does |
|---|---|
list | The 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
| Field | Type | Meaning |
|---|---|---|
schema_version | integer | Document format, shared with rbx check --json. 1 today. Refuse a version you do not understand |
datastore / scope | string | The store this was read from, as --datastore and --scope named it |
entry | string | The key that was asked for |
found | boolean | False when there is no such key. Exit code is 0 either way, the same non-event the human form prints as “No entry” |
deleted | boolean | True when the entry is soft-deleted and still readable. Roblox purges it thirty days after the delete |
revision_id | string | The revision the value came from. Absent when Roblox did not say, and when there is no entry |
value | any | The 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 |
out | string | Where --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" }
]
}
| Field | Type | Meaning |
|---|---|---|
schema_version | integer | Document format, shared with rbx check --json. 1 today. Refuse a version you do not understand |
map | string | The sorted map, as --map named it |
limit | integer | The --limit in force |
limit_reached | boolean | The run stopped at --limit, not at the end of the map. Raise it to see the rest |
count | integer | Rows in items |
items[].id | string | The item id. Absent when Roblox sent none, which the human listing prints as <no id> |
items[].numeric_sort_key / .string_sort_key | number / string | At most one of the two, since the flags that set them are exclusive |
items[].expire_time | string | Absent when the item has no TTL |
items[].value | any | Absent 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
}
| Field | Type | Meaning |
|---|---|---|
schema_version | integer | Bumped when a documented field changes meaning or disappears. |
topic | string | The topic, as the experience passes it to SubscribeAsync. |
universe_id | string | A string, not a number: universe ids exceed 2^53. |
bytes | integer | Encoded length of the message, which is what the limit applies to. |
applied | boolean | true once it was sent. false on a dry run, which is the default. |
message | string | The 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 competition | Numbers per image | |
|---|---|---|
| One campaign, up to 10 creatives | none | no |
| One campaign per creative | some | yes |
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
launchtwice 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
| Command | What it does |
|---|---|
launch | One campaign per --creative, identical otherwise. Needs --apply. |
list | Campaigns 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] --amount | Change the budget. Needs --apply. |
rename <id> --name | Change the name. Needs --apply. |
creatives | Images available as creatives, and their moderation state. --archived lists archived ones instead of live. |
universes | Experiences this account may advertise. |
accounts | Billing accounts this key can spend from. |
options | Formats, 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
| Flag | Meaning |
|---|---|
--creative <ASSET_ID> | Image to test. Repeat once per variant. Listing one twice is refused. |
--name | Base name. Each campaign gets it plus its asset id. |
--budget | Dollars per campaign. Five variants at 25 is 125 dollars. |
--budget-type | DAILY or LIFETIME. |
--days | How long to run. |
--start | RFC 3339. Defaults to as soon as review clears. |
--payment | CREDIT_CARD, ADS_CREDIT or INVOICE. See ads options. |
--country, --age, --device | Narrow the audience. Repeatable. |
--apply | Actually create them. |
--yes / -y | Skip 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"]
}
]
}
| Field | Type | Meaning |
|---|---|---|
schema_version | integer | Document format, shared with rbx check --json. 1 today |
totals.returned | integer | Rows in campaigns |
totals.active | integer | How many are ACTIVE. Not the same as serving, see below |
campaigns[].id | string | Campaign id |
campaigns[].name | string | Free 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[].status | string | What the campaign was asked to do: ACTIVE, PAUSED, CANCELLED |
campaigns[].delivery_status | string | What it is actually doing: SERVING, IN_REVIEW, NOT_SERVING, REJECTED |
campaigns[].delivery_status_reasons | array of strings | Why, when Roblox says. Always present, empty when it said nothing |
campaigns[].budget | object | Absent when Roblox reported none, which is not a budget of zero |
campaigns[].budget.amount_micros | string | Micro-USD exactly as Roblox sent it. The authoritative figure |
campaigns[].budget.amount_usd | string | The same amount in dollars, truncated to the cent. Absent when the micros are not a number |
campaigns[].budget.type | string | DAILY or LIFETIME |
campaigns[].target_universe_id | string | The experience advertised. A string: universe ids exceed 2^53 |
campaigns[].creative_asset_ids | array of strings | Normally 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" }]
}
| Field | Type | Meaning |
|---|---|---|
totals.requested | integer | Ids given on the command line |
totals.returned | integer | Ids Roblox answered for: rows in statuses |
totals.failed | integer | Ids it refused: rows in failures |
statuses[] | array of objects | id, status, delivery_status, delivery_status_reasons |
failures[] | array of objects | id 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
| Flag | Meaning |
|---|---|
-X, --method | HTTP method. Anything but GET also needs --apply. |
-d, --data | JSON body. Parsed before sending, so a typo fails here rather than as a confusing 400. |
--apply | Actually 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.tomlas otherrbxsubcommands - Cross-platform - Works on Windows, macOS, and Linux
- Zero dependencies - No API key needed, just launches Studio
Quick start
- Create or reuse
rbxplace.toml
[prod]
universe_id = 9876543210
places.main = 123456789012345
places.lobby = 987654321
[dev]
universe_id = 9876543212
places.main = 345678901234567
- 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--envflag, then to an interactive picker.PLACE- Place name within the environment (e.g.,main,lobby). Falls back to the global--placeflag, 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 openoperates 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
assetdeliveryendpoint 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
| Situation | Backend |
|---|---|
| No API key | Public assetdelivery.roblox.com (optional Studio cookie, opt-in — see cookie.md) |
--api-key / RBX_API_KEY set | Open Cloud asset-delivery-api |
--version <n> given | Open Cloud (requires an API key) |
--public given | Public, 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.
| Shell | Where it goes | Then |
|---|---|---|
| bash | ~/.local/share/bash-completion/completions/rbx | New shell, or source the file. Needs bash-completion installed |
| zsh | any directory on $fpath, named _rbx | New 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.fish | Picked up immediately |
| powershell | appended to $PROFILE | New 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
| File | Written by | Committed | Where conflicts come from |
|---|---|---|---|
rbxshop.lock.toml | shop sync, shop pull, shop init --from-remote, shop rename, import | yes | a resource added or changed on both sides |
rbxmeta.lock.toml | meta sync, meta pull, meta init --from-remote, import | yes | the same field patched on both sides, plus the thumbnail array |
rbxconfig.lock.toml | config sync, config pull, import | yes | every sync, unconditionally (see below) |
rbxapikey.lock.toml | the apikey verbs | no, it stores secrets | not applicable once you have gitignored it, which apikey create refuses to proceed without |
| the codegen folder | shop codegen, shop sync | usually | never 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.tomlrewritessynced_atandrevision_idon everyconfig syncand everyconfig pull.synced_atis the wall clock at the moment of the write,revision_idis theconfigVersionRoblox 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.tomlstoresmedia.thumbnailsas 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 letrbx meta pullrestate 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:
- Never resolve a
rbxshop.lock.tomlconflict by deleting entries. Every entry you drop is oneCreateon the next sync. git checkout --ours/--theirson 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.- Deleting the lockfile and re-running
syncis the worst version of the same mistake. Forrbx metaandrbx configthat is merely wasteful (both re-send state they already had). Forrbx shopit 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 anexpectedCost,rbx shop syncexposes it as--badge-cost(default0), and the scope the call needs is namedmanage-and-spend-robux. The command that rebuilds a shop lockfile from reality isrbx shop pull, notsync.
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
| File | Keep | Then run |
|---|---|---|
rbxshop.lock.toml | the union of resource entries; never fewer ids than either side had | rbx shop pull --env <name> --dry-run, then without it |
rbxmeta.lock.toml | either 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.toml | either side; synced_at and revision_id are bookkeeping | rbx config sync --env <name> --dry-run, which prints the live-versus-local diff before writing anything |
| the codegen folder | neither; take either side to clear the marker | rbx 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
Createfor 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_passlands 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 syncdoes not refuse when the lockfile’s recordeduniverse_iddisagrees with the env it resolved. It overwrites the recorded value.rbx shop checkprints the mismatch, andrbx meta syncandrbx config syncrefuse outright, so shop is the odd one out here. After a merge that could have mixed env sections, runrbx shop checkbeforerbx 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 .gitignore —
rbx 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.
Related
- docs/check.md is the CI contract and the exit codes
- docs/shop.md covers the lockfile format and the codegen guard
- docs/meta.md covers the sync ordering and media hashing
- docs/config.md covers revisions and rollback
- ARCHITECTURE.md explains why every run is sequential
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.
| Command | Why the cookie | Required? |
|---|---|---|
rbx init create-group, create-universe, create-place, rename-place, rename-universe | Open 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-groups | The 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-places | Nothing. 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 pull | Reads 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 sync | Writes 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 reads | The 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-manage | Roblox’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 backend | The 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 import | Nothing, 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 doctor | Reports 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 importdoes not auto-detect for its own call. It passes--cookie/RBX_COOKIEstraight 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. Themetastep thatimportruns afterwards is an ordinaryrbx metainvocation and does resolve the cookie normally, auto-detection included.rbx apikey introspectresolves a cookie it does not send. It shares the client constructor with the rest ofrbx 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.
What the cookie does not protect
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.
How a cookie is resolved
GlobalFlags::resolve_cookie in rbx-core is the only resolver, and it is consulted in this order:
--cookie <value>orRBX_COOKIE. Explicit, and highest priority.RBXAPIKEY_COOKIE. The one per-tool variable that survivedrbx apikeybecoming a subcommand. Explicit too, so it beats auto-detection.--no-auto-cookie. If set, resolution stops here with no cookie.- Auto-detection from a local Roblox Studio install (Windows registry, macOS plist), via the
rbx_cookiecrate. 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-cookiecheck, 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_COOKIEbeats auto-detection. It used to lose, becauserbx apikeyonly 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 emptyRBXAPIKEY_COOKIEis 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 everyrbx apikeysubcommand 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 run | What happens |
|---|---|
--auto-cookie passed | Sent. The standing yes, for a person who has decided once. |
| A terminal on all three streams | Asked, once, and the answer is remembered for the process. |
Anything else — CI, a pipe, a cron job, --json into a file | Not 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:
| Command | What the check prevents |
|---|---|
rbx meta sync, when the plan contains at least one cookie-only field | The 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-group | Paying 100 Robux for a call that is about to be refused. |
rbx init create-universe, create-place | An irreversible creation followed by a rename that fails, leaving a resource named after nothing. |
rbx init rename-place, rename-universe | A read that answers for a public resource being mistaken for proof the rename will be accepted. |
rbx apikey create, prune | Nothing 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 syncthat checks before the prompt and again before the first write asks Roblox once, andapikey creategets 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 inRBX_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
rbxwrites isrbxplace.toml, the per-tool config and lockfiles, generated Luau modules, downloaded assets, shell completions, and (forrbx 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.--cookieand--api-keyare 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.
Related
- 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