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 does not 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 — useful when the Cell you are 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 are not there — create them again against the platform.