How to Connect Sortie to GitHub Issues
This guide configures Sortie to poll issues from a GitHub repository, dispatch agents, and track state through labels. By the end, you’ll have a working WORKFLOW.md that authenticates against GitHub, maps your issue labels to Sortie states, and reports status changes back to the repo.
Prerequisites
- Sortie installed and on your
PATH(installation guide) - Quick start completed with the file adapter (quick start)
- A GitHub repository where you have permission to manage issues and labels
- A personal access token, classic or fine-grained (creation steps below)
Create a personal access token
Sortie needs a token that can read and write issues and labels on the repository you configure. A classic token scoped to the repository works, and so does a fine-grained token granted issue read and write plus repository metadata read. GitHub documents how to create either and what each scope covers; see managing personal access tokens.
Nothing else is required for the tracker. Auto-merge and branch cleanup need a token that can also write to the repository.
Store the token in an environment variable:
export SORTIE_GITHUB_TOKEN="<your-token>"No endpoint override is needed for github.com. For GitHub Enterprise Server, set endpoint in the tracker block of WORKFLOW.md to your instance’s API base URL, or override it at deploy time with the generic tracker env var:
export SORTIE_TRACKER_ENDPOINT="https://github.yourcompany.com/api/v3"Write the minimum configuration
Replace the tracker section in your WORKFLOW.md front matter:
---
tracker:
kind: github
api_key: $SORTIE_GITHUB_TOKEN
project: myorg/myrepo
active_states: [backlog, in-progress, review]
terminal_states: [done]
agent:
kind: claude-code
command: claude
---
Fix #{{ .issue.identifier }}: {{ .issue.title }}Five fields are required:
api_key: a single token string. Unlike Jira, this is not anemail:tokenpair. It’s the PAT by itself, sent as aBearertoken on every request.project:owner/repoformat. Must contain exactly one/with both segments non-empty. Example:acme-corp/platform.kind:github.active_statesandterminal_states: the labels that mark an issue as work for Sortie and as finished. Sortie has no default for either list.terminal_statesneeds at least one entry, or the workflow fails to load. See Map states to labels.
The $VAR syntax expands environment variables at config load time. If you omit endpoint, Sortie defaults to https://api.github.com.
Map states to labels
This is the key difference from Jira. GitHub has no native workflow states beyond open and closed. Sortie derives richer states from issue labels. You control the workflow by defining which labels represent active and terminal states.
active_states: label names for issues eligible for dispatch (e.g.,backlog,in-progress,review). Sortie picks up an open issue only when it carries one of these labels. An open issue with none of them is left alone.terminal_states: label names for completed issues (e.g.,done,wontfix). A closed issue counts as terminal even when it carries none of them.
To hand an issue to Sortie, add one of the active_states labels to it. To keep an issue away from Sortie, leave the label off.
All comparisons are case-insensitive. Config values are lowercased at startup, so "In-Progress" and "in-progress" behave identically.
Create the active_states labels before you start. An issue can only carry a label that already exists, and those labels are what make an issue eligible for dispatch. Create one label per entry in active_states and terminal_states. At the first poll, Sortie logs a warning for each active_states label the repository does not have. GitHub’s own documentation covers creating labels through the web UI or the gh CLI: managing labels.
How state derivation works
When Sortie reads an issue, it scans the issue’s labels against terminal_states first, then handoff_state, then active_states, in config order. The first match wins. Because the handoff label is part of that scan, an issue parked in handoff keeps its own state rather than looking unlabeled. If no label matches, a closed issue counts as terminal, and an open issue has no state and is not dispatched. See state derivation for the full rules.
Removing every state label from an issue Sortie is working on stops that work, and the workspace stays. Sortie waits for a second poll that sees no state label before it stops, so a label edit made in two steps does not interrupt the run.
When Sortie transitions an issue, it removes the old state label, adds the new state label, and closes or reopens the issue as needed. Moving to a terminal state closes the issue. Moving to an active state from a closed issue reopens it. All label operations are idempotent. Retrying a failed transition converges to the correct state.
Scope issues with a query filter
By default, Sortie fetches all open issues in the repository and filters client-side by state label. This works fine for repos with up to a few hundred open issues.
For larger repos, set query_filter to push filtering server-side using GitHub search syntax:
tracker:
kind: github
api_key: $SORTIE_GITHUB_TOKEN
project: myorg/myrepo
query_filter: "label:agent-ready milestone:v2.0"Sortie routes this through the search endpoint with the query repo:myorg/myrepo type:issue state:open label:agent-ready milestone:v2.0.
Other useful filters:
# Only issues with a specific label
query_filter: "label:agent-ready"
# Only issues in a milestone
query_filter: "milestone:v2.0"
# Only issues assigned to a user
query_filter: "assignee:octocat"
# Combination
query_filter: "label:agent-ready assignee:octocat"One tradeoff: the search endpoint is metered far more tightly than the issues endpoint, so use query_filter only when you need server-side filtering. Only use query_filter when you need server-side filtering.
Configure handoff state
When an agent completes its work, Sortie can transition the issue to a review state:
tracker:
kind: github
api_key: $SORTIE_GITHUB_TOKEN
project: myorg/myrepo
active_states: [backlog, in-progress]
handoff_state: review
terminal_states: [done]Sortie removes the current state label (e.g., in-progress), adds the review label, and keeps the issue open, because review is not in terminal_states.
Constraints:
handoff_statemust not appear interminal_states. A handoff parks the issue for a person; it is not a close. Sortie rejects the configuration at load time.handoff_statemust not appear inactive_stateseither, or the issue would be dispatched again on the next poll. If you usereviewas the handoff label, leave it out ofactive_states, as the snippet above does.- Sortie creates the label on demand the first time an issue transitions into it.
Closing stays a human decision. Move the issue to a terminal state once you have read the work, and Sortie cleans up its workspace on the next sweep.
Configure dispatch-time transitions
Sortie can transition an issue when the agent picks it up, moving it to an “in progress” column so your team sees work has started:
tracker:
kind: github
api_key: $SORTIE_GITHUB_TOKEN
project: myorg/myrepo
active_states: [backlog, in-progress]
in_progress_state: in-progress
handoff_state: review
terminal_states: [done]in_progress_state must appear in active_states. If the issue is already in that state at dispatch time, the transition is skipped. If it fails for other reasons, Sortie logs a warning and continues. The agent session proceeds regardless.
Enable tracker comments
Sortie can post comments on issues at session lifecycle points. Add a tracker_comment entry to notifications and list the events you want:
notifications:
- kind: tracker_comment
events: [session.started, session.completed, session.stopped, session.failed]List only the events you care about. Nothing is posted by default. See how to route notifications for Slack, webhooks, and moving off the deprecated tracker.comments flags. Comments are posted as Markdown. No conversion is needed, unlike Jira’s Atlassian Document Format.
Comment failures are non-fatal. Sortie logs a warning and continues.
Verify the connection
Validate syntax
Check your configuration without making API calls:
sortie validate ./WORKFLOW.mdThis parses front matter, compiles the prompt template, and runs preflight checks. It catches missing fields, a malformed endpoint, bad owner/repo format, env vars that resolve to empty strings, an empty terminal_states, blank or whitespace-padded state labels, an empty active_states (a warning, because nothing is dispatched), state overlap between active_states and terminal_states, and a handoff_state that appears in either list.
When GITHUB_TOKEN is set but api_key is empty, it hints at the available token. See validate-time checks for the full list of GitHub-specific diagnostics.
Test connectivity
Run a single poll cycle without dispatching agents:
sortie --dry-run ./WORKFLOW.mdWatch the logs. A successful run produces one dry-run: candidate line per matching issue, followed by a summary:
level=INFO msg="dry-run: complete" candidates_fetched=3 would_dispatch=2 ineligible=1 max_concurrent_agents=3candidates_fetched=3 means Sortie found 3 issues matching your active states (and query_filter, if set). would_dispatch counts how many of those it would have dispatched; dry-run mode never actually spawns an agent.
If candidates_fetched=0 and you expected results, see An open issue is not picked up.
Troubleshoot errors
Wrong token or expired
level=ERROR msg="failed to fetch candidate issues" error="tracker: tracker_auth_error: GET /repos/myorg/myrepo/issues: 401"Verify the token is valid:
curl -s -H "Authorization: Bearer $SORTIE_GITHUB_TOKEN" \
"https://api.github.com/user" | head -5If this returns your profile, the token works. If it returns 401, generate a new one.
Insufficient permissions
level=ERROR msg="failed to fetch candidate issues" error="tracker: tracker_auth_error: GET /repos/myorg/myrepo/issues: 403 insufficient permissions"A 403 that isn’t rate limiting means the token lacks the required scope. For a classic PAT, enable repo. For a fine-grained PAT, grant Issues: Read and Write.
Rate limiting (primary)
level=ERROR msg="failed to fetch candidate issues" error="tracker: tracker_api_error: GET /repos/myorg/myrepo/issues: 403 rate limited (primary)"Happens when x-ratelimit-remaining hits zero. This is uncommon for small repos. If you hit it, increase polling.interval_ms or add a query_filter to reduce the number of issues fetched per tick.
Rate limiting (search)
level=ERROR msg="failed to fetch candidate issues" error="tracker: tracker_api_error: GET /search/issues: 429 rate limited"The search endpoint is metered far more tightly than the issues endpoint. If you’re using query_filter, consider increasing polling.interval_ms.
Repository not found
level=ERROR msg="failed to fetch candidate issues" error="tracker: tracker_not_found: GET /repos/myorg/myrepo/issues: 404"Check that project is in owner/repo format and that the token has access to the repo. Private repositories require explicit token access: a fine-grained PAT must be scoped to the repo, and a classic PAT must have repo scope.
An open issue is not picked up
Sortie dispatches an open issue only when it carries a label from active_states. Check these in order:
- Does the issue carry one of the
active_stateslabels? Spelling must match, ignoring case. Sortie logsskipped open issues without a state labelat INFO with the number of open issues it left alone, and logs it again only when the number changes. - Does the repository have the label? Sortie logs a warning,
configured active label missing from tracker labels, naming eachactive_stateslabel the repository lacks, so a mistyped label shows up at the first poll. - Does the issue also carry a
terminal_stateslabel? A terminal label outranks an active one, so the issue reads as finished. Sortie logsissue carries more than one state labelwhen an issue carries several. - Does the issue match
query_filter, when you set one?
Transition does not change the label
Check the token first: applying and removing labels needs write access to issues on the repository. A label named in active_states is the one case that still has to exist beforehand, because an issue without an active-state label never becomes a candidate for dispatch in the first place.
Issue is a pull request
tracker: tracker_not_found: resource is a pull request, not an issue: 42GitHub’s issues API co-mingles pull requests with issues. Sortie filters them out when it polls for candidates, but naming a pull request number directly is an error rather than a silent miss.
Full production example
---
tracker:
kind: github
api_key: $SORTIE_GITHUB_TOKEN
project: acme-corp/platform
query_filter: "label:agent-ready"
active_states:
- backlog
- in-progress
in_progress_state: in-progress
handoff_state: review
terminal_states:
- done
- wontfix
polling:
interval_ms: 60000
workspace:
root: ~/workspace/sortie
agent:
kind: claude-code
command: claude
max_turns: 3
---
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 }}
**Issue:** {{ .issue.url }}
{{ end }}This configuration polls every 60 seconds, picks up open issues labeled agent-ready that also carry the backlog or in-progress label, runs up to 3 agent turns per issue, and moves completed issues to the review label. Issues reaching done or wontfix are closed automatically. For the full set of configuration options, see the WORKFLOW.md reference. For prompt template syntax, see How to write a prompt template.
Was this page helpful?