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.

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 8077

A 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 GreetIn declares.
  • 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, since f.Set panics 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 onError transition takes over if there is one. The host above backs off on 429 and 503 with f.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, Goto and Sleep are 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 no state, and a state it carries is not read. In changes, 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 with null ("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 changes hold what the step’s earlier runs wrote, and its state already includes them. The reply’s changes are the step’s whole delta, so start from the request’s changes and 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 sleepMillis and backoffInitialDelayMillis.

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.