Lists and callbacks
v1.28 · v1.34Project, filter and accumulate with pure built-ins. A callback is an expression, not a function value.
labels: [text] = list::map( in.issue.labels, l -> l.name )the spec: lists ↗
A spec, not a runtime
dotFactory is a small language for automations with and without AI agents. You write the dataflow — typed values moving through triggers, steps and sinks — and leave the object models, async runtimes and dependency-injection frameworks behind.
No runtime to install. No platform to sign up for. The walkthrough is a real file; the feature excerpts come from factories that validate.
an issue in · a reviewed reply out · a retry loop in between
The problem
A trigger feeds a step, the step calls an agent, the output feeds another step, maybe a retry, then a sink. That picture is simple. But the moment you open an editor, the language makes you manage machinery that has nothing to do with it.
class hierarchies & metaclassesWhen the language fits the domain, the code becomes a direct translation of intent. Everything else follows from that.
The core vocabulary
Every idea earns its place by being needed to describe how work flows through a graph. This is the core of the language; the rest is data, built-ins and host adapters.
Structural records, lists and maps. Fields and shapes — no classes, inheritance, or constructors.
type Issue = { number: int }
Admits one seed value and holds it constant for the whole run. The host supplies the kind.
trigger issue_opened: kind: github config: { path: "/api/github/webhook", target: { one: { repo: "acme/widgets" } } }
Where work happens. Invokes a unit and binds its typed outputs.
step draft:
runner: agent
How a step runs: a shell command, a container, or an agent behind a typed interface.
runner agent: kind: opencode in: open out: open
Named edges that move values between ports. The bindings are the program.
in: { issue: in.issue }
A prev (loop-carried) binding re-fires a step with feedback — a retry, not control flow.
prev feedback: review.result
An effect. Inside a loop it fires each pass; downstream it fires once.
sink result: kind: file config: { path: "out/result.json" }
A bridge to the outside, implemented by the host. Secrets are opaque — redacted everywhere.
external port credential (key: text) -> secret
Anatomy of a factory
A GitHub issue arrives, an agent drafts a reply, and a reviewer approves it or sends it back. The loop retries while the reply is not ready, and the result is posted back to the issue. Pick a part to see what it does. validated · v1.35
The real file:
examples/reply.fct.
It passes fct validate and fct sinks.
Beyond the loop
The retry is the execution model. These are the everyday features that keep the factory small: collections without loops, open-keyed data, absence that cannot be confused with null, and configuration that lives with the deployment.
Project, filter and accumulate with pure built-ins. A callback is an expression, not a function value.
labels: [text] = list::map( in.issue.labels, l -> l.name )the spec: lists ↗
Text keys, one value type, structural conformance. A missing path is absence; map::* is total.
type Labels = map(text, text) team: text = labels.team owner: text? = map::at(labels, "owner")the spec: maps ↗
T? is some(T) | none; absence is a probe, never a value. Branch with is some / is none.
type Issue = { milestone: text? } sink save: when: start.issue.milestone is some in: { text: opt::value(start.issue.milestone) }the spec: options ↗
A deployment value, not source: factory → user → organization, then a wider tier. Text, readable, editable.
model: text = config( key: "agent.model", default: "opencode-go/deepseek-v4.1-flash" )the spec: config ↗
Short excerpts from the validated factories; the anatomy above is the complete file.
The execution model
Press play. The trigger admits a GitHub issue, the loop runs draft →
review, the reviewer sends the draft back once, then approves.
The progress sink fires inside the loop —
once per pass — while comment waits downstream and posts
once.
A simulated animation of reply.fct. The code and adapters
are real; the run is illustrative.
Why a spec
The factory describes what should happen. The host supplies how: which trigger kinds exist, which runner backends run, where sinks write, how ports resolve. Those are adapter concerns — so they can compete without locking in your workflows.
The spec defines the concepts; implementations follow. Tooling — validators, diagrammers, editors — can be built against the interface, independently of any one host.