Notiflows as code
The on-disk format the Notiflows CLI uses — notiflows.json project config, per-notiflow notiflow.json with a flat steps array, extracted template bodies, and environment-variable substitution.
The CLI maps your project's notiflows to a directory tree you can commit to git, review in pull requests, and deploy through CI. The format is a near 1:1 mapping of the Management API request body, with a couple of ergonomic conventions (body extraction, env-var substitution) on top.
Project config — notiflows.json
notiflows init creates a notiflows.json at the root of your repo:
{
"notiflowsDir": ".notiflows",
"project": "my-app"
}notiflowsDir— where the notiflow working tree lives (defaults to.notiflows). Notiflows are stored under<notiflowsDir>/notiflows/<handle>/.project— the project slug to operate on. Optional here; it can also come from--projectorNOTIFLOWS_PROJECT(see resolution precedence).
The CLI discovers notiflows.json by walking up from the current directory, so you can run commands from anywhere inside the repo.
Directory layout
A realistic working tree:
my-app/
notiflows.json
.notiflows/
notiflows/
welcome-series/
notiflow.json # name, active flag, flat steps array, __meta
steps/
email_1/
body.html # email template body, extracted for clean diffs
email_2/
body.html
order-shipped/
notiflow.json
steps/
sms_1/
body.txt
in_app_1/
body.mdThe directory name is the notiflow handle and is immutable — renaming the directory retargets a different notiflow. Notiflow handles are hyphens-only (welcome-series, not welcome_series). Step handles, by contrast, may use underscores (email_1).
notiflow.json
Each notiflow is a single notiflow.json:
{
"$schema": "https://notiflows.com/schemas/notiflow.json",
"name": "Welcome Series",
"active": true,
"include_in_preferences": true,
"steps": [
{ "handle": "trigger", "type": "trigger", "position": 0 },
{
"handle": "email_1",
"type": "channel",
"position": 1,
"channel_type": "email",
"channel_handle": "transactional-email",
"template": {
"channel_type": "email",
"data": {
"subject": "Welcome, {{ data.first_name }}!",
"content_type": "html",
"body@": "steps/email_1/body.html"
}
}
},
{
"handle": "wait_1",
"type": "wait",
"position": 2,
"settings": { "duration": 1, "duration_unit": "days" }
},
{
"handle": "email_2",
"type": "channel",
"position": 3,
"channel_type": "email",
"channel_handle": "transactional-email",
"template": {
"channel_type": "email",
"data": {
"subject": "Getting started",
"content_type": "html",
"body@": "steps/email_2/body.html"
}
}
},
{ "handle": "end", "type": "end", "position": 4 }
],
"__meta": {
"version": 3,
"published_version": 2,
"status": "draft",
"has_unpublished_changes": true,
"sha": "a1b2c3...",
"created_by": { "type": "account_token", "id": "...", "name": "ci-deploy" },
"published_by": { "type": "member", "id": "...", "name": "Ada Lovelace" }
}
}Steps are a flat array — no edges
steps is a flat array that mirrors the backend's steps-only schema. There are no edges; ordering and branching are encoded on the steps themselves:
handle— the stable step id (trigger,email_1,end).type— one oftrigger,end,channel,condition,wait,digest,throttle.position— order within the step's scope (the root sequence, or within a branch).channel_type/channel_handle— for achannelstep, which channel to deliver through (email,sms,in_app,mobile_push,web_push,chat,webhook).parent_step_handle/branch_handle— for a child of a condition step: which condition it belongs to (parent_step_handle) and which branch of that condition (branch_handle).settings— type-specific settings. Wait, digest, and throttle useduration+duration_unit(seconds|minutes|hours|days); throttle addsthrottle_key, digest addsdigest_key.conditions— optional gate conditions (groups of{ property, operator, value }) that decide whether a step runs.
Never strip the trigger and end steps. Every notiflow keeps a terminal trigger step (position 0) and an end step. An upsert sends the full flat steps array — any step you omit is removed on the server. The CLI round-trips whatever the server returns; preserve the terminal steps when editing by hand.
Template body extraction (@-suffix keys)
To keep diffs readable, a channel step's main body field is extracted out of template.data into its own file under steps/<handle>/, and replaced inline by a pointer key with an @ suffix:
"data": {
"subject": "Welcome!",
"content_type": "html",
"body@": "steps/email_1/body.html"
}body@ points at the file; on push, the CLI reads the file back and inlines it as the real body key. The extension is chosen by channel type:
| Channel type | Body file |
|---|---|
email | body.html |
in_app, chat | body.md |
sms, mobile_push, web_push | body.txt |
webhook | body.json |
Channel-step bodies must be non-empty — every channel template requires body (and content_type) server-side.
Environment-variable substitution
Values in your files can reference environment variables with ${ENV_VAR} syntax; the CLI substitutes them at push time. This keeps provider ids, endpoints, and other per-environment values out of git while staying declarative.
The read-only __meta block
__meta is a read-only mirror of the notiflow's current state — version, published_version, status, has_unpublished_changes, the content sha, and who created the version (created_by / published_by). It (and $schema) are stripped before any push, so read-only data is never echoed back to the server. The sha powers conflict-safe pushes — see pushing safely.
Related
- Command reference — the commands that operate on this format.
- CI/CD — deploying notiflows from your pipeline.
- Building notiflows — the step types explained conceptually.
Account tokens
An account token (prefix nf_at_) is the credential that authenticates every Notiflows developer surface — the CLI, the MCP server, the agent toolkit, and the Management API. Create one in the dashboard, use it, and rotate it safely.
Command reference
The complete Notiflows CLI command reference — authentication, project inspection, sync (pull/push/diff), notiflow lifecycle, triggering runs, and channels.