Process Flows
Author Process Flows via the SDK — stages, tasks, personas, action templates, and voice triggers — and ship them as extensions
This artifact ships inside an Extension. For the shared lifecycle — install, extension build, upload — see Extensions & Authoring SDK.
A Process Flow is a structured, repeatable workflow with stages, tasks, and personas — kickoffs, audits, onboarding, due-diligence reviews. You author it once as a Process Flow using this SDK and ship it inside a .scrydon-extension.tar.gz; the platform instantiates it per run as a Process Flow.
SDK and extension are the only authoring path. Process Flows are defined in code using this SDK and installed via the platform's Settings → Platform → Extensions → Sources upload. There is no in-app editor. Once an extension is installed, workspace admins turn the template on for an environment from Extensions, making it instantiable in Process Flows.
Don't want to write that code by hand? See Author an Extension with an AI Coding Agent — describe the stages, tasks, and approvals in plain language and let a tool like Claude Code, Cursor, or GitHub Copilot do the SDK work.
process flow archives are pure data. The archive is JSON — no executable code ever ships in the archive. Custom logic is referenced by ID against system or organization-level workflows.
Scrydon extension archives. Process flows ship as .scrydon-extension.tar.gz archives that archive the flow alongside its ontology (and, in future releases, KB seeds and block extensions). The extension imports atomically — ontology install, slug → tenant class ID rebind, process-flow materialise, all in one transaction. Build with bunx @scrydon/sdk-authoring extension build; inspect with bunx @scrydon/sdk-authoring extension inspect. See the spec and packages/sdk-authoring/src/extensions/container/examples/ai-boardroom/ for a worked example.
Install
bun add -d @scrydon/sdk-authoring zodnpm install --save-dev @scrydon/sdk-authoring zodimport {
defineProcessFlow,
defineStage,
defineTask,
defineAction,
definePersona,
defineVoiceTrigger,
} from '@scrydon/sdk-authoring/process-flows'Anatomy of a template
| Element | Purpose |
|---|---|
| Package | Identity of the archive: id, name, version |
| Template | The flow itself — default view, execution config, metadata |
| Persona | Roles a task can be assigned to (one, many, or system) |
| Attribute | A typed, classified fact every run carries — a customer email, a case reference |
| Stage | An ordered phase. Transitions are manual, automatic, or approval |
| Task Template | Work items inside stages. Carry actions, dependencies, default attributes |
| Action Template | The thing a task asks the user to do — checklist, document, approval, workflow, entity_link, file_upload, distribution, voice_trigger, map, form, destruction, persona_assignment |
| Voice Trigger | Optional voice-driven enrichment block scoped to a path |
A complete example
import {
defineProcessFlow,
defineStage,
defineTask,
defineAction,
definePersona,
} from '@scrydon/sdk-authoring/process-flows'
export default defineProcessFlow({
manifestVersion: 1,
package: {
id: 'acme-kickoff',
name: 'Acme Customer Kickoff',
version: '1.0.0',
},
template: {
slug: 'acme-kickoff',
name: 'Customer Kickoff',
description: 'Standard onboarding flow for new enterprise customers',
version: '1.0.0',
defaultView: 'wizard',
executionConfig: {
stageFlow: 'sequential',
taskFlow: 'parallel',
allowRuntimeTaskCreation: true,
},
metadata: {
icon: 'rocket',
color: '#2563eb',
tags: ['onboarding', 'sales'],
},
personas: [
definePersona({ slug: 'csm', displayName: 'Customer Success Manager', cardinality: 'one' }),
definePersona({ slug: 'champion', displayName: 'Customer Champion', cardinality: 'one' }),
definePersona({ slug: 'system', displayName: 'System', cardinality: 'system' }),
],
stages: [
defineStage({
slug: 'discovery',
name: 'Discovery',
transitionMode: 'manual',
estimatedDuration: { value: 5, unit: 'days' },
}),
defineStage({
slug: 'rollout',
name: 'Rollout',
transitionMode: 'approval',
}),
],
taskTemplates: [
defineTask({
slug: 'collect-stakeholders',
stageSlug: 'discovery',
name: 'Collect stakeholders',
defaultAssignedRoleSlug: 'csm',
actions: [
defineAction({
name: 'Stakeholder list',
actionType: 'document',
isRequired: true,
metadata: { persona: 'csm' },
}),
],
}),
defineTask({
slug: 'kickoff-call',
stageSlug: 'discovery',
name: 'Kickoff call',
dependsOnTaskSlugs: ['collect-stakeholders'],
actions: [
defineAction({
name: 'Record kickoff call',
actionType: 'voice_trigger',
isRequired: true,
metadata: { triggerPath: '/kickoff/' },
}),
defineAction({
name: 'Capture summary to KB',
actionType: 'workflow',
isRequired: false,
workflowId: 'system.summarize-meeting',
metadata: { promoteToCorpus: 'summary' },
}),
],
}),
defineTask({
slug: 'rollout-plan',
stageSlug: 'rollout',
name: 'Approve rollout plan',
dependsOnTaskSlugs: ['kickoff-call'],
actions: [
defineAction({
name: 'Approve plan',
actionType: 'approval',
isRequired: true,
metadata: { persona: 'champion' },
}),
],
}),
],
voiceTriggers: [],
},
})Action Types
| Action type | Use it for |
|---|---|
checklist | A simple to-do the user marks complete |
document | A document the user must produce or attach |
approval | An approval gate — assigned to a persona, blocks downstream tasks until granted. Rejecting the gate (the Reject button) halts the flow: the gate's task stays open and no downstream stage unlocks, whether or not the approval action is marked required, until the gate is re-decided to approved or skipped as an explicit override |
workflow | Reference an executable workflow — either by workflowId (a pre-existing system or org workflow in the tenant) or by workflowSlug (a workflow shipped in the same Extension — see Workflows). The two are mutually exclusive |
entity_link | Link the task to a typed Object instance via the ontology |
file_upload | A file drop — bytes get ingested into the instance KB |
distribution | Send to a channel (email, cortex, slack, teams); supports quorum, fire-and-forget, all-acknowledged |
voice_trigger | Capture audio against a path — STT runs and the transcript flows into KB |
map | Show a governed geo layer and record an operator classification per feature |
form | Collect typed answers and deliver them without storing them — only the submission is recorded |
destruction | Delete the platform's copies of files an earlier task collected (or attest a deletion elsewhere) and record the outcome: verified, attested or failed |
persona_assignment | The assignee picks who holds a persona (metadata.assignsPersona) — one replaces the holder, many adds; tasks depending on the step stay locked until the pick lands |
See Action Types for a per-type deep-dive with copy-pasteable examples, runtime UX notes, and the metadata fields each type reads.
Who hears about a task
Every task template's defaultAssignedRoleSlug (its persona) and its due date (dueOffsetDays or due) drive the platform's worklist and its notifications:
- When an instance is created, its cast is staffed first: the persona assignments of the environment's most recent instance of the same flow are copied onto it, and any persona still empty is staffed from the functional role it declares.
- Everybody a first-stage task waits on (assigned to them by name, or to a persona they hold) then receives one
process.instance_creatednotification. It lists the first-stage tasks waiting on that person, with their due dates. A template that opens eight tasks at once sends each participant one message, not nine. Being in the cast is not enough: someone whose work comes later is notified when their task opens, not at the start. The organization's administrators are told that the flow started without someone to do its work when the cast is empty, or when a first-stage task waits on a persona nobody holds. - When a task becomes open later (dependents on completion, a move into a stage, an added task, or a (re)assignment), the people it waits on — its assignee, or the holders of its persona on that instance — receive
process.task_assigned, once each. Someone newly assigned to a persona hears about the open tasks already waiting on it. Someone who already held it is not paged again when the cast is edited. - When an open task passes its due date, they receive
process.task_sla_breached, once, and the flow's activity log records the breach.
The same rule decides what "Needs me" lists on the Flows dashboard and what Horizon's Home worklist shows: a task nobody is assigned to and no persona claims appears for nobody — the instance page says so and offers to assign the persona.
Every one of these notifications arrives in the in-app inbox and by email. The email carries a button that opens the task, or the flow when several tasks are listed.
Staffing a persona from the organization chart
A persona can name the organization-chart functional role that normally fills it. An instance nobody staffed, which is the default for every trigger-started one, then staffs that persona from the organization chart:
definePersona({
slug: 'approver',
displayName: 'Access Approver',
cardinality: 'one',
functionalRole: {
slug: 'access-approver',
// Optional: a dotted path into the instance context holding a department slug.
departmentFromContext: 'request.department',
},
}),| Field | Type | Description |
|---|---|---|
functionalRole.slug | string (slug) | The functional role whose holders staff this persona. |
functionalRole.departmentFromContext | string (dotted path), optional | Where the instance context holds the department to ask about. The department's own holders are used first. When the department has none, the organization-wide holders stand in. Without this field, the organization-wide holders are used. |
The rules:
- An operator's choice wins. A persona that already has a holder, whether carried over from the last run or assigned by hand, is never touched.
- Staffing is an assignment. The role's holders are written as the persona's assignments, marked as assigned by
system:functional-roleon the instance's activity log. They can therefore act, including approving a gate routed to that persona, and they can be replaced from the persona modal like anybody else. - A
cardinality: 'one'persona is never guessed. When the role has several holders at that scope, nobody is staffed and the persona shows as unstaffed. - An unknown department is not widened. When the context names a department the organization does not have, nobody is staffed, rather than falling back to the organization.
- A
systempersona cannot declare a functional role.
When a step reaches a persona that is still empty, the platform looks up the role again. This covers a role that had no holder when the instance started. Who is told then follows the ladder: the persona's holders, then the declared functional role, then an organization role sharing the persona's slug, and finally the organization's administrators. Administrators reached this way get a staffing notice only. They are not given the content or the right to approve.
Who can complete a step
Completing a step (submitting a form or a map decision, editing or approving an action, running its workflow, picking a persona, approving or rejecting a stage transition) is allowed to:
- a workspace admin or owner;
- the person who created the instance;
- the task's assignee: the user the task is assigned to, or a holder of the task's persona on that instance. They must be a member of the workspace. This is what lets the people a trigger-started instance staffs from the organization chart act on it: such an instance is created by the platform, so nobody else holds it.
An assignee may complete only their own task. They cannot complete another task of the same instance, and they cannot cancel or rerun a step, move a task, send a card back, or finish or reopen the instance; those stay with admins, owners and the creator. An approval gate that names a persona also requires holding that persona.
Approval-gate routing
When a stage has transitionMode: "approval", the platform notifies the right people the moment a task reaches the gate. Three fields on defineStage control who is notified and how many approvals are required:
| Field | Type | Default | Description |
|---|---|---|---|
approverPersona | string (slug) | organization administrators | The persona whose current members receive the approval-request notification. When nobody holds the persona it falls back to an organization role sharing the slug, then to the organization's administrators. Only a person assigned to the persona can actually approve, so that fallback notifies someone who can staff it — it does not grant approval rights. |
resolutionMode | "first_claim" | "all_must_approve" | "quorum" | "first_claim" | When multiple approvers are notified, determines the decision rule. first_claim — any one approver can decide. all_must_approve — every notified user must approve. quorum — at least quorumN approvers must approve. |
quorumN | number (integer ≥ 1) | — | Required when resolutionMode: "quorum". The minimum number of approvals needed. |
When the gate is reached, the platform sends a process.approval_requested notification to each resolved user and displays "Approval request sent · N notified · sent <date>" in the approval task sheet.
Example — legal-quorum gate:
defineStage({
slug: 'legal-review',
name: 'Legal review',
transitionMode: 'approval',
approverPersona: 'legal-reviewer',
resolutionMode: 'quorum',
quorumN: 2,
})The flow halts at the gate until the quorum is satisfied. Rejecting the gate halts the flow regardless of quorum count — a single rejection is always final.
Grounding an AI step in workspace documents (metadata.retrieval)
The reverse direction: a workflow action that runs an agent — a @system/*
built-in or an extension/canvas workflow (inline or sibling) — can be
grounded in one or more documents uploaded earlier in the flow, so the agent reasons over real inputs instead of the task name alone. Declare it with
metadata.retrieval on the action:
defineAction({
name: "AI Qualification Assessment",
actionType: "workflow",
isRequired: false,
executionMode: "automatic", // run when the stage is entered
workflowId: "@system/agent-project-qualifier",
metadata: {
aiAgent: "project-qualifier",
// Where the agent result lands, by target ACTION TYPE (see below):
// an assessment on this workflow step itself, and an advisory banner
// on the flow's human approval action.
outputSurface: [
{
kind: "workflow",
contentType: "project_qualification_assessment",
pageTitle: "AI Qualification Assessment",
},
{
kind: "approval",
targetTaskSlug: "go-no-go-decision",
advisorySource: "ai-qualification",
},
],
retrieval: {
// The document(s) the agent must judge. `fromTask` is the slug of an
// earlier task: its completed document action's page, its file_upload
// action's ingested documents, and/or its workflow (agent) action's
// assessment page are resolved as stable, source-level entries.
inputs: [{ fromTask: "upload-source", mode: "full" }],
// Optional per-source maximum. The exact shared rendered-block budget
// can assign less after headings, status text, and markers are counted.
// Omitted → mode-derived default (full: 24,000; summary: 4,000).
inputDocMaxChars: 60_000,
},
// Optional default maximum for the answer this step PRODUCES. Words, not
// pages: "a page" is undefined for Markdown, and a limit is only worth
// declaring if something can check it. Ship a low value on steps whose
// output is distributed and leave it unset on internal working documents.
maxWords: 800,
},
})| Field | Meaning |
|---|---|
retrieval.inputs[] | { fromTask, mode }. Loads the source document(s) produced at fromTask (a task slug): the page written by its completed document action, pages derived from its file_upload uploads, and/or the assessment page written by its agent workflow action. Known task locations are authoritative; lexical KB selection is used only when no deterministic location exists and is labelled Selected relevant documents, not complete inventory. Pointing fromTask at an earlier agent step requires that upstream step to declare outputSurface: "workflow" so an assessment page exists. mode: "full" (default) delivers a verbatim prefix and is never summarized. mode: "summary" condenses each oversized source independently; a missing, failed, over-capacity, or timed-out summary falls back to a visibly marked source-local clip. Inputs are read as the step's assignee, else the person staffing its persona, at their clearance. An AI step nobody staffs reads them as the identity its model call runs as: the Run As account of its workflow, which needs Knowledge (Read) and membership of the workspace, or else the person who created the instance. An instance an extension workflow created has no creator to read as, so without a Run As its inputs are reported as unreadable. |
retrieval.inputDocMaxChars | Optional positive integer (max 200,000). A per-source maximum: the verbatim-prefix ceiling in full mode or condense target in summary mode. The model-aware shared budget covers the exact rendered block—section and source headings, coverage status, separators, bodies, and truncation markers—so a source can receive less than this maximum. Omitted → summary: 4,000; full: when the model window is known, the larger of 24,000 and an equal share of the budget across the step's full sources (so a large-window model delivers sources whole instead of condensing them), otherwise 24,000. If the model window is unavailable, the UI labels the fixed operational estimate rather than claiming the whole prompt fits. |
maxWords | Optional positive integer between 50 and 20,000. The default maximum number of words for the answer this step produces. It is the counterpart to retrieval.inputDocMaxChars, which bounds only what goes in. The step passes it to the platform as outputConstraints: { maxWords } on every model completion it makes, so the limit is enforced the same way for any caller of ai.llm.complete (see TypeScript SDK → outputConstraints). The platform renders the limit into the prompt, counts the answer, and condenses an overrun once. If the answer is still over after that pass, it is published in full and the step shows the overrun instead of discarding the work. An answer that was condensed is labelled with its count and shortened next to the output, so it is never mistaken for one that fit the first time. Words are whitespace-separated tokens containing a letter or digit, so Markdown syntax (##, table pipes, ---) does not count. The person running the flow can override or clear the limit for a step at runtime, and their value wins. This applies to every AI step: @system/* agents, including the ones that reply in JSON, and canvas workflows. For a JSON reply, only the string values are counted, and the condensed rewrite must keep exactly the same keys, so a verdict stays parseable. On a canvas workflow the limit applies to each Agent block's completion in that run. It is not passed on to a child workflow that the canvas calls. Setting a limit on a step that runs no agent is rejected (output-word-cap-unsupported, HTTP 400) rather than stored where nothing reads it. |
outputSurface | Optional. One selection or an array. Each selection's kind is the target action type the result lands on, plus per-surface config. kind: "workflow" writes the assessment onto the agent step itself AND as a clearance-capped page in the instance KB (contentType, pageTitle): a verdict-shaped reply ({verdict, confidence, rationale, …} JSON) renders the verdict layout; any other reply (prose, other JSON schemas) renders the agent's own text as the page body. kind: "approval" merges a one-line advisory onto the decision action of the task named by targetTaskSlug (advisorySource labels its provenance). The candidates are that task's own decision actions plus every stage's (stage actions are stamped onto a task as it enters the stage). To pick one, give the gate an action slug and add targetActionSlug. Without it, a task's single own gate receives it; several own gates → the first in task order; no own gate → the single stage gate, or nothing when there are several. Several own gates and several stage gates warn at build and name the candidates in the log; a targetActionSlug that matches no decision action fails the build (details). Other action types are reserved for future surfaces. Surfaces are never implicit — an agent step without outputSurface writes no assessment page. |
mode: "summary" is lossy — do not use it for compliance-critical scoring.
This mode is optimised for readability and fitting the LLM's context window; it
does not guarantee a loss-free representation of every fact. For
compliance-critical tasks — strict RFP evaluation, legal or contractual scoring —
use mode: "full" together with the pull channel
(<start.processFlow.knowledgeBaseId> + the knowledge RAG tools), so that
the agent can fetch decision-relevant criteria on demand. full itself can still
be clipped or omitted by the exact shared capacity; every such loss is marked.
The Prompt & Context panel reports one row for every stable known source, including same-title siblings. It distinguishes represented, omitted for capacity, read failed, and summary fallback outcomes; only content-bearing sources contribute output references. Preparation is bounded to 500 inventoried sources, 1 MB per loaded body, 8 MB across all declared inputs, 24 summary calls at concurrency 6, and 60 seconds. If the authorized inventory cannot be completed within its source or elapsed-time bound, preparation stops with a generic error before the agent model runs. Once inventory is complete, a summary deadline uses the visibly marked source-local fallback described above.
Today workflow (assessment on the step) and approval (advisory on a target
approval action) are the implemented output surfaces. The retrieval spec
itself is generic and works for any agent-bearing workflow step that needs
grounding — @system/* built-ins receive the gathered context as their user
message automatically, while extension/canvas workflows must additionally reference
<start.processFlowContext> in a block to receive it (see
Receiving process-flow context
— without the reference the gathered context is dropped).
One knowledge base for every run (knowledge.scope)
By default each run of a process flow gets its own knowledge base. Set
knowledge.scope: "template" and every run of that flow in a workspace
environment writes into one shared knowledge base instead:
template: {
// …
knowledge: { scope: "template" },
}- The shared base is created the first time the flow runs in an environment and appears in the workspace's knowledge-base list, badged Process flow.
- Each run owns one folder,
instances/<date>-<run name>-<id>/, holding its uploads, documents, transcripts and closing summary. AI steps read their own run's folder plus anything outsideinstances/— so reference material members upload at the root of the base is available to every run. - Earlier completed runs ground later ones through their own folders.
- Deleting a run removes only its folder; deleting the base from the knowledge page is allowed and the next run provisions a fresh one.
- Runs that started before the flow declared
templatekeep their own base.
If your workflows address the base directly. A canvas step that reads
<start.processFlow.knowledgeBaseId> gets the shared base under
scope: "template", so it sees every run's folder unless it narrows itself.
The run's own folder path is exposed as
<start.processFlow.knowledgeBaseFolder>. The value is
instances/<date>-<run name>-<id>, the folder every page of the run lives at
or under, so it can be pasted as-is into the Knowledge block's Folder (path
scope), which narrows both Search and Add Data
(Knowledge). An agent's knowledge tool is not narrowed for
you, so for an AI step prefer the declared-input grounding
(metadata.retrieval), which is folder-scoped for you. A workflow that starts
a run with create_process_instance gets the same two values as that block's
knowledgeBaseId and knowledgeBaseFolder outputs
(Platform blocks).
scope: "instance" (the default) keeps today's behaviour.
Carry earlier runs forward (knowledge.priorCycles)
A new run can build on what earlier runs of the same flow decided. By default it carries over the five most recent completed runs in the same workspace environment:
- its AI steps receive relevant excerpts from those runs, and
- its steps' shared conversations receive each run's closing summary and can search those runs' pages, so a question such as "what did we decide last time?" is answered from — and cites — the earlier runs.
Set the number per flow:
template: {
// …
knowledge: {
scope: "template",
priorCycles: { maxCycles: 3 },
},
}maxCyclesis an integer from0to10;0turns carry-over off.- Leave
priorCyclesout to follow the platform default (currently 5). - The value is frozen into each run when it starts, so publishing a new version of the extension does not change a run that is already in progress.
- A single AI step can still override it with
metadata.priorCycles.maxCycles, or opt out entirely withexcludeContext: ["priorCycles"]. - Works with either
scope. Runs still in progress, cancelled runs, and pages classified above the reader's clearance are never carried over.
Instance attributes (template.attributes)
Declare the facts every run of the flow carries — who the customer is, which case this is about — once, on the template. Each attribute is an ontology property: a slug, a display name, a type, a cardinality, and a classification.
import { defineAttribute } from "@scrydon/sdk-authoring/process-flows";
template: {
// …
attributes: [
defineAttribute({
slug: "customerEmail",
displayName: "Customer email",
scalarType: "string",
cardinality: "required",
classification: "confidential",
piiFlags: ["email"],
}),
defineAttribute({
slug: "caseReference",
displayName: "Case reference",
scalarType: "string",
cardinality: "optional",
classification: "internal",
}),
],
}- Types are the ontology scalar types:
string,int,float,bool,uuid,timestamp,date,json,bytes.cardinality: "many"takes a list. - Values are checked when they are written — when a run starts (including
the seed of a
create_process_instanceblock) and when an operator edits them. A required attribute that is missing, or a value of the wrong type, is refused with HTTP422and codeinvalid_instance_attributes, listing every problem. Keys you did not declare are still accepted, so flows written before attributes existed keep working. - Classification decides who sees a value.
public,internal,confidentialandrestrictedmap, in that order, to the clearance levelsUNCLASSIFIED,RESTRICTED,CONFIDENTIALandSECRET. An AI step only receives an attribute its clearance covers; the rest are shown in the step's Prompt & Context panel as withheld. Leave the classification out and the attribute is treated asUNCLASSIFIED—extension validatewarns when such an attribute carriespiiFlags. - Classification caps what a person sees, as it caps what a model sees.
The run's detail (
GET /api/process-flows/:id) returns incontextonly the attributes within the reader's own clearance; arestrictedattribute is absent for a reader below SECRET, and a member with no clearance reads at the lowest rank. When an operator savescontext, an attribute they could not see and did not send keeps its stored value. The list of runs in an environment (GET /api/process-flows) returns no attribute values at all.
Reading an attribute
| Where | How |
|---|---|
An @system AI step | Automatically: the step's prompt gets an Instance Attributes block with every value it may see. |
| A canvas workflow step | <start.processFlow.attributes.customerEmail> |
| A distribution's external recipients | externalRecipients.recipients: [{ kind: "attribute", key: "customerEmail" }] — see Action types |
A value that is missing, withheld, or does not match its declaration is never
replaced by an empty string. On a canvas step the reference stays as literal
text, and the Prompt & Context panel lists the attribute with the reason. A
canvas reference to an attribute the template does not declare is flagged
there too. An AI step can opt out entirely with
excludeContext: ["attributes"].
The seed context, and what agents see of it
A run's context is what it was started with, for example the context of
the create_process_instance block that opened it. The instance page shows it
read-only under Run context, closed by default with a count of values. Keys
starting with _ are platform bookkeeping and are not listed, and an attribute
above your clearance is absent, as it is in the API.
Agents receive only the keys the template declares in attributes. A key
you seed without declaring it is stored and shown on the instance page, but it
never reaches an agent: not an AI step's Instance Attributes block, and not
a <start.processFlow.attributes.*> reference. To hand a complaint's subject
and body to the flow's agents, declare both:
attributes: [
defineAttribute({
slug: "subject",
displayName: "Subject",
scalarType: "string",
cardinality: "required",
classification: "internal",
}),
defineAttribute({
slug: "body",
displayName: "Message body",
scalarType: "string",
cardinality: "optional",
classification: "internal",
}),
],Then seed them under the same keys, { "subject": "<start.body.subject>", "body": "<start.body.message>" }. A reference inside a JSON string is
inserted escaped, so the line breaks and quotes an email body almost always
contains stay inside the value
(Seeding free text into context).
As with any attribute, an AI step receives a value only when its clearance
covers the attribute's classification.
Allowed values (oneOf)
An attribute can list the only values it accepts. A value outside the list is
refused on write, like a value of the wrong type (422,
invalid_instance_attributes), and conditions use the list to check that each
branch can be reached.
defineAttribute({
slug: "workMode",
displayName: "Work mode",
scalarType: "string",
oneOf: ["remote", "hybrid", "office"],
});oneOf works with string, int, float, bool, uuid, date and
timestamp attributes. Each entry must match the attribute's type, and the list
cannot repeat a value. extension validate reports a problem with the list as
invalid_attribute_one_of.
Conditional tasks (condition)
Some tasks only apply to some runs. For example, ship a laptop only to a remote
hire, or ask for a second approval only above a threshold. Give such a task a
condition. It is a boolean expression over the run's
instance attributes:
taskTemplates: [
{ slug: "ship-laptop", stageSlug: "onboard", name: "Ship laptop",
condition: 'workMode == "remote"' },
{ slug: "issue-badge", stageSlug: "onboard", name: "Issue badge",
condition: 'workMode != "remote"' },
{ slug: "legal-review", stageSlug: "review", name: "Legal review",
condition: "contractValue >= 50000 && needsVisa == false" },
{ slug: "wrap-up", stageSlug: "onboard", name: "Wrap up",
dependsOnTaskSlugs: ["ship-laptop", "issue-badge"] },
]The expression language is small:
- attribute slugs as names
- string, number and boolean literals
- comparisons (
==,!=,<,<=,>,>=) &&,||and!- a few value methods, such as
["a","b"].includes(region)
It runs in a sandboxed interpreter, never as code. A slug that contains -
reads as a subtraction, so give conditioned attributes slugs without a hyphen.
How a run treats a conditional task:
| The condition is… | The task… |
|---|---|
| true | runs normally. |
| false | is skipped. It shows as Skipped in every view, and no one can work its actions. For its stage and for the tasks that depend on it, a skipped task counts as done, so a branch the run does not take never blocks the run. |
| not decidable yet (an attribute it reads has no value) | stays locked. It is neither skipped nor opened until an attribute write decides it. |
- Required tasks on the taken branch are enforced as usual. Only the tasks whose condition is false stop counting.
- Conditions are re-checked on every attribute edit. A task that has not been completed follows the new values: an open task can become skipped, and a skipped task comes back into the flow. Clearing a value does not decide anything, so nothing changes.
- Completed work is never undone. A value that changes after a task was completed leaves that task completed.
- A closed stage stays closed. If a skipped task's stage has already
finished, a later edit that makes its condition true does not reopen the
stage. The run's activity log records this as
task_revival_refused. Reopen the stage to redo it. - Revival does not re-lock work already started. Tasks that unlocked because a dependency was skipped stay open when that dependency comes back.
- A manual move is an override. Moving a skipped task on the board brings it back into the flow, the same way a move opens a locked one.
extension validate checks every condition:
| Code | Severity | Meaning |
|---|---|---|
invalid_task_condition | error | The expression does not parse, or fails for some allowed value. |
undeclared_condition_attribute | error | It reads a name that template.attributes does not declare. |
unreachable_task_condition | error | No allowed value makes it true, so the task could never run. |
task_condition_always_true | warning | Every allowed value makes it true, so it never skips. |
task_condition_discloses_classified_attribute | warning | It reads a confidential or restricted attribute. Anyone who can see the flow can see whether the task was skipped. |
Reachability can only be checked over values the validator can list: bool
attributes and attributes with oneOf (up to 256 combinations). A condition
over an open-ended number or string, such as contractValue >= 50000, is
checked for syntax and declared names only.
Due dates (due)
dueOffsetDays: 5 says "five days after the run started". When the schedule
is organised around a date the run carries — a new hire's start date, a
contract signature, a statutory deadline — declare that date as an
instance attribute and anchor the
task (or action) on it with due:
template: {
timezone: "Europe/Brussels", // optional — see below
attributes: [
defineAttribute({
slug: "startDate",
displayName: "Start date",
scalarType: "date",
cardinality: "required",
classification: "internal",
}),
],
taskTemplates: [
{
name: "Prepare desk",
due: {
anchor: { kind: "attribute", key: "startDate" },
offset: { value: -1, unit: "days" }, // the day BEFORE the start date
time: "10:00", // at 10:00 local time
},
},
{
name: "Kick-off call",
due: { anchor: { kind: "instance_start" }, offset: { value: 2, unit: "hours" } },
},
],
}- Anchor —
{ kind: "attribute", key }names a declareddateortimestampattribute with a single value;{ kind: "instance_start" }is the moment the run started.extension validaterefuses an anchor on an undeclared attribute, on any other type, or on amanyattribute, and a template that declares bothdueanddueOffsetDays. - Offset — signed, so a negative value is work before the anchor.
daysandweeksmove the calendar date and keep the local time of day, even across a daylight-saving change;hoursandminutesare elapsed time. With no offset the task is due at the anchor. At most ten years either side. - Time —
"HH:MM", local, and only with adays/weeksoffset (or none). Adateanchor withouttimeis due at the end of that day, so a task due "on the start date" is not overdue at one minute past midnight. - Time zone — the calendar arithmetic runs in
template.timezone(an IANA name such asEurope/Brussels). Leave it out and the run uses the organisation's time zone (Settings → Organization → Time zone), thenUTC. The zone is fixed when the run starts: changing the organisation's time zone later does not move a running flow's due dates. - A missing anchor is refused. Starting a run without the anchor
attribute, or with a value that is not a date, fails with HTTP
422, codeinvalid_instance_attributes, and an issuedue_anchor_unresolvednaming the attribute — even when the attribute itself isoptional. No run is created. - An anchor in the past is honoured. A start date of last week makes the task overdue immediately; the due date is never clamped to "now".
- Editing the anchor moves the due date. When an operator edits the attribute on a running flow, every task and action anchored on it that is still open is re-dated. Completed and archived tasks, and completed or skipped actions, keep their date. A task skipped by its condition is re-dated too, so it carries the current deadline if a later edit brings it back; while it stays skipped, its date notifies no one. A task whose due date moves is a new deadline: if it passes the new date, its people are notified again.
dueOffsetDays keeps working unchanged and is still measured in UTC from the
moment the run started.
Case clock (caseClock)
For a time-critical flow ("alert to released report in 60 minutes") the case
header can show how much of the target time has gone. Mark one task template
with caseClock: true, next to a due anchored on the case start:
{
slug: "release",
name: "Release the report",
due: { anchor: { kind: "instance_start" }, offset: { value: 60, unit: "minutes" } },
caseClock: true,
actions: [{ name: "Approve the release", actionType: "approval", isRequired: true }],
}No component or block is needed. The platform counts from the case start
(startedAt, else createdAt) towards that task's due time, and shows the
result in the case header only, for this flow only:
- It runs until the first of two things: the marked task is completed (for an approval, approved), or the flow ends (completed, closed as declined, or cancelled). A rejection that sends work back to an earlier stage does not stop it. A flow put on hold does not stop it either.
- It stops when the marked task's required actions are done. The time it stops at is when the last of those actions finished. Optional actions do not count, so finishing one later does not move the stop time. Completing a required action that is already completed again moves its finish time, and the clock's stop moves to that later moment.
- Colours and words — green ("Within target") while under 75% of the target, amber ("Close to the target") from 75% up to and including 100%, red ("Over target") once past it. The state is written next to the time, so it does not depend on colour.
- After it stops it shows the final time, green if it stopped within the target and red if it stopped past it.
- Long targets read in hours and days: a 48-hour target shows as
2d 0h, not as minutes. - A reopened case keeps counting from the original start. The clock measures the total time of the case, including the time it was closed.
- A marked step that a task condition skips never completes, so the clock stops only when the flow ends.
- A cancelled case stops at the moment it was cancelled. A case cancelled before this recorded the moment shows no clock.
extension validate refuses a flow with more than one caseClock task, a
caseClock task whose due is anchored on an attribute rather than
instance_start (the legacy dueOffsetDays counts as instance_start), and a
caseClock task that has no required action (isRequired: true). The
marked task also gets an ordinary due date, so the worklist shows it as
overdue when it passes.
The object a task is about (entityType)
A task template can declare which kind of ontology object it is about with
entityType, set to an Object Type's API name:
{ name: "Configure FI", entityType: "sap_module", actions: [/* … */] }entityType names the type only. Starting a run links no object: every
task created from the template keeps entityType and starts with no linked
object, because the platform never guesses one from the task's name.
Someone on the run links the object from the task's Linked Entity section: they pick a bound Object Type (the declared one is preselected), then search for one of its objects by name or ID. The task then stores the object's ID, its type, its label and the ontology it came from. Unlink entity clears the object and keeps the declared type.
Once a task is linked:
- Open in Ontology Designer opens the object in Analytics' instance graph, selected, with its neighbors loaded. If you cannot see the object, the graph says so.
GET /api/process-flows/{id}/tasks/{taskId}/related-instanceslists other runs whose task is linked to the same object: the same type and the same ID. A task with no linked object lists none.- An embedded workflow reads the object as
<start.processFlow.entity.*>(see Embedded workflows).
The task update API enforces the same rule: an entityRef sent without its
entityType is rejected with HTTP 400, and so is clearing entityType
while an entityRef stays.
Task DAG validation
Tasks can declare:
dependsOnTaskSlugs— other tasks that must complete firstunlockAfterTaskSlug+unlockDelay— a soft gate that opens N days/weeks after a predecessor finishes
A task with an unlockDelay becomes active on schedule, whether or not
anyone opens the flow. A background sweep runs hourly and activates every task
whose delay has elapsed, dispatches its automatic actions, and notifies the
approver when the task carries an approval gate.
This matters most at the end of a flow, where delayed tasks usually live: a day-7 check-in or a day-30 review is typically the last task in its instance, so there is no later task to trigger it and no reason for anyone to reopen a flow whose visible work is finished. Expect the task to appear within an hour of its due moment.
The inspect CLI runs DFS cycle detection (WHITE/GRAY/BLACK) and fails closed on any cycle. Stable error codes:
| Code | Meaning |
|---|---|
task_cycle | A dependency edge participates in a cycle |
unknown_dep_slug | dependsOnTaskSlugs references an unknown task slug |
stage_unknown | taskTemplates[].stageSlug doesn't match any stage |
Extensions a flow needs
You do not list the extensions a flow depends on — the platform derives
them. Every block type the flow's workflows use counts: a block dropped on the
canvas (microsoft-teams) and every tool an agent block may call (its tool
list). That includes workflows the flow reaches but does not define inline —
ones shipped as separate workflow content of the same extension.
Each derived block type that belongs to an extension is checked in the workspace the flow is turned on for. It can be unusable for three reasons:
| Reason | Meaning | What an admin does |
|---|---|---|
not_installed | The extension that provides the block is not installed in the organization | Install it from Settings → Platform → Extensions |
not_connected | It is installed, but no account is connected for it | Connect an account on the extension's Connections tab |
not_entitled | It is installed and connected, but not enabled for this workspace | Enable it for the workspace |
A block the platform runs without any connection is never reported: one from an extension enabled by default that needs no credential and no required setting — most of the built-in Scrydon blocks. That holds until an admin adds a connection for it; from then on that connection is checked like any other.
What happens depends on when the check runs:
- Turning a flow on in a workspace is refused. Turn on in Extensions fails with a message naming each block and its reason, and the flow stays off. Fix what it names, then turn it on again. Installing an extension organization-wide is never refused — there is no workspace to check yet.
- Updating or repairing a flow only reports. A flow that is already on in a workspace keeps updating to a newer release, and keeps being repaired, even while a block is unusable. Refusing either would not stop the flow failing at that step; it would only block the fix. The shortfall is still checked and logged. Removing a flow is never checked.
- Starting a run only warns. An account can be disconnected after the flow
was turned on. The run still starts; the check records an
extension_requirements_unmetentry in the run's activity log naming each block and its reason, and the run's page shows a compact warning above the board listing them, with a link to Settings → Platform → Extensions, where an org admin fixes each one, so the step that later fails already has its explanation. The warning reflects what was unmet when the run started: fixing the extension lets later steps succeed, but the notice on that run stays as the record.
template.requirements.integrations does not drive the check — the derived set
does. Keep it as a note for readers, naming extensions (microsoft), not
distribution channels. extension build fails when an entry is a channel
(extension_is_distribution_channel: slack, email, which are configured per
distribution action) or when the template reaches no workflow at all
(extension_unreachable_no_workflows), so nothing it declares can be used.
Installing an archive that was built before these checks existed only
warns with the same code, so an extension that installed before keeps
installing; the next build fails until the entry is fixed.
Operators can switch the refusal off with the Helm value
auth.enforceProcessFlowRequirements: false (it sets
SCRYDON_PROCESS_FLOW_REQUIREMENTS_ENFORCE=false on api-platform; see the
Helm reference). The check still runs and logs
what it would have refused.
Derive the set yourself
The derivation the platform runs is exported, so a build script or a CI check can show what a flow will be checked for before it is uploaded:
import {
collectBlockTypesFromWorkflowDefinition,
deriveRequiredBlockTypes,
} from "@scrydon/sdk-authoring/process-flows/derive-block-types";
deriveRequiredBlockTypes(manifest); // sorted, de-duplicated block types of the inline workflows
collectBlockTypesFromWorkflowDefinition(workflow.definition); // one workflow, e.g. a separate workflow entryBoth are pure: no network, no platform state. They return block types
(microsoft-teams), not extensions; the platform maps each block type to the
extension that provides it when it checks.
Stability. @scrydon/sdk-authoring/process-flows/derive-block-types is
public SDK surface and follows the package's semver: the subpath, the two
function names and their signatures change only in a major release. The set
they return may grow in a minor release when the workflow schema gains a new
place to name a block type, because it has to keep matching what the platform
checks.
Platform capabilities a flow needs
Extensions are derived; platform capabilities — an AI language model,
speech-to-text, web search — are declared. List them in
template.requirements:
requirements: {
// The flow cannot do its job without these: turning it on is refused.
capabilities: ["stt"],
// Steps that use these are optional: the flow runs without them.
optionalCapabilities: ["llm"],
},| Field | When it is unmet |
|---|---|
capabilities | Turning the flow on is refused, like an unusable extension. Starting a run only warns. |
optionalCapabilities | Nothing is refused. The run's page shows an information notice: the steps using it cannot produce a result, but they are optional, so they do not hold the run up. |
Declare a capability as required only when a required step depends on it —
for example an AI step with isRequired: true. If every AI step is optional,
put llm in optionalCapabilities, or the flow is refused in workspaces that
could run every step it needs.
The names are the platform's capabilities: llm, embedding, stt, tts,
image, video, ocr, moderation, webSearch, geo. extension build
fails on any other name (unknown_capability_requirement); installing an
archive built before this check only warns, and the platform ignores a name
it does not know.
A capability is checked exactly as a step would use it: the flow is ready when the platform would pick a provider for it in that workspace and environment. It can be unmet for three reasons:
| Reason | Meaning | What an admin does |
|---|---|---|
not_configured | No installed extension provides it with a usable connection, and no default is set | Set a default under Settings → Extensions → Models & defaults |
not_entitled | A provider exists for the organization, but none is enabled for this workspace | Enable a provider for the workspace |
disabled_by_policy | The organization switched the capability off | Switch it back on under Models & defaults, if that is intended |
The same refusal message names both kinds — extensions by block, capabilities in
words ("AI language model (no default configured)") — and links to Models &
defaults. The auth.enforceProcessFlowRequirements switch above covers
capabilities too. If the platform cannot check a capability (for example a
transient fault), the flow is not refused for it.
Troubleshooting: "Flow refused: configure a default LLM"
Turn on fails with AI language model (no default configured). The flow
declares llm in requirements.capabilities and nothing in the organization
provides one for this workspace.
- An organization admin opens Settings → Extensions → Models & defaults (the link in the message goes there).
- Install or connect a model provider, then choose it as the LLM default. If the row says the capability is off, the organization turned it off on purpose: switch it on only if that is intended.
- If a default exists but the reason is not enabled for this workspace, enable the provider for the workspace.
- Turn the flow on again.
If you author the flow and its AI steps are all optional, move llm to
optionalCapabilities instead: the flow then turns on everywhere, and where no
model is set up those optional steps produce no result without holding the run
up.
Build, inspect, upload
bunx @scrydon/sdk-authoring extension validate src/extension.tsbunx @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/acme-kickoff-1.0.0.scrydon-extension.tar.gzSign in as an org admin and open Settings → Platform → Extensions → Sources in the platform app. Click Upload an extension (one-off) and drop your .scrydon-extension.tar.gz. When the extension ships a workflow, the dialog will ask you to pick a workspace environment — the workflow definitions install into that environment. Ontology and process-flow content install at the org level regardless.
The platform upload flow is the canonical entry point for all extension content kinds since ADR 2026-05-21 Unified extension upload surface.
After the extension is installed, a workspace admin opens Extensions in the agentic sidebar and clicks Turn on on the Process Flow row to make it available in that environment. Once it is on, any workspace member can start a new Process Flow from Flows in Horizon, where flows are operated. See Extensions.
For automation, the underlying agentic route still accepts uploads. Pass workspaceEnvironmentId only if the extension ships workflow content; otherwise it's optional.
curl -X POST "$AGENTIC_URL/api/extensions/import?organizationId=$ORG_ID&workspaceEnvironmentId=$ENV_ID" \
-H "Cookie: $SESSION_COOKIE" \
-F "file=@dist/acme-kickoff-1.0.0.scrydon-extension.tar.gz"Extension layout
<extension>.scrydon-extension.tar.gz
├── extension.json # top-level extension manifest (ExtensionArchiveManifestSchema)
├── ontology/
│ └── manifest.json # OntologyManifestSchema — may be empty for flow-only extensions
├── workflow-<slug>/ # optional — zero or more workflow subdirs
│ └── manifest.json # WorkflowManifestSchema
└── process-flow/
├── manifest.json # ProcessFlowManifestSchema
├── assets/ # optional — JSON / image assets referenced by the flow
│ ├── icon.svg
│ └── preview.png
└── meta/ # optional — non-functional metadata (e.g. sbom.cdx.json)Each subdir round-trips as a valid standalone artifact. Allowed asset extensions: .json, .svg, .png, .jpg, .jpeg, .gif, .md. Symlinks, hardlinks, absolute paths, and path traversal (..) are rejected by the inspector.
When an extension ships workflows, the importer runs them in their own phase before process-flow install — that way every workflowSlug on an action template resolves to a materialized workflowId before the process flow lands in the database. See Workflows for the slug-rebind contract.
Security caps
The runtime inspector reads the archive in streaming mode (tar.list parser-only — never extracts to disk) and enforces hard caps:
| Cap | Limit | Failure code |
|---|---|---|
| Compressed archive | 5 MB | archive_too_large |
| Total uncompressed | 10 MB | total_size_exceeded |
| Per-file size | 5 MB | file_too_large |
| File count | 200 | too_many_files |
| Symlink / hardlink | n/a | symlink_rejected |
| Absolute path | n/a | absolute_path_rejected |
| Path traversal | n/a | path_traversal_rejected |
| Disallowed top-level dir | n/a | disallowed_path |
| Disallowed extension | n/a | disallowed_extension |
The /import route returns 413 for size violations and 400 for structure or graph violations.
Why no executable code
process flows are declarative. Custom logic is referenced — by workflowId (a workflow already living in the tenant) or by workflowSlug (a workflow shipped alongside the template in the same Extension) — not archived. Even when an Extension ships workflows in workflow-<slug>/ subdirs, the on-disk representation is pure JSON; the runtime block catalog lives in the platform. This keeps the surface deterministic, reproducible, and uploadable by non-engineering authors.
Where to next
Per-type reference — one page per actionType with examples, metadata fields, and the runtime UX.
After installing an extension, workspace admins turn Process Flows on for an environment and deploy Workflows from Extensions.
Download ready-made process-flow archives — ISO Quarterly Review, ISO Yearly Review.
Process flows produce typed instances against the ontology — author the type system first.
Ship actionType: workflow actions and the workflows they reference in the same Extension via workflowSlug.
Workflows referenced by actionType: workflow are built from blocks — many of which come from extensions.