Vancetope — Recipes
A Recipe is a named, reusable blueprint for a worker process: Engine + Default-Params + Prompt-Prefix + Tool-Adjustments. When spawned (e.g., by Arthur via
process_spawn), the Recipe is resolved into a concreteThinkProcessDocument. The separation of Engine ↔ Recipe is the clean two-layer architecture that allows Vancetope to scale without engine proliferation.Persistence: Recipes are stored as YAML documents under
recipes/<name>.yamlin the Document Layer. The cascade lookupproject → _tenant → classpath:vance-defaults/recipes/runs viaDocumentService.lookupCascade— the same mechanism as Documents/Prompts. There is no longer a separate Mongo collection for Recipes.
See also: think-engines arthur-engine settings-system server-tools inline-and-embedded-content (Rich-Content-Output via doc_link-Tool +vance:-URIs, included by default in every EnginepromptPrefix)
1. Terms and Delimitation
| Term | What it is | Cardinality | Location |
|---|---|---|---|
| Engine | Algorithm with lifecycle (start/resume/steer/stop), Tool-Loop, Streaming. Stateless regarding instances. Java code. | few (3-5) | vance-brain/.../<name>/ |
| Recipe | Recipe: which Engine, which defaults, which Prompt-Prefix, which Tools on/off. Configuration. | many (10-100) | YAML Document under recipes/<name>.yaml (Cascade) |
| Process | Running instance, born from exactly one Recipe (or without — direct engine-spawn is still allowed). Persisted as ThinkProcessDocument. |
n per Session | Mongo |
Engines are rare and structural. A new Engine type is a major feature: different lifecycle (reactive vs. batch), different Inbox processing, possibly different persistence. Today we have arthur and ford; deep-think is planned.
Recipes are numerous and feature-driven. A new Recipe is a configuration change: “spawn analyze with ford, validation on, Sonnet model, Prompt-Prefix for analysis worker”. No code change.
2. Recipe Schema (YAML)
A Recipe is a YAML file with the following top-level fields. The name comes from the filename (recipes/<name>.yaml), not from a field:
| Field | Type | Required | Meaning |
|---|---|---|---|
description |
String |
yes | A single line describing what the Recipe does — rendered in recipe_list and in the Arthur prompt |
engine |
String |
yes | Engine name (ford, arthur, marvin, …) |
params |
Map<String, Object> |
no | Default engineParams. Merged with caller parameters (see §4). Common keys: model, validation, maxIterations, modelSize, rag.autoInject, rag.minScore, rag.topK (see rag.md §5 — RAG-AutoInject is currently active in Arthur). LLM sampling controls see §5c |
promptPrefix |
String (Pebble Template) |
no | System prompt content for the Engine — single source of truth for Engine Persona. Sent as a Pebble template through the renderer, with access to tier, model, provider, mode, profile, recipe, engine, params (see §5 for render context and §5b for syntax subset) |
promptMode |
APPEND \| OVERWRITE |
default APPEND |
How promptPrefix is combined with the Engine default fallback (see §5) |
dataRelayCorrection |
String |
no | Override for the “data-relay-gap” Validator correction (see §5a) |
allowedToolsAdd |
List<String> |
no | Tools to be added to the Engine default. Entries with @-prefix are resolved as label selectors — via server-tools and via the client tool registration of the Session (see §6.2) |
allowedToolsRemove |
List<String> |
no | Tools to be removed from the Engine default (same @-selector syntax) |
allowedToolsKeep |
List<String> |
no | Pure ranking, not visibility. These tools remain in the manifest if the tool surface budget needs to be cut (“important”). Entries can be names, @-label selectors, or prefix patterns (doc_*). Ineffective without maxTools at the endpoint — see server-tools §14 |
allowedToolsDropFirst |
List<String> |
no | Counterpart: these tools are moved to deferred first (“less important”). Same entry syntax; allowedToolsKeep wins in case of overlap |
defaultActiveSkills |
List<String> |
no | Skills that are sticky-active from spawn (fromRecipe=true). See §6c |
allowedSkills |
List<String> |
no | Whitelist: only these skills may ever become active (Trigger / Default / /skill). Missing ⇒ no restriction. Empty list ⇒ lockdown. See §6c |
locked |
boolean |
default false |
If true: caller overrides are ignored (Recipe is binding) |
listed |
boolean |
default false |
Opt-in for the User-Facing Recipe Picker (Web-UI Session Start Modal). Server additionally hard-filters internal: true. Foot still lists all Recipes — this flag only affects discovery clients (see §6e) |
projectKind |
String |
default normal |
Picker filter by project type: normal (Default) ⇒ selectable only in regular projects, system ⇒ only in SYSTEM-Hub projects (_user_*, _tenant), any ⇒ everywhere. Reason: the Hub chat is always Eddie (SessionChatBootstrapper ignores Recipe overrides there), regular projects run on Arthur/Worker — a Recipe in the wrong picker is a clickable lie. Unknown values fail the Recipe load (no silent fallback). Only a display filter: explicit names via --recipe/session-bootstrap/Spawn tools remain free (see §6e) |
web |
boolean |
default false |
Release for Web callers: only with web: true may the generic Light-LLM route POST /brain/{tenant}/light-llm/{project} execute this Recipe. Second gate above internal (which LightLlmService requires anyway), not its alternative. Deliberately on the Recipe and not on the caller: a custom app is a web client, every web client reaches the same route with the same session — a per-app permission would be a fiction that looks like a boundary. Purpose-specific routes (/follow-up) retain their own contract and do not need this flag. See light-llm-service.md |
tenants |
List<String> |
no | For which tenants this Recipe applies. Missing or empty ⇒ all — every Recipe written without this field remains valid. Necessary because a bundled Recipe is in the classpath and the classpath layer of the lookup cascade is tenant-agnostic: what an addon provides, every tenant finds — for a Recipe that starts an agent with its own credentials, this is the wrong scope. Enforced in RecipeLoader.load, not at a display location (see §2a) |
title |
String |
no | Display name for Recipe picker UIs. Falls back to Recipe name if not set |
category |
String |
no | Picker grouping key (kebab-case, normalized to trim + lowercase on parse). Pure display metadata for the User-Facing Recipe Picker — Spawn, Engine, and Tool logic never read this field. Group order and localized labels come from _vance/config/recipe_categories.yaml (§6e). If the field is missing, the Recipe lands in the last “Other” group of the picker |
tags |
List<String> |
no | Free for discovery (e.g., [research, code, web]) |
guard |
List<Map> |
no | Cross-Engine (Frankie, Arthur, Eddie). List of Shooty-Guards: a JS guard script at a Point — Yield point (continueWith injects a follow-up into its own queue, so the Engine continues working), Turn start (activateSkill), or Command dispatch (deny, fail-closed). Per entry: script or scriptBody (required, exactly one), params (optional, → vance.params.*), trigger (start|command|stop|terminate|both, default stop), maxRounds (only stop/terminate), allowTools. Without block = No-op. Full semantics in shooty.md |
Trap:
paramsswallows misaligned top-level fields.paramsis an open map — unknown keys end up asengineParamson the Process. A field indented one level too deep is therefore accepted without complaint and never takes effect. This has actually happened:coding.yamlandtrillian-worker-void.yamlcarried their completepromptPrefixunderparamsand ran without a single line of it — the symptom looked like a model ignoring instructions, rather than instructions never arriving.RecipeLoaderhas since warned on load, andBundledRecipeStructureTestchecks all bundled Recipes for this form.
There is no Mongo collection for Recipes. Persistence, versioning, audit come from the Document Layer (soft-delete, createdBy, storage backend for inline-vs-blob, etc.).
2a. tenants: — a Load Barrier, not a Display Filter
This field answers a question that could not be asked before: who owns a provided Recipe? An addon that bundles a Recipe places it in the classpath — and the classpath layer of the Cascade (§3) is tenant-agnostic. Thus, every tenant finds it. For most Recipes, this is correct; for one that starts an agent with its own credentials, it is the wrong scope.
Checked in RecipeLoader.load. This is the single point through which every usage passes — RecipeResolver, all four spawn paths, the LightLlmService. A Recipe rejected there is therefore unreachable, not merely invisible.
The distinction is the whole purpose: if the check were on a list route, “not displayed” would mean something different from “cannot be used”, and a process_create(recipe: "…") from a foreign tenant would bypass it. listAll filters along — a listed name that answers “does not exist” on load would be a trap.
Three related stipulations:
- Missing or empty means “all”. Any other choice would have silently disabled every existing Recipe.
- Rejection is as “not found”, not as a separate error. An “exists, but not for you” would be an oracle about the Recipes of foreign tenants.
- The Cascade does not search further. If it encounters a Recipe that does not apply here, the result is empty — the selector says “this does not apply here”, not “take another one”. A tenant with its own Recipe of the same name gets theirs first anyway, because the Cascade starts with them.
What this field does not achieve. It limits the scope, not the effect. The Recipe document is in the Brain’s classpath; whoever looks there sees it. And what an agent started with it can do depends on its settings and the permissions of its account — not on the Recipe name.
3. Cascade — How a Recipe Name is Resolved
For process_spawn(recipe="analyze"), the resolver runs via RecipeLoader, which in turn uses DocumentService.lookupCascade:
load(tenantId, projectId, name) → Optional<ResolvedRecipe> :=
documentService.lookupCascade(tenantId, projectId,
"recipes/" + name + ".yaml")
1. Project: <project>/recipes/<name>.yaml → source = PROJECT
2. _tenant: _vance/recipes/<name>.yaml → source = VANCE
3. Resource: classpath:vance-defaults/recipes/<name>.yaml → source = RESOURCE
4. → empty
First-hit-wins — innermost wins. Project override beats _tenant override beats Resource default. No field merge between stages: whoever overrides, rewrites all fields, otherwise it becomes unclear which values are currently active.
Resource Recipes are the source of truth for standard functionality. They are located under vance-brain/src/main/resources/vance-defaults/recipes/<name>.yaml and survive Mongo data loss. _tenant and Project Recipes only exist if they have been actively configured.
Hot-Reload:
- Resource Recipes require a Brain restart (classic classpath read).
_tenantand Project Recipes are read fresh with each lookup — this is the identical Cascade mechanism as for Documents.
Listing (recipe_list and the embedded Catalog in the Arthur/Marvin prompt) runs via DocumentService.listByPrefixCascade(tenantId, projectId, "recipes/") and merges inner over outer by path.
4. Caller Parameters and Override Semantics
When spawning, the caller (Arthur via Tool, Foot via Bootstrap, REST client) can provide params in addition to recipe:
process_spawn(
recipe = "analyze",
name = "pom-analyzer",
goal = "Analyze pom.xml structure",
params = { "model": "claude-haiku-4-5" } // override
)
Merge Rules (Recipe unlocked, default):
effectiveParams = recipe.params ⊕ caller.params
// caller wins per key
If recipe.locked == true: caller.params is ignored and the override is noted with a warning in the log. This is the “binding Recipe” variant for tenant compliance cases.
Audit Logging: every override is logged at INFO level:
INFO RecipeResolver: recipe='analyze' override applied
overridden_keys=[model]
caller=process:69e8c8d... tenant=acme
This allows later tracing of which Workers ran with Recipe defaults and which with overrides.
5. Prompt Composition
Recipes are the Single Source of Truth for Engine Prompts. In source code, Engines only retain a one-line fallback prompt for the edge case where no Recipe override is present (does not happen in practice, as default/arthur/ford Recipes are mandatory in vance-defaults/recipes/).
5.1 Render Context
promptPrefix is a Pebble template. Tier/Model/Mode/Profile variants live within the template body, not in separate fields. The renderer (PromptTemplateRenderer) is called at the start of the turn and populates the following variable context:
| Variable | Type | Value |
|---|---|---|
tier |
String |
"small" / "large" — from ModelInfo.size (lowercase) |
model |
String |
Resolved model name, e.g., "claude-sonnet-4-6", "gemini-2.5-flash" |
provider |
String |
"anthropic", "google", "openai", … (lowercase) |
mode |
String |
"NORMAL", "EXPLORING", "PLANNING", "EXECUTING" (Plan Mode) |
profile |
String |
"foot", "web", "default" |
recipe |
String |
Recipe name |
engine |
String |
Engine name |
lang |
String |
Chat language from Memory Cascade (empty until language settings arrive) |
params |
Map<String, Object> |
Merged Recipe parameters, read access via {{ params.maxIterations }} |
profileAppend |
String |
Pre-rendered content of the active profileBlock.promptPrefixAppend. Recipe template can insert {{ profileAppend }} anywhere — e.g., before the hard rules instead of at the end. If the variable is not referenced AND the append is non-blank, the renderer appends it as a fallback (Backwards-Compat). See planning/prompt-inlining.md §3 |
The effective Tier value can be enforced per Recipe call via params.modelSize (SMALL / LARGE / AUTO, Default AUTO) — e.g., to deliberately run the Small variant on a Large model or to test unclassified models. AUTO ⇒ Catalog wins.
5.2 Pebble Syntax & Security Boundary
The full Pebble grammar is available — {{ var }}, {% if/elseif/else/endif %} (note: elseif, not elif), {% for %}, {% raw %}…{% endraw %}, Boolean operators (and/or/not), Builtin and registered Custom filters (slug, yamlIndent), and the Jinja2 compatibility test {% if model is matching("regex") %}.
The security boundary is not a syntax subset, but the deactivation of method/reflection access. Prompt/Recipe/Setting Form/Wizard/Template bodies effectively originate from DB documents that anyone with Doc-Write can create — they are therefore untrusted. The renderer therefore pins a deny-all MethodAccessValidator (DenyMethodAccessValidator): no method and no getter can be called from a template. This closes the classic SSTI→RCE chain ({{ x.getClass().forName(…) }}) and makes the context content irrelevant for security — a render attempting to call a method fails fail-closed. Map/List/Array access ({{ m.key }}, {{ xs[0] }}) and filters bypass this check and work normally.
Control structures (for/if/include) are not a privilege escalation beyond {{ }} for an untrusted author (same Engine, same locked validator) — they therefore remain allowed. Two deliberate style recommendations (not a hard boundary): hand-written Recipe prompts should not be iterative ({% for %} inflates the prompt), and {% include %} re-renders a variable value as a template (footgun with indirectly injected data) — avoid where not necessary. Internal Recipes (e.g., zarniwoop-*, zaphod-*, fook) legitimately use {% for %} to render candidate lists.
DoS Residual Risk (deliberately open):
{% for i in range(0, 10000000) %}remains possible (CPU/Heap). Method access is closed, RCE is not possible; a render timeout/output cap is a separate, still open hardening point.
5.3 Composition
At turn start, SystemPrompts.compose(process, engineDefault, renderer, ctx) runs:
renderedDefault = renderer.render(engineDefault, ctx)
renderedOverride = renderer.render(process.promptOverride, ctx)
if renderedOverride is empty:
finalSystemPrompt = renderedDefault // no Recipe override
elif promptMode == APPEND:
finalSystemPrompt = renderedDefault
+ "\n\n--- recipe extension ---\n\n"
+ renderedOverride
elif promptMode == OVERWRITE:
finalSystemPrompt = renderedOverride
Profile promptPrefixAppend (Pebble template) is already appended to recipe.promptPrefix with a \n\n-separator in RecipeResolver.apply before the render stage — meaning the Profile append participates in the same render run. Profile append is always additive, even in OVERWRITE mode.
Arthur Special Case: Arthur additionally appends the Recipe Catalog after the compose step, so the LLM sees the worker recipes to choose from. Marvin does something similar for his Planner.
5.4 Compile-Validation
RecipeLoader calls PromptTemplateRenderer.compile(promptPrefix) and compile(profileBlock.promptPrefixAppend) on load. Syntax errors fail as RecipeParseException with clear diagnostics (Pebble line number, token). ValidatingPhase (Slartibartfast) performs the same compile check for drafts generated by the LLM under RULE_PROMPT_PREFIX_TEMPLATE_VALID.
Compile-Validation is syntax-only (catching parse errors early). The security boundary (§5.2) is the deny-all MethodAccessValidator and applies at render time — a template attempting method/reflection access compiles, but fails fail-closed during rendering.
5.5 Storage on Process
At spawn, the following are written to the Process: recipeName, promptOverride (unrendered Pebble string), promptMode, dataRelayCorrectionOverride, allowedToolsOverride, engineParams. Rendering happens per turn — Tier/Mode/Model can change between turns (model switch, plan mode transition). Recipe edits do not affect running Processes (snapshot semantics).
5.6 Stance / Proactivity
Engine-Base (prompts/<engine>-prompt.md) provides the default stance — how the model reacts to requests with missing detailed information. Currently:
- Arthur: balanced. Mandatory info (what, on what) → ASK_USER. Optional details (path, title, format) → choose wisely, execute directly, mention chosen defaults in the ANSWER.
- Other Engines (Eddie, Ford, Marvin, …): Engine-Base provides a sensible default for the Engine role; Recipes override if necessary.
Recipe-Override via promptPrefixAppend. Each Recipe (Profile block) can override the default by appending its own stance section. Convention:
- A Markdown subsection (
## Style — …) that unambiguously describes how the model handles vagueness: choose more aggressive defaults, more restrictive ASK_USER, or with specific domain rules (citation requirement, source validation, …). - Plain text, no schema variable. Drift tolerance is acceptable — each personality may have its own nuances.
Examples in the bundled Recipes:
- eddie.yaml — Frontman stance: for creatively open requests (“write a poem”), default autonomously, choose topic/title/path itself, mention in ANSWER. Only ask if the answer depends on user knowledge. The block is repeated per profile (eddie, foot, web, default) because
promptPrefixAppendlives per-profile and some profiles have additional client-context sections — a YAML anchor (*frontman_stance) deduplicates where possible. - arthur.yaml — no override, Engine default (balanced) is sufficient for the Orchestrator use case.
- (future)
analyze,web-research— strict stance: citation requirement, no assumptions, ASK_USER for gaps; Recipe author decides per use case.
Why free-form instead of Schema Variable. A schema enum (proactiveness: strict|balanced|creative) would be more consistent, but couples the personality vocabulary to an Engine edit as soon as a new nuance is desired. Free-form promptPrefixAppend uses an existing mechanism, shows directly what happens in the Recipe, and allows different Recipes to express different personalities. Drift risk is low — Recipes are rare and reviewed collaboratively.
When to elevate to a Schema Mechanism. If 10+ Recipes repeat similar stance texts, or if Tenant/UI wants to expose selection between stances, a Recipe top-level append slot + named stance constants would be worthwhile. Until then: free-form per Recipe, YAML anchor for DRY.
5a. Validator Corrections
Engines with active validation (params.validation: true) inject a corrective SystemMessage upon detecting a failure pattern. Both triggers are language-agnostic (structural, not regex-based) — see structured-engine-output:
- No-Tool-Call — the LLM reply contains no tool call whatsoever (neither Work-Tool nor
respond). Engine-default wording, no Recipe override (the correction text is tightly coupled to therespond-tool). - Data-Relay-Gap — Tool-Result large (≥ 500 characters), Reply short (≤ 200 characters). Indicates that the LLM did not relay the tool data. Override via
dataRelayCorrection(format string with two%d-placeholders:toolDataChars,replyLen).
null/empty → Engine default (one-line fallback) is used. The Bundled Recipes default, arthur, ford carry more detailed wordings tailored to the Engine context for the data-relay variant.
formatSafe(...) protects against malformed templates: in case of a format exception, the template string is taken literally instead of crashing the turn.
The old regex-based “intent-without-action” heuristic (INTENT_PATTERNS) has been replaced by the structured respond-tool convention plus the No-Tool-Call validator.
5c. LLM Sampling Parameters
The following params-keys control the LLM wire parameters per call. They are read by EngineChatFactory from EngineChatFactory.effectiveParams(process) — merged with the runtime overlay engineParamOverrides (see override semantics below) — and placed on AiChatOptions; the respective AiModelProviders map to the backend’s wire field. Providers that do not recognize a field ignore it silently — Recipes thus run portably across providers without every mapping gap breaking a spawn.
| Key | Type | Default | Effect |
|---|---|---|---|
temperature |
Double (0..2) |
0.7 |
Sampling temperature. Recipe wins over the caller default (temperature has no null default — hence no caller detection possible). |
maxTokens |
Integer |
null (Provider default) |
Hard cap on generated tokens. Caller-explicit values win. |
topP |
Double (0..1) |
null |
Nucleus sampling. |
topK |
Integer |
null |
Top-K cutoff. |
stopSequences |
List<String> |
null |
Hard-stop strings. YAML single strings are wrapped into a one-element list. Empty entries are filtered. |
seed |
Long |
null |
Determinism seed for replay/QA. |
frequencyPenalty |
Double |
null |
Penalty proportional to frequency. |
presencePenalty |
Double |
null |
Penalty for each token already seen. |
Override Semantics
- Nullable fields (all except
temperature): Recipe parameter is only written if the caller has not already set the field. Call code that buildsAiChatOptions.builder().topP(0.1)…wins. temperature: has a non-null default (0.7), caller-explicit and default are indistinguishable. Here, the Recipe always wins ifparams.temperatureis set. This aligns with the single-source-of-truth rule for Recipes; callers who need to enforce a fixed value set it on the Options instance after theEngineChatFactory.forProcess(...)call.- Runtime Overlay (
engineParamOverrides): set live via the//llm-Engine command (reasoning additionally via//thinking), wins over the Recipe default — precedence Override > Recipe > Option-Default. Merged fresh per turn, no respawn. Sub-callers withlockSampling(Judges/Validators) skipapplySamplingParamsand are unaffected. Details: engine-commands.md §9.
Type-Tolerance
YAML parsers deliver numbers as Integer, Long, or Double depending on the path. The reader accepts any Number subtype and also parses strings ("0.4" → 0.4). Invalid values are dropped with a WARN log line instead of crashing the spawn.
Provider-Coverage
| Param | OpenAI | LM Studio | Anthropic | Gemini | Ollama / Ollama Cloud |
|---|---|---|---|---|---|
temperature |
✓ | ✓ | ✓ | ✓ | ✓ |
maxTokens |
✓ | ✓ | ✓ (Required, Default 4096) | ✓ (as maxOutputTokens) |
✓ (as numPredict) |
topP |
✓ | ✓ | ✓ | ✓ | ✓ |
topK |
ignored | ignored | ✓ | ✓ | ✓ |
stopSequences |
✓ (stop) |
✓ (stop) |
✓ (stop_sequences) |
✓ | ✓ (stop) |
seed |
✓ (Integer-Cast) | ✓ (Integer-Cast) | ignored | ✓ (Integer-Cast) | ✓ (Integer-Cast) |
frequencyPenalty |
✓ | ✓ | ignored | ✓ | ignored |
presencePenalty |
✓ | ✓ | ignored | ✓ | ignored |
“Ignored” means: the value is silently discarded on the Java side, no API call and no error. Recipe authors can deploy the same YAML block across all providers.
seed is Long everywhere in the Recipe, but is cast to Integer for OpenAI/Gemini/Ollama (the native SDKs expect int-range). Values > 2^31 silently lose their upper bits — this is acceptable because seeds are values for reproduction, not semantic numbers.
Example — Deterministic Worker
engine: ford
params:
model: default:fast
temperature: 0.0
topP: 0.1
seed: 1
stopSequences:
- "END_OF_REPORT"
maxTokens: 2048
Anthropic ignores seed, all other providers give reproducible outputs with these settings.
Example — Creative Long-Form Recipe (OpenAI/Gemini)
engine: arthur
params:
model: default:large
temperature: 0.9
topP: 0.95
frequencyPenalty: 0.3
presencePenalty: 0.2
On Anthropic, frequencyPenalty/presencePenalty are ignored; the Recipe runs, the long-form effect is only missing on Claude.
6. Tool Pool Adjustment
Engines declare their default tool whitelist via allowedTools(). Recipes can adjust this:
finalAllowed = (engine.allowedTools ∪ recipe.allowedToolsAdd) ∖ recipe.allowedToolsRemove
With an empty Engine whitelist (= unrestricted, Ford default), Add has no effect (everything is already there), but Remove creates a concrete whitelist of “all minus removed”. This is the path to, for example, temporarily start a Ford worker without shell tools.
6.1 Convention: Engine-Base Empty, Recipe is Single Source of Truth
Engines like Arthur and Eddie declare allowedTools() = Set.of() — no hardcoded Java list. Rationale:
- No duplicate maintenance. A new tool in the Brain is automatically picked up by
dispatcher.resolveAll(ctx); the Engine does not need to be updated in Java. - Single Source of Truth. The Recipe YAML fully describes what the Engine sees and how it classifies it (
allowedToolsAdd / Remove / Defer, plus label selectors like@write,@executive). - Classification via Labels, not Lists. A Recipe might say, for example, “defer @write, @executive, @side-effect; remove @destructive — bulk wipes like doc_purge / kit_apply”, instead of listing 80 tool names individually. New tools are immediately correctly classified if they set their labels.
If base.isEmpty() && filter, ContextToolsApi.classify expands to the full Dispatcher pool at runtime and applies the Recipe overlays (see Java documentation for the method). Specialty Engines (Marvin worker, Vogon strategy, Zaphod) may continue to maintain narrow static lists — for them, the narrow scope is a feature, not a maintenance burden.
Implication for action-internal Tools. Tools like project_create, project_chat_send, which Engines only call via invokeInternal from Action-Handlers, are visible in the LLM Catalog (Recipe does not hide them). This is allowed — with Auto-Activate-on-direct-call, the LLM can also call them directly, the Action-Handler translates the same vocabulary (endpoint restriction see server-tools.md §13: on endpoints with restricted tool call decoding, the prompt prescribes Activate-First because direct calls can silently fail there). System prompt guidelines (Arthur: “DELEGATE instead of process_spawn”) guide the model to the structured path, but are not a compulsion.
6.2 Label Selectors via Client Tools
An @<label> selector resolves against two sources:
- Server Tools —
ServerToolService.findByLabel(tenant-/project-scoped, see server-tools.md). - Client Tools of the Session — the registration of the connected client (
ClientToolRegistry). Foot pushes its labels toToolSpec.labels; an MCP pack fromfoot-tools/*.json(global and/or project-local, see foot-tool-packs.md) carries thelabels:from the pack file plus automaticallymcp,mcp:<pack>, andside-effect.
Thus, a Recipe addresses a Capability, not generated names:
allowedToolsAdd:
- "@browser" # → chrome__navigate_page, chrome__take_snapshot, … (29 Tools)
Two properties result from the design and are intentional:
- Only per-Turn, never at Spawn. The spawn path (
applyDefaulting→allowedToolsOverride) has no session scope, so it expands client labels to nothing. Resolution happens at every turn intoolFilterFor. A/tools reloadin Foot (different pack set) thus takes effect immediately, without a name list frozen in the Process becoming outdated. If the pack is missing (client disconnected, pack deactivated), the label expands to empty like any unresolved selector. addextends the Dispatch Pool. AnallowedToolsAddentry that names a tool outside ofbaseis added to the pool byContextToolsApi.classifyinstead of being discarded — otherwise, a client tool resolved per-turn could never reach the allow-set frozen at spawn. Names already inbaseretain the old meaning (“promote to primary”); newly added ones retain their owndeferred()flag. A pack withdefaultDeferred: trueis thus reachable viatool_listwithout putting 29 schemas into every turn. Role-Gate, Profile-Gate, andallowedToolsRemoveapply unchanged to newly added names as well.
6a. Connection Profile Block
Recipes can carry an optional override block per Connection Profile. The Profile block adjusts the Recipe for the specific client class without having to duplicate the Recipe per client.
Profile value comes from the WebSocket handshake as an open string (?profile=…, see client-protokoll-erweiterbarkeit §2.1a). Profile block keys are also open strings — Tenants can introduce their own profiles (e.g., ci-bot, kiosk) via Recipe configuration without the Brain needing code changes. The canonical values (foot, web, mobile, daemon) are documented in de.mhus.vance.api.ws.Profiles as string constants.
Motivation: foot (terminal client) brings filesystem and shell tools, and the worker should actively use them; web runs in the browser without local tools — client_*-tools must be removed, otherwise the LLM hallucinates calls that do nothing; mobile wants shorter/cheaper sessions. Three Recipes (foot_arthur, web_arthur, mobile_arthur) would be proliferation at the Recipe level — the Profile block keeps this in one Recipe.
Schema
arthur:
engine: arthur
promptPrefix: |
You are Arthur, the coordinator...
allowedToolsAdd: [...] # base
params:
manualPaths: [docs/] # base — see §6b
profiles:
foot:
promptPrefixAppend: |
You are connected to a terminal client. The user is editing
local files; reach them via client_file_read / client_exec_run.
web:
allowedToolsRemove: [client_file_read, client_file_write, client_exec_run, …]
promptPrefixAppend: |
Web client — no local filesystem. Use workspace_* for project files.
mobile:
allowedToolsRemove: [client_file_*, client_exec_*]
params: { maxIterations: 6 } # mobile: shorter sessions
default: # Catch-all for unknown Profile values
allowedToolsRemove: [client_file_*, client_exec_*]
promptPrefixAppend: |
Non-CLI client — workspace_* only.
protected: false # base visibility (search/spawn list)
Block Fields
| Field | Type | Meaning |
|---|---|---|
allowedToolsAdd |
List<String> |
In addition to recipe.allowedToolsAdd (same @-selector syntax) |
allowedToolsRemove |
List<String> |
In addition to recipe.allowedToolsRemove |
promptPrefixAppend |
String (Pebble Template) |
Appended after recipe.promptPrefix — additive, not replacing. Pebble is also allowed here (e.g., {% if profile == "foot" %}…{% endif %} branches). For OVERWRITE-mode, the Recipe remains the master, the Profile append is still appended (Profile append is always additive) |
params |
Map<String, Object> |
Profile-specific parameter defaults — merged between Recipe defaults and Caller parameters. Commonly used: manualPaths (see §6b), maxIterations, model |
Profile Block Lookup with Fallback
The resolver selects the effective Profile block along a cascade:
profileBlock(connectionProfile) :=
1. recipe.profiles.get(connectionProfile) → exact match wins
2. recipe.profiles.get("default") → catch-all block, if defined
3. ∅ → Recipe base without Profile overlay
This means the fallback applies in two cases:
- Profile not specified — Wire default
web(see Extensibility Spec §2.1a) goes through the same lookup; without aprofiles.web-block, it lands onprofiles.defaultor Recipe base. - Profile specified, but no block configured — e.g., a tenant custom profile
ci-botfor which the Recipe does not yet have an explicit block:profiles.defaultapplies.
profiles.default is the only Profile key with reserved semantics (catch-all). All other keys are freely selectable.
Recipe Visibility
At Recipe top-level, not in the Profile block:
| Field | Type | Meaning |
|---|---|---|
protected |
boolean |
If true: Recipe selectable only as a login/bootstrap Recipe, not visible in the search/spawn list (for default Engines like arthur that should not be accidentally chosen as a worker Recipe) |
Profile-specific visibility (visibleTo: [foot, web]) is not in the schema — if one wants to hide a Recipe for a profile, simply omit the Profile block and use the default fallback, or the Recipe is generally protected.
Merge Semantics
effectiveTools = (engine.allowed
∪ recipe.allowedToolsAdd
∪ profile.allowedToolsAdd)
∖ (recipe.allowedToolsRemove
∪ profile.allowedToolsRemove)
effectivePrompt = render(recipe.promptPrefix, ctx + {profileAppend: rendered(profile.promptPrefixAppend)})
// If the template does not reference {{ profileAppend }} AND
// profile.promptPrefixAppend is non-blank, the renderer
// automatically appends the rendered append at the end (Auto-Append-
// Fallback). See planning/prompt-inlining.md §3.
effectiveParams = recipe.params ⊕ profile.params ⊕ caller.params // last wins
If recipe.locked == true, Caller parameters are ignored (see §4) — Profile parameters are still applied, as they represent Recipe author intent, not Caller override.
Daemon
daemon is reserved as a canonical Profile value for vance-foot -d (planned), but has no special effect in the current resolver — it flows through the same block lookup cascade as any other profile. If daemon mode is implemented, special handling will move to the connect handler in the Brain (no chat bootstrap, instead DaemonRegistry entry), not into the Recipe schema.
6b. Manuals — Recipe-Configurable How-To Documentation
manual_list and manual_read are the generic documentation tools: they read a list of folders from params.manualPaths and union the found Markdown files.
params:
manualPaths:
- manuals/ # general manuals
- eddie/manuals/ # engine-specific (Eddie-Hub)
profiles:
web:
params:
manualPaths: # web profile additionally shows web-specific manuals
- manuals/
- manuals/web/
- eddie/manuals/
Resolution
For each path in manualPaths:
DocumentService.listByPrefixCascade(tenantId, projectId, "<folder>/")
— thus runs through project → _tenant → classpath:vance-defaults/. Then the hits from all paths are merged in Recipe order: first hit per <name> wins. This allows an engine-specific path to override a similarly named general-path hit (specificity order: specific first).
manual_read(name) walks the same path list, retrieves the first <folder>/<name>.md that the Cascade provides.
Delimitation from other Doc Sources
| Tool | What | Where configured |
|---|---|---|
manual_list / manual_read |
Curated How-To documentation (Markdown files) | Recipe params.manualPaths |
workspace_* |
Project-specific files (uploaded by user / generated by worker) | Storage backend per Project |
client_file_* |
Local FS on the Foot client | Foot Process |
Server Tool doc_lookup |
Single pinned document as its own tool | ServerToolDocument with Type doc_lookup |
manual_* replaces the old engine-specific docs_* and eddie_docs_*-tools — one implementation, multiple paths, recipe-configured.
6c. Skill Integration
Recipes control Skills on two axes — which Skills are active from spawn and which Skills are allowed to become active at all. Both fields are optional and can be used independently. Skills themselves are described in skills.md — Recipes only define which subset of their spawn bubble is eligible.
Schema
analyze:
engine: ford
defaultActiveSkills:
- naming-conventions # sticky active from spawn
allowedSkills: # Whitelist
- naming-conventions
- code-review
- typescript-style
defaultActiveSkills
List of Skill names that are written to the fresh Process as sticky active Skills at spawn. Each entry becomes an ActiveSkillRefEmbedded with:
fromRecipe: true— the/skill clear-path respects this (recipe-bound Skills cannot be cleared if the Recipe is locked; seeskills.md§7a)oneShot: false— sticky, runs until session endresolvedFromScope: RESOURCEas a safe default; Engines re-resolve on turn
If the Recipe cascade does not find the named Skill names at spawn time, the entry is silently carried along — Engines (Ford.resolveActiveSkills) then discard it on the next turn with a warning log. Recipe author can verify with a spawn smoke test.
allowedSkills — Whitelist
| Value | Effect |
|---|---|
Field not set / null |
No restriction — current behavior. Trigger match, Default-Active, and /skill <x> operate against the full Skill cascade visibility scope |
| List with entries | Whitelist. Trigger match iteration filters to these names; /skill <x> with an unlisted Skill fails with SkillNotAllowedByRecipeException (“Skill ‘foo’ is not allowed by recipe ‘analyze’”) |
Empty list [] |
Hard Lockdown — no Skill can ever become active. defaultActiveSkills must then also be empty, otherwise Recipe parse error |
Validation during Recipe Parse: if allowedSkills is set, then defaultActiveSkills ⊆ allowedSkills. Otherwise, RecipeLoader.parse throws IllegalStateException.
Snapshot Persistence
At spawn, allowedSkills is written as Set<String> into ThinkProcessDocument.allowedSkillsOverride — snapshot, same pattern as allowedToolsOverride. If the Recipe is later edited or deleted, nothing changes for the running Process. The defaultActiveSkills entries go directly into process.activeSkills and are subject to normal Skill lifecycle logic from then on.
Relationship to Recipe Lock
recipe.locked == true protects engineParams from Caller overrides. At the Skill level, this works differently: defaultActiveSkills are written regardless (Recipe author intent), and allowedSkills is a spawn-time snapshot decision — after spawn, there is no “Caller”, only user input (/skill). Here, the user may not clear Recipe skills if the Recipe is locked, and may only activate new skills from allowedSkills. Both rules are implemented in SkillSteerProcessor.
Profile Block Override (not in v1)
Profile blocks (profiles.foot:) cannot currently override allowedSkills / defaultActiveSkills. Use case is thin: Skills are usually client-agnostic. If a concrete need arises (e.g., a client-specific style skill only for foot), the schema can be extended — it does not break existing configuration.
6d. Completion-Guard Block (guard:)
A Recipe can provide one or more Completion-Guards as a spawn default. A Guard is a JS script that runs at a yield point (Frankie-Stop, Arthur/Eddie-Reply→IDLE) and decides whether the Engine delivers or continues working — via vance.guard.continueWith(prompt) it injects a follow-up into its own queue, and the Engine runs again.
guard:
- script: _vance/guards/llm-judge.js # Cascade path OR inline scriptBody
params: # optional → vance.params.*
judge: "Ist damit eine Entwicklungsaufgabe fertiggestellt worden?"
prompt: "Wurde auch build gemacht und die Spezifikation aktualisiert?"
trigger: stop # stop | terminate | both (Default: stop)
maxRounds: 2
allowTools: false # false: Supervisor surface; true: full Process tools
Exactly one of script/scriptBody is required. The bundled _vance/guards/llm-judge.js is a reusable “LLM-Judge + fixed prompt” guard, configured via params: {judge, prompt} — the 90% case without writing JS. The Recipe block is the spawn default; in operation, an additional runtime guard can be installed additively via the guard-command family (//guard script <path>) — typically by a Skill with an activate: sequence (skills.md §2a). Mechanics (script execution, continueWith, round cap, fail-open, loop/session-scratch, per-user-turn reset) fully described in shooty.md.
6e. User-facing Recipe Picker
The Web-UI opens a modal with selectable Recipes at session start (+-button in the picker). Which Recipes appear there is controlled by the listed-flag from §2 — no auto-collection across the entire Recipe list, because 90% of Recipes are helpers / validators / light-LLM profiles that have no place in a user picker.
REST-Surface:
GET /brain/{tenant}/projects/{project}/recipes/listed
→ 200 {
categories: [ { id, title? } ], // Doc order; title = Locale → Text
recipes: [ { name, title?, description?, category? } ]
}
- Permission:
Resource.Project(tenant, project)READ - Cascade resolution as with
recipe_list(Project →_vance→ Bundled), merge by Recipe name - Filter:
listed == true && internal == falseplus project type filter (projectKind, §2):system-Recipes (Eddie) appear only in SYSTEM-Hub projects,normal(Default) only in regular projects,anyeverywhere. The Hub chat is always Eddie — an Arthur entry in the Hub picker would be a selection thatSessionChatBootstrappersilently ignores; an Eddie entry in a regular project would spawn a Hub Engine without a Hub - Sorting (server-side,
RecipeCategoriesService): (1) Category group — documented categories in doc order, then undocumented categories alphabetically, Recipes withoutcategorylast — (2) withintitle || name, case-insensitive. The list remains flat; the client groups by key on first occurrence and trusts the server sorting.
Categories Document _vance/config/recipe_categories.yaml:
categories:
- id: coding
title:
en: Coding
de: Programmierung
This document is a sorting aid, not a registry — deliberately soft:
- Neither complete nor mandatory. Categories not named in it still appear (alphabetically after the documented ones); a missing document means alphabetical group order without labels; Recipes without
categoryform the last “Other” group. - Fail-open. A malformed document (wrong type, missing/empty
id, duplicateid, empty title entries) is logged with WARN and ignored — the picker then shows the no-document fallback, but never blocks session start. - Locale-Map, no Locale resolution on the server.
titleis an open language→text map; the server passes it through, the client resolves: exact UI locale → base language (de-CH→de) →en→ humanized Id (code-read→Code Read). Tenant categories are an open vocabulary, true i18n is impossible for them — Humanize is the only generic fallback. - Cascade like Recipes: Project →
_vance→ Bundled (vance-brain-resourcevance-defaults/_vance/config/recipe_categories.yaml), first-hit-wins, no field merge — whoever overrides, rewrites all fields. - No cache, no event listener: the document is read per picker request (parse-on-every-read like Recipes).
Client Behavior:
- The modal entry “Default” is rendered by the client and sends
chatRecipe: nulltosession-bootstrap→ ServerRecipeResolver.applyDefaultingapplies as before (recipe → engine → default). This means the “Default” selection automatically reflects any project-/tenant-wide override of thedefault-Recipe — no separate sentinel needed. “Default” is ungrouped above all category groups. - Above the list is a narrow search field that narrows the display: case-insensitive substring filter over
title || nameanddescription(filterListedRecipes). Filtering happens before grouping — groups without hits disappear, “Default” remains selectable regardless of the filter. The filter is reset each time the modal is opened. - The bundled
default.yamlis therefore not marked withlisted: true; otherwise, there would be duplicate “Default” entries. - Foot has the same picker as a UI command:
/ui-new(Fullscreen-Lanterna, search field, Default + category groups, Enter starts, Esc cancels) — it calls the same REST endpoint and starts the pick viasession-bootstrapwithchatRecipe./new [recipe]is the text counterpart without a dialog. Explicit names remain free everywhere:--recipe,/session-bootstrap, and Spawn tools still accept all Recipes, not just listed ones —listed/categoryonly control the discovery pickers. Bundled Defaults withlisted: true(with category):arthur,eddie,discuss,trillian-adam,trillian-void(chat);coding,benjy-coding,code-read,app-builder(coding, App-Builder from the Bistromath-Addon);web-research,analyze,quick-lookup(research);ford,frankie,benjy,marvin,creator(workers). Of these, onlyeddiecarriesprojectKind: system— it appears exclusively in the Hub picker (_user_*,_tenant), all others only in regular projects. Tenant and Project layers can opt-in mark any further Recipes or remove bundled entries from the picker with an override file withoutlisted: true.
7. Fields on ThinkProcessDocument
For Engines to see the Recipe-derived values during a turn, they are projected onto the Process:
| Field | Source |
|---|---|
recipeName |
for Audit/UI — not for Engine logic |
connectionProfile |
active Profile value at spawn (foot/web/mobile) — for audit, if one later wants to trace under which profile a Process was created |
activeSkills |
seeded at spawn from Recipe defaultActiveSkills (each entry with fromRecipe=true, sticky). Thereafter normal Skill lifecycle |
allowedSkillsOverride |
Snapshot of Recipe allowedSkills as Set<String>?. null = no restriction; non-null = whitelist against which trigger match and /skill validate. Mirror to allowedToolsOverride |
engineParams |
populated with effectiveParams (Recipe defaults + Profile defaults + Caller merge) |
promptOverride |
Recipe promptPrefix + Profile promptPrefixAppend as unrendered Pebble template (rendering occurs per turn with current Tier/Mode/Profile context) |
promptMode |
APPEND / OVERWRITE |
dataRelayCorrectionOverride |
Recipe dataRelayCorrection, if set |
allowedToolsOverride |
effectiveAllowed, if != Engine default |
The Recipe is no longer referenced after spawn — the Process carries its effective configuration itself. This way, Processes survive if the original Recipe is later deleted or edited.
8. Discovery — How Arthur Finds Recipes
Static in the Prompt: When the Arthur system prompt boots, a section is appended:
## Available worker recipes
- `analyze` — Worker for substantive analysis: reads files, inspects state,
returns findings with cited evidence. Use for "analyse X", "compare Y".
- `quick-lookup` — Cheap one-shot fact retrieval. Use when one tool call
with a short answer suffices.
- `web-research` — Multi-source web research with summarization.
- `code-read` — Reads codebases, summarises structure, finds references.
The static list contains exactly the bundled Recipes (from YAML). Tenant and Project Recipes are not embedded there — they must be discovered via the Tool (see below). Advantage: Bundled defaults are known to the LLM without an extra round trip; the list only changes on Brain restart.
Dynamic via Tool: recipe_list (primary for Arthur) provides the effective Catalog for the current Tenant/Project view — i.e., bundled + tenant + project with correct override accounting. Arthur calls it as needed if the built-in default set is insufficient.
Schema:
recipe_list() → {
recipes: [ { name, description, engine, tags, locked, source: BUNDLED|TENANT|PROJECT }, … ],
count: int
}
Optional recipe_describe(name) as a Secondary Tool — returns the complete Recipe Document if Arthur wants to see the default parameters/prompt.
9. Spawn Cascade — RecipeResolver.applyDefaulting
Every spawn path outside of Engine internals goes through a central defaulting method. After trigger pipeline consolidation, the actual create + start runs through exactly one Executor (SpawnActionExecutor via the ActionExecutorRegistry); ProcessCreateTool / ProcessRunTool / SessionBootstrapHandler / ScriptCortexController are thin callers that build and dispatch a TriggerAction.Recipe. SessionChatBootstrapper is the only spawn point that directly calls thinkProcessService.create — it needs very tight lifecycle control (see arthur-engine). See trigger-actions for the pipeline vocabulary.
applyDefaulting(tenantId, projectId, recipeName, engineName, callerParams) →
recipeName != null → apply(recipeName)
(Error if Recipe unknown)
recipeName == null,
engineName == X → if recipe X exists → apply(X)
else → empty Optional
(Caller then falls back to engine-direct)
both null → apply("default")
(Error if `default`-Recipe missing — bundled, so always present)
Spawn examples:
| Call | Behavior |
|---|---|
process_spawn(recipe="analyze", params={...}) |
apply analyze, merge caller-params |
process_spawn(engine="ford", params={...}) |
apply Recipe ford (bundled) — provides prompt + validator overrides; merge caller-params |
process_spawn(engine="arthur") |
apply Recipe arthur |
process_spawn(name="foo") (neither engine nor recipe) |
apply Recipe default (ford-based, generic prompt) |
process_spawn(engine="custom-x") (no Recipe custom-x) |
engine-direct fallback without Recipe override (Engine fallback prompt) |
process_spawn(engine="ford", recipe="analyze", ...) |
Conflict — recipe wins, engine-argument is logged-and-ignored |
Arthur’s System Prompt recommends the Recipe path as standard. Engine-direct is a fallback for uncataloged custom Engines.
10. Initial Repertoire (Bundled Resources)
Current delivery as individual YAML files in vance-brain/src/main/resources/vance-defaults/recipes/. Three convention Recipes plus a series of specialized Workers:
| Name | Engine | Role | Usage |
|---|---|---|---|
default |
ford | Generalist Fallback | Called if neither Recipe nor Engine is set. Validation on, default:analyze model |
arthur |
arthur | Engine Default for Arthur | Full Arthur system prompt (Coordinator role), validator override for Intent-Without-Action. Auto-applied for engine="arthur" without Recipe |
ford |
ford | Engine Default for Ford | Worker personality + both validator overrides. Auto-applied for engine="ford" without Recipe |
quick-lookup |
ford | Fast One-Shot | Validation on, default:fast (SMALL model), 3 iterations |
analyze |
ford | Multi-Step Analysis | Validation on, default:analyze (LARGE), 10 iterations, dedicated Small-Variant-Prompt |
web-research |
ford | Multi-Source Web Research | default:web, 12 iterations |
code-read |
ford | Read-only Codebase Inspection | default:code, removes write-tools (client_file_write, client_file_edit, workspace_write, workspace_delete) |
Convention: Recipes named after an Engine (arthur, ford, later deep-think) are their default bundles and carry the source-of-truth prompts. Specialized Recipes (analyze, code-read, …) build upon them — same Engine, different prompt prefix + different params.
With the introduction of deep-think, for example, deep-think (Engine default), task-tree-plan, deep-analyze will be added — on the same Recipe track, with a new Engine underneath.
11. Tenant and Project Customization
Recipes are Documents — editing therefore happens via the Document Layer:
- Tenant-wide Override: Document with path
recipes/<name>.yamlin the_tenant-Project. Beats the Resource default without further configuration. - Project-Override: Document with path
recipes/<name>.yamlin the respective User Project. Beats_tenantand Resource. - Rollback to Default: Delete the Document — the next lookup falls back to the next outer Cascade stage.
There is no longer a dedicated Recipe REST controller — the Document Editor (CLI / Web-UI Document Editor) is sufficient. Who is allowed to do what comes from the Document ACL model, once that is defined.
11a. Model Alias Resolution
Recipe params.model ideally references aliases, not concrete provider models:
params:
model: default:analyze # Alias, resolved via Settings
Resolution rules (see llm-resource-management.md for details):
input := <prefix>:<rest>
prefix ∈ registered providers (gemini, anthropic, …)
→ use directly: (prefix, rest)
Setting `ai.alias.<prefix>.<rest>` set
→ recursively resolve with the resolved value
prefix == "default" and no alias configured
→ fall back to (ai.default.provider, ai.default.model)
else
→ UnknownModelException
This means:
- Recipes run out-of-the-box on any tenant — regardless of whether they have Gemini, Anthropic, or OpenAI keys.
default:fast/default:analyze/default:deeponly need to be configured by the tenant as aliases (or silently fall back toai.default.*). - Models come and go without Recipe editing.
gpt-5released? Set tenant settingai.alias.default.deep = openai:gpt-5, done. - Directly provider-specific specs (
anthropic:claude-sonnet-4-5) remain valid — e.g., if a Recipe author intentionally wants to bind to a specific model.
12. Open Points
- Versioning of Recipes. Today: Recipe is mutable, edits overwrite in-place. For reproducibility (which version of a Recipe ran on day X?), the Recipe would need to be versioned, with a reference from the Process to the specific version. Not v1, but not prevented by the schema.
- Recipe-Composition. Can Recipes extend other Recipes (
extends: analyze)? Not v1 — if needed, later. - Schema validation of
params. Currently freeMap<String,Object>. If Engines receive a typeddefaultSettings()schema (seethink-engines.mdpoint 4), the Recipe resolver can validate against the Engine schema before triggering the spawn. - Recipe Cost Model. Recipes could carry a
costClass(cheap/normal/expensive) that feeds into the quota system (llm-resource-management.md). A clean integration would be to query the quota system per Recipe call before allowing the spawn. - Recipe Discovery from a Worker Engine. Are Workers (Ford, Deep-Think) allowed to inspect Recipes themselves or spawn sub-workers via Recipe? V1 no — only Arthur orchestrates; for multi-level orchestration (deep-think → sub-deep-think), this needs to be reconsidered.