# Templates as code

Source: https://kirora.io/docs/api/templates-as-code

Read, edit and write back a template's JSON document from git, the CLI or an agent.

A template is a JSON document: the same one the visual designer saves. You can keep it in
git, edit it by hand or with an agent, and send it back, and the designer shows your
change. Nothing converts it: what you read is what you write.

## The document

`GET /v1/templates/{id}` returns the template with its document:

```json
{
  "data": {
    "id": "0192f3d0-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
    "name": "Weekly digest",
    "schemaVersion": 1,
    "blocks": [
      { "id": "hero", "type": "heading", "text": "Hi {{first_name|there}}" },
      { "id": "cta", "type": "button", "text": "Read more", "href": "https://juniperkitchen.com/journal" },
      { "id": "footer", "type": "footer", "text": "{{ org.name }}\n{{ org.address }}", "locked": true }
    ],
    "theme": {},
    "language": "en",
    "plainTextOverride": null,
    "transactionalDefaults": {
      "subject": "This week at {{ org.name }}",
      "previewText": null,
      "from": null,
      "replyTo": null
    },
    "alias": null,
    "publishedVersion": null,
    "hasUnpublishedChanges": true,
    "revision": 7,
    "thumbnailUrl": null,
    "tags": [],
    "createdAt": "2026-10-01T09:30:00.000Z",
    "updatedAt": "2026-10-02T14:05:00.000Z"
  }
}
```

- `blocks` is the email, top to bottom. Each block has a unique `id` and a `type`; the
  API reference lists every block type as its own schema (`HeadingBlock`,
  `ButtonBlock`, `FooterBlock` and the rest, under `EmailBlock`) with its fields. A
  block of any other `type` is refused with 400. A footer is required: a `footer`
  block at the top level (not inside a section), with `locked: true` and its text,
  usually your name and postal address. Without it the template does not render.
- `theme` holds only what this email overrides of the workspace's brand kit
  (`getBrandKit`); `{}` follows it.
- `transactionalDefaults` is what a transactional send of the template uses unless the send gives
  its own: the subject, preview line, sender and reply-to. Campaigns and journeys copy the design
  and set their own.
- Keep fields you do not recognise inside a block. The format is versioned
  (`schemaVersion`) and grows by adding fields, so a tool that drops unknown ones loses
  data.
- Merge tags work in any text, the subject and the preview line, in either spelling:
  `{{first_name|there}}` or `{{ contact.firstName | default: "there" }}`.
- `POST /v1/templates/render` draws a document without saving it, with its warnings: a
  quick check before you write. `POST /v1/templates/{id}/render` draws a saved one: its
  published version (what sends use), its draft, or an older version.

## An HTML template

To send HTML you already have, make it one `html` block followed by the footer. For
transactional mail (a receipt, a password reset), set `unsubscribeLink: false` on the
footer:

```json
{
  "name": "Password reset",
  "alias": "password-reset",
  "blocks": [
    { "id": "body", "type": "html", "text": "<h1>Reset your password</h1><p>…</p>" },
    { "id": "footer", "type": "footer", "text": "{{ org.name }}\n{{ org.address }}", "locked": true, "unsubscribeLink": false }
  ],
  "transactionalDefaults": { "subject": "Reset your password" }
}
```

The HTML is sent as written, not checked or inlined. Campaigns and journeys refuse
`html` blocks, so a template like this serves transactional sends only.

## Writing it back

`PATCH /v1/templates/{id}` with the fields you change and the `revision` you read as
`expectedRevision`:

```json
{
  "blocks": [
    { "id": "hero", "type": "heading", "text": "Hi {{first_name|there}}" },
    { "id": "footer", "type": "footer", "text": "{{ org.name }}\n{{ org.address }}", "locked": true }
  ],
  "expectedRevision": 7
}
```

If someone saved in between, the response is `409 REVISION_CONFLICT`, whose `details` carry
the current `revision`, `updatedAt` and who changed it; read again, merge, and retry.
Without `expectedRevision`, the last write wins.

Saving changes the draft. Transactional sends that name the template in `email.template`
use the published version until
someone publishes (`POST /v1/templates/{id}/publish`), which an agent key cannot do.
A published version also keeps the brand kit and shared blocks as they were, so
after a shared footer or the brand changes, `hasUnpublishedChanges` turns true and
publishing again brings the change to sends.

## Sending a test

`POST /v1/templates/test-send` sends a design to check how it looks, and records nothing
as a message. It needs `templates:write` and an `integration` or `partner` key; an
`agent` key is refused with 403, so a person sends an agent's tests. A key sends to one
workspace member who has accepted their invitation, once per call. An embed session can
send only to the addresses its `testSendTo` names, and cannot send when that list is
empty. The test comes from a verified sending domain.

## From the CLI

```sh
kirora templates get 0192f3d0-1a2b-7c3d-8e4f-5a6b7c8d9e0f > weekly-digest.json
# edit .data.blocks, then send the document and its revision back
jq '{blocks: .data.blocks, theme: .data.theme, expectedRevision: .data.revision}' \
  weekly-digest.json > update.json
kirora templates update 0192f3d0-1a2b-7c3d-8e4f-5a6b7c8d9e0f -d @update.json
```

## From an agent

The MCP server's `read_api` and `write_api` tools reach the same two operations
(`getTemplate`, `updateTemplate`), so an agent reads a template, edits its blocks and
writes them back with `expectedRevision`. An agent's edit leaves the template waiting for a person to
publish it; see [Agents](https://kirora.io/docs/api/agents).

## Next steps

- [Objects and rules](https://kirora.io/docs/api/models): the template document in full
- [Agents](https://kirora.io/docs/api/agents): let an assistant edit templates
- [Staging](https://kirora.io/docs/api/staging): test templates before they reach customers