carlos / todomvc-bakeoff Public
π€ The CARLOS platform, member-side
What carlosframework/platform does for an app, and the carlos CLI surface a member drives it with. Everything here works with zero infrastructure access β no SSH, no AWS console, no box commands. That is a design law, not a convenience: every command is built for a user who has nothing but the CLI and a browser. If a task seems to need a box, either you are self-hosting and operating the platform itself, or you have found a product gap to file β never a workaround to build.
Snapshot date 2026-10-18, describing what Carloku serves: platform v0.28.0, released 2026-10-17. Verbs are stable, flag details evolve β trust carlos <verb> -h over this file, and remember a CLI can offer a flag the console you are talking to has switched off or does not have yet ("Switched off, or not released" at the end). The CLI ships for macOS/Linux (brew, apt, static binaries) and Windows (client-only zip β no self-replace, carlos update defers to a fresh download).
Flags are written --flag. Single-dash forms still parse and always will, but as of 2026-08-27 the documented face of every verb is the double dash, and the aliases are -a (--app), -c (--console), -e (--environment), -v (--version). Three flags turn up nearly everywhere and are not repeated below: --app, --account (or --account-id when you want a sqid matched as a sqid and nothing else), and --console, which you need only when this terminal is logged in to more than one deployment.
Start with carlos auth whoami
Before concluding you cannot reach a CARLOS deployment, run it:
carlos auth whoami # the default console
carlos auth whoami --console https://<console> # a specific oneIt prints the identity, the terminal's label, the expiry, and every account with its sqid β which is also where you find the sqid your app's hostname needs.
This is first because the obvious check is the misleading one. An agent's instinct is to curl the console and read the answer: the console will 302 you to a login page every time, because a browser session is not what the CLI holds. That redirect says nothing about whether you have access, and reading it as "no access" has already cost one session an afternoon and ended in a filed issue. The CLI's credential is the only thing that answers the question.
Same for the deployment's identity: a self-hosted console is not carloku.com, and the console origin and the apps domain are different hostnames (Tito's are carlos.tito.io and platform.tito.io). If a project commits a .carlos/config, that names the console it belongs to; otherwise --console does.
What the platform owns (never hand-roll these)
- Routing and TLS β the edge is the only process on :443; the route table doubles as the ACME allowlist; per-host certs auto-obtained and renewed, including customer domains and (via ACME delegation) wildcards.
- Replication β Litestream on every instance database, run by the platform's host agent; restore drills are the platform's job too. A hibernating route's rung is tunable (
streamβ the default, about once a second β /batched, five minutes /daily), a loss-window trade an operator makes on a large, rarely-written database. - Hibernation β provisioned instances doze when idle and wake on request; live and default-on, not a future feature. Cents-per-month idle cost is the platform's economic story.
- Process supervision and restarts β instances are converged from bucket records;
carlos restartcycles them in seconds, console-side. - Config delivery β
carlos env/carlos secretswrites converge onto boxes within seconds (a serial bump, 2s poll). No ssh-delivered EnvironmentFiles. - Deploy verification β
carlos deployfollows the rollout box by box where boxes report it (they do on Carloku), and otherwise watches the URL: the app's build probe if it declares one (carlos apps build-probe), else theX-Carlos-Versionheader, which the edge stamps from the route row the moment adoption lands, whether or not the process has restarted yet. - Outbound email β the platform mints a sending identity on a domain, publishes DKIM/SPF/DMARC where it controls the zone, and delivers working SMTP credentials as config. Apps never hold an AWS key and nobody runs an MTA. See "Sending email" below.
- Scheduled work β the platform keeps the clock: a declared schedule POSTs a tick to a path in your app at the time it is due, waking a hibernating instance to run it. No in-app scheduler, no cron line on a box. See "Scheduled work" below.
- Errors, availability and analytics β the edge and the log stream feed all three, with no SDK, no JavaScript, no cookie and no stored IP. Errors and availability collect from the day an app ships; analytics is opt-in. See "Watching an app" below.
- Password gate β
carlos gateputs one shared password in front of an app's hostnames, enforced at the edge ahead of the cache and the activator, so the app behind it never sees the cookie. A curtain over a hostname, not accounts for the app's own users. - DNS steering β
carlos steering ... latencyopts a host into a latency record per armed edge so the nearest one answers. A static site on a platform hostname is steered from creation without asking; a custom domain is not, because its DNS is yours.
Concepts
- Account β the tenancy unit. Public short id (sqid, e.g. three letters) appears in default hostnames; it is not a secret. Roles are owner/member.
carlos auth whoamishows yours. - App β named per account (not globally). Claimed with
carlos apps create; deleted apps sit in a 30-day trash. - Release β immutable, content-addressed, produced by the ship half of
carlos deploy(or bycarlos shipon its own). Kinds:binary(default) andstatic. Versions are free-form; git short sha is the house convention. - Channels and pipelines (release pipelines v2, live 2026-08-22) β a new app is born with one channel, named
edgeby default (renameable at creation): that is its production, andcarlos deploylands on it. Multi-channel ceremony is opt-in: a console-mediated pipeline declares an ordered channel list (names are app-defined; promotion onto channel N must come from Nβ1; the first is the entry channel) with per-channel, default-permissive policy βbakeduration,passkeystep-up,promote_approvals, andchange_approvals(which also guards editing/removing the channel and fast-tracking through its bake).canary/<slug>stays a reserved platform namespace outside any pipeline: always allowed, zero bake, per-session dead ends. Apps with no pipeline keep the legacy frozen ladder (edge β beta β stable, holds 0/24h/72h, unconditional passkey on stable β reaching stable cuts a semver tag).--hotfix, the bake bypass, is an operator act on the direct-bucket path only: the console refuses it by name, so a member never has it. Box-side, bake changes ratchet: a shorter window is honoured only after the previously-known window has elapsed once on the box's own clock, so a compromised console session cannot collapse a hold and ship in the same hour. - Production flag β legacy: superseded by per-channel pipeline rules for pipelined apps, still honoured by legacy boxes/apps. Its sharp edge is recorded: a stable promote plus the console's default-checked safety delay once left a hibernating app unwakeable for days β ceremony belongs on channels you chose, not on defaults.
- Instance β one account's running process for an app on a host. Declared console-side (
carlos instances enableonce per app, theninstances create --host β¦); a box reconciler mints the actual route. Exec-backed and hibernating by default. The process contract is<bin> --socket <path> --db <path>on a unix socket β there is no$PORTβ and every instance servesGET /healthzandGET /api/version. Every exec child is handedCARLOS_HOST, its hostname exactly ascarlos instances listshows it β read it, never the socket's file name, which says nothing about the host on an isolated box β andCARLOS_REGION, the placement code of the box it runs on (ie,au, β¦), so an app that wants to know where it is can read it rather than guess. Since v0.26.0 the platform also writes aCARLOS_INSTANCE_KEYinto the instance's own env before its first start: a per-instance secret key for sealing the instance's own data. It is not an API token and opens no platform door. On a box that has isolated its instances (v0.25.0), each instance runs in a box of its own: its own directory and namespaces, seeing only its own app's build, under a memory ceiling set from the staged manifest plus CPU and IO limits, and with no access to loopback or private addresses on the box except its DNS resolver β so an app cannot reach a sibling over127.0.0.1, and one that grows past its tier is OOM-killed and restarted rather than allowed to take the box's other tenants with it. On such a box the working directory is the instance's data directory, the one its database is in, so a relative path like./data/blobslands beside the database. A static site has no instance β the edge serves it off the channel pointer, and giving one an instance produces a route that can never wake ("Deploying a static site" below). - Config environments (bundles) β
carlos env/carlos secretswrite the app's default bundle;--environment <name>writes a named one, layered on top of the default when config is materialized. A route is bound to a bundle, and on a provisioned route that binding lives in the instance record:carlos instances create --environmentat birth,carlos instances set-environmentafterwards. (The box-localcarlos route --environmentis reverted by the reconciler within seconds and refuses such a row by name.)carlos env environmentslists an app's bundles, andcarlos restart --environment <name>cycles exactly the routes that read one. - Canary β
carlos canarystands a second instance beside production on a channel and hostname belonging to one branch:canary/<slug>and<slug>-<app>.<sqid>.<domain>(one label since v0.24.0, so slug and app together are at most 63 characters), the slug defaulting to the git branch. Promotion into a canary is never refused and costs no rung β canary channels sit outside every pipeline, so a build that has ridden one still enters the pipeline fresh. Seven-day lease, refreshed on every re-promote. It is a URL, not a traffic split. - Service credential β a bearer token a server holds rather than a person: a CI job that ships, a signup flow that creates an instance per customer.
carlos services create --role <r> --app <app> <name>mints one; reach is fixed at mint and bound to one account and (with--app) one app. Roles, narrowest first:deploy(for CI: ships, and promotes or rolls back only the rungs named at mint βservices create --role deploy --app <app> --rungs canary,edge ci, neverstable, and onlycanary/<name>channels on an app that declares a pipeline; no hotfix, no app creation, so a new app's first deploy still needs a person; CI setsCARLOS_SERVICE_TOKENandCARLOS_CONSOLEand runscarlos deployas usual),publish(ship releases),instance(create, delete, repoint and move the app's instances, attach its domains, write its path routes, read its logs, errors and analytics),operate, andadmin(which includes minting store IAM users β a real handover). No role can runcarlos instances enableor mint another credential. "Provisioning instances from your own app" below. - Path routes β one hostname split by its first path segment:
example.com/acmeserved by one route,example.com/globexby another, everything else by the hostname's own route. A prefix points at an existing route and borrows its channel, config, hibernation and billing; it defines nothing of its own. "One hostname, many instances" below. .carlos/configβ two layers, global~/.carlos/configand per-project./.carlos/config(committed; nearest wins walking up). Holds console, account, app, kind, artifact β the reason zero-argumentcarlos deployworks β and optionally nameddestinations, one per console or app a checkout deploys to, picked with-d <name>on any verb that takes--console(carlos config destinationsedits them). Project values are honored only when the file's console matches the session's, so a committed config cannot silently redirect someone else's credentials.- Default addresses β
<app>.<sqid>.<apps-domain>(on the hosted platform,oncarlos.com); canary form<canary>-<app>.<sqid>.<domain>. Minted alias hosts intentionally carry noX-Carlos-Versionβ verify on the canonical host.
Describe changes in Activity
Agents pass --message explicitly for ship, deploy, promote, rollback, restart, instances create, instances delete, features set, env set, env unset, and env exec-delivery. Use a concise sentence describing the intended change or why it is needed: carlos deploy --message "Fix invitation links after an email address changes". The console records the exact action, version, channel, or instance separately; repeating only a commit hash or "deploy app" adds no useful context. Do not invent an outcome you have not verified, or include secrets or config values.
Messages are optional, one line, at most 240 Unicode characters. They are separate from release labels and release notes. Interactive CLI commands ask for a description by default; an empty answer skips it. Noninteractive commands do not prompt. --no-prompt suppresses the question for a command, and "message_prompt": "off" in .carlos/config disables prompts by default. Agents should still supply --message when prompts are disabled. Explicit --message still applies with --no-prompt or config suppression. deploy asks once and uses the same description for its ship and promote steps.
The member CLI
| Verb | What it does | |---|---| | carlos auth login\|whoami\|logout\|default | Device-code login (approve in any signed-in browser); identity + memberships; per-project default console | | carlos apps create\|list\|place\|set\|build-probe\|delete\|restore | Claim an app (a name whose platform address is already held is refused, and tmp- names are reserved); list every app, the Trash included; place it on a customer fleet; set --swap overlap\|hold and --swap-concurrency choose how its instances take a new build ("Two builds at once"); build-probe /api/version makes deploy ask the process which build it runs rather than trust the header β worth setting on anything you deploy often; trash/restore | | carlos deploy | Release new code β reach for this one. ship + promote + watch until the new build serves; zero-arg with a saved project config, -d <name> for a named destination. Follows the rollout box by box and names a box where the build failed. --channel to land somewhere other than the entry channel (a canary); --swap hold for a release that cannot run beside its predecessor. Also the WHOLE story for a static site: --kind static --host <h> <dir> declares the route as well as shipping it ("Deploying a static site") | | carlos ship | The ship half alone: publish a release without releasing it (--kind binary\|static, --version, --notes); refuses a Go binary whose main package differs from the app's last release unless you pass --program-change (deploy has no such flag: ship, then promote); rate-limited per app (~2/minute β a 429 carries Retry-After) | | carlos promote | The promote half alone: move an already-shipped version onto a channel β a ladder step or a re-point, not how you release new code. The channel is positional, and optional when the app has only one. --retry re-tries a build that failed on some box, once you have fixed what it depended on. (--hotfix is refused by the console: an operator's direct-bucket act) | | carlos canary | Deploy onto a branch-only channel + hostname, waiting on the canary URL's own X-Carlos-Version; ls / rm. Reach for --environment if the app carries config β see "Standing a canary beside production" | | carlos rollback | Point a channel back at an earlier version | | carlos pipeline | Show or shape the app's release channels; init --template edge-production\|full-ladder replaces the single default channel with a starter pipeline | | carlos channels / carlos releases | What each channel serves / every shipped version; releases retention prunes old ones | | carlos version target | The semver family ships auto-increment under | | carlos env / carlos secrets | Plain vars / sealed secrets, layered per environment; env sync forces convergence; secrets genkey mints keypairs locally | | carlos instances enable\|create\|list\|delete\|set-upstreams\|set-channel\|set-environment\|set\|update\|retry\|door-key | Opt an app in (enable --private-storage gives every later instance its own environment and bucket); declare/inspect/remove instances; reorder or remove upstreams (adding an upstream address is operator-only since v0.25.0); move a route onto another channel or config bundle. list shows INSTALLED and SERVING builds and a REASON when they differ; retry <host> clears a failed build's hold on one instance; set <host> --updates manual\|--pin <build> and update <host> steer how one instance takes builds; create --role replica --region <r> makes a replica near buyers, holding a one-way copy of the live instance's data (the live instance names allowed regions with --replicas at its own create); door-key manages keys for the edge-local door, which a box operator must turn on. --health running\|asleep\|not-responding narrows a listing, which is usually what you want when something is wrong. For binaries only β a static site needs none of these, and carlos deploy declares what it does need | | carlos restart | Cycle an app's processes β no version or config change. --host narrows to one route, --environment to every route reading that bundle (the request that follows a secrets set), --dry-run prints the set. Sleeping instances are left asleep | | carlos routes | The app's routes through the console: upstream, channel, config environment, replication rung. It reads the console's own box, and says so on a multi-box fleet | | carlos steering | latency opts a host into a per-edge latency DNS record; off returns it to one static answer. Shared-pool instances only in v1 | | carlos features set\|list | The app's own feature flags β the platform stores and serves them, only the app interprets them. Lands on the instance's next deployment-block poll: no restart, no env edit, no box file. key= clears | | carlos gate set\|list\|clear | One shared password in front of the app's hostnames, custom domains included. set reads stdin when --password is absent; changing or clearing signs every visitor out | | carlos errors | One row per distinct error, newest first β error-tagged log lines plus the 500s the app itself answered, no wiring at all. --fp <fingerprint> expands a group; --set-level warn widens what counts | | carlos availability | Every 502, 503 and 504 the edge saw (the instance could not answer), grouped by cause, plus failed wakes and exits; one outage is one row. --fp expands a cause to each request. The Availability tab beside Errors | | carlos analytics | Page views, daily visitors, bytes, top pages/referers/events, counted at the edge. --set-count edge turns it on; --range today\|7d\|30d | | carlos logs | Merged app + platform + edge timeline (-f follows, --grep, --since) β no box access | | carlos db pull\|query | A read-only copy of an instance's database, restored from its replica on your machine; the live one is never touched. --at <RFC 3339 time> reads it as it was then (sleeping instances keep seven days of history). --instance when the app has several | | carlos run --app <a> --instance <host> -- <command> | Run one of your app's own commands as that instance, with its environment and database, output streamed as it arrives (v0.28.0). --file uploads files with the run (64 MB in total), --timeout (30 minutes by default, up to 6 hours), --follow <id> reattaches. A sleeping instance is woken; a live one keeps serving beside it; one run at a time per instance, refused while it moves or if its box is not yet on the nested layout. The way to run a one-off task against production without a box | | carlos geo <address> | What the X-Carlos-Geo-* headers would say for any address, with the credit the database requires | | carlos sidecars enable\|create\|ls\|wake\|logs\|rm | A process the platform runs for the app with no socket, hostname or DNS: a bot holding a connection out, a worker. Woken only by its schedule or wake | | carlos config destinations | Name the places one checkout deploys to in .carlos/config; -d <name> picks one | | carlos domains attach\|detach\|verify\|list\|paths | attach --app <a> '*.<parent>' (quoted) gives every instance a name <label>.<parent> with no per-instance claim; <parent> must be attached first and a * DNS record must point at the deployment. verify finds the TXT record a deployment that requires domain proof asks for. paths <host> prints the hostname's published path-route map (written through the API β "One hostname, many instances"). Claim customer hostnames (--wildcard, --catchall); prints the DNS records to create; certs follow automatically. Since 2026-08-27 the platform orders and renews those certs itself, and list joins the fleet's readings sweep, so DNS state, certificate expiry and delegation are readable from the terminal β a certificate the platform has given up on now says so instead of sitting pending forever | | carlos store apply\|create\|status\|rotate\|scan | Object storage. Once per account, first: an owner runs store apply --account <a> --note "<why>" and a deployment operator approves it β until then create has nothing to provision into, and status says the account is unapproved or pending. After approval create is the whole member workflow (unlimited stores, usage metered); --environment <e> gives one config bundle its own bucket and credential; credentials arrive as env; rotate is two-phase (--finish retires the old key). scan is opt-in virus scanning over the bucket's objects (request/status are yours, grant/revoke/sweep/enforce a deployment operator's) β and a scanner cannot read ciphertext, so a client-encrypted store is marked not applicable rather than given a coverage claim | | carlos email enable\|status\|test\|domains\|events\|credentials\|rotate | Declare sending; provision a verified domain (domains rm frees one); SMTP credentials arrive as env; events shows what became of each message for 30 days, events push --path posts them to the app, signed (pause/resume are a deployment operator's) | | carlos relay enable\|status\|disable | The platform's own STUN/TURN relay for an app's WebRTC calls. enable delivers CARLOS_RELAY (base64 JSON: per-region ICE URLs, a key, a username suffix) and the app mints a TURN credential per call from the key; status shows issue, pickup and per-region health. enable refuses, with a sentence, on a deployment that runs no relay. Never run coturn or buy a TURN service | | carlos services create\|ls\|rotate\|revoke | Server-held credentials (--role deploy\|publish\|instance\|operate\|service\|admin, --app to bind one app, --rungs for deploy); the secret prints once; rotate refuses the old secret on its next call, revoke stops the credential outright | | carlos schedule ls\|set\|rm\|run | Declare recurring work per app (--every 6h or --cron "0 8 * * *" β a POST to a path your app serves); ls shows next/last per instance; run fires one now | | carlos ledger append\|publish\|verify | Open hash-chained per-app ledgers (the transparency machinery) | | carlos accounts create\|list\|migrate | Mint/list accounts; move an app between them. An account is your tenancy on the platform β never create one per customer of your app (customers are instances: "Provisioning instances from your own app") | | carlos skills | List the deployment's published agent skills (/.well-known/agent-skills/index.json) and fetch one, digest-verified, to stdout | | carlos fleets create\|add-box\|rotate-token\|β¦ | Bring-your-own-boxes fleets that dial the console | | carlos update | Update the CLI binary itself (signature-verified; defers to brew/apt) | | carlos vet | Check a release against the platform contract |
Box-side verbs exist (edge, agent, adopt, route, add, ops, bootstrap) but they are the operator's surface for running a platform deployment β a member never types them, and an agent reaching for them on a member task has taken a wrong turn.
Provisioning instances from your own app
A product where every customer gets their own instance creates those instances itself, at signup, through the same console API the CLI uses. Nothing here needs a box, and nothing here is a workaround:
- A person enables the app once β
carlos instances enable --app <tenant-app>. This writes the manifest that bounds which domain, pool and regions any instance may be created in, and no credential can do it. Add--private-storageand every instance created afterwards gets a config environment named after its host's first label with an object store bound to it, provisioned by the ordinary create call (the account needs an approvedstore applyfirst, and nothing is torn down when an instance is deleted). - Mint the credential β
carlos services create --role instance --app <tenant-app> signup. Store it withcarlos secrets seton the app that runs signup, never on the tenant app itself: every tenant instance would then hold a token able to delete its siblings. - Create on signup β
POST /api/cli/apps/<account>/<tenant-app>/instanceswithAuthorization: Bearer <credential>and a JSON body;hostis required (one DNS label plus the manifest's domain),region,channel,environmentandmessage(the Activity description) are optional. The console writes the record and announces it, and a box mints the route within seconds. - Wait for the real thing before sending the person there: poll
https://<host>/api/versionfor a 200 (a sub-500 answer is not evidence), behind a zero-JS "getting your space ready" page that refreshes itself.
Give every label a random suffix (<slug>-<6 random base32 chars>, the label being the host's first DNS segment). That makes a collision with somebody else's instance a first-call event, so the one retry below can read its 409 without guessing.
The answers to handle, from the apps that run this:
| Status | Meaning | Do | |---|---|---| | 201 | Created | Wait for /api/version, then hand off | | 409 | That host already exists (or a sibling app owns the label) | Never treat it as yours: pick a new label. On a retry of your own timed-out call, a 409 means the first call worked | | 429 | The credential's own rate limit (it is separate from any person's, so a signup burst never starves your promotes) | Show a sentence and a retry; never loop | | 502 / timeout | The console could not complete the write | Retry once with the same host | | 400 | Bad body, or a host that is not one label plus the manifest's domain | A bug on your side; fail loudly | | 401 / 404 | Bad credential, or the wrong account or app (every miss is the same 404) | A configuration bug; fail loudly |
Also on the same credential: GET .../instances (list), GET, PATCH (reorder or remove addrs, set the probe β adding an upstream address is operator-only since v0.25.0) and DELETE .../instances/<host>, and POST/PUT .../domains for customer hostnames. Deleting an instance removes its record, never its data β the parked database and blobs are left in place on purpose, and the call is idempotent.
The known gap: the instance role is app-wide. One credential can create, delete or move every instance of the app, not only the ones it made. That is why it lives on the signup app alone, and why a signup app compromise must be named in your published trade-offs. A narrower, create-only credential is a filed platform request β do not build your own token broker around it.
One hostname, many instances
Path routes let one hostname β your product's own domain β serve each customer from their own instance, so example.com/acme and example.com/globex are two processes and two databases behind one address. The hostname's own route keeps everything else (/, sign-in, marketing).
- The hostname must be one the app holds:
carlos domains attach --app <hub-app> example.comfirst. - The map is written whole through the API, by a person or by the hub app's
instance/publishcredential:PUT /api/cli/apps/<account>/<hub-app>/domains/example.com/pathswith{"paths": {"acme": "acme.<tenant-domain>", "globex": "globex.<tenant-domain>"}}. Keys are one bare path segment (no slash); values are hostnames of existing routes. Publish the full map from your own records every time β a lost delta desyncs silently, a whole republish repairs.{"paths": {}}clears it; a body without thepathswrapper is refused, so a typo can never wipe the map. - Targets must belong to the same account (they may be another app of that account β a hub app pointing at tenant-app instances is the intended shape).
- Targets must live on the same box as the hostname's own route. A target homed elsewhere is silently ignored and its prefix falls through to the hostname's route. That caps a path-routed product at one box today; cross-box path targets are a filed platform request.
- A write goes live in about five seconds.
carlos domains paths --app <hub-app> example.comprints what was published;carlos routesshows what the box actually applied. - The upstream receives the original path (
/acme/...) unchanged, and the target route is what is billed, versioned and woken. - One hostname is one browser origin. Cookies, storage and script are shared across every prefix, so every tenant's pages can act on the hub's session. That is fine while every tenant runs your app's code; if tenants serve HTML their own customers wrote, give each tenant its own hostname instead.
Deploying a static site
A static site is one command, and the command is the same one binaries use:
carlos apps create --app mysite
carlos deploy --app mysite --kind static --host mysite.<sqid>.oncarlos.com ./siteThat ships the directory as an immutable release, promotes it to the app's entry channel, declares the route, and watches the URL until X-Carlos-Version reports the build. Nothing else is needed β not carlos instances enable, not carlos instances create, not a separate carlos domains attach.
--host is required on the first static deploy and only that one. A static app has no instance record yet, so there is nothing to resolve the host from; that deploy writes the record, and afterwards plain carlos deploy resolves host and channel by itself. Give it a custom domain and the deploy attaches that too, printing the DNS records to publish.
Expect the URL to take up to a minute after promoted prints. A static route trusts its resolved channel pointer for about 60 seconds, so the old page can outlive the promote by that long β carlos deploy already waits past one such window, and says so while it waits.
A static site has no instance, and must not be given one
This is the trap. The verbs read like the binary recipe β enable an app for instances, create one, point it at a channel β and every step succeeds. The result cannot work.
A static site is served by the edge straight off the channel pointer: there is no process, nothing hibernates, nothing wakes. An instance is a process the platform execs. A static release's manifest carries a site tarball and no build for any box's architecture, so an instance route on that channel finds nothing to run and fails every wake, permanently.
Older platform builds reported that failure as "nothing is promoted for this instance's channel" β while carlos channels showed the promote sitting right there. Both statements were true and neither named the problem, which is the shape of the route, not the state of the channel. Current builds name it and refuse the create; if you meet the old message, this is what it means.
The way out, on an app already in that state:
carlos instances delete --app mysite --host mysite.<sqid>.oncarlos.com
carlos instances create --app mysite --host mysite.<sqid>.oncarlos.com --kind static --channel <ch>The already-shipped release needs no re-ship β a static route reads the channel pointer, so the site serves within seconds of the record landing.
Channels belong to the app, not to the platform
A new app is born with one channel, edge by default, and that one channel is its production. More are opt-in, declared by a pipeline, and named whatever the app calls them.
So do not guess a channel name from the canary β edge β beta β stable ladder in --help: that is the frozen legacy ladder an app inherits only when it declares no pipeline. carlos channels lists what this app actually has and carlos pipeline shows the order. carlos promote <version> with no channel resolves it when the app has one, and names the app's real channels when it has several.
Standing a canary beside production
Review happens on a deployed canary, never on localhost, and one command does it:
carlos canary --app jam ./jamThe name defaults to the slugified git branch, so branch new-queue gives channel canary/new-queue and host new-queue-jam.<sqid>.oncarlos.com. An explicit --name is validated rather than quietly repaired, because the same string has to serve as both a channel segment and part of a hostname label: lowercase letters, digits, dashes, and at most 63 characters with the app name. carlos canary ls shows each canary's host, what it serves, which config bundle it is bound to and what is left of its seven-day lease; rm deletes the instance record and the host stops being served. The channel lapses on its own.
If the app carries config, pass --environment. This is the trap, and it is worth stating in full because the failure looks like an app bug. Config in CARLOS is per-app, so a canary with no bundle of its own serves the app's default bundle β where every origin-shaped value names the app's main host. Anything keyed to that single scalar refuses requests on the canary hostname: the Origin header the app compares against, magic-link and callback URLs, a WebAuthn RP ID, the cookie Secure flag. The result is a canary that serves every GET perfectly and fails every form submission. Write the bundle first, then bind it:
carlos env --app jam --environment jam-canary JAM_ORIGIN=https://new-queue-jam.<sqid>.oncarlos.com
carlos canary --app jam --environment jam-canary ./jamThe same applies to any second instance standing beside a live one, not just a canary β carlos instances create --environment, or carlos instances set-environment on a route that already exists, followed by a restart to pick the values up.
Watching an app: errors, availability and analytics
All three read from the terminal or the app's dashboard tabs. Errors and availability collect from the day the app ships β nothing to turn on. Analytics is off until you switch it on (a deliberate ruling, 2026-09-07: counting visitors is the app's call, not the platform's).
carlos errors --app hello --since 24h
carlos availability --app hello --since 24h
carlos analytics --app hello --set-count edge
carlos analytics --app hello --range 7dcarlos errors needs no wiring at all: it groups error-tagged log lines (or warn too, with --set-level warn) together with the 500s the app itself answered, one row per distinct error with a count and a location. --fp <fingerprint> expands one group to its occurrences.
carlos availability (since v0.22.0) holds what used to sit in errors: every 502, 503 and 504 the edge saw, meaning the instance could not answer, grouped by cause (refused, closed mid-request, timed out, no box connected, or the app's own 503), with failed wakes and exits beside them. One outage is one row however many pages it hit; --fp expands a cause to each request and path. A visitor who gives up after five seconds or more without response headers is filed as an outage, so an endpoint that holds its headers by design, like a long poll, shows a standing row there, and that row is expected.
carlos analytics counts at the edge as it serves the app: page views, daily visitors, bytes served, top pages, top referers, named events β no JavaScript, no cookie, no stored IP, which is why an app on CARLOS has no business reaching for a third-party analytics script. Visitors are a sketch, so a multi-day total is daily visitors summed rather than a deduplicated unique count. The switch is per-app or account-wide. Where the deployment publishes a GeoIP database, page views are counted by country too.
Neither replaces carlos logs, still the merged app + platform + edge timeline (-f, --grep, --since, --stream app, --host) with no box access.
Sending email
The platform issues SMTP credentials. An app declares that it sends, the console provisions an SES identity for a domain, publishes the DNS records where it controls the zone, and delivers working credentials as config. The app never holds a cloud key and nobody runs an MTA.
One command does the whole walk β declare, provision the domain, wait for verification, deliver:
carlos email enable --app <app>The identity defaults to the app's own host (<app>.<sqid>.oncarlos.com), so the signing domain matches the domain of the links inside the mail. Credentials arrive as env β CARLOS_SMTP_HOST, _PORT, _USER, _PASS, _FROM. Transport is submission on 587 with STARTTLS. --env-prefix MAILER renames the whole set to MAILER_HOST, MAILER_PORT, and so on, for an app that already reads its own names.
For a customer's own domain:
carlos email enable --app <app> --domain mail.example.comOn the platform domain CARLOS publishes the records itself and verification is usually seconds. On a custom domain it prints the record set for the zone's owner to publish, then polls until SES confirms it (--timeout, default 10m; a gave-up wait exits non-zero and names what SES is still waiting for). carlos email domains add is the same provisioning step on its own, for adding a second sending domain to an app that already sends.
The record set a customer has to publish is always these five, for <domain> and the region the identity was first verified in:
| Name | Type | Value | |---|---|---| | <token>._domainkey.<domain> Γ 3 | CNAME | <token>.dkim.amazonses.com | | mail-<region>.<domain> | MX | 10 feedback-smtp.<region>.amazonses.com | | mail-<region>.<domain> | TXT | v=spf1 include:amazonses.com ~all | | _dmarc.<domain> | TXT | v=DMARC1; p=none; adkim=s; aspf=s (plus rua= if the deployment sets one) |
The SPF record sits on the MAIL FROM subdomain rather than the identity domain because that is the domain SPF is checked against.
The published DMARC policy is p=none, deliberately: a tenant whose very first send is rejected by its own DMARC record learns nothing useful from the failure. There is no carlos email verb that changes it. On a custom domain the zone is the customer's, so they can tighten it themselves whenever they choose β read the DKIM note below first.
Then:
| Verb | What it does | |---|---| | carlos email status --app <app> | What is declared, which domains verified, what was delivered | | carlos email test --app <app> --to me@example.com | Sends a REAL message through a throwaway credential, then revokes it | | carlos email credentials create\|list\|revoke | Standalone credentials for something not running on CARLOS; the password is shown once | | carlos email rotate --app <app> [--finish] | Two-phase key rotation β new key delivered, then the old one retired |
Three things that will bite
- A freshly minted credential does not work for the first minute or two. SES's SMTP endpoint does not see a new IAM access key immediately, and the symptom is
535 Authentication Credentials Invalidβ which reads exactly like a wrong password and is not one. Wait and retry. An app's first sends straight afterenablehit this too, so do not diagnose a broken credential from the first minute of logs. A 535 that outlasts a few minutes is a different problem and is worth treating as real: check the key is stillActiveand not paused (carlos email status), and that the password was derived for the same region as the SMTP host β the derivation is region-scoped, so a credential minted for one region never authenticates against another's endpoint. - DMARC alignment rests on DKIM alone. Records are published with
adkim=s; aspf=s, and that strictness is what stops one tenant's signature authenticating another tenant's From address on a shared domain. SPF authenticates but does not align: the envelope sender is a region-qualified subdomain (mail-<region>.<domain>), which strict alignment treats as a different domain. DMARC therefore passes on the DKIM rail only. That is normal for SES β but it means DKIM is the single rail, so never turn off DKIM signing on a sending identity. If a customer tightens their own zone top=reject, that is safe but unforgiving: a DKIM CNAME later dropped from their zone stops being a downgrade and becomes total delivery failure. - The MAIL FROM subdomain carries its region forever. It is fixed at the first verification (
mail-eu-west-1.<domain>). On a custom domain that record lives in the customer's zone β the one record CARLOS cannot republish for them β so moving regions later means going back to the customer for a DNS change.
Scheduled work
The platform keeps the clock. A hibernating instance cannot run an in-process timer, so recurring work is declared from outside the app and delivered as a tick: a POST to a path you declared, at the time it is due, waking the instance first and holding it awake while your handler runs. Nothing else crosses the boundary β no payload, no job state, no queue. Live 2026-08-24.
Declare it with the CLI, not in code:
carlos schedule set --app <app> --name sync --every 6h --path /jobs/sync
carlos schedule set --app <app> --name reminders --cron "0 8 * * *" --path /jobs/reminders--every is a Go duration, whole minutes, 1m to 30d. --cron is five UTC fields (min hour dom month dow, with *, n, a-b, */n, a,b) β no names, no @daily, no seconds. Names are ^[a-z0-9][a-z0-9-]{0,31}$, at most 20 per app. carlos schedule ls prints the declaration and then what each instance reports (next, last, last_status); run requests an out-of-band fire on top of the normal rhythm; rm removes the declaration. The verbs say recorded and requested, never "fired": they write one console object, and each box acts on it within seconds.
The app's whole part is a handler on that path:
// POST /jobs/sync
func handleSync(w http.ResponseWriter, r *http.Request) {
if !carlos.Tick(r) { // amadan.net/rastrillo/rastrillo/carlos
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if occ, ok := carlos.TickOccurrence(r); ok && alreadyDone(occ) {
w.WriteHeader(http.StatusNoContent)
return
}
if err := syncer.RunOnce(r.Context()); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) // 5xx = retry
return
}
w.WriteHeader(http.StatusNoContent)
}carlos.Tick is the constant-time compare of the bearer against $CARLOS_ADMIN_TOKEN, which the platform mints into the instance's environment; use it rather than hand-rolling the check. A tick arrives on an ordinary public route, so a tick nobody authenticated is an internet request to that path β and the X-Carlos-* headers are not evidence, anyone can set those. The status is your whole reply: 2xx done, 5xx retry with backoff (five attempts, 1m doubling to 15m, then the schedule advances), 4xx don't bother.
One-shots are the app's own call, over the control socket the agent binds before your process starts:
err := carlos.ScheduleAt(ctx, "remind-"+id, when, "/jobs/remind") // carlos.ScheduleCancel drops itSame tick, same handler, same name namespace as declared schedules (a collision returns ErrDeclaredSchedule). Up to 400 days ahead, 1000 pending per host. Reach for one only when the work genuinely has a specific time; a daily schedule whose handler asks its own database "what falls today?" beats one timer per row.
Things that bite
- Do the work inside the request. The instance is held awake for exactly as long as the request is open (30-minute ceiling) and the idle clock starts the moment you return. Reply 202 and finish in a goroutine and that goroutine gets hibernated mid-job.
- Dedupe on
X-Carlos-Schedule-Atβ unix seconds, read it withcarlos.TickOccurrence. It is the instant the delivery is for, so every retry of one failed occurrence carries the same value, as does a redelivery after a box crash or a deploy that cut a long job. Never key on the wall clock: a retry twenty minutes later is the same occurrence. KeepTickas the guard β a request with no occurrence header means "no dedupe key, run it", which is what your own "Sync now" button looks like. - A 4xx is never retried. A path with no handler, or a handler that refuses a token it cannot see, records
app refusedand the schedule advances to its next occurrence. That failure is quiet βnextstill moves, solsreads like a success. Checklast_status. - An instance running since before the platform roll refuses its own ticks. The token is minted at spawn, so a process older than the roll has no
$CARLOS_ADMIN_TOKEN,Tickis always false, andcarlos.ScheduleAtreturnsErrUnauthorized.carlos restartclears it; nothing else does. - A schedule tighter than the idle window keeps the instance resident, and residency is billed. Since 2026-08-31 a tick no longer stamps activity, but the sweeper deliberately will not put a host to sleep when its next tick is due sooner than the idle window (15 minutes by default) β staying awake is genuinely cheaper than a wake/sleep cycle per tick, each of which is a full SQLite checkpoint-and-restore. So a 5-minute schedule against a 15-minute window never sleeps and accrues instance-hours around the clock; an hourly one sleeps about three quarters of each hour.
carlos schedule lsandsetnow say so in one line rather than leaving it to be found on a bill. The platform warns and never refuses: a tight schedule is a legitimate thing to want. - Unit-backed instances are not delivered to in this release β
lssaysunsupportedagainst them. Exec-backed instances (the default) and sidecars work. - Once-timers outlive the process. They live in the box registry, so a restart loses none, and re-asserting them at boot is safe because a repeated name replaces rather than adds. Off-platform there is no control socket at all:
ScheduleAtreturnsErrNotOnCarlos, which boot code should treat as "skip", not as a failure.
Two builds at once
Since v0.26.0, where the deployment has switched it on (Carloku did on 2026-10-14; the release default is off, and then a build is taken by restart and deploy watches the URL instead of each box's rollout), a new build reaching an awake instance starts beside the old one and takes over once it passes its health check, so no request waits (--swap overlap, the default). For a few seconds both builds run against the same database and the same scheduled work, and the new build's migrations run while the old one still serves. That makes two rules part of every release:
- Migrations stay additive across two consecutive releases. Add a column, table or index in one release; drop the old one in a later release, once no running build reads it. A rename is an add now and a drop later.
- Schedule names stay the same across the two builds (
carlos schedule).
A release that cannot follow them ships with --swap hold on carlos deploy or carlos ship: each instance stops before its new build starts, and requests wait a few seconds for it. carlos apps set <app> --swap hold makes that the app's rule, and --swap-concurrency is how many instances on one box swap at once, after the first has stayed up for ten seconds. If a build fails on a box, that box stops rolling it out, its other instances stay on the build they were serving, and carlos deploy names the failure and exits non-zero; carlos instances list shows it under REASON. Fix the cause, then carlos promote --retry <version> <channel> (every box) or carlos instances retry <host> (one instance).
Visitor location headers
On a deployment that publishes a GeoIP database (since v0.25.1), every request the edge passes to an app carries the visitor's rough location: X-Carlos-Geo-Country (ISO 3166-1, IE), X-Carlos-Geo-Subdivision (ISO 3166-2, only some databases), X-Carlos-Geo-Lat / -Lon (two decimals: an area, not a position) and X-Carlos-Geo-Source (which database answered). Any of them can be missing: treat that as unknown, never as an error. The edge strips any a visitor sends, so the values are always the platform's. A cached page that differs by country sends Vary: X-Carlos-Geo-Country; never vary on the coordinates.
An app that shows a visitor anything worked out from these headers must credit the database X-Carlos-Geo-Source names β DBIP-β¦: "IP Geolocation by DB-IP", linked to https://db-ip.com; GeoLite2-β¦: "This product includes GeoLite2 data created by MaxMind, available from https://www.maxmind.com". For an address that is not the current request (a log line, a webhook), carlos geo <address> --app <a>, or from an app GET /api/cli/apps/<account>/<app>/geo?ip=<address>, at most 60 a minute. That call needs an instance-role token, which is app-wide (it can also create, repoint and delete every sibling instance), so make it only from a central app that already holds one; never deliver that token to a tenant app's instances just to look up an address. Tenant instances use the request headers.
Deploy truths (each paid for at least once)
carlos deployis the release motion: ship, promote to the entry channel (or the channel the app's instances follow), then follow each box's rollout (or, where a box does not report one, watch the URL) until the shipped build is serving. "Held for bake" on a channel that declares one is the system working, not failing.- A promote is not a deploy until the process cycles. An awake instance starts the new build within seconds of its box adopting it (beside the old one where two builds at once is on), an asleep one takes it on its next request, and unit-stamped routes are restarted. A bespoke long-running unit outside the platform's knowledge stays old until something restarts it β know which of the three your app is.
- Verify against the thing you changed, with the binary you built. A 200 is not proof: an app-shell route returns 200 HTML for
/api/versionand will happily "verify" any build ever shipped. The proof is the build the process itself reports: setcarlos apps build-probe, or readX-Carlos-Versionon the canonical host knowing the edge stamps it before the process has cycled; stale DNS and stale local binaries have both produced false "verified" reports. - Review on a canary, not on localhost.
carlos canarycannot disturb production: its channel sits outside every pipeline and its hostname is the branch's own. Bring a config bundle with it if the app has one. - When CI ships for you, verify by ancestry, not equality β a PR merging behind yours cancels your run and ships a commit containing yours.
- Stamp your build version. For rastrillo apps,
-ldflags "-X amadan.net/rastrillo/rastrillo.BuildVersion=<sha>"β or every release's/api/versionreportsdev.
Switched off, or not released
Released, switched off on Carloku β the CLI has the flag, the console declines or ignores it, and nothing should be built to stand in for them: app residency (--region on apps create, deploy and canary since v0.23.0; the console starts the app in its home region and says so), instance moves (carlos move, v0.26.0; it sends nothing), and temporary static sites (deploy --temporary / --ttl, apps ttl|keep, v0.25.1). Names starting tmp- are reserved whether or not temporary sites are on.
Released in v0.28.0 but refused until an operator turns them on: seeded instances (instances create --from <host> starts a new instance from a copy of another's database; instances import brings in one the platform never ran). Not built: an instance-bound token in place of the app-wide instance role, a create-only provisioning role, and cross-box path targets. Check carlos <verb> -h against the console and wait.
Self-hosting the platform
The platform is the same software members deploy onto: a self-hosted deployment (Tito's path) runs its own console, boxes, and bucket in its own cloud account, and its members use the identical carlos CLI pointed at that console. Operating it is real work β box provisioning, the adopt/restart cadence, platform rolls β and it is the one context where box verbs and cloud access are legitimate. Commit a .carlos/config naming the deployment's console so sessions cannot fall back to the wrong one; the CLI's account default is not your account (ops exists on every deployment and is empty β a not found against it once cost an hour of confident wrong diagnosis).
The deployment's own binary is a channel like any app's: carlos system update --channel canary|stable promotes a release, and every box on that channel fetches it, verifies it against the signed pointer, installs it, restarts its edge, and rolls itself back if the new one does not come up and stay up. carlos system status shows each box's running build and last apply outcome; carlos system rollback undoes it; --spread 10m staggers a stable roll. The console is an app β update it with carlos deploy --app console. Owner of the ops account only.
For the underlying machinery β registry, router, replication, hibernation internals β see blueprint.md, which is the reference for what the platform does on your behalf and for hand-rolling outside it.