Guides/How to Build Notifications with AI Agents/Templates as Code: Environments, CI, and Governance

Chapter 8

Templates as Code: Environments, CI, and Governance

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.

how to build product notifications with AI

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.

The loop

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.

Keeping templates in git

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.

Check before you push

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.

Diff before you overwrite

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.

Test and production environments

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.

Promoting to production

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.

Inspecting what ran

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.

Guardrails for agent-driven changes

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.

What to use instead of a dry run

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:

  • The Test environment, with a test user and the built-in email provider
  • Draft state, so nothing is live until someone publishes
  • Draft keys, if your workspace has them, to render an unpublished draft against a real payload
  • The AI node's Test panel, for anything with a model in it (Chapter 6)
  • GET /messages/{message_id}/output, to read exactly what was rendered
  • The runs API, to confirm the flow went the way you expected

That 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.

Frequently asked questions

How do I keep Courier templates in version control?

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.

How do I promote a template from test to production?

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.

Can I preview a send without delivering it?

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.

How do I see what a journey run actually did?

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.