Skip to main content

Why a DSL

Everyone who runs agents in production eventually hits the same class of incident:

The flow reaches step seven and the model has quietly "optimized away" a validation; some branch never ran. Two people debug for two days and finally find, deep in the transcript, that a long context in turn three diluted the instructions.

That is not a model-capability problem. You put control flow on a probabilistic channel. Prompts are for judgment, not for process; process needs determinism, and determinism needs a form that can be checked.

Agent Flow's entire position is one sentence:

Whatever a machine can execute deterministically, never let a model improvise.

The three ways you write this today, and what each lacks

Way 1: control flow in the prompt

You have probably seen the symptoms:

  • Constraints develop amnesia. Change one word, behavior shifts; ten turns later "validate, then continue" has become "looks fine, continue"
  • Un-reviewable. No one can prove a 500-word prompt covers every branch — code review is blind here
  • The verdict lives in the model. The prompt says "continue after the check passes" — whether it passed, the model reports. In production that is no verification at all
  • Not replayable. Same input, different execution path; when something breaks you cannot reproduce it

Way 2: control flow in YAML / JSON

Feels more "engineered", but it's the same recipe on a different semantics-free text:

  • No types: a mistyped field or a drifted shape explodes at runtime
  • No shared semantics: every framework invents its own depends_on and ${ref}; the same YAML changes meaning across frameworks
  • What orchestration actually needs — concurrency budgets, output contracts, verification verdicts, team membership — has no representation, only layer upon layer of field conventions

Way 3: general-purpose code (LangGraph / Semantic Kernel / raw SDKs)

Maximum expressiveness, therefore maximum exposure:

  • Workflow is arbitrary code: sandboxing is theater; untrusted distribution is off the table
  • "Can this flow concurrently write the same file?" — requires tracing every callback by hand; no static analysis exists
  • Concurrency gate, budget, timeout, retry, order restoration… every constraint is a hand-written pattern; miss one line, ship one incident
  • A framework's constraints live in convention; a language's live in grammar. The former rots, the latter doesn't

One task, four ways

An ordinary task: process 20 emails concurrently — classify each, draft replies for the ones that need them. Constraints: concurrency ≤ 4; total calls ≤ 40; per-call timeout 2 minutes; output must carry id and category; ids must not repeat.

The prompt version

You are an email assistant. Classify the 20 emails below as urgent /
normal / spam, and draft polite replies for those needing one. Note: do
not process too many at once, keep total calls under 40, spend at most
2 minutes per email, return JSON, every item must include id and
category, ids must not repeat, please strictly follow…

How many turns do those constraints survive? Nobody knows. When an id duplicates, who intercepts it?

The YAML version

steps:
- id: classify
foreach: ${emails}
concurrency: 4 # Who enforces it? Does it stack with other steps?
output_schema: classified_email # Who validates? And after failure?
- id: draft
when: ${classify.needs_reply} # Does the field exist? When do typos surface?
budget: 40 # Is "budget" this framework's word — or your imagination?

Every line leans on one interpreter's private semantics — switch frameworks, rewrite everything.

The code version

const sem = new Semaphore(4);
let runs = 0;
const seen = new Set();
const out = await Promise.all(emails.map(async (e) => {
if (++runs > 40) throw new Error('budget'); // forgot on the retry path
const r = await withTimeout(call(e), 120_000); // forgot to void late results
if (!r.id || seen.has(r.id)) throw new Error('dup'); // you must remember all this
return r;
}));
// Is the gate fair? Is output order restored? Would you run this in an untrusted sandbox?

Every line is a pattern; miss one line, ship one incident — and nothing statically checks what you missed.

The Agent Flow version

limits {
concurrency: 4
agent_runs: 40
duration: 20m
}

stage classify -> ClassifiedEmail[] {
let items = parallel map input.emails as item limit 4 {
agent(assistant) {
task "Classify one email: urgent, normal or spam"
input { email: item }
tools none
expect ClassifiedEmail // contract: id and category, or it fails
timeout 2m
}
}

require unique(items[*].id)
else fail "ids must stay unique"

return items
}

Concurrency, budget, timeout, contract, uniqueness — every one of them is checked syntax, not convention. Get any of them wrong and flow check refuses you with a line number, instead of production finding out for you.

One bug, four places to die

These failure modes die at different stages depending on how you wrote the flow — the earlier it dies, the cheaper it is:

Failure modePromptYAML / JSONGeneral codeAgent Flow
Mistyped field / drifted shapeproduction incidentruntime errormaybe code reviewcompile time
A step gets skippedroutinerarerareimpossible (grammar)
A forged "passed" verdictundetectablehost code's jobcode's jobimpossible (Runtime verdict)
Over budget / unauthorized writethe invoice, laterno representationhand-written, missedenforced by limits / write

Write permissions form a compile-time contract

write accepts only a static path[]. Array literals infer a compatible common element type, so [input.kb_path] is correctly path[] when the field is a path; arrays containing text, numbers, or incompatible elements are rejected at compile time. Passing the type check validates only the request shape—the runtime still intersects it with host policy.

Why it has to be a language

Because three guarantees only exist at the language level — no framework or convention provides them:

  1. Grammar: the mistake cannot be written. Referencing a stage dependency without declaring it, expect-ing a type that doesn't exist, using a reserved word as a variable — these do not parse. Not a lint suggestion; an impossibility
  2. Types: bad data cannot enter. An agent's output contract = a type = a JSON Schema, enforced by Ajv at runtime with one automatic repair, then a hard failure; every downstream field access was checked at compile time
  3. Runtime: verdicts and budgets cannot be bypassed. verification.passed can only be computed by pass when; agent_runs / duration / tools / write are gates, not suggestions

The third one needs a controlled execution environment: Agent Flow compiles to restricted JavaScript running in a subprocess isolate whose only egress is the host ABI, with JSON-only boundary values. General-purpose code cannot give you "cannot" — a restricted language can. This is also the technical foundation of "embeddable and vendor-free": the entire coupling between language and model is one interface, AgentRuntime.

Frequent objections

Models keep getting stronger — won't prompts be enough?

Stronger models fix judgment, not reliability. No matter how strong, a model should never own the verdict "I checked" — that's an architecture question, not a capability question. Quite the opposite: the stronger the model, the more it deserves a deterministic skeleton so its capability lands consistently instead of fluctuating with conversation quality.

Another language to learn — isn't that expensive?

Forty-three reserved words and one page of grammar — an afternoon to read, and the Quickstart gets a first workflow running in ten minutes. Compare that with the 800-word prompt you maintain today, versioned as v7_final_final.txt, manually re-tested after every edit. A DSL isn't added complexity; it's the flow already living in your head, written into a form a machine can check.

Why not just LangGraph / Semantic Kernel?

They solve "writing orchestration in a general language, comfortably"; Agent Flow solves "orchestration you can trust". Different goals: general languages are powerful but cannot draw boundaries; a restricted language gives up Turing completeness and gets back static checking, sandboxed execution, and gates that cannot be bypassed. They also compose: write host and integrations in your general language, run the workflow core as .flow.

How do I integrate an existing system?

The language binds to no model or vendor. The host implements the AgentRuntime interface (resolveAgent / resolveTeam / invocation) and injects it — see Packages & Architecture. Switch models or vendors; the workflow doesn't change a line.

What it is not for (an honest boundary)

  • Single-turn Q&A, summaries, translating a passage — call the API directly; no intermediate layer
  • Exploratory multi-agent conversations where the process itself is still evolving — let it settle in conversation first, then freeze it as .flow
  • One-off scripts with no governance or replay requirements — whatever is fastest

The sweet spot: the process is thought through, and what you need is reliable, auditable, replayable execution. Leave the chaos of exploration to conversation; give the settled skeleton to a language — that is exactly what "determinism first" means.

Next steps