Skip to content

Architecture

The core split

Developer is built around one idea: the agent reads and reasons, Python acts. The agent can explore code, understand context, write files, and produce structured output. But it can never push code, create PRs, post comments, or file issues. That's Python's job.

This isn't just a preference. build_options() in core/providers/claude.py gives every agent run limits that Claude Code enforces:

  • the stage's tool profile (core/tool_profiles.py) decides which built-in tools exist;
  • permission_mode="dontAsk" refuses anything the profile didn't pre-approve, and restricted mode keeps file tools inside the worktree and ignores the target repo's own Claude settings;
  • a PreToolUse hook (core/agent_guards.py) allows only read-only git subcommands in Bash and refuses file access outside the worktree;
  • Bash runs in Claude Code's sandbox (core/sandbox.py), limited to the worktree, the repo's allowed domains and extra writable paths, with credential files and variables denied.

See the safety boundary for what each layer stops.

The agent works in a git worktree. It can read code, edit files and run commands as its stage's profile allows. When it's done, Python inspects what changed, runs git diff, and the pipeline function decides whether to commit and push.

Agent interface

The pipeline code doesn't talk to Claude directly. It talks to an AgentProvider interface defined in core/agent.py:

class AgentProvider(ABC):
    async def run(
        self,
        prompt: str,
        *,
        cwd: str | Path,
        profile: ToolProfile,
        output_model: type[BaseModel] | None = None,
        resume: str | None = None,
        fork_session: bool = False,
        stage: str = "",
    ) -> RunResult: ...

RunResult carries the structured output, a session ID (for resuming later), and metrics. ClaudeProvider (core/providers/claude.py) is the only implementation. It runs Claude Code through the Claude Agent SDK, which is a core dependency, and applies the claude: settings for the named stage. Services take any AgentProvider, which is how tests substitute a scripted agent.

Pipelines don't call the provider directly either. Each pipeline function runs its stages through a StageRunner (pipelines/runner.py): ask() for a stage that returns structured output, work() for one that changes files. The runner renders the stage's template, runs the agent, validates the output and records the stage's metrics.

Worktrees

Developer clones target repos into a repos/ directory in its home, beside developer.yaml (gitignored in a checkout). It never works in a clone directly. Each run fetches the branch it starts from and creates a fresh git worktree from the clone, then runs the repo's setup commands inside that worktree. That way dependencies such as node_modules or a .venv exist where the agent and the quality tools run. If a setup command fails, the run stops before the agent starts. When the run ends, however it ends, the repo's teardown commands stop anything setup started outside the worktree, such as containers.

Worktrees give you isolation. Multiple pipelines can run against the same repo concurrently without stepping on each other. Each worktree is a separate working directory with its own checked-out branch, but they share the same git objects. Cheap to create, cheap to clean up.

A run that will commit gets a branch of its own: developer/issue-<N>-<run id> for resolve, developer/pr-<N>-<run id> for address. Two runs never share a branch, even for the same issue. When a run ends, its worktree is removed. Its branch is removed too, unless the run failed after committing: then the branch stays in the clone so its commits can be recovered.

The resolve pipeline creates a worktree, does its work, and when the pipeline is done, Python commits as the App's bot and pushes from that worktree. Pushes never force: resolve's new branch must not exist yet, and address only pushes if the PR branch is still at the commit the run fetched. A push someone makes during the run is never overwritten. The review pipeline checks out the PR's head in a detached worktree, so the agent explores the code under review.

Structured output

Most stages use Pydantic models for structured output. The agent returns JSON that gets validated against the model. If validation fails, the agent gets another turn to fix the output.

The models live in src/developer/models/. Some examples:

  • TriageResult -- the triage decision and reasoning
  • PRResult -- branch name, commit message, PR body
  • AuditReport -- list of findings with titles, descriptions, and labels
  • FeedbackAnalysis -- actionable items and out-of-scope items
  • RepoInvestigation -- setup and teardown commands, testing philosophy, and quality tools discovered by the add-repo agent

Each pipeline function inspects these structured outputs to decide what happens next, with ordinary match and if on fields such as the triage decision.

Templates

Prompts are Jinja2 templates in src/developer/templates/. Each pipeline has its own subdirectory. Two shared includes show up in most templates:

  • _personality.md.j2 -- writing style rules (no AI tells, conversational tone)
  • _labels.md.j2 -- canonical GitHub label set

Each stage's template receives the variables its pipeline passes to that stage: the issue or PR, plus what earlier stages produced that it needs. Templates fail on any variable they don't get, and a contract test renders every template from the context its command really builds.

Metrics

Every stage records:

  • Duration (wall clock)
  • Turn count (how many agent turns)
  • Tool call count
  • Input and output tokens
  • Cache hit tokens
  • Cost, and tokens and cost per model
  • Any rate-limit warnings or rejections

Claude Code reports a session's cost cumulatively across resumes, so the provider subtracts what a resumed session had already spent: each stage's cost is only its own. The stages' metrics are formatted as a collapsible <details> block that Python appends to PR bodies and comments, and saved in the run's metrics.json.

API server

Developer has a FastAPI server (src/developer/api/) that exposes the same pipelines as the CLI over HTTP. Start it with developer serve (default port 6768).

The API follows a task-based pattern. Pipeline endpoints (POST /resolve, POST /review, etc.) return a task_id immediately. The pipeline runs in a background daemon thread, because each stage calls asyncio.run() internally, which can't nest inside FastAPI's event loop. Clients poll GET /tasks/{task_id} for status, progress logs, and results.

The CLI and the API run each pipeline through the same service in src/developer/services/, such as resolve_issue(). A service takes the repo's config, the agent and the GitHub client. It owns the run from fetching the issue or PR to cleaning up the worktree, and returns a typed result (for example ResolveResult, with the decision and the URLs it published). The Click commands and the API's _run_* task functions are thin adapters. They build the agent and GitHub client, call the service, and print the result or return it as JSON. Everything a task logs goes into its task log.

Each app keeps its tasks in memory in a TaskStore, which allows one active task per repo and target (an issue, a PR, or a kind of audit) and answers a second request with 409. There's no persistence and no UI yet. DEVELOPER_API_TOKEN turns on bearer-token auth. The API is an optional extra, so the CLI doesn't need FastAPI.

Run records

Every run leaves a directory in runs/ (in Developer's home), named <time>-<pipeline>-<repo>-<target>:

  • transcript.jsonl -- everything Developer and the agent logged during the run, one JSON object per line. Agent lines carry their stage, and tool calls carry the tool and its input.
  • metrics.json -- each stage's model, duration, turns, tokens and cost.
  • result.json -- what the run published, or the error it stopped on.

Developer logs through Python's logging (the developer logger). The CLI shows INFO and above as plain lines; the API copies each task's log into the task.

GitHub integration

Developer authenticates as a GitHub App using JWT-based auth. The App ID and private key come from the environment or .env. Developer reads .env without exporting it, and blanks these variables in the agent's environment. Before a run starts, Developer asks GitHub which of the App's installations can reach the run's repo, so repos can belong to different accounts. A repo no installation can reach stops the run there, before the agent starts. githubkit exchanges the App's JWT for that installation's tokens for API calls, and every push mints a fresh installation token, because they expire after an hour. Every list call reads every page.

The bot's commit email is constructed from the app's slug and user ID, so commits show up as authored by the bot account. PRs, comments, and issues are all created through the GitHub API, never through git.