Skip to content

Typed prompt vars

What you'll learn: how to reuse a single step.define across multiple run() call sites with different inputs, with TypeScript catching missing/extra/wrong vars at every site, and how to generate .d.ts sidecars so promptFile: 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

ShapeRecommended pattern
Reuse the same step with different inputsInline prompt: 'Hi ' + run(STEP, { vars: { name } })
Multi-paragraph prompt reused with different inputspromptFile: '@/...' + sidecar codegen + run(STEP, { vars: { ... } })
One-shot prompt with no reuseInline prompt: with no placeholders
Compose 2+ fragments before handing to the steploadPrompt(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 missing

A typo:

ts
await run(GREET, { vars: { name: 'Ada', tpic: 'logic' } })
//                                      ~~~~  Object literal may only specify known properties

A 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 present

Whitespace 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 types

This 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