ThreadLang is a small DSL for deterministic LLM workflow programs. Execution is parse → AST → runtime → emit, with structured trace events at every phase.
- Deterministic parsing — same source produces the same AST.
- Explicit, inspectable AST nodes (frozen dataclasses).
- Runtime traceability — every step appends a
TraceEvent. - No hidden magic; clarity over cleverness.
- Zero required runtime dependencies. The OpenAI-compatible backend uses
stdlib HTTP; the
anthropicextra is required only forAnthropicClient.
- Cyclic control flow, loops, and recursion.
- Parallel step scheduling.
- Streaming output.
- External events, human approval, cancellation, and in-flight migration.
- Distributed execution.
- Source-level system prompts (plain llm/emit calls send one user-role prompt; agent message history is runtime-owned).
- An advanced type system (expression values are strings).
These are deliberate. The point of v1 is that the workflow shape (context → steps → emit) actually executes; the surface area is held narrow on purpose so it doesn't outgrow the parser before the model layer earns extension.
program = "thread" name "{" context [ steps ] emit_block "}"
context = "context" "{" { name "=" string } "}"
steps = "steps" "{" { step } "}"
step = "step" name "{" ( llm_body | agent_body | route_body ) "}"
llm_body = "llm" string "{" expression [ expect ] [ then ] "}"
agent_body = "agent" string "{" [ tools ] [ max_iters ] expression [ then ] "}"
tools = "tools" "[" [ name { "," name } ] "]"
max_iters = "max_iters" integer
expect = "expect" "{" rule { rule } "}"
rule = "one_of" string { "," string } | "matches" string
| "max_chars" integer | "nonempty"
route_body = "route" string "{" expression arm { arm } [ "else" "->" target ] "}"
arm = "on" string "->" target
then = "then" "->" target
target = name | "end"
emit_block = emit_text | emit_llm
emit_text = "emit" "text" "{" expression "}"
emit_llm = "emit" "llm" string "{" expression "}"
expression = term { "+" term }
term = string | "context." name | "inputs." name
| "steps." name ".output" [ "?" ]
integer = digit { digit }
contextblock: name → string-literal map. Required.stepsblock: zero or more forward-onlyllm,agent, orroutestep definitions. Optional. Each executed step binds its output tosteps.<step_name>.output; route edges can skip later steps.emitblock: required. Eitheremit text(string concatenation over expression terms) oremit llm "<model>" { ... }(rendered prompt sent to the model; response becomes the program output).- Step names within a single
stepsblock must be unique.endis a reserved jump target and cannot be a step name. - Source bytes, string literals, regex patterns, and
max_itersare bounded by normative fail-closed policy; values over those limits are rejected. - Comments and delimiters inside quoted strings are lexical content, not structure. The parser consumes all input and reports line/column errors.
The language semantics are independent of storage. The bundled durable runtime
provides step-boundary checkpoints on one POSIX process and one local SQLite
store. It binds a v0.13 run to its canonical Workflow IR and canonical inputs;
the source digest is retained as metadata and as the legacy resume fence for
rows without IR identity. Concurrent resume is rejected with a compare-and-swap
transition. A hard crash may repeat the current incomplete llm, agent, or
route step, or an incomplete emit llm; this is not deterministic
event-history replay. Side-effecting tools must be declared idempotent to run
durably. The full operational contract is production.md.
Steps form a forward-only DAG. Every step has an outgoing edge:
- default — fall through to the next declared step;
then -> <step|end>on anllm/agentbody — an explicit edge;- a
routebody'son "<label>" -> <target>arms — conditional edges, picked by the model under an output contract (see below); end— skip the remaining steps and go to emit.
All targets must be declared after the step that jumps to them (parser-enforced), so every step runs at most once per run. This is what keeps step-name checkpoints, resume, and replay correct with routing.
A route step calls its model with the rendered prompt plus a generated
output contract ("Reply with exactly one of: ...") derived from its arm
labels. The reply is normalized (whitespace/quote trim, case-insensitive)
and must equal an arm label. A miss is traced as a rejection and retried
once with the violation fed back; a second miss takes the else -> edge
(binding the raw reply as the step output) or fails the run if there is
none. The chosen label is bound to steps.<name>.output; the jump itself
is deterministic code.
steps.<name>.output? (optional reference) renders as "" when the step
was skipped by routing — how emit or a join step reads branch outputs.
The non-optional form on a skipped step fails the run.
An llm body may carry an expect { ... } block — a conjunction of rules
its reply must satisfy, one per line:
one_of "a", "b"— the reply must be one of the listed values, matched with the same normalization as route labels (whitespace/quote trim, case-insensitive); the canonical value is what gets bound.matches "<regex>"— the whole bound output must match the pattern (re.fullmatch); may appear more than once.max_chars N— reply length cap.nonempty— the reply must contain non-whitespace text.
The rendered contract is appended to the prompt, so the contract the
runtime enforces is the contract the model was shown. With any expect
present the bound output is whitespace-stripped. one_of is applied
first regardless of declaration order, so the other rules validate the
output that will actually be bound. A violating reply is
traced (contract phase), retried once with each violation named in the
feedback, and a second violation fails the run — contracts are hard
requirements; there is no else edge for llm steps. Rules are validated
at parse time (regexes compile, max_chars >= 1, no duplicate values or
rule kinds — matches excepted).
A step whose contract includes one_of is a closed-enum call and is
dispatched through the client's optional route(model, prompt, options)
protocol when present — the dry-run client answers with the first value,
keeping contracted programs runnable offline. expect is only valid on
llm bodies: a route step's contract is its arms, and an agent step's
final answer is shaped by its tool loop.
- Execute source through
run_program, or strictly load Workflow IR v1 and bridge it throughprogram_from_ir/run_irto the same authoritative AST interpreter. - Build the deterministic context map.
- Traverse the forward-only step graph from the first declared step:
llmrenders its prompt and callscomplete(or optionalroutefor aone_ofcontract). Contracts are validated, retried once with feedback, and then fail closed.agentcallsagent_stepin a bounded loop, executes only allow-listed tools, feeds observations back to the client, and binds the tool-free final answer.routecalls optionalrouteor falls back tocomplete, normalizes the closed-label result, retries one rejection, takes the matching forward edge, useselseafter a second miss, or fails if noelseis declared.- After step completion and outgoing-edge resolution, bind the output and invoke the optional checkpoint callback. Resumed outputs skip their model/tool work.
- Evaluate the emit block:
emit text— concatenate expression terms.emit llm— render prompt expression, call model, return response.
- Append the final emit event and return
RuntimeResult(output, trace, step_outputs).
Trace phases cover context construction, llm and agent turns, tool calls and
denials, routing, contract rejection, emit text term evaluation, checkpoint
reuse, and final emission. A resumed route re-derives its edge from the stored
output without another model call.
class LLMClient(Protocol):
def complete(self, model: str, prompt: str) -> str: ...
class AgentLLMClient(Protocol):
def agent_step(
self,
model: str,
messages: Sequence[Message],
tools: Sequence[ToolSpec],
) -> AgentTurn: ...
class RouteLLMClient(Protocol):
def route(
self,
model: str,
prompt: str,
options: Sequence[str],
) -> str: ...complete is the baseline protocol for llm, emit llm, and the fallback
route path. agent_step is required only by agent steps. route is optional;
when absent, the runtime sends the same closed-label contract through
complete.
Built-in implementations:
DryRunClient— deterministiccomplete, first-option routing, and a two-phase agent stub. Used by tests and explicitthreadlang --dry-run.AnthropicClient— Claudecompleteand native tool use via the optionalanthropicSDK. Requirespip install 'threadlang[anthropic]'andANTHROPIC_API_KEY.OpenAICompatClient— dependency-free stdlib HTTP client implementingcompleteandagent_stepthrough OpenAItools/tool_calls. It defaults to DeepSeek and can target OpenAI, Ollama, vLLM, or another compatible/v1endpoint.
Any object satisfying only complete(model, prompt) -> str can run llm/emit
work and routes through the fallback. Agent programs require agent_step.
threadlang [--version] [--from-ir] [--emit-ir PATH]
[--input k=v ...]
[--backend {dry-run,anthropic,openai}] [--dry-run]
[--base-url URL] [--max-tokens N] [--timeout SECONDS]
[--store PATH] [--resume RUN_ID] [--probe N]
[--trace] [--metrics]
<SOURCE>
--inputis repeatable. Keys are referenced asinputs.<key>.--from-irstrictly loads canonical Workflow IR instead of source;--emit-ir PATHcompiles or normalizes IR and exits (-writes stdout). It cannot combine with--store,--resume, or--probe.--backendselects a real provider or deterministic dry-run and defaults toanthropic;--max-tokensand--timeoutconfigure provider calls.--dry-runis shorthand for--backend dry-run.--base-urlconfigures the OpenAI-compatible endpoint.--storeenables durable trace/checkpoint persistence.--resumerequires--store, verifies the current definition and effective inputs against stored identity, and reuses completed checkpoints.--probe Nalso requires--store, cannot combine with--resume, and prints a persisted stability report.--traceprints structured trace events to stderr;--metricsprints metrics derived from that trace.- If the selected real provider cannot be constructed and the program needs a
model call, the CLI exits with an error. It never turns a real run into
synthetic dry-run output; use
--dry-runexplicitly for plumbing checks. - A program with no model steps and
emit textstill runs without a provider, because its selected client is never called.