Skip to content

Docs

Pro and up

Workflows

A workflow composes tasks, queries, server commands and AI steps into a visual DAG (a graph of connected steps) that runs end to end. You draw the graph on a canvas, wire one step's output into the next, and the engine runs it — on demand, on a schedule, or from a webhook. Crucially, the whole workflow runs as a single governed agent identity, so every step is RBAC-scoped and written to the audit log exactly like an interactive session.

Run as — the governance model

Every workflow is bound to one agent credential (you pick it when you create the workflow). That credential is the identity the workflow acts as, and it decides what the workflow is allowed to touch — which databases, servers, agents, clusters and apps, and which actions on them. Steps inherit this credential; they can never exceed it. Nothing in a workflow runs with broader access than the credential it was built under.

The effective access of a step is the credential's scopes intersected with your own permissions, and re-checked against guardrails at run time. This is the same scoped, time-boxed mechanism behind Agent access (MCP) — workflows simply drive those governed primitives from a graph instead of from a chat. Every step is audited as the bound credential.

Build a workflow

  1. Go to Workflows → New workflow. Optionally start from a template.
  2. Give it a name, an optional description, and choose the agent credential it runs as (see above).
  3. Add steps from the node palette on the left — drag a node onto the canvas, or click to add it. Every workflow starts with a single Trigger node that can't be deleted.
  4. Connect nodes by dragging from one node's output handle to the next node's input handle. The graph must stay acyclic (no loops) with exactly one trigger.
  5. Select a node to configure it in the inspector on the right — its target(s), SQL, command, prompt, etc., plus error handling. Data and server nodes let you pick several targets: the same step then runs on each (e.g. one command across many servers) and reports a per-target result. Select empty canvas to edit the workflow's name, credential and trigger.
  6. Save, then enable it (for scheduled / webhook triggers) or Run now.

Triggers

The trigger decides when a workflow runs. Set it on the canvas with nothing selected.

  • Manual — runs only when you click Run now (or call its webhook).
  • Schedule — runs on a recurrence (daily / weekly / monthly), or a custom 5-field cron expression, at a wall-clock time in an IANA timezone (e.g. Europe/Istanbul). The editor previews the next run. Like scheduled tasks, the time is DST-correct — it keeps firing at the same local time across daylight-saving changes.
  • Once — runs exactly once at a date + time you pick.
  • Event — runs when something happens in the workspace: an alert fires or resolves, an incident is declared, a detection is raised, or a task fails, optionally filtered by key=value.
  • Webhook — on any of the triggers above, generate a URL (in edit mode) and POST to it to start a run; the request body is delivered as the trigger input.
A webhook's token is the secret — anyone with the URL can trigger the workflow, unauthenticated. Treat it like a password: share it narrowly, and regenerate or remove it if it leaks.

Node types

The palette groups nodes by what they do: read data, reshape / validate it, perform an action, control the flow, or call the AI. Each entry below lists what the node does, the fields you set in the inspector (Config), and what it leaves for later steps (Output — reference it with {{ .steps.<node_id>.<field> }}).

Read data

  • DB query db.query — runs read-only SQL against one or more databases (writes are refused). Config: target_ids, sql · Output: columns, rows (capped at 200), row_count, duration_ms
  • HTTP request http.request — calls an external API or webhook over HTTP. Internal/private addresses are blocked (SSRF guard), and a non-2xx response is not a failure — the status is in the output so you can branch on it. Config: method (GET/POST/…), url, headers (JSON), body, timeout_sec · Output: status, body, headers, duration_ms · Requires the credential to hold integration.manage
  • Kubernetes GET kube.get — a read (GET) against one or more clusters' API at a path you supply. Config: target_ids, path (e.g. /api/v1/pods), query · Output: status, body
  • App GET app.get — a read (GET) against one or more internal apps at a path. Config: target_ids, path · Output: status, body
  • Kubernetes logs kube.logs — tails a pod's logs from one cluster. Config: target_id, pod, namespace, container, tail · Output: lines
  • Docker logs docker.logs — tails a container's logs from one or more servers. Config: server_ids, id (container name/id), tail · Output: lines
  • Host metrics host.metrics — a CPU / memory / disk snapshot from one or more servers. Config: server_ids
http.request → { "method": "POST", "url": "https://hooks.example.com/ingest",
                 "headers": { "Content-Type": "application/json" },
                 "body": "{\"count\": {{ .steps.tally.count }}}", "timeout_sec": 10 }
# later: branch on {{ .steps.<id>.status }}, or read {{ .steps.<id>.body }}

Read this workspace

These read Subnomic's own records — what this workspace already knows — rather than a machine you connected. They always return only this workspace's data: the workspace comes from the run itself, so there is no field that could point one at someone else's. Every filter is optional (leave it on Any), and each returns rows, count, total and truncated — so a condition like len(steps.<id>.rows) > 0 gates whatever comes next. Unlike the target-scoped nodes above, these are not narrowed by the credential's selected targets: the permission alone decides, exactly as it does for a person opening the matching screen.

  • Incidents incident.list — this workspace's incidents, newest first. Config: status, severity, limit · Needs incident.read
  • Alerts alert.list — firing or resolved alerts, newest first. Config: status, severity, source, limit · Needs alert.read
  • Detections detection.list — anomalies the detector raised from session activity. Config: status, type, severity, limit · Needs detection.read
  • Tasks task.list — the jobs this workspace enqueued (docker / kubernetes / host commands). Config: status, action, agent_id, limit · Needs task.read
  • Audit log activity.search — searches who did what, with the same filters the audit search uses. Config: terms, actions, entities, from/to (YYYY-MM-DD), limit · Needs activity.read
incident.list (node id: open_incidents) → { "status": "open", "severity": "high", "limit": 50 }
condition → expr: "len(steps.open_incidents.rows) > 0"
notify    → body: "{{ .steps.open_incidents.count }} incident(s) still open"

Reshape & validate data

  • Transform transform — computes named values from earlier steps and exposes them for the rest of the graph. Each value is an expression (the same language as conditions — no {{ }} braces). Config: set (a map of name → expression) · Output: each name you defined
  • Assert assert — a guard: evaluates a boolean expression and fails the run (with your message) when it's false. Set On error → Continue to warn-and-proceed instead of stopping. Config: expr, message · Output: passed
transform → set: { count: "len(steps.orders.rows)",
                   env:   "trigger.input.env" }
# later steps read {{ .steps.<id>.count }}

assert → expr: "len(steps.orders.rows) > 0"
         message: "No orders today — upstream pipeline may be broken"

Perform an action

  • Server command server.exec — runs a shell command on one or more servers (guardrail-checked; a required-approval server needs an active grant). Config: server_ids, command, timeout_sec · Output: stdout, stderr, exit_code (per server when several are selected)
  • Task task — enqueues a Docker / Kubernetes action (apply, scale, restart, set image, …) on an agent — the same governed task pipeline used elsewhere in Subnomic. Config: agent_id, action, data (JSON), timeout_sec · Output: status, result
  • Create schedule schedule.create — creates a scheduled task as a step (name, agent, action, recurrence, timezone), exactly like the Scheduled tasks form. It fires the same actions as the Task step, so it carries the same payload.

What a Task acts on

An action needs a target. Scale to 5 is not a step until the step also says scale what. Pick the agent, then the object from its inventory, and the payload is built for you — that is the Resource mode of the step's target picker, and it is where you should stay.

Advanced mode hands you the raw JSON payload instead. Use it when no picker can express the target — most often when the object's name comes from an earlier step ({{ .steps.find.rows 0 }}), or for an apply that carries a whole manifest. The payload must address something:

  • Kubernetes — apiVersion, kind, name, plus namespace for a namespaced kind. scale adds replicas, setimage adds container and image, and apply carries the whole object under manifest instead.
  • Docker — kind, id and name, plus replicas or image for those operations.

A payload that names no target is refused when the step tries to queue it, not silently run. Guardrail rules are matched against the target too, so a rule written for ns=production can only cover an action that says which namespace it is in. The editor flags any step still missing a target before you save.

  • Notify notify — alerts the workspace's owners / admins in-app and by e-mail, and optionally posts to a chat channel (Slack / Teams / Discord / …). The title and body are templated. Config: title, body, channel_id (optional)

Control the flow

  • Condition condition — a two-way branch on a boolean expression — wire the next step onto the true or false handle (see Conditions & branching below). Config: expr
  • Switch switch — a multi-way branch: it evaluates a value and routes to the matching case handle, or to default when none match. Config: expr, cases (comma-separated values) · one outgoing edge per case + default
  • Manual approval manual.approval — pauses the run until a person approves or rejects it, with an optional message to the approver.
  • Delay delay — waits a number of seconds (up to 900) before continuing — e.g. to let a rollout settle before verifying it. Config: seconds
  • Sub-workflow sub.workflow — calls another workflow as a step, passing optional JSON input — compose build → test → deploy from reusable pieces. Config: workflow_id, input (JSON)

Call the AI

  • Runa ai.agent — runs an AI step against a templated prompt. The AI can use the same governed tools (queries, commands, introspection, …) the credential allows; restrict it with an optional tool allowlist and cap its tool-use loop. Config: prompt, tools (optional allowlist), max_iterations · Output: answer

More nodes

  • Storage — objects.buckets, objects.list, objects.stat, objects.read, objects.write, objects.delete, objects.share, objects.presign_upload.
  • Workspace actions — incident.declare, incident.update, incident.note, alert.resolve.
  • Hosts and rollouts — host.file_read, host.file_write, rollout.status, cloud.provision.
  • Integrations — email.send through your own mail provider, and connector.call for PagerDuty, Jira and GitHub actions.
  • Loops — foreach runs a workflow once per item, for up to 50 items.
Not sure where to start? Open Templates from the Workflows page for ready-made graphs (health-check & alert, data-quality gate, webhook relay, backup-verify, release rollout, …) — instantiate one, then just fill in your targets and credential.

Passing data between steps

Each step records its output, and later steps can reference it. Text fields (SQL, commands, prompts, paths) support templating: reference an earlier step with {{ .steps.<node_id>.<field> }} and the trigger payload with {{ .trigger.input.<name> }}. A few helpers are available: default, toJson, upper, lower, trim.

SQL (db.query):     SELECT * FROM orders WHERE user_id = {{ .trigger.input.user_id }}
Prompt (ai.agent):  Summarize these rows: {{ toJson .steps.recent_orders.rows }}
Command (server.exec): systemctl restart {{ index .steps.recent_orders.rows 0 }}
Body (notify):      {{ .steps.summarise.answer }}      <- an ai.agent answers in "answer"
Body (notify):      {{ .steps.restart.result }}        <- a task answers in "result"
References are checked when you save: a reference to a step that isn't in the graph, or to a field that step's type doesn't produce, is refused with the node named. It has to be — at run time a bad reference fails the node rather than silently inserting an empty value, and finding that out mid-run is late. The field names are listed below.

What each step outputs

Every step type names its output differently, and the two that carry a payload use different words for it — an AI step answers in answer, a task step answers in result. Mixing them up is the most common wiring mistake there is.

Step Fields
db.querycolumns, rows, row_count, duration_ms, truncated
server.exectask_id, exit_code, stdout, stderr, duration_ms, timed_out, truncated
tasktask_id, status, result, error
kube.get · app.getstatus, content_type, body, truncated
kube.logs · docker.logslines, truncated, error
host.metricstimestamp, cpu_cores, cpu_avg_percent, uptime_seconds, process_count, processes, memory, disk, swap, load
ai.agentanswer, tool_calls
http.requeststatus, body, headers, duration_ms
notifydelivered, recipients, ai_generated, title, body, channel_id, channel_delivered
transformthe keys you set
condition · switch · assertmatched, branch · value, branch · passed
delay · manual.approvalseconds · approved, by, at
schedule.createscheduled_task_id, next_run_at, next_run_local, reused
sub.workflow · foreachchild_run_id, status, context · items, succeeded, failed, results
the workspace-data list stepsrows, count, total, truncated

Two more exist on any step you set Continue on error for, and only then: error and continued. The trigger step itself produces nothing — read the run input as {{ .trigger.input.<name> }}. Referencing a whole step ({{ toJson .steps.q }}) is always fine; only the field after the step id has to exist.

Multiple targets change the output shape. With one target a data/server node outputs its fields directly ({{ .steps.q.rows }}). With several it outputs a per-target list instead — reference each result under {{ .steps.q.results }} (each entry carries its target id, ok, and that target's fields). Keep this in mind if you later add a second target to a step other steps reference.

Conditions, switch & branching

A Condition node evaluates a sandboxed expression and splits the graph into a true and a false branch — wire the next step onto whichever handle you need. Expressions are written in a safe, side-effect-free language and read step outputs directly (no template braces), for example:

len(steps.recent_orders.rows) > 0
steps.health_check.exit_code == 0
steps.probe.status == 200 && len(steps.db.rows) > 0

A Switch node is the multi-way version: instead of true/false, its expr evaluates to a value and the run takes the outgoing edge whose case label matches it — falling back to the default edge when none do. List your cases in the inspector (the node grows one labelled handle per case, plus default) and wire each handle to the step that should handle it. Cases are compared as text, so cases: 200, 404, 500 matches an HTTP status, and cases: ok, degraded matches a string.

switch → expr: "steps.probe.status",  cases: 200, 404
  ├─ 200      → notify "healthy"
  ├─ 404      → notify "missing"
  └─ default  → notify "unexpected status"

Steps on a branch that wasn't taken are skipped — they don't run and have no side effects, and that propagates down the graph (a step whose only inputs were skipped is skipped too).

Error handling

Each node (except the trigger) can be tuned in the inspector:

  • Retries — 0–5 extra attempts (with a short backoff) if the step fails.
  • On error — Fail the run (default) halts the whole workflow, while Continue records the error on that step and proceeds downstream — useful for non-critical steps.

Manual approval & resume

A Manual approval node pauses the run and waits for a human. The run shows as Paused in its trace, where an approver clicks Approve (the run resumes past the node) or Reject (the run fails). Use it as a gate before a deployment or a destructive change.

Workflows are checkpointed: every step that succeeds is persisted as it completes. If a run pauses for approval — or a worker crashes mid-run — it resumes from where it left off, restoring completed steps' outputs rather than re-running them. A server command or task that was already sent is adopted rather than sent again, and an HTTP request carries an idempotency key so the receiving side can do the same.

Runs & history

Every run is recorded under the workflow's Runs view: its trigger, start time, duration and status (pending / running / succeeded / failed / canceled / paused). Open a run to see the live trace — the graph colored by step status, plus each step's resolved input, output, error and linked task — which auto-refreshes while the run is in flight.

  • Run now — start a run immediately, regardless of trigger.
  • Cancel — stop an in-progress run.
  • Enable / disable — pause a scheduled or webhook workflow without deleting it.
The run engine is highly available: each pending run is claimed with a database lock, so one worker runs it at a time even across several backend replicas, and a run whose worker dies is picked up again. The schedule loop runs the same way — a due workflow fires once, not once per replica.

Permissions

workflow.read workflow.write workflow.run

read covers viewing workflows and run history; write covers create / update / delete and enable / disable (including webhook setup); run covers Run now, cancel, and approving or rejecting a paused run. Beyond these, each step is additionally bound by the agent credential the workflow runs as — so the workflow can only reach targets and actions that credential is scoped for.