---
title: Notiflows as code
description: 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](/docs/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:

```json
{
  "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 `--project` or `NOTIFLOWS_PROJECT` (see [resolution precedence](/docs/cli#authenticate)).

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.md
```

The 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`:

```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 of `trigger`, `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 a `channel` step, 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** use `duration` + `duration_unit` (`seconds` | `minutes` | `hours` | `days`); throttle adds `throttle_key`, digest adds `digest_key`.
- `conditions` — optional gate conditions (groups of `{ property, operator, value }`) that decide whether a step runs.

<Callout type="warn">
**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.
</Callout>

### 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:

```json
"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](/docs/cli/commands#sync).

## Related

- [Command reference](/docs/cli/commands) — the commands that operate on this format.
- [CI/CD](/docs/cli/ci-cd) — deploying notiflows from your pipeline.
- [Building notiflows](/docs/learn/building-notiflows/overview) — the step types explained conceptually.
