Tutorial: Your First Go Workflow
A Go workflow bundle is a small package main Go program that harnessd discovers, compiles, and runs on demand. You write a function, drop it in a directory, and the harness does the rest: it builds a standalone binary from your source, wires up a JSON protocol over stdin/stdout, and exposes the whole thing through the /v1/script-workflows HTTP API.
This tutorial walks you through creating that bundle from scratch, starting it via the HTTP API, and streaming its events back.
What you will build: a workflow that emits a named phase, logs a message, and returns its input arguments.
Note on
ctx.Agentand API keys. The phase, log, and return steps in this tutorial are fully key-free using the fake provider. However,ctx.Agent(...)dispatches through the harness provider registry, which requires a real API key (e.g.OPENAI_API_KEY). The core workflow and event streaming exercise in this tutorial omits the agent call to stay key-free. The section Calling a sub-agent shows the full agent example and is clearly labeled as requiring a key.
What you will learn:
- How to write a
workflow.jsonmanifest and amain.goentrypoint - How the harness discovers and builds bundles automatically
- How to start a run with
POST /v1/script-workflows/{name}/runs - How to read
workflow.phase.started,workflow.log, andworkflow.completedevents from the SSE stream - Why
ctx.Agent(...)requires a real API key even when the fake provider is configured
Prerequisites
harnessdbuilt and on yourPATH(runmake buildfrom the repo root, orbash scripts/install.sh)curlandjqavailable in your shell- The
go-agent-harnessrepo checked out — the harness needs its own source on disk to compile workflow bundles
Set up a key-free harnessd
The fake provider (HARNESS_PROVIDER=fake) stands in for a real LLM. It returns scripted responses from a JSON turns file and requires no API key or network access.
Write a fake turns file
HARNESS_FAKE_TURNSis required whenHARNESS_PROVIDER=fake. The file is consumed by the top-level harness runner; it is not used byctx.Agent(...)calls inside workflow bundles (those require a real API key — see the note above).cat > /tmp/workflow-turns.json << 'EOF'[{"content": "analysis complete","usage": {"prompt": 50, "completion": 20},"cost_usd": 0.0001,"cost_status": "available"}]EOFCreate a workflow discovery directory
The harness looks for bundles in
~/.go-harness/workflows/by default. Create a directory for your new bundle:mkdir -p ~/.go-harness/workflows/hello-workflowStart harnessd
Run this from the repo root (the directory containing
go.modthat declaresmodule go-agent-harness). The server loadsprompts/catalog.yamlfrom the working directory at startup.HARNESS_PROVIDER=fake \HARNESS_FAKE_TURNS=/tmp/workflow-turns.json \HARNESS_AUTH_DISABLED=true \harnessdThe server listens on
:8080by default. Leave it running in this terminal.Confirm the health check
In a second terminal:
curl -s http://localhost:8080/healthzExpected response:
{"status":"ok"}
HARNESS_FAKE_TURNS is required when HARNESS_PROVIDER=fake. If the path is missing or the file does not exist, harnessd fails at startup with a fatal error. Note: ctx.Agent(...) calls inside workflow bundles do not use this turns file — agent calls go through the provider registry, which requires a real API key regardless of HARNESS_PROVIDER. See Calling a sub-agent.
The harness compiles workflow bundles by generating a temporary go.mod that uses a replace directive pointing go-agent-harness at the harness source root on disk. It locates that root by checking HARNESS_SOURCE_ROOT first, then walking up from the current working directory looking for a go.mod file that declares module go-agent-harness. If the build fails with a module-not-found error, set HARNESS_SOURCE_ROOT to the absolute path of the repo checkout:
export HARNESS_SOURCE_ROOT=/path/to/go-agent-harness
Write the bundle
A bundle is a directory containing exactly two files: workflow.json (the manifest) and a Go entrypoint (conventionally main.go).
Write workflow.json
cat > ~/.go-harness/workflows/hello-workflow/workflow.json << 'EOF'{"name": "hello-workflow","description": "Introductory workflow that emits a phase, logs a message, calls an agent, and returns its args.","version": 1,"language": "go","entrypoint": "main.go","when_to_use": "Use to verify end-to-end workflow discovery, build, run, and SSE event streaming.","timeout_seconds": 60}EOFEvery field matters:
Field Required Notes nameyes kebab-case identifier; must match ^[a-z0-9]+(-[a-z0-9]+)*$descriptionyes non-empty string versionyes must be 1languageyes must be "go"entrypointyes filename of the Go source file in this directory when_to_useno usage hint shown to orchestrating agents timeout_secondsno default is 300 (5 minutes) Write main.go
This version is fully key-free — it emits a phase, logs a message, and returns its args without calling a real LLM. The Calling a sub-agent section below shows how to add
ctx.Agent(...)once you have an API key.cat > ~/.go-harness/workflows/hello-workflow/main.go << 'EOF'package mainimport sdk "go-agent-harness/pkg/workflowsdk"func main() {sdk.Main(func(ctx *sdk.Context) (any, error) {// Announce the phase — emits workflow.phase.started_ = ctx.Phase("Hello")// Log a message — emits workflow.log_ = ctx.Log("hello-workflow is running")// Return the input argsreturn map[string]any{"args": ctx.Args,}, nil})}EOF
What the SDK surface looks like here:
sdk.Main(fn)— connects the binary to the host JSON protocol and calls your function. It reads astartmessage from stdin containing the run arguments, then writes the terminalresultorerrormessage when your function returns.ctx.Phase(title string) error— emits aworkflow.phase.startedevent, which groups subsequent log and agent events under a named phase.ctx.Log(message string) error— emits aworkflow.logevent with the given message string.ctx.Agent(prompt string, opts *sdk.AgentOpts) (*sdk.AgentResult, error)— spawns a sub-agent. Requires a real LLM API key — see Calling a sub-agent below. Blocks until the agent result arrives from the host. Returns*sdk.AgentResultwith anOutputstring field.ctx.Args— the workflow arguments decoded from the start message. In this tutorial it carries whatever JSON you pass in theargsfield of the POST body.
pkg/workflowsdk is the only Go-importable public surface of go-agent-harness. Everything else — the engine, the server, the harness runner — lives under internal/ and is not importable by external modules. Your workflow binary links only against go-agent-harness/pkg/workflowsdk.
Run it
The harness discovers bundles when it starts and whenever its file watcher triggers (default poll interval: 5 seconds). If harnessd was already running when you created the directory, wait a few seconds or restart it.
List registered workflows
curl -s http://localhost:8080/v1/script-workflows | jq .You should see
hello-workflowin the list:{"workflows": [{"name": "hello-workflow","description": "Introductory workflow that emits a phase, logs a message, calls an agent, and returns its args.","when_to_use": "Use to verify end-to-end workflow discovery, build, run, and SSE event streaming."}]}If the list is empty, the harness has not yet built the bundle. Check the
harnessdterminal output for build errors. The most common cause is a missingHARNESS_SOURCE_ROOT— see the callout above.Start a workflow run
RUN=$(curl -s -X POST http://localhost:8080/v1/script-workflows/hello-workflow/runs \-H "Content-Type: application/json" \-d '{"args": {"topic": "go workflows"}}' | jq -r .run_id)echo "run_id: $RUN"The server responds with HTTP 202:
{"run_id": "wf_...","status": "running","workflow_name": "hello-workflow"}Stream events over SSE
Open a stream to watch events as they arrive:
curl -s "http://localhost:8080/v1/script-workflow-runs/$RUN/events"You will see lines like this (whitespace added for readability):
id: 1event: workflow.starteddata: {"workflow":"hello-workflow"}id: 2event: workflow.phase.starteddata: {"phase":"Hello"}id: 3event: workflow.logdata: {"message":"hello-workflow is running"}id: 4event: workflow.completeddata: {"workflow":"hello-workflow"}The stream closes automatically when a
workflow.completedorworkflow.failedevent is emitted.Read the result
curl -s "http://localhost:8080/v1/script-workflow-runs/$RUN" | jq .{"id": "wf_...","workflow_name": "hello-workflow","status": "completed","result_json": "{\"args\":{\"topic\":\"go workflows\"}}","error": "","created_at": "...","updated_at": "..."}result_jsonis the JSON-encoded return value from your workflow function. To parse it as an object in a singlejqcommand:curl -s "http://localhost:8080/v1/script-workflow-runs/$RUN" | jq '.result_json | fromjson'
Event types you will encounter in a workflow run:
workflow.* event types
| Event | When it fires |
|---|---|
workflow.started | Run begins executing |
workflow.phase.started | ctx.Phase(title) called |
workflow.log | ctx.Log(message) called |
workflow.agent.started | ctx.Agent(...) invoked |
workflow.agent.completed | Agent returned successfully |
workflow.agent.failed | Agent returned an error |
workflow.finding | ctx.Feedback("finding", ...) called |
workflow.warning | ctx.Feedback("warning", ...) called |
workflow.feedback | ctx.Feedback with any other kind |
workflow.completed | Workflow function returned without error |
workflow.failed | Workflow function returned an error or panicked |
Calling a sub-agent
Requires a real LLM API key. ctx.Agent(...) dispatches through the harness provider registry. Even when HARNESS_PROVIDER=fake is set, agent calls resolve the default model (gpt-4.1-mini) via the OpenAI provider catalog and will fail with API key env "OPENAI_API_KEY" is not set unless a real key is provided. Set OPENAI_API_KEY (or the appropriate key for your provider) and remove HARNESS_PROVIDER=fake before using ctx.Agent.
Once you have an API key, you can extend main.go to call a sub-agent:
cat > ~/.go-harness/workflows/hello-workflow/main.go << 'EOF'
package main
import sdk "go-agent-harness/pkg/workflowsdk"
func main() {
sdk.Main(func(ctx *sdk.Context) (any, error) {
_ = ctx.Phase("Hello")
_ = ctx.Log("hello-workflow is running")
// Call a sub-agent — requires a real LLM API key.
// Emits workflow.agent.started / workflow.agent.completed.
result, err := ctx.Agent("Summarize the workflow in one sentence.", &sdk.AgentOpts{
Label: "summarize",
})
if err != nil {
return nil, err
}
return map[string]any{
"summary": result.Output,
"args": ctx.Args,
}, nil
})
}
EOF
With a real provider configured, the SSE stream will include additional agent events:
id: 4
event: workflow.agent.started
data: {"isolation":"","label":"summarize","phase":"Hello","prompt":"Summarize the workflow in one sentence."}
id: 5
event: workflow.agent.completed
data: {"hasSchema":false,"label":"summarize","phase":"Hello"}
And the result will contain a summary key from the agent's output.
Iterate
The harness caches compiled binaries by a SHA-256 content hash of all files in the bundle directory (first 16 hex chars). When you edit main.go or workflow.json, the hash changes and the harness rebuilds automatically on the next run — no restart needed.
Edit and re-run
Make a change to
main.go— for example, add a second log call:_ = ctx.Log("second log line")Save the file, then start a new run:
curl -s -X POST http://localhost:8080/v1/script-workflows/hello-workflow/runs \-H "Content-Type: application/json" \-d '{"args": {"topic": "iteration"}}' | jq .The harness detects the changed hash, rebuilds the binary, and executes the new version. The
harnessdterminal log will show abuilding bundleline for the new hash.Resume a failed run
If a run ends with
status: "failed", you can resume it rather than starting from scratch. This is useful when a transient error interrupted an otherwise correct workflow.curl -s -X POST "http://localhost:8080/v1/script-workflow-runs/$RUN/resume" \-H "Content-Type: application/json" \-d '{"args": {"topic": "retry"}}' | jq .Resume only works when the run's status is
"failed". Attempting to resume a"completed"run returns an error.Set HARNESS_SOURCE_ROOT if the build cannot find the module root
The build synthesizes a
go.modwith areplacedirective:replace go-agent-harness => <moduleRoot>The harness locates
moduleRootby checkingHARNESS_SOURCE_ROOTfirst, then walking up from the working directory looking for ago.modthat declaresmodule go-agent-harness. If neither path succeeds the build fails.To fix it permanently, add the export to your shell profile:
export HARNESS_SOURCE_ROOT=/path/to/go-agent-harnessOr pass it inline when starting
harnessd:HARNESS_SOURCE_ROOT=/path/to/go-agent-harness \HARNESS_PROVIDER=fake \HARNESS_FAKE_TURNS=/tmp/workflow-turns.json \HARNESS_AUTH_DISABLED=true \harnessdSet HARNESS_GO_WORKFLOW_CACHE_DIR to an absolute path
The harness writes compiled bundle binaries to
<workspace>/.harness/workflow-cache/by default. Because this path is relative, the binary is written relative to the harness process's working directory — which may not be what you expect. If the working directory at exec time differs from the directory at build time, exec fails with:fork/exec .harness/workflow-cache/bin/hello-workflow-<hash>: no such file or directoryAlways set
HARNESS_GO_WORKFLOW_CACHE_DIRto an absolute path:export HARNESS_GO_WORKFLOW_CACHE_DIR=/tmp/harness-workflow-cacheOr pass it inline with
HARNESS_SOURCE_ROOT:HARNESS_SOURCE_ROOT=/path/to/go-agent-harness \HARNESS_GO_WORKFLOW_CACHE_DIR=/tmp/harness-workflow-cache \HARNESS_PROVIDER=fake \HARNESS_FAKE_TURNS=/tmp/workflow-turns.json \HARNESS_AUTH_DISABLED=true \harnessd
ctx.Agent(...) requires a real API key and will fail when HARNESS_PROVIDER=fake is set, even though the top-level harness uses the fake provider. This is because agent calls resolve the model via the provider registry, which requires a configured provider key. To use ctx.Agent, set OPENAI_API_KEY (or the appropriate key for your provider) and remove HARNESS_PROVIDER=fake from the startup environment. The workflow code itself does not change.
Next steps
- Workflow SDK reference — the full
pkg/workflowsdkAPI includingctx.Feedback,ctx.Question, andctx.Workflowfor nested workflows: /docs/workflows/workflow-sdk - Script Workflows HTTP API — all six routes, request/response shapes, SSE format, and resume semantics: /docs/server/script-workflows-api
- Workflow engine concepts — how the engine handles concurrency, budget tracking, and the
Parallel/Pipelineprimitives available inside the harness: /docs/workflows/workflow-engine - Events reference — the complete
workflow.*event type list and theEventstruct fields: /docs/concepts/events