Skip to content

Architecture

Request Flow

Webhook Mode

PR comment "@bot do something"  ──or──  PR opened/pushed/reopened
        │                                        │
        ▼                                        ▼
GitHub/GitLab sends webhook             GitHub/GitLab sends webhook
        │                                        │
        └──────────────┬─────────────────────────┘
            POST /webhooks/{platform}
                       ├─ verify_webhook()         ← signature/token check
                       ├─ parse_event()            ← normalize into PullRequestEvent
                       ├─ [repo in ALLOWED_REPOS?] ← skip if not listed
                       ├─ [lifecycle event in REVIEWER_TRIGGERS?]
                       │       ▼
                       │   enqueue_job()            ← no auth check, no reaction
                       │       │
                       │       ▼
                       │   job_queue.enqueue()  ← reviewer with empty prompt
                       ├─ [comment event?]
                       │       ▼
                       │   extract_mention()        ← identify bot type + prompt
                       │       │
                       │       ▼
                       │   enqueue_job()
                       │       │
                       │       ├─ allowed_users check
                       │       ├─ post_reaction("eyes")
                       │       │
                       │       ▼
                       │   job_queue.enqueue()
                       └─ [otherwise] → ignored
                    job runs serially per PR
                    └─ clone/update → fetch diff + comments → run agent (read-only) → submit review

CLI Flow

nominal-code review owner/repo#42 [--dry-run] [--prompt "..."]
_parse_pr_ref()                     ← validate owner/repo#N format
build_platform()                    ← construct platform from env token
        ├─ fetch_pr_branch()       ← resolve HEAD branch via API
review()
        ├─ clone/update workspace
        ├─ fetch diff + comments (parallel)
        ├─ build prompt + run agent (Claude Code CLI)
        ├─ parse JSON + filter findings
_print_review()                     ← format results for terminal
        ├─ [unless --dry-run] submit_review() or post_reply()
exit 0

CI Flow

nominal-code ci {platform}
build_platform(name, config)        ← construct platform from CI config
        │                              GitHub: commands/ci/github.py
        │                              GitLab: commands/ci/gitlab.py
        ├─ build_event()            ← read event from CI env vars
        ├─ resolve_workspace()      ← use CI runner checkout
Config.for_ci()                     ← build config with ApiAgentConfig
review()
        ├─ diff + comments (parallel fetch)
        ├─ build prompt + run agent (LLM provider API)
        ├─ parse JSON + filter findings
submit_review() or post_reply()
exit 0

Agent Runners

Nominal Code supports two agent execution backends. The mode is selected automatically based on the execution context.

Claude Code CLI Runner

Used by CLI mode and webhook server mode. Wraps the Claude Code SDK to spawn the Claude Code CLI as a subprocess.

  • Streams messages from the CLI process and captures the conversation ID for multi-turn continuity.
  • Monkey-patches the SDK message parser to gracefully handle unknown message types (e.g. rate_limit_event), preventing the stream from crashing.
  • Supports conversation resumption via stored conversation IDs.
  • Requires the Claude Code CLI to be installed and on PATH (or set via AGENT_CLI_PATH).
  • Uses the CLI's configured login method — supports Claude Pro and Claude Max subscriptions as an alternative to per-token API billing.
  • Captures token usage and cost from the SDK's ResultMessage when available.

LLM Provider API Runner

Used by CI mode. Calls the LLM provider API directly with tool use. Supports multiple providers (Anthropic, OpenAI, Google Gemini, DeepSeek, Groq, Together, Fireworks).

  • Implements an agentic loop: sends a prompt, processes tool_use blocks by executing tools locally, sends results back, and repeats until the model produces a final text answer or the turn limit is reached.
  • Provides tools: Read (file contents), Glob (file search), Grep (content search), Bash (shell commands with allowlist validation), WriteNotes (structured findings for explore agents), and submit_review (structured output for the review agent).
  • Does not require the Claude Code CLI — only a provider API key (per-token billing).
  • Accumulates token usage across all turns and computes dollar cost using a bundled pricing table.

Runner Selection

Execution Mode Agent Runner Selected By
CI (nominal-code ci) LLM provider API ApiAgentConfig
CLI (nominal-code review) Claude Code CLI CliAgentConfig
Webhook server Claude Code CLI CliAgentConfig

The dispatcher in agent/invoke.py routes to the appropriate backend based on whether the config is a CliAgentConfig or ApiAgentConfig.

Sub-Agents

The API runner (agent/api/runner.py) supports spawning sub-agents via the Agent tool. When sub_agent_configs is passed to run_api_agent(), the tool is dynamically added with the available sub-agent types. The reviewer in review/reviewer.py configures an "explore" sub-agent type for codebase investigation.

Agent Tool Dispatch

When the model calls the Agent tool, _dispatch_tools() creates an asyncio.Task for each Agent call. Multiple Agent calls in the same turn run concurrently. Each task runs _handle_agent_tool(), which:

  1. Validates the subagent_type against the provided configs.
  2. Creates a temporary notes directory with a header file.
  3. Recursively calls run_api_agent() with the sub-agent's provider, model, tools, and turn budget.
  4. Reads the notes file content after completion.
  5. Returns the notes (or the agent's text output if no notes were written) to the parent agent.

Sub-agents cannot spawn other sub-agents (no recursive Agent tool) or produce reviews (no submit_review tool).

SubAgentConfig

Defined in agent/sub_agent.py, this frozen dataclass configures a sub-agent type:

Field Type Description
provider LLMProvider LLM provider instance
model str Model identifier
provider_name ProviderName Provider name for cost tracking
system_prompt str Full system prompt
max_turns int Turn budget (default 32)
allowed_tools list[str] Tool names the sub-agent may use
description str Description shown in Agent tool schema

WriteNotes Tool

Agents with a notes file (both the reviewer and explore sub-agents) have access to a WriteNotes tool that appends structured findings to a pre-assigned markdown file. The agent writes findings incrementally — callers, tests, type definitions, knock-on effects — organized under markdown headings.

Each sub-agent gets its own notes file (no write conflicts during concurrent execution). Files are created in a temporary directory that is cleaned up after notes are read back. The notes content is the sub-agent's primary deliverable: the reviewer receives the notes, not the raw exploration conversation.

See Compaction for how notes files also serve as the compaction summary.

Explore Sub-Agent

The reviewer configures a "explore" sub-agent with these tools:

Tool Purpose
Read Read file contents
Glob Find files by pattern
Grep Search file contents
Bash Shell commands
WriteNotes Record structured findings

The reviewer decides when to spawn explorers based on the diffs it sees. It provides a task prompt describing what to investigate (e.g. "find all callers of process_event and check if they handle the new return type"). The explore sub-agent discovers everything through its tools.

Cost Tracking

Sub-agent costs are collected as tuple[CostSummary, ...] on AgentResult.sub_agent_costs and propagated to ReviewResult.sub_agent_costs. Each CostSummary carries token counts, API call count, and dollar cost for one sub-agent invocation.

Prompts

Bundled prompt files in prompts/explore/: - explorer.md — system prompt for explore sub-agents (read-only context gathering via tools). - suffix.md — sub-agent suffix template appended to system prompts ("You are a background sub-agent...").

Policies

Event handling is governed by two frozen Pydantic models that separate what gets processed from how it gets dispatched:

  • FilteringPolicy — repository allowlists, user authorization, and PR title tag matching. Applied before any dispatch decision.
  • RoutingPolicy — reviewer auto-trigger events and bot usernames for @mention matching.

Both policies are fields on the top-level Config (config.filtering, config.routing) and are constructed from the YAML file and environment variables at startup.

The webhook handler's public dispatch functions (dispatch_lifecycle_event, dispatch_comment_event, filter_event) accept policies directly rather than the full Config, making them reusable in contexts that construct their own policies — such as a multi-tenant enterprise wrapper that builds per-organization policies from a database.

See Policies for the full reference.

Configuration Architecture

The config/ package uses a two-layer pattern: mutable Settings models for input, frozen Config models for output.

Layer 1: Settings (input) — config/models.py

AppSettings and its nested *Settings models (KubernetesSettings, RedisSettings, etc.) are mutable Pydantic models that mirror the shape of the YAML file and environment variables. Their job is purely structural — they hold raw values exactly as the user provided them.

config/env.py provides load_app_settings(), which merges three sources in priority order: model defaults → YAML file → environment variables. The ENV_MAP table in env.py flattens env var names (e.g. K8S_NAMESPACE) into the nested structure (["kubernetes", "namespace"]).

Layer 2: Config (output) — config/settings.py, config/kubernetes.py

Config, WebhookConfig, KubernetesConfig, RedisConfig, etc. are frozen (frozen=True) Pydantic models that represent the validated, resolved configuration the application consumes. They are immutable and safe to pass around.

The bridge: config/loader.py

loader.py transforms Settings into Config. This is where business logic lives:

  • Validation — e.g. "at least one bot must be configured", "ALLOWED_USERS is required".
  • Derivation — reading prompt files from disk, parsing trigger strings into EventType frozensets, resolving agent provider configs.
  • Reshaping — the Config models don't mirror the YAML structure. Fields get flattened, renamed, or combined (e.g. resources.requests.cpuresource_requests_cpu).
  • Conditional constructionKubernetesConfig is None when no image is set, RedisConfig is None when no URL is set.

Why two layers?

Settings and Config serve different masters:

Settings Config
Serves The user writing YAML/env vars The application code consuming config
Shape Mirrors the config file structure Mirrors what the code needs
Mutability Mutable (intermediate merge target) Frozen (safe to pass around)
Content Raw strings, file paths, lists Resolved values — file contents loaded, enums parsed, frozensets built
Optionality Everything has defaults Missing-means-disabled expressed as None

A single-layer approach would force one model to serve both roles — you'd either leak file paths and parsing logic into application code, or leak validation rules into the YAML schema. The two-layer split keeps the config file ergonomic for users while giving application code exactly the types it needs.

Adding a new config field

  1. Add the field with a default to the appropriate *Settings model in models.py.
  2. If it needs an env var, add a (ENV_NAME, ["section", "field"]) entry to ENV_MAP in env.py and the appropriate type set (INT_KEYS, BOOL_KEYS, or COMMA_LIST_KEYS).
  3. Add the field to the corresponding *Config model in settings.py or kubernetes.py.
  4. Forward the value in the relevant _resolve_*() or load_config() function in loader.py.
  5. Add tests for the env var override in tests/test_config.py.

Components

Webhook Server

An aiohttp application that exposes:

  • GET /health — returns {"status": "ok"}
  • POST /webhooks/{platform} — one route per enabled platform

Each incoming request is verified, parsed, and dispatched. The HTTP response is returned immediately; actual processing happens asynchronously via the job queue.

Pre-flight Checks (commands/webhook/helpers.py)

  • run_pre_flight() — central pre-flight for all events. For comment events: validates the author against ALLOWED_USERS, logs the event, posts the eyes reaction. For lifecycle events: logs with event type/title/author, posts a PR reaction, and skips auth and comment reaction. Returns whether the job should proceed.

Job Processing (commands/webhook/jobs/runner/process.py)

  • ProcessRunner — sets clone URLs on the event, builds the handler closure, and enqueues jobs for serial execution via the job queue.

Handlers

  • review.handler.review() — core review logic (clone, fetch diff + comments, build annotated diffs, run multi-turn reviewer agent with optional sub-agents, parse JSON, filter findings). Returns a ReviewResult without posting. Used by webhook, CLI, and CI modes.
  • review.handler.run_and_post_review() — webhook/CI entry point. Calls review() then posts results to the platform.

CLI Module (commands/cli/main.py)

  • _parse_pr_ref() — parses owner/repo#42 into a repo name and PR number.
  • _run_review() — orchestrates the CLI flow: resolve branch, call review(), print results, optionally post. Uses build_platform() from platforms/ to construct the platform client.

CI Module (commands/ci/main.py)

  • run_ci_review() — main entry point for CI-triggered reviews. Dispatches to the platform-specific CI module, runs the review, and posts results.

Platform CI Modules (commands/ci/{github,gitlab}.py)

Each platform provides a CI module with three functions:

  • build_event() — reads CI environment variables and returns a PullRequestEvent. GitHub reads $GITHUB_EVENT_PATH; GitLab reads $CI_PROJECT_PATH, $CI_MERGE_REQUEST_IID, etc.
  • build_platform() — constructs a Platform from CI config (GitHubConfig or GitLabConfig).
  • resolve_workspace() — returns the CI runner's checkout directory ($GITHUB_WORKSPACE or $CI_PROJECT_DIR).

Agent Dispatcher (agent/invoke.py)

Single entry point for agent execution. Routes to the API or CLI runner based on the agent config type (CliAgentConfig or ApiAgentConfig), with conversation persistence. See Agent Runners above.

API Runner (agent/api/runner.py)

Implements the LLM provider API agentic loop with local tool execution. See LLM Provider API Runner above.

API Tools (agent/api/tools.py)

Defines and executes tools for the API runner: Read, Glob, Grep, Bash, WriteNotes, and submit_review. Bash commands are validated against an allowlist when allowed_tools restricts the agent. WriteNotes is restricted to a pre-assigned file path controlled by the orchestrator — agents cannot write to arbitrary locations.

CLI Runner (agent/cli/runner.py)

Wraps the Claude Code SDK to stream messages from the CLI subprocess. See Claude Code CLI Runner above.

Prompt Composition

The reviewer prompt is composed from multiple sources across two layers:

System prompt (instructions — who you are, how to behave): 1. Base reviewer prompt (prompts/reviewer_prompt.md) 2. Suggestions instructions (prompts/reviewer_suggestions.md) — appended when inline_suggestions is enabled in config 3. Repository guidelines (.nominal/guidelines.md or built-in) — wrapped in <repo-guidelines> tags

User message (the review input — what to review): 1. Branch header and user prompt (wrapped in <untrusted-request>) 2. Changed files with annotated diffs (wrapped in <untrusted-diff>) — each line prefixed with its actual line number 3. Existing PR comments (wrapped in <untrusted-comment>) 4. Exploration notes — structured findings from concern-partitioned sub-agents 5. Review instruction (use annotated line numbers, call submit_review)

The review agent runs as a multi-turn agentic loop with access to Read, Glob, Grep, Bash, WriteNotes, the Agent tool (for spawning explore sub-agents), and submit_review. It can investigate the codebase directly or delegate to sub-agents before producing its review.

System prompt composition is handled by agent/prompts.py, which loads guidelines from the .nominal/ directory with per-repo and per-language overrides. Language detection is based on file extensions in the PR diff.

Conversation Store and Job Queue

  • ConversationStore (conversation/memory.py) — a unified in-memory store with two parallel dicts keyed by (platform, repo, pr_number): lightweight conversation IDs and full message histories (API mode only). Used to resume conversations across multiple interactions on the same PR.
  • AsyncioJobQueue (commands/webhook/jobs/queue/asyncio.py) — per-PR async job queue for in-process mode. Each PR key gets its own asyncio.Queue with a single consumer task, ensuring that agent invocations on the same PR run serially (no race conditions). The consumer and queue are cleaned up when drained.
  • RedisJobQueue (commands/webhook/jobs/queue/redis.py) — Redis-backed per-PR job queue for Kubernetes mode. Uses Redis lists for serial execution and Redis pub/sub for event-driven job completion. Each PR key gets a consumer task that loops with BRPOP, creates K8s Jobs, and awaits completion signals — no K8s API polling required.

Cost Tracking (llm/cost.py)

Both agent runners capture token usage and compute dollar costs per invocation:

  • Pricing data — a bundled llm/data/pricing.json file maps model IDs to per-token rates (input, output, cache write, cache read). Generated from the LiteLLM community pricing database by scripts/update_pricing.py and auto-updated weekly via a GitHub Actions workflow.
  • API runner — accumulates TokenUsage from each provider response across the agentic loop, then calls build_cost_summary() to compute the total cost.
  • CLI runner — extracts total_cost_usd and token usage from the SDK's ResultMessage.
  • Output — cost is attached to AgentResult.cost and ReviewResult.cost, logged by the review handler, and formatted for CI output.

Git Workspace (workspace/git.py)

Manages per-PR cloned repositories. Handles initial cloning, updating (fetch + reset), pushing changes, and provides a shared .deps/ directory for private dependency cloning.

Workspace Setup (workspace/setup.py)

Helper functions for branch resolution and workspace construction. resolve_branch() fetches the PR branch from the platform API when the webhook payload doesn't include it. create_workspace() and setup_workspace() construct and initialise GitWorkspace instances.

Error Handling (agent/errors.py)

An async context manager (handle_agent_errors()) that wraps handler execution. Catches workspace setup failures and agent runtime errors, posts user-facing error messages to the platform, and prevents unhandled exceptions from crashing the event loop.

Workspace Directory Layout

{WORKSPACE_BASE_DIR}/
└── {owner}/
    └── {repo}/
        ├── .deps/           ← shared private dependency clones
        ├── pr-1/            ← workspace for PR #1
        ├── pr-2/            ← workspace for PR #2
        └── pr-N/

Each pr-{N} directory is a shallow clone of the repository checked out to the PR's head branch.

In CI mode, the workspace is the CI runner's checkout directory (e.g. $GITHUB_WORKSPACE or $CI_PROJECT_DIR) — no cloning is needed.

Job Queue

The job queue ensures that only one agent runs per PR at a time. This prevents race conditions when multiple comments arrive in quick succession on the same PR.

Jobs are keyed by (platform_name, repo_full_name, pr_number). When a job is enqueued:

  1. If no queue exists for that key, one is created along with a consumer task.
  2. The job is put into the queue.
  3. The consumer processes jobs serially, one at a time.
  4. When the queue drains, the consumer task and queue are cleaned up.

In Kubernetes mode, the RedisJobQueue replaces the in-memory AsyncioJobQueue. The flow is:

  1. Webhook arrives → KubernetesRunner.run() enqueues the job payload to a Redis list keyed by nc:queue:{platform}:{repo}:{pr}.
  2. A per-PR consumer task BRPOPs from the list and creates a K8s Job for each dequeued payload.
  3. The Job pod runs nominal-code run-job, performs the review, and publishes a completion signal to nc:job:{job_name}:done via Redis pub/sub.
  4. The server receives the signal and the consumer moves on to the next queued job.

Source Layout

nominal_code/
├── main.py              # Entry point: dispatches to webhook server, CLI, or CI
├── config/
│   ├── models.py        # Mutable *Settings models (YAML/env input layer)
│   ├── settings.py      # Frozen *Config models (application output layer)
│   ├── env.py           # ENV_MAP, type sets, load_app_settings() (YAML + env merge)
│   ├── loader.py        # Settings → Config transformation with validation
│   ├── policies.py      # FilteringPolicy and RoutingPolicy (frozen Pydantic models)
│   ├── agent.py         # AgentConfig, CliAgentConfig, ApiAgentConfig
│   └── kubernetes.py    # KubernetesConfig
├── models.py            # Shared enums (EventType, FileStatus) and dataclasses
├── commands/
│   ├── cli/             # One-shot review CLI (package)
│   │   └── main.py      # argparse, platform construction, review orchestration
│   ├── ci/              # CI mode
│   │   ├── main.py      # CI review entry point (run_ci_review)
│   │   ├── github.py    # CI mode: build event, platform, and workspace from GitHub Actions env vars
│   │   └── gitlab.py    # CI mode: build event, platform, and workspace from GitLab CI env vars
│   └── webhook/         # Webhook server and K8s job entrypoint
│       ├── main.py      # aiohttp app with /health and /webhooks/{platform} routes
│       ├── helpers.py   # Pre-flight checks (auth, reactions, logging), mention extraction
│       ├── result.py    # DispatchResult dataclass
│       └── jobs/        # Job processing
│           ├── main.py          # K8s pod entry point (run-job)
│           ├── payload.py       # JobPayload serializable dataclass
│           ├── dispatch.py      # Job dispatch logic (execute_job)
│           ├── handler.py       # JobHandler protocol
│           ├── runner/          # Job runner implementations
│           │   ├── base.py      # JobRunner protocol + build_runner() factory
│           │   ├── process.py   # ProcessRunner: job enqueueing and handler dispatch
│           │   └── kubernetes.py # Kubernetes Job dispatcher with Redis queue integration
│           └── queue/           # Job queue implementations
│               ├── base.py      # JobQueue protocol
│               ├── asyncio.py   # AsyncioJobQueue (per-PR in-memory async queue)
│               └── redis.py     # RedisJobQueue (Redis-backed per-PR queue + pub/sub)
├── llm/
│   ├── provider.py      # LLM provider protocol and base classes
│   ├── registry.py      # Provider registry and factory
│   ├── messages.py      # Canonical message types (LLMResponse, TokenUsage, etc.)
│   ├── cost.py          # CostSummary, pricing loader, cost computation
│   ├── data/
│   │   └── pricing.json # Bundled model pricing (auto-updated from LiteLLM)
│   ├── anthropic.py     # Anthropic provider implementation
│   ├── openai.py        # OpenAI provider implementation (also used by DeepSeek, Groq, Together, Fireworks)
│   └── google.py        # Google Gemini provider implementation
├── agent/
│   ├── invoke.py        # Single entry point: invoke_agent() (with persistence)
│   ├── result.py        # AgentResult dataclass (output, turns, conversation ID, cost, sub_agent_costs)
│   ├── sub_agent.py     # SubAgentConfig dataclass, DEFAULT_MAX_TURNS_PER_SUB_AGENT
│   ├── prompts.py       # Guideline loading, language detection, system prompt composition
│   ├── errors.py        # Async context manager for handler error handling
│   ├── compaction.py    # Notes-based message compaction
│   ├── sandbox.py       # Output sanitization and environment building
│   ├── api/
│   │   ├── runner.py    # LLM provider API agentic loop with sub-agent dispatch
│   │   └── tools.py     # Tool definitions and execution (Read, Glob, Grep, Bash, WriteNotes, Agent, submit_review)
│   └── cli/
│       └── runner.py    # Claude Code CLI subprocess wrapper (SDK integration)
├── conversation/
│   ├── base.py          # Conversation store protocol
│   ├── memory.py        # In-memory conversation store
│   └── redis.py         # Redis-backed conversation store
├── prompts/
│   ├── reviewer_prompt.md       # Base reviewer system prompt
│   ├── reviewer_suggestions.md  # Inline suggestions instructions
│   ├── languages/
│   │   └── python.md            # Built-in Python language guidelines
│   ├── explore/
│   │   ├── explorer.md          # Explore sub-agent system prompt
│   │   └── suffix.md            # Sub-agent suffix template
│   └── output/
│       ├── json_fix_system.md   # JSON repair system prompt
│       ├── json_fix_user.md     # JSON repair user prompt
│       └── json_fix_retry.md    # JSON repair retry prompt
├── review/
│   ├── reviewer.py      # Review orchestration: diff fetching, sub-agent config, output parsing
│   ├── prompts.py       # Reviewer prompt building, fallback prompt, comment formatting
│   ├── diff.py          # Diff handling utilities
│   └── output.py        # Output parsing and JSON repair
├── platforms/
│   ├── base.py          # Platform protocol and shared dataclasses
│   ├── http.py          # request_with_retry(): HTTP request helper with transient error retries
│   ├── github/
│   │   ├── auth.py      # GitHubAuth ABC, PAT and App auth implementations
│   │   └── platform.py  # GitHub webhook handler and REST API client
│   └── gitlab/
│       ├── auth.py      # GitLabAuth: PAT auth implementation
│       └── platform.py  # GitLab webhook handler and REST API client
└── workspace/
    ├── git.py           # GitWorkspace: clone, update, push per-PR workspaces
    └── setup.py         # Branch resolution and workspace construction helpers