View as Markdown

Prompt templates and variables

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 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 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 (for example pr.changedFiles) is rejected at save; see 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 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 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. {{ 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.

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.
  • 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, 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.

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
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:

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:

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:

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:

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:

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:

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:

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:

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

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`.:

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:

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:

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:

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: ...):

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.