The CLI
carlos is one binary that plays three roles, picked by the first word you
type. Most days you are in the first role: you have an app, and you want it
running somewhere. The second is for people who stand a deployment up and
administer it. The third is what systemd starts on a box.
Run carlos help for the same grouping at the terminal, and
carlos <command> -h for a command's own flags. This page says what each
command is for and when you would reach for it. It does not list every flag,
because -h already does.
Three flags turn up almost everywhere, and the sections below do not repeat
them. --app names the app (-a for short). --account names the account it belongs to, or
--account-id when you want a sqid matched as a sqid and nothing else; leave
both off if you belong to exactly one account. --console picks which
logged-in console to act through, and you need it only when this terminal is
logged in to more than one deployment.
Most app commands work two ways. Signed in with carlos auth login, they go
through a console's API, which is how someone with no box access and no cloud
credentials gets their work done. With CARLOS_DEPLOYMENT_BUCKET or
CARLOS_DEPLOYMENT_DIR set, they write to the bucket directly. That is the
operator's path. Where a command only works one way, its section says so.
Your apps
Everything from claiming a name to watching a deploy land. None of it needs access to a box, a cloud console, or the deployment's bucket; that is a rule the platform holds itself to, and a command that broke it would be a bug.
carlos auth
Logs this terminal in to a console. carlos auth login prints a short code
that you approve in a browser already signed in to that console, and the
browser does not have to be on this machine, so this works over SSH. The
token lands in ~/.carlos/credentials at mode 0600, one entry per console,
so a laptop can hold credentials for the flagship and your own deployment at
once.
carlos auth login
carlos auth whoami
Do not do this on a box. A box has no browser and no human, and the
long-lived token you would leave behind is a standing credential nobody is
watching; pass --owner <address> to
carlos accounts instead. carlos auth default
shows the console this machine talks to by default, and sets it when you give
it a URL. With --project it writes ./.carlos/config, which you can commit
so a checkout pins its own deployment.
carlos apps
Claims an app name, moves an app onto a customer fleet, and deletes or restores one. Creating goes through the same door the console's New app form uses, so a name that form would refuse is refused here too.
carlos apps create --app hello
carlos apps delete moves an app to the Trash. Every route drops, custom
domains included, the name stays reserved, and you have thirty days to
carlos apps restore it. Restoring brings back releases, channels and the
production flag exactly as they were, but no routes at all — not even the
platform apex — so plan on adding those again. Delete is refused while the
app is flagged production; clear the flag first.
--place fleet/<name> at create, or carlos apps place --target fleet/<name>
later, puts the app's routes and instances on a fleet your account owns.
--target "" brings them back to platform-served. --target is mandatory even
in that empty spelling: leaving it off is a usage error, never a silent
clear.
Two more flags are stamped at claim time. --control-plane marks the app as
the control plane, after which it refuses trash, purge, rename and transfer,
and refuses a service credential's promote — owner only. --build-probe /path
names a path whose response body carries the running build, /healthz serving
ok abc1234 being the shape; carlos deploy then
polls that instead of the X-Carlos-Version header, which the edge stamps from
the route row and which therefore reports the new build before the process has
actually restarted. Both are checked against what the console echoes back, so a
write that silently failed is an error rather than a quiet lie.
carlos ship
Publishes a release. The artifact is hashed, stored under its content address, and recorded in a manifest that never changes afterwards. Shipping does not change what anything serves — for that, see carlos promote.
carlos ship --app hello --label "fixing modals" ./hello
--version is optional once the app has a version target set, and leaving it
off is the point of having one: ship mints the next iteration under it. Use
--kind static with a directory to ship a site instead of a binary. --label
is one line for humans and shows up in carlos releases and in the console,
so write it for the person doing a rollback at 2am.
carlos promote
Points a channel at a version you have already shipped. The ladder is
canary → edge → beta → stable: beta is entered from edge and stable from
beta, so "it was signed off on beta" means something, and reaching stable
cuts the release's semver tag. Channels named canary/<something> are never
refused, because that is what the fleet itself runs on.
carlos promote --app hello a1b2c3d edge
Promoting the same version twice succeeds and does nothing. --hotfix skips
the ladder and is an operator act: it works on the direct-bucket path only,
and the console refuses it by name, so the flag never quietly does nothing.
carlos deploy
Ship, promote, and then wait until the app's URL actually serves the new
build. It watches the X-Carlos-Version header on the live host and does not
report success until that header names what you shipped, over a 2xx. Reach
for this one by default; ship and promote separately when you want the gap
between them.
carlos deploy --app hello ./hello
Signed-in only. With saved project defaults you can run carlos deploy with
no arguments and no questions. A first run at a terminal fills the blanks by
asking. If your account owns no apps yet, it offers to claim one named after
the directory you are in, through the same door as carlos apps create. It
then asks which artifact to ship, works out from what you point it at whether
that is a binary or a site, and offers to save both answers as the project's
defaults. With neither arguments nor defaults and no terminal to ask at — CI,
usually — it refuses instead of guessing. --channel overrides the channel it
picks (normally the one the app's instances already follow), and --host
scopes the watch to a single instance. A static site needs that second flag,
having no instances to resolve a channel from.
carlos deploy --app website --kind static --host www.example.com ./dist
carlos restart
Cycles an app's processes with no new version and no config change. This is the answer to a wedged instance.
carlos restart --app hello
It reports the restart as requested, which is the honest word: the console touches one small object and every box serving the app notices within seconds. Long-running tenants come back within seconds. Exec-backed instances stop within seconds and respawn on their next request, which for an idle app may be a while. Nothing is stuck: the instance comes back with the next request that needs it. Hibernating instances are left asleep.
carlos schedule
Gives an app a timetable. A schedule is a time and a path: at each fire the
app's own instance gets a POST at that path, so the work is a route your app
already serves rather than a separate worker. The sub-verbs are ls, set,
rm and run.
carlos schedule set --app hello --name nightly --every 6h --path /jobs/nightly
--every takes whole minutes, from 1m up to 720h; --cron takes a
five-field expression instead, and you give one or the other, never both.
carlos schedule ls prints the declared schedules alongside what each
instance reports it will do next and what it did last. carlos schedule run --app hello --name nightly asks for one out-of-band run on top of the normal
timetable, and every instance fires within about fifteen seconds.
The wording is deliberate: set records, rm removes, run requests. The
console writes one small object and every box serving the app notices on its
own within seconds; nothing is pushed at a box. The write itself wakes
nothing, and a hibernating instance is woken by its own runner when a tick
falls due. Schedules need a logged-in console; there is no direct-bucket
form.
carlos rollback
Points a channel back at the version it was serving before.
carlos rollback --app hello stable
carlos channels
What each of the app's channels is serving right now, with the tag if the release earned one at stable.
carlos channels --app hello
carlos pipeline
Shows an app's release channels and the rules for moving a version through
them, and shapes that ladder. Bare carlos pipeline is the show verb: the
channels in order, plus any change still waiting on confirmations. init,
add, set and remove edit it.
carlos pipeline init --app hello --template edge-production
Two starter templates exist, edge-production and full-ladder. After
that, --bake <dur> holds a version for a while before the channel may adopt
it, and --passkey, --promote-approvals N and --change-approvals N set how
much human agreement a promotion into the channel, or an edit to the channel
itself, has to collect. set leaves any rule flag you did not type exactly
as it was, so two people shaping different rules do not overwrite each other.
A fresh app has one channel, edge, and prints as a single line rather than
a one-row table. Shaping a pipeline wants a logged-in console: there is no
bucket-direct editor for it. Promoting and rolling back are unaffected and
work in either mode.
carlos releases
Every version shipped, newest first, with the label its author typed and the tag it earned. Channels answer "what is running"; this answers "what is there to run".
carlos releases --app hello
There is one sub-verb. carlos releases retention --keep 20 sets an ambient
prune policy, and --dry-run shows you what it would remove before you commit
to it. The safety list always wins over the number: channel pointers,
rollback history, tagged releases, anything inside the bake window, and each
box's own adopted versions are never pruned, so nothing promoted or recent
disappears by policy. --off goes back to keeping everything.
carlos version
carlos version prints the build id of the binary in front of you. It is the
first thing to check when a command behaves unlike this page describes.
carlos version target is a separate thing wearing the same word: it sets
the semver your ships count iterations under, so carlos ship can mint the
next one for you.
carlos version target --app hello 0.5.0
carlos env
Plain per-app config vars: set, unset, list.
carlos env set --app hello LOG_LEVEL=debug
Values land in the app's default bundle. --environment <name> writes a named
bundle instead, layered on top of the default when config is materialized,
and carlos route --environment is what binds a route to one.
carlos env environments --app hello lists the bundle names an app has.
carlos secrets
The same shape as carlos env, sealed. list prints key names and never
values.
carlos secrets set --app hello STRIPE_KEY=sk_live_example
Sealing uses a public key; the private half lives on the box that decrypts
them and never leaves it, in either mode. carlos secrets genkey mints the
pair, and it is always local to your terminal.
carlos instances
An app's instances are the places it actually runs. carlos instances enable
is the app's opt-in to provisioning, and until it is set the API refuses to
create anything. After that, create, list, delete and set-upstreams
work on records through the console, and the box's reconcile pass turns a
record into a live route.
carlos instances enable --app hello
carlos instances list --app hello
--health <slug> narrows the listing to one state: running, asleep,
not-responding and the rest. That is usually what you want when something
is wrong.
Typed on a box with no sub-verb, carlos instances does something different:
it lists that box's own registry routes, backing and owning unit included.
Off the box, carlos routes answers the same
question through the console.
carlos steering
Decides whether one instance's DNS answer varies by where the request comes
from. latency opts the host into a Route53 latency record per armed edge,
so the nearest one answers; off clears the opt-in and returns the host to
the single static answer every requester shares.
carlos steering --app hello --host hello.example.com latency
The opt-in is recorded immediately and reaches the box's registry row at the next converge tick, but no DNS answer changes until the deployment itself has armed its Route53 steering converger. In v1 only instances on the shared pool can be steered; a pool-scoped route is refused, and the refusal names that as the reason.
A static site on a platform hostname is steered from the moment it is
created — you do not have to ask. Static is the one kind every edge can
serve on its own, straight from the channel pointer, so there is no reason
for one edge to answer for all of them. off turns it back off if you want
a single answer, and nothing re-applies the default to a host that already
exists. Custom domains are not steered for you, because their DNS is yours:
point them at the steering hostname the app's Settings page names.
carlos routes
The app's routes on this deployment: where each one sends traffic, which channel it follows, and which config environment it is bound to. That last column is the one worth knowing about, because it decides which sealed secrets the route's process is delivered.
carlos routes --app hello
It reads the console's own box, so on a multi-box fleet a short answer is not necessarily a complete one. The command says so every time it prints.
carlos domains
Attaches a customer's own hostname to one of the app's routes, detaches it, or lists what is claimed.
carlos domains attach --app hello www.example.com
--route picks which route to point at, and you can leave it off when the app
has exactly one instance record. The hostname comes live on the box owning
that route at its next domains pass, certificate and all. The list verb
joins the claims to the fleet's own readings sweep, so DNS state, certificate
expiry and delegation are readable without leaving the terminal.
carlos features
Sets or lists an app's own feature flags — switches the app defines, the platform stores and serves, and nobody but the app interprets.
carlos features set --app titogo sandbox=on
carlos features list --app titogo
Keys and values are the app's vocabulary (sandbox=on, beta-ui=v2); the
platform never reads them. key= with nothing after the equals clears a key.
The app finds the map on the deployment block its instance token already
fetches beside managed_domains, so a change lands on its next poll — no
restart, no environment edit, no root-owned file on an edge box. Setting is
an admin-role write and every change is audited under the app's Activity;
the app's own instance credential can read the flags but never set them.
carlos logs
The app's own stdout and stderr, merged with the platform's events about it: wakes, restarts, and failures with the reason. A site served from files has no app stream, but its platform events still show up.
carlos logs --app hello --since 1h -f
-f follows, polling every two seconds until you interrupt it. --grep takes
an RE2 pattern, --stream app drops the platform commentary, and --host
narrows to one instance.
carlos store
Object storage for an app. create declares that the app wants a bucket,
which is a request and not a provision — nothing exists in any cloud account
until an operator runs grant. status shows what is declared, granted and
delivered, including a rotation that stopped half way.
carlos store create --app hello
carlos store status --app hello
carlos store rotate mints a fresh credential and leaves the old one live
until you run it again with --finish, so the app has a window to pick the
new one up. Rotating, declaring and reading status are member verbs and want
nothing but your bearer token. grant is the one that spends money in the
deployment's own cloud account, so it also wants an address on
CARLOS_STORE_OPERATORS; that check runs after the account gate, so a
stranger still meets the same 404 as everyone else rather than a 403 that
would confirm the app exists.
carlos email
Tenant email sending. carlos email enable is the whole path in one
command: it declares the From address the app sends as, ensures a sending
domain, waits for SES to verify it, and delivers SMTP credentials to the app
as env vars under a prefix — CARLOS_SMTP unless --env-prefix names
another.
carlos email enable --app hello
carlos email test --app hello --to you@example.com
On a custom domain, enable prints the DNS records to publish and then
keeps polling until SES confirms them, giving up after ten minutes unless
--timeout says otherwise; a wait that gives up exits non-zero and names what
SES never confirmed. carlos email domains add is that same half on its own,
for a second domain on an app already declared.
carlos email test sends a real message and reports what SES said about it.
It sends through a throwaway standalone credential it mints and revokes
around the send, so it never needs the delivered credential's password, and
it proves that mail leaves the building rather than that the console believes
it should.
status shows every domain's per-region verification state, the day's count
against the cap, and whether sending is paused. credentials create mints a
standalone SMTP credential for something not running on CARLOS — a laptop, a
cron box — printed once on that command's output and nowhere else. rotate
mints a fresh delivered credential and leaves the old one live until you run
it again with --finish. pause and resume are operator verbs. None of it
has a direct-bucket form; all of it wants a logged-in console.
carlos ledger
Append-only hash-chained ledgers an app can publish. append adds one JSON
entry to a chain, head prints that chain's current head, list shows the
app's ledgers and what it publishes, and publish decides which of them are
served publicly.
carlos ledger append --app hello carbon entry.json
blob uploads files to a chain as content-addressed blobs and prints each
one's sha, so an entry can point at it. Uploads are create-only and capped at
4 MB a file, and re-uploading identical bytes does nothing.
Ledgers have no direct-bucket mode at all: every sub-verb except verify
goes through the console, because a contributor's machine is never given
bucket credentials. carlos ledger verify walks a published chain over plain
HTTPS and re-hashes every entry. It needs no credentials at all: anybody can
check a ledger you publish, including you, from a machine that has never been
logged in.
carlos vet
Checks a shipped release against the platform contract before anyone promotes it. The manifest has to exist and parse, and every artifact's stored bytes have to match the sha256 and size it claims.
carlos vet --app hello --version a1b2c3d --boot
--boot goes further and runs the binary: it must accept -socket <path>,
serve HTTP on that socket, and answer GET /healthz with a 200 within ten
seconds. That is the entire app contract, and this is the cheapest place to
find out you have broken it.
carlos update
Replaces the carlos binary on your workstation with the latest published
release, checking the signed checksums before it swaps anything. Where a
package manager owns the install, it prints the brew or apt command
instead of fighting it.
carlos update
It refuses to run as root, and refuses on a box: box binaries are rolled by
the platform, and a self-updating box would fight that machinery. -y skips
the confirmation, though it still wants a terminal.
carlos skills
The platform publishes its agent skills at
/.well-known/agent-skills/index.json, per the Agent Skills Discovery
draft. carlos skills lists what is published; carlos skills <name> fetches one, checks
its sha256 digest against what the index claims, and prints the SKILL.md to
stdout — a mismatch means corruption or tampering, and the command refuses
to print it.
carlos skills
carlos skills getting-started > SKILL.md
-index <url> points it at another deployment's index, for a self-hosted
site or a test.
Your deployment
The verbs for the person who owns the boxes. Some of these run against a console like the app commands above; others open a box's registry directly and only make sense while you are standing on it.
carlos bootstrap
Prepares a host to be a CARLOS box: the service user, the data directories, the systemd units. Run it once per host, as root.
carlos bootstrap
--root <dir> writes the files into a staging directory instead, creating no
users and running no systemctl, so you can read what it would do before it
does it. --offcloud is for a box outside EC2, where credentials come from a
staged 0600 file instead of an instance profile.
carlos accounts
Accounts are the tenancy primitive: apps, fleets, credentials and bucket
prefixes all hang off one. create mints an account and prints the sqid
every object underneath it is keyed by.
carlos accounts create --name acme --owner someone@example.com
--owner is the path for a box or a CI job, where there is no browser to log
in with. It needs no identity of its own and takes precedence over any
logged-in one.
carlos accounts migrate copies an app's objects to an account-qualified
prefix and re-stamps its routes. Run it with --dry-run first; it prints the
plan and touches nothing.
carlos fleets
A fleet is a group of remote boxes an account owns, dialing in to this
console over a channel named <fleet>/<label>. Create one against the
customer's own data bucket, then register each box.
carlos fleets create --bucket acme-data acme
carlos fleets add-box acme pi-1
The box's bearer token prints exactly once, at add-box. The console keeps
only its hash, so losing that output means rotate-token; a second add-box
will only refuse the label. Put it straight into the box's credential store.
Rotation is not revocation. A fresh token refuses the box's next dial-in with
the old one, but it does not evict a channel the box is already holding. If a
credential may be compromised, detach-box is the command that actually
stops it.
carlos services
Credentials a server holds rather than a person: a CI job that ships, a sidecar that reads instances. Reach is fixed at mint, so adding you to another account later does not widen it, and it has its own rate-limit budget.
carlos services create --role publish --app hello ci-shipper
Pick the narrowest role that works. publish ships releases, instance is
what a box-side reader wants, operate administers, and admin includes
store grant, which mints a path-scoped IAM user; treat that one as a real
handover. --app binds the credential to one app, which also means it cannot
create apps.
The secret prints once, same as a fleet token, and the same distinction
applies at the other end: rotate refuses the credential's next call with
the old secret, revoke stops it whatever secret it holds.
carlos add
Writes a route straight into a box's registry: this hostname, served by this app, following this channel. It is the box-local, operator-side counterpart to an instance record.
carlos add --app hello --socket /run/hello.sock hello.example.com
--channel sets what the route follows, --kind picks instance, service or
static, and --addr takes a TCP upstream where the app is not a socket
tenant. --unit console.service names the systemd unit that owns the route's
process, which is what lets adoption cycle it; leave it off for an ordinary
exec child.
carlos remove
Drops one of this box's routes. It stops routing the hostname and never touches the instance's database.
carlos remove hello.example.com
carlos route
Changes one thing about a route that already exists. Each axis is its own operation and the command refuses to combine them, because a capability grant and a repoint are different acts with different safety rails, and a command that did both would commit them together.
carlos route --host hello.example.com --channel edge
--channel and --environment repoint, and those two may be given together.
--grant and --revoke change a capability. --hibernate lets the route sleep
when idle. --backing and --unit are two answers to one question — who owns
this route's process — so the command will not let you write both.
Repointing the channel is the half to be careful with. Adoption converges a
route onto whatever its channel currently points at, so moving the channel
changes what production serves on the next pass; the command resolves the
target pointer first and refuses a version change unless you pass
--allow-version-change; --dry-run runs the same rails and prints the plan
without writing. And once a route is Provisioned, its channel and
environment belong to its instance record: carlos route refuses both axes
on such a row and names the record, because the reconciler would revert you
within seconds anyway.
carlos release-keygen
Mints the deployment's root signing key, or a scoped key that can sign only certain rungs of the ladder.
carlos release-keygen --scope canary,edge --name builder
With no flags it prints a fresh root pair: the key goes into your secret
manager, the public half into host config. With --scope it prints a key and
a grant. The grant carries no secret material but has to travel with the key,
because a scoped key on its own signs pointers nothing will accept. Only the
root key can mint a grant, which is what stops a scoped key widening itself.
carlos economics
Records one month's AWS bill so the console's economics dashboard has a real number to divide by.
carlos economics bill --month 2026-08 --total 412.55 --line AmazonEC2=55.00
Operator only, and the console answers everyone else with the same bare 404 it gives an unknown route. A "not found" here almost always means your account lacks the fleet-operator bit, not that you typed the path wrong.
carlos economics backhaul shows what serving through region edges costs
the platform this period: the part of each account's egress that was
backhauled from its home box, priced at the rate card's inter-region
$/GB and absorbed — it appears in nobody's bill.
carlos economics backhaul --period mtd
carlos status
--live prints what every box in the fleet last reported about itself:
release, tunnel state, memory, load, free disk. A box that has stopped
reporting shows as UNREACHABLE with how long it has been dark, and none of
its stale readings.
carlos status --live
--dry-run <dir> is the self-hoster's leak check. It builds a throwaway fleet
stuffed with fixture secrets, runs it through the real publish path into a
scratch directory, reads every published object back, and fails if any
fixture escaped. Run it before you ever point this binary at a real bucket.
Neither mode publishes anything: the public status page is written by each
box's own agent tick.
On the host
These are what systemd starts. You will rarely type them, but knowing what they do explains most of what happens between a promote and a URL changing.
carlos edge
The front door. TLS and ACME, and the proxy that maps a hostname to whatever
is serving it. One per box, started by carlos-edge.service.
carlos edge --dev --http :8080
--dev serves plain HTTP with no TLS or ACME, which is how you run it on a
laptop. --fallback redirects unknown hostnames somewhere (an apex marketing
site, usually) instead of returning the plain 404.
carlos agent
Edge, the hibernation activator, release adoption and the status tick, in one
process per host. This is what a box actually runs; carlos edge alone is
the proxy without any of the rest.
carlos adopt
One release-adoption pass: read each route's channel, resolve what that channel points at, fetch it if the box does not have it, and swap the symlink. The hourly timer runs this verb.
carlos adopt
Adoption swaps a symlink, and on its own it does not restart anything. A long-running unit keeps serving the old build until something cycles it, and a hibernating tenant picks the new one up when it next wakes — so the order that works is promote, then adopt, then restart. A route that names its owning unit is the exception: adoption cycles that one itself, and rolls the adoption back if the restart fails, so the version header never claims a build the process is not serving.
--restart-only and --config-only are the narrower passes the agent asks a
root-side unit to run for it, since the agent can request a pass but cannot
write to /etc or restart units itself.
carlos ops
Box operations, run by a timer or by an operator standing on the box.
litestream regenerates the replication config from the registry and the
data directory, printing changed or unchanged so the caller knows whether
to restart replication. restore-verify restores every configured replica
from S3 and integrity-checks it. hibernate-verify does the same drill for
the replicas the config does not name — hibernating instances, whose
replication belongs to the activator — and publishes a per-host verdict to
the deployment bucket so you can read it off-box.
carlos ops restore-verify
Everything else
carlos help belongs to all three roles above; carlos canary belongs
to none of them yet.
carlos help
Prints the command list, grouped the way this page is. It goes to stdout and
exits 0, so carlos help | less works. A bare carlos with no arguments
prints the same text on stderr and exits 2, because that one is a mistake.
carlos canary
Reserved, and not implemented: typing it exits with not implemented.
Canary releases today are ordinary channels named canary/<something>,
promoted with carlos promote like any other rung.
Read this page as markdown — exact, unstyled, and cheap for an agent to fetch.