Status: Draft v1 (language-agnostic)
Purpose: Define a service that orchestrates coding agents to get project work done, using GitHub Projects (v2) as the issue tracker and Claude Code CLI or GitHub Copilot CLI as the coding agent.
Lineage: This specification is a derivative of the Symphony Service Specification
(https://github.com/openai/symphony/blob/main/SPEC.md). Sections that Baton inherits unchanged are
marked [= Symphony] and restated in condensed form; sections that differ are written out in full.
Appendix B summarizes every difference for readers already familiar with Symphony.
The key words MUST, MUST NOT, REQUIRED, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and
OPTIONAL in this document are to be interpreted as described in RFC 2119.
Implementation-defined means the behavior is part of the implementation contract, but this
specification does not prescribe one universal policy. Implementations MUST document the selected
behavior.
Baton is a long-running automation service that continuously reads work from a GitHub Projects (v2) board, creates an isolated workspace for each issue, and runs a coding agent session for that issue inside the workspace.
The service solves the same four operational problems as Symphony:
- It turns issue execution into a repeatable daemon workflow instead of manual scripts.
- It isolates agent execution in per-issue workspaces so agent commands run only inside per-issue workspace directories.
- It keeps the workflow policy in-repo (
WORKFLOW.md) so teams version the agent prompt and runtime settings with their code. - It provides enough observability to operate and debug multiple concurrent agent runs.
Important boundary (inherited from Symphony):
- Baton is a scheduler/runner and tracker reader.
- Tracker writes (Status transitions, comments, PR links) are performed by the coding agent using
the
ghCLI (or a GitHub MCP server) available in the workflow/runtime environment. - A successful run can end at a workflow-defined handoff state (for example
In Review), not necessarilyDone.
Implementations MUST document their trust and safety posture explicitly. Baton's reference posture is high-trust (see Section 15).
- Poll GitHub Projects on a fixed cadence and dispatch work with bounded concurrency.
- Maintain a single authoritative orchestrator state for dispatch, retries, and reconciliation.
- Create deterministic per-issue workspaces and preserve them across runs.
- Stop active runs when issue state changes make them ineligible.
- Recover from transient failures with exponential backoff.
- Load runtime behavior from a repository-owned
WORKFLOW.mdcontract. - Expose operator-visible observability (at minimum structured logs).
- Support tracker/filesystem-driven restart recovery without requiring a persistent database.
- Rich web UI or multi-tenant control plane.
- General-purpose workflow engine or distributed job scheduler.
- Built-in business logic for how to edit issues, PRs, or comments (lives in the workflow prompt and agent tooling).
- Mandating a single approval/sandbox posture for all implementations.
Workflow Loader— readsWORKFLOW.md, parses YAML front matter and prompt body, returns{config, prompt_template}.[= Symphony]Config Layer— typed getters, defaults,$VARenv indirection, validation.[= Symphony]Issue Tracker Client— GitHub Projects v2 GraphQL adapter. Fetches candidate items in active states, refreshes states for running issues, fetches terminal-state issues for startup cleanup, and normalizes payloads into the stable issue model (Section 4).Orchestrator— owns the poll tick, in-memory runtime state, dispatch/retry/stop/release decisions, session metrics.[= Symphony]Workspace Manager— maps issue identifiers to workspace paths, lifecycle hooks, terminal cleanup.[= Symphony]Agent Runner— creates workspace, builds prompt, launches a coding agent session via one of the agent adapters (claude_codeorcopilot), streams agent updates to the orchestrator.Status Surface(OPTIONAL) andLogging.[= Symphony]
Policy (repo WORKFLOW.md) → Configuration (typed getters) → Coordination (orchestrator) →
Execution (workspace + agent subprocess/SDK session) → Integration (GitHub Projects adapter) →
Observability (logs + OPTIONAL status surface).
- GitHub GraphQL API v4 (
https://api.github.com/graphql) for Projects v2 reads. - Local filesystem for workspaces and logs.
- OPTIONAL workspace population tooling (Git CLI /
ghCLI, typically used by hooks). - A coding agent executable:
claude(Claude Code CLI) supporting headless/print mode and the Claude Agent SDK protocol, orcopilot(GitHub Copilot CLI) supporting programmatic (-p) mode.
- Host environment authentication:
GITHUB_TOKEN(orgh authlogin) with read access to the Project and read/write access to the target repositories for the agent's tracker writes.- Agent authentication (Claude subscription/API key, or Copilot subscription) as required by the selected agent CLI.
Normalized issue record used by orchestration, prompt rendering, and observability output.
Fields:
id(string)- Stable GraphQL node ID of the Issue (
I_...). Used for tracker lookups and internal map keys.
- Stable GraphQL node ID of the Issue (
item_id(string)- GraphQL node ID of the ProjectV2Item (
PVTI_...) linking the issue to the configured project. Retained so agents/operators can address project field updates; the orchestrator itself does not write fields.
- GraphQL node ID of the ProjectV2Item (
identifier(string)- Human-readable key composed as
<owner>__<repository_name>-<issue_number>(example:acme__myrepo-123). The/separator inowner/repois replaced with__so the identifier is a single URL-safe path segment and the owner/repo boundary is unambiguous.
- Human-readable key composed as
number(integer) — issue number.repository(string) —owner/name.title(string)description(string or null) — issue body.priority(integer or null)- Derived from the OPTIONAL Project
priority_field(single-select). The option's position (1-based, top option = 1) is the priority. Lower numbers are higher priority. Null when the field is absent or unset.
- Derived from the OPTIONAL Project
state(string)- Current value of the Project Status field (single-select option name).
branch_name(string or null)- Derived from linked branches when available; otherwise null. Implementations MAY leave this null and let the workflow prompt define branch naming.
url(string or null)labels(list of strings) — normalized to lowercase.blocked_by(list of blocker refs)- Derived from GitHub issue dependencies ("blocked by" relationships). Each blocker ref
contains
id,identifier, andstate(the blocker's Project Status when it is on the same project, otherwise its issue stateOPEN/CLOSEDmapped as non-terminal/terminal). - If the dependencies API is unavailable to the token,
blocked_byMUST default to[].
- Derived from GitHub issue dependencies ("blocked by" relationships). Each blocker ref
contains
created_at/updated_at(timestamps or null)
{config, prompt_template} parsed from WORKFLOW.md.
Typed runtime values derived from front matter plus environment resolution.
{path, workspace_key, created_now}.
{issue_id, issue_identifier, attempt, workspace_path, started_at, status, error?}.
State tracked while a coding-agent session is running.
Fields:
session_id(string,<agent_session_id>-<turn_number>)agent_session_id(string)- Claude Code: the
session_idreported by the CLI/SDKinitmessage. - Copilot: the session identifier reported by the CLI (or a generated UUID when none is exposed).
- Claude Code: the
turn_number(integer, 1-based within the current worker lifetime)agent_pid(string or null)last_agent_event(string/enum or null)last_agent_timestamp(timestamp or null)last_agent_message(summarized payload)input_tokens/output_tokens/total_tokens(integers)last_reported_*_tokens(integers, for delta tracking)turn_count(integer)
{issue_id, identifier, attempt, due_at_ms, timer_handle, error}.
{poll_interval_ms, max_concurrent_agents, running, claimed, retry_attempts, completed, agent_totals, agent_rate_limits} — single authoritative in-memory state. agent_totals
replaces Symphony's codex_totals with the same shape (tokens + runtime seconds).
Issue ID— GraphQL Issue node ID; use for tracker lookups and internal map keys.Issue Identifier—<owner>__<repo>-<number>(e.g.acme__myrepo-123); use for logs and workspace naming. The/inowner/repois replaced with__to preserve the owner/repo boundary.Workspace Key— derive fromissue.identifierby replacing any character not in[A-Za-z0-9._-]with_.[= Symphony]Normalized Issue State— compare Status option names afterlowercase+ trim.Session ID—<agent_session_id>-<turn_number>.
- Explicit runtime setting (CLI positional argument). 2. Default
WORKFLOW.mdin cwd. Unreadable file →missing_workflow_fileerror.
Markdown with OPTIONAL YAML front matter delimited by ---. Front matter MUST decode to a map.
Body (trimmed) is the prompt template. No front matter → whole file is prompt body, empty config.
Top-level keys: tracker, polling, workspace, hooks, agent, claude_code, copilot.
Unknown keys SHOULD be ignored for forward compatibility. Extensions MAY define additional
top-level keys (for example server, Section 13.7).
kind(string)- REQUIRED for dispatch. Current supported value:
github_projects.
- REQUIRED for dispatch. Current supported value:
endpoint(string)- Default:
https://api.github.com/graphql. Override for GitHub Enterprise Server.
- Default:
token(string)- MAY be a literal token or
$VAR_NAME. Canonical environment variable:GITHUB_TOKEN. - If
$VAR_NAMEresolves to an empty string, treat the token as missing. - REQUIRED scopes: read access to the Project (
read:projectclassic scope or fine-grainedProjects: read), plus whatever repository access the agent's ownghusage requires (typicallyContents,Issues,Pull requests: read/write).
- MAY be a literal token or
owner(string)- REQUIRED. Organization login or user login that owns the project.
owner_type(string)organization(default) oruser.
project_number(integer)- REQUIRED. The project's number as shown in its URL.
status_field(string)- Default:
Status. Name of the single-select field whose options are the issue states.
- Default:
priority_field(string, OPTIONAL)- Name of a single-select field used to derive
issue.priority. Absent → priority is null.
- Name of a single-select field used to derive
repos(list ofowner/namestrings, OPTIONAL)- When present, only project items whose content is an Issue in one of these repositories are eligible. Default: no repository filter.
required_labels(list of strings)- Default
[]. An issue MUST contain every configured label to dispatch or continue. Matching ignores case and surrounding whitespace. A blank configured label matches no issue.[= Symphony]
- Default
active_states(list of strings)- Default:
Todo,In Progress.
- Default:
terminal_states(list of strings)- Default:
Done. - Additionally, an issue whose underlying GitHub Issue is
CLOSEDMUST be treated as terminal regardless of its Status field value.
- Default:
interval_ms(integer), default30000. Changes SHOULD re-apply at runtime.
root(path or$VAR), default<system-temp>/baton_workspaces.~expanded; relative paths resolve against theWORKFLOW.mddirectory; normalized to absolute.
after_create,before_run,after_run,before_remove(multiline shell scripts, OPTIONAL) with identical failure semantics (after_create/before_run failures are fatal to the creation/attempt; after_run/before_remove failures are logged and ignored).timeout_ms(integer), default60000.- Hooks run with the workspace directory as cwd and receive these environment variables:
BATON_ISSUE_ID,BATON_ISSUE_IDENTIFIER,BATON_ISSUE_NUMBER,BATON_ISSUE_REPO,BATON_ISSUE_URL,BATON_ISSUE_STATUS,BATON_WORKSPACE. When hooks run under Git Bash on Windows,BATON_WORKSPACEuses Git Bash path syntax (/c/...) andBATON_WORKSPACE_NATIVEexposes the native Windows path (C:\...).
Orchestration-level agent settings (agnostic of the selected runner):
kind(string)- REQUIRED for dispatch. Supported values:
claude_code,copilot.
- REQUIRED for dispatch. Supported values:
max_concurrent_agents(integer), default10.[= Symphony]max_turns(positive integer), default20. Limits coding-agent turns within one worker session.[= Symphony]max_retry_backoff_ms(integer), default300000.[= Symphony]max_concurrent_agents_by_state(mapstate_name -> positive integer), default{}.[= Symphony]
Runner-specific config when agent.kind == "claude_code". Values not listed here are
pass-through to the targeted Claude Code version; implementations SHOULD NOT hand-maintain enums
for pass-through values.
command(string shell command)- Default:
claude. Used when driving the CLI as a subprocess. Implementations using the Claude Agent SDK in-process MAY ignorecommandbut MUST still validate that the runtime can start a session.
- Default:
model(string, OPTIONAL) — passed through to the session.permission_mode(string)- Default:
acceptEdits. Pass-through Claude Code permission mode (for exampledefault,acceptEdits,bypassPermissions,plan).
- Default:
allowed_tools(list of strings, OPTIONAL)- Pass-through tool allowlist (for example
Bash(gh:*),Bash(git:*),Edit,Write).
- Pass-through tool allowlist (for example
disallowed_tools(list of strings, OPTIONAL)append_system_prompt(string, OPTIONAL) — pass-through.extra_args(list of strings, OPTIONAL) — appended verbatim to CLI invocations.turn_timeout_ms(integer), default3600000(1 hour).stall_timeout_ms(integer), default300000(5 minutes).<= 0disables stall detection.
Runner-specific config when agent.kind == "copilot".
command(string shell command), defaultcopilot.model(string, OPTIONAL).allow_all_tools(boolean), defaultfalse.allow_tools/deny_tools(lists of strings, OPTIONAL) — pass-through tool permissions.extra_args(list of strings, OPTIONAL).turn_timeout_ms(integer), default3600000.stall_timeout_ms(integer), default300000.
- Strict template engine (Liquid-compatible semantics sufficient). Unknown variables and unknown filters MUST fail rendering.
- Template input variables:
issue(all normalized fields from §4.1.1, includingnumber,repository,labels,blocked_by) andattempt(integer or null). - Empty prompt body → runtime MAY use a minimal default prompt
(
You are working on a GitHub issue.). Read/parse failures MUST NOT silently fall back.
Error classes: missing_workflow_file, workflow_parse_error,
workflow_front_matter_not_a_map, template_parse_error, template_render_error.
Workflow file errors block new dispatches until fixed; template errors fail only the affected run
attempt.
Path selection → YAML parse → defaults → $VAR resolution (only for values that explicitly
reference $VAR_NAME) → coercion and validation. ~/$VAR expansion applies only to local
filesystem path values, never to URIs or shell command strings.
Dynamic reload is REQUIRED: detect WORKFLOW.md changes, re-read and re-apply config and prompt
without restart. Invalid reloads MUST NOT crash the service; keep the last known good
configuration and emit an operator-visible error. In-flight agent sessions need not be restarted.
Startup validation failure → fail startup. Per-tick validation failure → skip dispatch that tick,
keep reconciliation active. [= Symphony §6.3]
Validation checks:
- Workflow file can be loaded and parsed.
tracker.kindis present and supported (github_projects).tracker.tokenis present after$resolution.tracker.ownerandtracker.project_numberare present.- The configured project resolves to a ProjectV2 node and the
status_fieldexists with at least one option (resolved lazily and cached; failures surface as validation errors). agent.kindis present and supported.- For
claude_code/copilotsubprocess mode: the runnercommandis present and non-empty.
tracker.kind: string, REQUIRED, currentlygithub_projectstracker.endpoint: string, defaulthttps://api.github.com/graphqltracker.token: string or$VAR, canonical envGITHUB_TOKENtracker.owner: string, REQUIREDtracker.owner_type:organization(default) |usertracker.project_number: integer, REQUIREDtracker.status_field: string, defaultStatustracker.priority_field: string, OPTIONALtracker.repos: list ofowner/name, OPTIONALtracker.required_labels: list of strings, default[]tracker.active_states: list, default["Todo", "In Progress"]tracker.terminal_states: list, default["Done"](+ closed issues are always terminal)polling.interval_ms: integer, default30000workspace.root: path, default<system-temp>/baton_workspaceshooks.*: shell scripts or null;hooks.timeout_msdefault60000agent.kind:claude_code|copilot, REQUIREDagent.max_concurrent_agents: integer, default10agent.max_turns: integer, default20agent.max_retry_backoff_ms: integer, default300000agent.max_concurrent_agents_by_state: map, default{}claude_code.command: string, defaultclaudeclaude_code.permission_mode: string, defaultacceptEditsclaude_code.allowed_tools/disallowed_tools: lists, OPTIONALclaude_code.turn_timeout_ms: default3600000;claude_code.stall_timeout_ms: default300000copilot.command: string, defaultcopilot;copilot.allow_all_tools: defaultfalse; timeouts as above
Baton inherits Symphony's orchestration state machine unchanged.
Unclaimed → Claimed (= Running or RetryQueued) → Released. Claimed issues are tracked in
claimed; running issues in running; queued retries in retry_attempts.
Worker turn-loop nuance (inherited, restated for the new runners):
- After each normal turn completion, the worker re-checks the tracker issue state.
- If the issue is still in an active state (and still carries
required_labels), the worker SHOULD start another turn on the same agent session (Claude Code: resume the sameagent_session_id; Copilot: resume the same session), up toagent.max_turns. - The first turn uses the full rendered task prompt. Continuation turns send only continuation guidance, not the original prompt.
- After a normal worker exit, the orchestrator schedules a short continuation retry (~1 second) to re-check whether the issue remains active.
PreparingWorkspace → BuildingPrompt → LaunchingAgentProcess → InitializingSession →
StreamingTurn → Finishing → terminal (Succeeded | Failed | TimedOut | Stalled |
CanceledByReconciliation).
Poll tick, normal/abnormal worker exit, agent update event, retry timer fired, reconciliation state refresh, stall timeout — identical semantics to Symphony §7.3.
Single-authority state mutation; claimed/running checks REQUIRED before launch;
reconciliation before dispatch every tick; restart recovery is tracker- and filesystem-driven;
startup terminal cleanup. [= Symphony §7.4]
Startup: validate config → startup cleanup → immediate tick → repeat every
polling.interval_ms. Tick sequence: reconcile running issues → preflight validation → fetch
candidates → sort → dispatch while slots remain → notify observers.
An issue is dispatch-eligible only if all are true:
- It has
id,identifier,title, andstate. - Its state is in
active_statesand not interminal_states, and the underlying issue is notCLOSED. - Its repository passes the
tracker.reposfilter (when configured). - It contains every label in
tracker.required_labels. - It is not already in
runningorclaimed. - Global and per-state concurrency slots are available.
- Blocker rule for the first active state (
Todoby default): do not dispatch when anyblocked_byentry is non-terminal. - Project items whose content is a DraftIssue or Pull Request are never eligible.
Sorting: priority ascending (null last) → created_at oldest first → identifier
lexicographic.
available_slots = max(max_concurrent_agents - running_count, 0); per-state override map with
normalized keys; fallback to global limit.
- Continuation retries after clean exit: fixed
1000ms. - Failure retries:
delay = min(10000 * 2^(attempt - 1), agent.max_retry_backoff_ms). - Retry handling fetches active candidates, releases claims for issues that disappeared or went
inactive, requeues on slot exhaustion with error
no available orchestrator slots.
Part A — stall detection against the runner's stall_timeout_ms (elapsed since last agent event
or started_at). Part B — tracker state refresh for all running issue IDs: terminal (or closed
issue, or required_labels no longer satisfied → treat as non-active) ⇒ stop; terminal ⇒ also
clean workspace; still active ⇒ update snapshot; refresh failure ⇒ keep workers, retry next tick.
Query terminal-state issues, remove corresponding workspaces, log-and-continue on fetch failure.
- Per-issue path:
<workspace.root>/<sanitized_issue_identifier>; workspaces are reused across runs and not auto-deleted on success. - Creation algorithm, hook execution contract (
sh -lc/bash -lc, cwd = workspace,hooks.timeout_ms), and failure semantics are inherited unchanged. - OPTIONAL workspace population is implementation-defined and typically done in hooks (for
example
gh repo cloneinafter_create,git fetch && git switchinbefore_run).
- The coding agent runs only in the per-issue workspace path (
cwd == workspace_path, validated before launch). - Workspace path MUST stay inside workspace root (absolute-path prefix check).
- Workspace directory names use only
[A-Za-z0-9._-].
This section defines Baton's language-neutral responsibilities when integrating Claude Code or GitHub Copilot CLI. The targeted agent CLI/SDK version is the source of truth for flags, message schemas, and transport; if this specification appears to conflict with the targeted agent's documentation, the agent's documentation controls protocol shape, while this section still controls orchestration behavior, workspace selection, prompt construction, continuation handling, and observability extraction.
An agent adapter MUST implement:
start_session(workspace, config) -> session— initialize an agent session whose working directory is the absolute per-issue workspace path.run_turn(session, prompt, on_event) -> turn_result— run one turn, streaming normalized events to the orchestrator until the turn terminates.stop_session(session)— terminate the session and the underlying process.
Completion conditions for a turn:
- agent-reported turn completion → success
- agent-reported error / nonzero exit → failure
- turn timeout (
turn_timeout_ms) → failure - subprocess exit before completion → failure
Normalized upstream events (each SHOULD include event, timestamp, agent_pid when available,
OPTIONAL usage map, payload fields as needed):
session_started, startup_failed, turn_completed, turn_failed, turn_cancelled,
turn_input_required, permission_denied, tool_use, notification, other_message,
malformed.
Two conforming transport modes:
- SDK mode (RECOMMENDED): drive sessions in-process with the Claude Agent SDK
(
@anthropic-ai/claude-agent-sdkor the Python equivalent), passing the workspace as the session working directory,permission_mode,allowed_tools/disallowed_tools,model, and a per-turnmax_turnsof the SDK's choosing while Baton enforcesagent.max_turnsat the worker-loop level. - CLI subprocess mode: launch
bash -lc "<claude_code.command> -p <prompt> --output-format stream-json --verbose ..."with cwd = workspace, parsing newline-delimited JSON from stdout and keeping stderr separate. RECOMMENDED max line buffer: 10 MB.
Session startup responsibilities:
- Validate
cwd == workspace_pathbefore launch (Invariant 1). - Start the first turn with the rendered issue prompt.
- Extract
agent_session_idfrom the targeted protocol's init/system message and emitsession_startedwithsession_id = "<agent_session_id>-1". - Start later in-worker continuation turns by resuming the same session
(
--resume <agent_session_id>/ SDK resume option) with continuation guidance only; emitsession_startedis not repeated — incrementturn_numberinstead. - Supply the documented permission posture (
permission_mode, tool allow/deny lists) on every turn.
Streaming and observability extraction:
- Forward assistant text and tool-use messages as
notification/tool_useevents with summarized payloads. - Extract token usage from the targeted protocol's result/usage payloads. Where the payload
reports per-turn usage, accumulate; where it reports session-cumulative totals, track deltas
against
last_reported_*_tokensto avoid double-counting (Symphony §13.5 rules apply). - Extract rate-limit information when the targeted protocol exposes it; otherwise
agent_rate_limitsstays null.
Approval / user-input policy (documented posture, Section 15):
- Headless runs MUST NOT stall on permission prompts. With the default
permission_mode: acceptEditsplusallowed_tools, unpermitted tool calls fail inside the session; the adapter emitspermission_deniedand the session continues or ends per the agent's own behavior. - If the targeted protocol signals that user input is required, the adapter MUST fail the turn
(
turn_input_required) rather than wait indefinitely.
- Launch:
bash -lc "<copilot.command> -p <prompt> ..."with cwd = workspace, plus--allow-all-toolsor--allow-tool/--deny-toolflags per config. - Continuation turns resume the prior session where the targeted CLI version supports it
(
--resume); where resume is unavailable, the adapter MUST treat each turn as a new session in the same workspace and document this limitation. - Output parsing is implementation-defined against the targeted CLI version; the adapter MUST
still emit the normalized event set from §10.0 (at minimum
session_started,turn_completed/turn_failed, andnotificationheartbeats sufficient for stall detection). - Token usage extraction is OPTIONAL when the targeted CLI does not expose usage; totals then remain zero and MUST be reported as such, not fabricated.
Timeouts: turn_timeout_ms (total turn stream), stall_timeout_ms (orchestrator-enforced event
inactivity).
RECOMMENDED normalized error categories: agent_not_found, invalid_workspace_cwd,
startup_failed, turn_timeout, process_exit, turn_failed, turn_cancelled,
turn_input_required, parse_error.
Baton does not implement a client-side tracker tool. Instead:
- The workflow prompt SHOULD instruct the agent to use the
ghCLI for tracker writes (progress comments viagh issue comment, Status transitions viagh project item-edit, PR creation viagh pr create). - The Claude Code adapter SHOULD include
Bash(gh:*)inallowed_toolsfor this purpose. - The orchestrator remains a tracker reader; it MUST NOT require write scopes for its own operation.
- Implementations MAY additionally expose a GitHub MCP server to the agent session; that is an agent-toolchain concern, not orchestrator business logic.
fetch_candidate_issues()— return normalized issues on the configured project whose Status is inactive_states.fetch_issues_by_states(state_names)— used for startup terminal cleanup. Empty input returns empty without an API call.fetch_issue_states_by_ids(issue_ids)— used for active-run reconciliation; returns minimal normalized issues (id, identifier, state, labels, closed flag).
- Endpoint:
tracker.endpoint; auth viaAuthorization: Bearer <token>header. - Project resolution:
organization(login:) { projectV2(number:) }oruser(login:) { projectV2(number:) }perowner_type. The ProjectV2 node ID, the Status field ID, and its option list SHOULD be resolved once and cached; the cache MUST be refreshed when validation fails or on workflow reload. - Candidate query:
projectV2.items(first: 50, after: $cursor)selectingfieldValueByName(name: $status_field) { ... on ProjectV2ItemFieldSingleSelectValue { name } }andcontent { ... on Issue { id number title body url state createdAt updatedAt labels(first: 20) { nodes { name } } repository { name nameWithOwner } } }.- Status filtering happens client-side after normalization (the items connection has no
server-side status filter);
required_labelsfiltering also happens after normalization so refresh can observe label removal.[= Symphony §11.2 label rule]
- Status filtering happens client-side after normalization (the items connection has no
server-side status filter);
- Blockers: select the issue-dependencies connection (blocked-by relationships) when the schema
exposes it to the token; degrade to
[]otherwise. - State refresh query:
nodes(ids: $issueIds) { ... on Issue { ... } }with variable type[ID!]!, reading each issue's project item for the configured project to obtain its Status (projectItems(first: 10)filtered client-side by project ID). - Pagination REQUIRED for candidate issues; page size default
50; a page reportinghasNextPagewithout anendCursoris a pagination integrity error. - Network timeout:
30000 ms. - Rate limits: GraphQL is point-based (5,000 points/hour/token). At the default 30 s interval the
candidate + refresh queries cost a few points per tick; implementations SHOULD surface the
rateLimit { remaining resetAt }field in debug logs and MUST treat secondary-rate-limit responses as transientgithub_api_statuserrors (skip the tick, retry next tick).
Per §4.1.1, plus:
- Status option names and labels are trimmed; labels lowercased; states compared lowercase.
priorityderives frompriority_fieldoption position; non-resolvable → null.created_at/updated_atparse ISO-8601.- Items with non-Issue content are dropped during normalization.
RECOMMENDED error categories: unsupported_tracker_kind, missing_tracker_token,
missing_tracker_project, github_api_request (transport), github_api_status (non-200 /
secondary rate limit), github_graphql_errors, github_unknown_payload,
github_missing_end_cursor.
Orchestrator behavior on tracker errors [= Symphony §11.4]: candidate fetch failure → skip
dispatch this tick; refresh failure → keep workers; startup cleanup failure → warn and continue.
The orchestrator is a reader. All mutations (comments, Status transitions, PR metadata) are
performed by the coding agent via its toolchain (Section 10.4). Workflow-defined success
typically means "reached the handoff state" (for example In Review), not Done.
Inputs: workflow.prompt_template, normalized issue, OPTIONAL attempt. Strict
variable/filter checking; nested arrays/maps preserved for iteration; attempt lets the template
give different instructions for first run, continuation, and retry. Rendering failure fails the
attempt immediately.
- REQUIRED context fields:
issue_id,issue_identifieron issue logs;session_idon session lifecycle logs. Stablekey=valuephrasing; no large raw payloads; no secrets. - Log sinks are implementation-defined; sink failure MUST NOT crash orchestration.
- Token accounting follows Symphony §13.5 with
agent_totalsin place ofcodex_totals: prefer absolute totals where the agent protocol defines them, track deltas to avoid double-counting, accumulate runtime seconds on session end plus live elapsed time at snapshot time. - OPTIONAL runtime snapshot SHOULD return
runningrows (withturn_countand issue URLs),retryingrows,agent_totals, andrate_limits.
Same contract: server.port front-matter key / CLI --port (CLI wins), loopback bind by
default, dashboard at /, JSON API at /api/v1/state, /api/v1/<issue_identifier>,
POST /api/v1/refresh (202, coalescable), 405 for unsupported methods, JSON error envelope.
The dashboard/API MUST remain observability/control-only.
OPTIONAL webhook trigger extension: implementations MAY accept GitHub projects_v2_item webhook
deliveries and treat them exactly as a POST /api/v1/refresh (queue an immediate poll +
reconcile). Polling remains the source of truth; webhook loss MUST NOT affect correctness.
Failure classes (workflow/config, workspace, agent session, tracker, observability) and recovery
behavior are inherited unchanged: validation failures skip dispatch but keep the service alive;
worker failures become backoff retries; tracker fetch failures skip the tick; restart recovery is
in-memory-free (startup cleanup → fresh poll → re-dispatch). Operator intervention points:
edit WORKFLOW.md (hot-reloaded), move issues on the board (terminal → stop + clean; non-active
→ stop), restart the process.
Baton's reference posture is high-trust: it is intended for boards and repositories fully controlled by the operating team. The documented default approval posture is:
- Claude Code:
permission_mode: acceptEditswith an explicitallowed_toolsallowlist (RECOMMENDED baseline:Bash(gh:*),Bash(git:*), plus the project's build/test commands,Edit,Write).bypassPermissionsis supported but MUST be an explicit operator choice. - Copilot: explicit
allow_toolslist;allow_all_tools: trueMUST be an explicit operator choice. - User-input-required turns fail the run (Section 10.1).
Workspace root containment, agent cwd = workspace, sanitized directory names. RECOMMENDED hardening: dedicated OS user, restricted workspace root permissions, dedicated volume.
$VAR indirection; never log tokens; validate presence without printing values.
Hooks are fully trusted configuration; run inside the workspace; truncate output in logs; timeouts REQUIRED.
Issue titles, bodies, and comments are externally controlled content on public repositories — treat them as untrusted prompt input. Hardening measures Baton deployments SHOULD evaluate:
- Restrict dispatch eligibility with
required_labelsso only triaged, team-applied issues reach the agent (a label only maintainers can apply is the primary injection gate). - Restrict
tracker.reposto known repositories; prefer private repos/boards. - Keep the tool allowlist minimal; do not grant network-capable tools beyond
gh/gitunless the workflow needs them. - Scope
GITHUB_TOKENnarrowly (fine-grained PAT or GitHub App limited to the target repos); use a separate token for the agent if its write scope must differ from the orchestrator's read scope. - Consider OS/container sandboxing for the workspace root and branch protection + required review on target repositories so agent PRs cannot merge unreviewed.
Service startup, poll-and-dispatch tick, reconciliation, dispatch bookkeeping, worker exit, and
retry-timer handling are identical to Symphony SPEC §16.1–§16.4 and §16.6 (with codex_totals
read as agent_totals). The worker attempt algorithm, restated for the new agent runners:
function run_agent_attempt(issue, attempt, orchestrator_channel):
workspace = workspace_manager.create_for_issue(issue.identifier) # §9
if workspace failed: fail_worker("workspace error")
if run_hook("before_run", workspace.path) failed:
fail_worker("before_run hook error")
adapter = agent_adapter_for(config.agent.kind) # §10
session = adapter.start_session(workspace.path, runner_config)
if session failed:
run_hook_best_effort("after_run", workspace.path)
fail_worker("agent session startup error")
turn_number = 1
while true:
prompt = if turn_number == 1
then render(workflow_template, issue, attempt) # §12
else continuation_guidance(issue, turn_number)
if prompt failed:
adapter.stop_session(session); run_hook_best_effort("after_run", workspace.path)
fail_worker("prompt error")
turn_result = adapter.run_turn(session, prompt,
on_event = (msg) -> send(orchestrator_channel, {agent_update, issue.id, msg}))
if turn_result failed:
adapter.stop_session(session); run_hook_best_effort("after_run", workspace.path)
fail_worker("agent turn error")
refreshed = tracker.fetch_issue_states_by_ids([issue.id]) # §11.1
if refreshed failed:
adapter.stop_session(session); run_hook_best_effort("after_run", workspace.path)
fail_worker("issue state refresh error")
issue = refreshed[0] or issue
if issue.state is not active or issue is closed
or required_labels not satisfied: break
if turn_number >= config.agent.max_turns: break
turn_number += 1
adapter.stop_session(session)
run_hook_best_effort("after_run", workspace.path)
exit_normal() # → 1s continuation retry
Validation profiles [= Symphony §17]: Core Conformance (REQUIRED, deterministic),
Extension Conformance (only for shipped OPTIONAL features), Real Integration Profile
(RECOMMENDED, environment-dependent).
Path precedence, hot reload with last-known-good fallback, typed errors for missing/invalid
front matter, defaults, $VAR and ~ resolution, strict prompt rendering — plus:
tracker.kind enforces github_projects; agent.kind enforces claude_code/copilot;
runner config blocks parse and validate per §5.3.6–§5.3.7.
Deterministic paths, create/reuse, hook semantics and timeouts, sanitization and root containment enforced before agent launch, hook env vars (§5.3.4) present.
- Project resolution honors
owner_typeand caches project/field IDs. - Candidate fetch filters by Status option,
repos, andrequired_labelsafter normalization. - Non-Issue items (drafts, PRs) are dropped.
- Pagination preserves order; missing
endCursorwithhasNextPageis a typed error. - Closed issues are treated as terminal regardless of Status.
- Blockers normalize from issue dependencies; missing API access degrades to
[]. - State refresh uses
nodes(ids: [ID!]!)and resolves the Status via the configured project's item. - Error mapping for transport, non-200, GraphQL errors, malformed payloads, secondary rate limits.
fetch_issues_by_states([])returns empty without an API call.
All Symphony items apply verbatim (sort order, blocker gating, reconciliation transitions, continuation retry attempt 1, exponential backoff with cap, slot-exhaustion requeue, stall kill, snapshot rows when implemented).
- Launch uses workspace cwd; out-of-root paths rejected.
- Claude Code: session id extracted from init;
session_startedemitted with<agent_session_id>-1; continuation turns resume the same session and incrementturn_number; permission flags from config are passed on every turn; stream-json parse errors emitmalformedwithout killing the orchestrator; turn timeout enforced; usage extracted per §13 delta rules;turn_input_requiredfails the turn. - Copilot: programmatic mode launch flags honor
allow_all_tools/allow/deny lists; resume used when supported, new-session fallback documented; normalized events sufficient for stall detection; absent usage reported as zero. - stderr kept separate from protocol stream in subprocess mode.
Operator-visible validation failures, structured log context fields, sink failure isolation, token/rate-limit aggregation correctness, status surface driven only from orchestrator state.
Positional workflow path, ./WORKFLOW.md default, clean startup failure surface, exit codes.
- A real GitHub smoke test runs with
GITHUB_TOKENagainst a dedicated test project/repo, uses isolated identifiers/workspaces, and cleans up artifacts when practical. - A real agent smoke test runs one trivial issue end-to-end with the configured agent CLI authenticated on the host.
- Skipped tests report as skipped; explicitly enabled profiles fail their job on failure.
- Workflow path selection (explicit + cwd default); loader with front matter/body split
- Typed config layer with defaults and
$resolution; dynamic watch/reload/re-apply - Polling orchestrator with single-authority mutable state
- GitHub Projects v2 client with candidate fetch + state refresh + terminal fetch (+ project/ field resolution and pagination)
- Workspace manager with sanitized per-issue workspaces and root containment
- Workspace lifecycle hooks with
hooks.timeout_ms(default 60000) and hook env vars - At least one agent adapter (
claude_codeRECOMMENDED first) implementing the §10.0 contract with session resume for continuation turns - Strict prompt rendering with
issueandattempt - Exponential retry queue with continuation retries after normal exit; configurable backoff cap
- Reconciliation stopping runs on terminal/non-active/closed/label-removed issues
- Workspace cleanup for terminal issues (startup sweep + active transition)
- Structured logs with
issue_id,issue_identifier,session_id - Documented approval/sandbox posture (§15.1)
- Second agent adapter (
copilot) - HTTP server extension (§13.7) with
--portprecedence and baseline endpoints - Webhook-triggered refresh (
projects_v2_item→ internal refresh) - TODO: persist retry queue and session metadata across restarts
- TODO: first-class tracker write APIs in the orchestrator (currently agent-only by design)
- TODO: pluggable tracker adapters beyond GitHub Projects (e.g. restore a Linear adapter for drop-in Symphony parity)
- Run the Real Integration Profile with valid credentials and network access.
- Verify hook execution and workflow path resolution on the target host OS/shell.
- Verify the agent CLI authenticates and runs headless on the target host (no interactive login prompts under the service user).
- If the HTTP server ships, verify port behavior and loopback bind.
The SSH worker extension profile (central orchestrator, remote worker hosts over SSH stdio,
worker.ssh_hosts, per-host caps, host-local workspaces, failover semantics) is inherited from
the Symphony SPEC unchanged, with "coding-agent app-server" read as "agent CLI session". Remote
hosts MUST additionally satisfy the agent-authentication prerequisite (Claude Code / Copilot
logged in for the SSH user).
| Area | Symphony | Baton |
|---|---|---|
| Tracker | Linear GraphQL (tracker.kind: linear, project_slug, LINEAR_API_KEY) |
GitHub Projects v2 GraphQL (tracker.kind: github_projects, owner + project_number, GITHUB_TOKEN) |
| Issue state | Linear workflow state | Project Status field option; closed issues always terminal |
identifier |
Linear ticket key (ABC-123) |
<repo>-<number> (myrepo-123) |
blocked_by |
Inverse relations of type blocks |
GitHub issue dependencies; degrades to [] |
priority |
Linear priority integer | OPTIONAL priority_field single-select position |
| Terminal-state defaults | Closed, Cancelled, Canceled, Duplicate, Done |
Done (+ closed issues) |
| Agent | Codex app-server (JSON-RPC over stdio), codex.* config |
Claude Code (Agent SDK or -p stream-json) / Copilot CLI (-p), claude_code.* / copilot.* config, agent.kind selector |
| Session identity | thread_id-turn_id |
agent_session_id-turn_number; continuation via session resume |
| Approval posture | Codex approval/sandbox policies | permission_mode + tool allow/deny lists; user-input-required fails the turn |
| Client-side tracker tool | OPTIONAL linear_graphql tool |
None; agent uses gh CLI (or GitHub MCP) via tool allowlist |
| Token accounting | Codex tokenUsage payloads |
Agent result/usage payloads; same absolute-vs-delta rules |
| Workspace default root | <tmp>/symphony_workspaces |
<tmp>/baton_workspaces |
| Hook env vars | (unspecified) | BATON_ISSUE_*, BATON_WORKSPACE specified; Windows also gets BATON_WORKSPACE_NATIVE |
| Webhooks | — | OPTIONAL projects_v2_item → refresh trigger |
Everything not listed above — orchestration state machine, polling/retry/reconciliation math, workspace safety invariants, observability requirements, failure model, HTTP extension, and the reference algorithms — is intentionally identical so that experience operating Symphony transfers directly.