---
title: "Prompt templates and variables"
description: "The Jinja templates for the prompt and ref, which variables each event provides, and which ref a run checks out."
section: "routines"
url: "https://forkline.dev/docs/routines/templates"
markdown: "https://forkline.dev/docs/routines/templates.md"
source: "src/content/docs/routines/templates.mdx"
---

# Prompt templates and variables

> [!TIP]
> **Agent hint**
> Rules on this page that most often cause invalid routines:
>
> - Use only variables that **every listed event** provides. Otherwise the validator refuses the routine at `/agent/prompt` with `` `pr.title` isn't available for `issue.opened`; every chosen event must provide it. ``. With one event the message is `` `sender.login` isn't available for `ci.failed`. ``, followed by `Did you mean ...?` when a variable has a similar name. Split the routine when events need different variables. The [availability table](#variable-availability) says which event has what.
> - Variables are camelCase and exact: `pr.headBranch`, not `pr.head_branch`. In a template the slash command is `comment.command.name`; in a `when` condition it is `comment.commandName`.
> - `ci.failures` entries have only `name`, `runId`, `url` and `conclusion`. There are **no logs** in the prompt; tell the agent to fetch them.
> - `comment.prompt`, `comment.path`, `comment.requestedModel` and `thread.comments` are [reserved](#reserved-variables) and rejected with "is not supported yet".
> - `ci.failed` has a `pr` only when the run belongs to a pull request: guard the routine with `filters.when: 'pr.number != null'` and the prompt with `{% if pr %}`. A variable that is not in the [availability table](#variable-availability) (for example `pr.changedFiles`) is rejected at save; see [Variables that do not exist](#variables-that-do-not-exist).
> - Fields an event does not fill (`pr.headBranch` on a Forgejo `ci.failed`, `pr.author` on GitLab) are **empty strings**, not missing, and a JSON `null` in a webhook `payload` prints `None`. `default(x)` replaces neither: write `{{ pr.headBranch | default(ci.branch, true) }}`.
> - A variable that is empty renders as empty text, not as an error. Do not call a filter such as `| length` on a root that may be absent (`pr` in a comment event): guard it with `{% if pr %}`.
> - Schedule and webhook routines have no `pr`, `issue`, `comment`, `ci`, `push` or `sender`. The prompt is at most **32 KiB**, the rendered prompt at most **512 KiB**, and it must not render empty.
> - Only the built-in filters and tests listed [below](#filters) exist. An unknown filter (`tojson`, `truncate`) or test is refused when you save, with the code `template_unknown_filter`.

`agent.prompt` and `agent.ref` are [minijinja](https://docs.rs/minijinja) templates: Jinja syntax. Forkline renders them for each run with the data of the event (or schedule, or webhook call) and gives the result to the agent. This page lists every variable, which events provide it, how templates behave when a value is missing, and which branch or commit the runner checks out.

## Template basics

| Syntax | Meaning | Example |
|--------|---------|---------|
| `{{ value }}` | Print a value. Missing values print nothing. | `{{ pr.title }}` |
| `{% if x %}...{% elif y %}...{% else %}...{% endif %}` | Conditional. A missing value, an empty string, an empty list, `false` and `0` are false. | `{% if issue.labels %}...{% endif %}` |
| `{% for item in list %}...{% endfor %}` | Loop. Inside, `loop.index` (from 1), `loop.first` and `loop.last` exist. Looping over a missing list does nothing. | `{% for f in ci.failures %}{{ f.name }}{% endfor %}` |
| `{{ value \| filter }}` | Apply a [filter](#filters). | `{{ pr.labels \| join(", ") }}` |
| `{% set x = ... %}`, `{% macro %}` | Your own variables and macros. They are not checked against the event. | `{% set n = pr.number %}` |
| `{# comment #}` | Comment, not rendered. | |
| `a ~ b`, `+`, `==`, `in`, `and`, `or`, `not` | Concatenation, arithmetic and comparison. | `{% if "bug" in issue.labels %}` |
| `{% raw %}...{% endraw %}` | Print text that contains `{{` literally. | |

Not available: functions such as `range()` and `now()` (the validator refuses them as not available). `{% include %}`, `{% extends %}` and `{% import %}` are **not** refused when you save, but there is no other template, so the run ends with `template_error`. A template is one self-contained text.

### Filters

These built-in filters work. Any other filter is refused when you save, as an unknown filter.

| Group | Filters |
|-------|---------|
| Text | `upper`, `lower`, `title`, `capitalize`, `trim`, `replace(from, to)`, `split(sep)`, `lines`, `indent(n)`, `escape` (`e`), `safe`, `string` |
| Lists | `length` (`count`), `join(sep)`, `first`, `last`, `sort`, `reverse`, `unique`, `list`, `min`, `max`, `sum`, `batch(n)`, `slice(n)`, `map`, `select`, `reject`, `selectattr`, `rejectattr`, `groupby`, `items`, `dictsort`, `chain`, `zip`, `attr` |
| Other | `default(value)` and `default(value, true)` (alias `d`), `int`, `float`, `bool`, `format`, `pprint`, `round`, `abs` |

Not available: `tojson`, `truncate`, `urlencode`, `striptags`, `filesizeformat`, `wordcount`. To pass structured data to the agent, loop over it and print the fields you need, or print the whole value (`{{ payload }}`).

**Tests** (`{% if pr.title is startingwith("fix") %}`): `defined`, `undefined`, `none`, `string`, `number`, `integer`, `sequence`, `mapping`, `iterable`, `boolean`, `true`, `false`, `odd`, `even`, `divisibleby`, `startingwith`, `endingwith`, `lower`, `upper`, `eq`, `ne`, `lt`, `le`, `gt`, `ge`, `in`. An unknown test is refused at save like an unknown filter.

### Strict at save time, lenient at run time

- **When you save**, every variable the template reads must exist for every listed event. A typo (`pr.numbr`) or a variable of another event is an error with the pointer `/agent/prompt` (or `/agent/ref`). A template that does not parse is refused with `the template does not parse: ...`. Variables you create yourself (`set`, loop variables, macros) and a bare root such as `{% if pr %}` are accepted.
- **When a run renders**, a missing value is not an error. A root that the event does not carry (`pr` on a comment on an issue) and an empty field render as empty text, and `{{ pr.number }}` is empty. Two cases are errors: a **filter or method that needs a value** called on a missing one (`{{ pr.labels | length }}` when there is no `pr`; `join` and `for` treat a missing list as empty) and a **template that renders to nothing or only whitespace**. Use `{{ pr.title | default("(none)") }}`, `{% if pr %}` or `{% for l in pr.labels %}` to be safe. `default(x)` replaces only a missing value. A field an event does not fill is an empty string, and a JSON `null` in a webhook `payload` prints `None`; for both write `default("x", true)`, which replaces every empty or false value.
- A run that cannot render is recorded with the outcome `template_error` and starts no task. The reasons are: ``template `prompt` failed to render: ...`` (for example a filter called on a missing value), `the prompt rendered empty`, ``template `prompt` rendered {n} bytes, the limit is 524288``, and a work limit: a template that loops without end (200,000 steps) is stopped.

| Limit | Value | Where |
|-------|-------|-------|
| `agent.prompt` source | 32 KiB (32,768 bytes) | save: `/agent/prompt`, `is {n} bytes, the limit is 32768` |
| `agent.ref` source | 1 to 1,024 bytes | save: `/agent/ref`, `must be between 1 and 1024 characters` |
| Rendered prompt or ref | 512 KiB (524,288 bytes) | run: `template_error` |
| Rendering work | 200,000 steps | run: `template_error` |

## Variable availability

Legend: ✓ always filled for this event. ○ declared (the validator accepts it) but may be empty, see the notes. — not available: using it for this event is a validation error. A template is valid only when **every** listed event has ✓ or ○ for each variable it uses. The four pull request events have the same variables, so they share a column. The field types and examples are in [Events](https://forkline.dev/docs/routines/events#fields).

| Variable | `pr.opened`, `pr.updated`, `pr.merged`, `pr.closed` | `issue.opened` | `comment.created` | `ci.failed` | `push` | schedule | webhook |
|---|---|---|---|---|---|---|---|
| `repository.fullName` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| `repository.owner` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| `repository.name` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| `repository.url` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| `repository.private` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| `repository.defaultBranch` | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| `event.name` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| `event.forge` | ✓ | ✓ | ✓ | ✓ | ✓ | — | — |
| `event.deliveryId` | ○ | ○ | ○ | ○ | ○ | — | — |
| `receivedAt` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| `sender.login` | ○ | ○ | ○ | — | ○ | — | — |
| `sender.isBot` | ✓ | ✓ | ✓ | — | ✓ | — | — |
| `pr.number` | ✓ | — | ○ | ○ | — | — | — |
| `pr.title` | ✓ | — | ○ | ○ | — | — | — |
| `pr.body` | ○ | — | ○ | ○ | — | — | — |
| `pr.author` | ○ | — | ○ | ○ | — | — | — |
| `pr.authorIsBot` | ✓ | — | ○ | ○ | — | — | — |
| `pr.baseBranch` | ✓ | — | ○ | ○ | — | — | — |
| `pr.headBranch` | ✓ | — | ○ | ○ | — | — | — |
| `pr.headSha` | ✓ | — | ○ | ○ | — | — | — |
| `pr.labels` | ○ | — | ○ | ○ | — | — | — |
| `pr.draft` | ✓ | — | ○ | ○ | — | — | — |
| `pr.fork` | ✓ | — | ○ | ○ | — | — | — |
| `pr.url` | ✓ | — | ○ | ○ | — | — | — |
| `issue.number` | — | ✓ | ○ | — | — | — | — |
| `issue.title` | — | ✓ | ○ | — | — | — | — |
| `issue.body` | — | ○ | ○ | — | — | — | — |
| `issue.author` | — | ✓ | ○ | — | — | — | — |
| `issue.labels` | — | ○ | ○ | — | — | — | — |
| `issue.url` | — | ✓ | ○ | — | — | — | — |
| `comment.id` | — | — | ✓ | — | — | — | — |
| `comment.body` | — | — | ○ | — | — | — | — |
| `comment.author` | — | — | ✓ | — | — | — | — |
| `comment.url` | — | — | ✓ | — | — | — | — |
| `comment.mentionsBot` | — | — | ✓ | — | — | — | — |
| `comment.command.name` | — | — | ○ | — | — | — | — |
| `comment.command.args` | — | — | ○ | — | — | — | — |
| `ci.name` | — | — | — | ✓ | — | — | — |
| `ci.runId` | — | — | — | ✓ | — | — | — |
| `ci.url` | — | — | — | ✓ | — | — | — |
| `ci.conclusion` | — | — | — | ✓ | — | — | — |
| `ci.headSha` | — | — | — | ✓ | — | — | — |
| `ci.branch` | — | — | — | ○ | — | — | — |
| `ci.failures` | — | — | — | ○ | — | — | — |
| `push.ref` | — | — | — | — | ✓ | — | — |
| `push.branch` | — | — | — | — | ✓ | — | — |
| `push.before` | — | — | — | — | ✓ | — | — |
| `push.after` | — | — | — | — | ✓ | — | — |
| `push.forced` | — | — | — | — | ✓ | — | — |
| `push.commitCount` | — | — | — | — | ✓ | — | — |
| `push.filesTruncated` | — | — | — | — | ✓ | — | — |
| `push.files.added` | — | — | — | — | ○ | — | — |
| `push.files.modified` | — | — | — | — | ○ | — | — |
| `push.files.removed` | — | — | — | — | ○ | — | — |
| `schedule.scheduledAt` | — | — | — | — | — | ✓ | — |
| `payload` | — | — | — | — | — | — | ✓ |

Notes for the ○ marks:

- `repository.defaultBranch` comes from the webhook for event runs, and from the synced repository for schedule and webhook runs, where it is empty until the first sync.
- `event.forge` is `github`, `gitlab` or `forgejo`. A schedule or a webhook has no forge, so using `event.forge` or `event.deliveryId` in their prompt is refused at save. `event.deliveryId` is the forge's delivery ID and is empty when the forge sent none.
- `event.name` is the event name (`pr.opened`, ...), `schedule` or `webhook`. `receivedAt` is an RFC 3339 UTC time; for a schedule it is the scheduled time.
- `sender.login` is empty only when the forge sent no sender. `ci.failed` has **no sender**.
- `pr.body`, `pr.labels`, `issue.body`, `issue.labels`, `comment.body` and the file lists can be empty. `pr.author` is empty on GitLab except for `pr.opened`.
- **`pr` in `comment.created`** is filled only when the comment is on a pull request, and on GitHub and Forgejo conversation comments carry no branches or head commit (`pr.baseBranch`, `pr.headBranch`, `pr.headSha` are empty). **`issue` in `comment.created`** is filled only when the comment is on an issue. Never both. A template for a comment normally branches: `{% if pr %}...{% elif issue %}...{% endif %}`.
- **`pr` in `ci.failed`** is filled only when the CI run belongs to a pull request, and then only `pr.number`, `pr.headSha`, and on GitHub and GitLab the branches. Title, body, author and labels are empty. See [Events](https://forkline.dev/docs/routines/events#pr-pr-events-and-sometimes-commentcreated-and-cifailed).
- `comment.command.name` and `comment.command.args` are empty strings when the comment has no slash command; `comment.command` itself is always an object, so `{{ comment.command.name }}` never fails. In a `when` condition the same values are `comment.commandName` and `comment.commandArgs`; templates and `when` use different names on purpose.
- `ci.branch` can be empty on Forgejo when the run belongs to a pull request; use `pr.headBranch` then. `ci.failures` is empty on GitLab when no failed job was reported.
- `push.files.added`, `push.files.modified` and `push.files.removed` are lists of paths; `push.filesTruncated` is `true` when they are incomplete.
- `payload` is the JSON body of the webhook call (any JSON value, so `payload.alert.id` and `payload.items[0]` work). A body that is not JSON arrives as `payload.raw_body`. Run now on a webhook routine sends the `payload` you give it, in the same shape; without one, `payload` is empty. `payload` is also readable in a webhook routine's [`when`](https://forkline.dev/docs/routines/filters#reading-the-webhook-payload-with-payload), within limits; templates always see the whole body. A JSON `null` is not missing: `{{ payload.title }}` prints `None`, so check what you need with `default("(no title)", true)`.
- A **schedule** has `repository.*`, `event.name`, `receivedAt` and `schedule.scheduledAt` (an RFC 3339 UTC time). It runs once per selected repository, see [Repository selection](https://forkline.dev/docs/routines/repositories).

### Variables that do not exist

Some values are often expected but are **not** provided. Using any of these names in `agent.prompt`, `agent.ref` or `filters.when` is refused at save: in a template with `` `pr.changedFiles` isn't available for `pr.opened`. ``, in `when` with `` `pr.changedFiles` is not a field, available fields are ... ``. Do not invent them: have the agent read them from the repository or the forge with the git-api tool.

| You may expect | What exists instead |
|----------------|---------------------|
| `pr.changedFiles`, `pr.files`, `pr.additions`, `pr.deletions`, `pr.diff` (no changed-files count, no file list, no diff for pull requests) | Nothing. The agent runs `git diff` or the git-api tool. `push.files.added`, `push.files.modified`, `push.files.removed` and `push.commitCount` exist for `push` only |
| `pr.commits`, commit messages or commit authors, `push.commits` | `pr.headSha`, `push.before` and `push.after`. For `push` only `push.commitCount` and the file lists exist |
| `pr.mergeable`, `pr.conflicted`, `pr.merged`, `pr.mergedBy`, `pr.state` | The event name: `event.name` is `pr.merged` or `pr.closed` |
| `pr.reviewers`, `pr.assignees`, `pr.milestone`, `pr.createdAt`, `pr.updatedAt` | None |
| `issue.assignees`, `issue.state`, `issue.milestone`, `issue.comments` | None. `thread.comments` is reserved |
| `comment.diff`, `comment.path`, `comment.line` | `comment.path` is reserved and refused |
| `ci.logs`, `ci.jobs`, `ci.log` | `ci.failures` has only `name`, `runId`, `url` and `conclusion`; the agent fetches the logs |
| `sender.id`, `sender.name`, `sender.email`, `sender.url` | `sender.login` and `sender.isBot` |
| `repository.id`, `repository.description`, `repository.language`, `repository.cloneUrl`, `repository.topics` | `repository.fullName`, `.owner`, `.name`, `.url`, `.private`, `.defaultBranch` |
| `now()`, `env.*`, `secrets.*`, `routine.name`, `run.id` | Functions are refused at save and there is no `env`, `secrets`, `routine` or `run` root. Use `receivedAt` for the event time |

### Reserved variables

These names are planned but not available yet. Using one in `agent.prompt` or `agent.ref` is a validation error at `/agent/prompt` (or `/agent/ref`) with the message `` `{name}` is not supported yet ``. Do not use them, even inside `{% if %}`.

| Variable | Message |
|----------|---------|
| `comment.prompt` | `` `comment.prompt` is not supported yet `` |
| `comment.path` | `` `comment.path` is not supported yet `` |
| `comment.requestedModel` | `` `comment.requestedModel` is not supported yet `` |
| `thread.comments` | `` `thread.comments` is not supported yet `` |

```yaml routine-invalid pointer=/agent/prompt code=reserved_variable
name: reply-in-thread
description: Uses a reserved variable
on:
  events: [comment.created]
repositories:
  include: all
agent:
  prompt: |
    Answer {{ comment.author }} in the thread:
    {{ thread.comments }}
```

## `ci.failures`

`ci.failures` is a list for the `ci.failed` event. Each entry has exactly four fields: `name`, `runId`, `url` and `conclusion`. The entries carry **no logs**: the prompt only tells the agent which runs or jobs failed, and the agent fetches the logs itself (for example with the `git-api` tool in the runner).

| Forge | What `ci.failures` holds |
|-------|-------------------------|
| GitHub | One entry: the failed workflow run itself (`name` is the workflow name, `conclusion` is `failure` or `timed_out`) |
| Forgejo | One entry: the failed run itself |
| GitLab | One entry per failed job of the pipeline, up to 100, without jobs that are allowed to fail. `conclusion` is `failed` and `url` is `{repository}/-/jobs/{id}`. The list can be empty |

Loop over it, and treat the empty list as a case:

```yaml routine
name: fix-failed-ci
description: Fix CI when a run fails on a branch other than main
on:
  events: [ci.failed]
repositories:
  include: all
filters:
  branches:
    exclude: [main]
agent:
  prompt: |
    The run "{{ ci.name }}" ({{ ci.conclusion }}) failed on branch {{ ci.branch }}
    at commit {{ ci.headSha }} in {{ repository.fullName }}.
    {{ ci.url }}

    {% if ci.failures %}Failed:
    {% for f in ci.failures %}{{ loop.index }}. {{ f.name }} ({{ f.conclusion }}): {{ f.url }}
    {% endfor %}{% else %}No failed job was reported; open the run to see what failed.
    {% endif %}
    The logs are not included here. Fetch them for each failure above, find the
    cause, fix it and push to the same branch.
```

## Which ref a run checks out

`agent.ref` picks the branch, tag or commit the runner checks out. It is a template too and is checked like the prompt (variables must exist for every listed event). It is trimmed; a ref that renders empty counts as not set. When `agent.ref` is not set, or renders empty, Forkline uses the **default ref** of the event:

| Source | Default ref |
|--------|-------------|
| `pr.opened`, `pr.updated`, `pr.merged`, `pr.closed` | The pull request's head branch (`pr.headBranch`). For a **fork** pull request (`pr.fork` is `true`) the **base** branch (`pr.baseBranch`), because the agent cannot push to the fork |
| `comment.created` on a pull request | The same rule, but only when the event carries the branches. GitHub and Forgejo conversation comments do not, so the repository's default branch is checked out and the agent switches to the pull request branch itself |
| `comment.created` on an issue, `issue.opened` | The repository's default branch |
| `ci.failed` | The pull request's head branch when the run belongs to a pull request and the forge reports it, else `ci.branch` (the branch of the run), else the default branch |
| `push` | The pushed commit, `push.after` (a commit SHA, not the branch name, so the run sees exactly what was pushed) |
| Schedule | The repository's default branch |
| Webhook (and Run now) | The repository's default branch |

The repository's default branch is `main` when Forkline does not know it yet. Setting `agent.ref` overrides every row above, for example to review against a long-lived branch:

```yaml routine
name: nightly-release-branch-check
description: Check the release branch every night
on:
  schedule:
    cron: "0 2 * * *"
    timezone: UTC
repositories:
  include:
    - name: "acme/api"
agent:
  ref: release/2.x
  prompt: |
    Run the test suite of {{ repository.fullName }} on {{ schedule.scheduledAt }}
    and report anything that fails.
```

A ref can use variables too, such as `"{{ pr.baseBranch }}"` (valid when every listed event is a pull request event).

## More examples

Pull request review, with fields every pull request event provides:

```yaml routine
name: review-prs
description: Review pull requests when they open or get new commits
on:
  events: [pr.opened, pr.updated]
repositories:
  include:
    - name: "acme/*"
agent:
  prompt: |
    Review pull request #{{ pr.number }} "{{ pr.title }}" by {{ pr.author }} in {{ repository.fullName }}.
    It merges {{ pr.headBranch }} into {{ pr.baseBranch }}.
    {% if pr.labels %}Labels: {{ pr.labels | join(", ") }}
    {% endif %}{% if pr.body %}Description:
    {{ pr.body }}
    {% endif %}
    Point out bugs and risky changes.
```

A slash command that works on pull requests and on issues, using the `pr` or the `issue` root, whichever the comment belongs to:

```yaml routine
name: fix-command
description: Run on the /fix command in pull requests and issues
on:
  events: [comment.created]
repositories:
  include: all
filters:
  command:
    names: [fix]
agent:
  prompt: |
    {{ comment.author }} wrote {{ comment.url }} on
    {% if pr %}pull request #{{ pr.number }}{% elif issue %}issue #{{ issue.number }} "{{ issue.title }}"{% endif %}
    in {{ repository.fullName }}.
    Command: /{{ comment.command.name }} {{ comment.command.args }}

    Comment:
    {{ comment.body }}
```

A push, listing the changed files:

```yaml routine
name: changelog-on-push
description: Check the changelog when main changes
on:
  events: [push]
repositories:
  include:
    - name: "acme/api"
filters:
  branches:
    include: [main]
agent:
  prompt: |
    {{ sender.login }} pushed {{ push.commitCount }} commit(s) to {{ push.branch }}
    ({{ push.before }}..{{ push.after }}).
    {% for path in push.files.added %}added: {{ path }}
    {% endfor %}{% for path in push.files.modified %}modified: {{ path }}
    {% endfor %}{% for path in push.files.removed %}removed: {{ path }}
    {% endfor %}{% if push.filesTruncated %}The file lists are incomplete.
    {% endif %}
    Make sure CHANGELOG.md mentions user-visible changes.
```

Events with different variables need different routines. When the events do share variables, write only those. This one runs for pull requests, issues and pushes and so reads only what all of them provide:

```yaml routine
name: log-activity
description: Ask the agent to note any activity in a repository
on:
  events: [pr.opened, issue.opened, push]
repositories:
  include:
    - name: "acme/api"
agent:
  prompt: |
    {{ sender.login }}{% if sender.isBot %} (a bot){% endif %} did something in
    {{ repository.fullName }} ({{ event.name }}, {{ event.forge }}, {{ receivedAt }}).
    Summarize what changed since yesterday.
```

A custom webhook, reading the JSON the caller sends:

```yaml routine
name: investigate-alert
description: Investigate an alert sent by a monitoring system
on:
  webhook: {}
repositories:
  include:
    - name: "acme/api"
agent:
  prompt: |
    An alert fired for {{ repository.fullName }}: {{ payload.title | default("(no title)", true) }}
    Severity: {{ payload.severity | default("unknown", true) }}
    {% if payload.raw_body %}Raw body: {{ payload.raw_body }}
    {% endif %}Investigate the cause and open an issue with your findings.
```

A schedule:

```yaml routine
name: weekly-dependency-update
description: Update dependencies every Monday morning
on:
  schedule:
    cron: "0 6 * * 1"
    timezone: Europe/Berlin
repositories:
  include:
    - name: "acme/*"
agent:
  prompt: |
    Update the dependencies of {{ repository.fullName }} (default branch {{ repository.defaultBranch }}).
    This run was scheduled for {{ schedule.scheduledAt }}.
```

## Invalid templates

A variable of another event. `pr.title` does not exist for `issue.opened`, so the routine is refused with `` `pr.title` isn't available for `issue.opened`; every chosen event must provide it. ``:

```yaml routine-invalid pointer=/agent/prompt code=template_unknown_variable
name: triage
description: Uses a pull request variable for an issue event
on:
  events: [pr.opened, issue.opened]
repositories:
  include: all
agent:
  prompt: |
    Look at "{{ pr.title }}" in {{ repository.fullName }}.
```

Fix: write one routine per event, or use variables both have (`repository.fullName`, `sender.login`, `event.name`).

`ci.failed` has no sender. With one event the message is `` `sender.login` isn't available for `ci.failed`. ``:

```yaml routine-invalid pointer=/agent/prompt code=template_unknown_variable
name: fix-ci
description: Reads the sender of a CI event
on:
  events: [ci.failed]
repositories:
  include: all
agent:
  prompt: |
    {{ sender.login }} broke the build of {{ ci.name }}. Fix it.
```

Schedules and webhooks have no event data:

```yaml routine-invalid pointer=/agent/prompt code=template_unknown_variable
name: weekly-review
description: Reads a pull request in a scheduled routine
on:
  schedule:
    cron: "0 6 * * 1"
repositories:
  include:
    - name: "acme/api"
agent:
  prompt: |
    Review pull request #{{ pr.number }} in {{ repository.fullName }}.
```

The ref is checked like the prompt, and its pointer is `/agent/ref`. `ci.branch` does not exist for pull request events:

```yaml routine-invalid pointer=/agent/ref code=template_unknown_variable
name: review-on-branch
description: Uses a CI variable in the ref of a pull request routine
on:
  events: [pr.opened]
repositories:
  include: all
agent:
  ref: "{{ ci.branch }}"
  prompt: |
    Review pull request #{{ pr.number }}.
```

A typo is a variable that does not exist:

```yaml routine-invalid pointer=/agent/prompt code=template_unknown_variable
name: review-typo
description: Misspells a variable
on:
  events: [pr.opened]
repositories:
  include: all
agent:
  prompt: |
    Review pull request #{{ pr.numbr }}.
```

A template that does not parse (`the template does not parse: ...`):

```yaml routine-invalid pointer=/agent/prompt code=template_syntax
name: broken-template
description: Leaves an if block open
on:
  events: [pr.opened]
repositories:
  include: all
agent:
  prompt: |
    {% if pr.labels %}Labels: {{ pr.labels | join(", ") }}
    Review pull request #{{ pr.number }}.
```

The full list of validation errors, with codes, is in [Validation errors](https://forkline.dev/docs/routines/errors).
