The file adapter replaces a live tracker with a local JSON file. Pair it with the mock agent and you can validate your entire workflow — prompts, hooks, state transitions — without API credentials, network access, or token spend.

Prerequisites

Create a test fixture

Create issues.json with the fields your prompt template uses. Four fields are required; the rest are optional and default to empty or nil values:

[
  {
    "id": "1",
    "identifier": "TEST-1",
    "title": "Validate login form inputs",
    "state": "To Do",
    "description": "The form accepts empty email addresses.",
    "priority": 1,
    "labels": ["bug", "auth"],
    "comments": [
      {
        "id": "c1",
        "author": "reviewer",
        "body": "Check the regex pattern, not just length.",
        "created_at": "2026-03-15T09:00:00Z"
      }
    ]
  },
  {
    "id": "2",
    "identifier": "TEST-2",
    "title": "Add rate limiting to public API",
    "state": "To Do",
    "description": "",
    "labels": [],
    "blocked_by": [
      { "id": "1", "identifier": "TEST-1", "state": "To Do" }
    ]
  }
]

This fixture tests two template paths at once: TEST-1 has comments and labels, TEST-2 has an empty description and a blocker. Every {{ if }} branch in your prompt gets exercised because the adapter preserves nil-vs-empty semantics — "comments": null means "not fetched," "comments": [] means "none exist," and omitting the field entirely defaults to null.

For the full field schema, see the file-based tasks spec.

Configure the workflow

Set tracker.kind to file and point file.path at your fixture:

---
tracker:
  kind: file
  active_states: ["To Do"]
  handoff_state: Done
  terminal_states: ["Done"]

file:
  path: ./issues.json

agent:
  kind: mock
  max_turns: 2

polling:
  interval_ms: 10000
---

**{{ .issue.identifier }}**: {{ .issue.title }}

{{ if .issue.description }}
{{ .issue.description }}
{{ end }}

{{ if .issue.comments }}
## Feedback
{{ range .issue.comments }}
- {{ .author }}: {{ .body }}
{{ end }}
{{ end }}

{{ if .issue.blocked_by }}
## Blockers
{{ range .issue.blocked_by }}
- {{ .identifier }} ({{ .state }})
{{ end }}
{{ end }}

Run sortie validate ./WORKFLOW.md to catch syntax errors before starting.

Run and observe

sortie ./WORKFLOW.md

Watch the logs. Sortie reads your JSON file, dispatches one mock agent session per active issue, runs two turns each, and transitions them to "Done." The full poll-dispatch-execute-handoff lifecycle runs identically to production — only the data source and agent are swapped.

Press Ctrl+C to stop after the cycle completes.

Test edge cases

The file adapter re-reads the JSON on every operation, so you can edit issues.json while Sortie is running. Add a new issue, change a state, introduce a nil field — the next poll picks it up.

Scenarios worth testing:

  • Nil parent guard. Add "parent": null and confirm your template handles it.
  • Empty description. Set "description": "" and verify the {{ if }} block skips it.
  • Priority sorting. Add issues with "priority": 1, "priority": 3, and "priority": null to confirm dispatch order.
  • Blocker rendering. Populate blocked_by with multiple entries and check the rendered prompt.

Each scenario targets a specific {{ if }} or {{ range }} branch in your template. If a field reference is misspelled, Sortie's strict mode (missingkey=error) fails immediately with a line number — no silent empty strings.

Graduate to a real agent

Once your template renders correctly with the mock agent, swap agent.kind to claude-code and keep the file tracker:

agent:
  kind: claude-code
  max_turns: 3

This runs a real agent against your test fixture — full code generation sessions without touching Jira. When you're satisfied, swap tracker.kind to jira, point it at your project, and the same workflow file drives production.

Troubleshooting

"missing required config key: path" — The file: block is absent or path is empty. Add file.path to your front matter.

"failed to parse file" — The JSON is malformed. Validate it: python3 -m json.tool issues.json > /dev/null

No issues dispatched — The state values in your JSON don't match active_states. Comparison is case-insensitive, but check for typos: "To do" won't match "To Do" because both sides are lowercased to "to do" before comparison — this means case differences are fine, but spelling must match.

For the full configuration schema, see the WORKFLOW.md reference. For template syntax and available variables, see How to write a prompt template.