Local development
ribo local runs your Cell on your own machine: the same V8 isolate runtime that serves it in production, plus c3 for SQL, g7 for objects, and a management API: four services on localhost, started and stopped by one command.
cd my-cell
ribo localNothing is deployed to the platform, nothing is billed, and after the first run it works with no network at all.
Why it starts a whole stack
A Cell is dispatchable only from the management server that holds it. The router resolves an address against that server's registry, so a Cell deployed to api.tissue.systems is invisible to a runtime on your laptop, and a Cell deployed on your laptop is invisible to the edges.
ribo local brings up a management server here and deploys into it. That is the arrangement in which http://localhost:8080/<address> can answer.
Quick start
Run it in a directory that has a ribo.toml:
$ ribo local
starting c3 · g7 · api · runtime
ready runtime 2026.08.014 :8080 · c3 :8081 · g7 :8083 · api :8082
account Acme Corp (acct_3f8c1a2b4d6e8f00)
deployed my-cell (js)
url http://localhost:8080/33ed412f7x2kp
watching /Users/you/my-cell (3 dirs) — a save redeploys
Ctrl-C to stop.The Cell is live at that URL:
curl http://localhost:8080/33ed412f7x2kp/Ctrl-C stops all four services and prints what it stopped.
You do not have to be logged in. With no credential the stack still runs and the deploy line reads account <local/unauthenticated>, which is what CI and offline work get.
What is running
| Service | Port | What it does |
|---|---|---|
runtime |
8080 |
Runs your Cell in its own V8 isolate and dispatches requests to it |
c3 |
8081 |
SQL databases behind type = "c3" bindings |
api |
8082 |
Management API: registry, deploys, buckets, vault |
g7 |
8083 |
Object storage behind type = "g7" and type = "files" bindings |
Three of the four are ribo itself. The runtime is the only separate executable, and ribo fetches it for you.
The ports are fixed: the runtime's service bindings name them literally, so there is no port flag. A busy port is reported rather than worked around.
Error: Port 8081 is already in use, which c3 needs.
A previous stack may still be running — try `ribo local stop`.The first run downloads the runtime
Fetching the Cell runtime 2026.08.014 for macos-arm64 (28 MB, signed by release@tissue.systems)The download is checked against a signed manifest before it is unpacked: a signature from outside ribo's release trust list, or a checksum that does not match, refuses to install rather than warning. It is then cached by version and reused:
| Platform | Cache |
|---|---|
| macOS | ~/Library/Caches/ribo/runtime/<version>/ |
| Linux | ~/.cache/ribo/runtime/<version>/ |
Runtime builds are published for macOS arm64 (Apple silicon) and Linux x86_64. On Windows, run ribo under WSL.
Two escape hatches:
RIBO_NO_DOWNLOAD=1 ribo local # fail instead of fetching anything
ribo local --runtime /path/to/runtime # use a runtime you already haveRIBO_RUNTIME_DIR does the same as --runtime for a whole shell.
Create databases and buckets first
The local stack starts empty, and ribo deploy doesn't create resources, locally or in production. Point the resource commands at the local management API before the first request:
export TISSUE_SERVER=http://localhost:8082 # every ribo command in this shell
ribo db create notes
ribo bucket create uploads
ribo vault set my-cell SECRETOr per command, without the export:
ribo db create notes --server http://localhost:8082A type = "files" binding is the exception: its bucket is derived from the address and created during the deploy.
Skip this and the Cell throws on the first request that touches the binding: C3 database "notes" not found, or bucket "uploads" not found. Both print in your terminal.
What your Cell prints
console.log goes straight to the terminal, unprefixed:
cell saw GET /notesAn uncaught exception is answered to the client as a bare Internal Server Error, which says nothing. The terminal gets what actually happened, with the request path and the frames:
error bucket "uploads" not found (/upload)
at async Object.fetch (cell.js:12:11)Errors are written to stderr and your Cell's own output to stdout, so a redirect separates them.
Saving a file redeploys the Cell
ribo local watches the Cell directory while it runs. Save cell.js and the next request runs the new code:
changed cell.js
reloaded my-cell (js)The address does not change and the services keep running. Only the Cell's code is replaced, because the runtime keys each isolate on the address and the deploy timestamp. Static files under a type = "files" binding are re-uploaded the same way, and the line says how many moved:
changed public/index.html
reloaded my-cell (js)
files FILES→33ed412f7x2kp-files (12 files: 1 uploaded, 11 unchanged)A build command in ribo.toml runs on every save, before the upload. Writes it makes into the directory do not trigger a further reload.
A failed reload (a build that exits non-zero, a bucket that does not exist) prints the error and leaves the previous deploy serving:
changed cell.js
reload failed — build failed: npm run build (exit 1)Four things are left out of the watch: .git, node_modules, target, and the scratch files editors write beside the one you saved (.swp, file~, .#file, .DS_Store). Directories a files binding names in allow_symlinks_to are watched, because a shared asset lives outside the Cell directory by design and a watch on a symlink is a watch on the link, not on what it points at.
To turn it off and deploy once:
ribo local --no-watchYou can also deploy from another terminal, whether or not the watch is on:
ribo deploy --server http://localhost:8082To bring the stack up without deploying anything, say when the Cell you're working on lives somewhere else, there is nothing to watch, so the watch does not start:
ribo local --no-deployWhere local data lives
Under ~/Library/Caches/ribo/local/ on macOS, ~/.cache/ribo/local/ on Linux:
| Path | Holds |
|---|---|
c3-data/ |
one SQLite file per database |
g7/ |
one directory per bucket |
cells/ |
deployed code, the local registry, vault values |
run.json |
the process IDs of the running stack |
It survives restarts, so the rows you wrote yesterday are there today. Delete the directory to start from nothing.
The databases are ordinary SQLite files. ribo db exec notes "select * from notes" --server http://localhost:8082 reads them, and so does any SQLite client.
Stopping a stack you lost
If a terminal was closed on a running stack, its services are still holding the ports:
$ ribo local stop
stopped c3 (54130)
stopped g7 (54132)
stopped api (54133)
stopped runtime (54134)With nothing running it prints No local stack recorded as running.
What is different from production
| Local | Production | |
|---|---|---|
| Routing | path-based: localhost:8080/<address>/… |
subdomain: <cell>.<subdomain>.tissue.dev |
| Authentication | none; every request reaches the Cell | account credentials on the API, Gate on the Cell |
| Gate | a [gate] block fails the deploy: the policy needs storage the local stack does not run |
policies, capability links, sessions |
| Pulse | no scheduler runs; [[pulse]] schedules are stored but nothing fires them |
dispatched on schedule, UTC |
| g7 | the binding API; the S3 API, presigned URLs and per-bucket credentials answer 501 |
all of it |
| Custom domains, Synapse | not part of the local stack | — |
Because routing is path-based, a Cell that reads its own hostname sees localhost:8080 locally. Route on the path, or read the address prefix off it.
To exercise a pulse(event, env) handler, send the invocation the scheduler would send:
curl -X POST http://localhost:8080/33ed412f7x2kp/ \
-H 'x-tissue-event: pulse' \
-H 'x-tissue-cron: */5 * * * *' \
-H 'x-tissue-scheduled-time: 2026-08-13T08:00:00Z'Flags
| Flag | Default | Description |
|---|---|---|
--config <path> |
./ribo.toml |
Config file to deploy |
--runtime <path> |
the ribo cache | Runtime executable, or a directory holding one |
--no-deploy |
off | Start the services without deploying the Cell in this directory |
--no-watch |
off | Deploy once and stop there, instead of redeploying on every save |
--profile <name> |
— | Credential profile for the deploy (see Accounts & profiles) |
Going to production
When it works locally, deploy it for real from the same directory:
ribo deployWithout --server the deploy goes to https://api.tissue.systems, and the databases, buckets and vault values you created locally aren't there. Create them again against the platform.