Skip to content

Configuration reference

Configuration lives in .aiur/config (YAML), and prompt_file: and hooks_file: point at sibling files.

Older root-level config files are rejected. When moving one, also move the files it references, or rewrite their paths so they still resolve from the new config directory.

Supported secret and workspace-root fields resolve ~ and $VAR values; other path fields do not generally expand environment references.

Environment variables

Environment variables are declared once in the env schema (Aiur.Env.Schema), which validates them at startup and generates the checked-in .env.example (run mix aiur.env.example from src/ to regenerate; a CI check fails when the example drifts from the schema).

  • Layering. The launcher reads ~/.aiur/.env then ./.env, each file filling only unset names, so the home file wins. When both files set the same variable to different values, the repo value is silently dead and the daemon logs a startup warning naming the variable (never its value).
  • Required vs optional. The only configuration that aborts a boot is the GitHub credential (GITHUB_TOKEN, a gh keyring login via gh auth login, or the complete GitHub App set — GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, and one of GITHUB_APP_PRIVATE_KEY_PATH / GITHUB_APP_PRIVATE_KEY) and the tracker configuration. Every integration — GitHub App auth, webhooks, Linear, voice, dashboard, Supervisor Decision API, provider keys — is optional; absence disables the feature and is reported once at startup, never a boot failure.
  • All-or-nothing credential groups. A partially configured group (one dashboard credential without the other, or some but not all GitHub App credentials) fails at startup naming the missing members; a fully absent group is a supported setup.
  • Type validation. Values that fail their declared type (for example AIUR_OPENCODE_BRIDGE_PORT=banana) abort the boot naming the variable and what was expected, instead of failing at first use hours later.
  • Secrets never leak. Secrets render as an empty placeholder in .env.example and are excluded from error text and startup warnings. No real value from any .env file reaches the generated example, logs, or error output.
  • Dashboard credentials (AIUR_DASHBOARD_USERNAME / AIUR_DASHBOARD_PASSWORD) are values an operator chooses and types into a browser; see GUI for choosing and setting them. Without them the dashboard refuses all requests (fails closed); the CLI and TUI are unaffected.

The generated .env.example groups variables under ## Required, ## Optional - ... (one section per integration), ## Runtime - launcher-managed, and ## Development and debugging headers, with a one-line purpose above each key and a terse right-hand "how to fetch" note aligned to a common column.

Top-level

KeyTypeDefaultControls
max_vertical_panesinteger3Caps visible agent chat panes.
pre_warmed_sessionsinteger3Number of opencode sessions booted early; 0 disables pre-warm.
max_log_history_mbinteger1000Caps persistent log history in MB.
prompt_filestringnilPer-repository Liquid prompt template.
debugbooleanfalseEnables file logging without the CLI debug flag.
hooks_filefile pointernoneSibling YAML file merged as the hooks: block.
executor_takeover_first_alert_hoursinteger8First Executor takeover advisory threshold in hours; 0 disables.
executor_takeover_continuous_alert_hoursinteger1Repeated takeover advisory cadence in hours after the first; 0 disables repeats.

executor takeover alerts

Aiur watches nonterminal tickets in the run scope and, once a ticket's convergence age crosses a configurable threshold, raises an advisory needs_attention alert visible in aiurdev alerts --needs-attention and the watch actionable section. The alerts are advisory takeover prompts — they never perform a takeover automatically.

  • executor_takeover_first_alert_hours (default 8) — a nonterminal ticket first raises the advisory once its convergence age reaches this value.
  • executor_takeover_continuous_alert_hours (default 1) — while the ticket stays nonterminal and unresolved, the advisory is repeated at most this often. A value of 0 disables repeats (first alert only); 0 on the first threshold disables the feature. Negative or non-integer values are rejected.

Convergence age is now − min(first_observed_active_work_at, open_pr_created_at):

  • first_observed_active_work_at is persisted durably per ticket in daemon state, set once the first time the monitor observes the ticket as nonterminal and in scope. A worker restart, redispatch, max_turns recycle, or daemon restart never resets it.
  • open_pr_created_at is the creation time of the ticket's open PR (a floor, so an already-open PR is never hidden by a freshly installed or restarted monitor).

The alert carries actionable evidence.

EvidenceDetail
IdentityTicket and PR.
AgeElapsed convergence age.
ActivityLast material push, current live-owner state, and dispatch/restart count.
PR healthBase and merge freshness.
CICurrent state when available, for tickets already alerted.

A ticket that becomes terminal or leaves the run scope resolves its active advisory and forgets its convergence state; a re-opened ticket starts a fresh episode.

tracker

KeyTypeDefaultControls
tracker.kindstringrequiredSelects linear, github, or memory.
tracker.base_branchstringrequiredBranch agents target with PRs. aiur init offers the repository default read from GitHub, but there is no runtime fallback: an unset value raises.
tracker.active_statesarraytracker-specificStates eligible for dispatch. GitHub values are lifecycle label slugs such as todo and in-progress, not display names.
tracker.terminal_statesarraytracker-specificStates that stop work. GitHub values are lifecycle label slugs such as done.
tracker.terminal_fence_grace_secondsinteger30How long a terminal tracker observation remains lifecycle-fenced while an authoritative queued item is still undelivered.
tracker.github.max_inflightinteger4Cap on concurrent tracker HTTP requests across all endpoints (1-100).
tracker.github.max_inflight_per_endpointinteger2Cap on concurrent requests to any single tracker endpoint (1-100). Must not exceed tracker.github.max_inflight.
tracker.github.requests_per_minuteinteger120Tracker request budget per minute (1-10000). Lower it when the tracker rate-limits Aiur.
tracker.github.stagger_msinteger75Delay inserted between tracker requests, in milliseconds (0-5000), so a poll cycle does not burst.
tracker.github.daemon_core_limit_per_hourinteger3000Hourly billable Core (REST) response ceiling for the daemon actor. A 304 is reconciled as free. When the daemon hits the ceiling, only its requests hold until the rolling hour rolls back under it. 0 disables.
tracker.github.daemon_graphql_limit_per_hourinteger4500Hourly billable GraphQL response ceiling for the daemon actor. 0 disables. Raised from 2000 because the guard now books the high-level GraphQL-on-the-wire reads (gh pr view/list/status/checks, gh issue view/list/status, gh search issues/prs, gh api graphql) to the GraphQL window, and the App-token daemon alone measures ~3,400-4,300 such requests per hour.
tracker.github.daemon_search_limit_per_hourinteger600Hourly billable ceiling for GitHub's separate search pool (gh search repos/code/commits/users hit REST /search/*, metered at roughly 30 requests per minute rather than 5,000/hour). 0 disables.
tracker.github.agent_core_limit_per_hourinteger250Hourly billable Core (REST) response ceiling for each agent workspace. When one agent hits it, only that agent holds. 0 disables.
tracker.github.agent_graphql_limit_per_hourinteger600Hourly billable GraphQL response ceiling for each agent workspace. 0 disables. Raised from 375 so a single agent's normal loop (pr view/issue view/pr checks) has headroom once high-level GraphQL commands book to the GraphQL window.
tracker.github.agent_search_limit_per_hourinteger600Hourly billable ceiling against the search pool for each agent workspace. 0 disables. Kept separate from core and graphql because GitHub meters the search pool independently and it throttles first.
tracker.github.credentialsarray[]Additional GitHub credentials the daemon spreads read traffic across, so one exhausted budget does not stop the fleet. Empty — the default — means one credential resolved exactly as before. See Credential pooling.
tracker.github.credentials.idstringrequiredLowercase identifier naming this credential in aiur github-usage and aiur github-cost. Must be unique.
tracker.github.credentials.kindstringmachine_userOne of app_installation, machine_user or human. Set it to human for a real person's token so Aiur keeps writes off that identity.
tracker.github.credentials.identitystringnilThe GitHub login this credential authenticates as. Reporting only, so a usage row names an account rather than a hash.
tracker.github.credentials.token_envstringrequired except for app_installationEnvironment variable holding the token. An app_installation credential mints its own and needs none. A variable that is not exported drops the credential from the pool rather than failing boot.
tracker.github.credentials.writesbooleanfalseWhether this credential may carry writes (comments, labels, merges, PR creation). A human credential cannot be set to true: GitHub attributes the write to that person and it breaks the agent-authors / human-reviews separation the merge policy depends on.
tracker.github.credentials.enabledbooleantrueSet to false to keep a credential in the file but out of the pool, for a token being rotated or an account temporarily rate-limited.
tracker.github.repostringthe checkout's origin remoteGitHub owner/name used by Aiur. Omitted or left blank, it auto-detects from the origin remote of the directory the daemon was launched from — both for the repository Aiur polls and for the repository tracker identities are qualified by, which is what lets several daemons for different repositories share one ~/.aiur/config. A value that is present but not owner/name is rejected rather than auto-detected, so a typo cannot silently redirect a fleet at whatever checkout it happens to run from. Set it explicitly whenever the daemon should track a repository other than its own checkout.
tracker.github.label_prefixstringagentPrefixes lifecycle labels.
tracker.github.bot_accountstringnilLogin the agents publish as — the account that pushes branches, opens pull requests, and comments for a ticket. This is an identity, not the credential: the credential is GITHUB_TOKEN. aiur init defaults it to the token's login; prefer a dedicated bot account when operators also comment from a trusted CODEOWNER account. In a non-interactive or --force run the wizard applies the detected token login, or omits the key entirely when no login can be detected. Re-running aiur init preserves an existing value. When no tracker.github.github_app.account is set this login also stands in as the daemon's own identity for self-loop suppression.
tracker.github.identity_modestringseparate_accountWhether the agents post as a login no human uses (separate_account) or share the operator's own login (single_account). Stated, never inferred: nothing compares bot_account against the token's viewer login to guess, because that guess is wrong in both directions and silently changes which comments wake an agent. Under separate_account the author login proves authorship and nothing else is needed. Under single_account it proves nothing, so Aiur appends an invisible HTML-comment marker to comments it writes and suppresses only comments carrying it — anything unmarked, including every comment posted before this existed, reads as human and wakes the agent. Any other value is rejected at config load.
tracker.github.github_app.accountstringnilOptional. The GitHub App bot login (<app-slug>[bot]) the daemon writes as when App credentials are configured (see GitHub). Set it only when the daemon's identity differs from the agents': an App installation token can never write as tracker.github.bot_account, so one key naming both would make every agent-authorship check demand a login no agent holds. Leave it unset for a single-identity install — self-loop suppression, PR command handling and the CODEOWNERS self-include then fall back to tracker.github.bot_account exactly as before. Only the login lives here; the App credentials stay in GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID and GITHUB_APP_PRIVATE_KEY_PATH.
tracker.github.trusted_accountsarray[]Usernames allowed to direct agents.
tracker.github.allowed_usersarray[]GitHub logins allowed to use trusted operator paths.
tracker.github.human_mergersarray[]GitHub logins allowed to perform human merge actions.
tracker.github.planning_root_limitinteger100Maximum Build Order planning roots fetched in one cycle.
tracker.github.planning_page_budgetinteger4Maximum GitHub planning pages fetched in one cycle.
tracker.github.planning_call_budgetinteger4Maximum GitHub planning calls fetched in one cycle.
tracker.linear.api_keystringenv fallbackLinear API key; $VAR resolves from the environment.
tracker.linear.project_slugstringnilLinear project polled by Aiur.
tracker.linear.endpointstringhttps://api.linear.app/graphqlLinear GraphQL endpoint.
tracker.linear.assigneestringenv fallbackLinear assignee filter.

polling

KeyTypeDefaultControls
polling.interval_secondsinteger120Seconds between tracker polls. The repo-events firehose shares this tick.
polling.intervalsmap of class → integer%{}Per-class poll cadences in seconds. Each key names a poll class — dispatch, ci, review, planning, firehose — and overrides interval_seconds for that class only. A class with no entry falls back to interval_seconds, so an unset map keeps today's single-interval behaviour exactly. 0 means the class is on-demand — no timer, refreshed only when a consumer explicitly asks — which is the recommended value for planning. (firehose is not recommended at any value: its loop rides the dispatch tick and is not gated, so an entry would be a dead knob.) dispatch must be a positive integer: the dispatch tick always runs, so dispatch: 0 is rejected. review only diverges on a repo proven webhook-backed — on a polling repo its safety net stays at the dispatch rate. An unknown class key or a negative value is rejected.
polling.idle_widen_factorfloat5.0Multiplier applied while no agents are actively running. Must be between 1.0 and 100.0.
polling.usage_interval_secondsinteger300Seconds between provider-meter probes. Values below 120 are rejected to avoid provider rate-limit degradation.
polling.view_state_sweep_secondsinteger900Seconds between runs of the view-state reconciliation sweep. It exists only to recover a webhook delivery that was lost, so it is a recovery bound rather than a refresh interval — a delivery that arrives updates the dashboard immediately and for free, and shortening this makes nothing fresher. The open-backlog and ad-hoc-overlay sources are event-sourced and not swept at all; the sweep reconciles the daemon-owned Build Order pack-status projection (which writes status.json on disk and stays on this cadence until it is moved to the event stream too) and runs the issue-family divergence watermark — a single bounded updated_at-ordered head page that keeps webhook loss detectable and re-converges a dropped delivery.

Freshness thresholds follow this cadence. You do not set them separately.

  • The effective interval is a class's interval after idle_widen_factor, webhooks.poll_widen_factor and GitHub's poll floors are applied.

  • Since #2309 each poll loop resolves its interval by naming the class it serves, and polling.intervals lets those classes diverge. The classes:

    ClassPollsWhy it gets its own cadence
    dispatchopen issues and agent:* labels (the dispatch trigger)Cheap (conditional REST, usually 304) and urgent. The default for every unlisted class and for un-named PollCadence reads.
    cicheck state on a pull request with work in flightExpensive GraphQL and urgent, but only while a PR is actually in flight (the loop is demand-scoped). intervals.ci is deliberately not recommended: the loop rides the dispatch tick, so a value below dispatch is inert (the loop can never fire more often than the tick) and one above it slows CI detection — a stale CI read has agent-visible consequences. Leave it unset to inherit interval_seconds.
    reviewcomments and review threadsExpensive GraphQL, moderately urgent, and webhook-covered for comment arrival — the poll is a safety net, so minutes is defensible. The divergence is enforced, not asserted: on a repo not proven webhook-backed the class resolves to the dispatch cadence, so the safety net never silently slows on a polling repo.
    planningBuild Order catalog, pack status, ad-hoc listingsThe most expensive reads and the least urgent. Recommended value 0 (on-demand): the catalog's only consumers are web pages and it is demand-gated, so it needs no timer.
    firehoserepo eventsAlready self-regulating via GitHub's X-Poll-Interval; the class exists so status can show its configured cadence, not to change its loop. The firehose loop is not gated on a class cadence — it rides the dispatch tick — so no value is recommended: leave it unset to inherit interval_seconds. An entry would be a dead knob.
  • The dashboard, the Units catalog and Build Order ticket history all judge staleness against the effective interval of the class they mean: the orchestrator snapshot readers derive from dispatch, Build Order catalog and ticket history from planning.

  • Build Order's remaining graph_catalog_refresh_ms — the failure-backoff base for the catalog scope, the window after which the catalog snapshot is shown as ageing, and the floor the labelled-read cadence rides on — is derived from the effective planning interval, so an idle fleet widens the Build Order backoff exactly as it widens the tracker poll.

  • The catalog itself is event-sourced (#2313): the page renders the store projection, there is no recurring sweep, and the only GitHub reads are the rare reconciliation (daemon boot and degraded webhook delivery). It is not demand-gated by who is looking — the reconciliation is the daemon-owned writer that re-converges the store — and it needs no timer. A selected root's staleness window and failure backoff are therefore re-based on delivery latency (webhooks.silence_threshold_seconds), the gap after which degradation triggers the reconciliation, rather than on a poll cadence.

  • So a change to an interval needs no matching threshold edit.

  • aiur status prints the effective value and the live interval per class, for example:

    POLL idle backoff active: interval=1200s base=120s factor=5.0x
    POLL class intervals: ci=120s dispatch=120s firehose=120s planning=0s review=300s

    0s means the class is on-demand: no timer, refreshed only when a consumer asks. ci sits at the dispatch cadence because the loop rides the tick and is deliberately not given its own interval; review shows its configured value only while the repo is proven webhook-backed.

  • The idle widening only applies once the daemon has observed an idle cycle: a freshly restarted fleet starts at the base interval, and a live fleet with dispatchable tickets keeps the base interval so work is not left waiting behind a backed-off sweep (#2138).

webhooks

KeyTypeDefaultControls
webhooks.reposlist of owner/name[]Repos expected to deliver webhooks. A hint only. A listed repo keeps polling at full rate until it actually delivers.
webhooks.silence_threshold_secondsinteger900How long a proven repo may go without a delivery before it degrades back to full polling and raises a needs-attention alert.
webhooks.sweep_interval_secondsinteger60How often proven repos are checked for silence.
webhooks.poll_widen_factorfloat2.0Multiplier applied to polling.interval_seconds for repos proven webhook-backed. Values below 1.0 are rejected.

See GitHub polling and webhooks for the setup story and runtime states.

workspace

KeyTypeDefaultControls
workspace.rootstring pathtmp aiur_workspacesRoot for agent workspaces.
workspace.bootstrap_imagestringnilDocker image for warm build-cache seeding.
workspace.bootstrap_image_pullbooleanfalsePulls the bootstrap image before seeding.

worker

KeyTypeDefaultControls
worker.ssh_hostsarray[]SSH hosts available for remote execution. Each server must allow BASH_ENV, ENV, HOME, and ZDOTDIR through OpenSSH AcceptEnv; Aiur neutralizes them before the account shell starts and fails closed if the server rejects them.
worker.max_concurrent_agents_per_hostinteger or nilnilPer-host concurrent-agent cap.

agent

KeyTypeDefaultControls
agent.priorityarray[]Ordered dispatch preference, as routes (backend or backend:model); see Routes in agent.priority. Presence makes a backend dispatchable, the first available entry is the default, and limits advance to the next entry until recovery. A non-empty list replaces agent.kind, agent.switch_model_on_ratelimit, and backend_configs.<b>.enabled.
agent.pricing_policy.avoid_peak_pricingbooleantrueRoutes around peak-pricing windows through agent.priority; false follows the list exactly and never changes spend reporting. When the window cannot be determined, routing never moves work (it fails toward not rerouting). Inspect the current window and next boundary with mix aiur.pricing_window.
agent.kindstringcodexDeprecated default backend; ignored when agent.priority is non-empty.
agent.remote_controlbooleanfalseOpts RC-capable backends into remote control.
agent.prior_work_continuationbooleantrueLets a resumed ticket continue existing workspace work when policy permits.
agent.max_dispatches_per_ticketinteger0Per-ticket dispatch latch; 0 disables the latch.
agent.max_concurrent_agentsinteger or nilderived from host capacityGlobal simultaneous-agent cap. When omitted, it derives from the measured host capacity: schedulers + schedulers / 4 (e.g. 20 on a 16-core host), so the ceiling is calibrated to the box instead of a hard-coded count. Explicit config wins. The load envelope reduces effective concurrency below this ceiling under host pressure.
agent.max_concurrent_buildsinteger4Caps local agent Mix verification; 0 deliberately disables the concurrency cap. When every build slot is busy or builds are queued, the dispatch gate defers new admissions (build capacity hold). Re-derived from a measured load curve (see ticket #2311): with agent.mix_scheduler_cap at 4 on a 16-scheduler host and the hard load gate at 24.0, four concurrent builds (~16 schedulers) stay far below the ceiling, so the default rose from 2.
agent.build_start_stagger_secondsinteger0Minimum spacing between local Mix build starts; 0 disables pacing.
agent.min_free_memory_mbinteger or nilnilLinux MemAvailable floor shared by dispatch and the Mix build gate.
agent.build_gate_max_hold_secondsinteger3600Absolute wall-clock cap on how long one build-gate slot may be held. The lease holder releases the slot at the cap and the daemon raises a needs-attention alert naming the command; 0 disables the backstop.
agent.build_gate_retain_secondsinteger120Maximum post-command window the lease holder keeps a slot after the wrapped command exits, gated on a descendant still consuming CPU. The holder releases the moment the retained tree goes idle, so this bounds only a genuinely-busy descendant (a runaway build), not an adopted idle daemon; 0 disables the courtesy.
agent.max_concurrent_agents_by_statemap%{}Per-state caps overriding the global cap.
agent.rtk.enabledbooleanfalseOpts agents into rtk output compression, and turns on the Agent output compression panel on the analytics page. Off by default: rtk rewrites the commands an agent runs, which is a behaviour change rather than a correctness fix, and its saving is strongly command-dependent (measured here: ls -la src/lib/aiur 12724 → 1044 bytes; git log --oneline -30 2033 → 2033 bytes, i.e. none). Enabling it is not sufficient on its own — Aiur refuses to admit rtk unless the host's rtk config also carries exclude_commands = ["gh"] under [hooks], because rtk's hook otherwise rewrites gh and gh in an agent workspace is the GitHub quota guard. The refusal is reported on the analytics page and in the daemon log.
agent.routingmap%{}Maps complexity levels to backend/model/effort routing.
agent.switch_model_on_ratelimitarray[]Deprecated claim-time fallback order; ignored when agent.priority is non-empty.
agent.rate_limit_fallbackstringclaudeDeprecated automatic recovery backend for an already-running agent; derived from the first eligible agent.priority entry after the primary when set; "" disables it.
agent.complexity_promptsmap%{}Adds prompt guidance by complexity level.
agent.max_turnsinteger or nilnilPer-issue turn cap; nil is uncapped.
agent.max_retry_attemptsinteger3Failed-turn retry count.
agent.max_retry_backoff_msinteger300000Retry backoff ceiling in milliseconds.
agent.turn_timeout_msinteger3600000Backstop timeout for one turn.
agent.stall_timeout_msinteger3600000Silent-agent watchdog; 0 disables it.
agent.max_agent_duration_minutesinteger60Active-runtime pause checkpoint; 0 disables it.
agent.ci_wait_rewake_minutespositive integer5Re-wakes a CI-wait-paused agent for one recovery check when no terminal event arrives.
agent.max_load_averagefloat1.5Per-scheduler load ceiling. Above it, dispatch holds only when a short-window CPU sample also shows less than 60% reclaimable capacity (idle + niced CPU); null disables it. Until that sample exists — the first dispatch decision after the daemon starts has nothing to compare against — dispatch proceeds, and the next cycle holds if the measured window confirms the contention.
agent.target_load_averagefloat1.0Adaptive per-scheduler load target; null disables the adaptive envelope.
agent.run_queue_thresholdfloat or nilnilPer-scheduler runnable-process ceiling for the instantaneous run-queue dispatch gate; null disables it. When enabled, procs_running above run_queue_threshold × schedulers holds only when the same CPU sample shows less than 60% reclaimable capacity, catching real short bursts without treating niced work as contention (run_queue capacity hold).
agent.load_ramp_stepinteger1Capacity increase while load is below the target.
agent.load_cooldown_secondsinteger60Minimum interval between adaptive capacity reductions.
agent.capacity_starvation_alert_after_secondsinteger60Minimum seconds a ready-work capacity-starvation condition must persist before system.dispatch.capacity_starved / system.fleet.capacity.starved raise. The below-target dispatch ramp clears itself within a few poll cycles, so this dwell keeps the intended ramp quiet while a genuine gate that outlives the bound still raises.
agent.budget_broker_rate_window_secondsinteger300The sliding window over which budget-broker-timeout retries are counted for the retry-rate signal. The individual retry is uninteresting; the rate is the signal.
agent.budget_broker_degraded_retry_thresholdinteger5The retry count within the window above which the budget broker counts as degraded. Set from a measured quiet-period baseline — if the normal rate is zero, almost any sustained rate is worth surfacing — and kept above an isolated timeout, which must page nobody.
agent.budget_broker_degraded_alert_after_secondsinteger600How long the degraded budget-broker retry rate must persist before the single system.github.budget_broker_degraded alert raises (the dwell): a momentary blip that clears within this bound produces nothing, a sustained degradation raises exactly once.
agent.synthetic_load_process_capinteger or nilnilCaps synthetic load processes; 0 disables the guard.
agent.backend_configsmap%{}Provider-specific configuration, including per-backend settings and credentials for OpenAI-compatible backends. A backend listed in agent.priority is enabled automatically.
agent.rate_limit_primarystringdefault backendDeprecated primary backend watched for automatic rate-limit recovery; derived from agent.priority when set.
agent.max_turns_by_complexitymap%{}Per-complexity turn caps.
agent.mix_scheduler_capinteger4Caps schedulers in agent-launched Mix BEAMs.
agent.saturation_log_enabledbooleantrueRecords host and VM diagnostics when sustained load crosses the saturation threshold.

Routes in agent.priority

Each entry is a route, not just a backend name. A route uses the same grammar agent.routing has always used:

<backend>[:<model>[:<effort>]][+remote]
  • claude: the backend's own direct connection, exactly as before.
  • openrouter:anthropic/claude-sonnet-5: that model reached through OpenRouter.

A colon-free entry means what it has always meant, so existing configs need no change.

yaml
agent:
  priority:
    - claude                                # Anthropic direct
    - openrouter:anthropic/claude-sonnet-5  # same model, billed by OpenRouter
    - codex                                 # OpenAI direct
    - openrouter:moonshotai/kimi-k2.7-code  # no direct Moonshot key: OpenRouter only

  backend_configs:
    openrouter:
      provider:
        order: [Anthropic, "Together AI"]
        allow_fallbacks: true
        ignore: [Azure]
        sort: price

  pricing_policy:
    avoid_peak_pricing: true

A model reachable two ways may appear twice, and the order is the fallback order. Duplicate routes are rejected; duplicate backends are not.

Model nameBehavior
Full provider slugCanonical form and cost-reporting key.
Short family aliasResolves to the newest matching concrete slug before the request, so normal pricing applies.
Alias claimed by multiple vendorsRejected during config load.
Aggregator ID beginning with ~Rejected because its target can change during a run.

OpenRouter needs an explicit model. It fronts a catalog rather than a product, so a bare openrouter entry is a config error.

An untagged model never falls back to OpenRouter implicitly. Bare claude means direct-only, always. Routing through OpenRouter is something you write.

What happens when a route fails

CauseBehaviour
No API key configuredThe route is skipped at selection time and the next entry is used. Named once at startup in the log, not per claim. If every entry lacks its key, aiur fails loudly rather than dispatching nothing.
Usage or rate limit (429)Advances to the next entry and records the backend in model-usage.json with its reset time. Self-healing.
Transient error (5xx, timeout, malformed response)Retries, then advances for that claim only, and raises an operator attention. Deliberately not written to model-usage.json: that file means "rate-limited until reset_at", and recording an outage there would make the outage indistinguishable from a quota event.
Auth rejected (401)Does not advance. Hard failure plus an attention. A key that is present and wrong is a config error, and falling through would move spend silently onto another route while the broken credential stayed hidden.

agent.backend_configs.<backend>

KeyTypeDefaultControls
agent.backend_configs.<backend>.enabledbooleanbackend registry defaultExplicitly enables or disables dispatch for the backend. agent.priority takes precedence by enabling every backend it names.
agent.backend_configs.<backend>.commandstringbackend registry commandOverrides the backend command used for model discovery and setup where supported.
agent.backend_configs.<backend>.modelstring or nilnilSelects the backend's default model where the backend accepts a configured model.
agent.backend_configs.<backend>.default_modelstring or nilbackend registry valueOverrides the registry fallback model for an OpenAI-compatible backend; model takes precedence.
agent.backend_configs.<backend>.base_urlURL stringbackend registry valueOverrides the registry endpoint for an OpenAI-compatible backend.
agent.backend_configs.<backend>.api_key_envstringbackend registry valueNames the environment variable containing the backend API key.
agent.backend_configs.<backend>.management_api_key_envstring or nilbackend registry valueNames the environment variable containing a provider's usage-management API key.
agent.backend_configs.<backend>.transportstringbackend registry valueOverrides the OpenAI-compatible transport with chat_completions or responses.
agent.backend_configs.<backend>.balance_baselinenumber or nilnilSeeds prepaid-balance usage tracking for backends that expose a balance API.
agent.backend_configs.<backend>.quirks.reasoning_content_replaybooleanbackend registry valueReplays reasoning content when the backend requires it in later requests.
agent.backend_configs.<backend>.quirks.text_tool_fallbackbooleanbackend registry valueParses text-encoded tool calls when the backend does not return structured calls.
agent.backend_configs.<backend>.quirks.openrouter_metadatabooleanbackend registry valueEnables OpenRouter endpoint metadata used for billing attribution.
agent.backend_configs.<backend>.quirks.local_concurrency_limitbooleanbackend registry valueApplies aiur's local concurrency slot around backend requests.

agent.backend_configs.openrouter

These settings control the OpenRouter transport; selection lives entirely in agent.priority.

KeyTypeDefaultControls
agent.backend_configs.openrouter.provider.orderarray of strings or nilomittedPreferred upstream providers, most preferred first.
agent.backend_configs.openrouter.provider.ignorearray of strings or nilomittedUpstream providers to exclude.
agent.backend_configs.openrouter.provider.allow_fallbacksboolean or nilomittedWhether OpenRouter may cross to another upstream within one request.
agent.backend_configs.openrouter.provider.sortstring or nilomittedprice, throughput, or latency.

Cost attribution

Cost caseAttribution
openrouter:anthropic/claude-sonnet-5Uses OpenRouter's price row because OpenRouter bills the request, even when Anthropic serves it upstream.
Same model through direct and OpenRouter routesKeeps separate identities and may carry different rates.

Local Codex turns use Aiur's shared build admission.

Build-gate behaviorDetail
Admission failureMix does not run and the ticket reports status 125. Repair the reported metadata or lock directory, flock, or python3 dependency, then restart or re-dispatch the agent.
BUILD GATE DEGRADEDStop the old fleet, confirm no old Mix verification remains, then clear only the legacy records named in the message.
BUILD GATE HOLDER / BUILD GATE QUEUEDaiur status names every held lease: slot=, the owning pid, the quoted command, and how long it has been held (or waiting while queued). This tells a correctly-busy gate apart from one pinned by a leaked or dead process. A slot whose command process group is gone renders as held without a command (and its HOLDER line gains (command gone)), so BUILD GATE n/n active never claims work is happening when nothing is.
Hold-timeout backstopA slot held past agent.build_gate_max_hold_seconds (default 1h) is released by the lease holder itself, which logs and leaves a durable slot-N.hold-timeout marker. aiur status prints those as BUILD GATE TIMEOUT lines, and the daemon raises a needs-attention alert naming the command — the same backstop bounds both a leaked holder waiting on reparented daemons and a --trace run that monopolises a slot.
Post-command retainAfter the wrapped command exits, the holder keeps the slot only while a descendant is still consuming CPU (agent.build_gate_retain_seconds, default 120s, is the ceiling for that busy descendant). A descendant tree that goes idle for one second is treated as an adopted session daemon (dbus-daemon, gnome-keyring-daemon), so the slot is released immediately and nothing is signalled — the keyring daemon holds the fleet's GitHub credential. The effective retain is observable in aiur status (retain_seconds=) and in the lease_retained gate log line.
Dead holderA lease whose holder has exited is released automatically: Linux releases the flock with the process, and the PID fallback reclaims a slot whose recorded owner and process group are gone. A legitimately long-running build with a live holder keeps its lease; only the absolute max-hold backstop reaps by elapsed time.
Explicit opt-outSet agent.max_concurrent_builds: 0, set agent.build_start_stagger_seconds: 0, and omit agent.min_free_memory_mb. This removes every build safeguard.

Build admission covers direct mix compile / mix test, mix do compounds using + or legacy comma separators, elixir -S mix, and mise exec / mise x commands after -- or in a simple -c / --command string. One compound or nested wrapper chain holds one live-token lease.

Malformed compounds and command strings that could hide a Mix build fail with status 125. This is a cooperative PATH/shell boundary: aliases of Aiur's wrappers are canonicalized, but deliberately invoking a separate real executable by absolute, relative, or symlinked path bypasses the entrypoint and is not admitted.

Host-pressure fleet admission

Fleet admission uses total host pressure instead of a hard-coded process count, and disabled or unreadable signals fail open.

SignalAdmission behavior
CPU load and adaptive AIMD envelopeagent.max_load_average, agent.target_load_average, agent.load_ramp_step, and agent.load_cooldown_seconds reduce and re-ramp capacity around per-scheduler targets.
Run queueagent.run_queue_threshold reacts to procs_running spikes before the one-minute load average catches up.
CPU corroborationHigh load or runnable counts hold dispatch only when consecutive CPU samples show less than 60% reclaimable capacity; idle and niced CPU count as reclaimable. Without a measurable window there is no hold, so every capacity_hold for load or run_queue carries the reclaimable-CPU measurement behind it.
Memory, file descriptors, build pressure, and provider limitsDefer new dispatch while their configured reserve or limit is exhausted.
RecoveryGates reopen when pressure clears, and AIMD re-ramps within its cooldown window.
Hold signalWhere it appears
Idle rowsbacking off
Dashboard and statuscapacity_hold with the measured signal, threshold, and corroborating reclaimable-CPU measurement
Telemetrycapacity_hold and capacity_resumed
Alert feedDebounced system.fleet.capacity.backoff

Holds limit only new admissions. Running agents and agent-spawned sub-agents continue.

agent.claude

KeyTypeDefaultControls
agent.claude.commandstringaiur-claudeCommand launching the Claude backend.
agent.claude.modelstring or nilnilOptional Claude model override.
agent.claude.permission_modestringbypassPermissionsClaude permission mode.

agent.codex

KeyTypeDefaultControls
agent.codex.commandstringcodex app-serverCommand launching the Codex app server.
agent.codex.approval_policystring or mapuntrustedRuntime policy: untrusted, on-failure, on-request, granular, or never.
agent.codex.thread_sandboxstringworkspace-writeThread sandbox mode.
agent.codex.turn_sandbox_policymap or nilnilExplicit per-turn sandbox policy. For local workspaceWrite, writableRoots contains optional daemon-host extras; every entry must already exist and be writable. Aiur derives the current issue workspace and enabled shared GitHub budget root. Configured extras are not forwarded to SSH workers.
agent.codex.read_timeout_msinteger5000Codex app-server read timeout.
agent.codex.thrash_max_per_windowinteger6Rapid restart limit per window.
agent.codex.thrash_window_secondsinteger60Thrash-counting sliding window.

Model discovery

Aiur ships a curated model list per backend (Aiur.CodingAgent.backends/0). Providers release models faster than that list is edited, so for OpenAI-compatible backends aiur also asks the provider's own catalogue endpoint which models it currently serves, and caches the answer.

Discovery extends the curated list without replacing registry-owned effort vocabularies, capabilities, family aliases, presentation, or aiur init choices, and curated metadata wins when an ID collides.

BackendEndpointCredentialReturns
openrouterGET https://openrouter.ai/api/v1/modelsnone required (sent when OPENROUTER_API_KEY is set, so the request is attributed to your account)identifiers, context window, and pricing
deepseekGET https://api.deepseek.com/modelsDEEPSEEK_API_KEYidentifiers only
kimiGET https://api.moonshot.ai/v1/modelsMOONSHOT_API_KEYidentifiers only

codex and claude are not listed: they answer model/list over their own CLI transport, which aiur init already asks. Anthropic's GET /v1/models (x-api-key plus anthropic-version) has an adapter for operators who point an OpenAI-compatible backend straight at it; it returns identifiers and display names, no pricing.

Cache, TTL, and cold start

PropertyValue
Locationmodel-catalog.json, beside the active workflow config and model-usage.json
TTL24 hours
Refresh triggerLazy and backgrounded; reading the usable model set schedules a refresh only when the cache is older than the TTL.
Cold offline startthe discovered set is empty and aiur uses exactly the curated list, i.e. it behaves as it did before discovery existed
Corrupt cachetreated as absent; falls back to the curated list

Writes are atomic (temp file plus rename) and a concurrent refresh is a no-op rather than a duplicate request.

Config validation never makes a network call. Validation reads the cache and nothing else. An absent or stale cache means "cannot verify", and a model aiur cannot verify is accepted, never rejected.

Identifiers aiur refuses

Two classes of catalogue id are rejected at ingest, with the reason recorded in the cache under rejected:

  • reserved_routing_separator: an id containing :, such as moonshotai/kimi-k2.7-code:batch. Aiur routing values are backend:model:effort, so openrouter:moonshotai/kimi-k2.7-code:batch would parse batch as a reasoning effort. Pin such a variant only if and when aiur gains a way to escape the separator.
  • unstable_identifier_prefix: an id starting with ~, such as ~moonshotai/kimi-latest, which OpenRouter uses for a non-canonical pointer rather than an addressable model.

Pricing is advisory

Pricing ruleBehavior
Fetched OpenRouter priceRecorded in the cache for comparison but never written into the curated price table.
Curated rowAlways wins attribution.
Difference above 5%Logs both numbers as price drift without letting vendor data rewrite reported spend.

A discovered model with no curated price row is usable but visibly unpriced: its usage reports unknown cost with an unknown_price_model coverage reason. It is never costed at zero. A refresh logs how many discovered models are unpriced.

Per-backend opt-out

KeyTypeDefaultControls
agent.backend_configs.<backend>.model_discoverybooleantrueSet false to stop aiur asking this backend's catalogue endpoint. The curated list keeps working.
yaml
agent:
  backend_configs:
    openrouter:
      model_discovery: false

hooks

KeyTypeDefaultControls
hooks.after_createstring or nilnilCommand after workspace creation.
hooks.before_runstring or nilnilCommand before each agent run.
hooks.after_runstring or nilnilCommand after each agent run.
hooks.before_removestring or nilnilCommand before workspace removal.
hooks.timeout_msinteger600000Per-hook timeout; 10 minutes by default.

prewarm

KeyTypeDefaultControls
prewarm.enabledbooleanfalseOpts into one warm base checkout.
prewarm.base_buildstringnoneOne-time base build command.
prewarm.base_build_filestringnoneSibling script loaded into base_build.
prewarm.poll_secondsinteger0Base-refresh interval; 0 disables polling.

poll_seconds: 0 disables periodic refreshes, not dispatch-time freshness checks.

When a prewarm build or freshness probe holds fleet dispatch, an independent idle watchdog releases the gate for cold-clone fallback once the hold has been stalled for 10 minutes.

"Stalled" means the hold's worker process is dead with no completion signal in flight. A build that is still progressing — however slow a cold deps + compile + dialyzer run may be — is never killed by the watchdog.

The system.dispatch.prewarm_blocked alert is not raised for a routine refresh: a freshness probe that self-clears in seconds holds dispatch too briefly to matter to an operator.

The alert fires only once a hold has persisted past the routine bound: a probe that fails or exceeds its own timeout, a build that genuinely holds the fleet, or a stalled hold the watchdog releases. Its .resolved fires when the gate clears.

pr_watch

KeyTypeDefaultControls
pr_watch.enabledbooleanfalseEnables trusted PR comment watching.
pr_watch.watch_labelstringwatchLabel suffix enrolling a PR for watching.
pr_watch.command_prefixstring/aiurOne-off trusted comment command prefix.

pr_health

Periodic scan of open pull requests for conditions that stall PRs silently: a PR authored by a configured human merger (unmergeable by construction, since GitHub blocks self-approval), a non-draft PR older than stale_hours with no review, and a rework ticket whose PR's own contribution has genuinely changed since its blocking review.

Findings raise needs-attention alerts in the Executor's alert feed (system.pr_health.unmergeable_author / system.pr_health.stale_unreviewed / system.pr_health.rework_merge_only).

Enabling the scan enables the rework re-queue: a ticket in agent:rework whose PR's own contribution diff (merge-base..head) changed since the blocking CHANGES_REQUESTED review is moved to agent:human-review for the second look — GitHub keeps reviewDecision = CHANGES_REQUESTED until a brand-new review, so nothing else re-queues it.

A PR whose head only moved via merges of the base branch (own contribution unchanged) is NOT re-queued; it raises system.pr_health.rework_merge_only so the merge-only state is visible distinctly from genuine rework.

A re-queue that the thread-clearance gate refuses (the reworked PR still has unresolved review threads — the normal state of a rework ticket) raises system.pr_health.rework_requeue_failed; the head is not throttled on a failed write, so the re-queue retries on the next tick instead of silently stranding the ticket in rework.

KeyTypeDefaultControls
pr_health.enabledbooleanfalseEnables the PR-health scan and the rework re-queue.
pr_health.interval_secondsinteger1800How often the scan lists open PRs.
pr_health.stale_hoursinteger24A non-draft PR older than this with no review is flagged.

events

KeyTypeDefaultControls
events.block_state_debounce_secondsinteger10Debounces blocked/unblocked transitions.
events.custom_events_per_turn_maxinteger5Caps custom events per turn.
events.codeowners_refresh_secondsinteger3600CODEOWNERS refresh interval.

upgrade

The aiur run upgrade-version notice is optional and opt-out: it caches with a TTL (the registry is contacted at most once a day), fails open and silent when unreachable, never runs under aiurdev, and is channel-aware — a nightly or next user is never offered a lower latest.

KeyTypeDefaultControls
upgrade.check_enabledbooleantrueEnables the aiur run version notice and its registry check. Set false to suppress the check entirely (zero outbound calls).

AIUR_UPGRADE_CHECK_DISABLED (and the legacy AIUR_NO_UPDATE_NOTIFIER) are the environment-variable equivalents; the check also stays silent in CI runs.

alerts

KeyTypeDefaultControls
alerts.enabledbooleantrueMaster alert-sound switch.
alerts.use_os_default_soundsbooleanfalseUses built-in OS sounds by category.
alerts.sound_dirstring path or nilnilDirectory for custom sound files.
alerts.alerts_filestring path or nilbundled alerts fileTopic-to-sound YAML map.

elevenlabs

Both capture clients stream audio to Aiur, and Aiur calls ElevenLabs with the credential below; interactive conversation also streams speech audio back to the browser. This is the only place the credential is configured, and neither the sidecar nor the browser holds it.

This optional section backs Stream Deck voice input, Dashboard dictation, and interactive spoken replies; omitting it uses the defaults below.

KeyTypeDefaultControls
elevenlabs.api_keystring or nilnilElevenLabs credential. Accepts a literal value or a $ELEVENLABS_API_KEY environment reference. Speech input needs Speech to Text permission; spoken replies also need Text to Speech permission.
elevenlabs.language_codestringengISO-639-3 transcription language. ElevenLabs uses eng for English.
elevenlabs.voice_idstring or nilnilStock or owned ElevenLabs voice used for Dashboard interactive conversation replies. Find the identifier in My Voices; Aiur does not clone or manage voices.

ELEVENLABS_API_KEY is the environment variable for the credential. An explicit elevenlabs.api_key value wins; when the key is absent, or is the $ELEVENLABS_API_KEY reference, the variable supplies it. An environment variable set to the empty string resolves to no key.

The key is a secret. Keep it in .env and leave the $ELEVENLABS_API_KEY reference in the config file rather than pasting the value there. Aiur never logs the key, and the daemon scrubs every *_API_KEY variable, ELEVENLABS_API_KEY included, from agent process environments, local and SSH-launched alike, so no coding agent inherits it.

Configuring the key also adds an ElevenLabs meter to the Dashboard Units page, beside the GitHub API meter. It reads the account credit quota and next-invoice amount due from GET /v1/user/subscription; with no key configured the meter is absent entirely. See API meters for what each figure does and does not measure.

observability

KeyTypeDefaultControls
observability.dashboard_enabledbooleantrueReserved compatibility setting; use the launch-time --no-dashboard flag to suppress the listener in foreground or background mode.
observability.dashboard_writablebooleantrueEnables dashboard write paths. A dashboard bound beyond loopback refuses to start without both dashboard basic-auth environment variables; a loopback listener binds without them and fails closed (see below).
observability.refresh_msinteger1000Dashboard data refresh interval.
observability.render_interval_msinteger16Minimum render interval.
observability.telemetry_enabledbooleantrueRecords run telemetry for analytics.
observability.telemetry_retention_max_bytesinteger67108864Maximum retained telemetry bytes.
observability.telemetry_retention_max_age_daysinteger30Maximum retained telemetry age.
observability.telemetry_retention_prune_interval_bytesinteger or nilnilBytes between retention-prune checks.

dashboard_writable is an authorization gate, not an authentication mechanism. Every usable dashboard requires AIUR_DASHBOARD_USERNAME and AIUR_DASHBOARD_PASSWORD.

A loopback listener — writable or read-only — may bind without them, but its authentication plug fails closed and refuses every dashboard request until both credentials are set. A dashboard bound beyond loopback refuses to start without both credentials.

The supervising-Executor Decision API uses the separate AIUR_SUPERVISOR_TOKEN bearer credential. Generate it with openssl rand -base64 32, then put AIUR_SUPERVISOR_TOKEN=<generated-token> in ~/.aiur/.env (global) or the repository .env (project-local).

An exported value wins, followed by the global file and then the repository file. The value must be at least 32 bytes, bearer-safe, and free of surrounding whitespace. A present non-empty invalid value aborts startup; an absent or empty value leaves the API disabled.

decisions

KeyTypeDefaultControls
decisions.supervisor_allowed_kindsarray[]Decision kinds an authenticated supervising Executor may answer. Empty means none.
decisions.supervisor_allow_non_reversiblebooleanfalseAllows supervisor policy to cover partially reversible or irreversible decisions.

These policy keys never grant transport access by themselves. The supervisor API also requires AIUR_SUPERVISOR_TOKEN; mutations require the writable and origin gates described above.

server

KeyTypeDefaultControls
server.portinteger0HTTP port; 0 selects a free OS port.
server.hoststring127.0.0.1HTTP bind address. Set it explicitly to serve the dashboard beyond the machine; there is no automatic Tailscale detection.

When server.host is absent, the dashboard binds 127.0.0.1 (or the AIUR_DEFAULT_DASHBOARD_HOST override). A configured value is never replaced by that default. An explicit --host remains the highest-precedence override.

A fixed server.port that is already bound — for example a second aiur instance on the same host — does not crash the daemon. The second instance logs an explicit startup message naming the port and the conflict, disables only its own dashboard, and keeps running agents.

The durable repository Executor state also records every daemon start and stop in <repo>.control-lifecycle.json, with the invoking process's OS pid, parent pid, and hostname. All runs for that repository share the journal, so a second instance or a crash is identifiable after the fact even when each run has a different log directory.

opencode

KeyTypeDefaultControls
opencode.commandstringopencodeCommand launching opencode.
opencode.bridge_portinteger4097Aiur↔opencode bridge port.
opencode.bridge_hoststring127.0.0.1Aiur↔opencode bridge host.
opencode.serve_argsarray[]Extra opencode serve arguments.
opencode.model_prefixstringaiurPrefix for registered synthetic models.
opencode.prewarm_disabledbooleanfalseDisables opencode session pre-warming.

build_order

KeyTypeDefaultControls
build_order.ticket_detail_freshness_msintegerderived (¼ poll interval, min 5000)Freshness window for ticket detail.
build_order.ticket_detail_max_entriesinteger32Maximum cached ticket-detail entries.
build_order.ticket_detail_max_description_bytesinteger16384Maximum cached ticket-description size.
build_order.ticket_history_limitinteger50Maximum ticket history records per view.
build_order.ticket_history_max_identitiesinteger100Maximum distinct ticket identities retained in history.
build_order.ticket_history_stale_after_msinteger60000Minimum age after which ticket history is stale. It is a floor, not the final window: the effective window is always at least two poll intervals wide, so a value below the poll cadence does not mark correct data stale.
build_order.graph_catalog_refresh_msintegerderived (1× effective poll interval)Base cadence for the Build Order catalog's reads (boot, a viewer's mount, a degraded re-list) and the window after which a selected root is displayed as ageing. The catalog is event-sourced (#2325) and demand-gated (#2312): it is maintained from the resource store's change stream, so this is not a recurring poll, and no page open means no read.
build_order.graph_catalog_labels_refresh_msintegerderived (5× effective poll interval, min 600000)Cadence for the labelled catalog read that resolves epic and wave counts on a boot/mount/degraded read; the event-sourced catalog resolves those counts from the store instead.
build_order.graph_refresh_timeout_msinteger30000Maximum graph-refresh request duration.
build_order.graph_max_selected_rootsinteger32Maximum selected Build Order roots.
build_order.graph_max_inflightinteger4Maximum concurrent graph refreshes.

Two removed keys

build_order.graph_selected_refresh_ms and build_order.graph_demand_refresh_ms no longer exist. They were the two settings by which viewing bought GitHub reads: the demand cadence fired when an operator selected a root, and the selected cadence repeated for as long as the page stayed open.

No value makes that correct, because it makes API cost track how many people are looking rather than what has changed. They were removed rather than retuned.

A selected root is now read by the daemon's own catalog reconciliation, and by nothing else. Each catalog update carries a per-root change marker — the root's identity, member count and update time, plus a digest of its members' states — and a watched root whose marker moved is re-read once, as is a watched root that has never been read.

Selecting a root and holding it open consume zero GitHub reads.

The catalog itself is different: it is the most expensive single query in the system, and since #2312 it is demand-gated on an open Build Order page. Opening /build-orders renders the stored snapshot immediately (with its age), then buys one refresh on mount.

While any Build Order page stays open the catalog reconciles on the cadence below; closing the last page stops it entirely, so a headless run — the normal case — buys none of it.

Aiur.BuildOrder.GraphProjection.refresh/2 is the explicit "read this now" path. It exists so that removing the viewer cadence does not also remove an operator's ability to demand a read, but nothing calls it yet — an operator-facing refresh control is its intended consumer.

A configuration that still sets either key keeps loading unchanged: unknown keys are ignored rather than rejected, so an upgrade gets the new behaviour instead of a boot failure.

Derived Build Order cadences

Three of these keys have no fixed default. They are derived from the poll interval, and setting any of them explicitly overrides the derivation.

Build Order displays state that the tracker produces, so it cannot be fresher than the tracker's own cycle. Refreshing faster only re-reads a graph that cannot have moved.

The previous fixed defaults were chosen when the tracker polled every 5 seconds, and did not move when the tracker changed to 120 seconds. Deriving them is what stops that recurring.

Since #2325 the Build Order catalog is event-sourced: it is maintained from Aiur.GitHub.ResourceStore change events, so there is no recurring catalog poll at all — a root's membership and a blocked-by edge reach the page the moment the delivery deposits them.

What remains on a cadence is the boot fill (one GraphQL read per daemon start), the degraded re-read, and the selected-root reads those changes trigger; the two graph keys below size those and the staleness window that follows, and they follow the effective interval: the one the daemon actually scheduled.

  • It is not polling.interval_seconds alone. It includes polling.idle_widen_factor and webhooks.poll_widen_factor.
  • It is the value aiur status reports as interval=.
  • So an idle fleet widens the catalog's reads and the staleness window exactly as it widens the tracker, and a fleet that picks up work narrows both back together.
  • The widening matters only while a page is open: the catalog is event-sourced (#2325) and demand-gated (#2312), so with no Build Order page open it neither polls nor reads — it costs nothing rather than merely running slowly.

ticket_detail_freshness_ms follows the base interval instead. It is a staleness window for the ticket-detail drawer, read once when the daemon starts and never re-derived, so tying it to a cadence that moves would freeze it at whatever the cadence was at boot.

Each derivation, and the values it produces at a 120s base interval:

KeyDerivationBusy fleetIdle, polling repoIdle, webhook-backed
graph_catalog_refresh_ms1× effective interval, ceiling 36000001200006000001200000
graph_catalog_labels_refresh_ms5× effective interval, floor 600000, ceiling 3600000, never below graph_catalog_refresh_ms60000030000003600000
ticket_detail_freshness_ms¼ base interval, floor 5000, ceiling 300000300003000030000

The effective interval at idle is 600s for a repository Aiur polls, and 1200s once that repository is a proven webhook source (webhooks.poll_widen_factor multiplies again). The labelled catalog read reaches its 3600000 ceiling in that last column.

graph_catalog_labels_refresh_ms covers the 26-point labelled variant used by the boot fill and a degraded re-read, so it is the slowest of the three, and it can never fall below the catalog cadence it rides on — a labels read that outran the catalog read would make every boot or degraded re-read buy the expensive query.

ticket_detail_freshness_ms is not a cadence: nothing fires on it. It is the staleness a ticket-detail reader accepts from the shared store before revalidating, and it is allowed to be tighter than the graph cadences because it is the only one of the three backed by a REST read — so the only one whose refresh can be a free 304 rather than a paid query.

What these cadences cost

GitHub's GraphQL API sends no ETag, no Last-Modified and no Cache-Control, and every query is a POST. There is no conditional request to make, so no GraphQL read below can ever return 304, however it is written.

What that leaves is how often a query runs, which is where almost all of the cost is.

GitHub's point cost is round(connection_requests / 100), with a minimum of one point. Anything below roughly 150 connection requests therefore costs exactly one point, however much it asks for.

Size is not free above that threshold, but the lever is small: measured, a 54-member Build Order root costs 3 points at the shipped 100-per-page and 2 at 54-per-page. Running a query less often is worth far more than making it leaner.

Measured against aiur-team/aiur with GitHub's own rateLimit { cost }:

ReadProtocolCostRevalidation
AiurBuildOrderCatalog (cheap)GraphQL1 point/pageNot possible
AiurBuildOrderCatalog (labelled)GraphQL26 points/pageNot possible
AiurBuildOrderSelectedRoot (54 members)GraphQL3 points/page, per selected rootNot possible
AiurLinkedPullRequestsGraphQL1 pointNot possible
GET /repos/{owner}/{repo}/issues/{number}REST1 REST request304, which costs no primary rate limit

Resolution & validation notes

  • An unset or blank prompt_file falls back to the built-in default prompt; a configured unreadable path fails startup.
  • A legacy top-level linear: section is merged into tracker.linear.
  • Only $VAR environment references resolve; legacy env:NAME values remain literal.
  • polling.interval_ms is rejected by the loader; use interval_seconds.

Command macro, delegate micro, maximize APM.