Appearance
Typed prompt vars
What you'll learn: how to reuse a single
step.defineacross multiplerun()call sites with different inputs, with TypeScript catching missing/extra/wrong vars at every site, and how to generate.d.tssidecars sopromptFile:paths get the same compile-time safety.
A workflow often wants to call the same step twice — with two topics, two slugs, two prompt variants. Until typed prompt vars, the choices were factory functions (a defineStep(topic) helper) or the loadPrompt() + prompt: escape hatch. Both work, but the type-checker stays silent if you forget a variable or typo a key.
orch addresses this with a single rule: declare what the prompt needs in the prompt itself. Inline literals get inferred via TypeScript's template-literal types; .md/.txt files get a generated .d.ts sidecar that augments a registry. Either way, the contract flows through Step<TResult, TVars> and into run(STEP, { vars }) — wrong call sites become red squiggles before the workflow ever runs.
When to reach for it
| Shape | Recommended pattern |
|---|---|
| Reuse the same step with different inputs | Inline prompt: 'Hi ' + run(STEP, { vars: { name } }) |
| Multi-paragraph prompt reused with different inputs | promptFile: '@/...' + sidecar codegen + run(STEP, { vars: { ... } }) |
| One-shot prompt with no reuse | Inline prompt: with no placeholders |
| Compose 2+ fragments before handing to the step | loadPrompt(path, vars) (substitutes eagerly; not the typed-vars path) |
If you find yourself writing a factory function whose only job is to vary one variable, that's the signal to reach for typed vars.
Inline literals — the zero-config path
Write the prompt as a string literal containing tokens. orch reads the literal type at compile time and produces the matching vars contract:
ts
import { claude, run, step, workflow } from 'orch'
const GREET = step.define('greet', {
agent: claude({ model: 'claude-haiku-4-5-20251001' }),
prompt: 'Hi {{name}}, focus on {{topic}}',
})
const wf = workflow('greet-twice', async (run) => {
await run(GREET, { vars: { name: 'Ada', topic: 'logic' } })
await run(GREET, { vars: { name: 'Boris', topic: 'cooking' } })
})A missing key:
ts
await run(GREET, { vars: { name: 'Ada' } })
// ~~~~~~~~~~~~~~~~~~~~~~ Property 'topic' is missingA typo:
ts
await run(GREET, { vars: { name: 'Ada', tpic: 'logic' } })
// ~~~~ Object literal may only specify known propertiesA wrong type:
ts
await run(GREET, { vars: { name: 'Ada', topic: ['logic'] } })
// ~~~~~~~~~ Type 'string[]' is not assignable to 'string | number | boolean'orch's TLT extractor handles whitespace inside the braces, duplicate placeholders, and an optional ? suffix marker (see the next section). The contract lives at the type level only — at runtime, substitution still runs once per run() call, before the agent ever starts.
Optional placeholders (the ? suffix)
A trailing ? inside the braces marks an optional placeholder. The inferred contract uses ?:, missing values substitute to the empty string, and the runtime accepts the key being omitted entirely:
ts
const GREET = step.define('greet', {
agent,
prompt: 'Hi {{name}}{{tone?}}',
})
await run(GREET, { vars: { name: 'Ada' } }) // ok — tone omitted
await run(GREET, { vars: { name: 'Ada', tone: '!' } }) // ok — tone presentWhitespace around the marker is tolerant — {{name?}}, {{ name? }}, and {{ name ? }} all parse identically (the substituter and the type-level extractor share the same regex).
promptFile: — generated sidecars for multi-paragraph prompts
Multi-paragraph prompts live in .md files. To make them participate in the same typed contract, run orch types once — it generates a .d.ts sidecar next to each prompt file that augments orch's PromptFileRegistry:
md
<!-- .orch/prompts/brainstorm.md -->
Topic: {{topic}}
Depth: {{depth?}}
Brainstorm three angles on the topic above.Run the generator:
sh
bunx orch typesThis writes .orch/prompts/brainstorm.md.d.ts:
ts
// AUTO-GENERATED by `orch types` — do not edit.
declare module 'orch' {
interface PromptFileRegistry {
'@/.orch/prompts/brainstorm.md': {
topic: string | number | boolean
depth?: string | number | boolean
}
}
}
export {}Now in your workflow:
ts
const BRAINSTORM = step.define('brainstorm', {
agent,
promptFile: '@/.orch/prompts/brainstorm.md',
})
await run(BRAINSTORM, { vars: { topic: 'crows', depth: 3 } })
await run(BRAINSTORM, { vars: { topic: 'magpies' } })The same TypeScript guarantees you got from the inline literal now flow through the .md file. Edit the prompt to add a new placeholder, re-run orch types, and every caller that misses the new key becomes a compile error.
Watch mode keeps the sidecars fresh
bunx orch types --watch keeps a regen loop alive while you edit. Save a prompt file, the matching .d.ts rewrites within ~250 ms, and your IDE picks up the new contract.
The @/... form is the typed path
Workflow-local paths (promptFile: 'slug.md') still work at runtime — substitution happens in assemblePrompt the same way — but only @/... paths get static inference in v1. The sidecar emitter uses the project-rooted key as the registry lookup, which gives every prompt a unique, collision-free identifier. Adopt @/ from day one for any file you want type-safe call sites.
Cold clones work without setup
orch run <workflow> runs the codegen pre-pass at startup. A fresh clone with no .d.ts files runs the workflow successfully — runtime substitution still enforces var correctness — and emits the sidecars in passing so the next IDE / bun run check session sees the typed contract. You never need to remember to run orch types before orch run.
Configuring discovery
By default orch types discovers prompts under .orch/workflows/**/*.{md,txt} and .orch/prompts/**/*.{md,txt}. Override via orch.config.ts:
ts
import { defineConfig } from 'orch'
export const config = defineConfig({
workflows: { ... },
prompts: {
include: ['custom/prompts/**/*.md'],
exclude: ['custom/prompts/drafts/**'],
},
})Explicit configuration replaces — it does not merge with the defaults. If you set include, list every glob you care about.
Migration from vars: on step.define
Earlier versions accepted vars: directly on step.define. That form is no longer allowed because it locked each step to a single set of inputs at module-load time. The migration is mechanical:
ts
// Before
const SLUG = step.define('slug', {
agent,
promptFile: 'slug.md',
vars: { userPrompt: 'add CSV export' }, // <-- baked at define()
})
const { slug } = await run(SLUG)
// After
const SLUG = step.define('slug', {
agent,
promptFile: 'slug.md',
})
const { slug } = await run(SLUG, { vars: { userPrompt: 'add CSV export' } })The error message at the old call site spells out the same recipe:
text
step.define("slug"): "vars" is no longer allowed on define — move vars to
run(STEP, { vars: ... }) for typed per-call injection.If you used a factory function (defineSlugStep(topic)) to vary one variable per call site, delete the factory and write the step once. The factory pattern is what typed prompt vars exists to replace.
How it interacts with caching
Two run(STEP, { vars: ... }) calls with different vars produce distinct cache keys inside the same workflow run — both calls execute. Two calls with the same vars hit the cache and execute once. The cache key is derived from a stable hash of the vars object, so key insertion order does not matter.
If you set as: explicitly, that wins: run(STEP, { as: 'override', vars: { ... } }) uses 'override' verbatim and does not append a vars hash. Reach for as: when you want to control the cache name yourself (e.g. you're checkpointing a step under a stable name across runs).
Where to go next
step.define,Step<TResult, TVars>,RunOverrides, and thePromptFileRegistryinterface are documented in the API reference.- The end-to-end working example lives at
tests/fixtures/typed-vars/reusable-step-workflow/— read it alongside this page. - For composing fragments rather than parameterising a single prompt, see the File-based prompts guide and
loadPrompt.