Skip to main content
The WORKFLOW.md file is Symphony’s core configuration artifact. It combines YAML frontmatter for runtime settings with a Liquid-templated Markdown body that becomes the agent’s instruction prompt.

File Structure

A WORKFLOW.md file has three parts:
  1. Opening --- delimiter
  2. YAML frontmatter (configuration)
  3. Closing --- delimiter
  4. Markdown body (prompt template)
---
tracker:
  kind: linear
  project_slug: "symphony-0c79b11b75ea"
workspace:
  root: ~/code/symphony-workspaces
hooks:
  after_create: |
    git clone https://github.com/openai/symphony .
agent:
  max_concurrent_agents: 10
codex:
  command: codex app-server
---

You are working on a Linear ticket `{{ issue.identifier }}`

Title: {{ issue.title }}
Description: {{ issue.description }}

YAML Frontmatter

The frontmatter contains all runtime configuration. See Configuration for the complete reference.

Parsing Rules

  • Must be valid YAML
  • Top-level keys: tracker, polling, workspace, agent, codex, hooks, observability, server
  • Empty frontmatter (---\n---) is valid and uses all defaults
  • Invalid YAML halts Symphony startup until fixed
Symphony validates the workflow file on startup and when it detects changes. Syntax errors will prevent the orchestrator from scheduling new work.

Markdown Body: Prompt Template

The Markdown body after the closing --- becomes the agent prompt. Symphony uses Liquid templating via the Solid library to inject issue data.

Template Rendering

From lib/symphony_elixir/prompt_builder.ex:18-24:
template
|> Solid.render!(
  %{
    "attempt" => Keyword.get(opts, :attempt),
    "issue" => issue |> Map.from_struct() |> to_solid_map()
  },
  @render_opts
)
Symphony renders the template with:
  • attempt: Retry counter (nil for first run, integer for retries)
  • issue: Full Linear issue struct as a map

Available Variables

attempt
integer | nil
Retry attempt number. nil on first run, 1+ on retries.
{% if attempt %}
This is retry attempt #{{ attempt }}
{% endif %}
issue.identifier
string
Linear issue ID (e.g., "MT-123").
Working on {{ issue.identifier }}
issue.title
string
Issue title.
Title: {{ issue.title }}
issue.description
string | nil
Issue body/description. May be empty.
{% if issue.description %}
{{ issue.description }}
{% else %}
No description provided.
{% endif %}
issue.state
string
Current Linear state name (e.g., "In Progress", "Done").
Current status: {{ issue.state }}
issue.url
string
Direct Linear URL to the issue.
URL: {{ issue.url }}
issue.labels
array
List of label names (lowercased).
Labels: {{ issue.labels }}
issue.priority
integer | nil
Linear priority value.
issue.branch_name
string | nil
Git branch name associated with the issue.
issue.assignee_id
string | nil
Linear user ID of the assignee.
issue.blocked_by
array
Array of blocking issues. Each blocker has id, identifier, and state.
{% if issue.blocked_by %}
Blocked by: 
{% for blocker in issue.blocked_by %}
- {{ blocker.identifier }} ({{ blocker.state }})
{% endfor %}
{% endif %}
issue.created_at
ISO8601 string
Issue creation timestamp.
issue.updated_at
ISO8601 string
Last update timestamp.

Example: Production Workflow

Symphony’s own WORKFLOW.md (from elixir/WORKFLOW.md:1-48):
tracker:
  kind: linear
  project_slug: "symphony-0c79b11b75ea"
  active_states:
    - Todo
    - In Progress
    - Merging
    - Rework
  terminal_states:
    - Closed
    - Cancelled
    - Duplicate
    - Done
polling:
  interval_ms: 5000
workspace:
  root: ~/code/symphony-workspaces
hooks:
  after_create: |
    git clone --depth 1 https://github.com/openai/symphony .
    if command -v mise >/dev/null 2>&1; then
      cd elixir && mise trust && mise exec -- mix deps.get
    fi
agent:
  max_concurrent_agents: 10
  max_turns: 20
codex:
  command: codex --model gpt-5.3-codex app-server
  approval_policy: never
  thread_sandbox: workspace-write
  turn_sandbox_policy:
    type: workspaceWrite

Template Rendering Mode

From lib/symphony_elixir/prompt_builder.ex:8:
@render_opts [strict_variables: true, strict_filters: true]
Symphony uses strict mode:
  • Undefined variables cause template rendering to fail
  • Typos in {{ issue.tittle }} will raise errors
  • Unknown Liquid filters will fail
This prevents silent failures from typos in production prompts.

Default Prompt

If the Markdown body is empty or only whitespace, Symphony uses this default (from lib/symphony_elixir/config.ex:12-24):
You are working on a Linear issue.

Identifier: {{ issue.identifier }}
Title: {{ issue.title }}

Body:
{% if issue.description %}
{{ issue.description }}
{% else %}
No description provided.
{% endif %}

Workflow File Reloading

Symphony watches WORKFLOW.md for changes:
  • Changes trigger validation
  • Invalid changes halt new agent scheduling
  • Running agents continue with their original configuration
  • Fix the file to resume scheduling
During development with Elixir hot code reloading, you can update the workflow file without restarting the service.

Common Patterns

Retry Awareness

{% if attempt %}
## Continuation (Attempt #{{ attempt }})

This workspace has prior state. Resume from current progress.
Do not restart from scratch.
{% else %}
## Initial Run

Fresh workspace. Begin with investigation.
{% endif %}

State-Specific Instructions

{% if issue.state == "Rework" %}
Reviewer requested changes. Address all feedback before re-submitting.
{% elsif issue.state == "Merging" %}
Run the land skill. Do not call gh pr merge directly.
{% endif %}

Blocker Handling

{% if issue.blocked_by %}
## Blockers

This issue is blocked by:
{% for blocker in issue.blocked_by %}
- {{ blocker.identifier }}: {{ blocker.state }}
{% endfor %}

Do not proceed until blockers are resolved.
{% endif %}

Label-Based Routing

{% if issue.labels contains "bug" %}
This is a bug fix. Start by reproducing the issue.
{% elsif issue.labels contains "feature" %}
This is a new feature. Begin with design validation.
{% endif %}

Validation

To validate your workflow file syntax:
mise exec -- iex -S mix
iex> SymphonyElixir.Workflow.load("./WORKFLOW.md")
{:ok, %{config: %{...}, prompt: "...", prompt_template: "..."}}
Errors will be returned with details:
{:error, {:workflow_parse_error, reason}}

Build docs developers (and LLMs) love