AFK spawn defaults diverge from the manual pre-fill, and provider options ride a generic bag beside typed model/effort¶
ADR-0008 built the spawn path around two typed knobs — model and effort —
layered by D12d (request → repo default → global default) and drawn from the
provider's own catalogs (D14). That shape carried one silent assumption: a
single default set serves every spawn. It doesn't. A manual instance has an
operator in front of the Start form, so its defaults are only a pre-fill — a
soft starting point the operator overrides per spawn. An AFK run has no
operator: its defaults are hard, literally what runs unattended. Wanting an
AFK fleet to run, say, a cheaper model or ultracode while manual spawns keep
opening on opus/max is simply not expressible when both draw the same numbers.
The second gap is extensibility. model/effort are the only spawn knobs the
interface knows, and they are typed end to end. Claude Code grew a new
spawn-time knob — ultracode — that lab must be able to set for AFK runs, and
Codex/Gemini (#2) will each bring their own. Threading every such knob as
another typed field — another column, another API param, another positional
argument to SpawnArgv — churns the whole interface every time a provider grows
an option.
This ADR evolves ADR-0008 (the AgentProvider seam) and brief decisions
D12d (spawn model/effort layering) and D14 (provider-owned catalogs),
and relates to #2 (OpenAI Codex — the first external consumer of the generic
bag). The decisions, pinned (settled with the operator 2026-07-07 — do not
re-litigate):
- A hybrid, not a full generalization. model/effort stay first-class and
typed across the whole interface — run row, repo columns, global settings,
providers API, and UI (D14 unchanged). Beside them sits exactly one generic
bag: the provider declares its option schema (
SpawnOptions() []OptionSpec), lab stores, validates, and renders the bag generically, and the provider applies it.ultracodeis claude-code's first and currently only entry. This is the extensible seam that makes "the interface works for all providers" real: when Codex/Gemini land, each declares its own options and applies them behind the same seam — zero refactor of the typed core. - The manual/AFK split, layered as base + AFK-override. The existing globals
spawn_model_default/spawn_effort_defaultand the repo columnsmodel_default/effort_defaultkeep their meaning as the base — the manual pre-fill, and what an AFK run inherits when its own override is empty. New optional AFK-override slots sit beside them at both scopes. Manual resolution is unchanged (request → repo base → global base); AFK resolution becomes repo.afk ?? global.afk ?? repo.base ?? global.base. Non-breaking: an operator who sets no override sees today's behavior exactly. - ultracode is AFK-only, defaults-only, and applied as provider-owned prompt
injection. It is a boolean spawn option applied by prepending a
provider-owned directive line to a non-empty
InitialPrompt. A manual instance passes an empty prompt, so every prompt-scoped option is a natural no-op — a manual operator who wants ultracode simply types the keyword. There is no per-run override:POST /repos/{id}/afk/startstays bodyless. Verified against Claude Code's docs, changelog, and--help: ultracode is a prompt trigger keyword (renamed from "workflow" in v2.1.186), not a CLI flag, env var, orsettings.jsonfield — which is exactly why application is prompt injection and not argv. The directive text is provider-owned and freely tunable; it is not a new pinned Claude coupling. - The
SpawnSpecseam. The positionalSpawnArgv(session, model, effort, prompt)is replaced bySpawnArgv(SpawnSpec{SessionName, Model, Effort, Options, InitialPrompt}), so a future option never churns the signature. Application is provider-owned and not argv: claude-code readsOptions["ultracode"]and rewrites its own prompt; the generic core never learns what any option means.internal/afkstays provider-agnostic — ultracode must not leak into the afk package (unlike incogni, which is a lab-domain repo flag lab owns and acts on). - Validation mirrors model/effort. The bag validates against the resolving repo's provider schema: an unknown option key or a bad value is a 400, the same discipline D12d applies to an unknown model/effort. An empty AFK model/effort is explicitly allowed (it means inherit). A global bag may span providers once more than one exists; at spawn it is filtered to the repo's provider before it is applied.
- Storage, one migration. Global settings gain rows
spawn_model_default_afk,spawn_effort_default_afk(empty = inherit), andspawn_options_afk(JSON, e.g.{"ultracode":"true"}). Repos gain nullable columnsafk_model_default,afk_effort_default,afk_options(JSON). One migration (0004), mirrored across sqlite and postgres. Deferred: noruns.optionshistory column and no run-row options persistence — re-adoption re-adopts a live tmux session, never re-spawns, so nothing needs the bag after launch; bool is the only option type (enum/string reserved). - Resolver.
ResolveModelEffortbecomes run-kind-aware; a newResolveSpawnOptions(ctx, prov, repo, kind)returns the empty map for manual andrepo.afk_options ?? global.spawn_options_afk(filtered + validated) for AFK. Both feed a newLaunchSpec.Options→SpawnSpec.Options. - Schema-driven UI.
GET /api/v1/providersgrows anoptionsfield (the declared schema). Global Settings and Repo Settings render an "AFK defaults" section from it — model/effort selects with an explicit inherit entry, and a bool option (ultracode) as a checkbox. A future provider's options render automatically. The manualStartInstanceFormis unchanged.
Status¶
Accepted. Evolves ADR-0008 (the AgentProvider seam) and brief decisions
D12d/D14; settled with the operator 2026-07-07 (issue #19). model/effort stay
typed and first-class; the generic options bag is additive beside them. One
schema migration (0004) adds the AFK-override slots and the options bag at both
scopes, sqlite + postgres. Non-breaking: an operator who sets no AFK override or
option keeps today's manual-and-AFK-share-one-default behavior. Unblocks #2
(Codex): a new provider declares its own SpawnOptions() and applies them
behind SpawnSpec, with zero refactor of the typed core. ultracode is
claude-code's first entry and is provider-owned — not a pinned compat coupling.
Considered options¶
- Fully generalize model/effort into the bag too. Rejected: it discards the typed, first-class handling model/effort earn across the whole interface (validated catalogs, typed run-row columns, dedicated selects) and churns every layer to reach a knob that never needed generalizing. The hybrid keeps the two proven knobs typed and adds one bag beside them for everything else.
- A per-run AFK override form (or making ultracode a per-run flag). Rejected:
AFK is defaults-only by design —
POST /repos/{id}/afk/startis bodyless, and an unattended run has no one to fill a form. Options belong to the repo/global defaults that AFK resolves at launch, not to a per-spawn request. A manual user who wants ultracode types the keyword themselves. - Treat ultracode as a CLI flag or a
settings.jsonfield. Rejected on the evidence: ultracode is a prompt trigger keyword in Claude Code (renamed from "workflow" in v2.1.186), verified against its docs, changelog, and--help. There is no flag or settings key to set, so the only faithful application is prepending the keyword to the prompt — which also makes it a natural no-op for the promptless manual spawn. - Keep the positional
SpawnArgvand add a fifth positional per option. Rejected: it churns the interface signature — and every provider double and call site — each time any provider grows an option.SpawnSpecabsorbs a new option in a struct field the generic core never has to understand. - Let ultracode reach the provider through the
afkpackage (like incogni). Rejected: incogni is a lab-domain repo flag with cross-cutting behavior lab owns and enacts; ultracode is opaque provider option data. Routing it as a genericOptionsbag the provider alone interprets keepsinternal/afkprovider-agnostic and prevents a claude-specific keyword from leaking into the AFK engine.
Consequences¶
internal/providergainsOptionSpec, theSpawnOptions() []OptionSpeccatalog method,OptionTypeBool, and theSpawnSpecstruct;SpawnArgvtakes aSpawnSpecinstead of four positionals. claude-code declares the singleultracodebool and applies it by rewriting a non-emptyInitialPrompt; theprovidertestfakes gain a scriptable schema.internal/storeadds three global settings rows and three nullable repo columns in migration 0004 (sqlite + postgres). Norunschange — resolved options are not persisted, because re-adoption never re-spawns.internal/instancemakesResolveModelEffortrun-kind-aware and addsResolveSpawnOptions; both feed a newLaunchSpec.Options.internal/afkstays provider-agnostic — it carries the bag through, never reads it.GET /api/v1/providersgains theoptionsschema; Global + Repo Settings render an "AFK defaults" section (model/effort selects with an explicit inherit entry, ultracode as a checkbox) generically from it. The manual Start form is untouched.- Adding a provider option is now additive: declare it in
SpawnOptions(), apply it in the provider, and the store bag, validation, and Settings UI carry it with no typed-core change. Codex (#2) is the first external consumer.