Skip to content
Orbital

Write prompts

Read run values in a prompt, and use conditions, loops and filters

Write the text an agent step receives, and let Orbital fill in the run’s values before each visit. One prompt can then say something different on a retry, after a review or in a different project.

review.dot
review [prompt="Review the change against this goal: {{ inputs.goal }}"]

The template language is Nunjucks. Every example below shows the prompt and the text it renders to.

A short prompt fits in the node’s prompt attribute, such as prompt="Review the goal for risks.". The attribute sits inside double quotes, so write strings inside the template with single quotes, as every example here does.

A longer prompt belongs in its own file. prompt_file="prompts/review.md" names a file relative to the workflow file, and a prompts folder beside the workflow is the usual place. A node takes one or the other, never both. Orbital reads a prompt file again before each later visit, so an edit reaches a running workflow. If the edited file is invalid, Orbital reports the problem and keeps the prompt it last checked.

{{ … }} prints a value. The goal is inputs.goal, each declared input is inputs.<name>, and anything the run has gathered is under context. Template variables lists every name.

prompt
Work towards this goal: {{ inputs.goal }}
Extra notes: {{ inputs.notes }}

This run was started without the optional notes input, so that line ends empty:

renders
Work towards this goal: Add a dark mode toggle to the settings page
Extra notes:

A value the run does not hold renders as nothing, never as an error or the word undefined. Validation catches most mistakes first. A prompt that reads a context value some earlier path never writes fails orbital validate, and so does a name that is not a template variable.

{% if %} includes text only when its test holds. {% elif %} tries another test and {% else %} covers the rest. Compare with == and !=, and join tests with and, or and not. An empty value counts as false.

prompt
{% if context.verdict == 'reject' -%}
Fix every finding the reviewer listed.
{% elif context.verdict == 'pass' -%}
Tidy the change and write the summary.
{% else -%}
Review the change from the start.
{% endif -%}

With verdict set to reject:

renders
Fix every finding the reviewer listed.

The dashes in -%} stop each tag from leaving a blank line behind. Whitespace control explains them.

{% for %} repeats its text for each item. Inside it, loop.index counts from 1, loop.first and loop.last mark the ends, and loop.length is the number of items. {% else %} inside a loop renders when there is nothing to loop over.

prompt
Check each folder:
{% for name, folder in run.folders -%}
{{ loop.index }}. {{ name }} at {{ folder.path }}
{% else -%}
This project has no folders.
{% endfor -%}

In a project with the folders web and api:

renders
Check each folder:
1. web at /work/shop/web
2. api at /work/shop/api

Context values are single pieces of text, numbers or true and false, so a loop has two useful sources: the folders under run.folders or project.folders, and a list written in the prompt itself, such as {% for area in ['tests', 'types'] %}. A json output is stored as text, and a loop over it walks its characters.

A filter changes a value on its way into the prompt. Write it after a |, and chain as many as you need.

Filter What it does
default('x') Uses x when the value is missing. default('x', true) also replaces empty text.
trim Removes spaces and line breaks from both ends.
join(', ') Joins a list into one piece of text.
length Counts the items in a list, the folders in run.folders, or the characters in text.
upper and lower Change the case.
replace('a', 'b') Replaces every a with b.
prompt
Branch: {{ inputs.title | trim | lower | replace(' ', '-') }}
Priority: {{ inputs.priority | default('normal') | upper }}
Last failure: {{ context.failure_reason | default('none', true) }}
Folders: {{ run.folders | length }}
Checks: {{ ['lint', 'tests', 'types'] | join(', ') }}

With title set to Fix Login Redirect, no priority input, no failure yet and one folder:

renders
Branch: fix-login-redirect
Priority: NORMAL
Last failure: none
Folders: 1
Checks: lint, tests, types

failure_reason is empty text after a success rather than missing, which is why that line needs default('none', true). Nunjucks has more filters; its list of built-in filters covers them all.

Every line break in the prompt stays in the output, including the ones around {% %} tags. A condition that is false leaves an empty line where it stood:

prompt
Review the change.
{% if context.failure_reason %}
The last attempt failed: {{ context.failure_reason }}
{% endif %}
Report what you found.
renders
Review the change.
Report what you found.

A dash inside a tag removes the spaces and line breaks on that side. {%- trims before the tag and -%} trims after it. {{-, -}}, {#- and -#} work the same way.

prompt
Review the change.
{%- if context.failure_reason %}
The last attempt failed: {{ context.failure_reason }}
{%- endif %}
Report what you found.
renders
Review the change.
Report what you found.

When the step fails and comes back, the same prompt adds the failure on its own line between the other two.

{# … #} holds a note for whoever edits the prompt. The agent never sees it.

prompt
{# The reviewer reads this on a phone, so keep the prompt short. -#}
Review the change in three sentences or fewer.
renders
Review the change in three sentences or fewer.

Text between {% raw %} and {% endraw %} renders exactly as written. Use it when the agent must see template syntax of its own, such as a placeholder in a file it edits.

prompt
Add a greeting to the email template. It must say:
{% raw %}Hello {{ customer.name }}, your order has shipped.{% endraw %}
renders
Add a greeting to the email template. It must say:
Hello {{ customer.name }}, your order has shipped.

A prompt cannot read files, so include, import and extends are unavailable, and it cannot run shell commands. Orbital checks everything a prompt reads before the run starts. A prompt that pulled in another file or a command’s output would read things that check never saw.

To give a prompt a file’s contents or a command’s result, add a command node before it. Its facts land in context, where the prompt reads them. To share text between prompts, put it in one prompt file and point several nodes at it.

Orbital writes context.failure_reason after every step. It is empty on success and holds the reason on failure. A step reached again through a failure edge can explain what went wrong, while its first visit reads as normal.

prompt
Implement the goal: {{ inputs.goal }}
{%- if context.failure_reason %}
Your last attempt failed: {{ context.failure_reason }}
Fix that first.
{%- endif %}

After the tests failed:

renders
Implement the goal: Fix the login redirect
Your last attempt failed: npm test exited with code 1: 2 of 48 tests failed
Fix that first.

On the first visit, the prompt ends after the goal.

With inputs="ticket_url" on the workflow, the new-run form asks for a ticket URL and every prompt can read it.

prompt
Goal: {{ inputs.goal }}
Implement the ticket at {{ inputs.ticket_url }}. Keep the change to what the ticket asks for.
renders
Goal: Ship the ticket as one pull request
Implement the ticket at https://github.com/acme/shop/issues/42. Keep the change to what the ticket asks for.

inputs.ticket_url always renders what the run started with. context.ticket_url renders the current value, which a later step may have changed.

List each branch’s result after a fan-in

Section titled “List each branch’s result after a fan-in”

After a fan-in, each branch’s result sits under context.parallel.results, in edge order, with its node, outcome, failure_reason and context. Quote the index. Read each branch on its own line. Validation checks every value a prompt reads, and it cannot check a loop over the results.

prompt
Two reviewers read the change. Combine their findings into one list.
{{ context.parallel.results['0'].node }}: {{ context.parallel.results['0'].context.assessment }}
{{ context.parallel.results['1'].node }}: {{ context.parallel.results['1'].context.assessment }}
renders
Two reviewers read the change. Combine their findings into one list.
security: The coupon code is not escaped before the query.
tests: Nothing covers an expired coupon.

run.folders.<name>.path is where the run works on that folder now, which is a worktree once the run has one. project.folders.<name>.path is the folder as the project holds it. The name is the folder’s name in the project.

prompt
Work only inside {{ run.folders.web.path }}.
Do not edit {{ project.folders.web.path }}. That folder belongs to the person who started the run.
renders
Work only inside /home/me/.orbital/worktrees/r7Kq2/web.
Do not edit /work/shop/web. That folder belongs to the person who started the run.

context.response.<node> is the final prose of the agent step called <node>, without the outputs it handed over. It is empty until that step completes, and it names agent steps only.

prompt
The planner wrote this plan:
{{ context.response.plan }}
Implement it step by step.
renders
The planner wrote this plan:
1. Read the return URL from the query string.
2. Redirect there after sign-in.
Implement it step by step.
Write To
{{ context.verdict }} Print a value.
{% if context.verdict == 'reject' %} … {% endif %} Include text only when a test holds.
{% for name, folder in run.folders %} … {% endfor %} Repeat text for each item.
{{ inputs.title | trim }} Change a value with a filter.
{%- and -%} Trim the line break before or after a tag.
{# note #} Leave a note the agent never sees.
{% raw %} … {% endraw %} Print template syntax as written.