Workflows
Author executable workflow definitions and ship them inside a Scrydon extension alongside a Process Flow
Three "workflow" surfaces exist: building workflows interactively in the editor (Blocks, Triggers, Execution), the YAML Reference for the editor's import/export format, and this page — authoring workflow definitions that ship inside an Extension. For the Extension build/upload lifecycle, see Extensions & Authoring SDK.
A Workflow is an executable block-graph that runs alongside Process Flows — a HITL gate, an approval routing, a multi-step automation. The Workflow Authoring SDK lets you ship workflows inside the same .scrydon-extension.tar.gz archive as your Process Flow, so a single upload installs the ontology, every workflow the flow needs, and the flow itself in one atomic transaction.
Workflows shipped in an Extension are pure JSON — the archive carries the block / edge / sub-block graph, never code. The workflow runtime (blocks, executors, the orchestrator) lives in the platform and is invoked by workflowId when a workflow action runs.
When to use this SDK
Use the Workflow Authoring SDK when:
- A Process Flow you ship has an action of
actionType: "workflow"and you want the workflow to live in the same Extension (so re-importing the extension also refreshes the workflow). - You're building a demo / starter Extension and don't want to require operators to wire up workflows by hand after install.
- You want extension-level idempotency — re-uploading the same
.scrydon-extension.tar.gzupdates the workflow rows in place.
If the workflow already exists in your tenant (system workflow or hand-authored), keep using actionType: "workflow" + workflowId on the action template — no Extension changes needed.
Install
bun add -d @scrydon/sdk-authoring zodnpm install --save-dev @scrydon/sdk-authoring zodimport {
defineWorkflow,
defineBlock,
defineEdge,
} from '@scrydon/sdk-authoring/workflows'Anatomy of a workflow
| Element | Purpose |
|---|---|
| Package | Identity of the workflow manifest: id, name, version |
| Workflow Entry | One named workflow with slug, name, version, definition — an extension may ship many |
| Block | A node in the executable graph (starter, human_in_the_loop, an extension block, …). Carries subBlocks of authored config |
| Sub-block | A single authored field on a block (reviewerRole, resolutionMode, …). The type is open-ended so the editor can evolve independently |
| Edge | A directed connection between blocks. May carry an optional sourceHandle / targetHandle and data |
| Loop / Parallel | Optional sub-flow descriptors that group nodes for iteration or fan-out |
| Variable | A workflow-scoped variable surfaced in the editor's Variables popover |
| Metadata | name, description, exportedAt — non-functional fields preserved by the runtime |
A complete example — HITL gate
This example mirrors the HITL gate that PR #1208 demos: a single human-in-the-loop block that gates the transition from Realize → Deploy. The Process Flow (next section) references it by slug.
import { defineWorkflow } from '@scrydon/sdk-authoring/workflows'
export const realizeGate = defineWorkflow({
slug: 'hitl-realize-gate',
name: 'HITL — Realize → Deploy Gate',
description: 'Approval gate before transitioning from Realize to Deploy.',
version: '1.0.0',
definition: {
blocks: {
'block-start': {
id: 'block-start',
type: 'starter',
name: 'Start',
position: { x: 0, y: 0 },
subBlocks: {},
outputs: {},
enabled: true,
},
'block-hitl': {
id: 'block-hitl',
type: 'human_in_the_loop',
name: 'Realize → Deploy Gate',
position: { x: 240, y: 0 },
subBlocks: {
reviewerRole: {
id: 'reviewerRole',
type: 'short-input',
value: 'admin',
},
resolutionMode: {
id: 'resolutionMode',
type: 'dropdown',
value: 'first_claim',
},
},
outputs: {
approved: 'boolean',
notes: 'string',
},
enabled: true,
},
},
edges: [
{ id: 'edge-start-hitl', source: 'block-start', target: 'block-hitl' },
],
loops: {},
parallels: {},
},
})defineWorkflow, defineBlock, and defineEdge are identity functions at runtime — they narrow types so your IDE catches a missing field or a typo before the build ever runs.
The slug-rebind contract
Inside an Extension, workflows and Process Flows are decoupled through an extension-local slug. The Process Flow references a workflow by workflowSlug; the extension importer materializes the workflow into the tenant, gets a real workflow id back, and rebinds the action template:
// In the Process Flow:
defineAction({
name: 'Realize → Deploy gate',
actionType: 'workflow',
isRequired: true,
workflowSlug: 'hitl-realize-gate', // ← references the workflow by extension-local slug
})The importer walks the manifest, looks up hitl-realize-gate in the resolved slug → id map produced by the workflow install phase, and mutates the action template in-place: workflowId is set to the materialized id and workflowSlug is cleared. From that point on the action behaves like any other workflow action.
workflowId and workflowSlug are mutually exclusive on an action template — the schema rejects manifests that declare both. Use workflowId when you're pointing at a pre-existing workflow in the tenant; use workflowSlug when the workflow ships in the same Extension.
A slug the Process Flow template declares inline in its own workflows[] list is not rebound: the action keeps its workflowSlug, and every run materializes its own copy of that workflow. An inline declaration takes precedence over a workflow-<slug>/ subdir with the same slug. Both upload paths (Settings → Extensions and POST /api/extensions/import) accept the same archives.
If the slug doesn't resolve (the template doesn't declare it inline, the extension didn't ship a workflow-<slug>/ subdir for it, or the manifest inside named something else), the install fails fast with UNKNOWN_WORKFLOW_SLUG. Likewise, two workflow subdirs claiming the same slug fail with DUPLICATE_WORKFLOW_SLUG.
Packaging into an Extension
An Extension may carry an ontology, one Process Flow, and zero or more workflows. Each workflow lives in its own workflow-<slug>/ subdir with a manifest.json produced from defineWorkflow. The top-level extension.json lists every workflow as a separate contents[] entry:
import { defineExtension } from '@scrydon/sdk-authoring/extensions'
export default defineExtension({
manifestVersion: 1,
package: {
id: 'sap-activate-brabant',
name: 'SAP Activate (Brabant)',
version: '1.0.0',
},
contents: [
{ kind: 'ontology', path: 'ontology', version: '1.0.0', required: true },
{ kind: 'workflow', path: 'workflow-hitl-gate', version: '1.0.0', required: true },
{ kind: 'process-flow', path: 'process-flow', version: '1.0.0', required: true },
],
installOrder: ['ontology', 'workflow', 'process-flow'],
metadata: { isSystemExtension: false, isDemoExtension: true, tags: ['sap-activate'] },
})A few rules the bundler enforces:
ontologyandprocess-floware singletons — each may appear at most once.workflowmay repeat.contents[].pathmust be unique across all entries.installOrderis a permutation of the distinct kinds incontents[]— a single"workflow"entry covers every workflow subdir; they all run in the workflow phase, beforeprocess-flowso the slug map is populated when the process-flow rebind runs.
Extension layout
<extension>.scrydon-extension.tar.gz
├── extension.json # ExtensionArchiveManifestSchema (lists every subdir)
├── ontology/
│ └── manifest.json # OntologyManifestSchema — may be empty
├── workflow-hitl-gate/
│ └── manifest.json # WorkflowManifestSchema (one per workflow)
├── workflow-cutover-gate/
│ └── manifest.json # (optional — extensions may ship many workflows)
└── process-flow/
├── manifest.json # ProcessFlowManifestSchema
└── assets/ # optional — JSON / image assetsThe extension inspector validates each subdir's manifest against its own Zod schema. Workflow manifests round-trip as standalone JSON — you can validate one in isolation with bunx @scrydon/sdk-authoring extension validate against a workflow manifest.json.
Block validation rules
WorkflowDefinitionSchema enforces two structural invariants beyond the per-field types:
| Rule | What it catches |
|---|---|
blocks[key].id must equal the record key | A typo'd id that would silently break edge references |
Every edge source / target must reference an existing block id | A dangling edge from a deleted block |
Block type is intentionally an open string — the runtime block catalog (starter, human_in_the_loop, every extension block, …) evolves independently of the SDK and the schema doesn't lock to a snapshot. Same for sub-block type: the value is preserved verbatim and surfaced to the editor.
Block references are checked too
Those two rules cover the edge graph — how control flows. The other half is
the <blockName.field> references inside sub-block values — and inside a
loop's forEachItems, whileCondition and doWhileCondition — which is how
data flows. They are checked in every workflow your extension ships: the
workflows of a workflow manifest and the inline template.workflows of a
process flow. Every command that produces a shippable extension resolves every
one of them: extension validate, extension build, build-dist and
build-source.
Errors fail the command and are reported all at once, so a workflow with
several bad references takes one run to fix, not one run each. Warnings print to
stderr and never change the exit code.
A reference names a block by its name, lowercased with spaces removed —
never by its id:
defineBlock({ id: 'block-intake-webhook', name: 'Report', /* … */ })
// reads as <report.body.subject> ✅ the name, normalized
// NOT <block-intake-webhook.body.subject> ❌ the id never resolvesNames are not limited to ASCII: a block named Résumé is read as
<résumé.content> and is checked like any other. A field segment may contain
a hyphen — <api.headers.content-type> reads the content-type key. An
array element is read by its position, written either [0] or .0:
<api.data.users[0].name> and <api.data.users.0.name> are the same
reference, and both are checked. Only a number goes in the brackets —
<api.data[key]> is not a reference.
| Finding | Severity | Meaning |
|---|---|---|
Unknown reference root \x`` | error | No block in the workflow is named x. Usually a typo, or a reference left behind after a block was renamed. |
Unknown workflow variable \v`` | error | A <variable.v> reference, but the workflow declares no variable v and no variables block assigns one. |
Block \X` declares no output `f`` | warning | The root resolves but the field is not in that block's declared outputs. Only reported when the block declares outputs at all, because a block's output shape can be dynamic. |
… reads loop context, but this block is not inside any loop | warning | A <loop.…> reference from a block that is in no loops body. A parallels body does not count — see the Parallel row. |
… the Dapr interpreter does not resolve the \variable` root` | warning | The variable is declared, so the authoring is right — but see the note below. |
… reads from block \X`, which is disabled` | warning | The root resolves, but that block is turned off. A disabled block never runs, so it produces no output and the literal text is used. Enable it or drop the reference. |
| Any error inside a disabled block | warning | A disabled block is skipped before its inputs are resolved, so a bad reference in it cannot break a run. It is reported as a warning and becomes an error once the block is enabled. |
Any error in a loop field its loopType does not use | warning | Only forEachItems (for forEach), whileCondition (for while) and doWhileCondition (for doWhile) are error-tier. A for loop uses none of them. The others are left over from switching the loop kind and are never read. |
parallels.<id>.nodes declares a Parallel container | warning | The Parallel container was removed: its body is not run per branch or per item, and <loop.…> does not resolve inside it. Use a loop, or independent branches. |
The three runtime roots are scoped, not blanket-accepted
start, loop and variable name no block, so they are resolved against what
the runtime actually provides rather than against blocks:
| Root | When it resolves |
|---|---|
start | Always. The trigger payload is seeded before the first block runs. |
loop | Only inside a loops body. Outside one — including inside a parallels body — the literal text is used. |
variable | Only inside a code block today — see below. |
<variable.name> does not currently resolve outside a code block. The
reference is substituted literally in every other sub-block, so a declared
variable read from, say, a Teams content field sends the text
<variable.name>. Read the assigning block's output instead —
a variables block named Set Vars that assigns greeting is readable as
<setvars.greeting>. extension validate reports this as a warning rather
than an error because the authoring is correct; the gap is in the workflow
runtime.
An unresolvable reference is not a runtime error — the literal text is
used as the value. A Teams block whose content reads
<announcemnt.content> posts the string <announcemnt.content> to the
channel. That is why this is an authoring-time error rather than something
you find in production.
Two shapes are deliberately not checked, because both are ordinary authored
text that the runtime also leaves alone: a reference with no .field segment
(<announcement> — indistinguishable from the HTML tag <div>), and anything
that starts with a space, is a number, or contains an operator, @, : or /
(a < b && c > d, <0.5>, <someone@example.com>). A name may start with
digits as long as a letter follows them directly: a block named 2FA check,
read as <2facheck.result>, is checked like any other, while <0.5> and
<1.2.3> stay text.
Migration note — new build errors
Extensions that built cleanly before this check can now fail extension build
/ extension validate with workflow_reference_unresolvable. Each of these
already produced a wrong value at runtime — the literal reference text — so the
build is reporting an existing defect, not introducing one. For the same
reason the check ships in a minor release of @scrydon/sdk-authoring, not a
major one; pin the previous version if a build has to stay green while you fix
the references it reports.
| What you wrote | Why it now fails | Fix |
|---|---|---|
A typo'd or stale root — <announcemnt.content>, or a reference left behind after renaming a block | No block has that name | Correct the name, or point the reference at the renamed block |
A reference by block id — <block-intake-webhook.body> | References resolve by the block's name, never its id | Use the normalized name — name: 'Report' → <report.body> |
<parallel.currentItem> (or any <parallel.…>) | The parallel container was removed; the runtime has no parallel root | Use a loop and <loop.currentItem> / <loop.index> inside its body |
An undeclared <variable.x> | The workflow declares no variable x and no variables block assigns it | Declare it, or read the assigning block's output (<setvars.x>) |
The same checks now cover a loop's forEachItems / conditions and a process
flow's inline template.workflows, so an extension whose workflow subdirs were
already clean can still find new errors there.
Already-published extensions keep installing. Importing an archive (the
upload preview under Settings → Platform → Extensions → Sources → Upload an
extension, the
/api/extensions/import API, and extension inspect) runs the same checks but
reports every finding as a warning — an unresolvable root keeps the
workflow_reference_unresolvable code, advisory findings use
workflow_reference_advisory — and never blocks the install. Rebuild the
extension to see them as errors and fix them.
Human-in-the-Loop gates
Every human_in_the_loop block is inspected when the extension is built, when
it is imported, and when the workflow is deployed — from the canvas, Monitor, an
automation, a promotion, or POST /api/v1/workflows/{id}/deploy. Three findings
block, three advise. Every blocking finding blocks at build, at import and at
deploy alike: an extension built before these checks existed is refused
until its gate is fixed and the extension is rebuilt. Each warning is returned
as hitlWarnings at deploy and shown next to the success message. A rollback to an earlier version reports its findings as
warnings instead of refusing, because that version already ran.
| Finding | What it means | |
|---|---|---|
hitl_reserved_field_name | error | A resume-form field uses a name the released block output owns. The runtime overwrites it, so the reviewer's value is discarded — and a field named approved records the opposite of a "No". Rename it (recommendation, severity, vote). |
hitl_single_signoff_multi_reviewer | warning | Two or more named approvers, but one sign-off releases the gate. The first answer decides and the rest are told the review closed. |
hitl_quorum_exceeds_approvers | warning | More approvals required than there are named approvers. The runtime clamps rather than deadlocking, so the gate releases below the count you authored. |
hitl_decision_options_invalid | error | A declared decision option is unusable: no value, a value with characters other than letters, digits, _ and -, an outcome other than approve / reject / other, a duplicate value, or only one valid option. |
hitl_decision_options_no_approve | warning | Decision options are declared but none has outcome approve, so the gate can never approve. Right for a pure triage gate; otherwise mark the option that lets the run continue. |
hitl_reviewer_not_org_role | error | The gate's reviewerRole is not an organization role (admin, member). A custom process persona is not accepted, even one the extension's own flow declares: the running workflow can assign people to a persona, so it would choose its own approver. To name specific people, set the gate's approvers to specific people. The gate's interpreter also refuses such a role at run time. |
The reserved names are approved, decision, reason, verdict,
responses, aggregate, submission, response, url and option. You do not
need a field for the decision itself — approving or rejecting is the decision,
and it is readable downstream as <gate.verdict> (and <gate.approved>). The
form is for what the envelope does not already carry.
The two approver-count warnings only fire in Specific people mode, where the panel size is known at authoring time. A role or persona resolves at runtime, so nothing here can tell how many people it reaches.
Decision options
A gate that needs more than approve / reject declares its ballot in the
decisionOptions sub-block. Each option maps to an outcome class — approve
counts toward the required approvals, reject is a refusal, other is
neither — and there are no weights. Either the editor's table rows or plain
objects are accepted:
decisionOptions: {
id: 'decisionOptions',
type: 'table',
value: [
{ value: 'go', label: 'Go', outcome: 'approve' },
{ value: 'no_go', label: 'No go', outcome: 'reject' },
{ value: 'defer', label: 'Defer', outcome: 'other' },
],
},Downstream blocks read <gate.verdict> (approve · reject · undecided),
<gate.aggregate.byOption.go> (a count per option, 0 included) and — on a
single sign-off gate — <gate.option>. The resume API and A2A accept only a
declared value, submitted as option; anything else is refused with HTTP 400
and not recorded. See Human in the Loop → Decision
options for how each rule
releases.
AI blocks and the model field
Core AI blocks (agent, router, evaluator, translate) have a required model field, but extension-authored workflows should leave it empty. Hardcoding a model would tie your extension to a vendor the installing organization may not have enabled. When the model sub-block is empty at execution time, the platform resolves the organization's default LLM extension (the one configured under Settings → Agentic → Default LLM Extension) and uses its model. If the organization has no default LLM configured, the run fails with <block name> is missing required fields: Model — configuring a default LLM extension resolves it.
Manifest version
Workflow manifests carry manifestVersion: 1. The build-time extractor injects this so authors don't have to.
The schema rejects:
| Error | Cause |
|---|---|
Slug must be lowercase alphanumeric with hyphens | A workflow slug failed /^[a-z0-9-]+$/ |
Must be a valid semver string | The workflow version is not semver |
workflows[] must not contain duplicate slugs | Two entries in one manifest share a slug |
blocks[key].id must equal the record key | A block's id disagrees with the record key |
edges[].source / edges[].target must reference an existing block id | A dangling edge after a block was deleted |
Unknown reference root \x` in `block-y.field`` | A <x.field> reference names no block in the same workflow — a typo, a rename, or a reference written against the block's id instead of its name |
Unknown workflow variable \v` in `block-y.field`` | A <variable.v> reference with no matching declared variable and no variables block assigning it |
Build, inspect, upload
Author each workflow with defineWorkflow. Compose the Extension with defineExtension and add a contents[] entry per workflow.
bunx @scrydon/sdk-authoring extension build src/extension.ts --outDir dist
# → dist/<package.id>-<package.version>.scrydon-extension.tar.gzbunx @scrydon/sdk-authoring extension inspect dist/sap-activate-brabant-1.0.0.scrydon-extension.tar.gzThe inspector lists every subdir, validates each manifest, and surfaces the slug map the importer will resolve.
Sign in as org admin → open Settings → Platform → Extensions → Sources in the platform app → click Upload an extension (one-off) → drop your .scrydon-extension.tar.gz. Because the extension ships workflow content, the dialog renders a workspace environment picker — pick the env where the workflow definitions should install. Ontology and process-flow content install at the org level regardless of the picker.
See ADR 2026-05-21 Unified extension upload surface for the design rationale.
The underlying agentic route also accepts uploads directly. An extension that ships workflows requires workspaceEnvironmentId on the call — workflows are scoped to a workspace environment, not the org. Pass it as a query parameter:
curl -X POST "$AGENTIC_URL/api/extensions/import?organizationId=$ORG_ID&workspaceEnvironmentId=$ENV_ID" \
-H "Cookie: $SESSION_COOKIE" \
-F "file=@dist/sap-activate-brabant-1.0.0.scrydon-extension.tar.gz"The route returns { ontology, workflows: { idsBySlug }, processFlow }. The idsBySlug map shows each extension-local slug rebound to its materialized workflow id, useful for follow-up audit / verification.
Idempotency
The materialized workflow row id is derived as extension:<packageId>:<slug>:<workspaceEnvironmentId>. Re-uploading the same Extension to the same workspace environment finds the existing row and (when replacePublished: true is passed) deletes + re-inserts it — workflow_blocks, workflow_edges, and workflow_subflows cascade. Different environment ids produce different rows, so the same extension can be installed side-by-side across dev / staging / prod environments.
workflow.variables is stored as Record<string, Variable> keyed by variable id. The materializer attaches the resolved workflowId to each variable on insert; authored entries omit it.
Where to next
Reference workflows from your Process Flow's defineAction calls via workflowSlug (extension-local) or workflowId (pre-existing).
Ship the ontology alongside workflows in the same Extension — the importer runs ontology first, then workflows, then process-flow.
Extension vs Extension, the build / upload loop, and the four subpath surfaces.