A spec, not a runtime

Describe the workflow.
Not the implementation.

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.

typed dataflow triggers steps sinks loops maps lists options config

No runtime to install. No platform to sign up for. The walkthrough is a real file; the feature excerpts come from factories that validate.

github · issue opened → draft ⇄ review → github · comment ⟲ a reviewed reply

an issue in  ·  a reviewed reply out  ·  a retry loop in between

The problem

You are thinking in dataflow. You are writing everything else.

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.

What you actually mean

  • The trigger fires on a GitHub issue.
  • The step drafts a reply.
  • The reviewer approves or sends it back.
  • The sink pings the team on Slack.

What the language asks about

  • class hierarchies & metaclasses
  • interface satisfaction & goroutine lifecycle
  • ownership, borrowing, lifetimes
  • the event loop, the DI wiring, the framework
When the language fits the domain, the code becomes a direct translation of intent. Everything else follows from that.

The core vocabulary

Eight concepts. No spare parts.

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.

T

Type

Structural records, lists and maps. Fields and shapes — no classes, inheritance, or constructors.

type Issue = { number: int }
▶

Trigger

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" } } }
▸

Step

Where work happens. Invokes a unit and binds its typed outputs.

step draft:
  runner: agent
⚙

Runner

How a step runs: a shell command, a container, or an agent behind a typed interface.

runner agent:
  kind: opencode
  in:   open
  out:  open
↦

Binding

Named edges that move values between ports. The bindings are the program.

in: { issue: in.issue }
⟲

Loop

A prev (loop-carried) binding re-fires a step with feedback — a retry, not control flow.

prev feedback: review.result
■

Sink

An effect. Inside a loop it fires each pass; downstream it fires once.

sink result:
  kind: file
  config: { path: "out/result.json" }
⇥

External port

A bridge to the outside, implemented by the host. Secrets are opaque — redacted everywhere.

external port credential
  (key: text) -> secret

Anatomy of a factory

One real file, read top to bottom.

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.

reply.fctscroll ↕source ↗
1import "types" as gh
2
3external port credential(key: text) -> secret
4external port github_token(repo: text) -> secret
5
6type AgentCall = { token: secret, prompt: text }
7
8runner agent:
9 kind: opencode
10 in: AgentCall
11 out: open
12
13trigger issue_opened:
14 kind: github
15 config: gh::GithubConfig = {
16 path: "/api/github/webhook"
17 target: { one: { repo: "acme/widgets" } }
18 event: issues
19 }
20 out:
21 issue: gh::Issue
22 repository: gh::Repository
23 action: gh::GithubIssueAction
24 sender: text
25
26loop reply:
27 in: { issue: gh::Issue }
28 out: { body: text = review.body }
29
30 step draft:
31 runner: agent
32 in:
33 token: credential(key: "opencode")
34 prompt: "Draft a reply. Return only the reply text."
35 title: in.issue.title
36 body: in.issue.body
37 prev feedback: review.body if not review.approved and draft.attempt < 3
38 out: { body: text }
39
40 step review:
41 runner: agent
42 in:
43 token: credential(key: "opencode")
44 prompt: "Review the draft. Return ready and the final text."
45 text: draft.body
46 out: { approved: bool, body: text }
47
48 step progress:
49 kind: file
50 config: { path: "out/reply-progress.json" }
51 in: { attempt: draft.attempt, body: draft.body }
52
53step post:
54 use: reply
55 in: { issue: issue_opened.issue }
56
57step comment:
58 kind: github_issue
59 config: { action: "comment" }
60 in:
61 token: github_token(repo: issue_opened.repository.name)
62 repo: issue_opened.repository.name
63 number: issue_opened.issue.number
64 body: post.body

Beyond the loop

Small language. Sharp edges.

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.

≡

Lists and callbacks

v1.28 · v1.34

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 ↗
#

Maps

v1.29

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 ↗
?

Options and absence

v1.33

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 ↗
⚙

Configuration

v1.35

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

A run is a round of firings until nothing can fire.

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.

uses credential port → secret agent opencode
issue trigger · github draft step review step comment sink · github_issue
in loop progress sink · each pass attempts recorded: 0
ready

A simulated animation of reply.fct. The code and adapters are real; the run is illustrative.

Why a spec

Write the factory once. Host it anywhere.

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.

# portable, self-describing
trigger issue_opened: kind: github · event: issues
loop reply: draft ⇄ review
sink comment: kind: github_issue
↔
  • Local — the reference host: containers & OpenCode
  • Cloudflare — workers as steps, R2 as a sink (adapter)
  • AWS / GCP — Lambda/Batch steps, SQS triggers (adapter)
  • Your own — register the kinds your domain needs

The spec defines the concepts; implementations follow. Tooling — validators, diagrammers, editors — can be built against the interface, independently of any one host.