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 viaAGENT_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
ResultMessagewhen 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_useblocks 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), andsubmit_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:
- Validates the
subagent_typeagainst the provided configs. - Creates a temporary notes directory with a header file.
- Recursively calls
run_api_agent()with the sub-agent's provider, model, tools, and turn budget. - Reads the notes file content after completion.
- 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@mentionmatching.
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
EventTypefrozensets, resolving agent provider configs. - Reshaping — the Config models don't mirror the YAML structure. Fields get flattened, renamed, or combined (e.g.
resources.requests.cpu→resource_requests_cpu). - Conditional construction —
KubernetesConfigisNonewhen no image is set,RedisConfigisNonewhen 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¶
- Add the field with a default to the appropriate
*Settingsmodel inmodels.py. - If it needs an env var, add a
(ENV_NAME, ["section", "field"])entry toENV_MAPinenv.pyand the appropriate type set (INT_KEYS,BOOL_KEYS, orCOMMA_LIST_KEYS). - Add the field to the corresponding
*Configmodel insettings.pyorkubernetes.py. - Forward the value in the relevant
_resolve_*()orload_config()function inloader.py. - 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 againstALLOWED_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 aReviewResultwithout posting. Used by webhook, CLI, and CI modes.review.handler.run_and_post_review()— webhook/CI entry point. Callsreview()then posts results to the platform.
CLI Module (commands/cli/main.py)¶
_parse_pr_ref()— parsesowner/repo#42into a repo name and PR number._run_review()— orchestrates the CLI flow: resolve branch, callreview(), print results, optionally post. Usesbuild_platform()fromplatforms/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 aPullRequestEvent. GitHub reads$GITHUB_EVENT_PATH; GitLab reads$CI_PROJECT_PATH,$CI_MERGE_REQUEST_IID, etc.build_platform()— constructs aPlatformfrom CI config (GitHubConfigorGitLabConfig).resolve_workspace()— returns the CI runner's checkout directory ($GITHUB_WORKSPACEor$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 ownasyncio.Queuewith 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 withBRPOP, 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.jsonfile maps model IDs to per-token rates (input, output, cache write, cache read). Generated from the LiteLLM community pricing database byscripts/update_pricing.pyand auto-updated weekly via a GitHub Actions workflow. - API runner — accumulates
TokenUsagefrom each provider response across the agentic loop, then callsbuild_cost_summary()to compute the total cost. - CLI runner — extracts
total_cost_usdand token usage from the SDK'sResultMessage. - Output — cost is attached to
AgentResult.costandReviewResult.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:
- If no queue exists for that key, one is created along with a consumer task.
- The job is put into the queue.
- The consumer processes jobs serially, one at a time.
- 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:
- Webhook arrives →
KubernetesRunner.run()enqueues the job payload to a Redis list keyed bync:queue:{platform}:{repo}:{pr}. - A per-PR consumer task
BRPOPs from the list and creates a K8s Job for each dequeued payload. - The Job pod runs
nominal-code run-job, performs the review, and publishes a completion signal tonc:job:{job_name}:donevia Redis pub/sub. - 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