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/promptwith`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 byDid 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, notpr.head_branch. In a template the slash command iscomment.command.name; in awhencondition it iscomment.commandName. ci.failuresentries have onlyname,runId,urlandconclusion. There are no logs in the prompt; tell the agent to fetch them.comment.prompt,comment.path,comment.requestedModelandthread.commentsare reserved and rejected with “is not supported yet”.ci.failedhas apronly when the run belongs to a pull request: guard the routine withfilters.when: 'pr.number != null'and the prompt with{% if pr %}. A variable that is not in the availability table (for examplepr.changedFiles) is rejected at save; see Variables that do not exist.- Fields an event does not fill (
pr.headBranchon a Forgejoci.failed,pr.authoron GitLab) are empty strings, not missing, and a JSONnullin a webhookpayloadprintsNone.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
| lengthon a root that may be absent (prin a comment event): guard it with{% if pr %}. - Schedule and webhook routines have no
pr,issue,comment,ci,pushorsender. 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 codetemplate_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 withthe 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 (
pron 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 nopr;joinandfortreat 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 JSONnullin a webhookpayloadprintsNone; for both writedefault("x", true), which replaces every empty or false value. - A run that cannot render is recorded with the outcome
template_errorand 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.defaultBranchcomes 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.forgeisgithub,gitlaborforgejo. A schedule or a webhook has no forge, so usingevent.forgeorevent.deliveryIdin their prompt is refused at save.event.deliveryIdis the forge’s delivery ID and is empty when the forge sent none.event.nameis the event name (pr.opened, …),scheduleorwebhook.receivedAtis an RFC 3339 UTC time; for a schedule it is the scheduled time.sender.loginis empty only when the forge sent no sender.ci.failedhas no sender.pr.body,pr.labels,issue.body,issue.labels,comment.bodyand the file lists can be empty.pr.authoris empty on GitLab except forpr.opened.princomment.createdis 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.headShaare empty).issueincomment.createdis filled only when the comment is on an issue. Never both. A template for a comment normally branches:{% if pr %}...{% elif issue %}...{% endif %}.princi.failedis filled only when the CI run belongs to a pull request, and then onlypr.number,pr.headSha, and on GitHub and GitLab the branches. Title, body, author and labels are empty. See Events.comment.command.nameandcomment.command.argsare empty strings when the comment has no slash command;comment.commanditself is always an object, so{{ comment.command.name }}never fails. In awhencondition the same values arecomment.commandNameandcomment.commandArgs; templates andwhenuse different names on purpose.ci.branchcan be empty on Forgejo when the run belongs to a pull request; usepr.headBranchthen.ci.failuresis empty on GitLab when no failed job was reported.push.files.added,push.files.modifiedandpush.files.removedare lists of paths;push.filesTruncatedistruewhen they are incomplete.payloadis the JSON body of the webhook call (any JSON value, sopayload.alert.idandpayload.items[0]work). A body that is not JSON arrives aspayload.raw_body. Run now on a webhook routine sends thepayloadyou give it, in the same shape; without one,payloadis empty.payloadis also readable in a webhook routine’swhen, within limits; templates always see the whole body. A JSONnullis not missing:{{ payload.title }}printsNone, so check what you need withdefault("(no title)", true).- A schedule has
repository.*,event.name,receivedAtandschedule.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.