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 call localhost endpoints, 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

FeatureDescription
Full REST APISame endpoints and request/response formats as the real Workflows and Executions APIs
gRPC APINative gRPC support for Go, Java, and Python client libraries
All step typesassign, call, switch, for, parallel, try/except/retry, raise, return, next, steps
Expression engineComplete ${} expression support with all operators
Standard libraryhttp, sys, text, json, base64, math, list, map, time, uuid, events, retry
Parallel executionBranches, parallel for loops, shared variables, concurrency limits, exception policies
Error handlingAll 17 GCW error tags, exponential backoff, custom retry predicates
Directory watchingPoint at a directory of YAML/JSON files and get hot-reload on save
Web UIBuilt-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, ...   |
                           +-----------------------+
  1. Start the emulator pointing at a directory of workflow YAML files
  2. The emulator watches for file changes and hot-reloads workflows
  3. Trigger executions via the REST API, gRPC API, or the Web UI
  4. Workflow steps that call http.* make real HTTP requests to your local services
  5. Inspect results in the Web UI or via the GET execution endpoint

Next steps

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

PortProtocolDescription
8787HTTPREST API and Web UI (configurable via PORT env var)
8788gRPCgRPC 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

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

VariableDefaultDescription
PORT8787HTTP server port
HOST0.0.0.0Bind address
PROJECTmy-projectGCP project ID for API paths
LOCATIONus-central1GCP location for API paths

Client-side variables

VariableDescription
WORKFLOWS_EMULATOR_HOSTSet 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

  1. On startup, the emulator reads all .yaml and .json files in the directory
  2. Each file is parsed and deployed as a workflow
  3. The filename (without extension) becomes the workflow ID
  4. 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

FilenameBehavior
my-workflow.yamlDeployed as my-workflow
MyWorkflow.yamlLowercased to myworkflow (with log warning)
my.workflow.yamlSkipped -- dots produce invalid ID my.workflow
123-start.yamlSkipped -- starts with a digit
README.mdIgnored -- 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

  1. Start the emulator (as a separate process or in TestMain)
  2. Deploy a workflow via the REST API
  3. Execute it
  4. Poll for completion
  5. 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 inspect exec["error"]
  • The argument field 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"
}
FieldTypeRequiredDescription
sourceContentsstringYesYAML or JSON workflow definition (max 128 KB)
descriptionstringNoHuman-readable description (max 1000 chars)

Response: The workflow resource (see below).

Errors:

  • 400 if workflowId is missing, sourceContents is 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\"}"
}
FieldTypeRequiredDescription
argumentstringNoJSON-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 ACTIVE state

Execution states

StateDescription
ACTIVECurrently running
SUCCEEDEDCompleted successfully (check result field)
FAILEDCompleted with error (check error field)
CANCELLEDCancelled 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

RPCDescription
ListWorkflowsList workflows in a project/location
GetWorkflowGet workflow details
CreateWorkflowCreate a new workflow (returns Operation)
DeleteWorkflowDelete a workflow (returns Operation)
UpdateWorkflowUpdate a workflow (returns Operation)

Executions service

RPCDescription
ListExecutionsList executions for a workflow
CreateExecutionStart a new execution
GetExecutionGet execution details
CancelExecutionCancel 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 GCWEmulator
Create/Update/Delete return long-running Operations that must be polledReturns the result immediately
Requires IAM authenticationAccepts all requests without credentials
Supports pagination (page_size, page_token)Returns all results in one response
Supports filter and order_by parametersNot 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:

FieldDescription
sharedVariables from parent scope writable by branches (must be declared before the parallel step)
concurrency_limitMax concurrent branches/iterations (default: up to 20)
exception_policyunhandled (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:

  • main accepts 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 call steps (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.

FunctionDescriptionExample
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 with map.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. Use json.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
ArgumentTypeRequiredDescription
urlstringYesTarget URL (HTTP or HTTPS)
headersmapNoRequest headers
bodyanyNoRequest body (auto-serialized to JSON if no Content-Type)
querymapNoURL query parameters (URL-encoded automatically)
authmapNoAuth config (accepted but not enforced by emulator)
timeoutintNoTimeout 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 HttpError containing the status code, response body, and headers.

Error behavior

ScenarioError tag
Target service not running (connection refused)ConnectionFailedError
Connection broke mid-transferConnectionError
Request exceeded timeoutTimeoutError
Non-2xx HTTP responseHttpError

Retry policies

PolicyRetries onDoes NOT retry
http.default_retry429, 502, 503, 504, ConnectionError, TimeoutError500
http.default_retry_non_idempotentSame as above500

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:

VariableDescription
GOOGLE_CLOUD_PROJECT_IDProject ID (from PROJECT env var)
GOOGLE_CLOUD_LOCATIONLocation (from LOCATION env var)
GOOGLE_CLOUD_WORKFLOW_IDCurrent workflow ID
GOOGLE_CLOUD_WORKFLOW_REVISION_IDCurrent revision ID
GOOGLE_CLOUD_WORKFLOW_EXECUTION_IDCurrent 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).

FunctionParametersReturnsDescription
text.find_allsource, substrlist of {index, match}Find all substring occurrences
text.find_all_regexsource, patternlist of {index, match}Find all regex matches
text.match_regexsource, patternboolTest if regex matches
text.replace_allsource, substr, replacementstringReplace all occurrences
text.replace_all_regexsource, pattern, replacementstringReplace regex matches (\0 full match, \1-\9 groups)
text.splitsource, separatorlist of stringsSplit string
text.substringsource, start, endstringSubstring (0-based, start inclusive, end exclusive)
text.to_lowersourcestringLowercase
text.to_uppersourcestringUppercase
text.url_encodesourcestringPercent-encode
text.url_decodesourcestringPercent-decode
text.url_encode_plussourcestringPercent-encode with + for spaces
text.decodedata, charsetstringBytes to string (default UTF-8)
text.encodedata, charsetbytesString to bytes (default UTF-8)

json

FunctionParametersReturnsDescription
json.decodedata (string or bytes)anyParse JSON
json.encodevaluebytesEncode to JSON bytes
json.encode_to_stringvaluestringEncode to JSON string
- step:
    assign:
      - parsed: ${json.decode("{\"key\": \"value\"}")}
      - encoded: ${json.encode_to_string(my_map)}

base64

FunctionParametersReturnsDescription
base64.decodedata (string)bytesDecode base64
base64.encodedata (bytes or string)stringEncode to base64

math

FunctionParametersReturnsDescription
math.absvaluesame typeAbsolute value
math.floorvalue (double)intFloor (largest int <= value)
math.maxa, blarger valueMaximum
math.mina, bsmaller valueMinimum

list

FunctionParametersReturnsDescription
list.concatlist, elementnew listAppend element (does not modify original)
list.prependlist, elementnew listPrepend element (does not modify original)
- step:
    assign:
      - items: ${list.concat(items, "new_item")}
      - items: ${list.prepend(items, "first")}

map

FunctionParametersReturnsDescription
map.getmap, key, default?valueGet value without KeyError. Returns null (or default) if missing
map.deletemap, keynew mapRemove key (does not modify original)
map.mergemap1, map2new mapShallow merge (map2 overrides)
map.merge_nestedmap1, map2new mapDeep 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

FunctionParametersReturnsDescription
uuid.generate(none)stringRandom UUID v4 (e.g., "550e8400-e29b-41d4-a716-446655440000")

retry

Built-in retry policies for use with try/retry blocks.

PolicyDescription
retry.alwaysAlways retry (returns true for any error)
retry.neverNever retry (returns false for any error)
retry.default_backoffDefault 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

TypeExamplestype() resultNotes
int1, -5, 0"int"64-bit signed integer
double4.1, -0.5, 3.14e10"double"64-bit IEEE 754
string"hello", 'world'"string"Max 256 KB (UTF-8)
booltrue, false"bool"Also True/False, TRUE/FALSE
nullnull"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

OperatorDescriptionExampleNotes
+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} -> 3Floor 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

OperatorDescription
==Equal (deep equality for maps and lists)
!=Not equal
<Less than
>Greater than
<=Less than or equal
>=Greater than or equal
  • null == null is true
  • null == <anything_else> is false
  • Comparing incompatible types with <, >, <=, >= raises TypeError
  • == and != between incompatible types returns false (no error)
  • Maps and lists use deep equality

Logical

OperatorDescription
andShort-circuit AND
orShort-circuit OR
notLogical 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

OperatorDescriptionExample
inKey exists in map, or value in list${"key" in my_map}, ${item in my_list}
not inNegation of in${"key" not in my_map}

Property and index access

SyntaxDescription
obj.keyMap property access
obj["key"]Map key access (supports dynamic keys)
list[0]List index access (0-based)
obj.a.b[0].cNested 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:

  1. ., [] -- property/index access
  2. not, - (unary) -- negation
  3. *, /, %, // -- multiplicative
  4. +, - -- additive
  5. <, >, <=, >= -- relational
  6. ==, != -- equality
  7. in, not in -- membership
  8. and -- logical AND
  9. or -- 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

CaseBehavior
${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 overflowWraps (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:

TagWhen raisedTypical code
AuthErrorGenerating credentials fails0
ConnectionErrorConnection broke mid-transfer0
ConnectionFailedErrorConnection never established (service down, DNS failure)0
HttpErrorNon-2xx HTTP responseHTTP status code
IndexErrorList index out of range0
KeyErrorMap key not found, or unknown env var in sys.get_env0
OperationErrorLong-running operation failure0
ParallelNestingErrorParallel nesting exceeds depth 20
RecursionErrorCall stack depth exceeds 200
ResourceLimitErrorMemory, step count, or other resource limits exceeded0
ResponseTypeErrorUnexpected response type from operation0
SystemErrorInternal system error0
TimeoutErrorHTTP request or callback await timed out0
TypeErrorType mismatch (e.g., "hi" + 5, not "string")0
UnhandledBranchErrorRaised after continueAll parallel when branches had errors0
ValueErrorCorrect type but invalid value (e.g., int("abc"))0
ZeroDivisionErrorDivision or modulo by zero0

Errors can have multiple tags. For example, an HTTP 404 error has tags: ["HttpError"].

ConnectionFailedError vs ConnectionError

This distinction is critical for local development:

ErrorMeaningCommon cause
ConnectionFailedErrorConnection was never establishedService not running, port not listening, DNS failure
ConnectionErrorConnection established but broke during transferService 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

  1. Error occurs in a step
  2. If inside a try block with retry: retry is attempted first
  3. If retry exhausted or not configured: except block executes (if present)
  4. If no except or except re-raises: error propagates up
  5. In a subworkflow: propagates to the caller
  6. In a parallel branch: depends on exception policy (unhandled aborts all; continueAll collects)
  7. At the top level of main: execution fails with state FAILED

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

PolicyRetries onDoes NOT retry
http.default_retry429, 502, 503, 504, ConnectionError, TimeoutError500
http.default_retry_non_idempotentSame as above500
retry.alwaysEverything(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

LimitValueError on exceed
Assignments per assign step50ResourceLimitError
Conditions per switch step50ResourceLimitError
Call stack depth (subworkflow nesting)20RecursionError
Steps per execution100,000ResourceLimitError
Expression length400 charactersValidation error

Parallel execution limits

LimitValueError on exceed
Branches per parallel step10ResourceLimitError
Max concurrent branches/iterations20ResourceLimitError
Parallel nesting depth2ParallelNestingError
Unhandled exceptions per execution (continueAll)100--

Size limits

LimitValue
Workflow source code128 KB
Variable memory (all variables, arguments, events)512 KB
Maximum string length256 KB
HTTP response size2 MB
Execution argument size32 KB

HTTP limits

LimitValue
HTTP request timeout1800 seconds (30 minutes)
Execution duration1 year

What happens when a limit is exceeded

  • Assignment/switch/branch limits: Deployment or validation error before execution starts
  • Call stack depth: RecursionError at runtime when depth 20 is exceeded
  • Step count: ResourceLimitError after 100,000 steps in a single execution
  • Parallel nesting: ParallelNestingError when nesting depth exceeds 2
  • Memory/size limits: ResourceLimitError when variable memory or result size exceeds the cap
  • HTTP timeout: TimeoutError when a request exceeds the configured timeout

Tips for staying within limits

  • Assign unused variables to null to 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_limit in 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
VariableDefaultDescription
PORT8787REST API and Web UI port
HOST0.0.0.0Bind address
GRPC_PORT8788gRPC API port
PROJECTmy-projectGCP project ID for API paths
LOCATIONus-central1GCP 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

  1. Start your services locally on different ports
  2. Start the emulator: gcw-emulator --workflows-dir=./workflows
  3. Trigger an execution via the API
  4. The emulator calls your services in order, passing data between them
  5. 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/json from 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

EnvironmentMY_SECRET env varBehavior
EmulatorSet to the actual secret valueswitch falls through, uses env var directly
GCP ProductionNot setswitch 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

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 .yaml or .json
  • The --workflows-dir flag 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_callback step
  • The workflow calls sys.sleep with 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

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

  1. Fork the repository
  2. Clone your fork:
    git clone https://github.com/YOUR_USERNAME/gcw-emulator.git
    cd gcw-emulator
    
  3. 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

  1. Ensure go vet ./... passes
  2. Ensure all unit tests pass: go test ./pkg/...
  3. Ensure integration tests pass (see above)
  4. Write a clear commit message describing the change
  5. Submit a pull request against the main branch

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