Skip to main content
Symphony is designed to be extended with custom integrations, tools, and orchestrator capabilities. This guide covers the main extension points.

Adding Custom Tracker Types

Symphony’s tracker system is abstracted to support multiple issue tracking systems. While Linear is the reference implementation, you can add support for GitHub Issues, Jira, or custom trackers.

Tracker Interface

Implement these operations for a new tracker:
1

fetch_candidate_issues()

Return issues in configured active states for a project.
@spec fetch_candidate_issues() :: {:ok, [Issue.t()]} | {:error, term()}
2

fetch_issues_by_states(state_names)

Return issues in specified states (used for startup terminal cleanup).
@spec fetch_issues_by_states([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()}
3

fetch_issue_states_by_ids(issue_ids)

Return current states for specific issue IDs (used for reconciliation).
@spec fetch_issue_states_by_ids([String.t()]) :: {:ok, map()} | {:error, term()}

Issue Normalization

Normalize tracker payloads to the Symphony issue model:
defmodule SymphonyElixir.Linear.Issue do
  @type t :: %__MODULE__{
    id: String.t(),
    identifier: String.t(),
    title: String.t(),
    description: String.t() | nil,
    priority: integer() | nil,
    state: String.t(),
    branch_name: String.t() | nil,
    url: String.t() | nil,
    labels: [String.t()],
    blocked_by: [blocker()],
    created_at: String.t() | nil,
    updated_at: String.t() | nil
  }

  @type blocker :: %{
    id: String.t() | nil,
    identifier: String.t() | nil,
    state: String.t() | nil
  }
end
Key normalization rules:
  • labels → lowercase strings
  • priority → integer only (lower numbers = higher priority)
  • state → trimmed and lowercased for comparison
  • blocked_by → derived from tracker-specific relations

Configuration Schema

Extend the tracker configuration section:
WORKFLOW.md
---
tracker:
  kind: github  # Your custom tracker type
  endpoint: https://api.github.com/graphql
  api_key: $GITHUB_TOKEN
  repository: org/repo
  active_states:
    - open
  terminal_states:
    - closed
---

Implementation Example

defmodule SymphonyElixir.GitHub.Adapter do
  @behaviour SymphonyElixir.Tracker

  alias SymphonyElixir.Config
  alias SymphonyElixir.GitHub.{Client, Issue}

  @impl true
  def fetch_candidate_issues do
    with {:ok, api_key} <- Config.tracker_api_key(),
         {:ok, repo} <- Config.tracker_repository(),
         active_states <- Config.tracker_active_states() do
      Client.fetch_issues(api_key, repo, active_states)
      |> normalize_issues()
    end
  end

  @impl true
  def fetch_issues_by_states(state_names) do
    # Implementation
  end

  @impl true
  def fetch_issue_states_by_ids(issue_ids) do
    # Implementation
  end

  defp normalize_issues({:ok, raw_issues}) do
    issues = Enum.map(raw_issues, &Issue.from_github_payload/1)
    {:ok, issues}
  end

  defp normalize_issues(error), do: error
end

Error Handling

Handle tracker-specific errors consistently:
defp handle_api_error({:error, %HTTPoison.Error{reason: reason}}) do
  {:error, {:github_api_request, reason}}
end

defp handle_api_error({:error, status}) when is_integer(status) do
  {:error, {:github_api_status, status}}
end

Custom Dynamic Tools

Dynamic tools are client-side tools that Symphony provides to Codex during app-server sessions. The linear_graphql tool is the reference implementation.

Tool Definition

From lib/symphony_elixir/codex/dynamic_tool.ex:
@linear_graphql_tool "linear_graphql"
@linear_graphql_description """
Execute a raw GraphQL query or mutation against Linear using Symphony's configured auth.
"""
@linear_graphql_input_schema %{
  "type" => "object",
  "additionalProperties" => false,
  "required" => ["query"],
  "properties" => %{
    "query" => %{
      "type" => "string",
      "description" => "GraphQL query or mutation document to execute against Linear."
    },
    "variables" => %{
      "type" => ["object", "null"],
      "description" => "Optional GraphQL variables object.",
      "additionalProperties" => true
    }
  }
}

Adding a New Tool

1

Define the tool schema

@slack_notify_tool "slack_notify"
@slack_notify_description "Send a notification to a Slack channel"
@slack_notify_input_schema %{
  "type" => "object",
  "required" => ["channel", "message"],
  "properties" => %{
    "channel" => %{"type" => "string"},
    "message" => %{"type" => "string"}
  }
}
2

Add to tool_specs()

def tool_specs do
  [
    # ... existing tools
    %{
      "name" => @slack_notify_tool,
      "description" => @slack_notify_description,
      "inputSchema" => @slack_notify_input_schema
    }
  ]
end
3

Implement execute() handler

def execute(tool, arguments, opts) do
  case tool do
    @slack_notify_tool ->
      execute_slack_notify(arguments, opts)
    # ... other cases
  end
end

defp execute_slack_notify(arguments, _opts) do
  with {:ok, channel, message} <- normalize_slack_args(arguments),
       {:ok, response} <- SlackClient.send_message(channel, message) do
    success_response(response)
  else
    {:error, reason} ->
      failure_response(tool_error_payload(reason))
  end
end
4

Register during app-server startup

Tools are advertised during the thread/start handshake in the Codex app-server protocol.

Tool Response Format

Return structured responses that Codex can interpret:
# Success response
%{
  "success" => true,
  "contentItems" => [
    %{
      "type" => "inputText",
      "text" => Jason.encode!(result, pretty: true)
    }
  ]
}

# Failure response
%{
  "success" => false,
  "contentItems" => [
    %{
      "type" => "inputText",
      "text" => Jason.encode!(%{"error" => "Tool execution failed"}, pretty: true)
    }
  ]
}

Tool Testing

Test tools independently of the app-server:
test "linear_graphql executes valid query" do
  arguments = %{
    "query" => "query { viewer { id name } }",
    "variables" => %{}
  }

  result = DynamicTool.execute("linear_graphql", arguments)

  assert result["success"] == true
end

test "linear_graphql handles missing query" do
  arguments = %{}

  result = DynamicTool.execute("linear_graphql", arguments)

  assert result["success"] == false
  assert result["contentItems"] |> hd() |> get_in(["text"]) =~ "requires a non-empty"
end

Extending the Orchestrator

The orchestrator is the core coordination layer. Common extension points:

Custom Concurrency Policies

Implement per-state concurrency limits:
WORKFLOW.md
---
agent:
  max_concurrent_agents: 10
  max_concurrent_agents_by_state:
    "In Progress": 5
    "Merging": 2
---
From SPEC.md section 8.3:
Per-state limit:
  • max_concurrent_agents_by_state[state] if present (state key normalized)
  • otherwise fallback to global limit

Custom Retry Policies

Extend retry logic with custom backoff strategies:
defp calculate_retry_delay(attempt, reason) do
  base_delay = min(10_000 * :math.pow(2, attempt - 1), max_retry_backoff())
  
  case reason do
    {:turn_timeout, _} -> base_delay * 2  # Longer backoff for timeouts
    {:rate_limit, _} -> 60_000           # Fixed 1min for rate limits
    _ -> base_delay
  end
end

Observability Extensions

Add custom metrics and telemetry:
defmodule SymphonyElixir.Telemetry do
  def handle_event([:symphony, :agent, :start], measurements, metadata, _config) do
    # Send to monitoring system
    Metrics.increment("symphony.agent.started", 
      tags: [issue_state: metadata.issue.state])
  end
  
  def handle_event([:symphony, :agent, :complete], measurements, metadata, _config) do
    Metrics.timing("symphony.agent.duration", 
      measurements.duration,
      tags: [outcome: metadata.outcome])
  end
end

State Machine Extensions

Add custom orchestrator states or transitions:
defmodule SymphonyElixir.Orchestrator.States do
  # Standard states
  @unclaimed :unclaimed
  @running :running
  @retry_queued :retry_queued
  
  # Custom states
  @manual_approval :manual_approval
  @quarantined :quarantined
  
  def transition(issue, event) do
    case {current_state(issue), event} do
      {:running, :approval_required} -> @manual_approval
      {:running, :suspicious_activity} -> @quarantined
      # ... standard transitions
    end
  end
end

HTTP Server Extensions

The optional HTTP server can be extended with custom endpoints:
defmodule SymphonyElixir.HttpServer.CustomRoutes do
  use Plug.Router

  plug :match
  plug :dispatch

  get "/api/v1/custom/metrics" do
    metrics = collect_custom_metrics()
    send_json(conn, 200, metrics)
  end

  post "/api/v1/custom/trigger/:issue_id" do
    issue_id = conn.params["issue_id"]
    # Trigger custom action
    send_json(conn, 200, %{triggered: issue_id})
  end
end
Mount in the main router:
plug SymphonyElixir.HttpServer.CustomRoutes

Configuration Extensions

Add custom configuration sections:
WORKFLOW.md
---
# Standard config
tracker:
  kind: linear
  
# Custom extension
monitoring:
  datadog_api_key: $DATADOG_API_KEY
  trace_sampling_rate: 0.1
  
notifications:
  slack_webhook: $SLACK_WEBHOOK
  alert_on_failure: true
---
Parse in config module:
defmodule SymphonyElixir.Config do
  def monitoring_config do
    workflow_config()
    |> Map.get("monitoring", %{})
    |> parse_monitoring_config()
  end
  
  defp parse_monitoring_config(section) do
    %{
      datadog_api_key: resolve_env_var(section["datadog_api_key"]),
      trace_sampling_rate: section["trace_sampling_rate"] || 0.1
    }
  end
end

Best Practices

Maintain SPEC.md Alignment

Keep extensions compatible with the language-agnostic specification. Document deviations clearly.

Test in Isolation

Unit test extensions independently before integrating with the orchestrator.

Handle Errors Gracefully

Return structured errors that the orchestrator can retry or surface appropriately.

Document Configuration

Add configuration schema, defaults, and examples for any new settings.

Examples from Production

Linear GraphQL Tool

Complete implementation in lib/symphony_elixir/codex/dynamic_tool.ex:
  • Handles both string and object input formats
  • Validates required query field
  • Normalizes GraphQL errors array to success: false
  • Returns structured JSON response

Memory Tracker (Testing)

In-memory tracker for testing in lib/symphony_elixir/tracker/memory.ex:
defmodule SymphonyElixir.Tracker.Memory do
  @behaviour SymphonyElixir.Tracker

  def start_link(initial_issues \\ []) do
    Agent.start_link(fn -> initial_issues end, name: __MODULE__)
  end

  @impl true
  def fetch_candidate_issues do
    issues = Agent.get(__MODULE__, & &1)
    {:ok, issues}
  end
end

Additional Resources

Build docs developers (and LLMs) love