Skip to content

Action Types

Actions are the smallest execution unit in Strøm. Each action defines what runs and how.

Type Description
script Runs scripts in a runner environment with workspace files. Supports runner: local (default), docker, or pod.
docker Runs a Docker image as-is — no workspace mount.
pod Runs an image as a Kubernetes pod — no workspace mount.
task References another task, creating a child job. Dispatched server-side — workers never see them.
agent Calls an LLM as a workflow step. Dispatched server-side — workers never see them.
approval Pauses execution for human approval. Dispatched server-side — workers never see them.

docker / pod actions require image. The image’s default entrypoint/cmd runs unless overridden. Use this when you have a self-contained image (e.g., DB migrations, deploy tools).

script actions require script (inline code) or source (file path). Workspace files are available at /workspace (read-only). Use this for build/test/deploy scripts that need your source code. Supports multiple languages via the language field.

Quick reference showing which fields are valid for each action type:

Field type: script type: docker type: pod type: task type: agent type: approval
script ✓ (inline) - - - - -
source ✓ (file path) - - - - -
cmd - ✓ ✓ - - -
entrypoint - ✓ ✓ - - -
command - ✓ ✓ - - -
image - ✓ ✓ - - -
runner ✓ (local/docker/pod) - - - - -
language ✓ (shell/python/js/ts/go) - - - - -
dependencies ✓ - - - - -
interpreter ✓ - - - - -
manifest ✓ (pod runner only) - ✓ - - -
task - - - ✓ - -
provider - - - - ✓ -
prompt - - - - ✓ -
system_prompt - - - - ✓ -
message - - - - - ✓
model - - - - ✓ -
output - - - - ✓ -
temperature - - - - ✓ -
tags ✓ ✓ ✓ - - -
env ✓ ✓ ✓ - - -
timeout ✓ ✓ ✓ - ✓ ✓
input ✓ ✓ ✓ ✓ ✓ ✓

Script actions run commands or scripts in a runner environment. By default, scripts run as shell commands, but you can use the language field to write inline scripts in Python, JavaScript, TypeScript, or Go.

actions:
greet:
type: script
script: "echo Hello {{ input.name }}"
input:
name: { type: string, required: true }

Scripts are relative to the workspace root:

actions:
deploy:
type: script
source: actions/deploy.sh
input:
env: { type: string, default: "staging" }

Pass arguments to scripts using the args field. Each element is a Tera template:

actions:
deploy:
type: script
source: actions/deploy.sh
args:
- "{{ input.environment }}"
- "--region"
- "{{ input.region }}"
input:
environment: { type: string }
region: { type: string, default: "us-east-1" }

For shell scripts, args become positional parameters ($1, $2, …). For other languages, they appear as standard command-line arguments (e.g. sys.argv[1:] in Python).

The args field is only valid on type: script actions. Docker and pod actions should use cmd, command, or entrypoint instead.

Actions can declare environment variables. Values support templating:

actions:
deploy:
type: script
source: actions/deploy.sh
env:
DEPLOY_ENV: "{{ input.env }}"
API_KEY: "{{ secret.api_key }}"
input:
env: { type: string }

Use the language field to write inline scripts in languages other than shell. When language is set, the script content is written to a temporary file and executed with the appropriate interpreter.

Language Value Aliases Toolchain preference
Shell shell (default) — bash > sh
Python python — uv > python3 > python
JavaScript javascript js bun > node
TypeScript typescript ts bun > deno
Go go — go run

Python example:

actions:
analyze-data:
type: script
language: python
dependencies:
- pandas
- requests
script: |
import pandas as pd
import requests
resp = requests.get("https://api.example.com/data")
df = pd.DataFrame(resp.json())
print(f"Rows: {len(df)}")
print(f'OUTPUT: {{"count": {len(df)}}}')
input:
url: { type: string }

JavaScript example:

actions:
fetch-status:
type: script
language: javascript
script: |
const resp = await fetch("https://api.example.com/status");
const data = await resp.json();
console.log(`Status: ${data.status}`);
console.log(`OUTPUT: ${JSON.stringify(data)}`);

TypeScript example:

actions:
generate-report:
type: script
language: typescript
dependencies:
- zod
script: |
import { z } from "zod";
const Schema = z.object({ name: z.string() });
const result = Schema.parse({ name: "test" });
console.log(result);

Go example:

actions:
compute:
type: script
language: go
script: |
package main
import "fmt"
func main() {
fmt.Println("OUTPUT: {\"result\": 42}")
}

The dependencies field installs packages before running the script. It requires a language other than shell.

Language Install command
Python uv pip install --system <deps> or pip install <deps>
JavaScript bun install <deps> or npm install <deps>
TypeScript bun install <deps> or npm install <deps>
Go Dependencies are resolved automatically by go run

Use the interpreter field to override the auto-detected binary:

actions:
legacy-python:
type: script
language: python
interpreter: python3.11
script: |
import sys
print(f"Using Python {sys.version}")

Runs a prepared Docker image as-is. No workspace files are mounted.

actions:
migrate-db:
type: docker
image: company/db-migrations:v3
env:
DB_URL: "{{ secret.db_url }}"
# No cmd — image's default entrypoint runs
deploy:
type: docker
image: company/deploy-tool:latest
cmd: "deploy --env production"
custom-entrypoint:
type: docker
image: company/tool:v2
entrypoint: ["/app/run"]
cmd: "--verbose --env staging"

Runs a prepared image as a Kubernetes pod. No workspace files are downloaded.

actions:
train-model:
type: pod
image: pytorch/pytorch:2.1.0-cuda12.1-cudnn8-runtime
cmd: "python /app/train.py --epochs 10"
tags: ["gpu"]

Runs scripts in a Docker container with the workspace mounted at /workspace (read-only):

actions:
lint-python:
type: script
runner: docker
script: "pip install ruff && ruff check /workspace"
run-tests:
type: script
runner: docker
script: "cd /workspace && npm ci && npm test"
analyze:
type: script
runner: docker
language: python
dependencies: [pandas]
script: |
import json
import pandas as pd
df = pd.read_csv("/workspace/data.csv")
print("OUTPUT: " + json.dumps({"rows": len(df)}))

Workers can be configured with a default runner_image in their configuration. When a type: script action with runner: docker or runner: pod runs, the configured runner_image is used instead of the published stroem-runner image. If no runner_image is configured, the system falls back to the default image.

Set this in your worker’s worker-config.yaml:

runner_image: "ghcr.io/myorg/custom-runner:latest"

This allows you to pre-bake tools, dependencies, and configurations into a custom runner image tailored to your workflows.

Runs scripts as a Kubernetes pod with the workspace downloaded via an init container, at the step’s revision (see Runners):

actions:
gpu-test:
type: script
runner: pod
tags: ["gpu"]
script: "python /workspace/test_gpu.py"

Both pod runners (type: pod and runner: pod) name the pod stroem-{first 8 chars of job id}-{task}-{step}. Task and step are lowercased, every other character becomes -, repeated dashes collapse to one, and leading/trailing dashes are dropped. A name longer than 63 characters is cut short and ends in an 8-character hash of the full job id, task and step. This keeps the instances of a long-named for_each step (step[10], step[11]) apart, although it does not guarantee unique names.

By default a step pod carries these labels, which you can use with kubectl -l:

Label Value
app stroem-step
stroem.io/job-id the job id
stroem.io/task the task name, in the same lowercased form as the pod name (left out if nothing remains)
stroem.io/step the step name with characters Kubernetes does not allow in a label replaced by - (deploy[0] → deploy-0)

Label values are capped at 63 characters. A manifest override’s metadata.labels is merged over these defaults, so it can replace them. The exact step name is in the stroem.io/step-name annotation. Strøm always sets this annotation, replacing any value a manifest gives it.

Actions of type: task reference another task by name. When a step using a task action becomes ready, the server creates a child job that runs the referenced task’s full flow.

actions:
run-cleanup:
type: task
task: cleanup-resources
tasks:
cleanup-resources:
mode: distributed
input:
env: { type: string }
flow:
cleanup:
action: cleanup-resources-action
input:
env: "{{ input.env }}"
deploy:
mode: distributed
input:
env: { type: string, default: "staging" }
flow:
build:
action: build-app
cleanup:
action: run-cleanup
depends_on: [build]
input:
env: "{{ input.env }}"

When the deploy task’s cleanup step becomes ready, the server creates a child job running cleanup-resources. The child completes its own flow, and the parent step is marked as completed.

  • Must have a task field referencing an existing task — in the same workspace, or, since 0.16.5, another workspace by workspace.task name (see Calling tasks in other workspaces)
  • May have a ref field — a branch, tag or commit SHA of the task’s owner workspace to run the task from (see Git Refs); ref is valid on no other action type
  • Cannot have script, source, image, or runner fields
  • No worker tags required — task steps are server-dispatched
  • Self-referencing tasks are rejected at validation time (with ref, the same task at another commit is allowed)
  • Maximum nesting depth of 10 levels prevents infinite recursion
  • Input templates are rendered server-side before creating the child job
  • Child jobs track their parent via parent_job_id and parent_step_name
  • When a child completes, the parent step is marked completed with the child’s output
  • When a child fails, the parent step is marked as failed
  • The parent’s orchestrator runs after child completion, promoting downstream steps
  • The job detail’s step view lists every child job created for the step (child_jobs), newest first
  • A child job’s detail page links back under its title — child of <parent> at step <step> — and GET /api/jobs/{id} returns both parent_job_id and parent_step_name

Cross-workspace type: task actions (a task: field naming another workspace’s task, directly or through a cross-workspace action: wrapper) changed several existing behaviours:

  1. action: platform.run-deploy (a cross-workspace type: task action) now runs platform’s task. Before, it ran the caller’s task of the same bare name if one existed, else failed.
  2. A type: task action’s own input defaults are taken from the action definition persisted on the step at job creation, not re-read at dispatch: editing the action no longer changes an in-flight job’s child. Secrets the defaults reference are still read live at dispatch, and the referenced task definition is still resolved live.
  3. Connection-typed fields on a type: task action’s schema are no longer resolved against the action schema — at creation (literal pre-check) or at dispatch. Resolution happens once, against the task’s schema. A field the action declares as a connection but the task declares as a primitive, or that is absent from the task’s schema entirely, now arrives as the name string, not the resolved object.
  4. On a cross-workspace call, a non-string value in a connection-typed task input is rejected. It is a 400 at submit only when the value is a literal supplied by the caller’s flow step, the step has no when, and the caller is not the task’s workspace; a templated value, a value on a when-guarded step, or a foreign action default fails the step at dispatch if the step is reached. Within one workspace an object is still accepted as before.
  5. The creation-time check that a type: task action’s target task exists now runs for every type: task reference — a plain, same-workspace task name included — and regardless of whether the step has a when guard: an unknown workspace, a missing task, or a self-reference is a 400 at submit (500 if the owner workspace is configured but currently unavailable). Before this release, only a cross-workspace (qualified) reference got this treatment; a plain reference to a missing task used to fail the step later, at dispatch. The separate check for a bad literal connection value on the task’s schema is exempt for a when-guarded step (the step may never run) — it is a 400 at submit only when the step has no when; otherwise it falls back to failing the step at dispatch if reached, same as a templated value. A hook action wrapping a cross-workspace task is not created at all — see below.
  6. The job detail step DTO carries child_jobs for type: task steps.
  7. A cross-workspace type: task step’s own job-step input (visible on the parent job) no longer shows the action owner’s resolved default values when the owner is a different workspace from the caller — only what the caller itself supplied. The child job’s input is unaffected and still carries the full merged input.

Action input defaults are rendered exactly once. A default whose render produces {{ … }} text (for example default: "{{ secret.X }}" where secret X’s value contains {{) keeps that text literally; it is never evaluated a second time.

Actions that run as Kubernetes pods (type: pod or type: script + runner: pod) support a manifest field for injecting arbitrary pod configuration. The value is deep-merged into the generated pod manifest.

  • Objects: recursively merged
  • Arrays of objects with name field (e.g., containers, env): matched by name and deep-merged per element; unmatched entries are appended
  • Other arrays and scalars: replaced entirely

Service account and annotations:

actions:
deploy:
type: pod
image: company/deploy:latest
cmd: "deploy --env production"
manifest:
metadata:
annotations:
iam.amazonaws.com/role: my-role
spec:
serviceAccountName: my-sa

Resource limits (target the step container by name):

actions:
heavy-build:
type: pod
image: node:20
cmd: "npm run build"
manifest:
spec:
containers:
- name: step
resources:
requests:
memory: "256Mi"
cpu: "500m"
limits:
memory: "512Mi"

Node selector and tolerations:

actions:
train-model:
type: pod
image: pytorch/pytorch:2.1.0
tags: ["gpu"]
cmd: "python train.py"
manifest:
spec:
nodeSelector:
gpu: "true"
tolerations:
- key: "gpu"
operator: "Exists"
effect: "NoSchedule"

Adding a sidecar container:

actions:
test-with-db:
type: pod
image: node:20
cmd: "npm test"
manifest:
spec:
containers:
- name: postgres-sidecar
image: postgres:16
env:
- name: POSTGRES_PASSWORD
value: test

The manifest field is rejected on type: docker, type: task, and type: script with runner: local or runner: docker.

Actions can emit structured output by printing a line with the OUTPUT: prefix followed by JSON:

#!/bin/bash
echo "Doing work..."
echo "OUTPUT: {\"status\": \"deployed\", \"version\": \"1.2.3\"}"

Only the last OUTPUT: {json} line is captured. The JSON is parsed and made available to downstream steps via templating.

Actions of type: approval pause job execution and request human approval before continuing. The approver can approve or reject the step, optionally providing a rejection reason.

  1. Pending: Step is ready to execute but waiting for approval.
  2. Suspended: Step is paused and awaiting human decision. Triggers on_suspended hooks (see Hooks).
  3. Completed or Failed: Approver has made a decision; the step transitions to the final state and the job continues.
actions:
deploy-approval:
type: approval
message: "Approve deployment to production?"
tasks:
deploy:
flow:
approve:
action: deploy-approval
execute:
action: deploy-script
depends_on: [approve]

The message field is a Tera template with access to job input and completed step outputs. Use it to show approvers relevant context:

actions:
deploy-approval:
type: approval
message: |
Approve deployment of **{{ input.version }}** to **{{ input.environment }}**?
Current version: {{ input.current_version }}
Deployer: {{ input.deployer_email }}
tasks:
deploy:
input:
version: { type: string, required: true }
environment: { type: string, required: true }
current_version: { type: string, required: true }
deployer_email: { type: string, required: true }
flow:
approve:
action: deploy-approval
input:
version: "{{ input.version }}"
environment: "{{ input.environment }}"
current_version: "{{ input.current_version }}"
deployer_email: "{{ input.deployer_email }}"
execute:
action: deploy-script
depends_on: [approve]
input:
version: "{{ input.version }}"
environment: "{{ input.environment }}"

After a step enters suspended state, approvers use the API to make a decision:

Approve:

Terminal window
curl -X POST "http://localhost:8080/api/jobs/{job_id}/steps/{step_name}/approve" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"approved": true}'

Reject:

Terminal window
curl -X POST "http://localhost:8080/api/jobs/{job_id}/steps/{step_name}/approve" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"approved": false, "rejection_reason": "Version mismatch — need to use 2.0 instead"}'

The rejection_reason is optional but recommended for audit trails. When rejected, the step fails. Steps after a rejected approval run only if their own depends_on edge to the approval step accepts failed (e.g. accept: [completed, failed] or accept: any) — otherwise they’re skipped unreachable. The approval step’s own continue_on_failure is a separate decision: it’s what decides whether the rejection fails the job, not whether anything downstream runs.

Suspended steps are subject to a timeout. If not approved or rejected within the timeout period, the recovery sweep automatically fails the step:

actions:
deploy-approval:
type: approval
message: "Approve deployment?"
timeout: 24h # Max 24 hours — default is no timeout

Timed-out steps fail with a message like "Approval step timed out after 24h". Steps after it run only if their own depends_on edge accepts failed — otherwise they’re skipped unreachable. The approval step’s own continue_on_failure only decides whether that timeout fails the job.

When a step enters suspended state, on_suspended hooks fire to notify approvers. See Hooks for full documentation and examples.