Userland-first agent defaults: birth batches as data, prompts out of platform code
Context#
When an agent is created on the iterate platform, the platform assembles its birth batch — an atomic set of events appended to the new agent's stream that define what the agent is: an existence marker, the system prompt, model configuration, standing context, and the subscriptions that attach its processors.
Today, almost everything in that batch is hardcoded in apps/os/src/domains/agents/agent-defaults.ts:
- A ~200-line
DEFAULT_AGENT_SYSTEM_PROMPTstring constant, plus separate prompt variants for Slack, Telegram, and email agents. - Five manually-bumped revision constants (
DEFAULT_AGENT_SYSTEM_PROMPT_REVISION = "9"etc.) that must be incremented whenever prompt content changes, or idempotency keys silently collide. - Model selection, a boot-context blob, workspace instructions — all string constants.
Changing any of it requires a platform deploy. Meanwhile the platform also ships a default config-repo template (configs/default/, codegen'd into the OS worker as PROJECT_REPO_INITIAL_FILES) — so "what a default agent is" effectively lives in two places: once as code, once as data, and they must be kept in agreement by hand.
The codemode experiment (#2423) pushed against this: it needed a project to choose its agents' prompt, response format, and driving processor from userland. The mechanism that made that possible is a project-stream event (project/agent-birth-defaults-configured) that the agent-creation door reads and folds into every birth batch. But its current payload is a narrow, hand-picked set of fields — an agent config patch, a system prompt, a list of extra processor subscriptions — each with its own schema entry and its own special-cased plumbing in the birth-batch builder. Every future thing a project might want to default (a different boot context, a standing capability, another context item) would mean another field and another builder branch. That's the growth pattern this refactor kills.
The principle: the event vocabulary is the platform; everything opinion-shaped is data a project owns. The platform ships mechanism — processors, the capability host, event schemas, the creation door, safety rails. Prompts, model choices, formats, and which processor drives an agent are files in a config repo. The "default experience" is nothing more special than the default template. Precedence collapses to one sentence: explicit call-site events > project birth events > embedded-template birth events.
The refactor#
1. Birth defaults become "a list of birth events", not a field bag#
Replace the current defaults payload with the general form:
// project/agent-birth-defaults-configured — appended by the project's config
// worker to the project root stream; latest occurrence wins per `matches` key
{
matches?: { pathPrefix?: string }, // default: agents born through the generic door
birthEvents: Array<{ type: string; payload: unknown }>,
}A prompt is then just a keyed agents/context-added. A driver choice is just an agent/configured. A processor attachment is just a stream/subscription-configured. Nothing needs per-field plumbing, and the next defaultable thing needs zero platform changes.
Guard rails, since this is userland data reaching a platform-assembled batch:
- Validated at fold time in the project processor: every event must parse against the agent-consumed vocabulary (
AgentProcessorContract.parseConsumedInput), except an allowlist of platform-lane items —stream/subscription-configuredwith afacet-processorreceiver whose name is a registered agent-family slug. Malformed defaults fold to nothing plus a warning; agent creation is never breakable from userland. - Idempotency keys are platform-minted (
agent/birth-defaults:<contentHash>:<index>:<projectId>:<agentPath>): content rides the key — the established pattern for replay-safe supersession — so projects can't wedge creates with hand-rolled keys. - Stored as a fold, not a scan: the project processor's contract gains the consumed event and a
state.agentBirthDefaultsslot; the creation door reads the processor snapshot. (The current implementation pages the raw stream for the latest event of the type — delete that.)
The codemode template is the only current producer; migrate its #publishAgentBirthDefaults to the list form in the same PR. No compatibility shim — the old shape is an unreleased experiment surface.
2. The default prompt gets ONE home: the template#
Move the text of DEFAULT_AGENT_SYSTEM_PROMPT to configs/default/prompts/agent-system-prompt.md, and delete the constant plus its revision. Two consumers, one source:
- Build-time fallback: the platform reads the file out of
PROJECT_REPO_INITIAL_FILES— the exact mechanismONBOARDING.mdalready uses to feed the onboarding prompt (agent-defaults.ts:292,409). Occurrence identity becomes a content hash, retiring the manual revision constants. - Runtime publishing:
configs/default/worker.tspublishes the prompt as a birth event onproject/worker-updated, the same way the codemode template does. Editing the prompt file in a live project's config repo therefore updates that project's future agents with a git commit and zero platform involvement — while fresh projects and the bootstrap window get identical text from the embedded fallback.
Channel prompts follow the same road: configs/default/prompts/{slack,telegram,email}.md, with slackAgentSystemPrompt and friends becoming thin interpolators (they inject per-connection facts into template text). Routers keep passing explicit systemPromptPolicy, so their precedence over project defaults is unchanged.
Two facts (verified) make this safe:
- The blocking project-create lane probes the config worker for readiness before committing
project/created, and the firstproject/worker-updatedlands in the same batch — so a project's own defaults exist one delivery-hop after creation. The embedded fallback covers that hop (the UI's fast path can drive the onboarding agent inside it). - The turn loop already holds turns until the canonical prompt slot exists (
agent-turn-loop.ts:226-243, indefinite). With a fallback prompt always present in the birth batch, that gate never holds in practice, and it needs no change.
The prompt-budget test moves to reading the template file — the ceiling still gates the default experience. Userland prompts instead get a best-effort publish-time token-count warning computed by the template worker on commit.
Deliberately left alone#
- Boot context stays platform-side: it's per-agent facts (paths, project id) requiring interpolation, not opinion. Splitting its embedded advice-strings out is a later refinement.
- MCP/onboarding prompts: already direct context appends sourcing template content; no change.
- Per-channel project overrides (
matches.channel),config-repo reset --template <ref>, and retiringsystemPromptPolicyfrom routers: follow-ups once this settles.
Sequencing: builds on #2423's defaults event and headless processor — a follow-up PR after #2423 merges (or stacked on it if review stalls).
Critical files#
apps/os/src/domains/agents/agent-defaults.ts— schema swap, builder collapse, prompt-constant deletion, fallback-from-templateapps/os/src/domains/projects/project-processor-contract.ts+project-processor-implementation.ts— the defaults foldapps/os/src/rpc-targets.ts— snapshot read replaces the raw-event scanconfigs/default/prompts/*(new) +configs/default/worker.ts— publish defaults;configs/codemode-tag/worker.ts— migrate to the list formapps/os/src/domains/repos/config-repo-template.codegen.cjs— no change (already walksconfigs/default/); regenerate the checked-in outputapps/os/src/domains/integrations/{slack,telegram}-processor-implementation.ts,apps/os/src/domains/email/email-processor-implementation.ts— prompts become template-backed interpolators
Verification#
- Unit: birth-list folding, allowlist rejection, prompt supersession, and key determinism in
agent-defaults.test.ts; defaults-fold specs on the project processor; the prompt-budget test against the template file. - Acceptance bar: with no project defaults present, birth batches are byte-identical to today's —
agent-processor.test.tsandagent-headless-processor.test.tspass unmodified. - Live on a preview slot: create a default-template project → first turn identical to today; edit the project's prompt file and commit → new agents carry it at birth, no deploy; re-run the codemode end-to-end smoke on the migrated list form.
Risks#
- The fold-time allowlist is the security-relevant surface (userland must not smuggle events outside the agent vocabulary into a platform-assembled batch); the parse + explicit subscription allowlist is the whole defense — test it directly.
- Deleting a project's prompt file degrades to the embedded fallback, never to promptless (the codemode template already implements this rule).
- The embedded fallback and a project's live file can drift — by design (projects fork on purpose); the codegen lint keeps the embedded copy itself honest.
Status (skim me)#
Done — implemented and live-verified on preview-15. Both halves landed: (1) birth defaults generalized to a validated birth-events list (fold + snapshot read + codemode-tag migration), (2) all prompt text moved to configs/default/prompts/*.md with content-hash revisions — the default worker publishes its prompt file as birth defaults at runtime, byte-identical outputs verified against the old constants.
Checklist#
- project processor: consume
project/agent-birth-defaults-configured(list form), fold intostate.agentBirthDefaultswith parse/allowlist validation (contract event +state.agentBirthDefaults; reduce validates viavalidateAgentBirthEvents, malformed latest folds to null; spec in project-processor.test.ts) -
agent-defaults.ts:AgentBirthDefaults→ birthEvents list; builder collapses to "append the validated list"; platform-minted content-hash keys (agent/birth-defaults:<djb2>:<index>:<projectId>:<agentPath>; prompt-slot events replace the platform fallback slot; explicit systemPromptPolicy drops them;Defaultsgeneric keeps routersatisfies EmittedInput<...>exact) -
rpc-targets.ts: read defaults from the project processor snapshot; delete the raw-event scan (agentBirthDefaultsForProjectreadsfacetProcessorRelay(project).snapshot(), appliesmatches.pathPrefixagainst the agent path) -
configs/codemode-tag/worker.ts: publish list-form defaults (same content-hash key; three birth events: driver config, prompt slot, headless subscription) -
configs/default/prompts/agent-system-prompt.md: prompt text moves;DEFAULT_AGENT_SYSTEM_PROMPT/revision constants deleted; fallback readsPROJECT_REPO_INITIAL_FILES; hash identity (constant is nowembeddedTemplateFile("prompts/agent-system-prompt.md"); all revisions arecontentHash(...)— the manual bump comments are gone) -
configs/default/worker.ts: publish prompt birth default on worker-updated (#publishAgentBirthDefaults: newline-stripped file content as one prompt-slot birth event, content-hash key; deleted file publishes an EMPTY list → embedded fallback; pinned in config-repo-template.test.ts) - channel prompts →
configs/default/prompts/{slack,telegram,email}.md+ thin interpolators ({{postMessage}},{{telegramConnection}}/{{agentPathJson}}/{{chatIdNote}},{{agentSummaryInstruction}}; exported names/signatures unchanged so routers and tests didn't move; interpolation throws on unfilled placeholders) - prompt-budget test reads the template file; template worker adds publish-time token warning (budget test unchanged by design — the constants it reads are now file-sourced; worker warns at ~6k tokens)
- acceptance: no-defaults birth batches byte-identical (
agent-processor.test.ts,agent-headless-processor.test.tsunmodified) (all 7 prompt outputs byte-compared old-vs-new: identical; full apps/os unit lane green — 263 files, 2730 tests) - live verification on a preview slot (preview-15, projects uad-lab1 + uad-cmtag1: default worker published prompt defaults at project birth; agent born with prompt under
agent/birth-defaults:*key and NO platform fallback slot; prompt-file commit → superseding defaults event → next agent born with the edited prompt, zero deploys; codemode template published the 3-event list form, agent born driver=agent-headless with codemode prompt + subscription, full turn interpreted →web-message-sent: "LIST-FORM-SMOKE-OK")
Implementation log#
- (start) worktree
../worktrees/iterate/userland-agent-defaults, stacked on codemode-tag-format @ 1baac37f5. - Chunk 1 (c559e40dc): birth-events list, project-processor fold, snapshot read, codemode-tag migration.
- Chunk 2: prompts → configs/default/prompts/*.md. Old constants deleted from agent-defaults.ts; interpolators keep the exported names (slackAgentSystemPrompt etc.) so router callsites and channel tests are untouched. Revisions = djb2 content hashes (prompt-slot idempotency keys change once — equivalent to a revision bump; existing agents unaffected, birth-only events). MCP/onboarding composite
1.<rev>scheme retired: hashing the composed prompt covers constituents automatically. Byte-compared all 7 prompt outputs (default, email, slack, telegram ×2, mcp, onboarding) against HEAD's constants: identical. - CI caught a formatting drift: oxfmt reformats markdown (incl. embedded ts fences); prompts are now in .oxfmtrc.json ignorePatterns and restored byte-identical (8d7c1d06e).
- Live verification on preview-15 (2026-08-11): uad-lab1 (default template) and uad-cmtag1 (codemode-tag via
github:iterate/iterate#userland-agent-defaults&path:configs/codemode-tag). All checks passed — see checklist.