Most multi-step LLM code starts the same way: a Python function that glues prompt strings together. Extract some claims, feed them into a second prompt, maybe a third. It works. Then it grows. A few weeks later the shape of the workflow — what calls what, in what order, over which inputs — is buried inside string concatenation and control flow. You can't see it at a glance, you can't diff it cleanly, and you can't test it without paying for a model round-trip.
ThreadLang is a small experiment in fixing that: a DSL where the workflow is the program, and the program is something you can read, trace, and run without a network call.
A ThreadLang program is three things: context (deterministic values), steps (ordered LLM calls), and an emit (what comes out). Here's a two-step program — extract the key claims from some text, then retell them for a curious 10-year-old:
thread TwoStep {
context {
audience = "a curious 10-year-old"
}
steps {
step extract {
llm "claude-haiku-4-5-20251001" {
"Extract the three most important claims. Text:\n" + inputs.text
}
}
step retell {
llm "claude-haiku-4-5-20251001" {
"Rewrite these claims for " + context.audience + ":\n" + steps.extract.output
}
}
}
emit text {
steps.retell.output
}
}
Every value is explicit. inputs.text comes from the caller, context.audience is fixed, steps.extract.output is the first model's response bound by name. The dependency graph is right there in the source — not inferred from the order you happened to write your Python in.
Execution is four phases: parse → AST → runtime → emit. The parser produces frozen dataclass AST nodes. The runtime walks them and returns (output, trace, step_outputs) — and every context binding, every step call, every expression term appends a TraceEvent. You get a complete, structured record of what the workflow did, kept separate from what any model said.
The payoff is testing. ThreadLang ships a DryRunClient that satisfies the same LLM protocol but returns deterministic echoes instead of calling a model. So you can run any program, assert on its trace, and check the workflow's wiring end-to-end with zero API keys and zero nondeterminism:
threadlang examples/two_step.thread --input text="..." --dry-run --trace
The model is the one nondeterministic part, and it sits behind a one-method protocol — complete(model, prompt) -> str. Two clients ship today: an Anthropic adapter and the dry-run stub. Anything else — OpenAI, Ollama, a local model — is a one-method adapter away, and the workflow itself doesn't change.
v1 deliberately has no loops, no branching, no streaming, no tool use, and no real type system. That's not a roadmap I haven't gotten to — it's a decision. Each of those is a real language-design surface with its own semantics, and I wanted the basic workflow shape to actually run end-to-end before expanding it. A small thing that works beats a big thing that half-works.
The same logic drove the engineering choices:
anthropic is an optional extra, not a requirement.The first real addition is a rules block: pre- and post-conditions per step — output regex constraints, length bounds — where a rejection re-runs the step with a feedback prompt, up to N times. That turns "the model usually returns the right shape" into "the workflow guarantees the shape or fails loudly," which is the difference between a demo and something you'd put in front of production traffic.
ThreadLang is MIT-licensed and on GitHub. It's small on purpose — the interesting part isn't the line count, it's treating an LLM workflow as a program with a grammar, an AST, and a trace, instead of a pile of strings.