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.
List Workspaces
Section titled “List Workspaces”GET /api/workspacesReturns 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" }]Refresh Workspace
Section titled “Refresh Workspace”POST /api/workspaces/{ws}/refreshForce-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:
# Ensure Strøm has the latest code before executingcurl -X POST https://stroem.example.com/api/workspaces/default/refresh \ -H "Authorization: Bearer $STROEM_API_KEY"
# Then trigger the taskcurl -X POST https://stroem.example.com/api/workspaces/default/tasks/deploy/execute \ -H "Authorization: Bearer $STROEM_API_KEY"List Tasks
Section titled “List Tasks”GET /api/workspaces/{ws}/tasksReturns 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 Task Detail
Section titled “Get Task Detail”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.
Task Duration Stats
Section titled “Task Duration Stats”GET /api/workspaces/{ws}/tasks/{name}/stats?limit=50Aggregated 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.
Execute Task
Section titled “Execute Task”POST /api/workspaces/{ws}/tasks/{name}/executeCreates 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"}Upload Input File
Section titled “Upload Input File”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 |
Upload With an Upload URL
Section titled “Upload With an Upload URL”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.
Download Input File
Section titled “Download Input File”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 |
List Triggers
Section titled “List Triggers”GET /api/workspaces/{ws}/triggersReturns 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"] }]List Jobs
Section titled “List Jobs”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 Job Detail
Section titled “Get Job Detail”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
Cancel Job
Section titled “Cancel Job”POST /api/jobs/{id}/cancelCancels 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:
stroem cancel <job_id>Restart Job
Section titled “Restart Job”POST /api/jobs/{id}/restartCreates 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 Job Logs
Section titled “Get Job Logs”GET /api/jobs/{id}/logsGET /api/jobs/{id}/logs?tail_bytes=1048576GET /api/jobs/{id}/logs?full=trueReturns 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—truewhen earlier lines may exist;falseis exact.total_bytes— an upper bound on the size of the whole job log.returned_bytes— the length oflogs.
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 Step Logs
Section titled “Get Step Logs”GET /api/jobs/{id}/steps/{step}/logsSame parameters, envelope and header as Get Job Logs, filtered to one step. Use _server as the step name for server-side events.
Stream Job Logs (WebSocket)
Section titled “Stream Job Logs (WebSocket)”GET /api/jobs/{id}/logs/streamOpens 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).
websocat ws://localhost:8080/api/jobs/JOB_ID/logs/streamList Job Artifacts
Section titled “List Job Artifacts”GET /api/jobs/{id}/artifactsLists 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.
Download Job Artifact
Section titled “Download Job Artifact”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 viainfer).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 lacksViewpermission on the task.
List Users
Section titled “List Users”GET /api/usersReturns registered users with their authentication methods. Supports limit and offset query parameters. Response: { "items": [...], "total": N }.
Get User Detail
Section titled “Get User Detail”GET /api/users/{id}Returns a single user’s info including authentication methods and groups.
User Management (Admin Only)
Section titled “User Management (Admin Only)”The following endpoints require admin privileges (is_admin: true). Non-admin users receive a 403 Forbidden response.
List Users
Section titled “List Users”GET /api/usersReturns 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 User Detail
Section titled “Get User Detail”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"]}Set Admin Flag
Section titled “Set Admin Flag”PUT /api/users/{id}/adminGrants or revokes admin privileges.
Request body:
{ "is_admin": true}Response (200):
{ "is_admin": true}Set User Groups
Section titled “Set User Groups”PUT /api/users/{id}/groupsUpdates the user’s group memberships.
Request body:
{ "groups": ["devops", "qa"]}Response (200):
{ "groups": ["devops", "qa"]}List Groups
Section titled “List Groups”GET /api/groupsReturns all distinct group names in use.
Response:
["admin", "devops", "qa"]List Workers
Section titled “List Workers”GET /api/workersReturns registered workers with status and tags. Supports limit and offset query parameters. Response: { "items": [...], "total": N }.
Get Worker Detail
Section titled “Get Worker Detail”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 }}Error responses
Section titled “Error responses”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 |