Flows
Bird Flows is the runtime for multi-step, multi-channel messaging workflows. Welcome series, drip campaigns, retention loops, OTP fallback chains — defined in code, run on Bird's infrastructure, observable per step.
Concept
A flow is a trigger plus an ordered set of steps plus persistent state. The trigger fires the run (a webhook, an API call, a schedule, or an event from another flow). Each step is durable — runs survive deploys and restarts, and every step is queryable individually.
Step types
| Step | What it does |
|---|---|
send | Send a message on any channel (email, SMS, voice, WhatsApp). |
wait | Pause for a duration or until an absolute date. |
branch | Conditional split based on flow state, recipient data, or an inbound event. |
parallel | Fan out multiple steps at once and join on completion. |
api_call | Call any HTTPS endpoint and bind the response into flow state. |
code | Run a small JavaScript snippet on Bird's runtime — for transforms and computed variables. |
Defining a flow
Flows can be defined in TypeScript (typed DSL, recommended) or YAML (for config-only deploys). The two are interchangeable — same step types, same compiler.
// welcome-7d.ts
import { defineFlow } from "@bird/flows";
export default defineFlow({
name: "welcome-7d",
trigger: { type: "api" },
steps: [
{ type: "send", channel: "email", template: "welcome", to: "{{ to }}" },
{ type: "wait", duration: "1d" },
{
type: "branch",
when: "{{ events.email.opened }}",
then: [{ type: "send", channel: "email", template: "tips" }],
else: [{ type: "send", channel: "sms", template: "nudge", to: "{{ phone }}" }],
},
{ type: "wait", duration: "6d" },
{ type: "send", channel: "email", template: "week-recap" },
],
});
# welcome-7d.yaml
name: welcome-7d
trigger: { type: api }
steps:
- { type: send, channel: email, template: welcome, to: "{{ to }}" }
- { type: wait, duration: 1d }
- type: branch
when: "{{ events.email.opened }}"
then:
- { type: send, channel: email, template: tips }
else:
- { type: send, channel: sms, template: nudge, to: "{{ phone }}" }
- { type: wait, duration: 6d }
- { type: send, channel: email, template: week-recap }
Deploying a flow
Deploy a flow file from the CLI. The deploy is versioned and atomic — in-flight runs continue on the version they started on; new runs use the latest.
bird flows deploy welcome-7d.ts
You can also wire deploys to GitHub Actions — the CLI is the same binary your CI uses.
Running a flow
Invoke a deployed flow by name. The response carries a flow_run_* ID you can use to inspect state later.
const { data, error } = await bird.flows.run({
flow: "welcome-7d",
to: "delivered@bird.dev",
variables: {
name: "Ada",
phone: "+15005550006",
},
});
if (error) throw error;
console.log(data.id); // "flow_run_2bX91q..."
Observability
Every run is a span; every step inside the run is a child span. Fetch a run by ID or watch it live in the console.
const { data, error } = await bird.flows.runs.get("flow_run_2bX91q...");
{
"id": "flow_run_2bX91q...",
"flow": "welcome-7d",
"state": "running",
"started_at": "2026-06-01T17:00:00Z",
"steps": [
{
"name": "send-welcome",
"type": "send",
"status": "completed",
"duration_ms": 412,
"output": { "id": "email_2bX91q...", "state": "delivered" }
},
{
"name": "wait-1d",
"type": "wait",
"status": "running",
"duration_ms": null,
"output": null
}
]
}
Quiet hours, frequency caps, channel preferences
The runtime applies these per recipient before any send step actually sends. Quiet hours (per recipient's local time zone), per-channel frequency caps, and channel preferences (the recipient's stated preferred channel) are configured once at the account level and the runtime respects them automatically — you don't write the logic in your flow. A step held by quiet hours is paused and resumed at the next eligible window.
Unsubscribe handling
Flows respect per-channel opt-out automatically. A flow run that reaches a step targeting an opted-out recipient skips that step and surfaces a flow.step.skipped event with reason: "recipient_opted_out". Two-line semantics: opt-outs are per-channel (an unsubscribe from email does not silence SMS), and they're enforced at send time, not at run start — so opt-outs that happen mid-run still take effect.
Testing
Run a flow in dry-run mode to get back the planned step list without actually sending anything. Useful in CI, and useful for "what would this flow do if I invoked it now?" debugging.
const { data, error } = await bird.flows.run({
flow: "welcome-7d",
to: "delivered@bird.dev",
variables: { name: "Ada", phone: "+15005550006" },
dry_run: true,
});
if (error) throw error;
console.log(data.planned_steps); // [{ name, type, would_send_to, ... }]
Error handling
A step that exhausts its retry policy emits flow.step.failed; the run as a whole emits flow.failed. Failed runs land in the dead-letter queue and are replayable.
const { data, error } = await bird.flows.replay("flow_run_2bX91q...");
if (error) throw error;
console.log(data.id); // new flow_run_* id, same flow and variables
Subscribe to flow.started, flow.step.completed, flow.step.failed, flow.completed, flow.failed, and flow.cancelled from the webhooks doc to drive your own alerting.