GCP Cloud Workflows Emulator
A local emulator for Google Cloud Workflows that lets you develop, test, and debug workflows without deploying to GCP.
What is Google Cloud Workflows?
Google Cloud Workflows is a fully managed serverless orchestration platform that executes services in a defined order. Workflows are defined in YAML or JSON and can call HTTP APIs, Cloud Run services, Cloud Functions, and other Google Cloud services.
Why use an emulator?
Developing against the real Google Cloud Workflows service means every change requires a deploy-and-test cycle in the cloud. This emulator eliminates that cycle:
- Iterate locally -- edit a YAML file, save, and the workflow is redeployed instantly via hot-reload
- Test offline -- no GCP account, no credentials, no network required
- Orchestrate local services -- your workflow's
http.get/post/...steps calllocalhostendpoints, just like real GCW calls Cloud Run or Cloud Functions - Run integration tests -- deploy workflows and trigger executions via the same REST API your production code uses
- Inspect results -- use the built-in Web UI to view workflows, executions, results, and errors
Key capabilities
| Feature | Description |
|---|---|
| Full REST API | Same endpoints and request/response formats as the real Workflows and Executions APIs |
| gRPC API | Native gRPC support for Go, Java, and Python client libraries |
| All step types | assign, call, switch, for, parallel, try/except/retry, raise, return, next, steps |
| Expression engine | Complete ${} expression support with all operators |
| Standard library | http, sys, text, json, base64, math, list, map, time, uuid, events, retry |
| Parallel execution | Branches, parallel for loops, shared variables, concurrency limits, exception policies |
| Error handling | All 17 GCW error tags, exponential backoff, custom retry predicates |
| Directory watching | Point at a directory of YAML/JSON files and get hot-reload on save |
| Web UI | Built-in dashboard at /ui/ for inspecting workflows and executions |
How it works
You edit YAML files Emulator watches & deploys Your tests or curl
+-----------------+ +-----------------------+ +------------------+
| workflows/ | ---> | gcw-emulator | <--- | POST /executions |
| order.yaml | | :8787 (REST) | | GET /executions |
| notify.yaml | | :8788 (gRPC) | | |
+-----------------+ +-----------+-----------+ +------------------+
|
| http.get/post steps
v
+-----------------------+
| Your local services |
| :9090, :9091, ... |
+-----------------------+
- Start the emulator pointing at a directory of workflow YAML files
- The emulator watches for file changes and hot-reloads workflows
- Trigger executions via the REST API, gRPC API, or the Web UI
- Workflow steps that call
http.*make real HTTP requests to your local services - Inspect results in the Web UI or via the GET execution endpoint
Next steps
- Installation -- install the emulator
- Quick Start -- deploy and run your first workflow in under 5 minutes
Installation
Docker (easiest)
Pull and run the pre-built multi-arch image (amd64/arm64):
docker run -p 8787:8787 -p 8788:8788 ghcr.io/lemonberrylabs/gcw-emulator:latest
With a local workflows directory (hot-reloads on every save):
docker run -p 8787:8787 -p 8788:8788 \
-v $(pwd)/workflows:/workflows \
-e WORKFLOWS_DIR=/workflows \
ghcr.io/lemonberrylabs/gcw-emulator:latest
See Docker for Docker Compose examples, CI/CD setup, environment variables, and building your own image.
Go install
Requires Go 1.25 or later.
go install github.com/lemonberrylabs/gcw-emulator/cmd/gcw-emulator@latest
This installs the gcw-emulator binary to your $GOPATH/bin (or $HOME/go/bin by default).
Start the emulator:
gcw-emulator --workflows-dir=./workflows
Build from source
git clone https://github.com/lemonberrylabs/gcw-emulator.git
cd gcw-emulator
go build -o gcw-emulator ./cmd/gcw-emulator
./gcw-emulator
Verify the installation
Once the emulator is running, confirm it responds:
curl http://localhost:8787/v1/projects/my-project/locations/us-central1/workflows
Expected response:
{"workflows": []}
Open the Web UI at http://localhost:8787/ui/ to see the dashboard.
Ports
| Port | Protocol | Description |
|---|---|---|
| 8787 | HTTP | REST API and Web UI (configurable via PORT env var) |
| 8788 | gRPC | gRPC API (configurable via GRPC_PORT env var) |
Next steps
Proceed to the Quick Start to deploy and run your first workflow.
Quick Start
This guide gets you from zero to running a workflow in under 2 minutes.
1. Create a workflow file
Create a directory for your workflows and add a file:
mkdir workflows
Create workflows/greet.yaml:
main:
params: [args]
steps:
- build_greeting:
assign:
- message: '${"Hello, " + args.name + "!"}'
- done:
return: ${message}
2. Start the emulator
gcw-emulator --workflows-dir=./workflows
You should see:
GCW Emulator listening on 0.0.0.0:8787
3. Run the workflow
In another terminal:
curl -s -X POST \
http://localhost:8787/v1/projects/my-project/locations/us-central1/workflows/greet/executions \
-H "Content-Type: application/json" \
-d '{"argument": "{\"name\": \"Alice\"}"}' | jq .
This returns an execution object with a name field. Copy the execution name and check the result:
curl -s http://localhost:8787/v1/projects/my-project/locations/us-central1/workflows/greet/executions/<exec-id> | jq .
The response includes:
{
"state": "SUCCEEDED",
"result": "\"Hello, Alice!\""
}
4. Open the Web UI
Browse to http://localhost:8787/ui/ to see your workflow and execution in a dashboard.
5. Edit and iterate
Edit workflows/greet.yaml and save. The emulator detects the change and redeploys the workflow automatically. Run it again to see your changes.
Next steps
- CLI & Configuration -- all environment variables and flags
- Integration Testing -- use the emulator in Go tests
- Workflow Syntax -- all step types and features
- Localhost Orchestration -- the core integration testing pattern
CLI & Configuration
Starting the emulator
gcw-emulator
By default the emulator starts on port 8787 with no workflows loaded. Deploy workflows via the REST API.
With a workflows directory
gcw-emulator --workflows-dir=./workflows --port=9090
This loads all .yaml and .json files from the directory and watches for changes.
Environment Variables
| Variable | Default | Description |
|---|---|---|
PORT | 8787 | HTTP server port |
HOST | 0.0.0.0 | Bind address |
PROJECT | my-project | GCP project ID for API paths |
LOCATION | us-central1 | GCP location for API paths |
Client-side variables
| Variable | Description |
|---|---|
WORKFLOWS_EMULATOR_HOST | Set in your app/tests to redirect workflow API calls to the emulator. Follows the standard *_EMULATOR_HOST convention used by other GCP emulators (Pub/Sub, Firestore, Spanner, etc.) |
Example:
export WORKFLOWS_EMULATOR_HOST=http://localhost:8787
go test ./...
API Paths
All API paths include the project and location:
/v1/projects/{project}/locations/{location}/workflows/...
The PROJECT and LOCATION environment variables control these defaults. If you set PROJECT=my-app and LOCATION=europe-west1, the API paths become:
/v1/projects/my-app/locations/europe-west1/workflows/...
API-Only Mode
When --workflows-dir is not set, the emulator starts with zero workflows. This is useful for integration tests where each test deploys its own workflow definition programmatically via the Workflows CRUD API.
Directory Watching
When started with --workflows-dir, the emulator watches a directory for workflow files and hot-reloads them on changes.
How it works
- On startup, the emulator reads all
.yamland.jsonfiles in the directory - Each file is parsed and deployed as a workflow
- The filename (without extension) becomes the workflow ID
- The directory is watched for changes -- add, modify, or delete files and the emulator responds automatically
Workflow ID rules
The filename (minus extension) must be a valid workflow ID:
- Lowercase letters, digits, hyphens, and underscores only
- Must start with a letter
- Maximum 128 characters
Special cases
| Filename | Behavior |
|---|---|
my-workflow.yaml | Deployed as my-workflow |
MyWorkflow.yaml | Lowercased to myworkflow (with log warning) |
my.workflow.yaml | Skipped -- dots produce invalid ID my.workflow |
123-start.yaml | Skipped -- starts with a digit |
README.md | Ignored -- not .yaml or .json |
In-flight execution isolation
When a workflow file changes while an execution is running:
- The running execution continues using the workflow definition it started with
- Only new executions use the updated definition
This matches the real GCW behavior where each execution is pinned to a specific workflow revision.
Example
# Start with a workflows directory
gcw-emulator --workflows-dir=./workflows
# In another terminal, add a new workflow
cat > workflows/process-order.yaml << 'EOF'
main:
params: [args]
steps:
- validate:
call: http.post
args:
url: http://localhost:9090/validate
body:
order_id: ${args.order_id}
result: validation
- process:
call: http.post
args:
url: http://localhost:9091/process
body:
order_id: ${args.order_id}
validated: ${validation.body.valid}
result: result
- done:
return: ${result.body}
EOF
# The emulator detects the new file and deploys it immediately
# Now you can execute it via the API
Integration Testing
The primary use case for this emulator is integration testing. You define workflows in YAML, run them against your local services, and verify the orchestration works correctly.
Pattern
- Start the emulator (as a separate process or in
TestMain) - Deploy a workflow via the REST API
- Execute it
- Poll for completion
- Assert on the result
Go test example
package myservice_test
import (
"bytes"
"encoding/json"
"net/http"
"os"
"testing"
"time"
)
var emulatorURL string
func TestMain(m *testing.M) {
emulatorURL = os.Getenv("WORKFLOWS_EMULATOR_HOST")
if emulatorURL == "" {
emulatorURL = "http://localhost:8787"
}
os.Exit(m.Run())
}
func deployWorkflow(t *testing.T, id, source string) {
t.Helper()
body, _ := json.Marshal(map[string]string{"sourceContents": source})
url := emulatorURL + "/v1/projects/my-project/locations/us-central1/workflows?workflowId=" + id
resp, err := http.Post(url, "application/json", bytes.NewReader(body))
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("deploy failed: %d", resp.StatusCode)
}
}
func runWorkflow(t *testing.T, id string, args map[string]interface{}) map[string]interface{} {
t.Helper()
body := map[string]interface{}{}
if args != nil {
argsJSON, _ := json.Marshal(args)
body["argument"] = string(argsJSON)
}
data, _ := json.Marshal(body)
url := emulatorURL + "/v1/projects/my-project/locations/us-central1/workflows/" + id + "/executions"
resp, err := http.Post(url, "application/json", bytes.NewReader(data))
if err != nil {
t.Fatal(err)
}
var exec map[string]interface{}
json.NewDecoder(resp.Body).Decode(&exec)
resp.Body.Close()
// Poll for completion
name := exec["name"].(string)
getURL := emulatorURL + "/v1/" + name
for i := 0; i < 100; i++ {
resp, _ = http.Get(getURL)
json.NewDecoder(resp.Body).Decode(&exec)
resp.Body.Close()
state := exec["state"].(string)
if state == "SUCCEEDED" || state == "FAILED" || state == "CANCELLED" {
return exec
}
time.Sleep(100 * time.Millisecond)
}
t.Fatal("execution timed out")
return nil
}
func TestOrderWorkflow(t *testing.T) {
deployWorkflow(t, "order-flow", `
main:
params: [args]
steps:
- validate:
call: http.post
args:
url: http://localhost:9090/api/validate
body:
order_id: ${args.order_id}
result: resp
- done:
return: ${resp.body}
`)
result := runWorkflow(t, "order-flow", map[string]interface{}{
"order_id": "ORD-123",
})
if result["state"] != "SUCCEEDED" {
t.Fatalf("expected SUCCEEDED, got %s: %v", result["state"], result["error"])
}
}
Running tests
Start the emulator in one terminal:
go run ./cmd/gcw-emulator
Run your tests in another:
WORKFLOWS_EMULATOR_HOST=http://localhost:8787 go test -v ./...
Tips
- Use unique workflow IDs per test (e.g., append a timestamp) to avoid conflicts when running tests in parallel
- The emulator runs executions asynchronously -- always poll
GET .../executions/{id}until you see a terminal state - For tests that expect failures, check
exec["state"] == "FAILED"and inspectexec["error"] - The
argumentfield in create-execution is a JSON string, not a JSON object. Marshal your args to JSON and pass as a string.
Web UI
The emulator includes a built-in web UI served on the same port as the API.
Accessing the UI
Browse to http://localhost:{port}/ui/ (the root path / also redirects to the UI).
Pages
Dashboard (/ui)
Overview showing:
- All deployed workflows
- Recent executions across all workflows
- Counts by state (active, succeeded, failed, cancelled)
Workflow List (/ui/workflows)
All deployed workflows with:
- Workflow ID and state
- Total execution count
- Active execution count
- Last update time
Workflow Detail (/ui/workflows/{id})
Single workflow showing:
- Full YAML source code
- Execution history for this workflow
Execution List (/ui/executions)
All executions across all workflows, sorted by start time.
Per-workflow execution list at /ui/workflows/{id}/executions.
Execution Detail (/ui/executions/{workflowId}/{execId})
Single execution showing:
- State (ACTIVE, SUCCEEDED, FAILED, CANCELLED)
- Start and end times, duration
- Input arguments
- Result (on success) or error details (on failure)
REST API Reference
All endpoints are prefixed with /v1/projects/{project}/locations/{location}.
Default project is my-project and default location is us-central1. These are configurable via the PROJECT and LOCATION environment variables. See CLI & Configuration.
Workflows API
Create Workflow
POST /v1/projects/{project}/locations/{location}/workflows?workflowId={id}
Request body:
{
"sourceContents": "main:\n steps:\n - done:\n return: 42\n",
"description": "Optional description"
}
| Field | Type | Required | Description |
|---|---|---|---|
sourceContents | string | Yes | YAML or JSON workflow definition (max 128 KB) |
description | string | No | Human-readable description (max 1000 chars) |
Response: The workflow resource (see below).
Errors:
- 400 if
workflowIdis missing,sourceContentsis empty, or the workflow definition is invalid - 409 if a workflow with the same ID already exists
Note: The real GCW API returns a long-running Operation. The emulator completes immediately and returns the workflow directly.
Get Workflow
GET /v1/projects/{project}/locations/{location}/workflows/{workflowId}
Response:
{
"name": "projects/my-project/locations/us-central1/workflows/my-wf",
"state": "ACTIVE",
"revisionId": "000001-abc",
"sourceContents": "main:\n steps:\n ...",
"description": "",
"createTime": "2026-01-15T10:00:00Z",
"updateTime": "2026-01-15T10:00:00Z"
}
Errors: 404 if the workflow does not exist.
List Workflows
GET /v1/projects/{project}/locations/{location}/workflows
Response:
{
"workflows": [
{
"name": "projects/my-project/locations/us-central1/workflows/my-wf",
"state": "ACTIVE",
...
}
]
}
Update Workflow
PATCH /v1/projects/{project}/locations/{location}/workflows/{workflowId}
Request body: Same fields as Create (provide sourceContents and/or description).
Response: An Operation with done: true and the updated workflow in response.
Errors: 404 if the workflow does not exist.
Delete Workflow
DELETE /v1/projects/{project}/locations/{location}/workflows/{workflowId}
Response: An Operation with done: true.
Errors: 404 if the workflow does not exist.
Executions API
Create Execution
POST /v1/projects/{project}/locations/{location}/workflows/{workflowId}/executions
Request body:
{
"argument": "{\"key\": \"value\"}"
}
| Field | Type | Required | Description |
|---|---|---|---|
argument | string | No | JSON-encoded string with execution arguments (max 32 KB) |
The argument field is a JSON-encoded string, not a JSON object. This matches the real GCW API format.
Response: The execution resource with state: "ACTIVE".
The execution runs asynchronously. Poll the Get Execution endpoint to check for completion.
Errors: 404 if the workflow does not exist.
Get Execution
GET /v1/projects/{project}/locations/{location}/workflows/{workflowId}/executions/{executionId}
Successful response:
{
"name": "projects/my-project/locations/us-central1/workflows/my-wf/executions/exec-abc123",
"state": "SUCCEEDED",
"result": "\"Hello, World!\"",
"argument": "{\"name\": \"Alice\"}",
"startTime": "2026-01-15T10:00:00Z",
"endTime": "2026-01-15T10:00:01Z",
"workflowRevisionId": "000001-abc"
}
Failed response:
{
"name": "...",
"state": "FAILED",
"error": {
"payload": "{\"message\":\"division by zero\",\"tags\":[\"ZeroDivisionError\"]}",
"context": "step: calculate"
},
"startTime": "...",
"endTime": "..."
}
The result field is a JSON-encoded string. The error.payload field is also a JSON-encoded string containing the error map.
Errors: 404 if the execution does not exist.
List Executions
GET /v1/projects/{project}/locations/{location}/workflows/{workflowId}/executions
Response:
{
"executions": [
{
"name": "...",
"state": "SUCCEEDED",
...
}
]
}
Cancel Execution
POST /v1/projects/{project}/locations/{location}/workflows/{workflowId}/executions/{executionId}:cancel
Cancels an active execution. The execution state changes to CANCELLED.
Errors:
- 404 if the execution does not exist
- 400 if the execution is not in
ACTIVEstate
Execution states
| State | Description |
|---|---|
ACTIVE | Currently running |
SUCCEEDED | Completed successfully (check result field) |
FAILED | Completed with error (check error field) |
CANCELLED | Cancelled via the Cancel API |
State transitions: ACTIVE -> SUCCEEDED, FAILED, or CANCELLED.
Callbacks API
List Callbacks
GET /v1/projects/{project}/locations/{location}/workflows/{workflowId}/executions/{executionId}/callbacks
Response:
{
"callbacks": [
{
"name": "...",
"method": "POST",
"url": "http://localhost:8787/callbacks/abc123",
"createTime": "2026-01-15T10:00:00Z"
}
]
}
Send Callback
POST /callbacks/{callbackId}
Sends data to a waiting events.await_callback step. The request body can be any JSON payload and will be available in the callback result's http_request.body field.
gRPC API
The emulator also exposes a gRPC API on port 8788 (configurable via GRPC_PORT environment variable). The gRPC API implements the same operations as the REST API using the official Google Cloud Workflows protobuf definitions.
Workflows service
| RPC | Description |
|---|---|
ListWorkflows | List workflows in a project/location |
GetWorkflow | Get workflow details |
CreateWorkflow | Create a new workflow (returns Operation) |
DeleteWorkflow | Delete a workflow (returns Operation) |
UpdateWorkflow | Update a workflow (returns Operation) |
Executions service
| RPC | Description |
|---|---|
ListExecutions | List executions for a workflow |
CreateExecution | Start a new execution |
GetExecution | Get execution details |
CancelExecution | Cancel a running execution |
Connecting via gRPC
import (
workflowspb "cloud.google.com/go/workflows/apiv1/workflowspb"
executionspb "cloud.google.com/go/workflows/executions/apiv1/executionspb"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
conn, err := grpc.Dial("localhost:8788", grpc.WithTransportCredentials(insecure.NewCredentials()))
workflowsClient := workflowspb.NewWorkflowsClient(conn)
executionsClient := executionspb.NewExecutionsClient(conn)
Emulator simplifications
The emulator differs from the real GCW API in these ways:
| Real GCW | Emulator |
|---|---|
| Create/Update/Delete return long-running Operations that must be polled | Returns the result immediately |
| Requires IAM authentication | Accepts all requests without credentials |
| Supports pagination (page_size, page_token) | Returns all results in one response |
| Supports filter and order_by parameters | Not implemented |
Workflow Syntax
Workflows are defined in YAML (or JSON). A workflow file contains a main workflow and optional subworkflows. Steps execute sequentially by default.
Basic structure
main:
params: [args]
steps:
- step_name:
<step_type>: <value>
- another_step:
<step_type>: <value>
Step types
assign
Set variables. Up to 50 assignments per step. Assignments within a step execute sequentially -- later assignments can reference earlier ones.
- init:
assign:
- name: "Alice"
- age: 30
- greeting: '${"Hello, " + name}'
- items: [1, 2, 3]
- config:
debug: true
level: "info"
Index and key assignment:
- update:
assign:
- items[0]: "first"
- config.debug: false
- config["new_key"]: "value"
Assigning to a nested map path creates intermediate maps: myMap.a.b.c: "deep" creates {a: {b: {c: "deep"}}}.
call
Call HTTP endpoints, standard library functions, or subworkflows.
HTTP call:
- get_data:
call: http.get
args:
url: http://localhost:9090/api/data
headers:
Authorization: "Bearer ${token}"
query:
limit: "10"
timeout: 30
result: response
Standard library call:
- log_it:
call: sys.log
args:
data: "Processing complete"
severity: "INFO"
Subworkflow call:
- process:
call: my_subworkflow
args:
input: ${data}
result: output
The result field stores the return value in a variable. See the Standard Library for all available functions.
switch
Conditional branching. Conditions are evaluated top-to-bottom; the first match executes. Up to 50 conditions.
- check:
switch:
- condition: ${age >= 18}
steps:
- adult:
assign:
- category: "adult"
- condition: ${age >= 13}
next: teenager_step
- condition: true # default/fallback
steps:
- child:
assign:
- category: "child"
Each condition entry can have next (jump), steps (inline steps), or other inline step content (assign, call, return, raise).
If no condition matches and the switch step has no next, execution continues to the next sequential step.
for
Iterate over lists, map keys (via keys()), or ranges.
List iteration:
- loop:
for:
value: item
in: ${my_list}
steps:
- process:
call: http.post
args:
url: http://localhost:9090/process
body:
item: ${item}
With index:
- loop:
for:
value: item
index: i
in: ${my_list}
steps:
- log:
call: sys.log
args:
text: '${"Item " + string(i) + ": " + item}'
Range (inclusive both ends):
- loop:
for:
value: i
range: [1, 10]
steps:
- process:
assign:
- total: ${total + i}
Map iteration:
- loop:
for:
value: key
in: ${keys(my_map)}
steps:
- use:
assign:
- val: ${my_map[key]}
Loop control: Use next: break to exit the loop, next: continue to skip to the next iteration.
Scoping: Variables created inside a for loop do not exist after the loop ends. Variables from the parent scope that are modified inside the loop retain their changes. The loop variable (value) and index variable are scoped to the loop body.
parallel
Execute branches concurrently or iterate in parallel.
Parallel branches:
- fetch_all:
parallel:
shared: [results]
branches:
- get_users:
steps:
- fetch:
call: http.get
args:
url: http://localhost:9090/users
result: users
- save:
assign:
- results: ${map.merge(results, {"users": users.body})}
- get_orders:
steps:
- fetch:
call: http.get
args:
url: http://localhost:9091/orders
result: orders
- save:
assign:
- results: ${map.merge(results, {"orders": orders.body})}
Parallel for:
- process_batch:
parallel:
shared: [processed]
for:
value: item
in: ${items}
steps:
- process:
call: http.post
args:
url: http://localhost:9090/process
body: ${item}
Options:
| Field | Description |
|---|---|
shared | Variables from parent scope writable by branches (must be declared before the parallel step) |
concurrency_limit | Max concurrent branches/iterations (default: up to 20) |
exception_policy | unhandled (default -- abort on first error) or continueAll (collect up to 100 errors) |
Shared variables: Individual reads and writes are atomic, but compound operations like total: ${total + 1} are not atomic as a unit -- race conditions can occur. Variables not in shared are read-only copies within each branch.
Limits: 10 branches per step, 20 max concurrent, nesting depth 2.
try / except / retry
Error handling with optional retry. See Error Handling for the full error model.
- safe_call:
try:
call: http.get
args:
url: http://localhost:9090/unstable
result: response
except:
as: e
steps:
- handle:
assign:
- response:
error: ${e.message}
With retry:
- retry_call:
try:
call: http.get
args:
url: http://localhost:9090/flaky
result: response
retry:
predicate: ${http.default_retry}
max_retries: 5
backoff:
initial_delay: 1
max_delay: 30
multiplier: 2
except:
as: e
steps:
- fallback:
return: "service unavailable"
Retries re-execute the entire try block from the beginning, not just the failed step.
raise
Throw an error. Accepts a string or a map.
# String error
- fail:
raise: "something went wrong"
# Structured error
- fail:
raise:
code: 400
message: "invalid input"
tags: ["ValidationError"]
# Re-raise a caught error
- rethrow:
raise: ${e}
return
Return a value from the current workflow or subworkflow.
- done:
return: ${result}
In main, this terminates the execution and the value becomes the execution result. In subworkflows, the value is returned to the caller.
next
Jump to another step.
- check:
switch:
- condition: ${x > 10}
next: big_number
- small:
assign:
- category: "small"
next: done
- big_number:
assign:
- category: "big"
- done:
return: ${category}
Special targets: end (stop workflow), break (exit loop), continue (next iteration).
steps
Nested step grouping for organization. Variables share the parent scope.
- outer:
steps:
- inner1:
assign:
- x: 1
- inner2:
assign:
- y: 2
Subworkflows
Define reusable subworkflows alongside main:
main:
steps:
- call_helper:
call: add_numbers
args:
a: 10
b: 20
result: sum
- done:
return: ${sum}
add_numbers:
params: [a, b]
steps:
- calc:
return: ${a + b}
Key points:
mainaccepts a single parameter (the execution argument as a map)- Subworkflows accept multiple named parameters with optional defaults:
params: [required, optional: "default"] - Subworkflows can be called from
callsteps (named args) or expressions (positional args):${add_numbers(10, 20)} - Variables are isolated per subworkflow -- a subworkflow cannot access the caller's variables
- Subworkflows can call other subworkflows and themselves (recursion)
- Maximum call stack depth: 20
Parameters
# main receives execution argument as a single map
main:
params: [args]
steps:
- use:
return: ${args.name}
# Subworkflow with named params and defaults
greet:
params: [first_name, last_name: "Unknown"]
steps:
- build:
return: '${"Hello " + first_name + " " + last_name}'
When triggering an execution, the argument is a JSON-encoded string:
curl -X POST .../executions \
-H "Content-Type: application/json" \
-d '{"argument": "{\"name\": \"Alice\", \"age\": 30}"}'
If main has no params, the execution argument must be null or omitted.
Standard Library
The emulator supports all Google Cloud Workflows standard library functions.
Built-in expression helpers
These functions are called directly in expressions without a module prefix.
| Function | Description | Example |
|---|---|---|
default(value, fallback) | Returns value if not null, otherwise fallback | ${default(x, 0)} |
keys(map) | List of map keys (strings) | ${keys(my_map)} |
len(value) | Length of string, list, or map | ${len(items)} |
type(value) | Type name as string | ${type(x)} returns "int", "string", etc. |
int(value) | Convert to integer | ${int("42")}, ${int(2.7)} -> 2 |
double(value) | Convert to double | ${double("3.14")}, ${double(42)} |
string(value) | Convert to string | ${string(42)} -> "42" |
bool(value) | Convert string to boolean | ${bool("true")} -> true |
Notes:
default()only handles null. It does not catch KeyError. Combine withmap.get()for safe map access:${default(map.get(m, "key"), "fallback")}.int()from double truncates toward zero:int(-2.7)=-2.string()does not work on maps, lists, or null. Usejson.encode_to_string()for those.keys()does not guarantee key order.
http
HTTP client functions. All HTTP call steps make real HTTP requests to the target URL.
Methods
- step:
call: http.get # or http.post, http.put, http.patch, http.delete
args:
url: http://localhost:9090/api/data
headers:
Authorization: "Bearer ${token}"
Content-Type: "application/json"
body:
key: "value"
query:
limit: "10"
offset: "0"
timeout: 30
result: response
| Argument | Type | Required | Description |
|---|---|---|---|
url | string | Yes | Target URL (HTTP or HTTPS) |
headers | map | No | Request headers |
body | any | No | Request body (auto-serialized to JSON if no Content-Type) |
query | map | No | URL query parameters (URL-encoded automatically) |
auth | map | No | Auth config (accepted but not enforced by emulator) |
timeout | int | No | Timeout in seconds (max 1800, default 1800) |
http.request
Generic HTTP call with explicit method:
- step:
call: http.request
args:
method: "PUT"
url: http://localhost:9090/api/resource
body:
key: "value"
result: response
Response structure
response.body # Parsed body (JSON auto-parsed to map/list; text stays as string)
response.code # HTTP status code (integer)
response.headers # Response headers (map, keys are lowercased)
Auto-behaviors
- Request body: If no Content-Type header is set and body is not bytes, the body is JSON-encoded and Content-Type is set to
application/json; charset=utf-8. - Response parsing: If the response Content-Type is
application/json, the body is automatically parsed from JSON to a map/list. Text content types return a string. Everything else returns bytes. - Response headers: Header names are lowercased.
- Non-2xx responses: Raise an error with tag
HttpErrorcontaining the status code, response body, and headers.
Error behavior
| Scenario | Error tag |
|---|---|
| Target service not running (connection refused) | ConnectionFailedError |
| Connection broke mid-transfer | ConnectionError |
| Request exceeded timeout | TimeoutError |
| Non-2xx HTTP response | HttpError |
Retry policies
| Policy | Retries on | Does NOT retry |
|---|---|---|
http.default_retry | 429, 502, 503, 504, ConnectionError, TimeoutError | 500 |
http.default_retry_non_idempotent | Same as above | 500 |
Both use retry.default_backoff (initial 1s, max 60s, multiplier 1.25) with max_retries 5.
Important: http.default_retry does not retry HTTP 500 errors. This surprises many users. If you need to retry 500s, write a custom retry predicate.
sys
sys.get_env(name)
Returns the value of an environment variable as a string.
- step:
assign:
- project: ${sys.get_env("GOOGLE_CLOUD_PROJECT_ID")}
Built-in variables provided by the emulator:
| Variable | Description |
|---|---|
GOOGLE_CLOUD_PROJECT_ID | Project ID (from PROJECT env var) |
GOOGLE_CLOUD_LOCATION | Location (from LOCATION env var) |
GOOGLE_CLOUD_WORKFLOW_ID | Current workflow ID |
GOOGLE_CLOUD_WORKFLOW_REVISION_ID | Current revision ID |
GOOGLE_CLOUD_WORKFLOW_EXECUTION_ID | Current execution ID |
Raises KeyError if the variable name is not found.
sys.log(data, severity)
Logs a message. The emulator prints to stdout.
- step:
call: sys.log
args:
data: "Processing started"
severity: "INFO"
Also accepts text as an alias for data, and json for structured logging.
Severity values: DEFAULT, DEBUG, INFO, NOTICE, WARNING, ERROR, CRITICAL, ALERT, EMERGENCY.
sys.now()
Returns the current Unix timestamp as a double (seconds since epoch).
- step:
assign:
- timestamp: ${sys.now()}
sys.sleep(seconds)
Pauses execution for the specified number of seconds.
- step:
call: sys.sleep
args:
seconds: 5
events
Callback support for event-driven async patterns. See also Callbacks in the REST API reference.
events.create_callback_endpoint(http_callback_method)
Creates a callback endpoint that external services can invoke.
- create:
call: events.create_callback_endpoint
args:
http_callback_method: "POST" # Optional, default "POST"
result: callback_details
# callback_details.url contains the full callback URL
Supported methods: GET, HEAD, POST, PUT, DELETE, OPTIONS, PATCH.
events.await_callback(callback, timeout)
Pauses execution until a callback request is received or timeout elapses.
- wait:
call: events.await_callback
args:
callback: ${callback_details}
timeout: 3600 # Optional, default 43200 (12 hours)
result: callback_data
The returned callback_data contains:
callback_data.http_request.body # Parsed request body
callback_data.http_request.headers # Request headers
callback_data.http_request.method # HTTP method used
callback_data.received_time # Timestamp
callback_data.type # "HTTP"
Raises TimeoutError if timeout elapses before a callback is received.
Callback pattern example
main:
steps:
- create_callback:
call: events.create_callback_endpoint
args:
http_callback_method: "POST"
result: cb
- log_url:
call: sys.log
args:
data: ${cb.url}
- wait_for_approval:
try:
call: events.await_callback
args:
callback: ${cb}
timeout: 300
result: approval
except:
as: e
steps:
- check_timeout:
switch:
- condition: ${"TimeoutError" in e.tags}
return: "Timed out waiting for approval"
- rethrow:
raise: ${e}
- done:
return: ${approval.http_request.body}
text
String manipulation functions. All regex functions use RE2 syntax (not PCRE).
| Function | Parameters | Returns | Description |
|---|---|---|---|
text.find_all | source, substr | list of {index, match} | Find all substring occurrences |
text.find_all_regex | source, pattern | list of {index, match} | Find all regex matches |
text.match_regex | source, pattern | bool | Test if regex matches |
text.replace_all | source, substr, replacement | string | Replace all occurrences |
text.replace_all_regex | source, pattern, replacement | string | Replace regex matches (\0 full match, \1-\9 groups) |
text.split | source, separator | list of strings | Split string |
text.substring | source, start, end | string | Substring (0-based, start inclusive, end exclusive) |
text.to_lower | source | string | Lowercase |
text.to_upper | source | string | Uppercase |
text.url_encode | source | string | Percent-encode |
text.url_decode | source | string | Percent-decode |
text.url_encode_plus | source | string | Percent-encode with + for spaces |
text.decode | data, charset | string | Bytes to string (default UTF-8) |
text.encode | data, charset | bytes | String to bytes (default UTF-8) |
json
| Function | Parameters | Returns | Description |
|---|---|---|---|
json.decode | data (string or bytes) | any | Parse JSON |
json.encode | value | bytes | Encode to JSON bytes |
json.encode_to_string | value | string | Encode to JSON string |
- step:
assign:
- parsed: ${json.decode("{\"key\": \"value\"}")}
- encoded: ${json.encode_to_string(my_map)}
base64
| Function | Parameters | Returns | Description |
|---|---|---|---|
base64.decode | data (string) | bytes | Decode base64 |
base64.encode | data (bytes or string) | string | Encode to base64 |
math
| Function | Parameters | Returns | Description |
|---|---|---|---|
math.abs | value | same type | Absolute value |
math.floor | value (double) | int | Floor (largest int <= value) |
math.max | a, b | larger value | Maximum |
math.min | a, b | smaller value | Minimum |
list
| Function | Parameters | Returns | Description |
|---|---|---|---|
list.concat | list, element | new list | Append element (does not modify original) |
list.prepend | list, element | new list | Prepend element (does not modify original) |
- step:
assign:
- items: ${list.concat(items, "new_item")}
- items: ${list.prepend(items, "first")}
map
| Function | Parameters | Returns | Description |
|---|---|---|---|
map.get | map, key, default? | value | Get value without KeyError. Returns null (or default) if missing |
map.delete | map, key | new map | Remove key (does not modify original) |
map.merge | map1, map2 | new map | Shallow merge (map2 overrides) |
map.merge_nested | map1, map2 | new map | Deep merge (recursively merges nested maps) |
- step:
assign:
- value: ${map.get(config, "timeout", 30)}
- cleaned: ${map.delete(response, "internal_field")}
- combined: ${map.merge(defaults, overrides)}
uuid
| Function | Parameters | Returns | Description |
|---|---|---|---|
uuid.generate | (none) | string | Random UUID v4 (e.g., "550e8400-e29b-41d4-a716-446655440000") |
retry
Built-in retry policies for use with try/retry blocks.
| Policy | Description |
|---|---|
retry.always | Always retry (returns true for any error) |
retry.never | Never retry (returns false for any error) |
retry.default_backoff | Default backoff: initial_delay 1s, max_delay 60s, multiplier 1.25 |
Custom retry predicates
Define a subworkflow that receives the error map and returns true/false:
main:
steps:
- call_with_retry:
try:
call: http.get
args:
url: http://localhost:9090/api
result: response
retry:
predicate: ${my_retry_predicate}
max_retries: 5
backoff:
initial_delay: 1
max_delay: 60
multiplier: 2
my_retry_predicate:
params: [e]
steps:
- check:
switch:
- condition: ${"ConnectionFailedError" in e.tags}
return: true
- condition: ${"TimeoutError" in e.tags}
return: true
- condition: ${("HttpError" in e.tags) and (e.code in [429, 500, 502, 503, 504])}
return: true
- no_retry:
return: false
This predicate retries on connection failures, timeouts, and specific HTTP status codes including 500 (which http.default_retry does not retry).
Expression Language
Expressions are enclosed in ${} and evaluated at runtime. They can appear anywhere a value is expected: assignments, conditions, function arguments, URLs, and more.
Syntax
- step:
assign:
- result: ${a + b}
- greeting: '${"Hello, " + name + "!"}'
- check: ${x > 10 and y < 20}
When an expression starts a YAML value, wrap the entire value in quotes to avoid YAML parsing issues with colons and special characters.
Maximum expression length: 400 characters.
Data types
| Type | Examples | type() result | Notes |
|---|---|---|---|
| int | 1, -5, 0 | "int" | 64-bit signed integer |
| double | 4.1, -0.5, 3.14e10 | "double" | 64-bit IEEE 754 |
| string | "hello", 'world' | "string" | Max 256 KB (UTF-8) |
| bool | true, false | "bool" | Also True/False, TRUE/FALSE |
| null | null | "null" | Distinct type, not zero or empty string |
| list | [1, 2, 3], [] | "list" | Ordered, 0-indexed |
| map | {"key": "value"}, {} | "map" | String keys only |
| bytes | (no literal syntax) | "bytes" | Created via text.encode() or base64.decode() |
Operators
Arithmetic
| Operator | Description | Example | Notes |
|---|---|---|---|
+ | Addition / string concat | ${a + b}, ${"hi " + name} | string + int is TypeError |
- | Subtraction | ${a - b} | |
* | Multiplication | ${a * b} | |
/ | Division | ${10 / 3} -> 3.333... | Always returns double |
% | Modulo | ${10 % 3} -> 1 | |
// | Integer division | ${10 // 3} -> 3 | Floor division (truncates toward negative infinity) |
- (unary) | Negation | ${-x} |
Type promotion: When int and double are mixed, int is promoted to double. Division / always returns double, even ${4 / 2} = 2.0.
No implicit string conversion: ${"count: " + 42} is a TypeError. Use ${"count: " + string(42)}.
Comparison
| Operator | Description |
|---|---|
== | Equal (deep equality for maps and lists) |
!= | Not equal |
< | Less than |
> | Greater than |
<= | Less than or equal |
>= | Greater than or equal |
null == nullistruenull == <anything_else>isfalse- Comparing incompatible types with
<,>,<=,>=raises TypeError ==and!=between incompatible types returnsfalse(no error)- Maps and lists use deep equality
Logical
| Operator | Description |
|---|---|
and | Short-circuit AND |
or | Short-circuit OR |
not | Logical NOT |
Operands must be boolean. not "hello" or true and 1 raises TypeError. Short-circuit: if the left operand determines the result, the right operand is not evaluated.
Membership
| Operator | Description | Example |
|---|---|---|
in | Key exists in map, or value in list | ${"key" in my_map}, ${item in my_list} |
not in | Negation of in | ${"key" not in my_map} |
Property and index access
| Syntax | Description |
|---|---|
obj.key | Map property access |
obj["key"] | Map key access (supports dynamic keys) |
list[0] | List index access (0-based) |
obj.a.b[0].c | Nested access |
- Missing map key raises KeyError
- Out-of-bounds list index raises IndexError
- Negative list indices are not supported (raises IndexError)
- Use
map.get(obj, "key", default)for safe access
Operator precedence
From highest to lowest:
.,[]-- property/index accessnot,-(unary) -- negation*,/,%,//-- multiplicative+,--- additive<,>,<=,>=-- relational==,!=-- equalityin,not in-- membershipand-- logical ANDor-- logical OR
Use parentheses () to override precedence.
Function calls in expressions
Built-in functions and standard library functions can be called inside ${}:
- step:
assign:
- count: ${len(items)}
- upper_name: ${text.to_upper(name)}
- safe_value: ${default(map.get(config, "key"), "fallback")}
- data_type: ${type(value)}
- id: ${uuid.generate()}
Subworkflows can also be called with positional arguments:
- step:
assign:
- result: ${my_subworkflow("arg1", "arg2")}
See the Standard Library for all available functions.
Null handling
# Accessing a missing map key raises KeyError -- NOT null
- bad: ${myMap.missingKey} # KeyError!
# Safe access with map.get (returns null if not found)
- safe: ${map.get(myMap, "key")} # null if missing
# Safe access with a default value
- safe: ${map.get(myMap, "key", 0)} # 0 if missing
# default() handles null but does NOT catch KeyError
- val: ${default(map.get(myMap, "key"), "fallback")}
# Null comparisons
- is_null: ${value == null} # true if value is null
- both_null: ${null == null} # true
Common patterns
String building
- step:
assign:
- url: '${"http://localhost:9090/users/" + string(user_id) + "/orders"}'
- message: '${"Found " + string(len(items)) + " items"}'
Conditional defaults
- step:
assign:
- timeout: ${default(map.get(config, "timeout"), 30)}
- name: ${default(map.get(args, "name"), "Anonymous")}
Type checking
- step:
switch:
- condition: ${type(value) == "list"}
assign:
- count: ${len(value)}
- condition: ${type(value) == "string"}
assign:
- count: 1
Checking for map keys
- step:
switch:
- condition: ${"email" in user}
next: send_email
- condition: true
next: skip_notification
Edge cases
| Case | Behavior |
|---|---|
${x / 0} | ZeroDivisionError |
${"hi" + 5} | TypeError (use string(5)) |
${not "hello"} | TypeError (operand must be boolean) |
${myList[-1]} | IndexError (negative indices not supported) |
${} | Invalid (empty expression) |
${${x}} | Invalid (nested expressions not supported) |
${10 / 2} | 2.0 (division always returns double) |
${-10 // 3} | -4 (floor division, not truncation toward zero) |
| Integer overflow | Wraps (64-bit signed) |
Error Handling
Error structure
All errors in Google Cloud Workflows are represented as maps:
{
"message": "Human-readable error description",
"code": 404,
"tags": ["HttpError"]
}
HTTP errors include additional fields:
{
"message": "HTTP request failed with status 404",
"code": 404,
"tags": ["HttpError"],
"headers": {"content-type": "application/json"},
"body": {"error": "not found"}
}
try / except
Catch errors and handle them:
- safe_call:
try:
steps:
- fetch:
call: http.get
args:
url: http://localhost:9090/data
result: response
except:
as: e
steps:
- check_error:
switch:
- condition: ${"ConnectionFailedError" in e.tags}
return: "Service is not running"
- condition: ${"HttpError" in e.tags and e.code == 404}
return: null
- condition: true
raise: ${e}
The error variable (e in the as field) is a map with message, code, and tags fields.
try / retry / except
Add retry with exponential backoff:
- resilient_call:
try:
steps:
- fetch:
call: http.get
args:
url: http://localhost:9090/data
result: response
retry:
predicate: ${http.default_retry}
max_retries: 5
backoff:
initial_delay: 1
max_delay: 60
multiplier: 2
except:
as: e
steps:
- handle:
return: '${"Failed after retries: " + e.message}'
Key behavior: Retries re-execute the entire try block from the beginning, not just the failed step.
Retry count: max_retries: 3 means 1 initial attempt + 3 retries = 4 total attempts.
Backoff formula: delay = min(initial_delay * multiplier^attempt, max_delay)
Error tags
The emulator supports all 17 Google Cloud Workflows error tags:
| Tag | When raised | Typical code |
|---|---|---|
AuthError | Generating credentials fails | 0 |
ConnectionError | Connection broke mid-transfer | 0 |
ConnectionFailedError | Connection never established (service down, DNS failure) | 0 |
HttpError | Non-2xx HTTP response | HTTP status code |
IndexError | List index out of range | 0 |
KeyError | Map key not found, or unknown env var in sys.get_env | 0 |
OperationError | Long-running operation failure | 0 |
ParallelNestingError | Parallel nesting exceeds depth 2 | 0 |
RecursionError | Call stack depth exceeds 20 | 0 |
ResourceLimitError | Memory, step count, or other resource limits exceeded | 0 |
ResponseTypeError | Unexpected response type from operation | 0 |
SystemError | Internal system error | 0 |
TimeoutError | HTTP request or callback await timed out | 0 |
TypeError | Type mismatch (e.g., "hi" + 5, not "string") | 0 |
UnhandledBranchError | Raised after continueAll parallel when branches had errors | 0 |
ValueError | Correct type but invalid value (e.g., int("abc")) | 0 |
ZeroDivisionError | Division or modulo by zero | 0 |
Errors can have multiple tags. For example, an HTTP 404 error has tags: ["HttpError"].
ConnectionFailedError vs ConnectionError
This distinction is critical for local development:
| Error | Meaning | Common cause |
|---|---|---|
| ConnectionFailedError | Connection was never established | Service not running, port not listening, DNS failure |
| ConnectionError | Connection established but broke during transfer | Service crashed mid-response |
When your local service is not running, the emulator raises ConnectionFailedError. This is the error you'll see most often during development.
Catching errors by tag
- step:
try:
call: http.get
args:
url: http://localhost:9090/api
result: response
except:
as: e
steps:
- route_error:
switch:
- condition: ${"ConnectionFailedError" in e.tags}
return: "Service is down"
- condition: ${"HttpError" in e.tags and e.code == 429}
next: retry_later
- condition: ${"HttpError" in e.tags and e.code >= 500}
return: "Server error"
- condition: ${"TimeoutError" in e.tags}
return: "Request timed out"
- condition: true
raise: ${e}
raise
Throw a custom error:
# String error -> {message: "...", code: 0, tags: []}
- fail:
raise: "validation failed"
# Map error with custom fields
- fail:
raise:
code: 400
message: "Invalid order ID"
tags: ["ValidationError"]
# Re-raise a caught error
- rethrow:
raise: ${e}
Error propagation
- Error occurs in a step
- If inside a
tryblock withretry: retry is attempted first - If retry exhausted or not configured:
exceptblock executes (if present) - If no
exceptorexceptre-raises: error propagates up - In a subworkflow: propagates to the caller
- In a parallel branch: depends on exception policy (
unhandledaborts all;continueAllcollects) - At the top level of
main: execution fails with stateFAILED
Variable scoping with try/except
Variables declared inside except are not visible outside:
# WRONG: error_msg is not accessible after the try/except block
- handle:
try:
call: http.get
args:
url: http://localhost:9090/data
result: response
except:
as: e
steps:
- save:
assign:
- error_msg: ${e.message} # Only in scope inside except
# CORRECT: declare the variable before the try/except
- init:
assign:
- error_msg: null
- handle:
try:
call: http.get
args:
url: http://localhost:9090/data
result: response
except:
as: e
steps:
- save:
assign:
- error_msg: ${e.message} # Modifies parent-scope variable
- use:
return: ${error_msg} # Works
Built-in retry policies
| Policy | Retries on | Does NOT retry |
|---|---|---|
http.default_retry | 429, 502, 503, 504, ConnectionError, TimeoutError | 500 |
http.default_retry_non_idempotent | Same as above | 500 |
retry.always | Everything | (nothing) |
retry.never | (nothing) | Everything |
retry.default_backoff: initial_delay 1s, max_delay 60s, multiplier 1.25.
See the Standard Library > Custom retry predicates section for writing predicates that retry on HTTP 500.
Limits
The emulator enforces the same limits as Google Cloud Workflows.
Execution limits
| Limit | Value | Error on exceed |
|---|---|---|
Assignments per assign step | 50 | ResourceLimitError |
Conditions per switch step | 50 | ResourceLimitError |
| Call stack depth (subworkflow nesting) | 20 | RecursionError |
| Steps per execution | 100,000 | ResourceLimitError |
| Expression length | 400 characters | Validation error |
Parallel execution limits
| Limit | Value | Error on exceed |
|---|---|---|
Branches per parallel step | 10 | ResourceLimitError |
| Max concurrent branches/iterations | 20 | ResourceLimitError |
| Parallel nesting depth | 2 | ParallelNestingError |
Unhandled exceptions per execution (continueAll) | 100 | -- |
Size limits
| Limit | Value |
|---|---|
| Workflow source code | 128 KB |
| Variable memory (all variables, arguments, events) | 512 KB |
| Maximum string length | 256 KB |
| HTTP response size | 2 MB |
| Execution argument size | 32 KB |
HTTP limits
| Limit | Value |
|---|---|
| HTTP request timeout | 1800 seconds (30 minutes) |
| Execution duration | 1 year |
What happens when a limit is exceeded
- Assignment/switch/branch limits: Deployment or validation error before execution starts
- Call stack depth:
RecursionErrorat runtime when depth 20 is exceeded - Step count:
ResourceLimitErrorafter 100,000 steps in a single execution - Parallel nesting:
ParallelNestingErrorwhen nesting depth exceeds 2 - Memory/size limits:
ResourceLimitErrorwhen variable memory or result size exceeds the cap - HTTP timeout:
TimeoutErrorwhen a request exceeds the configured timeout
Tips for staying within limits
- Assign unused variables to
nullto free memory - Only store essential portions of large API responses
- Use
list.concat()judiciously in parallel for loops (each call copies the list) - Break large workflows into subworkflows for readability, but watch the call stack depth
- Use
concurrency_limitin parallel steps to control resource usage
Docker
Run the emulator
docker run -p 8787:8787 -p 8788:8788 \
ghcr.io/lemonberrylabs/gcw-emulator:latest
The image exposes port 8787 (REST API + Web UI) and port 8788 (gRPC).
Mount a workflows directory
To use directory watching with Docker, mount your local workflows directory:
docker run -p 8787:8787 -p 8788:8788 \
-v $(pwd)/workflows:/workflows \
-e WORKFLOWS_DIR=/workflows \
ghcr.io/lemonberrylabs/gcw-emulator:latest
The emulator watches for file changes and hot-reloads workflows automatically.
Environment variables
Pass environment variables with -e:
docker run -p 8787:8787 \
-e PROJECT=my-app \
-e LOCATION=europe-west1 \
-e PORT=8787 \
-e GRPC_PORT=8788 \
ghcr.io/lemonberrylabs/gcw-emulator:latest
| Variable | Default | Description |
|---|---|---|
PORT | 8787 | REST API and Web UI port |
HOST | 0.0.0.0 | Bind address |
GRPC_PORT | 8788 | gRPC API port |
PROJECT | my-project | GCP project ID for API paths |
LOCATION | us-central1 | GCP location for API paths |
WORKFLOWS_DIR | (none) | Path to workflows directory inside the container |
Docker Compose
Basic setup
services:
gcw-emulator:
image: ghcr.io/lemonberrylabs/gcw-emulator:latest
ports:
- "8787:8787"
- "8788:8788"
volumes:
- ./workflows:/workflows
environment:
- WORKFLOWS_DIR=/workflows
- PROJECT=my-project
- LOCATION=us-central1
With your application services
A more complete example with the emulator orchestrating local services:
services:
gcw-emulator:
image: ghcr.io/lemonberrylabs/gcw-emulator:latest
ports:
- "8787:8787"
volumes:
- ./workflows:/workflows
environment:
- WORKFLOWS_DIR=/workflows
depends_on:
- order-service
- notification-service
order-service:
build: ./services/orders
ports:
- "9090:9090"
notification-service:
build: ./services/notifications
ports:
- "9091:9091"
In your workflow YAML, reference services by their Docker Compose service name:
main:
steps:
- create_order:
call: http.post
args:
url: http://order-service:9090/orders
body:
item: "widget"
result: order
- notify:
call: http.post
args:
url: http://notification-service:9091/notify
body:
order_id: ${order.body.id}
When running inside Docker Compose, services communicate via the Docker network using service names as hostnames.
Build your own image
The repository includes a multi-stage Dockerfile:
git clone https://github.com/lemonberrylabs/gcw-emulator.git
cd gcw-emulator
docker build -t gcw-emulator .
docker run -p 8787:8787 gcw-emulator
The Dockerfile uses a two-stage build: the first stage compiles the Go binary, and the second stage copies it into a minimal Alpine image with only ca-certificates and tzdata.
Using with CI/CD
Run the emulator as a service in your CI pipeline:
# GitHub Actions example
services:
gcw-emulator:
image: ghcr.io/lemonberrylabs/gcw-emulator:latest
ports:
- 8787:8787
Then your tests can deploy workflows and run executions against http://localhost:8787 (or http://gcw-emulator:8787 inside the service network).
Localhost Orchestration
This is the core value proposition of the emulator: your workflow's http.* steps make real HTTP requests to your local services, exactly as Google Cloud Workflows calls Cloud Run or Cloud Functions in production.
The pattern
In production, your GCW workflow calls Cloud Run services:
main:
steps:
- validate:
call: http.post
args:
url: https://validate-service-xyz.run.app/validate
body: ${args}
auth:
type: OIDC
result: validation
- process:
call: http.post
args:
url: https://process-service-xyz.run.app/process
body: ${validation.body}
auth:
type: OIDC
result: result
- done:
return: ${result.body}
For local development, point those URLs at localhost:
main:
steps:
- validate:
call: http.post
args:
url: http://localhost:9090/validate
body: ${args}
result: validation
- process:
call: http.post
args:
url: http://localhost:9091/process
body: ${validation.body}
result: result
- done:
return: ${result.body}
The auth config is accepted by the emulator but not enforced -- no credentials needed locally.
Making URLs configurable
Use sys.get_env to switch between local and production URLs:
main:
params: [args]
steps:
- get_config:
assign:
- validate_url: ${sys.get_env("VALIDATE_SERVICE_URL")}
- process_url: ${sys.get_env("PROCESS_SERVICE_URL")}
- validate:
call: http.post
args:
url: ${validate_url + "/validate"}
body: ${args}
result: validation
- process:
call: http.post
args:
url: ${process_url + "/process"}
body: ${validation.body}
result: result
- done:
return: ${result.body}
Testing the full flow
- Start your services locally on different ports
- Start the emulator:
gcw-emulator --workflows-dir=./workflows - Trigger an execution via the API
- The emulator calls your services in order, passing data between them
- Check the execution result
This verifies that:
- Your workflow syntax is correct
- The service orchestration logic works
- Data flows correctly between services
- Error handling catches failures properly
Error behavior
Service not running
When a local service is not running, the emulator raises a ConnectionFailedError:
- safe_call:
try:
call: http.get
args:
url: http://localhost:9090/health
result: response
except:
as: e
steps:
- check:
switch:
- condition: ${"ConnectionFailedError" in e.tags}
return: "Service is not running on port 9090"
Service returns an error
When a service returns a non-2xx status code, the emulator raises an HttpError:
- safe_call:
try:
call: http.post
args:
url: http://localhost:9090/api/orders
body: ${order_data}
result: response
except:
as: e
steps:
- check:
switch:
- condition: ${"HttpError" in e.tags and e.code == 400}
return:
error: "Bad request"
details: ${e.body}
- condition: ${"HttpError" in e.tags and e.code == 404}
return:
error: "Not found"
The error includes the response body and headers, so you can inspect what the service returned.
Automatic retry
Configure retry to handle transient failures:
- fetch_with_retry:
try:
call: http.get
args:
url: http://localhost:9090/api/data
result: response
retry:
predicate: ${http.default_retry}
max_retries: 3
backoff:
initial_delay: 1
max_delay: 10
multiplier: 2
This retries on 429, 502, 503, 504, ConnectionError, and TimeoutError.
Parallel service orchestration
Call multiple services concurrently:
main:
params: [args]
steps:
- init:
assign:
- results: {}
- fetch_all:
parallel:
shared: [results]
branches:
- get_user:
steps:
- fetch:
call: http.get
args:
url: '${"http://localhost:9090/users/" + string(args.user_id)}'
result: user
- save:
assign:
- results: ${map.merge(results, {"user": user.body})}
- get_orders:
steps:
- fetch:
call: http.get
args:
url: '${"http://localhost:9091/orders?user=" + string(args.user_id)}'
result: orders
- save:
assign:
- results: ${map.merge(results, {"orders": orders.body})}
- done:
return: ${results}
Tips
- Use different ports per service to avoid conflicts
- Start all services before running the workflow, or use try/except to handle services that may not be ready
- Run services in Docker Compose alongside the emulator for reproducible setups (see Docker)
- JSON responses from your services are auto-parsed by the emulator -- return
application/jsonfrom your services and access the data directly as maps/lists
FAQ
How do I handle Google Cloud native functions (e.g., Secret Manager) that the emulator doesn't support?
The emulator does not support googleapis.* connectors such as googleapis.secretmanager.v1.projects.secrets.versions.access. These require real GCP service backends.
The recommended workaround is to use environment variables with conditional execution: inject the value via sys.get_env at the emulator level, and only call the GCP-native function in production when the env var is not set.
Pattern
1. Read the value from an env var with a default in init:
- init:
assign:
- projectId: '${sys.get_env("PROJECT_ID")}'
- mySecret: '${sys.get_env("MY_SECRET", "")}'
sys.get_env(name, default) accepts a second argument -- if the env var isn't set, it returns the default (empty string here) instead of raising an error.
2. Wrap the GCP-native call in a switch so it only runs when the env var is empty:
- maybe_get_secret:
switch:
- condition: ${mySecret == ""}
steps:
- fetch_secret:
call: googleapis.secretmanager.v1.projects.secrets.versions.access
args:
name: '${"projects/" + projectId + "/secrets/MY_SECRET/versions/latest"}'
result: secretResult
- extract_secret:
assign:
- mySecret: ${text.decode(base64.decode(secretResult.payload.data))}
If mySecret is already populated from the env var, the switch falls through and execution continues with the existing value. If it's empty (i.e., running in production without the env var), it fetches from Secret Manager.
How it works in each environment
| Environment | MY_SECRET env var | Behavior |
|---|---|---|
| Emulator | Set to the actual secret value | switch falls through, uses env var directly |
| GCP Production | Not set | switch enters the branch, calls Secret Manager |
This pattern works for any googleapis.* connector call -- not just Secret Manager. The key idea is: provide the value via an environment variable when running locally, and let the workflow fetch it from GCP when the env var is absent.
See also
- Limitations -- full list of unsupported features
- CLI & Configuration -- environment variable reference
Limitations
The following features are not supported by the emulator.
Google Cloud Connectors
The googleapis.* connectors (e.g., googleapis.cloudresourcemanager.v3.projects.get) require real GCP service backends and are not emulated. The emulator handles all http.* calls but not connector-specific semantics.
Workaround: Mock connector responses by running a local HTTP service that returns the expected responses, and replace connector calls with http.* calls pointing at your mock.
IAM / Authentication
The emulator accepts all requests without any authentication or authorization checks. No credentials are needed.
Eventarc / Pub/Sub Triggers
Workflows can only be triggered via the REST API (POST .../executions). Eventarc triggers, Pub/Sub subscriptions, and Cloud Scheduler triggers are not supported.
Long-Running Operations
In the real GCW API, workflow Create/Update/Delete return long-running Operations that need to be polled. The emulator completes these operations immediately and returns the result directly.
Execution Step History
The emulator provides execution results and errors, but does not maintain a step-by-step audit log (step entries). You can see the final state but not which steps ran in what order.
CMEK / Billing / Quotas
Customer-managed encryption keys, billing simulation, and quota enforcement are not applicable to a local emulator.
Multi-Region
The emulator runs as a single local instance. Multi-region deployment semantics are not simulated.
Workflow Revision History
The emulator supports updating workflows, but does not maintain a revision history. ListWorkflowRevisions is not available.
Troubleshooting
Common issues
"Connection refused" errors in workflow execution
Symptom: Workflow fails with ConnectionFailedError when calling http.get or http.post.
Cause: The target service is not running on the specified port.
Fix: Start your local service before executing the workflow. Verify it is listening on the expected port:
curl http://localhost:9090/health
Workflow file not being picked up
Symptom: Added a YAML file to the workflows directory but it does not appear in the API.
Possible causes:
- Filename starts with a digit (e.g.,
123-workflow.yaml) -- must start with a letter - Filename contains dots (e.g.,
my.workflow.yaml) -- dots produce invalid workflow IDs - File extension is not
.yamlor.json - The
--workflows-dirflag was not set when starting the emulator
Execution stays in ACTIVE state
Symptom: GET .../executions/{id} keeps returning "state": "ACTIVE".
Possible causes:
- The workflow is waiting on an
events.await_callbackstep - The workflow calls
sys.sleepwith a long duration - An HTTP call step is hanging because the target service is slow to respond
"workflow not found" when creating execution
Symptom: POST .../executions returns 404.
Fix: Verify the workflow was deployed successfully:
curl http://localhost:8787/v1/projects/my-project/locations/us-central1/workflows
Check that the workflow ID in the URL matches exactly.
YAML parsing errors
Symptom: Create workflow returns 400 with "invalid workflow definition".
Common YAML mistakes:
- Expressions starting with
${need to be quoted:'${a + b}'or"${a + b}" - Indentation must be consistent (use spaces, not tabs)
- Lists use
-prefix with a space after the dash
Results are JSON strings, not objects
Symptom: The result field in the execution response is a JSON-encoded string like "\"hello\"" instead of "hello".
Explanation: This matches the real GCW API. The result field is always a JSON-encoded string. Parse it in your client code:
var result string
json.Unmarshal([]byte(exec.Result), &result)
Getting help
- Check the REST API Reference for correct endpoint formats
- Review the Workflow Syntax for YAML structure
- Open an issue at github.com/lemonberrylabs/gcw-emulator/issues
Contributing
Contributions are welcome! See the full CONTRIBUTING.md in the repository root for complete guidelines.
Test Requirements
Every bug fix and feature must include tests. PRs without adequate test coverage will not be merged.
- Bug fixes: Add a test that reproduces the bug and verifies the fix.
- New features: Add both unit tests (
pkg/) and integration tests (test/integration/) that exercise the feature end-to-end. - Refactors: Ensure existing tests still pass. If you're changing behavior, update the tests to match.
Quick Start
- Fork the repository
- Clone your fork:
git clone https://github.com/YOUR_USERNAME/gcw-emulator.git cd gcw-emulator - Create a feature branch:
git checkout -b feature/my-change
Building
go build ./cmd/gcw-emulator
Running Tests
Unit tests
go test ./pkg/...
Integration tests
Start the emulator:
go run ./cmd/gcw-emulator
In another terminal:
cd test/integration
WORKFLOWS_EMULATOR_HOST=http://localhost:8787 go test -v ./...
The integration test suite has 221+ tests covering all step types, standard library functions, error handling, parallel execution, the REST API, the Web UI, and edge cases. See test/integration/helpers_test.go for shared test utilities.
Submitting Changes
- Ensure
go vet ./...passes - Ensure all unit tests pass:
go test ./pkg/... - Ensure integration tests pass (see above)
- Write a clear commit message describing the change
- Submit a pull request against the
mainbranch
Reporting Issues
Use GitHub Issues to report bugs or request features. Include:
- What you expected to happen
- What actually happened
- Steps to reproduce
- Workflow YAML (if applicable)
- Emulator version or commit hash