Chapter 6
A journey is a multi-step flow that Courier runs on its own: send, wait, check something, branch, send again. The state lives on Courier's side, which is why a seven-day delay doesn't require a cron job or a row in your database tracking where everyone is.

Last updated: September 2026
A journey is a multi-step flow that Courier runs on its own: send, wait, check something, branch, send again. The state lives on Courier's side, which is why a seven-day delay doesn't require a cron job or a row in your database tracking where everyone is.
Start with what an agent produces when you describe a flow, then look at the AI node, which puts a model inside that flow as a step.
A journey is JSON with a nodes array. Here's the prompt:
Build a welcome journey. Send the welcome email immediately, wait one day, then send an in-app reminder only if the user hasn't finished setup. Create it as a draft.
And roughly what comes back:
{"name": "Welcome Journey","state": "DRAFT","enabled": true,"nodes": [{ "type": "trigger", "trigger_type": "api-invoke" },{ "type": "send", "message": { "template": "TEMPLATE_ID" } },{ "type": "delay", "mode": "duration", "duration": "P1D" },{"type": "branch","paths": [{"label": "Setup complete","conditions": ["data.setup_complete", "is equal", "true"],"nodes": []}],"default": {"label": "Needs a nudge","nodes": [{ "type": "send", "message": { "template": "REMINDER_TEMPLATE_ID" } }]}},{ "type": "exit" }]}
Three rules that will bite you if you hand this to an agent, all of which return a
400 rather than failing quietly:
client-supplied node ids are not
allowed; ids are server-generated. You get the generated ids back in the response{"AND": [[...]]} with one entry
fails with Too small: expected array to have >=2 items. Use AND or OR only when
you genuinely have two or more conditionsnodes array and put one exit at the endThen you invoke it per user, with whatever data the branches need.
You'll notice branches require a default path. Every run has to go somewhere, and Courier makes you say where rather than letting people fall off the end.
Rather than memorizing node types, it's more useful to know what's askable. There are eleven, and this is what they mean in a prompt:
There's also a throttle node, which caps how often runs pass a point.
Cancel is the one people forget and then need. If you've started a three-message dunning sequence and the invoice gets paid, you want those runs to stop.
Building a journey through the API takes a few ordered steps: create the journey shell, create the templates each send references, wire the graph, publish. Your agent does this.
The one thing worth knowing, because it looks like a bug when you hit it: you can't create a journey with its send nodes in a single call. Every send references a template scoped to that journey, and those templates have to exist first. If your agent tries it in one shot and gets an error, the fix is "build the templates first," not "something is broken."
Journey templates belong to their journey. You can't reference one from a regular send, and you can't import an existing workspace template into a journey.
Here's the thing that makes journeys more than a scheduler: a branch can route on whether the previous message actually landed.
After the welcome email, wait a day. If they haven't opened it, send a push instead.
That's a real flow rather than a guess, because Courier knows the delivery status of the message the journey itself sent:
["send_status.<SEND_NODE_ID>", "was", "OPENED"]
<SEND_NODE_ID> is the server-generated id of the earlier send node, which you read back
from the create or replace response. A lone condition is written as a bare atom like this;
wrap it in AND or OR only once you have two or more.
One mechanic deserves calling out, because it's counterintuitive and an agent will get it wrong. The statuses are cumulative and ordered: SENT < DELIVERED < OPENED < CLICKED. So was DELIVERED is true for a message that was later opened or clicked, not only for one that stopped at delivered. UNDELIVERABLE sits outside that ordering and matches exactly.
If you want "delivered but not opened," that's was DELIVERED and was not OPENED together, not was DELIVERED alone.
New journeys are drafts. Keep them that way until you've looked.
The failure mode to know about: invoking a draft returns a success code and delivers nothing. You get a runId, everything looks healthy, and no messages exist. If your agent reports a successful invoke and nothing arrives, check whether the journey was ever published.
Read the draft, look at the branches, then publish deliberately. Chapter 8 covers inspecting runs after the fact.
Publishing snapshots a version, and you can republish an earlier one to roll back. Runs already in flight finish on the version they started on, so a rollback never strands someone mid-sequence.
An AI node runs an LLM as a step inside a running journey. Courier sends your prompt plus the run's context to the model, parses the response into structured output, and merges those fields back into the journey. Everything downstream can branch on them and render them.
This isn't "AI writes the email." It's a model producing typed values that the rest of your flow can act on.
You configure four things: the model, a web search toggle, the prompt, and an output schema. The prompt supports {{variable}} interpolation, so it can read trigger data, profile fields, and anything a fetch node pulled in.
The model's response is parsed as JSON and merged straight into the journey's data. Downstream nodes read those fields exactly like trigger data.
Say your schema defines subject_line, body_copy, recommended_feature, and tone. Then:
data.tone{{subject_line}} and {{body_copy}}Courier's own worked example does exactly this: feed in a user's profile and activity, get back those four fields, branch on data.tone, and send a celebratory email on one path or an urgent push on the other.
The schema is the contract between a non-deterministic step and a deterministic branch. Loose schemas are where this breaks.
Two ways to write one. Form mode takes fields with a name, a type (string, number, or boolean), and an optional description that guides the model:
| Field | Type | Description |
|---|---|---|
subject_line | string | Personalized subject under 60 characters |
body_copy | string | 1-2 sentence nudge referencing the user's activity |
tone | string | One of: encouraging, celebratory, urgent |
JSON mode takes a real JSON Schema, which is what you want when you need an actual enum rather than a described one. If a branch depends on the value, enforce it rather than asking politely.
Design for failure too. If an AI node fails, whether from a model error or a timeout, the journey continues and no output is merged into the context. So downstream branches have to handle the fields being absent. Give the branch a default path that assumes the AI output isn't there, and you've turned a possible dead end into a normal path.
The catalog. Each of these is a real shape, not a demo.
1. Decider. Fetch usage data, have the model classify engagement into a small set of buckets, branch on the result. The workhorse. Everything else is a variation.
2. Copywriter. Generate the subject and body per branch, so a power user and a dormant user get genuinely different messages instead of the same template with a different first name.
3. Digest summarizer. A batch or digest node collects the week's events, and the model writes the human summary. "Three people commented on your designs and Sara approved the budget" instead of a bulleted dump. This one is genuinely hard to do any other way.
4. Triage and routing. An inbound alert arrives, the model assesses severity, the branch picks the channel. Page someone for a P1, drop a P3 in the inbox. The model reads the messy description; the branch makes the decision.
5. Runtime adaptation. Adjusting a message based on context the template can't hold. Note this is not how to do translation. Use AI Translation from Chapter 5 for that.
6. Sentiment-aware follow-up. Read the last support interaction, branch on how it went, apologize on one path and upsell on the other. Nobody gets an upsell after a bad week.
7. Brand and safety check. Run generated copy past the model against your rules before the send node. On a fail, branch to a human-review exit instead of sending.
8. Chaining. Decider feeds copywriter feeds send. Works well, with one caution: if two nodes write the same key, the later one wins. Keep their schemas distinct.
Start with the cheapest model that can do the job, and move up only when you can name what's wrong with the output. For constrained tasks a smaller model is often faster, more consistent, and genuinely better, because it has less room to over-elaborate on a schema you deliberately made narrow.
Reaching for the biggest model by default is the most common mistake here.
We'd start with the GPT-5.6 family. Luna, Terra, and Sol form a clean three-tier ladder that covers every job below, and moving up a tier is a single name change rather than a switch between providers.
| What the node does | Start with | If that's not enough |
|---|---|---|
| Classify into a fixed set (risk, intent, engagement) | GPT-5.6 Luna | GPT-5.6 Terra |
| Route or triage on clear signals | GPT-5.6 Luna | GPT-5.6 Terra |
| Extract structured fields from text you have | GPT-5.6 Luna | GPT-5.6 Terra |
| Summarize a batch into a readable digest | GPT-5.6 Terra | GPT-5.6 Sol |
| Write customer-facing copy with tone and nuance | GPT-5.6 Terra | GPT-5.6 Sol |
| Reason over messy or conflicting context | GPT-5.6 Sol | Claude Opus 5 |
Luna handles more than people expect: classification, routing, and extraction rarely need anything above it. Terra is the workhorse for anything that has to read well. Sol is for genuine reasoning over messy input, not for making copy sound nicer.
If you prefer Anthropic, the same ladder maps across: Claude Haiku 4.5 for the first three rows, Claude Sonnet 5 for digests and copy, Claude Opus 5 for reasoning, Claude Fable 5 when Opus isn't enough. Courier also offers GPT-5.5, GPT-5.4, GPT-5.4 Mini, GPT-5.4 Nano, and Claude Opus 4.8.
How do you know it's time to move up? Name the symptom first, because most of these are schema problems wearing a model costume:
| Symptom | Usually means |
|---|---|
| Fields missing, or the wrong types | Schema is too loose. Tighten it before touching the model |
| The same input classified differently across runs | Your categories overlap. Fix the descriptions |
| A value outside your listed options | Move the constraint into JSON mode as a real enum |
| Copy is grammatical but generic and off-brand | A genuine move-up case |
| Reasoning is shallow on conflicting inputs | A genuine move-up case |
Only two of those five are actually a model problem. Change one thing at a time.
A few habits that keep cost sane without guesswork: constrain the schema before upgrading the model, gate expensive nodes behind conditions so a skipped node costs nothing, watch how much context you're sending (a large fetch response upstream becomes a cost problem downstream), and leave web search off unless the prompt genuinely needs current information. Current per-model rates are on the AI node docs page.
There's a Test panel in the node's configuration drawer. Run the prompt against the selected model with sample values and see the structured response before publishing the journey. Use it. It's much faster than publishing, invoking, and reading logs.
For a run that already happened, the step context shows which model ran, whether web search was on, and the output schema that was sent. If a node failed, the error is there too.
Most flows don't need one.
If a branch condition or a template variable does the job, use that. It's free, instant, and deterministic. An AI node is the right call when the decision genuinely requires reading unstructured input or generating language, and the wrong call when you're using a model to compare two numbers.
Three things to weigh: every node adds latency to a running flow, every node is non-deterministic in a system that sends real messages to real people, and each one is another thing to review when output drifts.
And specifically: don't use an AI node to translate. AI Translation handles that better, as Chapter 5 covers.
Yes, with a review step. The pattern that works: the agent edits the draft, you read the diff, you publish. Skipping review is fine for something low-stakes like a confirmation, and risky for anything that fans out to a segment.
Every send references a template scoped to that journey, and those templates have to exist before the send can point at them. So the order is: create the journey, create the templates, wire them in, publish. Your agent handles this, but the error message makes more sense once you know why.
Republish an earlier version. The journey id stays the same, and runs already in flight finish on the version they started on, so nobody gets stranded mid-sequence.
They finish on the version they started on. New runs pick up the new version. The same is true of journey templates: editing one after publishing creates a draft, and you re-publish the journey to make it live.
Read unstructured input and produce a decision from it. A branch compares values you already have. An AI node turns a support transcript, a usage pattern, or a free-text description into a typed field the branch can then compare. If you already have the field, use the branch.
Most often the node failed and the journey carried on, because a failed node merges no output into the context. Check the step context for the error, then make sure your downstream branch has a default path that handles the fields being missing.
Previous chapter
Designing Templates with AI
The reason cross-channel templates matter isn't elegance. It's that the alternative is writing the same message four times and keeping four copies in sync forever. One document, many renderings, and an agent that can produce it from a sentence.
Next chapter
Patterns You Can Build from a Prompt
Most product messaging is a small number of shapes repeated with different content. Here are the common ones, each as a prompt and the flow it produces.
© 2026 Courier. All rights reserved.