The Markdown body below the YAML front matter in WORKFLOW.md is a text/template that Sortie renders once per agent turn. This guide walks you through building a production prompt — from a one-liner to a full multi-mode template with conditionals, iteration, and structured data.
Prerequisites¶
- A
WORKFLOW.mdwith valid YAML front matter (quick start) - Familiarity with your tracker's issue fields (title, description, labels)
Start with the essentials¶
Every prompt needs the issue identifier and title. Place them after the closing --- of the front matter:
---
tracker:
kind: jira
project: PROJ
active_states: [To Do, In Progress]
terminal_states: [Done]
agent:
kind: claude-code
---
Fix {{ .issue.identifier }}: {{ .issue.title }}
This renders to Fix PROJ-42: Login page returns 500 on empty email.
Add the description¶
Guard optional fields with {{ if }} — empty strings evaluate to false:
{{ if .issue.description }}
### Description
{{ .issue.description }}
{{ end }}
The same pattern works for every optional string field: url, assignee, branch_name, issue_type.
The description often contains multiline Markdown. The template inserts it as-is — formatting passes through to the agent.
Use all available issue fields¶
The .issue object is normalized across tracker backends:
| Variable | Type | Notes |
|---|---|---|
.issue.id |
string | Internal tracker ID |
.issue.identifier |
string | Human-readable key (PROJ-123) |
.issue.title |
string | Issue summary |
.issue.description |
string | Body text; empty when absent |
.issue.priority |
integer or nil | Lower = higher priority; nil when unavailable. {{ if .issue.priority }} guards both |
.issue.state |
string | Current tracker state |
.issue.branch_name |
string | Tracker-provided branch; empty when absent |
.issue.url |
string | Web link to the issue |
.issue.labels |
list of strings | Lowercase; empty (non-nil) list when none |
.issue.assignee |
string | Identity from the tracker; empty when absent |
.issue.issue_type |
string | Bug, Story, Task, etc.; empty when absent |
.issue.parent |
object or nil | .parent.id, .parent.identifier |
.issue.comments |
list or nil | Each has .id, .author, .body, .created_at. nil = not fetched; [] = no comments |
.issue.blocked_by |
list of objects | Each has .id, .identifier, .state. Never nil; empty when no blockers |
.issue.created_at |
string | ISO-8601 timestamp |
.issue.updated_at |
string | ISO-8601 timestamp |
Two other top-level variables are available alongside .issue:
| Variable | Type | Purpose |
|---|---|---|
.attempt |
integer | 0 on first try, >= 1 on retry |
.run.turn_number |
integer | Current turn within the session |
.run.max_turns |
integer | Configured maximum turns |
.run.is_continuation |
boolean | true on turns 2+ of a multi-turn session |
What counts as falsy in {{ if }}
0, "" (empty string), nil, false, and empty collections ([], {}) all evaluate to false. This means {{ if .issue.description }} skips absent descriptions, {{ if .attempt }} skips the first try, and {{ if .issue.blocked_by }} skips empty blocker lists — no explicit comparison needed.
Branch on first run, continuation, and retry¶
A single template serves three modes. Use .attempt and .run.is_continuation to branch:
{{ if not .run.is_continuation }}
## First Run
Read the specification. Understand the problem before writing code.
Write tests first, then implement the solution.
{{ end }}
{{ if .run.is_continuation }}
## Continuation (Turn {{ .run.turn_number }}/{{ .run.max_turns }})
You are resuming. Check `git status` and test output.
Continue from where the previous turn left off.
{{ end }}
{{ if and .attempt (not .run.is_continuation) }}
## Retry — Attempt {{ .attempt }}
A previous attempt failed. Do not repeat the same approach.
Diagnose the root cause before making changes.
{{ end }}
How the branching works:
- First run:
.attemptis0,.run.is_continuationisfalse. The "First Run" block renders; the other two don't. - Continuation turn:
.run.is_continuationistrue. Only the "Continuation" block renders. - Retry:
.attemptis>= 1,.run.is_continuationisfalse. Only the "Retry" block renders.
If you omit the is_continuation branch entirely, Sortie substitutes a built-in fallback on continuation turns when the rendered output is empty. Explicit branching gives better results because you control what the agent sees.
Render labels, blockers, and comments¶
Labels¶
Labels are a list of lowercase strings. Use the join function to flatten them:
{{ if .issue.labels }}
**Labels:** {{ .issue.labels | join ", " }}
{{ end }}
Blockers¶
Blockers are a list of objects. Iterate with {{ range }}:
{{ if .issue.blocked_by }}
## Blockers
{{ range .issue.blocked_by }}- **{{ .identifier }}**{{ if .state }} ({{ .state }}){{ end }}
{{ end }}
{{ end }}
The dot changes inside {{ range }}
Inside a range block, . is rebound to the current list element — not the root data. Writing {{ .issue.identifier }} inside {{ range .issue.blocked_by }} fails because . is now a blocker object, not the top-level map. Use the dollar-sign prefix {{ $.issue.identifier }} to reach the root from inside any range or with block.
Comments¶
Comments carry human feedback and review notes. Each has .id, .author, .body, and .created_at. The field is nil when not fetched and an empty list when no comments exist — both are falsy in {{ if }}:
{{ if .issue.comments }}
## Feedback
{{ range .issue.comments }}### {{ .author }} ({{ .created_at }})
{{ .body }}
{{ end }}
{{ end }}
For long comment threads, toJSON passes everything in one block:
{{ if .issue.comments }}
Comments: {{ .issue.comments | toJSON }}
{{ end }}
Use the built-in functions¶
Sortie ships three functions beyond Go's template builtins:
| Function | Usage | Result |
|---|---|---|
toJSON |
{{ .issue.labels \| toJSON }} |
["bug","urgent"] |
join |
{{ .issue.labels \| join ", " }} |
bug, urgent |
lower |
{{ .issue.state \| lower }} |
in progress |
Pipe argument order
The pipe (|) passes the value as the last argument. {{ .issue.labels | join ", " }} calls join(", ", labels) — the separator comes first in the function signature because the piped list is appended at the end.
toJSON is useful when the agent needs structured data. Instead of a range loop for blockers:
Blockers: {{ .issue.blocked_by | toJSON }}
The agent receives valid JSON directly.
Add template comments¶
Go template comments ({{/* ... */}}) are stripped at parse time:
{{/* Required env vars: SORTIE_JIRA_ENDPOINT, SORTIE_JIRA_API_KEY */}}
You are a senior engineer working on {{ .issue.identifier }}.
Useful for documenting env var requirements or leaving notes for colleagues.
Verify the result¶
Check for syntax errors without running a full cycle:
sortie validate WORKFLOW.md
This parses the front matter and compiles the template, reporting errors with line numbers. Run it after every edit.
For an end-to-end test with rendering, use the file tracker and a mock agent:
---
tracker:
kind: file
active_states: [To Do]
terminal_states: [Done]
file:
path: test-issues.json
agent:
kind: mock
max_turns: 1
---
Your template here...
Create test-issues.json with a sample issue (see examples/issues.json for the format) and start Sortie:
sortie WORKFLOW.md
Check the logs for the rendered prompt. Render errors appear with line numbers.
Avoid common mistakes¶
Referencing a variable that doesn't exist.
Sortie runs in strict mode (missingkey=error). A typo like {{ .issue.titel }} fails rendering immediately instead of producing an empty string. Check field names against the variable table above.
Forgetting to guard nil fields.
.issue.parent is nil when no parent exists. Accessing .issue.parent.identifier without a guard panics:
{{/* Wrong — crashes when parent is nil */}}
Parent: {{ .issue.parent.identifier }}
{{/* Correct */}}
{{ if .issue.parent }}
Parent: {{ .issue.parent.identifier }}
{{ end }}
Whitespace control.
Go templates insert newlines for each {{ if }} and {{ end }} line. For tighter output, use the trim markers {{- and -}}:
{{- if .issue.url }}
Ticket: {{ .issue.url }}
{{- end }}
The - trims whitespace on that side of the tag. For most prompts, the extra newlines are harmless.
Complete example¶
{{/* Production prompt for Jira + Claude Code workflow */}}
You are a senior engineer. Your work is tracked by Sortie.
## Task
**{{ .issue.identifier }}**: {{ .issue.title }}
{{ if .issue.description }}
### Description
{{ .issue.description }}
{{ end }}
{{ if .issue.labels }}
**Labels:** {{ .issue.labels | join ", " }}
{{ end }}
{{ if .issue.url }}
**Ticket:** {{ .issue.url }}
{{ end }}
## Rules
1. Read relevant docs before writing code.
2. Run `make lint && make test` — all checks must pass.
3. Keep changes minimal.
{{ if not .run.is_continuation }}
## First Run
Start by reading the specification and existing code.
Write tests first. Implement second.
{{ end }}
{{ if .run.is_continuation }}
## Continuation (Turn {{ .run.turn_number }}/{{ .run.max_turns }})
Review workspace state and continue. Do not restart from scratch.
{{ end }}
{{ if and .attempt (not .run.is_continuation) }}
## Retry — Attempt {{ .attempt }}
A previous attempt failed. Diagnose before changing code.
{{ end }}
{{ if .issue.comments }}
## Feedback
{{ range .issue.comments }}### {{ .author }}
{{ .body }}
{{ end }}
{{ end }}
{{ if .issue.blocked_by }}
## Blockers
{{ range .issue.blocked_by }}- **{{ .identifier }}**{{ if .state }} ({{ .state }}){{ end }}
{{ end }}
{{ end }}
This template handles all three modes, renders every useful issue field including comments and blockers, and degrades gracefully when optional data is absent. For the full front matter schema, see the WORKFLOW.md reference.