Workflows API
Run a node graph (source to model to filter to track to step to sink) over an image, a video, or a dataset. Create, run, poll, and read artifacts headlessly.
A workflow is a small graph of typed blocks that turns a source (an image, a video, or a dataset) into an answer: run a model over every frame, keep the classes you care about, optionally track and count them, and emit results to a sink. You build the graph visually in the app, then drive runs from here.
A workflow’s model block loads your trained model directly, so no deployment is required. Runs are billed ONCE, on success, from the measured GPU time they use - there is no charge up front, and a failed or cancelled run is free (even if it spent real GPU time first). Management calls (create, update, delete, run, cancel) require member+ role.
The graph
A graph is {"version": 1, "nodes": [...], "edges": [...]}. Each node is
{"id", "type", "config"}; each edge is {"source", "sourceHandle", "target", "targetHandle"}.
| Block | In | Out | Config highlights |
|---|---|---|---|
source |
- | frames | kind (image/video/dataset), gcs_uri or dataset_project_id, sample_fps |
model |
frames | detections | model_id (a ready model), confidence_threshold, class_filter |
merge |
detections | detections | strategy (consensus/union/intersection), min_votes, iou_threshold |
filter |
detections | detections | keep_classes, min_confidence |
track |
detections | detections | tracker (bytetrack/botsort) |
step |
detections | events | step_type (line_cross/dwell/occupancy), geometry |
visualize |
det + events | frames | draw toggles |
sink |
det/events/frames | - | kind (json/csv/webhook/annotated_video) |
An image or video source addresses its file by gcs_uri - the value
client.video.upload() hands back, so you pass it through rather than construct
it. A dataset source uses dataset_project_id instead.
Structural rules are validated before a run starts: one source, no loops, every
block connected, and a counting or dwell step must sit downstream of a track,
which must sit downstream of a model. A single-image source cannot use track
or step (no temporal axis).
Ensembles
Two detectors rarely fail the same way. A workflow can run up to three models
over the same frames and combine them with a merge block.
strategy |
Keeps | Use it for |
|---|---|---|
consensus |
detections at least min_votes models found (default 2) |
fewer false positives - the default |
union |
everything any model found | maximum recall |
intersection |
only what every model found | maximum precision |
Two models found “the same detection” when they share a class name and their
boxes overlap by at least iou_threshold (default 0.5). A model votes at most
once per object, so a model that fires twice on one thing cannot manufacture its
own consensus. The surviving box is the highest-confidence one, unchanged -
coordinates are never averaged into a box no model predicted. Its confidence
becomes the mean across the models that voted, and each detection carries votes
and model_count.
{
"nodes": [
{"id": "s", "type": "source", "config": {"kind": "video", "gcs_uri": "<from client.video.upload()>", "sample_fps": 5}},
{"id": "m1", "type": "model", "config": {"model_id": "<uuid-a>"}},
{"id": "m2", "type": "model", "config": {"model_id": "<uuid-b>"}},
{"id": "mg", "type": "merge", "config": {"strategy": "consensus", "min_votes": 2, "iou_threshold": 0.5}},
{"id": "out","type": "sink", "config": {"kind": "json"}}
],
"edges": [
{"source": "s", "target": "m1"}, {"source": "s", "target": "m2"},
{"source": "m1", "target": "mg"}, {"source": "m2", "target": "mg"},
{"source": "mg", "target": "out"}
]
}
More than one model block is only valid when all of them feed the merge
block - a model wired around it would be run and billed and then ignored, so the
run is rejected (models_need_merge / model_not_merged). An ensemble costs
about as many times as it has models: every model runs over every frame.
create
Create a workflow from a graph. Returns a Workflow in draft status.
Source: Workflows.create
| Arg | Type | Default | Notes |
|---|---|---|---|
name |
str |
required | Workflow name. |
graph |
dict[str, Any] |
required | Node graph defining the workflow - source, model, filter, sink. |
readme |
str | None |
None |
Markdown workflow card shown on the workflow page. |
description |
str | None |
None |
One-line summary shown in the workflows list. |
template_key |
str | None |
None |
Start from a built-in template instead of an explicit graph. |
clip = client.video.upload(
local_path="clip.mp4",
)
graph = {
"version": 1,
"nodes": [
{"id": "s", "type": "source", "config": {"kind": "video", "gcs_uri": clip.gcs_uri, "sample_fps": 5}},
{"id": "m", "type": "model", "config": {"model_id": "your-model-uuid", "confidence_threshold": 0.4}},
{"id": "f", "type": "filter", "config": {"keep_classes": ["car", "truck"]}},
{"id": "k", "type": "sink", "config": {"kind": "json"}},
],
"edges": [
{"source": "s", "target": "m"},
{"source": "m", "target": "f"},
{"source": "f", "target": "k"},
],
}
wf = client.workflows.create(
name="Vehicle detector",
graph=graph,
)
print(wf.id, wf.status) # 'draft'
pictograph workflows create "Vehicle detector" --graph graph.json
curl -s -X POST "https://api.pictograph.io/api/v1/developer/workflows/" \
-H "X-API-Key: $PICTOGRAPH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Vehicle detector", "graph": '"$(cat graph.json)"'}'
Returns Workflow
Workflow · 10 fields
class Workflow(BaseModel):
"""A saved node-graph workflow."""
id: str
organization_id: str
name: str
description: str | None = None
graph: dict[str, Any] = {}
template_key: str | None = None
status: Literal['draft', 'ready', 'archived'] = 'draft'
last_run_id: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
list
Every workflow in your organization.
Source: Workflows.list
for w in client.workflows.list():
print(w.id, w.name, w.status)
pictograph workflows list
curl -s "https://api.pictograph.io/api/v1/developer/workflows/" \
-H "X-API-Key: $PICTOGRAPH_API_KEY"
Returns Sequence[Workflow]
Workflow · 10 fields
class Workflow(BaseModel):
"""A saved node-graph workflow."""
id: str
organization_id: str
name: str
description: str | None = None
graph: dict[str, Any] = {}
template_key: str | None = None
status: Literal['draft', 'ready', 'archived'] = 'draft'
last_run_id: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
get
Fetch one workflow. The REST response also carries a freshly computed
validation array listing anything that would block a run.
Source: Workflows.get
| Arg | Type | Default | Notes |
|---|---|---|---|
workflow |
str |
required | Workflow name or id. |
wf = client.workflows.get(
workflow="Vehicle detector",
)
print(wf.status, wf.last_run_id, wf.graph)
pictograph workflows get "Vehicle detector"
curl -s "https://api.pictograph.io/api/v1/developer/workflows/Vehicle%20detector" \
-H "X-API-Key: $PICTOGRAPH_API_KEY"
Returns Workflow
Workflow · 10 fields
class Workflow(BaseModel):
"""A saved node-graph workflow."""
id: str
organization_id: str
name: str
description: str | None = None
graph: dict[str, Any] = {}
template_key: str | None = None
status: Literal['draft', 'ready', 'archived'] = 'draft'
last_run_id: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
update
Change a workflow’s name, description, graph, or status (draft, ready,
archived). There is no CLI command for this - edit the graph in the app, or use
the SDK.
Source: Workflows.update
| Arg | Type | Default | Notes |
|---|---|---|---|
workflow |
str |
required | Workflow name or id. |
name |
str | None |
None |
New workflow name. Left unchanged when None. |
readme |
str | None |
None |
New workflow card. Left unchanged when None. |
description |
str | None |
None |
New summary. Left unchanged when None. |
graph |
dict[str, Any] | None |
None |
Replaces the node graph. Left unchanged when None. |
status |
WorkflowStatus | None |
None |
New state: draft, ready or archived. Left unchanged when None. |
wf = client.workflows.update(
workflow="Vehicle detector",
status="ready",
)
curl -s -X PATCH "https://api.pictograph.io/api/v1/developer/workflows/Vehicle%20detector" \
-H "X-API-Key: $PICTOGRAPH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"status": "ready"}'
# No update command; re-create or edit in the app.
pictograph workflows get nightly-count
Returns Workflow
Workflow · 10 fields
class Workflow(BaseModel):
"""A saved node-graph workflow."""
id: str
organization_id: str
name: str
description: str | None = None
graph: dict[str, Any] = {}
template_key: str | None = None
status: Literal['draft', 'ready', 'archived'] = 'draft'
last_run_id: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
run
Validate the graph and start a run. Raises ValidationError (400) with the
specific issues if the graph is not runnable, and PaymentRequiredError (402)
if there is not enough compute credit.
Source: Workflows.run
| Arg | Type | Default | Notes |
|---|---|---|---|
workflow |
str |
required | Workflow name or id. |
created = client.workflows.run(
workflow="Vehicle detector",
)
print(created.run_id, created.deposit_micro_usd)
pictograph workflows run "Vehicle detector" --no-wait
curl -s -X POST "https://api.pictograph.io/api/v1/developer/workflows/Vehicle%20detector/run" \
-H "X-API-Key: $PICTOGRAPH_API_KEY"
Returns WorkflowRunCreated
WorkflowRunCreated · 2 fields
class WorkflowRunCreated(BaseModel):
"""Run response - the new run id + `deposit_micro_usd`, which is the un-charged pre-run ESTIMATE. Workflows bill ONCE, on success, from measured GPU time; a failed or cancelled run is free. The field name is kept for wire-compat."""
run_id: str
deposit_micro_usd: int = 0
wait_for_run
Block until a run reaches a terminal state - use this instead of hand-rolling a
poll loop. Returns the run on completed, raises ApiError if it ends error
or cancelled, and PollTimeoutError if timeout elapses (the run keeps going
server-side).
Source: Workflows.wait_for_run
| Arg | Type | Default | Notes |
|---|---|---|---|
run_id |
str |
required | The run UUID from run. |
poll_interval |
float |
5.0 |
Seconds between checks (default 5s). |
timeout |
float |
3600.0 |
Max seconds to wait (default 3600 = 1h, the runner’s cap). |
run = client.workflows.wait_for_run(
run_id=created.run_id,
poll_interval=5.0,
timeout=3600.0,
)
for art in run.artifacts:
print(art["kind"], art.get("download_url"))
# `run` waits by default; drop --no-wait to block until the run finishes.
pictograph workflows run "Vehicle detector" --timeout 3600
# Poll the run until status is terminal.
curl -s "https://api.pictograph.io/api/v1/developer/workflows/runs/<run-id>" \
-H "X-API-Key: $PICTOGRAPH_API_KEY"
Returns WorkflowRun
WorkflowRun · 16 fields
class WorkflowRun(BaseModel):
"""One execution of a workflow over a source."""
id: str
organization_id: str
workflow_id: str
status: Literal['queued', 'processing', 'completed', 'error', 'cancelled']
progress: float = 0.0
frames_total: int | None = None
frames_done: int = 0
sample_fps: float | None = None
step_results: dict[str, Any] = {}
artifacts: list[dict[str, Any]] = []
warnings: list[str] = []
deposit_micro_usd: int = 0
final_micro_usd: int | None = None
error: str | None = None
created_at: datetime | None = None
completed_at: datetime | None = None
get_run
Read a run’s current state and, once complete, its artifacts (signed download URLs).
Source: Workflows.get_run
| Arg | Type | Default | Notes |
|---|---|---|---|
run_id |
str |
required | Run id. |
run = client.workflows.get_run(
run_id=created.run_id,
)
print(run.status, f"{run.frames_done}/{run.frames_total}", f"{run.progress:.0f}%")
pictograph workflows run-status 7f3a1b2c-4d5e-4f60-8a91-2b3c4d5e6f70
curl -s "https://api.pictograph.io/api/v1/developer/workflows/runs/$RUN_ID" \
-H "X-API-Key: $PICTOGRAPH_API_KEY"
Returns WorkflowRun
WorkflowRun · 16 fields
class WorkflowRun(BaseModel):
"""One execution of a workflow over a source."""
id: str
organization_id: str
workflow_id: str
status: Literal['queued', 'processing', 'completed', 'error', 'cancelled']
progress: float = 0.0
frames_total: int | None = None
frames_done: int = 0
sample_fps: float | None = None
step_results: dict[str, Any] = {}
artifacts: list[dict[str, Any]] = []
warnings: list[str] = []
deposit_micro_usd: int = 0
final_micro_usd: int | None = None
error: str | None = None
created_at: datetime | None = None
completed_at: datetime | None = None
| Field | Meaning |
|---|---|
status |
queued, processing, completed, error, or cancelled. |
progress |
0-100. |
frames_done / frames_total |
Frames processed / estimated. |
step_results |
Per step block: aggregate (headline number), time series, per-track events. |
artifacts |
[{kind, gcs_path, bytes, download_url}] for JSON / CSV / annotated-video outputs. |
error |
Set when status == "error". |
cancel_run
Stop an in-flight run and refund the deposit.
Source: Workflows.cancel_run
| Arg | Type | Default | Notes |
|---|---|---|---|
run_id |
str |
required | Run id. |
client.workflows.cancel_run(
run_id=created.run_id,
)
pictograph workflows cancel 7f3a1b2c-4d5e-4f60-8a91-2b3c4d5e6f70 --yes
curl -s -X POST "https://api.pictograph.io/api/v1/developer/workflows/runs/$RUN_ID/cancel" \
-H "X-API-Key: $PICTOGRAPH_API_KEY"
Returns None
bulk_cancel_runs
Cancel many runs in one call, each stopped in flight with its deposit refunded
idempotently. Foreign, terminal, or missing ids land in not_found rather than
failing the call.
Source: Workflows.bulk_cancel_runs
| Arg | Type | Default | Notes |
|---|---|---|---|
run_ids |
Sequence[str] |
required | Run ids to act on. Ids that do not resolve are reported, not raised. |
result = client.workflows.bulk_cancel_runs(
run_ids=[
"7f3a1b2c-4d5e-4f60-8a91-2b3c4d5e6f70",
"8a4b2c3d-5e6f-4071-9b02-3c4d5e6f7081",
],
)
print(result.count, "cancelled;", result.not_found, "skipped")
pictograph workflows cancel-batch \
7f3a1b2c-4d5e-4f60-8a91-2b3c4d5e6f70 8a4b2c3d-5e6f-4071-9b02-3c4d5e6f7081 --yes
curl -s -X POST "https://api.pictograph.io/api/v1/developer/workflows/runs/bulk-cancel" \
-H "X-API-Key: $PICTOGRAPH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"run_ids": ["7f3a1b2c-4d5e-4f60-8a91-2b3c4d5e6f70", "8a4b2c3d-5e6f-4071-9b02-3c4d5e6f7081"]}'
Returns BulkActionResult
BulkActionResult · 3 fields
class BulkActionResult(BaseModel):
"""Result of a server-side bulk state-change (e.g. pause/resume)."""
succeeded: list[str] = []
not_found: list[str] = []
count: int = 0
delete
Delete a workflow and its run history.
Source: Workflows.delete
| Arg | Type | Default | Notes |
|---|---|---|---|
workflow |
str |
required | Workflow name or id. |
client.workflows.delete(
workflow="Vehicle detector",
)
pictograph workflows delete "Vehicle detector" --yes
curl -s -X DELETE "https://api.pictograph.io/api/v1/developer/workflows/Vehicle%20detector" \
-H "X-API-Key: $PICTOGRAPH_API_KEY"
Returns None
bulk_delete
Delete many workflows in one call. Names that do not resolve land in not_found
rather than raising, so a re-run still succeeds.
Source: Workflows.bulk_delete
| Arg | Type | Default | Notes |
|---|---|---|---|
workflows |
Sequence[str] |
required | Names to delete (UUIDs are accepted too). Duplicates are ignored; ids that don’t resolve in your organization are reported in BulkDeleteResult.not_found rather than raising, so a re-run still succeeds. |
res = client.workflows.bulk_delete(
workflows=["Vehicle detector", "Dwell monitor"],
)
print(res.succeeded, res.not_found, res.count)
pictograph workflows delete "Vehicle detector" "Dwell monitor" --yes
curl -s -X POST "https://api.pictograph.io/api/v1/developer/workflows/bulk-delete" \
-H "X-API-Key: $PICTOGRAPH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"workflow_ids": ["<uuid-a>", "<uuid-b>"]}'
Returns BulkDeleteResult
BulkDeleteResult · 3 fields
class BulkDeleteResult(BaseModel):
"""Result of a server-side bulk delete (one chunked, org-scoped call)."""
succeeded: list[str] = []
not_found: list[str] = []
count: int = 0