Skip to main content

Run a workflow from your system

Find the workflow, read its input contract, start a run, follow it, collect what it produced, and cancel or erase when you need to. For whoever writes the system that triggers a workflow built, activated and tested in Studio (Workflows: build).

Before you start: the two credentials from Authentication, a key whose scopes and workflow access cover what you plan to do, and an active workflow. Bodies are JSON, times are ISO-8601 in UTC, and paths are relative to https://$STUDIO/api/v1.

The routes​

Method and pathScopeWhat it does
GET /healthnoneok or disabled
GET /meany valid keyThe calling key and its limits
GET /workflowsworkflows:readActive workflows this key may run
GET /workflows/{workflow_id}workflows:readOne workflow in full, with readiness
GET /workflows/{workflow_id}/parametersworkflows:readThe input contract of one version
POST /workflows/{workflow_id}/runsruns:createStart a run
GET /runsruns:readThis key's runs, newest first
GET /runs/{run_id}runs:readOne run in full, with its result
GET /runs/{run_id}/stepsruns:readPer-step state
GET /runs/{run_id}/approvalsruns:readSteps waiting for a person
GET /runs/{run_id}/outputsoutputs:readThe files a finished run produced
GET /runs/{run_id}/outputs/{output_id}/contentoutputs:readOne output (at most 25 MB)
GET /runs/{run_id}/outputs.zipoutputs:readEvery output as one zip (at most 500 MB, 2,000 files)
POST /runs/{run_id}/cancelruns:createStop a run
GET /runs/{run_id}/pii-redactionruns:readThe run's PII redaction counts
GET /runs/{run_id}/eventsruns:readThe durable event log, by segment
DELETE /runs/{run_id}/dataruns:deleteErase a finished run's data

Steps​

1. Set up the shell and check the API​

export STUDIO="<app_domain>"
export KEY="ak_<key_id>.<secret>"
export EXT="ake_<external_id>"
api() { curl -sS -H "Authorization: Bearer $KEY" -H "X-AlphaAgent-Key-External-Id: $EXT" "$@"; }

curl -sS "https://$STUDIO/api/v1/health"
api "https://$STUDIO/api/v1/me"

api adds the two headers to every call on this page; /me answers with the key's scopes, workflow access, expiry and live counters.

2. Find the workflow​

api "https://$STUDIO/api/v1/workflows?q=kyc&limit=20"

Only active workflows the key may run are listed, sorted by name. q is a case-insensitive prefix match on the name; limit is 1 to 100 (default 20); follow next_cursor for the next page.

{
"items": [
{"workflow_id": "<workflow_id>", "name": "KYC document review", "description": "…",
"status": "active", "active_version": 4, "updated_at": "2026-09-20T10:02:11+00:00"}
],
"next_cursor": null
}

GET /workflows/{workflow_id} returns the same item plus:

versions [{"version", "created_at"}], every saved version
parameters the active version's input contract (step 3)
nodes [{"slug", "title", "node_type", "requires_approval", "approve_before_start", "depends_on"}]
node_type: task | handoff | conditional | ampg_update | pii_redact
settings {"max_run_hours", "overlap_policy"}
pii_policy the redaction policy the run starts from
readiness {"ok", "reasons"}: when ok is false the run would be refused, and each reason names what a
workflow author must fix in Studio (the workflow itself not active, a step without an agent,
a connector missing or without a passing test, an environment that is not active)

What a workflow that is not active answers depends on how the key was scoped. A key with All active workflows access gets 409 workflow_not_active from GET /workflows/{workflow_id}, GET …/parameters and POST …/runs: the workflow exists, its status bars it, and the same three routes answer 404 workflow_not_found only for an id that is not in the deployment. A key whose explicit list names the workflow reads it whatever its status (200, with readiness.reasons carrying workflow is draft, not active) and is refused only at POST …/runs. A deactivated workflow therefore does not vanish from an integration: it answers 409 until it is active again.

3. Read the input contract​

api "https://$STUDIO/api/v1/workflows/<workflow_id>/parameters"
api "https://$STUDIO/api/v1/workflows/<workflow_id>/parameters?version=3"

Without version you get the active version's contract; a version that does not exist answers 422 version_not_found.

{
"workflow_id": "<workflow_id>",
"version": 4,
"parameters": [
{"name": "applicant_id", "type": "string", "required": true, "description": "Case reference",
"constraints": {"pattern": "^S-[0-9]+$"}},
{"name": "channel", "type": "enum", "required": false, "default": "web",
"description": "", "enum_values": ["web", "branch"]},
{"name": "documents", "type": "s3_prefix", "required": true,
"description": "Folder of scanned documents",
"connector": {"connector_id": "<connector_id>", "name": "Onboarding bucket", "region": "eu-west-1"},
"bucket": "<your-bucket>", "prefix_default": "applicants/",
"constraints": {"max_objects": 500, "max_bytes": 2147483648}}
]
}
name, type, required, description on every parameter; type: string | number | boolean | enum | s3_prefix
required true only when you must send a value. A parameter with a default (or an
s3_prefix with a prefix_default) is published required: false and takes
its default when you omit it; Studio's run dialog shows the same
parameter pre-filled
default when one is declared
enum_values for enum
constraints min and max (numeric bounds for number, length bounds for string),
pattern for string, max_objects and max_bytes for s3_prefix
connector, bucket, prefix_default for s3_prefix: the connector whose role reads the bucket, an optional
fixed bucket, an optional default prefix

How to write an s3_prefix value is on Patterns.

4. Start a run​

api -X POST "https://$STUDIO/api/v1/workflows/<workflow_id>/runs" \
-H "Idempotency-Key: signup-S-88213-attempt-1" \
-H "Content-Type: application/json" \
-d '{
"parameters": {"applicant_id": "S-88213", "documents": "applicants/S-88213/"},
"input_text": "Prioritise the passport scan.",
"client_reference": "case-4471",
"labels": {"channel": "web"}
}'

Idempotency-Key is required. The same key with the same body within 24 hours answers 200 with the run that already exists; the same key with a different body answers 409 idempotency_conflict. Body fields, all optional:

parameters a value for every required parameter, no unknown names, each of the declared type;
otherwise 400 invalid_parameters with details: [{"name", "reason"}]
input_text a free-text goal the first step reads, like the note in Studio's run dialog
version a specific version to run; default the active version
client_reference your own identifier, echoed on the run and usable as a filter; never personal data
labels your own key/value tags, echoed on the run; never personal data
pii_policy_override a redaction policy that may only tighten the workflow's own; otherwise
400 policy_not_tightening or 400 invalid_pii_policy, each with details.field

Their bounds are on Limits, errors and codes. The API checks, in order: the workflow is within the key's reach (404 workflow_not_found: absent, or outside an explicit list), active (409 workflow_not_active; an all-active-workflows key gets this from the GET routes too), the version exists, the parameters validate, the override tightens, the workflow is ready (409 workflow_not_ready with details.reasons), the idempotency key is new or a replay, a concurrent-run slot is free (429 too_many_active_runs) and the daily quota has room (429 quota_exceeded). A concurrency refusal does not consume a quota unit.

A new run answers 202 with a run summary. The run is launched before the response is written, so the summary already reads running, or failed with an error block when the runtime refused it.

5. Follow the run​

api "https://$STUDIO/api/v1/runs/<run_id>"

Poll no more than every 5 seconds per run; every call counts against the key's per-minute limit. A run summary:

{
"run_id": "<run_id>", "workflow_id": "<workflow_id>", "workflow_name": "KYC document review",
"version": 4, "status": "running",
"created_at": "2026-09-27T09:00:00+00:00", "started_at": "2026-09-27T09:00:01+00:00",
"finished_at": null, "duration_ms": null,
"client_reference": "case-4471", "labels": {"channel": "web"},
"counts": {"total": 5, "completed": 2, "running": 1, "awaiting_approval": 0, "failed": 0, "skipped": 0},
"links": {"self": "/api/v1/runs/<run_id>", "steps": "…/steps", "approvals": "…/approvals",
"outputs": "…/outputs", "events": "…/events"}
}

GET /runs/{run_id} is the full run: the summary plus these fields.

parameters the values as accepted
input_text
inputs_manifest what was copied in for each s3_prefix parameter
error {"code", "message"} when the run failed
max_run_hours
triggered_by, key_id "api", and the key that started the run
summary_markdown the closing narrative
result, result_error, result_schema_version, result_json the machine-readable result (step 6)
data_purged_at set once the run's data was erased (step 8)
pii_redaction, pii_policy
warnings, completed_with_warnings stages that did not land while the run still completed (for example a knowledge-graph publish still building when its wait expired); `completed_with_warnings` is true only when `status` is `completed` and `warnings` is non-empty. Both are on the run summary too.

GET /runs lists the key's runs newest first:

api "https://$STUDIO/api/v1/runs?status=running,waiting_for_approval&workflow_id=<workflow_id>&client_reference=case-4471&created_after=2026-09-01T00:00:00Z&limit=20"

status is comma-separated; created_after and created_before are ISO-8601 (anything else answers 400 invalid_filter); limit and cursor page as on GET /workflows.

GET /runs/{run_id}/steps is one entry per step on the run's board (the version's nodes, plus the one step the runtime adds, below):

{"run_id": "<run_id>", "status": "running",
"steps": [{"slug": "collect", "title": "Collect documents", "node_type": "task", "derived": false, "status": "completed",
"started_at": "…", "finished_at": "…", "duration_ms": 41200, "depends_on": [],
"error": null, "result_summary": "…", "approved_by": null, "approved_at": null}]}

While a step waits for a person it carries "awaiting": {"phase": "start" | "result", "since": "…"}; once released, approved_by and approved_at name the Studio user, never the key.

A step's node_type is task | handoff | conditional | ampg_update | ampg_publish | pii_redact. Every ampg_update node becomes two steps: the build step, with the node's slug, and the publish step <slug>-publish (node_type ampg_publish), which depends on it; so steps can hold one entry more than the workflow's nodes. A step the runtime added carries "derived": true; a step you authored, false.

GET /runs/{run_id}/approvals lists only the steps parked on a human gate, as {"slug", "title", "phase", "since"} under pending. Nothing in the API releases them: that happens in Studio's Inbox, where approvals from API runs appear for everyone assigned to the deployment (Workflows: run, schedule, approve).

GET /runs/{run_id}/events?after_seq=0&limit=200 replays the durable event log. Events are grouped into segments (an approval starts a new one), each with its own sequence:

{"run_id": "<run_id>", "segment": "<segment_id>", "segments": ["<segment_id>"],
"events": [{"seq": 1, "at": "…", "type": "node_started", "node": "collect", "payload": {"…": "…"}}],
"next_after_seq": 1}

Poll with after_seq=next_after_seq on the same segment; when segments grows, read the new one from after_seq=0. limit is 1 to 500 (default 200). A segment that does not belong to the run answers 404 segment_not_found.

GET /runs/{run_id}/pii-redaction returns the redaction manifest of a run whose policy was on: counts only, never a redacted value. A run whose policy was off answers 404 pii_redaction_not_applied.

{"run_id": "<run_id>", "summary": "…", "complete": true,
"manifest": {"schema_version": 1, "mode": "…", "model_id": "…", "stages": ["…"], "totals": {"…": 0}, "complete": true, "capped": false}}

6. Collect the outputs​

api "https://$STUDIO/api/v1/runs/<run_id>/outputs"

Available once the run has finished (completed, failed or cancelled): a run still going answers 409 run_not_terminal with details.status, and a finished run whose output redaction is still completing answers 409 redaction_pending with Retry-After: 2.

{
"run_id": "<run_id>",
"outputs": [
{"output_id": "<output_id>", "key": "outputs/kyc_summary.pdf", "kind": "pdf", "title": "KYC summary",
"pages": 3, "bytes": 184322, "content_type": "application/pdf", "from_step": "summarise",
"created_at": "…", "download": {"method": "GET", "path": "/api/v1/runs/<run_id>/outputs/<output_id>/content", "expires_in": null}}
],
"bundle": {"path": "/api/v1/runs/<run_id>/outputs.zip", "max_bytes": 524288000, "max_objects": 2000}
}

outputs lists every file a step wrote under the run's outputs/ folder, whatever its type (kind file when there is no closer name), with from_step naming the step that wrote it - for the first 200 files of a run; a larger run carries from_step on the first 200 only and null on the rest, while every file is still listed and downloadable. result.json is served separately (below). A run started with an API key keeps its outputs like a run started in Studio. In Studio, a large run's page shows its first 200 files with "Showing 200 of N files"; the full list is in the API and the run's outputs folder.

api -o kyc_summary.pdf "https://$STUDIO/api/v1/runs/<run_id>/outputs/<output_id>/content"
api -H "Range: bytes=0-1023" "https://$STUDIO/api/v1/runs/<run_id>/outputs/<output_id>/content"
api -o run_outputs.zip "https://$STUDIO/api/v1/runs/<run_id>/outputs.zip"

One file carries Content-Type, Content-Disposition: attachment, ETag, Accept-Ranges: bytes and Cache-Control: private, no-store; a Range request answers 206 with Content-Range, an unsatisfiable one 416 range_not_satisfiable; a file over 25 MB answers 413 use_bundle with details.bundle pointing at the zip. The zip is application/zip, named run_<run_id>_outputs.zip; over 500 MB or 2,000 files it answers 413 bundle_too_large, and you fetch files one by one. A finished run that produced no files answers outputs: [] and an empty zip (a valid archive with no entries), not a 404.

The machine-readable result. When the workflow's final step writes outputs/result.json, the full run's result returns it parsed (at most 256 KB, a JSON object, valid against the version's result schema when one is declared) and result_schema_version echoes its $schema or version field. Otherwise result is null and result_error says why; result_json is present whenever the file exists, so the raw file can be fetched even then.

result_error run_not_terminal | redaction_pending | purged | missing | unavailable | too_large | invalid_json | schema_mismatch: <reason>
result_json {"key", "bytes", "download": {"method", "path"}}

7. Cancel a run​

api -X POST "https://$STUDIO/api/v1/runs/<run_id>/cancel" \
-H "Content-Type: application/json" -d '{"reason": "Case withdrawn"}'

reason is optional (up to 500 characters). A run still going answers 202 with its summary; a finished run answers 409 already_terminal.

8. Erase a run's data​

api -X DELETE "https://$STUDIO/api/v1/runs/<run_id>/data" \
-H "Content-Type: application/json" -d '{"reason": "Erasure request 2026-09-27"}'

Needs runs:delete. The run must have finished (409 run_not_terminal, "Cancel the run before erasing its data."); an already-erased run answers 409 already_purged with details.data_purged_at. The API removes the run's inputs, working files, outputs, event rows and board, and strips the run record of everything derived from your inputs (the parameters, input_text, inputs_manifest, the free-text error message, the result and the closing summary); status, timings, counts, ids, error.code, client_reference and labels stay.

{"run_id": "<run_id>",
"data_purge": {"requested_at": "…", "status": "purged", "deleted_objects": 42, "deleted_versions": 42,
"deleted_event_rows": 310, "deleted_graph_rows": 12, "scopes": ["…"], "versions": "all", "note": null}}

Afterwards GET /runs/{run_id} shows data_purged_at; /steps, /approvals and /events answer empty lists with data_purged_at; /outputs answers outputs: []. The erasure is recorded against the key as "Run data purged" in the administrator's Usage view.

9. The same flow in Python​

The script (it needs requests) finds the workflow, reads the contract, starts a run with a fresh idempotency key, polls every 5 seconds, prints which steps wait on an approval, and downloads the outputs.

import os
import time
import uuid
import requests

BASE = f"https://{os.environ['STUDIO']}/api/v1"
TERMINAL = {"completed", "failed", "cancelled"}

session = requests.Session()
session.headers.update({
"Authorization": f"Bearer {os.environ['KEY']}",
"X-AlphaAgent-Key-External-Id": os.environ["EXT"],
})


def call(method, path, **kwargs):
"""One request; on an error, print the envelope and re-raise."""
response = session.request(method, f"{BASE}{path}", timeout=60, **kwargs)
if response.status_code >= 400:
try:
error = response.json()["error"]
except ValueError:
error = {"code": response.status_code, "message": response.text[:200], "request_id": None}
print(f"{method} {path} -> {response.status_code} {error['code']}: {error['message']} "
f"(request_id {error.get('request_id')}, Retry-After {response.headers.get('Retry-After')})")
response.raise_for_status()
return response


def main():
# 1. Find the workflow by name prefix.
workflows = call("GET", "/workflows", params={"q": "kyc"}).json()["items"]
if not workflows:
raise SystemExit("no active workflow named kyc… is visible to this key")
workflow = workflows[0]

# 2. Read the contract so you know what to send.
contract = call("GET", f"/workflows/{workflow['workflow_id']}/parameters").json()
print("version", contract["version"], "parameters", [p["name"] for p in contract["parameters"]])

# 3. Start the run. A fresh Idempotency-Key per business event; reuse it on retry.
idempotency_key = f"signup-S-88213-{uuid.uuid4()}"
started = call(
"POST", f"/workflows/{workflow['workflow_id']}/runs",
headers={"Idempotency-Key": idempotency_key},
json={
"parameters": {"applicant_id": "S-88213", "documents": "applicants/S-88213/"},
"client_reference": "case-4471",
"labels": {"channel": "web"},
},
)
run = started.json()
print("run", run["run_id"], "status", run["status"])

# 4. Poll every 5 seconds until the run finishes.
while run["status"] not in TERMINAL:
time.sleep(5)
run = call("GET", f"/runs/{run['run_id']}").json()
if run["status"] == "waiting_for_approval":
pending = call("GET", f"/runs/{run['run_id']}/approvals").json()["pending"]
print("waiting for a person in Studio's Inbox:", [p["slug"] for p in pending])

# 5. Read the result and download the outputs.
if run["status"] != "completed":
print(run["status"], run.get("error"))
return
print("result", run["result"] if run["result"] is not None else run["result_error"])
while True:
outputs = call("GET", f"/runs/{run['run_id']}/outputs")
if outputs.status_code == 200:
break
time.sleep(int(outputs.headers.get("Retry-After", "2"))) # 409 redaction_pending
for output in outputs.json()["outputs"]:
content = call("GET", output["download"]["path"].removeprefix("/api/v1"))
with open(output["key"].rsplit("/", 1)[-1], "wb") as handle:
handle.write(content.content)
print("saved", output["key"], output["bytes"], "bytes")


if __name__ == "__main__":
main()

What you should see​

StatusMeaning
pendingAccepted, not yet launched
runningIn progress
waiting_for_approvalA step is waiting for a person in Studio's Inbox
completedFinished; outputs are available. A run whose knowledge-graph publish timed out is still completed, with the reason in warnings[] and completed_with_warnings: true; Studio's run page reads Completed · 1 warning
failedStopped; error.code says why (failure codes)
cancelledStopped by a cancel request, from the API or from Studio

In Studio the run appears in the workflow's runs list like any other, labelled as triggered by an API key with the key id, and everyone assigned to the governed deployment can open it.

Notes​

  • A key sees only the runs it created. Runs started in Studio, by a schedule or by another key answer 404 run_not_found.
  • Connectors need one recorded passing test, on any version; the test at an AWS connector's creation or update counts. Otherwise 409 workflow_not_ready carries "connector '<name>' (<id>) has not passed Test connection. Open the connector's page and press Test connection, then start the run again." The other reasons are a step without an agent or an inactive environment; relay details.reasons to the workflow author.
  • A 400 invalid_parameters refusal is resent with a new Idempotency-Key; 409 idempotency_conflict with Retry-After: 1 means the first request is still being processed.
  • 502 runtime_unreachable or runtime_error: the deployment's runtime did not answer; retry shortly, accepted runs keep going.