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.
types triggers steps runners bindings loops sinks external ports
No runtime to install. No platform to sign up for. The interface is the deliverable.
github · issue opened → run → slack · notify ⟲ retries under the hood
an issue in · a slack post out · a review 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
classhierarchies & 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 whole 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 entire language.
Structural records. 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
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" }
⇥
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 screen of code, read top to bottom.
The smallest factory that still shows the three things a factory is for: a credential, an agent runner, and a review loop with a bounded retry. Pick a part to see what it does.
minimal.fct
1# the data that flows
2type Issue = { repo: text, number: int, title: text, body: text }
3type AgentCall = { token: secret, prompt: text }
4external port credential(key: text) -> secret
5runner agent:
6 kind: opencode
7 in: AgentCall
8trigger issue_opened:
9 kind: github
10 config: { path: "/api/github/webhook", target: { one: { repo: "acme/widgets" } } }
11 out: { issue: Issue }
12loop build:
13 in: { issue: Issue }
14 out:
15 result: text = review.result
16 approved: bool = review.approved
17 step draft:
18 runner: agent
19 in:
20 prompt: "Draft a reply for the issue."
21 issue: in.issue
22 token: credential(key: "opencode")
23 prev feedback: review.result if not review.approved and draft.attempt < 3
24 out: { draft: text }
25 step review:
26 runner: agent
27 in: { prompt: "Approve the reply.", text: draft.draft }
28 out: { approved: bool, result: text }
29 sink progress:
30 kind: file
31 config: { path: "out/progress.json" }
32 in: { attempt: draft.attempt, draft: draft.draft }
33step run:
34 use: build
35 in: { issue: issue_opened.issue }
36sink notify:
37 kind: slack
38 in:
39 channel: "#support"
40 handles: [ "@oncall", "@platform" ]
41 text: run.result
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 notify waits downstream and fires
once.
uses credential port → secret agent opencode
issue trigger · github draft step review step notify sink · slack
draft ⟵ prev feedback ⟵ review re-fires while not approved & under 3 attempts
in loop progress sink · each pass attempts recorded: 0
ready
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
step draft: runner: agent
sink notify: kind: slack
↔
- Local — docker containers & the OpenCode server
- Cloudflare — workers as steps, R2 as sink
- AWS / GCP — Lambda/Batch steps, SQS triggers
- 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.