Skip to content

API

Base URL: http://localhost:8080 (configurable via server-config.yaml)

Task, job, and log endpoints do not require authentication unless the server is configured with an auth section. When ACL is configured, list endpoints (tasks, jobs, workspaces) are filtered based on user permissions. See Authorization for details.

GET /api/workspaces

Returns all configured workspaces with task, action, and trigger counts. triggers_enabled is false when the server config sets triggers: false for the workspace (its triggers are listed but never fired by this server). display_name and description come from the same server-config entry (see Display name and description); each is omitted when unset. name is always the workspace key used in every other path.

Response:

[
{
"name": "default",
"display_name": "Default jobs",
"description": "Jobs for the platform team",
"tasks_count": 3,
"actions_count": 5,
"triggers_count": 1,
"connections_count": 0,
"triggers_enabled": true,
"revision": "abc123"
}
]
POST /api/workspaces/{ws}/refresh

Force-reload a workspace from its source (git fetch or folder re-hash), bypassing the normal poll interval. Use this from CI pipelines to ensure the workspace is up-to-date before triggering a task.

Response:

{
"workspace": "default",
"revision": "abc123def456",
"refreshed": true
}

Errors:

Status Reason
404 Workspace not found
500 Reload failed (e.g., git fetch error)

Example — GitHub Actions workflow:

Terminal window
# Ensure Strøm has the latest code before executing
curl -X POST https://stroem.example.com/api/workspaces/default/refresh \
-H "Authorization: Bearer $STROEM_API_KEY"
# Then trigger the task
curl -X POST https://stroem.example.com/api/workspaces/default/tasks/deploy/execute \
-H "Authorization: Bearer $STROEM_API_KEY"
GET /api/workspaces/{ws}/tasks

Returns all tasks from the specified workspace.

Parameter Description
ws Workspace name

Response:

[
{
"name": "hello-world",
"workspace": "default",
"mode": "distributed",
"has_triggers": true
}
]

The folder field is included when set on the task. has_triggers is true when at least one enabled trigger targets this task.

GET /api/workspaces/{ws}/tasks/{name}
Parameter Description
ws Workspace name
name Task name

Response:

{
"name": "hello-world",
"mode": "distributed",
"input": {
"name": { "type": "string", "default": "World" }
},
"flow": {
"say-hello": {
"action": "greet",
"input": { "name": "{{ input.name }}" }
}
},
"triggers": [
{
"name": "nightly",
"type": "scheduler",
"cron": "0 0 2 * * *",
"task": "hello-world",
"enabled": true,
"input": {},
"next_runs": ["2026-02-19T02:00:00+00:00"]
}
]
}

The triggers array contains all triggers targeting this task. Scheduler triggers include next_runs with the next 5 upcoming fire times.

GET /api/workspaces/{ws}/tasks/{name}/stats?limit=50

Aggregated duration percentiles over the most recent completed runs of a task. Used by the UI to render the per-task duration insights card, the running-job ETA pill, and per-step p50 comparisons.

Parameter Description
ws Workspace name
name Task name
limit (query) Number of most-recent completed runs to aggregate. Default 50, clamped to [1, 500].

Failed, cancelled, and skipped runs are excluded — they would distort the distribution. for_each instance step rows (step[0], step[1], …) are also excluded from the per-step breakdown; the placeholder row already spans the whole loop, which is what you want for ETA.

ACL: View permission is sufficient.

Response:

{
"window": 50,
"task": {
"sample_size": 47,
"avg_ms": 5230.4,
"p50_ms": 4800.1,
"p95_ms": 12100.8,
"min_ms": 2100.0,
"max_ms": 18000.0,
"recent": [
{ "job_id": "...", "duration_ms": 4900.0, "completed_at": "2026-05-14T09:31:00Z" }
]
},
"steps": [
{
"step_name": "build",
"sample_size": 47,
"avg_ms": 1900.0,
"p50_ms": 1820.0,
"p95_ms": 4100.0,
"min_ms": 800.0,
"max_ms": 6200.0
}
]
}

All percentile and aggregate fields are null when sample_size is 0. Clients should treat percentiles as untrustworthy below ~5 samples and surface “insufficient data” instead.

POST /api/workspaces/{ws}/tasks/{name}/execute

Creates a new job for the given task.

Parameter Description
ws Workspace name
name Task name

Request body:

{
"input": { "name": "World" }
}

Response:

{
"job_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
POST /api/workspaces/{ws}/tasks/{name}/files?name=<file name>[&source_job_id=<job id>]

Uploads a file for a file input field. Send the raw bytes as the body (not multipart). The Content-Type header is stored as sent (default application/octet-stream). The server streams the body to storage and computes the size and SHA-256.

Parameter Description
ws, name Workspace and task
name (query) The original file name. The step sees a safe name
source_job_id Optional. Use it to upload a replacement file for a Re-run of that job

Response (201):

{
"file_id": "file_7f3a91c2e0b84d5aa1c3b2d4e5f60718",
"name": "report.csv",
"size_bytes": 1234,
"sha256": "9f86d081…",
"content_type": "text/csv",
"expires_at": "2026-10-31T12:00:00Z"
}

Execute the task with {"input": {"data_file": "file_7f3a…"}}. An unused upload expires after input_files.upload_ttl_secs (24 hours by default). An upload belongs to the user who made it and to this task.

Without source_job_id the call needs the Run permission on the task as it is now, and the task must declare a file field. With source_job_id it follows the rules of a Re-run, in this order: a source job that does not exist or that you may not view gives 404 Source job not found before anything else is checked; a pinned source job on which you have only View gives 403; then the source is checked (same workspace, a top-level job with a recorded input, the same task); then the task is taken at the source job’s ref (pinned) or as it is now, and it needs Run and a file field.

Status Description
201 Stored
400 invalid file name (the name query is missing, empty, . or ..); task has no file input; or the source job cannot be re-run with this task, for example Source job … is a run of task 'a', not 'b'
401 Authentication required (no token), Invalid or expired token, or Invalid API key / API key expired
403 View-only access: View permission on the task, or on a pinned source job
404 Workspace '{ws}' not found, Task not found (also when you have no permission on the task), or Source job not found
413 file is larger than {max} bytes (input_files.max_file_bytes). Nothing is kept
503 file storage is not configured
POST /api/uploads/{token}

Uploads a file through a single-use URL that the MCP tool create_upload_url returned. Send no Authorization header: the token is the credential. The body and the response are the same as for Upload Input File. The token is used up when the upload starts, so a failed upload needs a new URL. An unknown, used or expired token gives 404 Upload URL not found. The upload re-checks that the URL’s user may still run the task and that the task still has a file field; otherwise the answer is the same 404 Upload URL not found.

GET /api/jobs/{id}/input-files/{field}

Streams the file that a job received for the task-level input field field. Requires View permission on the job, checked on the job’s own task (a pinned job: the task folder at its ref). The response headers are like those of Download Job Artifact: the stored Content-Type, Content-Length, X-Content-Type-Options: nosniff, Cache-Control: private, max-age=0, must-revalidate, and a Content-Disposition that is inline only for the same safe types as artifacts, with the original name in filename*=UTF-8''… (and an ASCII-only filename= fallback).

Status Description
200 The file
400 The job ID is not a valid UUID
401 Authentication is on and the request has no valid token
404 Job not found (also when you may not view the job), or Input file not found (the job has no file for field)
503 file storage is not configured
GET /api/workspaces/{ws}/triggers

Returns all triggers in the workspace with upcoming fire times for cron-based triggers.

Response:

[
{
"name": "nightly",
"type": "scheduler",
"cron": "0 0 2 * * *",
"task": "nightly-backup",
"enabled": true,
"input": { "env": "production" },
"next_runs": ["2026-02-19T02:00:00+00:00"]
}
]
GET /api/jobs
Query parameter Default Description
workspace — Filter by workspace name
task_name — Filter by task name (requires workspace)
limit 50 Number of jobs to return
offset 0 Pagination offset

Response:

{
"items": [
{
"job_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"workspace": "default",
"task_name": "hello-world",
"mode": "distributed",
"status": "completed",
"source_type": "api",
"source_id": null,
"revision": "abc123def456",
"ref": null,
"created_at": "2025-02-10T12:00:00Z",
"started_at": "2025-02-10T12:00:01Z",
"completed_at": "2025-02-10T12:00:03Z"
}
],
"total": 1
}

ref is set on a job that runs on a git ref — the ref as written; revision is then its commit. With ACL configured, the list and total contain only the jobs you may view, filtered before limit / offset: a pinned job is authorised by the folder its own commit declares for its task.

Source types: "api", "user", "trigger", "webhook", "hook", "task"

Job statuses: pending, running, completed, failed, cancelled

GET /api/jobs/{id}

Returns job metadata and all steps with statuses.

Parameter Description
id Job ID (UUID)

Response:

{
"job_id": "a1b2c3d4-...",
"workspace": "default",
"task_name": "hello-world",
"status": "completed",
"revision": "abc123def456",
"ref": null,
"input": { "name": "World" },
"steps": [
{
"step_name": "say-hello",
"action_name": "greet",
"action_type": "script",
"runner": "local",
"input": { "name": "World" },
"output": { "greeting": "Hello World" },
"status": "completed",
"worker_id": "w1w2w3w4-...",
"started_at": "2025-02-10T12:00:01Z",
"completed_at": "2025-02-10T12:00:02Z",
"error_message": null,
"skip_reason": null,
"action_workspace": null,
"action_revision": null,
"action_ref": null,
"task_workspace": null,
"task_ref": null,
"task_revision": null
}
],
"input_files": [
{ "field": "data_file", "file_id": "file_7f3a…", "name": "report.csv", "size_bytes": 1234, "content_type": "text/csv" }
],
"derived_jobs": [
{
"id": "e5f6a7b8-...",
"source_type": "restart",
"status": "running",
"created_at": "2025-02-10T12:30:00Z",
"restart_from_step": "say-hello",
"restart_from_steps": null
}
]
}

skip_reason is null unless status is skipped; then one of condition, empty, cascade, unreachable (see the Conditionals guide).

ref is set on a pinned job — the ref as written; revision is then the resolved commit. On a step, action_workspace / action_revision name the workspace and commit its action came from (set for a cross-workspace or ref’d action), action_ref the ref it was resolved through, and task_workspace / task_ref / task_revision the commit a type: task step’s task runs at. A type: task step also lists its child_jobs[] (id, workspace, task_name, status, created_at, ref, revision).

input_files[] lists the files that the job received, one entry per task-level file field, ordered by field: field, file_id, name (the original name), size_bytes and content_type. It is an empty array when the job has none. field and file_id are identifiers and are never masked; name and content_type are masked like other content. Download a file with Download Input File.

derived_jobs[] lists the Restarts and Re-runs created from this job, newest first: id, source_type (restart or rerun), status, created_at, restart_from_step (null for a re-run and for a restart of failed steps) and restart_from_steps (the failed or cancelled steps a restart of failed steps began at, else null). The job itself has the same two fields. Hook jobs are not listed. With ACL configured, a run you may not view is left out. It is an empty array when nothing was created from the job.

Secret values are masked as •••••• everywhere in the response except identifiers (ids, names, statuses, revisions, refs, timestamps): workspace secrets, connection properties marked secret: true, ref+… vals references, and the secrets of every pinned commit the job is connected to (see Git Refs).

Status Description
404 Job not found, or not visible to you under the ACL
503 {"error": "redaction set unavailable, retry"} — the secrets needed to mask this job cannot be loaded right now (a pinned commit is unreachable); retry later

When a pinned commit the job is connected to can never be loaded again (it was force-pushed away, or its YAML is broken), the answer is 200 with every content string masked; ids, statuses and timestamps stay readable.

Step statuses: pending, ready, running, completed, failed, skipped, cancelled

POST /api/jobs/{id}/cancel

Cancels a pending or running job. Running steps are actively killed (processes terminated, containers stopped, pods deleted). Pending steps are marked as cancelled. Child jobs are recursively cancelled. Fires on_error hooks.

Parameter Description
id Job ID (UUID)

Response (200):

{
"status": "cancelled"
}

Error responses:

Status Description
404 Job not found
409 Job is already in a terminal state (completed, failed, or cancelled)

CLI:

Terminal window
stroem cancel <job_id>
POST /api/jobs/{id}/restart

Creates a new job for the same task and input, rerunning only the start steps and their dependents; every other step is carried over from the source job unchanged. The start step is either one step you choose (from_step) or every failed or cancelled step of the source job (all_failed: true; see Restart failed steps and Re-running and Restarting Jobs). Requires Run permission on the task. The source job must be terminal, must be a top-level job (not a type: task child, agent tool call, hook or uploaded-state job) and must have raw_input recorded (jobs predating Re-run prefill are rejected).

Parameter Description
id Source job ID (UUID)

Request body:

{
"from_step": "publish",
"dry_run": false
}
{
"all_failed": true,
"dry_run": false
}

Give exactly one of from_step and all_failed: true; both or neither is a 400.

Field Type Description
from_step string Step name to restart from. Must be a step in the task’s current flow, and not a for_each instance name (e.g. step[0]). For a pinned source job, “current” means the flow at the source’s ref re-resolved now.
all_failed bool When true, start at every top-level step of the source job that is failed or cancelled (or did not finish), continue_on_failure steps included. A for_each loop restarts as a whole. Steps no longer in the current flow are ignored.
dry_run bool When true, returns the plan without creating a job.

Response — dry_run: true (200):

{
"start_steps": ["publish"],
"restart_steps": ["agg-sessions", "publish", "recalc"],
"carried_over": ["dates", "recalc-sessions", "ai-sources"],
"carried_failed": [],
"carried_failed_tolerated": []
}

Response — dry_run: false (201):

{
"job_id": "a1b2c3d4-...",
"start_steps": ["publish"],
"restart_steps": ["agg-sessions", "publish", "recalc"],
"carried_over": ["dates", "recalc-sessions", "ai-sources"],
"carried_failed": [],
"carried_failed_tolerated": []
}
Field Description
start_steps The steps the restart begins at, sorted: [from_step], or the failed and cancelled steps. Steps added to the flow since the source job ran also rerun, but are listed only in restart_steps.
restart_steps Steps that will run (or ran) again under the current flow, sorted by name.
carried_over Steps copied forward from the source job without re-executing.
carried_failed Carried-over steps that ended failed and whose own continue_on_failure (under the current flow) does not tolerate it — the new job will end failed. Always empty with all_failed.
carried_failed_tolerated Carried-over steps that ended failed but whose own continue_on_failure (under the current flow) tolerates it. They are not rerun. Always empty with all_failed.

Error responses:

Status Description
400 The body gives both or neither of from_step and all_failed: true; the source job is not top-level or has no raw_input; from_step is not in the current flow or is a loop instance; all_failed finds no failed or cancelled step in the current flow; the workspace/task no longer exists; or the task’s input schema has gained a required field with no default
401 Auth is configured and no user was authenticated
403 Authenticated user has View but not Run permission on the task
404 Job not found, or ACL denies access
409 Source job is not terminal
GET /api/jobs/{id}/logs
GET /api/jobs/{id}/logs?tail_bytes=1048576
GET /api/jobs/{id}/logs?full=true

Returns the end of the job’s log by default: the last 256 KiB, cut to whole JSONL lines. tail_bytes asks for a different tail (1 byte to log_storage.read.tail_max_bytes, 4 MiB by default). full=true streams the whole log instead. The two parameters are mutually exclusive; an invalid value is a 400.

Tail response (application/json):

{
"logs": "{\"ts\":\"...\",\"stream\":\"stdout\",\"step\":\"say-hello\",\"line\":\"Hello World\"}\n",
"truncated": false,
"total_bytes": 81,
"returned_bytes": 81
}
  • truncated — true when earlier lines may exist; false is exact.
  • total_bytes — an upper bound on the size of the whole job log.
  • returned_bytes — the length of logs.

Full response (application/x-ndjson; charset=utf-8): the raw JSONL stream, no envelope.

Every response carries X-Stroem-Log-Source: local | archive | merged | none, naming the source that answered (see Log storage).

GET /api/jobs/{id}/steps/{step}/logs

Same parameters, envelope and header as Get Job Logs, filtered to one step. Use _server as the step name for server-side events.

GET /api/jobs/{id}/logs/stream

Opens a WebSocket for live log streaming. On connect the server sends the last 256 KiB of the log (whole lines) as one frame, then streams new chunks. ?skip_backfill=true skips that first frame. A chunk pushed while that first frame is being read can arrive twice, once inside the backfill and once as a live frame. Live delivery is best-effort: a client that falls more than 256 chunks behind skips the oldest, and in a multi-replica deployment a single log line over ~3.5 KB pushed through another replica is not streamed (it is in the stored log).

Terminal window
websocat ws://localhost:8080/api/jobs/JOB_ID/logs/stream
GET /api/jobs/{id}/artifacts

Lists files produced by successful steps in this job (anything the worker uploaded from /artifacts/). Requires View permission on the task.

Response:

[
{
"name": "report.html",
"content_type": "text/html",
"size_bytes": 12453,
"step_name": "build",
"created_at": "2026-06-01T10:00:00Z",
"url": "http://localhost:8080/api/jobs/JOB_ID/artifacts/report.html"
}
]

Empty array when no artifacts were produced or the job is still running.

GET /api/jobs/{id}/artifacts/{name}

Streams the artifact body with the Content-Type recorded at upload time. name may contain / (artifacts produced under sub-directories like reports/q1.html keep the slash). Requires View permission on the task.

Response headers:

  • Content-Type — the type recorded when the worker uploaded the file (sniffed via infer).
  • Content-Length — exact byte count.
  • X-Content-Type-Options: nosniff — blocks browsers from second-guessing the type.
  • Content-Disposition — inline; filename="..." for safe types (PNG, JPEG, GIF, WebP, text/plain); attachment; filename="..." for everything else (including HTML, SVG, XML, JSON — to keep the host origin XSS-safe).

Errors:

  • 404 Not Found — job or artifact name doesn’t exist.
  • 403 Forbidden — caller lacks View permission on the task.
GET /api/users

Returns registered users with their authentication methods. Supports limit and offset query parameters. Response: { "items": [...], "total": N }.

GET /api/users/{id}

Returns a single user’s info including authentication methods and groups.

The following endpoints require admin privileges (is_admin: true). Non-admin users receive a 403 Forbidden response.

GET /api/users

Returns registered users with pagination.

Query parameter Default Description
limit 20 Number of users to return
offset 0 Pagination offset

Response:

{
"items": [
{
"id": "user-uuid-...",
"email": "alice@example.com",
"is_admin": true,
"groups": ["devops", "admin"]
}
],
"total": 1
}
GET /api/users/{id}

Returns a single user’s info including admin flag and group memberships.

Response:

{
"id": "user-uuid-...",
"email": "alice@example.com",
"is_admin": true,
"groups": ["devops"]
}
PUT /api/users/{id}/admin

Grants or revokes admin privileges.

Request body:

{
"is_admin": true
}

Response (200):

{
"is_admin": true
}
PUT /api/users/{id}/groups

Updates the user’s group memberships.

Request body:

{
"groups": ["devops", "qa"]
}

Response (200):

{
"groups": ["devops", "qa"]
}
GET /api/groups

Returns all distinct group names in use.

Response:

["admin", "devops", "qa"]
GET /api/workers

Returns registered workers with status and tags. Supports limit and offset query parameters. Response: { "items": [...], "total": N }.

GET /api/workers/{id}

Returns a single worker’s info along with its recent steps (up to 50).

Response:

{
"steps": {
"items": [
{
"job_id": "...",
"workspace": "...",
"task_name": "...",
"job_status": "...",
"step_name": "...",
"action_type": "...",
"status": "...",
"started_at": "...",
"completed_at": "...",
"error_message": null
}
],
"total": 42
}
}

All endpoints return errors in a consistent format:

{
"error": "Description of what went wrong"
}
Status Description
400 Bad request (invalid input, missing fields)
401 Unauthorized (missing or invalid token)
404 Not found (unknown task, job, or step)
409 Conflict (e.g., cancelling an already-terminal job)
500 Internal server error