the engine needs a MIDWIFE
Every other wbx engine verb assumes something is already
running — wb rt status talks to a Nexus,
wbx dock needs one to dock into. Deploy is the one verb that has
no engine to call, because its job is to make the engine exist. It is the
bootstrap verb: native-only — there is no wasm build of it, because a wasm verb
needs a runtime to run inside, and at deploy time there isn't one yet.
The status quo answers this with a zoo. A YAML file for the orchestrator, a second CLI for the cloud, a secrets product wired to both, and a deploy script that scripts — fragile, order-dependent, complaining the moment a resource already exists. The reader who actually wants their workbooks to keep living after the laptop closes needs the layer under that one magic line: what file got written, what converged, where the secrets went, and what to type when it breaks. The deploy kit is that whole layer, and it fits on one screen.
the DEFINITION
1. the bootstrap family inside wbx — one org
file (deployment.org), one converge verb set
(init · validate · apply · status · logs · down · doctor · secrets),
and one provider seam — that stands an engine up from nothing, local or cloud,
from a single declaration.
The kit is compiled into the wbx binary — it ships inside the same
static executable as every other verb. The cli/deploy-kit/ directory
on disk holds only its assets: example deployments, provider recipes, and a
storage.env.example. You don't install the kit; you already have it
the moment you have wbx.
the whole config, ANNOTATED
This is the entire declaration. Running wbx deploy init local
scaffolds exactly this — five real lines and two comments, nothing hidden:
#+TITLE: Workbooks deployment #+DEPLOY_TARGET: local #+DEPLOY_APP: workbooks #+DEPLOY_PORT: 4000 # Secret NAMES this deployment needs (values: `wbx deploy secrets set KEY=VAL`): #+DEPLOY_SECRETS: OPENROUTER_API_KEY # DEPLOY_IMAGE defaults to ghcr.io/workbooks-sh/runtime:latest (env WB_IMAGE overrides).
The parser is deliberately dumb: it collects every #+KEY: value
and every :KEY: value property line into one uppercased map,
so keyword style and property-drawer style both parse, and comments are just org.
Each key has a fallback and a default:
internal_port in the cloud.ghcr.io/workbooks-sh/runtime:latest. env WB_IMAGE overrides it.id:dir id:dir … — toolkits the kit pushes to the engine after a cloud apply.That override ladder is the one worth memorizing: an exported
WB_IMAGE beats whatever the file says, which is how you pin a SHA in
CI or test a local build without editing the artifact. Everything else falls back
to a sane default, so the five-line scaffold above is genuinely complete.
the converge LOOP
The verb set is the same manners every wbx family uses — init to
scaffold, validate to check, apply to make it so, then
status / logs / down to live with it. Three of them are worth a closer
look because their behavior is the whole design:
- init — on a TTY (a human), it shows a two-option picker:
local — a container on this machine, cloud-identical versus
cloud — fly.io, under your own account. Piped or run by an
agent, it skips the prompt and takes the safe default,
local. You can also name it outright:init localorinit cloud. - validate — loads the file, checks that a non-local target has a
resolvable recipe, and warns (doesn't fail) on declared-but-unset secrets.
It prints
ok — deployment.org (target=… app=… image=… port=…). - apply — the same checks, but it hard-fails on missing secrets. Validate tells you what's wrong; apply refuses to ship something half-configured.
Then apply / status / logs / down all dispatch on the one value
in the file. Target local drives the built-in container seam; any
other target hands off to that provider's recipe. Same verbs, same file — the
fork is data, not code:
flowchart TD f[["deployment.org — the one declaration"]] f --> v["wbx deploy validate"] v -->|"ok / warns on unset secrets"| a["wbx deploy apply"] a -->|"target = local"| L["container seam
docker · podman · krunvm"] a -->|"target = <place>"| R["recipe
providers/<place>/bootstrap.sh"] L --> up["engine up"] R --> up up --> loop["status · logs · down
(dispatch on the same target)"] loop -.->|"re-run apply — converge, idempotent"| a style f fill:#d9dbd3,stroke:#121316,stroke-width:2.5px style up fill:#13d943,stroke:#121316 style L fill:#ffffff,stroke:#121316 style R fill:#ffffff,stroke:#121316
The dotted arrow is the point. apply is a converge, not a
script: re-running it re-pulls the image, replaces the container, re-stages the
secrets, and never complains that things already exist. Status and down read the
same declaration to find what apply made. You don't manage steps; you state the
end and run it as often as you like.
local, STEP by step
With #+DEPLOY_TARGET: local, apply finds a container
engine in automation-friendliness order — docker → podman → krunvm, the first
whose --version spawns. On docker or podman it amounts to exactly this:
docker pull ghcr.io/workbooks-sh/runtime:latest docker rm -f workbooks # idempotent re-apply docker run -d --name workbooks -p 4000:4000 \ -e WB_WEB=1 -e WB_DESKTOP=1 -e PORT=4000 \ -e WB_DESKTOP_DIR=/disco -e WB_DATA=/data \ -v ~/…/sh.workbooks/disco:/disco -v ~/…/sh.workbooks/data:/data \ --env-file ~/…/sh.workbooks/secrets.env \ ghcr.io/workbooks-sh/runtime:latest # → engine up — … discovery → …/disco/runtime.json · try `wb rt status`
The rm -f before run is what makes re-apply
idempotent — there's never a name conflict, because the kit removes the old
container first. Two volumes mount the durable state: /data holds the
registry SQLite db (so your deployed workbooks survive a restart) and
/disco is the discovery seam. The container writes a
runtime.json into /disco — scheme, port, token — and the
CLI's engine verbs read <app_dir>/disco/runtime.json to find the
local engine automatically:
sequenceDiagram participant U as wbx deploy apply participant D as docker participant C as the container participant S as wb rt status U->>D: pull · rm -f · run (mounts /disco + /data) D->>C: start engine (WB_WEB=1, secrets via --env-file) C->>C: write /disco/runtime.json (scheme · port · token) U-->>U: print "engine up … try `wb rt status`" S->>C: read runtime.json → connect — no URL to type
That handshake is why local needs no configuration to talk to: discovery is
a file on disk that only knows about local engines. For a remote engine
you set WB_ENGINE_URL (and, if it's locked, WB_ENGINE_TOKEN)
instead — more on that token in a moment. The krunvm path exists too, but carries
an honest limit we name in the edges section.
names in the file, values in the VAULT
The artifact says what it needs; it never holds what it needs.
#+DEPLOY_SECRETS: OPENROUTER_API_KEY is a declaration of a name.
The value lives in <app_dir>/secrets.env, written
chmod 0600, and managed by secrets set / list / unset / push.
Here is the full round trip with the real output shapes:
$ wbx deploy secrets set OPENROUTER_API_KEY=sk-or-… staged 1 secret(s) (~/Library/Application Support/sh.workbooks/secrets.env) — applied on next `wbx deploy apply` (or `secrets push`) $ wbx deploy apply # with a name still missing: error: declared secrets unset: OPENROUTER_API_KEY — `wbx deploy secrets set KEY=VALUE` (or export them) first
Two delivery paths, one declaration: locally the kit passes
--env-file secrets.env to the container; in the cloud it stages each
value through the provider's set_secrets hook. Either way, values
travel as environment at start time and never enter
deployment.org or the image. The missing-secrets check also reads the
deployer's own environment — so an exported OPENROUTER_API_KEY counts
as present, and secrets list prints names only, never values.
flowchart LR d["#+DEPLOY_SECRETS:
OPENROUTER_API_KEY
(a NAME)"] v["secrets.env — 0600
(the value, or exported env)"] loc["local: --env-file
on the container"] cld["cloud: set_secrets
hook stages it"] env["container env
at start time"] art["deployment.org · the image"] d --> v v --> loc --> env v --> cld --> env v -. "NEVER" .-> art d -. "NEVER" .-> art style d fill:#d9dbd3,stroke:#121316 style env fill:#13d943,stroke:#121316 style art fill:#ffd9d9,stroke:#b00,stroke-width:2px,color:#b00
The red arrows are the whole promise: nothing on the value side ever flows
into the artifact or the image. There's one secret the kit generates for
you. On the first cloud apply it mints a 256-bit (32-byte) random token
with the OS RNG, persists it as WB_PUBLIC_BEARER in
secrets.env, and prints once:
control-plane locked — bearer token generated and persisted in secrets.env. To talk to your engine locally, set: export WB_ENGINE_TOKEN=4f2a…64-hex-chars… (stored in …/secrets.env — will NOT be regenerated on future applies)
It never rotates, on purpose — rotation would 401 every live client mid-flight.
The runtime enforces the other half: once WB_PUBLIC_BEARER is set the
engine is a locked deploy — a request with no token gets 401, with no
fallback, and the dev x-tenant header trick only works when the engine
is not locked. Lose the printed token and you recover it from
secrets.env; it's the same value, sitting at 0600 where it always was.
same file, DIFFERENT value
Depth rung. Moving the engine to the cloud is editing one property:
#+DEPLOY_TARGET: fly. No new tool, no second config language. On
apply, the bundled fly recipe converges — creating only what's missing:
it ensures the fly app exists, stages secrets with fly secrets set --stage
(additive, released by the deploy), creates a 1 GB wbdata volume
if absent, and synthesizes a minimal fly.toml in a temp dir that
deploys your image by reference:
app = "my-workbooks-engine" primary_region = "sjc" [build] image = "ghcr.io/workbooks-sh/runtime:latest" [mounts] source = "wbdata" # 1GB volume, created if missing destination = "/data" [http_service] internal_port = 4000 force_https = true auto_stop_machines = "stop" # scale-to-zero — min_machines_running = 0 # the engine sleeps when nobody's talking
That min_machines_running = 0 is scale-to-zero by default: the
engine sleeps when idle and wakes on the next request, and your deployed workbooks
survive the sleep because the registry db lives on the /data volume
(litestream replicates that SQLite file). The public URL comes back as
https://<app>.fly.dev. Then, if the file declared
#+DEPLOY_TOOLKITS: id:dir …, the kit asks the recipe for that URL,
points WB_ENGINE_URL at it, and pushes each toolkit — the kit ships the
engine and its toolkits in one apply. Notice the local docker run and this
fly.toml express the same engine contract: a container, a port, a volume,
and secrets as env. That sameness is the provider-agnostic claim, made concrete.
five hooks and a SPINE
Depth rung. A provider here is not a plugin SDK — it's a shell file.
local is the built-in container seam; anything else is a recipe
at providers/<place>/bootstrap.sh that sources a neutral spine
(_recipe.sh) and fills five hooks plus a lifecycle trio. Fly is just the
one that ships bundled — explicitly a personal preference, not a privileged path:
| hook | its job | fly's one-liner |
|---|---|---|
provider_ensure_app | make the app exist | create the fly app if missing |
provider_set_secrets | stage the declared values | fly secrets set --stage (additive) |
provider_attach_volume | durable /data | create a 1GB wbdata volume if absent |
provider_deploy_image | run the engine container | synthesize fly.toml, deploy by ref |
provider_public_url | where to reach it | https://<app>.fly.dev |
| down · status · logs | the lifecycle trio | apps destroy --yes · machine state · fly logs |
The spine dispatches on WB_RECIPE_ACTION — up runs the
full converge (ensure_app → set_secrets → attach_volume → deploy_image → echo the
url), and secrets / down / status / logs / url each run their slice.
The kit hands the recipe its environment env -i-style — a clean
process carrying only PATH, HOME, the WB_* deploy vars, and
each staged secret — so credentials reach the hook without ever touching the
CLI's own process environment. The recipe is found in resolution order:
$WB_PROVIDERS_DIR, then the repo (walking up from cwd for
_recipe.sh), then copies embedded in the binary and materialized
fresh under the app dir.
So adding AWS, Render, or your own VPS is dropping a
bootstrap.sh — no recompile. The spine says it plainly: a provider
implements only the engine contract — run my container, give it a volume, secrets,
and a port — which ports cleanly to any host. There is no ephemeral-sandbox half to
implement; it's one BEAM container with WASM inside. Authoring a full provider is
its own deep dive — the recipes lesson — this page stays at
the contract.
when it doesn't CONVERGE
wbx deploy doctor is the debugging entry point. It probes each
engine and provider, lists which recipes resolved and from where, and reports your
secret state — a single screen that tells you why apply can't proceed:
$ wbx deploy doctor engines: ✓ docker 27.3.1 ✗ podman not found ✗ krunvm not found ✓ fly 0.3.x recipes: fly ← embedded (materialized under sh.workbooks/providers) secrets: ✗ missing: OPENROUTER_API_KEY
Three failures are canonical, and each names its own fix. No engine at
all: no container engine (docker/podman/krunvm) — install one, or use
`wbx deploy init cloud`. Image pull fails: the hint asks is it
published? override with WB_IMAGE — because the default image is a published
ghcr ref and a private build needs the override. Missing secrets: the exact
bail from apply, declared secrets unset: … — `wbx deploy secrets set
KEY=VALUE` (or export them) first. These tie back to the wbx
exit-code map: a verb that can't reach the engine exits
3, and its hint names wbx deploy local — the loop closes back
here, at the verb that brings the engine up.
edges, NAMED
The honest perimeter, because a bootstrap tool that hides its limits is the worst kind:
- krunvm can't take the secrets env-file yet. The docker/podman path
delivers
--env-file; krunvm can't, so if a deployment declares secrets and only krunvm is present,applybails — use docker/podman locally, or a cloud provider. It's a real gap, not a warning to ignore. - One cloud recipe ships today. Fly is bundled; everything else is a
bootstrap.shyou (or someone) writes. The seam is open; the shelf is short. - Cloud needs flyctl and your own account. The recipe exits with an
install URL if
fly/flyctlisn't on PATH, and the app lands under your Fly account, not ours. - The file is
./deployment.org, in cwd. The wbx verb reads exactly that path — it takes no file argument. The richer example files underdeploy-kit/deployments/use a fuller axis set (:TENANCY_MODE:,:STORAGE:,:AUTH:) read by the engine-side backend, not by this verb. Those axes still ride to the engine — but as environment (seestorage.env.example), not yet as first-class wbx keys. Treat them as where this grows, not as today's input format. - The bearer never rotates — and that's a feature. Rotation would 401 every live client; the kit chooses stability and stores the one token at 0600.
questions people actually ASK
Do I need Docker?
Any one of docker, podman, or krunvm covers local — the kit picks the first it
finds. Or skip local entirely and go straight to cloud with
wbx deploy init cloud, which needs flyctl and a Fly account instead.
Where exactly do my secrets live?
In <app_dir>/secrets.env — on macOS that's
~/Library/Application Support/sh.workbooks/, on Linux
~/.local/share/sh.workbooks/ — written chmod 0600.
secrets list prints names only, never values, and the file never
enters deployment.org or the image.
Can I re-run apply safely?
Yes — that's the whole model. Apply converges: it re-pulls, replaces the container (or creates only what's missing in the cloud), and re-stages secrets, without complaining that anything already exists. The one thing it will not touch is the bearer token — re-applying never rotates it.
How do I point wbx at the deployed engine?
Locally it's automatic — the container writes runtime.json into
/disco and the CLI reads it. For a remote engine, set
WB_ENGINE_URL, plus WB_ENGINE_TOKEN if it's a locked
cloud deploy (that's the bearer you were shown once on first cloud apply).
AWS, Render, my own VPS?
Write providers/<place>/bootstrap.sh, fill the five hooks
and the lifecycle trio on the neutral spine, and set
#+DEPLOY_TARGET: <place>. No recompile — the kit resolves your
recipe from WB_PROVIDERS_DIR or the repo. The full walkthrough is the
recipes lesson.
Is `wb deploy` how Workbooks itself ships?
No — this is the users' tool, for standing up the runtime image for your own use. Workbooks' own platform releases (the runtime image, the compiler toolchain) ship through CI and a manual maintainer step, never through the deploy kit. The kit runs the engine; it doesn't publish it.
keep GOING
The deploy kit sits under wbx and stands up a Nexus — start with its parent, then follow the engine it brings to life.