Chapter 8
Building things with an agent is one problem. Making the result repeatable is another: content in version control, promotion between environments, and a way to answer "what did that actually do?" after the fact.

Last updated: September 2026
Building things with an agent is one problem. Making the result repeatable is another: content in version control, promotion between environments, and a way to answer "what did that actually do?" after the fact.
Set expectations first. This is a workflow you assemble from existing API endpoints, not a feature you turn on. There's no courier templates pull, no git sync, no drift-detection command. The pattern works, but you're building it.
local files → validate → diff → push → publish → verify → (roll back)
Six steps, and the two people skip are diff and verify. Those are the two that catch problems.
One file per template, holding the Elemental content. Alongside it, a map from file to template id per environment, because ids are workspace-specific and the same template has a different id in Test and Production:
{"welcome.json": { "test": "nt_01ABC...", "production": "nt_01XYZ..." },"password-reset.json": { "test": "nt_01DEF...", "production": "nt_01UVW..." }}
To get started from templates that already exist, Design Studio exports Elemental directly: open the template, then Publish → Export to Elemental. It's a manual copy, which is fine for a one-time migration and not something to build a pipeline on.
One gotcha that will bite you exactly once, and then quietly forever if you don't fix it: a failed validation can still exit 0. Your CI goes green on broken content.
Make the failure loud. With curl that means --fail-with-body. With the CLI, check the exit behavior of the command you're using and assert on it rather than assuming. Whatever you do, prove your pipeline fails on bad input before you trust it to pass on good input.
A push overwrites the draft, and the draft is where Design Studio edits live.
If someone has been tuning copy in the UI and you push from your repo, their work is gone. Neither of you finds out until somebody notices the old subject line came back.
So: fetch the current draft, compare it against your local file, and look at what changes before you write. Normalize both sides first, since server-managed fields like ids and checksums will differ harmlessly.
The same technique against the published version answers a different question: what's actually live right now, and does it match what I think is live.
Two environments by default, fully isolated. Templates, brands, integrations, API keys, and logs all belong to one environment only. You can add more, and rename any of them except Production.
Three things worth knowing:
You can send in Test with nothing connected. Courier's built-in email provider and the in-app inbox work with no setup. That's enough to build and verify a flow before you've touched a provider.
Test is not automatically sandboxed. If you connect real SendGrid or Twilio credentials to your Test environment, test sends go through those real providers to real inboxes. Use a separate sandbox provider key for Test if that matters.
Mock keys exist on Business plans. A mock key simulates the full notification lifecycle without invoking the downstream provider, so you can watch a request flow through Courier without a send.
The documented path is the dashboard. Open the migration flow, choose Migrate Assets, and pick whether to copy or copy and publish.
You can also run the same validate-diff-push-publish loop against a Production key, and plenty of teams do. Know that's you assembling it, not the sanctioned path, so the failure modes are yours to handle.
The safer sequence either way: build and test in Test, validate against real payloads, promote, then verify in Production before you tell anyone it shipped.
Journey runs are queryable. You can list them, fetch one, and list its steps:
courier journeys:runs list --journey-id <JOURNEY_ID> --status <STATUS>courier journeys:runs retrieve --run-id <RUN_ID>courier journeys:runs list-steps --run-id <RUN_ID>
list-steps is the one that matters. It returns the per-node state of a single run, and each step's node_id is the id of the node in the published journey. So a step maps directly onto the graph your agent built. You can see which branch a run took and why it stopped where it did.
Better still: on send steps, the step carries a message_id. Follow it to the message and you get delivery status.
That gives you one continuous chain:
run → step → node in your journey → message → delivery status
That answers "did the thing my agent built actually work?" Runs are retained for 95 days.
For a single message rather than a run, the ladder from Chapter 4 still applies: messages list, then history, then content.
Two that matter, and they're both simple.
Draft plus a separate publish step. Everything is created as a draft. Keep the publish as a human action for anything that fans out. This is the single highest-value guardrail and it costs nothing.
One API key per agent, per environment. The key is your revoke button. If you have one key shared by three agents and a script, you can't turn off only the one that's misbehaving.
Courier also has workspace-level Send Limits as a backstop, though they're a Business and Enterprise feature and the behavior has an edge worth knowing: an over-limit message is silently blocked and logged rather than rejected with an error your code can catch. Useful as a safety net, not as a control you build logic around.
There's no dry_run parameter on the send API. If you're looking for one, here's what actually gets you the same confidence:
GET /messages/{message_id}/output, to read exactly what was renderedThat list is longer than a single flag would be, but each piece answers a different question, and together they cover more than a dry run would.
One Elemental JSON file per template in your repo, plus a map from file to template id per environment. Push with the templates API, publish deliberately, and diff against the current draft before each push so you don't overwrite UI edits. There's no built-in git sync, so this is a pattern you assemble.
The documented path is the dashboard's Migrate Assets flow, which copies assets and optionally publishes them. You can also run the same push-and-publish loop against a Production key, but that's you assembling it rather than the sanctioned route.
There's no dry-run flag. The equivalent is the Test environment plus draft state, draft keys where available, and fetching the rendered output after a test send. That combination tells you more than a dry run would, because you're reading what Courier actually produced.
courier journeys:runs list-steps --run-id <RUN_ID> returns the per-node state of the run. Each step maps to a node in the published journey, so you can see which branch it took, and send steps carry a message_id you can follow to delivery status. Runs are kept for 95 days.
Previous 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.
Next chapter
Where AI Notifications Fit: Use Cases and Build vs. Buy
Mechanics are one thing. Here's where agent-built messaging actually shows up in production, and whether you should be buying this layer at all.
© 2026 Courier. All rights reserved.