wirl.json and the CLI
Everything wirl.json can declare, and every command the wirl CLI has.
Installing the CLI#
Run it with npx, which fetches the CLI from npm the first time and needs no install beyond Node 22.13 or later (in a terminal, npm first asks "Ok to proceed? (y)": answer y):
npx wirl login
Every command on this site is written wirl <command>, for short: run it as npx wirl <command>, or run npm install -g wirl once and use plain wirl. If your npm registry does not carry wirl, or to run exactly the build this control plane serves, install from the control plane instead: npm install -g https://app.wirl.dev/cli/wirl.tgz for the CLI (then run plain wirl), and npx -y https://app.wirl.dev/cli/wirl-mcp.tgz for the MCP server. See Quickstart for connecting an agent instead, which can need no install at all.
The npm package and the control plane are versioned separately. npx wirl runs the latest CLI published to npm, and app.wirl.dev runs the build it was last deployed with, so either can be the newer. The tarball is built into the control plane's own image, so it is the CLI that control plane was released with. A CLI newer than the control plane says so where it can tell: its control plane is older than this copy of wirl (from wirl migrations and wirl migrations adopt), or this control plane is older than sending wirl.json in the body, so it cannot take it when a wirl.json is too large for a header. A CLI older than the control plane sends your whole wirl.json and says of a key it does not read that it is not a key this version of wirl reads, so it is ignored, though the control plane may read it; it checks wirl.json against its own rules before it uploads, so update the CLI (npm install -g wirl@latest, or npx wirl@latest <command>) when a deploy is refused on your machine for something these pages allow.
wirl.json#
wirl.json sits beside your project, where you run wirl deploy.
{
"app": "churn-dashboard",
"org": "acme",
"server": "dist/server.js",
"description": "Accounts at risk this month, from HubSpot and Stripe",
"accent": "sun",
"connections": [
"hubspot",
{ "slug": "stripe", "hosts": ["api.stripe.com"], "kind": "http_bearer" }
]
}
app— the app's slug. Combined with the org, it forms the app's address:<org>--<app>.wirl.run.org— which organisation to deploy to. Without it,wirluses your only organisation, and when you belong to more than one it stops and names them.static— a folder of built files served beside the server module, e.g."dist". Its files win for their exact paths and the module answers everything else. Needsserver; the module cannot be inside it. See The contract.spa—truewhen the front end has a client-side router: a page navigation that matches no file getsstatic'sindex.html. Needsstatic.dir— the directorywirl deployuploads when you give it none, e.g."dist". Only the CLI reads it: the MCPwirl_deploytool takes its directory as an argument. Givewirl deployorwirl previewa folder and it depends on what is in it: a folder with awirl.jsonof its own is a project, so thatwirl.jsonis the one used and itsdiris relative to it; a folder without one is build output, so thewirl.jsonwhere you ran the command is used and that folder goes up.server— the module exporting{ fetch }. Without it, the bundle is served as static files.connections— what the app needs from the organisation. Each entry is a connection's name, or an object narrowing it:slug— the connection's name.hosts— hostnames the app may call through this connection. It can only narrow the connection's own allowed hosts, never widen them.kind— the shape of the credential:http_bearer(the default),http_header,basic,none(a public host that needs no key), orwebhook_secret(one secret, no hosts — holds the signing secret apublic_pathsentry checks incoming requests against; the outbound broker refuses to use it).
description— shown under the app's name on its hub card. At most 160 characters.accent— the hub card's color: one ofblue,green,sun,purple,red,sky,orange,lime,mint,butter,blush,lilac. Without one, a new app gets one of the five pastels (mint,sky,butter,blush,lilac), chosen from its name, so the same name is always the same color, and a redeploy that says nothing aboutdescriptionoraccentleaves whatever was set before alone.visibility—"private"is the only value. It makes the app private when a deploy creates it, even in an organisation whose apps are otherwise open to everyone in it. It applies only when a deploy creates the app: an app that already exists keeps the visibility it has, andwirl visibilityis how you change it. Anything but"private"is refused.schedules— cron jobs wirl fires into the app. Each entry is an object:name— 1-40 lowercase letters, digits or dashes. Unique within the app.cron— a standard five-field cron expression.timezone— an IANA zone, e.g.America/Los_Angeles. Defaults toUTC.timeout— how long a fire may run before wirl gives up on it,"30s"to"15m". Defaults to"5m".retries— 0 to 3 more attempts after a failure. Defaults to0.overlap—"skip"(the default) leaves a still-running fire alone;"allow"starts another anyway.flow: a flow the app declares underflows, which the schedule then starts a run of instead of posting to its path. Such a schedule has notimeoutof its own.
See Schedules for what your app receives and how retries and overlap actually behave.
flowsdeclares work that outlives one request, which wirl posts to/__wirl/flow/<name>one step at a time. Each entry is an object:name: 1 to 40 lowercase letters, digits or dashes, unique within the app. At most 20 per app.step_timeout: how long one step may take,"30s"to"15m". Defaults to"5m".retries: 0 to 10 more tries of a step that fails. Defaults to5.
Any other key is refused, naming it. The app's own code starts its runs, answers their waits and reads them at
https://flows.wirl, with no connection:GET https://flows.wirl/runs/<run or key>answers{ status, step, waiting, output, outputTruncated, error, pausedUntil, run, resumeAt }. See Flows for what each step receives and answers, and what the read's keys hold.public_paths— paths the internet may call with no sign-in, checked before your app is. Each entry is an object:path— starts with/, and is either exact or ends in one trailing/*covering everything below it. No leading wildcard, no bare/*, and anything under/__wirlis reserved for wirl.verify—slack,stripe,github, ornone.connection— thewebhook_secretconnection holding the signing secret. Required unlessverifyisnone, and refused if given when it is.
At most 10 per app, service apps only. See Public paths for what your app receives, what each provider signs, and what a
nonepath costs you.migrations—{ "destructive": ["0007_drop_legacy.sql"] }: the files inmigrations/allowed to drop or rename a table or a column, replace or overwrite rows, delete or update every row, or give a full-text table a command. Any other file that would is refused, but for a backfill, and so is a name here that is no file inmigrations/. A deploy that would apply one to the live database still waits for a person: see The contract.
The CLI#
wirl — deploy an app somewhere real, behind a sign-in
wirl login Sign in from this terminal (or --token wirl_...)
wirl init [dir] [--server] Create wirl.json, and a working server module with --server
wirl init [dir] --starter Write your first app: a small team noticeboard, ready to deploy
wirl skill [dir] [--print] Write the skill file a coding agent reads (init does this too; --no-skill to skip)
wirl dev [dir] Run the app here, signed in as a stub user
wirl deploy [dir] Build, scan and deploy (creates the app if new)
wirl preview [dir] [--name n] Build, scan and put up a preview: never live, its own database
wirl preview --delete <name> Remove a preview
wirl preview open [--name n] Print a link that signs in and opens a preview
wirl previews List an app's previews
wirl fetch <path> --preview <name> Call a preview directly and print what it answers
wirl apps [--org <slug>] List apps you can open
wirl apps restore <name> Bring a deleted app back, inside its 30 days (its owner while a member, or an organisation owner or admin)
wirl delete [app] [--yes] Delete an app: restorable for 30 days, and its name stays taken
wirl share <email> [--role viewer|editor]
wirl unshare <email> Take a person's share back (an invitation waiting for them too)
wirl visibility [private|org] See or change who can open the app (public is a person's click on its Access page)
wirl transfer <email> Hand the app to a new owner (you keep editor access)
wirl move --to <org> Move the app to another organisation you belong to
wirl orgs list | create <slug> Your organisations; make one for a team
wirl members list | add <email> --role | remove <email> The roster (--org)
wirl versions List the app's deploys, never its previews
wirl rollback <deployment-id>
wirl migrations [dir] List what this app's database has applied
wirl migrations adopt [dir] Write migrations/0001_init.sql from the live database's shape, once
wirl schedules List the app's schedules with their last and next run
wirl run <schedule> [--preview <name>] Run one now and wait for the outcome
wirl flows [--preview n] List the app's flows with their unfinished runs and last run
wirl flows start <flow> [--input json] [--key k] [--preview n] [--fast] Start a run of a flow
wirl runs [<flow>] [--status s] [--preview n] List runs, newest first
wirl runs show|send|cancel|retry <run or key> [--preview n] Look at a run, answer a preview's run, cancel or retry one
wirl logs [--since 1h] [--errors] [--follow] [--limit n] [--cursor n] [--preview <name>] What the app printed, per request; --kind, --run, --ray, --search narrow it
wirl egress [--since 1h] [--run <id>] [--limit n] [--cursor n] What the app called through its connections, call by call
wirl pull <app> [dir] [--force] Fetch a deployed app's source and keep building
wirl connections list Every connection in the org: who made it, and whether it holds a key
wirl connections create <slug> --secret-file f.json [--name n] [--kind k] [--hosts a,b]
wirl connections rotate <slug> --secret-file f.json
wirl connections delete <slug>
wirl connections pending Apps waiting on an owner or admin
wirl connections grants Every app and what it uses (admins)
wirl connections show <app> What an app has been granted
wirl connections approve|revoke|request <app> <slug>
wirl whoami
wirl <command> --help for a command's flags and an example.
A few of these are worth a sentence:
wirl preview [dir]puts a build up at its own address, never live, with its own database (built frommigrations/the first time that name is used, with no rows but the ones those files insert, never production's), open to the same people as the app.wirl fetch <path> --preview <name>shows what it answers;wirl preview openprints a link for a browser instead. See Previews.wirl apps restore <name>brings a deleted app back inside the 30 days its data is kept: its owner, while still a member of the organisation, or an organisation owner or admin, may, with a full-scope sign-in as for sharing. An app that was public comes back only from the Recently deleted list on the dashboard, since only a person makes an app public. It comes back exactly as wide as it was when it was deleted, never wider, and its schedules resume from their next time.wirl appslists live apps only, so a deleted one is not in it, and its name stays taken: see the contract.wirl delete [app]deletes an app: its address and its previews stop answering, its schedules stop and its unfinished runs are cancelled, and its data is kept for the 30 days in whichwirl apps restore <name>brings it back (an app that was public comes back only from the Recently deleted list on the dashboard); its name stays taken. It asks for the app's name typed back unless given--yes, takes the app fromwirl.jsonwhen none is named but never guesses it from the folder's name, and needs a full-scope sign-in: a deploy-scoped one is refused before anything is asked.wirl share <email>shares the app with that person. Role defaults to viewer, so add--role editorwhen they should be able to deploy it too. What sharing again does to a role they already have is under Roles on an app.wirl unshare <email>takes that person's share back, one person at a time, and withdraws an invitation still waiting for them; it says what it found, and finding nothing is not an error. Its--jsonanswer hasshareRevoked,invitationWithdrawnandappDeleted;revokedis alwaystrue(it says only that the request was carried out).wirl versionslists the app's newest 50 deploys, newest first, failed ones included.wirl rollback <deployment-id>makes an earlier one that went live serve again, unless the reaper has pruned it. A preview takes a version number from the same count and is never listed, so the numbers can skip, and an app previewed before its first deploy has no v1: see Previews. Underwirl versions --json, each deploy'screatedByis the user id of whoever deployed it if you are a member of the app's organisation or deployed it yourself, and null otherwise, as it is in the versionwirl rollback --jsonanswers with.wirl migrations [dir]lists what the app's live database has applied, read only: its own record of migrations (_wirl_migrations), read now, and wirl's ledger of what it sent there, one line a file, saying where the two differ. It needs the access a deploy needs; with--preview <name>it reads that preview's database instead.wirl migrations adopt [dir]is for an app whose own code made its tables, once, before its first migration file: it writes the live database's shape (its tables, views and indexes, never a row) asmigrations/0001_init.sqlbesidewirl.json, and records it as applied there.migrations/must hold no.sqlfile yet. See The contract.wirl run <schedule>fires a declared schedule now, outside its cron, and waits for the outcome, the fast way to check one works right after deploying it. With--preview <name>, fires one of a preview's own declared schedules instead, and answers at once with no queue to wait on.wirl flows start <flow>starts a run of a flow and prints its id:--inputis the run's input,--keythe app's own name for the run, and--fast, for a preview's run only, makes every sleep and wait short.wirl runs show <run or key>prints where a run is, the newest 50 of its tries and of its events, and up to 50 earlier tries that went wrong (the run's page and--jsonhave every one);wirl runs sendanswers a preview's run,wirl runs cancelstops a run, andwirl runs retryruns a failed one again. See Flows.wirl logsprints what the deployed app printed, one block per request, newest first, with the status and any exception.--errorskeeps only failures,--followkeeps watching,--ray <id>finds the request behind acf-rayheader someone copied from a broken page,--cursor <n>reads the page before a "More before this" line, and--preview <name>reads a preview's own script instead of the live app's history. See Logs.wirl egresslists the calls the app made out through wirl, newest first: each one's method, host, connection, status and whether it was let through, and for a flow step's call its run,n, attempt and path. See Logs.wirl connections createreads the secret from a file,--secret-file, rather than a command-line argument.wirl connections rotatereads a replacement the same way.wirl connections approve|revoke|request <app> <slug>decides, or asks to decide, whether an app may use a connection.wirl whoamiprints who you are signed in as: your organisations and your token's scope.
Options#
--org <slug> Organisation. Defaults to wirl.json, or your only organisation.
--app <slug> App. Defaults to wirl.json or the directory name.
--no-create Fail instead of creating an app that does not exist yet.
--json Machine-readable output.
--version Print the version of wirl you are running, as the first word: wirl --version.
.wirlignore#
wirl deploy already leaves out what is never the app: .git, node_modules, .env and every .env.*, editor and agent folders, and wirl's own .wirl. A .wirlignore at the root of the deployed directory keeps out more, one pattern per line, in the shape of a .gitignore:
# notes and fixtures are not the app
notes.md
/scratch
fixtures/
*.log
docs/**/*.psd
- A bare name matches at any depth. A leading
/anchors it to the root, and a trailing/matches directories only. *stays inside one path segment;**crosses them. A pattern may hold at most four**; several in a row count as one.- A line starting with
!is refused, naming the line: a.wirlignorecan only keep more out, never bring back something wirl excludes. - Only the file at the deployed root is read, and it is never uploaded itself. It is not
.gitignore, because build output is usually git-ignored and is exactly what you deploy.
The deploy says how many files it kept out. wirl dev runs the files as they are on disk, so it prints the same count as a warning: an ignored file the module imports works locally and fails the deploy.