Tasks in Other Languages
For developers whose tasks are, or need to be, written in a language other than Go. Read Writing Tasks first: everything there about state, outputs and errors still applies, only the transport changes.
The Dwarf engine is written in Go, and so is the host that embeds it.
Your tasks don’t have to be. The host decides how each task is reached, so it can just as well call a
service written in Python, TypeScript, Java or anything else, over HTTP or any other transport. Graphs are
JSON too (workflow.Graph marshals to and from JSON), so LoadGraph can fetch them from a service in any
language as well.
There are two ways to do it.
Pattern 1: Plain JSON In and Out (Recommended)
The host sends each task its input as a plain JSON object and applies the JSON object that comes back as the task’s outputs. The task never sees anything Dwarf-specific, so it can be any HTTP endpoint, and the host stays in charge of retries and errors.
Here is a complete host that does this for every task, using the task’s URL as the endpoint to call:
type httpHost struct{}
func (h *httpHost) LoadGraph(ctx context.Context, workflowURL string) (*workflow.Graph, error) {
return fetch(ctx, workflowURL) // your registry / file / database / RPC
}
// ExecuteTask sends the step's input state to the task's URL as JSON and applies the
// JSON object that comes back as the task's outputs. Any language can serve the URL.
func (h *httpHost) ExecuteTask(ctx context.Context, taskURL string, f *workflow.Flow) error {
in, err := json.Marshal(f.Snapshot())
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, taskURL, bytes.NewReader(in))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
switch {
case res.StatusCode == http.StatusTooManyRequests || res.StatusCode == http.StatusServiceUnavailable:
if f.Retry(time.Second, 2.0, time.Minute, time.Hour) {
return nil // back off and run the task again
}
return fmt.Errorf("%s: gave up after %s", taskURL, res.Status)
case res.StatusCode >= 300:
return fmt.Errorf("%s: %s", taskURL, res.Status)
}
var outputs map[string]json.RawMessage
if err := json.NewDecoder(res.Body).Decode(&outputs); err != nil {
return err
}
for name, value := range outputs {
if !workflow.IsValidFieldName(name) {
return fmt.Errorf("%s: invalid output field %q", taskURL, name) // f.Set would panic
}
if err := f.Set(name, value); err != nil {
return err
}
}
return nil
}And here is a task served by Python, using only the standard library. The task itself is a typed function, and the input and output are dataclasses; the HTTP handler does the JSON conversion once:
# greet.py: a Dwarf task written in Python, using only the standard library.
import json
from dataclasses import asdict, dataclass, fields
from http.server import BaseHTTPRequestHandler, HTTPServer
@dataclass
class GreetIn:
name: str = ""
@dataclass
class GreetOut:
greeting: str
def greet(inp: GreetIn) -> GreetOut:
"""The task itself: typed input in, typed output out."""
return GreetOut(greeting="hello " + inp.name)
class Task(BaseHTTPRequestHandler):
def do_POST(self):
# The body is the step's full state; keep only the fields GreetIn declares.
state = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
known = {f.name for f in fields(GreetIn)}
out = greet(GreetIn(**{k: v for k, v in state.items() if k in known}))
body = json.dumps(asdict(out)).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body)
HTTPServer(("", 8077), Task).serve_forever() # all interfaces, port 8077A graph uses it like any other task:
g := workflow.NewGraph("Welcome")
g.SetURL("Greet", "http://greeter.internal:8077/greet")
g.AddTransition("Greet", workflow.End)Running the Welcome flow with {"name": "ada"} completes with greeting set to "hello ada".
What to know about this pattern:
- The task receives the step’s whole state and should read only the fields it needs. The Python
handler above keeps just the fields
GreetIndeclares. - Only the fields the task returns are its outputs. Each is written with
f.Set, exactly as a Go task writes it, so a field with a reducer takes the value as a contribution, as usual. The host checks each name first, sincef.Setpanics on a name that is not a valid field name. - Errors and retries stay in the host. A non-success status fails the attempt, and the graph’s
onErrortransition takes over if there is one. The host above backs off on429and503withf.Retry. Adjust that policy to your services. - The task must be idempotent, as every task must, because it can run more than once. Send
f.StepKey()along (for example in a header) if the service needs an idempotency key. - Control signals stay in Go.
Interrupt,Subgraph,GotoandSleepare called by Go code. If a remote task needs to request one, have it say so in its output, and let a small Go task (or the host) act on it.
Pattern 2: The Whole Flow Over the Wire
For a remote task that needs the step’s full context (its keys, its attempt number, its timestamps) or
wants to request a jump itself, the host can send the flow and apply the result that comes back. Wrap the
*workflow.Flow in workflow.RequestCarrier going out and workflow.ResultCarrier coming back:
func (h *flowHost) ExecuteTask(ctx context.Context, taskURL string, f *workflow.Flow) error {
body, err := json.Marshal(workflow.RequestCarrier(f)) // keys, state, attempt, timestamps, earlier changes
if err != nil {
return err
}
reply, err := post(ctx, taskURL, body) // your HTTP call
if err != nil {
return err
}
return json.Unmarshal(reply, workflow.ResultCarrier(f)) // the task's changes and control signals
}The task receives a request like this one, the first run of a Greet step:
{
"v": 1,
"flowKey": "1-235388-1-8484024c265f4028",
"stepKey": "1-235388-1-1-0c4a5c075564769a",
"state": {"name": "ada", "scratch": 1},
"changes": {},
"createdAt": "2026-11-07T20:38:22.813Z",
"updatedAt": "2026-11-07T20:38:22.813Z",
"stepCreatedAt": "2026-11-07T20:38:22.814Z"
}and replies with its changes, and optionally goto, the name of the task to run next, if the graph declares a
goto transition to it:
{
"v": 1,
"changes": {"greeting": "hello ada", "scratch!": null},
"goto": "Shout"
}- Echo
v. Every body carries the format’s version in"v", and the reply goes back in the version the request came in. The host rejects a body without one, or with a newer one than it reads. - Write outputs to
changes. The reply needs nostate, and astateit carries is not read. Inchanges, a plain field name is a write: a contribution on a field with a reducer, and a replacement on any other field. A name ending in!("messages!") overwrites the field, and an overwrite withnull("scratch!": null) deletes it. Names must be valid field names, or the step fails. - Hand back the earlier changes. On a re-run of the same step, after a retry, a resumed interrupt or a
returning subgraph, the request’s
changeshold what the step’s earlier runs wrote, and itsstatealready includes them. The reply’schangesare the step’s whole delta, so start from the request’schangesand add to them. They can hold keys ending in+, which the engine uses for a list that only grew; pass those back unchanged. - Durations are whole milliseconds, as in
sleepMillisandbackoffInitialDelayMillis.
The reply can also carry the fields for retries, sleeps, interrupts and subgraphs, but their protocol has more moving parts; keep those in Go with Pattern 1 unless you have a reason not to. The same carriers serve a task server written in Go, as in Tasks in Another Process.
Testing
NewEngineUnderTest runs the real engine against any host, including
one that calls out to other languages, so the same test can drive a flow end to end through your Python
or TypeScript services.
Next: Fan-Out & Subgraphs.