Orion treats agents as first-class entities — each lives as a folder with AGENTS.md + config.yaml + optional SOUL.md/MEMORY.md. The backend (loader, reconcile, IPC, migration, D9 filter) is production-quality. The user-facing CRUD UI is not finished.
Honest state of completeness: Today you can browse the 34 bundled agents and toggle them on/off (via a legacy disabled-list manifest). You cannot create, edit, archive, or delete agents through the UI. The IPCs exist and are tested — they just have no callers in src/. Specifically: the ”+ New Agent” button has no onClick handler, CapabilityDetail.tsx makes zero CRUD calls, and useAgents() (the canonical runtime read hook) has zero consumers in the codebase. See Honest gaps below.

Agent system at a glance

Folder layout

<dir>/<slug>/
  AGENTS.md       # required — system prompt body, plain markdown
  config.yaml     # required — typed config
  SOUL.md         # optional — persona/voice (top-level chat only, dropped for subagents)
  MEMORY.md       # optional — static seed memory v1 (top-level chat only, dropped for subagents)
readAgentWorkspace(dir) returns null if AGENTS.md is missing OR config.yaml is missing OR config.yaml lacks name + description (loader.mjs:217-240). Subdirectories that fail this check are silently skipped. Slug validation: ^[a-z0-9_-]{1,64}$, no leading dot, no path separators (agents.rs:258-281).

config.yaml fields

FieldRequiredTypeNotes
nameyesstringnon-empty after trim
descriptionyesstringnon-empty after trim
avatar_emoji / avatarEmojinostringsnake + camelCase accepted
providernostringdefault 'claude_sdk'
modelnostringe.g. claude-sonnet-4-6
toolsnostring[] or csvnull = inherit all
colornostringUI hint
thinkingnoenumoff/minimal/low/medium/high/xhigh
fallback_modelsnostring[]provider fallback chain
outputnostringdefault output file
default_readsnostring[]files to pre-read
default_progressnoboolprogress event default
max_subagent_depthnointrecursive nesting cap
is_archived is NOT a config.yaml field — it lives only in the runtime DB. See Three representations below.

Real example: prompts/agents/general/

name: general
description: General-purpose assistant for tasks that don't match a specialized agent
tools: Read, Write, Edit, Bash, Grep, Glob
thinking: medium
fallbackModels:
  - gemini-2.5-flash

Two-layer override (D4)

LayerPathMutabilityProvenance label
Built-inprompts/agents/<slug>/Read-only; refreshed by app upgrades; is_builtin_source_path() blocks CRUD disk writessourceLayer: 'builtin'
Vault<vault>/agents/<slug>/User-editable; travels with vault syncsourceLayer: 'vault'
Resolution (loader.mjs:470-487):
  1. Scan builtinDir → seed Map with bySlug.set(slug, {…, sourceLayer:'builtin'})
  2. Scan vaultDiroverwrite with bySlug.set(slug, {…, sourceLayer:'vault'})
  3. Sort + return
Whole-record replacement — when a slug exists in both layers, the entire AgentWorkspace (including AGENTS.md/config.yaml/SOUL.md/MEMORY.md) comes from the vault. No file-level merge across layers. ✓ VERIFIED. CRUD guard for built-ins (agents.rs:611-616) — update_agent rejects disk writes for built-ins:
“Cannot modify built-in agent’s content. To customize a built-in agent, create a vault entry with the same slug.”
delete_agent enforces the same. Runtime patches (is_archived, custom_env, …) ARE allowed for built-ins because they live only in the DB.

Three representations

RepresentationTruth forReadWrite
Filesystem folderAUTHORITATIVE — body, slug, every config.yaml fieldresolveAgentLayered({builtinDir, vaultDir})Edit config.yaml atomically via agents.rs::atomic_write
Build-time TS registryStatic name / slug / description / tools — SYNC cold-loadimport { AGENT_REGISTRY } from '@/lib/agents/generated-agent-registry'node scripts/generate-agent-registry.mjs (NEVER hand-edit)
Runtime DB indexis_archived, custom_env, custom_args, mcp_config, max_concurrent_runs + cached provider/model/name/descriptionlist_agents IPC → useAgents() hookupdate_agent / archive_agent IPC
Reconcile preserves runtime fields (initLoader.mjs:128-136):
ON CONFLICT(id) DO UPDATE SET
  name = excluded.name,
  slug = excluded.slug,
  description = excluded.description,
  avatar_emoji = excluded.avatar_emoji,
  source_path = excluded.source_path,
  provider = excluded.provider,
  model = excluded.model
  -- DELIBERATELY OMITS: is_archived, custom_env, custom_args, mcp_config, max_concurrent_runs

D9 subagent body filter — enforcement across paths

Dispatch pathFile:lineStatus
Claude SDK Agent/Taskclaude-agents-map.mjs:102assembleAgentBody(ws, {isSubagent:true}) applied unconditionally to every entry in query({agents:{…}}) map
Pi spawn_subagentpi-subagent-runner.mjs:934getAgentDefForSubagent(agentName, options.agentsDir)
Pi agent_info (discovery)pi-tools.mjs:1421-1424✓ POST VAL-009 fix (commit d2a26640, 2026-05-05) — was leaking SOUL+MEMORY before
Top-level chat (agentSlug)loader.mjs:516-546resolveTopLevelAgentBodyisSubagent:false (full body)
Hard-coded invariant in loader.mjs:303-307:
const SUBAGENT_BOOTSTRAP_ALLOWLIST = new Set(['AGENTS.md']);
Any new dispatch path MUST route through assembleAgentBody({isSubagent:true}) OR leak SOUL+MEMORY to downstream models.
VAL-009 sub-rule: Any tool whose return value flows back into another model’s context (e.g. agent_info) MUST also use getAgentDefForSubagent — not getAgentDef. The contract applies to the exposure surface, not just the dispatch surface. Verified leak path was fixed at commit d2a26640.

Migration paths

scripts/migrate-bundled-agents.mjs converts prompts/agents/<slug>.mdprompts/agents/<slug>/{AGENTS.md, config.yaml}. Archives originals to prompts/agents/.legacy/<slug>.md.
  • Idempotent: re-running is no-op when folder + AGENTS.md + config.yaml exist; --force overwrites
  • Flags: --dry-run, --verbose, --force, --dir <path>
  • Wired into prebuild chain
  • Rollback: mv prompts/agents/.legacy/* prompts/agents/ then delete the empty folder dirs
  • ✓ COMPLETE: 34 agent folders present at prompts/agents/, no live .md files

CRUD round-trip flow

Persistence guarantees (✓ VERIFIED):
  1. Content fields round-trip through config.yaml. Even if reconcile runs, disk YAML is authoritative for name/description/model/tools/....
  2. Runtime fields stay in the DB — ON CONFLICT(id) DO UPDATE SET omits is_archived, max_concurrent_runs, and custom_env / custom_args / mcp_config are never written by reconcile.
  3. Atomic writes: tempfile::NamedTempFile::new_in(parent) + persist(target) — rename(2) POSIX, MoveFileEx Windows. Crash mid-write leaves previous file intact.
  4. Delete safety: rusqlite tx with fs::remove_dir_all AFTER DELETE but BEFORE commit. FS failure drops tx → ROLLBACK → DB row preserved.
  5. Archive cache invalidation: every CRUD broadcast triggers invalidateArchiveCache() BEFORE reconcile, so subagent spawns see fresh archive flags within sub-second of mutation.

What works today

✓ Browse 34 bundled agents

AgentsManager.tsx renders the bundled agents from AGENT_REGISTRY (build-time static).

✓ Search + filter

Text search across name/description, filter chips for All / Enabled / Disabled.

✓ Toggle enabled/disabled

toggleAgent(slug, enabled) via the agent-manifest path (NOT is_archived).

✓ Click-through to detail

Routes to agent-detail page → CapabilityDetail (read-only).

✓ Bundled agent migration on boot

Legacy .md files in vault auto-convert to folder layout, with backup.

✓ Backend CRUD IPCs tested

25+ unit tests in agents.rs covering slug validation, config.yaml round-trip, atomic write, runtime patches, archive toggling, built-in protection.

Honest gaps

This is the user-facing story today. The IPCs exist; the UI doesn’t call them.
src/lib/ipc/agents.ts exports createAgent, updateAgent, archiveAgent, deleteAgent with full TypeScript types. grep -rn 'createAgent|updateAgent|archiveAgent|deleteAgent' src/ returns only the export site, zero call sites.A new component (modal, drawer, or full editor page) must be built that:
  • Submits to createAgent from the ”+ New Agent” button
  • Submits to updateAgent from a future edit form in CapabilityDetail
  • Calls archiveAgent from a confirm dialog
  • Calls deleteAgent from a destructive confirm
resourceConfigStore.ts:133-141 builds managedAgents from AGENT_REGISTRY ONLY. Vault-created agents would never appear because the registry is regenerated only from prompts/agents/<slug>/config.yaml at build time.Fix: replace the AGENT_REGISTRY.map(...) block with await listAgents() OR migrate the component to call useAgents() directly.
AgentsManager.tsx:182-191:
<button type="button" data-testid="ghost-card-new-agent" ...>
Has no onClick handler. Clicking does literally nothing. Confirmed by inspection.
243 LOC, 0 of them touch CRUD IPCs. The only detail surface routed to from agent cards. No body editor, no field editors, no save.
The legacy disabledAgents manifest (read by getAgentManifest() + ipcToggleAgent at resourceConfigStore.ts:96,178) is NOT the same as agents.is_archived.The Pi/Claude dispatch paths check is_archived via archive-cache.mjs, filtering subagent dispatch via claude-agents-map.mjs:94 and pi-subagent-runner.mjs. The UI toggle does NOT update the column subagent dispatch checks.Concrete consequence: Toggling a bundled agent “Off” in the UI does NOT prevent it from being spawned as a subagent. Only the new archive_agent IPC does, and nothing calls it.
orion://agents/migrated is window-broadcast (routing.rs:540) but grep -rn 'orion://agents/migrated' src/ returns zero. Toast or any user feedback is silently dropped.Either wire a toast or remove the broadcast path.
useAgents subscribes to orion://agents/reloaded, but the hook has no consumers, so reconcile events have no visible effect on the UI.
Marked legacy; no callers verified. Should be archived or deleted (note the destructive-commands.md rule — needs user approval before rm).
Last regen 2026-05-05. Today is 2026-05-17. If any config.yaml has been edited since 2026-05-05, sync renderers (mention autocomplete, slash commands) show stale descriptions.Fix: run node scripts/generate-agent-registry.mjs before each release.

Open questions

A studio/ or agents/new page mockup must exist somewhere — locked-design rules reference an “agents studio” but the LOCKED spec wasn’t located in this audit.
The two paths should not coexist — currently the UI toggle does NOT block subagent dispatch.
Vault-created agents have no build-time row, so the merge has to happen client-side. The registry is still useful for cold-load before the IPC resolves.
No <Toaster> / <Sonner> mount was confirmed in this audit.
Rule mentions D8 = “v1 static only.” If v2 is on roadmap, vault sync semantics need a story for write-back from subagents.

Key files

Engine-agnostic folder reader. resolveAgentLayered (L470-487), loadAgent, assembleAgentBody, filterBootstrapFilesForSession (L303-307 — D9 ALLOWLIST), resolveTopLevelAgentBody (L516-546). ✓ VERIFIED complete.
DB reconcile. INSERT OR REPLACE + orphan DELETE in single tx. ON CONFLICT(id) DO UPDATE SET clause at L128-136 deliberately preserves runtime cols. ✓ VERIFIED complete.
Builds Claude SDK query({agents:{...}}) map. Applies D9 unconditionally at L102. ✓ VERIFIED.
5s TTL in-memory archive flag cache. AgentArchivedError thrown at spawn time. ✓ VERIFIED.
Legacy <vault>/.orion/agents/*.md → folder layout migrator. Backup-before-write, atomic per-file, rollback documented. ✓ VERIFIED complete.
Rust IPC: list/get/create/update/archive/delete + atomic write helpers + tests. ✓ VERIFIED complete.
Typed IPC wrappers. ✓ exists, ✗ UNUSED by UI (zero call sites in src/).
Runtime catalog hook with 5s cache + orion://agents/reloaded subscription. ✓ implemented, ✗ zero consumers.
UI page. Reads AGENT_REGISTRY (build-time) via resourceConfigStore, NOT useAgents(). ”+ New Agent” button has no onClick. ✗ PARTIAL: read-only.
Agent detail page. Zero CRUD calls. ✗ MISSING CRUD.

Next

Subagents

How subagents inherit (only) the AGENTS.md filtered body and run in their own fresh AgentSession.

Agent Loops

Where agent definitions plug into the per-query system prompt assembly.