Action Types
The twelve Process Flow action types — what each one is for, how to author it, what the assignee sees, and the schema fields that drive it
An Action Template is the thing a Task asks someone (or the system) to do. A Task can carry one or many actions; they render side-by-side in the task detail panel and complete independently. Picking the right actionType is the most consequential authoring decision in a Process Flow — it determines what the assignee sees, what data the platform records, and which downstream gates and extensions fire. When a Task carries exactly one action, the kanban board shows that action's type chip (icon + color) directly on the task card — single-action tasks read as "the task is the action" at a glance.
This page expands on the summary table on the Process Flows overview — one section per type, with a copy-pasteable example pulled from a real extension in the SDK examples, and a one-line note on what the assignee actually sees in agentic.
All twelve action types are defined as a Zod enum in @scrydon/sdk-authoring/process-flows. The schema is the single source of truth — the authoring helpers (defineAction) and the runtime both consume it. Unknown values are rejected at extension validate time, before an archive is built.
Anatomy of an action
Every action shares the same envelope, regardless of type:
import { defineAction } from '@scrydon/sdk-authoring/process-flows'
defineAction({
slug: 'rollout-sign-off', // optional — stable handle, unique per template
name: 'Approve rollout plan', // required, 1–200 chars
description: 'Sign off on the customer rollout schedule.',
actionType: 'approval', // required, see below
isRequired: true, // required — drives gate completion
executionMode: 'manual', // optional — see below
dueOffsetDays: 5, // optional — relative due date
// or due: { anchor, offset, time } — anchored on an attribute, see Due dates
metadata: { persona: 'champion' }, // optional — per-type config bag
// workflowId / workflowSlug only used by actionType: 'workflow' — mutually exclusive
})The fields that vary per type all live on metadata. Each section below calls out the metadata keys that the runtime reads for that specific type.
slug is optional. It gives the action a stable handle other declarations can aim at — today an approval output surface's targetActionSlug — without depending on the display name, which you can reword freely. Slugs are lowercase letters, digits and hyphens, and must be unique across all of the template's stage and task actions.
executionMode
A single optional field controls when an action runs:
| Mode | Meaning |
|---|---|
manual (default) | The assignee clicks "Mark complete" / "Approve" / "Upload". Used for every human-driven action. |
automatic | The runtime fires it as soon as upstream dependencies are satisfied. With no declared dependencies that moment is stage entry — the action runs as soon as its task becomes active in the stage. Used almost exclusively with actionType: "workflow" for AI / agent steps. |
on_entry | Fires the instant the stage is entered, before any task UI is shown. Used for staging-area workflow steps that prep data for the human steps that follow. |
Pick manual whenever a human is the principal. Pick automatic when the platform should drive the step the moment it can. Pick on_entry only when the step must run before the stage is interactable.
Automatic execution applies to workflow actions only. Other action types
(distribution, checklist, voice triggers, …) keep their own dispatch paths
regardless of executionMode. Stage entry counts every way a task reaches a
stage: instance creation, an automatic or approved transition, a manual move,
and timed unlocks.
Only document and approval actions show the AI Recommendations panel —
the two types where the assignee makes a judgment call the knowledge base
can inform. Other action types do not render a Recommendations section.
Done in another system — metadata.externalSystem
Some steps are done outside Scrydon: a checklist ticked in a case system, a form filled in an ERP. Declare that system on a manual action and the assignee sees where the work happens before they start:
defineAction({
name: "Checks on the request",
actionType: "checklist",
isRequired: true,
metadata: {
checklistItems: ["Business registration number is active", "Liability insurance is valid"],
externalSystem: { name: "PermitDesk", url: "https://permitdesk.example.com/cases" },
},
});| Field | Rule |
|---|---|
name | Required. The system's name, 1–80 characters (trimmed). |
url | Optional. An https:// link with no username or password. Any other scheme (http:, javascript:, data:, …) fails validation. |
What the assignee sees, the same in the Wizard and in Kanban:
- "In PermitDesk" under the task title.
- A note at the top of the task that says the work is done in PermitDesk and that Scrydon cannot see it, so it does not check the work. With a
urlit carries an Open in PermitDesk button, which opens the link in a new tab. - The system's name under the task in This step.
It is a label and a link only: Scrydon does not call the system, and the assignee still completes the step in Scrydon. It applies to manual actions (the default mode) of any type. extension build fails when an automatic or on_entry action declares it — Scrydon runs those steps, and a workflow step's system is shown from the connection its blocks use, so nothing has to be declared. An install of an already-built archive only warns. When stored data does not validate, the step shows no system rather than a broken link.
checklist
The lightest-weight action. The assignee sees a single button labeled "Mark complete" and clicks it when done. No artifact, no metadata captured beyond the completion event itself.
Use for: confirmations, attestations, "I did X" acknowledgements where the audit trail just needs the actor and the timestamp.
Schema fields used: name, description, isRequired, executionMode, metadata.persona, metadata.checklistItems (optional).
Example (from the NATO multi-source detection extension — an operator confirms a sensor flag):
defineAction({
name: 'Confirm UAV detection',
description: 'Sensor operator confirms UAV radar reading and flags the track for fusion.',
actionType: 'checklist',
isRequired: true,
executionMode: 'manual',
metadata: { persona: 'operator' },
})What the assignee sees: a card titled "Confirm UAV detection" with a single primary button. Completion writes an action_completed activity-log entry tagged with the actor's id.
Checklist items
When a step is really several confirmations, list them in metadata.checklistItems. The assignee then sees one box per item instead of a single "Ready to complete" box, and Mark complete stays disabled until every item is ticked.
defineAction({
name: 'Log the report and stamp T0 (UTC)',
actionType: 'checklist',
isRequired: true,
metadata: {
persona: 'security-lead',
checklistItems: [
'Report received and logged: source, reporter, report id and contents',
'T0 stamped in UTC: the moment exploitation was corroborated, not the report time',
'CRA 24h early-warning deadline recorded',
],
},
})- Between 1 and 25 items, each 1–300 characters, and no duplicates. A tick is stored by the item's text on the action's
content.checkedItems, so two identical items could not be told apart. checklistItemsis valid only on achecklistaction. Items on any other action type, or a list that breaks the rule above, failextension build(checklist_items_not_on_checklist,checklist_items_invalid). An archive that already carries one still installs, with the same code as a warning, because earlier manifests could carry the key before it meant anything.- The server enforces the list, not only the button. Completing the action while an item is unticked returns HTTP
409withcode: "checklist_items_incomplete"and theuncheckedItemsstill open. - A malformed
checklistItemslist (for example duplicates, or an empty array) never falls back to the single box: the step cannot be ticked or completed, the panel says the items are malformed, and completing it returns HTTP409withcode: "checklist_items_invalid". Fix the template. - Each tick is saved on its own and merged with the ticks already stored, so two people ticking different items of the same step at the same time both keep their tick.
- The ticks of a completed step are the ones its completion was judged on, so changing them returns HTTP
409withcode: "checklist_action_completed". Mark the step pending first. - Ticking the step's box in the task list opens the step's panel, because the items are ticked there.
- A running instance keeps the template it started from. Items you add to a template apply to instances started after the change.
document
A long-form text artifact the assignee must produce. The platform opens a markdown editor inside the task; the resulting page is stored in the instance's workspace knowledgebase and indexed for retrieval by downstream AI steps.
Use for: plans, summaries, write-ups, meeting agendas, minutes — anything where the content is the deliverable, not a file.
Schema fields used: name, description, isRequired, executionMode, metadata.persona, metadata.promoteToCorpus (optional — one of summary / minutes / decisions / action-items; when the stage completes, the document is additionally published to the instance KB's instances/<instance>/canonical/ folder under that slug, where downstream KB-promotion steps look for it).
Example (from the AI Boardroom template — the secretary drafts the agenda):
defineAction({
name: 'Create meeting agenda',
actionType: 'document',
isRequired: true,
executionMode: 'manual',
metadata: { persona: 'secretary' },
})What the assignee sees: a card titled "Create meeting agenda" with an "Open editor" button. Completion is implicit — the action is considered done once the page is saved with non-empty content.
Where the document lands: while being edited, the content lives on the action itself. When the action is marked completed, the platform converts it to Markdown and writes it as a real page in the process flow's instance knowledge base, under instances/<instance>/documents/<stage>/ with the action's name as the page title. Completing the action again after edits updates the same page in place. From there it is queryable via the instance's KB and can ground later actionType: "workflow" steps via metadata.retrieval. Process flows created before instance knowledge bases existed get one provisioned automatically the first time a document, transcript, or canonical page is written.
Document actions show an AI Recommendations panel: the platform reads the instance and template knowledge bases, meeting extractions, and the linked entity, and generates readable suggestions for what the document should cover. The knowledge bases are read with the same deep, agentic retrieval the Knowledge block's Search uses — it works through every uploaded document (searching, listing, and reading full contents), not just the top few search hits, so a document you uploaded is reflected in the suggestions even if its topic doesn't match the step's name. The same deep grounding backs Generate with AI on the document body. Each suggestion is kept short: a title, a one-sentence summary, two or three key points, and the full reasoning behind it. A Reasoning Details expander shows how each recommendation was grounded; Refresh regenerates against the latest knowledge. Recommendations require an LLM extension to be configured for the organization.
Each recommendation shows its title and a one-line summary, and the most relevant ones come first with a Most relevant tag. Read more opens its key points and sources, and Full reasoning the complete text; one recommendation is open at a time. While the panel is narrow, recommendations sit under the action's output. Widen the panel (drag its edge, or press Show beside) and they move into a column beside it. The width you choose is remembered.
Lowering recommendation variance — metadata.selfConsistency
On a high-stakes step, one sampling of a model can fixate on a single reading of
the evidence. metadata.selfConsistency.samples asks the platform to draw that
many independent, differently-framed completions in parallel and consolidate
them into one answer.
{
name: 'Sign off consolidated advisory',
actionType: 'approval',
isRequired: true,
metadata: {
persona: 'approver',
selfConsistency: { samples: 3 }, // 1–5; omit or 1 = a single completion
},
}- Applies only to the recommendation-eligible types —
documentandapproval. - Omitted, or
samples: 1, is the default and behaves exactly as before. - Capped at 5. Each sample is one metered LLM call, plus one more to consolidate,
so
samples: 3costs roughly four completions instead of one — enable it on the steps a human signs their name under, not across a whole template. - A sample that fails is dropped rather than failing the run, and if consolidation itself fails the first successful sample is shown instead.
- The Reasoning Details expander says "Consolidated from N analyses" only when consolidation actually produced the answer. A run that drew N samples but fell back to a single one shows no note — the line is provenance for what you are reading, not a record of what was paid for.
- Variation comes from the prompt, not
temperature— reasoning models that reject a temperature setting still get genuine diversity.
The advisory-consolidation example template ships this on its sign-off step.
Questions and refinement in the Wizard: the right-hand Shared action conversation lets every authorized participant ask questions about the current document and its governed sources. A normal question never changes the document. To use an answer as an instruction, choose Apply as refinement explicitly; the platform reauthorizes the action and updates the document through the same refinement path. Outside the Wizard, Improve with Cortex next to Regenerate opens the same conversation in a side sheet.
The gate: "validate-and-rerun" flag on a document action turns it into a soft gate for an AI step that follows: after the human edits the document, the downstream workflow re-runs against the edited content. Useful for "AI drafts → human revises → AI re-runs" loops.
A document edited as a list — metadata.editor: "list"
Some documents are a list, not prose: a meeting agenda, the points to raise
with a supplier, the order of a run sheet. metadata.editor: "list" gives the
assignee an ordered list instead of the Markdown editor. Each item is a
title, which is required, and an optional plain-text description. Items
can be added, edited, removed and reordered by dragging.
import { DOCUMENT_EDITOR } from '@scrydon/sdk-authoring/process-flows'
defineAction({
slug: 'agenda',
name: 'Agenda',
actionType: 'document',
isRequired: true,
metadata: { persona: 'secretary', editor: DOCUMENT_EDITOR.LIST },
})- Only a
documentaction takeseditor. On any other type the build fails withmetadata.editor is only supported on a \document` action`. - The list is still a document to everything that reads one. On completion it is written to the instance knowledge base as a numbered Markdown list (each description under its title), so grounding, the closing summary and a distribution PDF read it the same way as a free-text document.
- The assignee completes it with Mark complete, or Save & complete when there are unsaved changes. An item without a title cannot be saved.
- A list holds at most 100 items, with titles up to 200 characters and descriptions up to 2,000.
- Editing a completed list marks the steps that read it out of date and offers to re-run the AI steps among them that have already run. See Managing runs.
To have an AI step write a first draft into the list, point its
document output surface at the list's slug.
Subject reports
A report about one object — a vessel, a supplier, an asset — should say what
the ontology knows about that object, under that object's own classification.
metadata.subjectReport fills a prose document from a Markdown template with
facts the platform reads through the governed ontology path, as the person
the step drafts for. Nothing is generated by a model: a placeholder is
replaced by the governed value, or by a visible gap when no source holds one.
defineAction({
name: 'Heads-up report',
actionType: 'document',
isRequired: true,
executionMode: 'automatic', // or leave it manual: a person presses Draft
metadata: {
subjectReport: {
// The subject is named by the detection that opened the flow — read from
// the platform's dispatch receipt, never from the flow's editable context.
subject: {
objectType: 'Vessel',
from: { kind: 'detection', evidence: 'entityId', property: 'mmsi' },
},
related: [
// The alert the detection fired on.
{ kind: 'detection', as: 'alert', objectType: 'SecurityAlert', evidence: 'observationId', property: 'alertId' },
// The newest assessment that references this vessel.
{ kind: 'referencing', as: 'risk', objectType: 'RiskAssessment', property: 'vesselMmsi', subjectProperty: 'mmsi', latestBy: 'assessedAt' },
// Objects one declared link away.
{ kind: 'linked', as: 'owner', linkType: 'VesselOwner' },
],
// A filter on what this report is built from, by each binding's
// `bindingContext.sourceSystem`.
sources: { allow: ['vessel-registry', 'ssa-gateway'] },
// Where the alert was raised, written as {{alert.position}} in the shared
// coordinate format (WGS 84, degrees and decimal minutes).
positions: { alert: { latitude: 'latitude', longitude: 'longitude' } },
template: [
'| Vessel | {{subject.name}} |',
'| IMO | {{subject.imo}} |',
'| Position at alert | {{alert.position}} |',
'| Operator verdict | {{feedback.verify-alert.value}} |',
].join('\n'),
// Optional: the approved text is saved as an ontology object.
persistAs: { objectType: 'HeadsUpReport', onApprovalOf: 'go-no-go' },
},
},
})- Only on a prose
documentaction. Any other type, oreditor: "list", fails the build. Every part of the declaration is strict, so a pasted block of facts is refused. - The subject comes from the opening detection.
subject.fromnames the detection evidence value (entityId, the watch's entity property, orobservationId, its observation property) and the subject property that holds it. A flow the watch dispatch did not open has no subject: the draft is refused with422 subject_unresolved. The detection itself is read as the drafter; one they cannot see refuses the draft (detection_not_visible), and so does a subject they cannot see (subject_not_visible) or one the evidence names twice (subject_ambiguous). related— up to 8 named reads.detectionreads the object the detection's evidence names.referencingreads objects whosepropertyequals the subject's id, or the subject'ssubjectProperty(latestBykeeps the newest;limit1–5, default 1).linkedreads objects onelinkTypeaway (limit1–10, default 3). Objects the drafter cannot see are absent.positions— forsubjector a related read, the properties that hold its WGS 84 latitude and longitude in decimal degrees. Each enables{{<namespace>.position}}, which the platform writes in the shared coordinate format: degrees and decimal minutes to report precision, for example51°58.2420' N 003°12.4560' E. A missing or out-of-range axis is a gap.template— Markdown with{{subject.<property>}},{{<as>.<property>}},{{detection.detectionId|watchSlug|firedAt}}and{{feedback.<task slug>.value|notes}}(a map decision an earlier task recorded). A placeholder that names nothing declared fails the build. The platform appends the sources the report used. Write a fact your sources do not hold yet as plain text — a labelled gap — never as an invented value.- Demo data is labelled from its source. A value read from a binding that
declares
bindingContext.demois shown as "value (Mocked — demo data)". Replace the source with a real one and the label disappears; the flow does not change. sources.allowis a filter, not enforcement: a fact from a source system not on the list — or from a binding that declares nosourceSystem, or an object owned by the ledger — is left out of this report and listed as excluded. A subject from such a source refuses the draft (subject_source_not_allowed). Other reads of that source are unaffected.- Who it drafts as. A person who presses Draft drafts as themselves. An
automaticstep drafts as the person who staffs its task (the assignee, or the holder of its role persona), so give the task adefaultAssignedRoleSlug. Until someone staffs it, the step waits — it never reads as the platform — and it is filled on the next read of the flow after the persona is cast. A refusal that names the flow (no detection, an invisible subject) fails the step with that reason. - Classification. The report is classified at the highest of the step's own classification, the detection's marking and the marking of every fact it used. A fact from a source-backed binding carries no marking of its own, so it counts at the drafter's clearance — conservative, and it can over-classify.
- A report above the flow's classification is contained. Participants below it see the step as withheld; only cleared participants can edit, redraft or complete it; it stays out of the closing summary and canonical promotion; and its knowledge-base page is written at its own classification, so a distribution excludes every recipient below it.
persistAs— on approval, the report text is saved as an object of that type, as the approver, marked at the report's classification. A reader below that marking gets an empty read of it.onApprovalOfnames the task whose approval approves the report (required for anautomaticstep); without it, completing the step is the approval. The type must declaretitle,body,subjectType,subjectId,processFlowInstanceId,approvedBy,approvedAt(strings) andsources(json). When the object cannot be saved the approval does not happen:409 subject_report_not_drafted,403 subject_report_above_clearance(the approver is not cleared),422 subject_report_refused(undeclared type or property),503 subject_report_unavailable(retry).
approval
A sign-off gate. The assigned persona sees an Approve / Reject pair of buttons. Approving marks the action complete and unblocks downstream tasks; rejecting halts the flow — the gate's task stays open and no downstream stage unlocks until the gate is re-decided.
Use for: binary stage-gate sign-offs, go/no-go decisions, governance reviews where someone with named authority must vouch for the result. If the step has more than two outcomes (e.g., proceed / re-investigate / re-triage), use a map action instead — see Map actions as multi-outcome decision gates below.
Schema fields used: name, description, isRequired, executionMode, metadata.persona.
Example (from the AI Boardroom template — executive sign-off on the AI-drafted summary):
defineAction({
name: 'Approve meeting summary',
actionType: 'approval',
isRequired: true,
executionMode: 'manual',
metadata: { persona: 'executive' },
})What the assignee sees: a card with Approve and Reject buttons. Approval writes an approval_granted activity entry; rejection records the decision, leaves the task open, and hard-blocks progression — the stage cannot drain and no downstream stage unlocks while a gate is rejected, even if every other action on the task is optional and complete. The block clears when the gate is re-decided to approved (or skipped as an explicit override). This holds whether or not the gate is marked isRequired.
Approval actions show an AI Recommendations panel containing a single consolidated decision brief — key risks, the available evidence, and what prior similar flows show — grounded in the same knowledge sources as document recommendations. Each risk carries a severity, the evidence a strength (strong, partial or weak), and the precedent a count of earlier flows; these describe the inputs, never the decision. The panel never recommends approving or rejecting; the decision stays with the assignee.
Decision rationale is operational text — do not put personal data in it.
When a decision settles, the platform records a rationale entry (the comment or reason an operator enters alongside their approval or rejection) in the append-only ontology assertion ledger. Ledger entries cannot be individually deleted. Rationale is intended for operational reasoning — tactical context, evidence citations, mission-relevant notes — not for personal identifiers, names, or contact details. Organizations subject to GDPR Art. 17 (right to erasure) should consult their GDPR evidence documentation for the platform's erasure stance and the accepted residual risk on this field.
workflow
Executes a Scrydon workflow — either an agentic AI agent, an extension block sequence, or a human-in-the-loop gate. The workflow runs in the platform's workflow engine; the action completes when the workflow returns.
Use for: AI agent steps, automated extensions, HITL gates that need richer than a yes/no — anything where executable logic must run as part of the flow.
Schema fields used: name, description, isRequired, executionMode (often automatic), workflowId or workflowSlug (mutually exclusive), metadata.aiAgent, metadata.retrieval, metadata.companyContextBriefing, metadata.outputSurface.
There are two ways to reference the workflow, and choosing between them is the key authoring decision for this type:
By workflowId — refer to an existing workflow
Use this when the workflow already exists in the tenant. Two common cases: a @system/* agent shipped by the platform, or a workflow the workspace built independently.
Example (from the NATO sense stage — calling the system-provided multi-source detector):
defineAction({
name: 'Run UAV radar detection',
actionType: 'workflow',
isRequired: true,
executionMode: 'automatic',
workflowId: '@system/agent-multi-source-detector',
metadata: { persona: 'ai-platform', aiAgent: 'multi-source-detector' },
})The runtime treats @system/* ids specially — they always resolve, regardless of which workspace the instance runs in.
By workflowSlug — ship the workflow inside the same extension
Use this when the workflow is bespoke to the process and you want them to ship as one atomic unit. Author the workflow alongside the process flow in the same .scrydon-extension.tar.gz; the importer materializes the workflow first, then rebinds every workflowSlug to the materialized id before the process flow lands.
Example (from the SAP Activate Brabant extension — a HITL gate shipped with the template):
defineAction({
name: 'Realize → Deploy Gate (HITL)',
actionType: 'workflow',
isRequired: true,
executionMode: 'manual',
workflowSlug: 'hitl-realize-gate', // resolved at install time
})workflowId and workflowSlug are mutually exclusive — the Zod refinement rejects an action that sets both. See Workflows for the slug-rebind contract.
What the assignee sees: if executionMode is automatic or on_entry, usually nothing — the step runs in the background and shows its result inline once it lands. If manual, a "Run" button appears; clicking it starts the workflow and streams progress into the card.
Rating the answer (👍/👎): once an agent step has produced output, the assignee sees a thumbs-up / thumbs-down bar beneath the answer with an optional note. This is a built-in runtime affordance — you do not author it, and it does not gate or advance the flow. Each person has their own rating: a re-vote replaces (never duplicates) that person's earlier rating, and the action carries a count of 👍 and 👎 across everyone who rated it. A 👎 does not require a note.
Ratings teach later runs: the ratings and notes on earlier answers of a step feed the next runs of that same step — in later runs of the same process flow, in the same workspace environment. The agent receives them as Reviewer feedback on earlier answers: the tally and the most recent notes, framed as preferences that cannot override the task or your prompt. Only feedback on an output the run is cleared to see is included; feedback on an output with an unknown or compartmented classification is never replayed. The Prompt & Context panel lists it under Reviewer feedback. To keep a step's answers independent of earlier feedback, add answerFeedback to its metadata.excludeContext.
Follow-up questions: once the step has an answer, its Cortex conversation shows a card above the input, Cortex can ask you a few questions. Questions are only asked on request: Ask me questions has Cortex ask what it would need to know to improve the answer. It asks up to three questions in the conversation, each with an answer field; the assignee answers what they can and chooses Save answers and redo the draft. Afterwards the card reads Your N answers are used for this step, with View or edit to change them. The answers are stored on the action and given to every later run of that action — they are the assignee steering this step, like a refinement, so excludeContext does not suppress them. Asking again keeps the questions already answered. A canvas workflow receives the answers only if its agent block reads <start.processFlowContext>; @system/* agents always receive them. Generating questions uses the workspace's default language model, so it is unavailable when none is configured.
For @system/* AI agent steps, metadata.retrieval lets you ground the agent in earlier-task documents; metadata.outputSurface declares where the agent's result lands. Full coverage on the parent page: Grounding an AI step in workspace documents.
Customer-aware grounding with Company Context
Keep the extension's task prompt stable and use metadata.companyContextBriefing when the action needs organization-specific grounding. Its question is a retrieval question of 1–2,000 characters, not a system-prompt override:
defineAction({
name: 'Prepare strategy recommendation',
actionType: 'workflow',
isRequired: true,
workflowSlug: 'strategy-agent',
metadata: {
companyContextBriefing: {
question:
'Which verified priorities, constraints, risk appetite, stakeholders, terminology, and policies are relevant to this strategy recommendation?',
},
},
})Before the action runs, the platform adds the stage, task, action, and subject details to this question, queries the organization's Company Context, and injects the cited answer as a separate context category. Retrieved text is treated as untrusted evidence, never as instructions, and the authored workflow prompt is unchanged. An absent, empty, or temporarily unavailable Company Context does not block execution; the action runs with its generic extension prompt and the Prompt & Context panel shows the briefing state.
Organization administrators configure and govern that source through Company Context; extension authors can read it only through this briefing contract or other authorized read surfaces.
Ask first, refine explicitly: the Wizard's Shared action conversation
is one durable thread for everyone authorized for the action. Participants can
ask follow-up questions without changing the output. On an eligible Cortex
answer, Apply as refinement starts a new authorized run against the current
output; Cortex cannot silently decide to refine it. For canvas workflows, the
refinement reaches the agent only when the workflow consumes
<start.processFlowContext>.
Where AI output lands
When a workflow action runs a @system/ agent, its result lands wherever its
outputSurface says — each surface is opt-in, and an action can declare both:
workflowsurface — written in two places:- The action itself — structured verdict (
verdict,confidence,rationale, cited learning titles) plus a rendered Markdown copy. - The instance knowledge base — a page under
instances/<instance>/assessments/<stage>/, written automatically on completion. This page is the source of truth: Saved to knowledge base under the action's output opens it. The page's clearance level is capped at the high-water-mark of the sources that informed it, so readers below that clearance see a denial instead of the content.
- The action itself — structured verdict (
approvalsurface — an advisory banner on a human decision action (approvalormap) of the task named bytargetTaskSlug, which need not be the agent's own task. See Advisory banner on decision actions for how the target is chosen.documentsurface — a first draft written into a list document of the agent's own task, named bytargetActionSlug. A person then edits and completes that list. With noworkflowsurface alongside it, no assessment page is written.
defineTask({
slug: 'agenda',
stageSlug: 'prepare',
name: 'Agenda',
actions: [
defineAction({
slug: 'agenda',
name: 'Agenda',
actionType: 'document',
isRequired: true,
metadata: { persona: 'secretary', editor: DOCUMENT_EDITOR.LIST },
}),
defineAction({
name: 'AI draft: agenda',
actionType: 'workflow',
workflowSlug: 'ai-agenda',
metadata: {
outputSurface: { kind: 'document', targetActionSlug: 'agenda' },
},
}),
],
})The agent's reply must be JSON: {"items":[{"title":"…","description":"…"}]},
or a bare array of those items. Code fences and prose around it are tolerated.
Ask for that shape in the prompt, and give the Agent block a strict
response format so the model is held to it. The platform keeps
only title and description from each item and drops any other field. It
skips items without a title and clips long text to the list's limits.
A draft never overwrites a person's work. It is written only while the list is empty, or while the list still holds, unchanged, the draft an earlier run wrote. Re-running the step therefore replaces its own draft and nothing else. If the reply holds no usable item, an empty list shows a notice that the AI draft could not be read. A list that already has items keeps them.
The list is the only place the draft appears. The agent step is not shown as a block of its own next to the list: it is an AI draft section folded in under the list, in every view. The section shows the step's status, its re-run and any failure, and opens by itself when the step failed or waits for a person. Inside it, the step says how many items it put in the list rather than showing a second copy of them. When the reply held no usable item, it shows the reply as the model wrote it. It is not counted as a step of its own either: the task's progress on the Kanban card and in the Wizard rail, and the rail's list of the task's steps, leave it out.
The build fails when targetActionSlug is missing, matches no action of the
agent's task, or names an action that is not a list document. A document surface
on a stage action fails the build too: a stage action has no task to look in. On import of an already-published extension these are reported as
warnings.
Re-running the action updates the same page in place. The Run Details panel on the action shows the execution envelope (workflow id, execution id, status, timestamps) and the raw stored payloads for debugging.
When the output is grounded in Company Context or instance-KB material, a References section links to every source the current viewer is authorized to open. The server resolves those links at display time; removed or inaccessible material is shown as unavailable instead of exposing a stale path. Use Focus to expand a workflow, document, or assessment output to a full-page reading view, then use A− / A+ to adjust the text size without changing the saved output.
How AI output renders — let the prompt choose
Wherever an agent result is displayed (the action card, the assessment page, the document view), the platform picks the rendering from the shape of the answer — which means your prompt decides presentation:
-
Content blocks — an answer that is a JSON array of content blocks renders with the same rich components the chat uses: markdown text sections, a live map, a step-by-step plan card, and every block type the chat gains in the future. Instruct the agent in your prompt, e.g.:
Answer as a JSON array of content blocks: [{ "type": "text", "text": "<your assessment as markdown>" }, { "type": "map", "title": "Affected area", "layers": [ { "id": "impact", "label": "Impact zone", "color": "#ef4444", "markers": [{ "id": "site-1", "lat": 51.2, "lng": 4.4, "label": "Site 1" }] } ] }] -
Structured JSON — any other JSON object/array renders as labeled cards (field names humanized, short values as chips, prose fields as markdown). Ideal for verdict-style schemas.
-
Prose — everything else renders as markdown.
Detection is strict: if any element of a JSON array is not a valid content block, the whole answer falls back to structured cards — a data answer is never partially swallowed. Interactive block types (options, confirmations) are chat-only today and render nothing on step surfaces.
Locking a step's output — metadata.editableOutput: false
By default a person may edit an agent's answer, and the edited text is kept
(and marked as human-reviewed). Declare editableOutput: false on a workflow
action when an edit would be silently discarded: a step whose answer feeds a
later agent step is re-run whenever its own inputs change, and that re-run does
not carry the previous text forward.
On a locked step:
- there is no Edit control; the label "Changes only by re-running" takes its place, and Regenerate re-runs the step;
- the platform refuses every direct edit of the answer server-side (HTTP 409,
code
output_edit_not_editable), whichever route is used; - a reviewer changes the answer by telling Cortex what to fix. That instruction is stored on the step as standing steering — shown as a removable chip — and the step re-runs fresh, without the previous answer. The instruction then applies to every later run of the step, including runs triggered by an upstream change, until someone removes it with the chip's ✕. Steering is at most 2,000 characters.
defineAction({
name: 'AI-voorstel: Risicoadvies',
actionType: 'workflow',
executionMode: 'automatic',
workflowSlug: 'risk-advice',
metadata: {
editableOutput: false, // its answer changes only by re-running
includeInSummary: false, // the pre-read already consolidates it
},
})Only the literal false locks a step; an absent field, true, or any other
value keeps it editable. The flag applies to workflow actions only.
Keeping a step out of the closing summary — metadata.includeInSummary: false
When a flow closes, the platform writes one condensed summary.md into the
instance knowledge base. Set includeInSummary: false on a step whose content is
already carried by a later step, so the summary does not repeat it. The step keeps
its page, its panel and its attachments; only the closing summary skips it.
Naming a group in the wizard rail — task attribute railGroupLabel
When several tasks in one stage all feed a single later task (for example five
advisories feeding a pre-read), the wizard rail shows them as one collapsible
stop with a pip per step. The grouping is derived from the dependencies; the extension
can only name it, through the reserved task attribute railGroupLabel in the
task template's defaultAttributes. Without it the rail uses a generic label.
Prompt & Context
Every agent-bearing action — a workflow action or a @system/ agent step —
shows a Prompt & Context panel in its action view (both the Wizard
walkthrough and the task detail). It surfaces the system prompt the agent
uses, the assembled user message, and the context that was included
(input documents, the meeting transcript, and any configured Company Context
briefing). What you see is exactly what runs. Classified context is revealed
only when your clearance permits.
Extension prompts remain read-only and stable. Use
companyContextBriefingfor customer-specific grounding instead of copying or overriding the prompt.
Controlling context exposure
By default, every agent step is given all eligible context: input documents from earlier tasks and the instance’s most recent meeting transcript, and a stage-context summary (instance/task name). You can suppress
any of these per step by declaring metadata.excludeContext.
The categories:
| Category | What it covers |
|---|---|
inputDocs | Documents from earlier tasks declared in retrieval.inputs[] |
companyContext | The Company Context briefing configured with companyContextBriefing |
priorCycles | Excerpts from earlier completed runs of this process flow (Carry earlier runs forward) |
answerFeedback | Reviewers' 👍/👎 and notes on earlier answers of this same step (see Ratings teach later runs above) |
transcript | The instance's most recent meeting transcript |
stageSummary | The instance/task name header that opens the assembled user message |
processContext | Context entered by people in other steps of this flow (document and map actions). Included automatically; suppress it when a step must not see upstream human input. |
Example — an agent that must not see meeting transcripts:
metadata:
retrieval:
inputs:
- fromTask: upload-bid
excludeContext:
- transcript # this agent must not see meeting transcripts- Absent or empty
excludeContextmeans all eligible categories are exposed — the default behavior. - Suppression is enforced at run time: the excluded source is not fetched, not
merely hidden from the UI. Suppressing
inputDocsprunes the retrieval spec before any read; suppressingtranscriptskips the transcript lookup entirely; suppressinganswerFeedbacknever reads earlier ratings. - A refinement instruction and the assignee's answers to follow-up questions are not categories: they are always sent.
- The action's Prompt & Context panel shows each category as Exposed or Suppressed by template, so it's always clear what context the agent had access to.
entity_link
Reserved / not yet covered by an in-tree example extension. The schema accepts entity_link and the runtime renders a placeholder card, but no example template in @scrydon/sdk-authoring/src/process-flows/examples/ currently uses it. Prefer document, file_upload, or a workflow action that writes a typed instance via the ontology API until a worked example ships. File an issue if you have a use case that requires this type today.
The intent is to attach a task to a typed Object instance from the ontology — so the action becomes "fill in this Object" rather than "produce a free-form artifact." The Object Type would be declared via task.ontologyContract.writes (or the action's ontologyContract), and the runtime would scaffold an editor for that type's properties.
Schema fields used: name, description, isRequired, executionMode, ontologyContract (declared at the action or task level).
file_upload
The assignee uploads one or more files. Bytes flow into the instance's workspace knowledgebase, get hashed and stored via @scrydon/storage, and are immediately indexed — making them available to downstream actionType: "workflow" steps via metadata.retrieval.inputs[].fromTask.
Use for: importing source documents (RFPs, contracts, board extensions, audit evidence) that downstream AI steps need to reason over.
Schema fields used: name, description, isRequired, executionMode, metadata.persona, and the optional drop-zone constraints metadata.acceptedExtensions, metadata.acceptedMimeTypes, metadata.maxSizeMb.
Constraining what can be uploaded. A file_upload step always accepts multiple files; you cannot turn multiplicity off. What you can declare is which files and how large:
metadata field | Type | Effect |
|---|---|---|
acceptedExtensions | string[] | Allowed file extensions (leading dot, case-insensitive — e.g. [".pdf", ".docx"]). |
acceptedMimeTypes | string[] | Allowed MIME types (e.g. ["application/pdf"]). Broadens acceptance — a file passes if its extension or its MIME type matches. |
maxSizeMb | number | Per-file size cap, in megabytes. |
All three are optional. Declare none and the step falls back to the platform defaults: extensions .pdf .docx .xlsx .csv .pptx .txt .md .json, max size 50 MB. Declaring acceptedExtensions or acceptedMimeTypes replaces the default extension set, so you can gate purely by MIME if you prefer.
These limits are enforced both in the picker (the drop-zone's accept filter and size check) and server-side at upload — a request that bypasses the UI is still rejected with a 400. Existing in-flight instances keep the defaults; the constraints take effect for flows instantiated after the template declares them.
Accepted ≠ knowledge-base indexed. What you allow here controls which files attach to the step. Whether an attached file also becomes a searchable knowledge-base page is decided separately by the ingest pipeline, which can currently text-extract: PDF, DOCX, ODT, PPTX, XLSX, TXT, MD, CSV, JSON, and audio (transcribed) — so every one of the platform-default extensions is indexed. Other types an author opts into — legacy .doc / .xls / .ppt, images, and archives — upload and stay downloadable as attachments, but are not indexed into the instance knowledge base, so downstream workflow steps using metadata.retrieval cannot read their contents. The upload itself always succeeds — non-indexable files are simply marked "Not indexed" in the task panel, and the rest of the batch is indexed as usual. Restrict acceptedExtensions to the indexable set if a step exists purely to feed an AI step.
Example (from the AI Boardroom template — secretary uploads the board extension):
defineAction({
name: 'Upload board documents',
actionType: 'file_upload',
isRequired: true,
executionMode: 'manual',
metadata: {
persona: 'secretary',
// Optional — omit for the platform defaults (.pdf/.docx/.xlsx/.csv/.pptx/.txt/.md/.json, 50 MB).
acceptedExtensions: ['.pdf', '.docx', '.pptx'],
acceptedMimeTypes: ['application/pdf'],
maxSizeMb: 25,
},
})What the assignee sees: a drop-zone card that accepts multiple files — select several at once or keep adding files over time (each upload appends to the step's attachment list; existing files are never replaced). The picker only offers the accepted types and rejects files over the size cap. Completion is explicit: once at least one file is attached, the assignee marks the step complete (the Mark step complete button on the card, or the action's checkbox in the task checklist). Each file becomes a KB page that downstream tasks can reference by this task's slug.
A downstream workflow action that wants to reason over the uploaded files declares them via metadata.retrieval.inputs: [{ fromTask: "upload-board-documents", mode: "full" }]. See the parent page's retrieval section.
distribution
Sends a message to the holders of one or more personas — and, subject to an organization policy, to recipients outside the organization entirely — and tracks acknowledgment. Used to broadcast a result, request acknowledgment before a tabletop, close a loop with stakeholders who aren't actively running the flow, or notify a counterparty such as a vulnerability reporter or an affected customer.
Delivery runs over email through the platform mailer, and over Cortex (channel: "cortex") as a Cortex message. Neither needs any extension setup. channel: "slack" and channel: "teams" are accepted in the manifest as declared intent, but delivery for those channels will be enabled in a future release; until then the panel shows the declared channel and disables sending for them.
Use for: notifications, briefings, summaries pushed to external channels.
Schema fields used: name, description, isRequired, executionMode, plus a richer metadata block:
metadata key | Meaning |
|---|---|
persona | Who is sending it (audit trail). |
channel | "email" · "cortex" (delivered today; see Posting to Cortex) · "slack" · "teams" (declared intent; delivery not yet active). |
recipientPersonas | Array of persona slugs whose holders receive the message. |
externalRecipients | Recipients who are not members of the organization — { addresses?, fromContext? }. Email only, and gated by an organization policy that is off by default. See Reaching someone outside the organization. |
cortexChannels | For channel: "cortex": up to 20 Cortex channels to post in, in addition to the persona holders' direct messages. Each ref names one channel: { kind: "literal", value: "<channel id>" } (a channel id is a UUID: a workspace channel's or an organization channel's, Default included), or { kind: "attribute", key: "<attribute>" } naming a declared single-valued string or uuid attribute that holds a channel id. Read only on a distribution action whose channel is cortex; any other action that declares it is refused at install. See Posting to Cortex. |
attachments | Up to ten unique { fromTask } selectors (the cap on declared SOURCES; a single upload task can contribute several files) for supported upstream document, workflow, or file_upload artifacts. Email only. Add defaultSelected: false to an entry to offer it in the compose form unticked — the sender can still add it. Omitted, a declared attachment is ticked. |
completion | "fire-and-forget" (done as soon as sent) · "all-acknowledged" (every recipient must ack) · "quorum" (N acks needed). |
quorumCount | Required when completion: "quorum". |
Example — distribute an audit-ready report and its supporting files by email:
defineAction({
name: 'Distribute report summary',
actionType: 'distribution',
isRequired: true,
metadata: {
persona: 'compliance-lead',
channel: 'email',
recipientPersonas: ['compliance-lead', 'ciso', 'dpo', 'eng-owner'],
attachments: [
{ fromTask: 'final-report' },
{ fromTask: 'supporting-files' },
],
completion: 'all-acknowledged',
},
})What the assignee sees: a focused panel showing the declared channel, the completion mode, and the resolved recipient list (each recipient persona's assigned users by name). For email, a compose form (subject prefilled from the action name, editable message) sends each recipient a personal tracked link; once sent, the panel shows per-recipient acknowledgment status and an "N of M acknowledged" progress line for all-acknowledged / quorum modes. Opening the tracked link shows a confirmation page; the recipient acknowledges with its Acknowledge receipt button (opening the link alone records nothing, so a mail scanner that follows links cannot acknowledge for anyone). A cortex distribution has no compose form: the platform posts it when the step runs.
Receipt links are one-time
Each recipient's link carries a one-time code that Scrydon stores for that recipient alone:
- A link acknowledges once, and only for the person it was sent to. Opening it again shows the recorded receipt; it never records a second one. A forwarded link cannot acknowledge for anyone else.
- A link can be replaced. When a step's send is retried because some recipients could not be reached yet, every recipient who has not yet acknowledged may be sent a new link — the link only, with the step's own subject: never the message, the content or an attachment again, and never a subject typed for the retry. This happens at most once an hour per step; a retry with nobody left to reach re-sends nothing. The earlier link then opens a page that says "This link was replaced" and asks the recipient to use their latest message.
- A completed step is not sent again. Its recipients are settled; a send on it is refused with "This step is already complete". Two sends of one step at the same moment are not run twice: the second is told the step is already being sent.
- Links sent before one-time links existed were replaced the same way, once, when your deployment was upgraded. A recipient who opens one of those older links sees the same "replaced" page.
Before a new link is sent, the recipient's access is checked again. Someone who has lost access to the flow, or whose domain is no longer allowed for external distribution, is not sent a link and shows as skipped on the step.
Attachment selectors must point to a direct or transitive task dependency, or to a task in an earlier stage when the stage flow is sequential. The selected task must produce a document, workflow output, or uploaded file. Generated outputs are attached as UTF-8 Markdown; file-upload outputs keep their original bytes and MIME type. The complete set is resolved before any recipient is notified, with a maximum of twenty files, 8 MiB per file, and 8 MiB total. Email receives real MIME attachments, while the in-app notification exposes authorized attachment links even when a recipient has disabled email or no email provider is configured. If the source is removed or changed after dispatch, the old link does not serve different bytes. These rules apply to a distribution declared on a stage's actions as well as on a task's: a selector that names no task, or a task that produces nothing attachable, fails extension validate.
Posting to Cortex
With channel: "cortex", a distribution posts its message in Cortex instead of mailing it. Each holder of a recipientPersonas persona receives it in their own direct message, even someone with no Cortex DM yet: the platform opens one. Each channel in cortexChannels receives it as a post. body is the message and is required. The subject, when present, is shown in bold above it.
defineAction({
name: 'Post the release note',
actionType: 'distribution',
executionMode: 'automatic',
isRequired: true,
metadata: {
channel: 'cortex',
recipientPersonas: ['release-manager'],
// 'releaseChannel' is a string or uuid attribute, declared in
// template.attributes, that holds a Cortex channel id.
cortexChannels: [{ kind: 'attribute', key: 'releaseChannel' }],
subject: 'Release 4.2',
body: 'Release 4.2 is out. The notes are in the release workspace.',
completion: 'fire-and-forget',
},
})The manifest, and extension validate, refuse a Cortex distribution that:
- is not
executionMode: "automatic"or"on_entry". The platform sends it; there is nothing to compose; - waits for acknowledgement. A Cortex message carries no receipt link, so
completionmust be"fire-and-forget"(the default); - has no
body; - names neither a persona nor a channel;
- declares
attachmentsorexternalRecipients. Both stay email only; - lists a
cortexChannelsref that cannot name a channel: a literal that is not a UUID, an attribute that is not declared, is not astringoruuid, or holds a list (cardinality: "many"). One ref names one channel; for several channels, list one ref each, up to 20.
cortexChannels is read only on a distribution action whose channel is cortex. Any other action that declares it is refused at install.
An attribute that names a channel is read when the step runs. If it has no value on this run, the ref names no channel: nothing is posted for it and nothing is recorded, because the channel may not be chosen yet. A value that is not a channel id (not a UUID), or an attribute the template does not declare, is skipped, and the attribute's key is listed under unresolvableCortexChannels on the step's distribution_dispatched activity entry, which is written when the send succeeds. If that leaves the step with no one to send to, it fails.
Who it posts as. The step's own reader: the person assigned to the step's task, else whoever holds the task's role persona; if nobody does, the Run As account of the workflow the action references; if there is none, the person who started the flow. An ordinary distribution references no workflow, so it goes from the persona holder straight to the person who started the flow; the Run As step applies only to an action that does reference one. This is the same order a step uses to read its inputs. A flow that the system created, or that an extension workflow started, has no person who started it, so without a Run As there is no one to post as. A Run As account whose clearance was raised above the one it was accredited with is not used, and there is no fallback to the person who started the flow. A Run As that is a service account posts as that service account's own Cortex identity, which is a member of the channels of the workspaces it belongs to, and of an organization channel only when a manager of that channel (its creator or an organization administrator) has added it; Default never holds one. Nothing substitutes another identity: a step with no one to post as is refused by the platform, and nothing is posted.
What is recorded. Each recipient, a person or a channel, is one delivery attempt, as for email. A post that Cortex accepted is recorded as posted, with its post id. A post the platform refused is recorded as skipped, with the reason as collaboration_post_<reason>; for example collaboration_post_not_a_channel_member when the step's reader has not joined the channel. The reasons are those of action.collaboration.post, which also governs every post: its limits, its data-loss prevention and its classification checks apply, and Cortex must be enabled for the organization (Availability). The step completes as soon as one post has landed; a post that failed on the way is not tried again after that.
When nothing landed. If no post landed and at least one failed in a way a later try may fix (Cortex unavailable, a rate limit, a network failure), the step fails as retryable and goes back to pending. It is sent again the next time one of three things happens: the flow advances, someone opens the flow, or the platform's hourly retry runs. A flow nobody touches is therefore retried within the hour. A retry uses the same keys, so a post that already went through is never posted twice. Each post is tried at most five times per run. A try that Cortex rate limited does not count towards the five: the post was not refused, only delayed, so it is tried again until Cortex takes it. After the fifth counted failed try the recipient is recorded as skipped, with collaboration_post_retries_exhausted. If nobody was reached, the step then fails for good, with one of two reasons, and neither is tried again:
- Cortex could not post as the step's account. The account the step posts as could not be signed in, for example because it was disabled or revoked.
- Cortex kept failing to take the post, on every try.
A post the platform refused for good is not tried again, so it never counts towards the five. If every post was refused (for example collaboration_post_not_a_channel_member on the only recipient), nothing was reached and the step fails with distribution_no_recipients_delivered.
When a post may have landed. Sometimes Cortex accepts a post and then cannot confirm that it was sent. That post may be in the conversation, so it is not tried again: the recipient is recorded as skipped, with collaboration_post_unconfirmed. If nobody else was reached, the step fails for good with distribution_cortex_post_unconfirmed, never with distribution_no_recipients_delivered, and the step says the message may have been posted. Check the conversation in Cortex before you reopen the step: a reopened step posts under new keys, so a post that did go through would appear twice. This reason is given over a post that ran out of tries; a post that a retry may still deliver is given over it, and the step stays retryable.
Once the cause is fixed, reopen the step to post again. When the five tries are spent, sending the same run once more posts nothing, because its attempts are already settled; a reopen starts a new run, as it does for the reopened stage described below.
The message is sent by Cortex and carries a record of the run that posted it. It mentions nobody: @room, user ids and links to people in the text notify no one.
Reaching someone outside the organization
recipientPersonas resolves through the instance's persona assignments to platform user accounts, so on its own a distribution action can only ever reach people who are already members of the organization. A large share of real processes exist because of a counterparty — a vulnerability reporter inside a published CVD SLA, a customer whose own statutory clock cannot start until you notify them, a candidate, a supplier, a data subject under GDPR Art. 34 — and every one of those is external by definition.
metadata.externalRecipients adds them. It is additive: declaring it never removes a persona recipient.
externalRecipients key | Meaning |
|---|---|
recipients | Preferred. A list of references: { kind: "attribute", key } names a declared instance attribute resolved per run; { kind: "literal", value } is an address fixed at authoring time. An attribute may hold one address or, with cardinality: "many", a list. |
addresses | Email addresses fixed at authoring time — a regulator's intake mailbox, a shared security alias. Equivalent to literal recipients, except that a malformed address is skipped silently (as it always was) rather than reported as unresolvable. |
fromContext | Legacy alias. Dot-separated paths into the instance's recorded values. A single-segment path (reporterEmail) resolves exactly like { kind: "attribute", key: "reporterEmail" }, including its declaration and classification; a nested path (reporter.email) is still read for flows written before attributes existed. New flows should declare an attribute and use recipients. |
defineAction({
name: 'Acknowledge receipt to the reporter',
actionType: 'distribution',
isRequired: true,
metadata: {
persona: 'security-lead',
channel: 'email',
externalRecipients: {
recipients: [
// Declared in template.attributes; recorded when the run starts.
{ kind: 'attribute', key: 'reporterEmail' },
// And the shared disclosure alias is always copied.
{ kind: 'literal', value: 'security@example.com' },
],
},
completion: 'all-acknowledged',
},
})Declaring an external recipient is not permission to reach them. Every resolved address is checked at dispatch against the organization's external-distribution policy, which is off by default and permits nothing until an administrator both enables it and allows the recipient's domain under Settings → Governance → External Distribution. A template whose external send is disallowed says so when the pack is inspected at install time, and again on the step itself before anybody presses Send — it never completes as though it had sent something. Every change to that policy is recorded in the Audit Log as process_flow.external_distribution.updated, with the administrator who made it and the settings before and after.
An attribute recipient that has no value on this run is skipped. One whose
stored value is not a usable address, or that the template does not declare, is
reported on the step as unresolvable instead of being silently dropped. An attribute recipient that names an undeclared or
non-string attribute fails extension validate, on a stage's actions as well
as on a task's.
What differs for an external recipient:
- Email only. There is no in-app notification, because there is no account to show one in.
channelmust be"email"; the manifest validator rejects an external recipient on any other channel. - The same tracked acknowledgement link. They receive the identical signed receipt URL, so
all-acknowledgedandquorumcompletion count them exactly like a member. This is the difference between a flow that records a message was due and one that records it was sent and received. - Attachments are separately governed. They are refused unless the organization has enabled attachments for external distribution, and each one must be at or below the configured classification ceiling. A generated artifact (a
documentorworkflowoutput) carries a resolvable classification and can be sent; an uploaded file does not — its marking lives in the storage layer, which the dispatcher cannot read without a principal — so uploads are never sent to an external recipient. The step reports this rather than quietly dropping the file. - Attachments arrive as bytes, never as links. The in-app attachment URL requires a session, so a link would fail in a counterparty's mail client.
- Delivery outcome is recorded either way. A bounce or an unconfigured mail provider is recorded as a failure on the action, distinguishable from a send that landed.
An operator can also add a one-off external address in the compose form at send time, subject to the same policy. Pack-declared recipients and operator-added ones are both shown on the step, each with its own verdict.
Auto-dispatch on stage entry. Distribution actions authored with executionMode: "on_entry" (or "automatic" with no declared upstream dependencies) fire automatically the moment their stage becomes active — no assignee action needed to trigger the send. Use this for broadcast notifications that should go out as soon as a stage opens, such as alerting stakeholders that a review has begun.
When a recipient persona has nobody assigned, the content is not sent to anyone in the persona's place. Recipients resolve through the persona's assignments, then through an organization role that shares the persona's slug. If neither yields anyone, the organization's administrators get a staffing notice instead. The notice names the unassigned persona and links to the flow where someone can be assigned. It does not include the distribution's subject, body or attachments, because the content was addressed to the persona's holder and an administrator is not that person. The step then fails with the reason Nobody holds this step's recipient persona, and the reason is shown in its panel. Assigning someone to the persona puts the step back to pending: an automatic step is sent again straight away, and a manual step can be sent again. Recipients that an operator added by hand, and external recipients, are still sent to, because they were addressed directly rather than through the persona. So is a Cortex channel in cortexChannels: it still receives the post, the administrators still get the staffing notice, and the step fails for want of a holder only when the send names no one else. A flow started by a trigger has no persona assignments at all. The instance page names the unassigned personas, and an Assign people to personas prompt appears above the board until they are filled. The step's recipient list shows when a person was reached via the organization role of the same name.
Approvals follow the same rule. An approval can only be given by a person assigned to the persona. An administrator is notified so that they staff the persona, and cannot approve in its place. Assign someone to the persona to unblock the step.
If a send fails for any other reason (no delivery channel configured, no recipient reachable, attachments unavailable, or the persona lookup itself failing, which is retried automatically), the reason is now recorded on the action and shown in its panel, rather than only in the server logs. The reason belongs to that attempt: it is cleared when the step is sent successfully, when staffing the persona puts it back to pending, and when its stage is reopened, because a reopened step has not been tried yet.
A step whose stage is reopened is sent again as a new send. When a rejected approval returns the flow to an earlier stage, a distribution step that was already sent goes back to pending and its panel offers the compose form and Send again. The new send has its own recipients, attachments and acknowledgement links. Acknowledgements from the earlier send do not count towards it, and an acknowledgement link from the earlier mail no longer works once the step has been sent again.
all-acknowledged and quorum count only recipients who can still acknowledge. A recipient who loses access to the process flow after delivery is excluded from the requirement, so the step completes once everyone who can acknowledge has. If that leaves nobody — every recipient lost access — the step fails with a stated reason instead of waiting forever. The platform re-checks waiting steps every hour, so this happens even when nobody is left to click Acknowledge receipt. The same rule applies when sending: a quorum step is refused if fewer recipients could actually be sent to than quorumCount requires. People skipped because they lost access before the send do not count.
voice_trigger
Captures audio against a path defined on the template. The recording is automatically transcribed (STT runs through the STT capability). When the action completes, the transcript is written as a page in the process flow's instance KB under instances/<instance>/transcripts/<stage>/, and the raw audio is queued for ingestion under instances/<instance>/audio/<stage>/ — both ready to ground downstream steps.
Use for: meeting recordings, dictation, voice-driven enrichment of a flow that's mostly silent the rest of the time.
Schema fields used: name, description, isRequired, executionMode, metadata.persona, metadata.triggerPath (matches a path on a top-level template.voiceTriggers[] entry).
Example (from the AI Boardroom template — recording the meeting itself, paired with a top-level voice-trigger that wires up STT and downstream enrichment):
// On the task action:
defineAction({
name: 'Record meeting',
actionType: 'voice_trigger',
isRequired: true,
executionMode: 'manual',
metadata: { persona: 'secretary', triggerPath: 'board-meeting' },
})
// On the top-level template:
defineVoiceTrigger({
path: 'board-meeting',
sttProvider: 'openai',
language: 'en',
enrichments: [
{ workflowId: '@system/agent-meeting-summarizer' },
],
})What the assignee sees: a card with a record button. While a Process Flow recording is active, the platform updates one draft transcript in the instance KB at a bounded cadence and the Wizard keeps the knowledge view and Shared action conversation alongside the recorder. Authorized participants can ask semi-live questions against the same persisted draft; the browser never sends private, unpersisted speech directly to Cortex. On stop, the remaining transcript and audio are flushed before the action completes, and retries update the same artifacts rather than creating duplicates. Enrichment workflows on the voice-trigger then run against the finalized transcript.
The pairing is what makes voice triggers powerful: the action declares "record here," and the template-level voice trigger declares "for any recording at path X, run these enrichments." One transcript, many downstream agents.
You don't deploy anything for this pairing yourself. The platform materializes the voice trigger automatically the first time an assignee starts a recording: a dedicated trigger workflow (named Voice Trigger — <path>) is provisioned once per workspace environment, carrying the STT provider, model, and language from the voiceTriggers[] entry. Paths are matched ignoring a leading slash, so /board-meeting and board-meeting pair up fine.
map
Renders a live governed geo layer — one or more ontology object types with bound coordinate columns — inside the task, and captures an operator classification per feature. The assignee sees the map, taps a feature to select it, and picks one of the author-declared options (optionally adding free-text notes). The recorded classification is stored on the action; an optional writeTarget writes it back to the managed table that backs the object type.
Use for: tactical picture / common operating picture steps, incident-response classification, any step where an operator needs to see spatial data and make a per-entity decision.
Schema fields used: name, description, isRequired, executionMode, and a metadata block with two sub-objects:
metadata key | Sub-key | Meaning |
|---|---|---|
geo | objectTypes | Array of ontology object type slugs to render. Each type must be bound to a managed table with at least one coordinate column. |
geo | ontologySlug | Optional. Narrows the geo layers to object types from a specific ontology. When omitted, the platform searches every readable ontology for the listed objectTypes. |
geo | focusAttribute | Optional — but effectively required when feedback.writeTarget is declared: the submitted classification's target row id is derived from the focused feature. A key in the instance's context object (the context value passed at instance creation) whose value is the id of the feature to center and highlight on load. |
geo | focusDetection | Optional boolean. When true and the instance was opened by a watch detection, the map also draws that detection's contributing contacts as an extra layer. The map draws a detection only for an instance the watch dispatch itself opened; the platform takes the detection from the dispatch's own record, never from the instance's context, so a manually started instance — or one whose context names a detection — draws nothing. The detection is read under the viewer's own clearance: a detection the viewer cannot read draws no contacts either — and the two cases look the same, so the map never reveals that a concealed detection exists. Absent or false → the map renders only objectTypes. |
feedback | prompt | Short prompt shown above the classification options. |
feedback | options | Author-declared array of { value, label } objects — the choices the operator can pick. 2–8 items required. |
feedback | allowNotes | Boolean. When true, a free-text notes field appears below the options. |
feedback | writeTarget | Optional. Declares a write-through: when the operator submits, the chosen value is upserted into the managed table backing the named object type. Requires the object type to have a single-column identity silver binding; composite-identity write targets are rejected fail-closed. |
Write-through failure contract. If writeTarget is declared and the upsert fails (HTTP 502 write_through_failed), the action stays open rather than completing — the recorded notes and classification are preserved, and a Retry button appears. This lets the operator re-submit without re-entering their decision.
writeTarget needs a target row to write to. Declare geo.focusAttribute and instantiate the flow with a context that carries the focused feature id — a submission with no resolvable focus is rejected with 400 target_id_required. If your step has no single focused feature, omit writeTarget and record the classification on the action only.
Example (from the drone-response C2 extension — a sensor operator classifies a detected track on the tactical picture):
- name: Tactical picture
actionType: map
isRequired: true
metadata:
geo:
objectTypes: [track] # bound, geo-bearing ontology object types
focusAttribute: trackRef # key in instance context holding the focused feature id
feedback:
prompt: Classify this track
options:
- { value: friendly, label: Friendly }
- { value: hostile, label: Hostile }
- { value: unknown, label: Unknown }
allowNotes: true
writeTarget: # optional write-through to the bound table
objectType: track
field: classificationThe trackRef above is resolved from the instance's context object at render time — it is a key whose value is the feature id (e.g. a UUID) the flow was instantiated with. The context object is set when createProcessInstance is called with a context parameter; extension authors should document which keys their template expects.
What the assignee sees: a full-bleed map panel with the declared object types rendered as governed layers (data is scoped to the operator's clearance). A feature is pre-selected if focusAttribute resolves; the operator can also tap any feature. With focusDetection, the contacts that fired the detection appear as their own layer in the legend. The feedback panel — prompt, option pills, optional notes field — slides up from the bottom of the card. Submit completes the action (and triggers the write-through, if declared), unless the selected option carries returnToStage — see below.
The geo layers are served through the same governed projection that powers the ontology map view — coordinates pass through the managed table's DLP masking rules, so features the operator cannot see at their clearance level are simply absent from the layer. The map renders the same set of features the operator could query via the ontology instances API.
writeTarget requires the object type's silver binding to use a single-column identity (one column uniquely identifies a row). If your table uses a composite key, omit writeTarget and handle the write in a downstream workflow action instead.
Live updates. When the platform's common operating picture feed is active, the map layer automatically re-reads governed data when a relevant change signal arrives — no page refresh needed. The re-read goes through the same governed endpoints as the initial render, so the operator sees only features they are cleared to see. No position data is pushed directly into the map from the realtime feed.
Backward arm: returnToStage
Any option in feedback.options may carry an optional returnToStage field.
When the operator selects that option:
- The classification and notes are recorded immediately (they are never lost, even if a later step fails).
- The flow reopens to the named stage — the target stage reverts to
in_progressand the current stage resets tonot_started. - The map action is not completed — it stays open for a new decision once the re-worked stage drains again.
The stage is identified by slug (the stageSnapshotId suffix after the last
:stage: segment, or the full snapshot id, or the stage name). An unknown slug
returns a 422 return_stage_not_found error.
feedback:
prompt: What is your decision?
options:
- { value: recon, label: 'Proceed to recon' }
- { value: re-triage, label: 'Return to triage', returnToStage: triage }
allowNotes: trueIn the example above, selecting Proceed to recon completes the action and
advances the flow. Selecting Return to triage records the classification as
evidence and sends the flow back to the triage stage without completing the
decision action — the operator will see the same map decision again after the
triage stage has been re-worked.
writeTarget is skipped on backward-arm submissions; the classification is
recorded on the action only.
Map actions as multi-outcome decision gates
A map action doubles as a multi-outcome decision gate — it is not limited to
spatial classification. When the template's DECIDE step needs more than two
outcomes (Approve / Reject), model it as a map action with the options list
carrying your outcomes. Downstream canvas workflows then read the decision via
<start.processFlow.feedback.<taskSlug>.value>:
Do NOT wrap a reference in quotes inside a condition expression. The Condition block evaluates its expression as code, so a resolved string is substituted as a quoted JavaScript literal — the quoting is done for you.
// ✅ Correct — the reference substitutes as "recon", so this is a string comparison
<start.processFlow.feedback.decide.value> === "recon"
// ❌ Wrong — becomes ""recon"" === "recon", which fails to parse
"<start.processFlow.feedback.decide.value>" === "recon"This page advised the opposite until now, and the guidance was inverted by a
fix rather than by design: before the Condition block quoted references
itself, a bare reference really did become a bare identifier. Since that
changed, the hand-quoted form has raised Unexpected trailing tokens — a
thrown block rather than a silent branch, which is noisier but still the
wrong outcome for anyone following the instructions.
# In the DECIDE task — a map action with multiple outcomes:
- name: DECIDE
actionType: map
metadata:
geo:
objectTypes: [track]
focusAttribute: trackRef
feedback:
prompt: What action is required?
options:
- { value: recon, label: 'Proceed to recon' }
- { value: re-triage, label: 'Return to triage', returnToStage: triage }
allowNotes: true
writeTarget:
objectType: track
field: decision
# In the RESPOND canvas workflow — a condition block branching on the decision:
# condition expression (correct — the reference is NOT quoted):
# <start.processFlow.feedback.decide.value> === "recon"The feedback record in <start.processFlow.feedback> is keyed by task
template slug. Each entry exposes three fields: value (the selected option
value), notes (the operator's free-text, null when the notes field was
omitted or left blank), and decidedAt (ISO-8601 timestamp, null when
unavailable). The last completed map submission for that task wins; re-opens
leave the prior entry intact until a new submission commits.
The map feedback is also indexed in the instance knowledge base (folder
map-feedback/<stageSlug>/, page slug = the kebab-cased action name — not the
optional action slug field). Downstream workflow
agent steps can ground themselves in the decision via
metadata.retrieval.inputs[].fromTask, exactly like document and file-upload
tasks.
Advisory banner on decision actions
A workflow action running a @system/ agent can send its verdict to a
decision action — an approval gate or a map decision — for review before the
human makes their call. Declare the approval output surface with the slug of
the task that carries the decision:
defineAction({
name: 'Run AI assessment',
actionType: 'workflow',
workflowId: '@system/agent-multi-source-detector',
executionMode: 'automatic',
metadata: {
outputSurface: [
'workflow',
{ kind: 'approval', targetTaskSlug: 'decide-response' },
],
},
})The advisory banner appears at the top of the decision card when the AI step
completes. A verdict-shaped reply ({ verdict, confidence, … } JSON) shows the
recommendation and its confidence; any other reply shows an excerpt of the
agent's own text (up to 600 characters) instead of a made-up verdict. The
human's own decision remains the authority; the banner is advisory only, and it
is merged into the action without overwriting a decision already recorded.
advisorySource (default ai-agent) labels where the advisory came from.
Which decision action receives it. The candidates are the target task's own
decision actions plus every stage's decision actions — stage-level actions
are stamped onto a task as it enters each stage, so a task that travels through
a staged flow collects one gate per stage. When the target task carries more
than one candidate, give the gate an action slug and aim at it with
targetActionSlug:
defineStage({
name: 'Recover',
slug: 'recover',
transitionMode: 'approval',
actions: [
{
slug: 'recovery-sign-off', // unique across the whole template
name: 'Incident Commander signs off',
actionType: 'approval',
isRequired: true,
},
],
})
// …on the agent step:
metadata: {
outputSurface: [
'workflow',
{
kind: 'approval',
targetTaskSlug: 'incident',
targetActionSlug: 'recovery-sign-off',
},
],
}The rules, checked the same way when the extension is built or imported and when the advisory is written:
| Config | Result |
|---|---|
No targetActionSlug, task carries its own single decision action | That action — unchanged from before action slugs existed. |
No targetActionSlug, task has none of its own but exactly one stage gate | That stage gate. |
No targetActionSlug, task carries several decision actions of its own | The first of them in the task's action order (sortOrder, ties broken by a stable internal id), as before action slugs existed. The build and import report a warning naming them, and so does the runtime log. Add a targetActionSlug to choose. |
No targetActionSlug, task has none of its own but several stage gates | Nothing is written. The build and import report a warning, and the runtime log names every candidate. |
targetActionSlug matches a candidate's slug exactly | That action. |
targetActionSlug matches no decision action (a typo, or a non-decision action's slug) | Build error, naming the candidates. Import warning — see below. |
targetTaskSlug missing (including the shorthand 'approval'), names no task, or names a task that can carry no decision action | Build error. Import warning — see below. |
Import never refuses an already-built extension over these targets. Until
these checks existed, a broken target was accepted and simply wrote nothing,
so an extension built before them — possibly already installed elsewhere — may
carry one. Importing it accepts it: /api/extensions/import/inspect returns
each finding as a warning with the same code
(approval_surface_missing_target, approval_surface_unknown_task,
approval_surface_no_decision_action, approval_surface_unknown_action), and
/api/extensions/import installs the extension and records each one in the
server log. The advisory is still written nowhere. The extension upload screen
does not show these warnings yet. Rebuilding the extension fails until the
target is fixed.
Action slugs follow the usual slug grammar (lowercase letters, digits and
hyphens) and must be unique across the template — every stage's and every
task's actions — because a stage gate is stamped onto every task that enters
the stage. A malformed or duplicate slug fails extension build
(action_slug_invalid, action_slug_duplicate). An archive that already
carries one still installs, with the same code as a warning: a
targetActionSlug that matches two actions is ambiguous and writes nothing.
When a task re-enters a stage (for example after a return), the stage's gate is stamped again; the advisory lands on the newest copy, and only on the target task's own copy — another task passing through the same stage keeps its gate untouched. An advisory for a gate whose stage the task has not reached yet is skipped. An advisory on the wrong gate is worse than no advisory.
destruction
Destroys the platform's copies of data an earlier step collected, and records whether that destruction is proven. It exists for retention and erasure obligations, such as a GDPR Art. 17 request or a contract's destruction clause, where the step's outcome is evidence. It is therefore deliberately hard to fake: the outcome is decided by the platform, never typed in.
Use for: purging uploaded personal data once the process no longer needs it, and recording that a copy held in another system was deleted.
Schema fields used: name, description, executionMode (must be manual, and any other value is rejected at extension validate), and metadata.destruction, which is one of the forms below. A destruction step is always required: declare isRequired: true, because isRequired: false is rejected at extension validate. The step holds its task, is labelled Required, blocks closing the flow with Finish, and blocks advancing in the wizard until it is performed. An optional destruction would let the flow close with the files still stored and no outcome recorded. The same rules apply to a destruction step declared in a stage's actions, which every task entering that stage receives.
metadata.destruction | Meaning |
|---|---|
{ kind: "process_files", fromTask: "<task-slug>" } | The files uploaded to the file_upload action(s) of that task in the same run. The platform deletes them and checks that they are gone. Validation rejects a slug that names no task, and a task that holds no file_upload action. |
{ kind: "external", system: "<name>" } | A copy in a system the platform cannot reach, such as a form platform with no delete API or a mailbox. The operator deletes it there and records what they did. |
Every run records exactly one of three outcomes:
| Outcome | When |
|---|---|
verified | Every copy was permanently deleted, and a check afterwards found it gone. This also covers a run where the platform held no copy at all. |
attested | The outcome rests on something the platform cannot observe: a person's statement (every external target), or a copy that was only hidden rather than destroyed (a soft delete). An attested outcome is never presented as verified. |
failed | A delete returned an error, a copy was still present afterwards, the check afterwards could not be made (for example, the storage provider refused it), or the upload step names a file that is not this run's. The step stays open. A retry reaches only the copies that are left, and the copies already destroyed stay on the record. |
What a process_files step deletes: for every file uploaded to the named task, the stored original and its instance knowledge-base copy (the source record, the pages derived from it, and its stored blobs). A file whose copies are all gone is removed from the upload step, so no screen offers a download that no longer exists. A file with any copy left stays on the upload step.
Only this run's copies. The step deletes a file only if it was uploaded to this run: a stored file under this run's own upload folder in its workspace and environment, and a knowledge-base copy in this run's own folder (when runs of one template share a knowledge base, the other runs' folders are out of reach). Anything else the upload step names is left untouched and recorded as refused: not a copy held by this run, and the outcome is failed, because the step names something the platform did not destroy.
What "verified" does not cover. It means the platform's delete succeeded and a check of the current object found nothing. It does not reach:
- Storage-provider retention, such as bucket versioning, soft-delete retention on the storage account, or backups. The platform cannot see these, so configure them on the provider to match your obligation.
- Content an AI step already derived from the files, such as a generated document, an assessment page, or a message a
distributionstep already sent. Put the destruction step before those steps, or account for them separately. - Copies in other systems, including files an extension pushed elsewhere. Declare those as an
externaltarget.
Audit trail: every outcome, failed included, writes a process_flow.destruction.recorded audit event, recorded against the person who performed or attested the step. It carries the outcome, the target kind, the number of copies, whether a person attested, and for an external target the declared system. It never carries a file name or storage key, because those can be the personal data being erased. A retry that repeats the audit write for the same outcome is deduplicated, so one destruction leaves one audit event. The run's activity log records a destruction_recorded entry.
Example:
- name: Collect payroll export
slug: collect-payroll
actions:
- name: Upload payroll export
actionType: file_upload
isRequired: true
- name: Purge payroll export
slug: purge-payroll
actions:
- name: Destroy uploaded payroll data
actionType: destruction
isRequired: true
executionMode: manual
metadata:
destruction:
kind: process_files
fromTask: collect-payroll
- name: Confirm the intake form was deleted
actionType: destruction
isRequired: true
metadata:
destruction:
kind: external
system: Microsoft FormsWhat the assignee sees: for process_files, a Destroy files button behind a confirmation, followed by the outcome and the result for each copy. For external, a note field and a Record attestation button. The step cannot be ticked off from the checklist. The generic action update refuses to complete, skip, fail, or start a destruction step, or write its content, and returns 409 destruction_outcome_required. The engine refuses the same changes from every other path, so closing the flow with Finish cannot skip the step either. If the step's stage is reopened, the previous outcome is archived and the step must be performed again, so files uploaded since are destroyed too. Only one person can perform the step at a time: a second request while one is running gets 409 destruction_in_progress. The record lists each copy by kind and number, never by file name.
form
Asks a person to fill in author-declared fields and delivers the answers without storing them. The platform validates the submission against the declared fields, hands the values to the form's destination, and drops them. What is recorded on the action is only the fact of the submission: when, by whom, to which destination, and the outcome.
Use for: collecting data the flow must act on but must not keep — a one-off contact for a hand-off, a confirmation with a few typed answers, details a counterparty needs by email. National ID numbers and bank details must not go through the email destination; see Destinations below. If the answers themselves are the outcome the flow must keep, use a document action (or a workflow that writes an ontology object) instead.
Schema fields used: name, description, isRequired, and metadata.form, which is required for this type (a form action without it fails extension validate):
metadata.form key | Meaning |
|---|---|
prompt | Optional. A short instruction shown above the fields. |
fields | 1–50 field declarations (below). Keys must be unique within the form. |
destination | Optional. Where the answers go: { kind: "transient" } (the default) or { kind: "email", to, subject }. See Destinations below. |
Each field:
| Field key | Meaning |
|---|---|
key | Identifier, ^[a-zA-Z][a-zA-Z0-9_]{0,63}$. |
label | Shown to the person filling the form. |
description | Optional help text under the label. |
type | text, textarea, email, number, date (ISO YYYY-MM-DD), boolean or select. |
required | Default false. A required boolean must be ticked. |
options | select only, and required there: { value, label } items with unique values. |
maxLength | String types only. Defaults: text/email 1,000, textarea 10,000; at most 20,000. |
Example:
defineAction({
name: 'Payroll details',
actionType: 'form',
isRequired: true,
metadata: {
form: {
prompt: 'Provide the details payroll needs for the first salary run.',
fields: [
{ key: 'iban', label: 'IBAN', type: 'text', required: true, maxLength: 34 },
{ key: 'contractStart', label: 'Contract start', type: 'date', required: true },
{
key: 'contractType',
label: 'Contract type',
type: 'select',
options: [
{ value: 'employee', label: 'Employee' },
{ value: 'contractor', label: 'Contractor' },
],
},
{ key: 'confirmed', label: 'The details above are correct', type: 'boolean', required: true },
],
destination: { kind: 'transient' },
},
},
})What the assignee sees: the declared fields, a notice saying where the answers go, and a Submit button. For transient the notice says the answers are not stored. For email it lists the addresses and says that Scrydon does not store the answers but the email provider and the recipients' mailboxes keep a copy. Field errors are shown per field before anything is sent. Submitting completes the action. Afterwards the step shows Form submitted, the time, and the same statement about who holds a copy. There are no answers to show, because Scrydon kept none.
What "not stored" covers. The submitted values are not written to the action, the instance knowledge base, the activity log, an event, a log line or a trace, and a rejected submission is not kept for a retry — the person submits again. A CI test submits a unique token through the form and searches every database table, every log call, every span and every request to the Dapr sidecar for it. What is recorded is content.formSubmission: submittedAt, submittedBy, destination and outcome. A failed email delivery also writes an activity-log entry that names recipients, never answers (see Destinations).
Only the form route completes a form. A form action cannot be completed, have its content written, or — once submitted — be moved back to another status through the generic action update; each returns 409 with code: "form_submission_required". To collect the answers again, reopen the stage. Submitting the same form twice at once completes it once; the second submission returns 409 with error: "already_completed". The engine refuses to complete a form action that has no submission record, whatever path asks.
Destinations.
kind | What happens on submit |
|---|---|
transient | Validation is the whole delivery. The values reach no other system. |
email | The answers are emailed as plain text, one Label: answer line per declared field under the prompt, to each address in to. Scrydon keeps no copy; your organization's email provider and each recipient's mailbox do. |
An email destination declares:
| Key | Meaning |
|---|---|
to | 1–10 external addresses. Addresses are lowercased, and duplicates are refused. |
subject | Up to 200 characters, fixed at authoring time. It cannot contain {{…}} placeholders or control characters. The subject is the one part of the message that mail logs record, so it can never carry an answer. |
destination: {
kind: 'email',
to: ['payroll@example.com'],
subject: 'New starter details',
},Every recipient must be allowed by your organization's External distribution policy. The policy is checked at every submission, before anything is sent. If any recipient is not allowed, nothing is sent to anyone. extension install also warns about recipients the policy does not allow.
The allowed-domain list is the only control. Do not use the email destination for national ID numbers or bank details. In this version a recipient is checked against the External distribution allowlist and nothing else: Scrydon does not run DLP over the email body and does not compare the answers with a classification or clearance. Once sent, the answers are held by your organization's email provider and in each recipient's mailbox, under their retention and outside Scrydon's control. Use the email destination for details whose copy in a mailbox you accept.
The recipients are mailed one at a time, in the order of to, and the first failed send stops the rest. A failed send returns 502 and leaves the step open:
delivery_failed: no recipient received the answers.delivery_partial: the recipients before the failed one received the answers.
Either way the activity log records a form_delivery_failed entry. It holds the outcome (failed or partial), deliveredTo, failedRecipient (the address and a reason code) and notAttempted. It never holds the answers.
A send cannot be taken back, and the email goes out before the step is completed. In two cases a recipient can therefore receive the same answers twice. This version accepts that:
- Two submissions at once. Both can send their answers. The one that does not complete the step still returns
409witherror: "already_completed", even though its email went out. - Resubmitting after
delivery_partial. The step is still open, and submitting again mails every recipient into, including those who already have a copy.
Delivery to an integration, an ontology write, internal members, and recording answers on the action are not available. Each of them would keep a copy of the answers somewhere in Scrydon.
persona_assignment
A step whose completion is a person selection: the assignee picks the organization members who will hold one of the template's personas, and submitting the pick completes the step. Use it when who does later work is decided during the run — the hiring manager nominates the onboarding buddy, the incident lead names the scribe — rather than when the instance is started.
Use for: nominating the person (or people) a later task is assigned to, when that choice belongs to someone inside the flow rather than to whoever started it.
Schema fields used: name, description, isRequired, and
metadata.assignsPersona — the slug of a persona declared in
template.personas. assignsPersona is required, executionMode must be
manual (a person has to choose), and the persona must be one a human can hold:
extension validate rejects an undeclared persona (unknown_persona), a
system persona (system_persona_not_assignable), and a persona that decides
an approval in the same template — a stage's approverPersona, an approval
action's persona, or the task role such an action falls back to
(approval_persona_not_assignable). A run may not staff the persona that
approves its own work, so that step could never complete.
personas: [
{ slug: 'hiring-manager', displayName: 'Hiring manager', cardinality: 'one' },
{ slug: 'buddy', displayName: 'Onboarding buddy', cardinality: 'one' },
],
taskTemplates: [
{
slug: 'pick-buddy',
stageSlug: 'onboarding',
name: 'Pick the onboarding buddy',
defaultAssignedRoleSlug: 'hiring-manager',
actions: [
defineAction({
name: 'Nominate buddy',
actionType: 'persona_assignment',
isRequired: true,
metadata: { assignsPersona: 'buddy' },
}),
],
},
{
slug: 'first-day-lunch',
stageSlug: 'onboarding',
name: 'Arrange the first-day lunch',
defaultAssignedRoleSlug: 'buddy',
dependsOnTaskSlugs: ['pick-buddy'],
},
],What the assignee sees: a person picker over the organization's members —
a single choice for a one persona, several for a many persona — with the
current holders shown, and an Assign and complete button.
What completion does. The pick goes through the same persona write path an
operator's Manage personas dialog uses, recorded with provenance task (the
task and action ids are on the audit row):
Persona cardinality | Effect of the pick |
|---|---|
one | Replaces the current holder. Picking two people is refused. |
many | Adds the picked people; existing holders stay. |
A task that depends on the step — here first-day-lunch — stays locked until
the pick lands, and the new holders are notified of the open tasks waiting on
their persona. The step cannot be completed any other way: marking it complete
without a pick is refused with persona_assignment_required.
Two picks are always refused, and each refusal is audited:
seating yourself — the person completing the step cannot pick themselves
(assign_persona_self_seating); and someone outside the organization —
non-members are never seated (assign_persona_no_assignable_users when
nobody could be).
A step may staff a persona that decides an approval on the run — the
hiring manager naming who signs the offer, for example. The people picked can
then approve the actions that persona gates, and the seating is recorded on the
instance's activity log with the step that made it. Only a workflow is refused
that (assign_persona_approval_persona).
Choosing the right type
A short decision flow for when authors are unsure:
| If the step is… | Use |
|---|---|
| A confirmation, attestation, or "I did it" tick | checklist |
| Free-form text the assignee writes inside the flow | document |
| A formal sign-off that gates the next stage | approval |
| Executable logic — an AI agent, extension sequence, or HITL gate | workflow |
| Bytes that come from outside the flow (PDFs, decks, exports) | file_upload |
| A message broadcast to an external channel | distribution |
| Audio capture | voice_trigger |
| Governed geo layer + operator classification (spatial situational-awareness step) | map |
| Typed answers the flow must act on but must not keep | form |
| Deleting data an earlier step collected, with evidence of whether it is gone | destruction |
| Choosing who holds a persona for the rest of the run | persona_assignment |
If the step is "fill in a typed Object," there isn't a first-class action type yet — model it as a workflow action that calls an agent or extension block to create the instance via the ontology API.
Common validation pitfalls
The extension validate command runs the Zod schema and rejects manifests that violate these rules. The ones that bite most often:
| Error | Cause | Fix |
|---|---|---|
workflowId and workflowSlug are mutually exclusive on an action template | Both workflowId and workflowSlug set on the same action. | Pick one. Use workflowId for @system/* and tenant-owned workflows; use workflowSlug for workflows shipped in the same extension. |
Invalid enum value. Expected … received "custom" (or similar) | A typo'd or legacy actionType value. | The runtime accepts exactly the eleven types on this page. There is no notification or custom type at the SDK layer. |
A form action must declare metadata.form (its fields and destination) | actionType: form without a metadata.form block. | Declare the fields under metadata.form.fields. |
A select field must declare its options / Field keys must be unique within a form | A select field without options, or two fields with the same key. | Give every select its options and every field a unique key. |
400 invalid_submission on form submit at runtime | A required field is empty, a value has the wrong type or is too long, a select value is not a declared option, or a key is not declared. | The response lists each failing field by key and error code — never the value. Fix the answer and submit again. |
422 destination_not_permitted on form submit | The organization's External distribution policy does not allow one of the email recipients. recipientErrors names each refused address. Nothing was sent. | Ask an administrator to allow the domain, or change the recipients and re-publish. |
503 destination_unavailable / 502 delivery_failed / 502 delivery_partial on form submit | The policy could not be read, or a mail send failed: delivery_failed means no recipient received the answers, delivery_partial means the recipients before the failed one did. The step stays open, and nothing of the answers is recorded. A failed send writes a form_delivery_failed activity-log entry naming who received a copy. | Submit again. After delivery_partial, the recipients who already have the answers receive a second copy. |
409 form_submission_required on an action update | A client tried to complete a form action, write its content, or change the status of a submitted one through the generic action update. | Submit through the form; to change a submitted form, reopen its stage. |
Required on quorumCount | metadata.completion: "quorum" without quorumCount. | Add quorumCount: <n>. |
Slug must be lowercase alphanumeric with hyphens on workflowSlug | Underscores or capitals in the slug. | Use kebab-case. |
Action triggerPath doesn't resolve at runtime | metadata.triggerPath on a voice_trigger action doesn't match any entry in template.voiceTriggers[].path. | Schema doesn't catch this; "Prepare Recording" fails with "Voice trigger not found" (404). The match ignores a leading slash but is otherwise exact — fix the path in either place so they pair up, then re-publish the extension. |
Destruction action "…" must be manual | A destruction action declared executionMode: automatic or on_entry. | Remove executionMode or set it to manual. The outcome is recorded against the person who performs or attests the deletion. |
Destruction action "…" must be required | A destruction action declared isRequired: false. | Set isRequired: true. A destruction step that should not have to run belongs out of the template. |
Destruction action "…" declares no destruction target | actionType: destruction without metadata.destruction. | Declare { kind: process_files, fromTask } or { kind: external, system }. |
Destruction target "…" holds no file_upload action / references no task in this template | fromTask names a task without a file_upload action, or no task at all. | Point fromTask at the slug of the task that collects the files. |
409 destruction_outcome_required at runtime | Something tried to complete, skip, fail, or start a destruction step, or write its content, through the generic action update. | Perform the step through its own endpoint (POST …/actions/<id>/destruction), which is what the task panel uses. A destruction step that should not run belongs out of the template. |
409 destruction_in_progress at runtime | Another request is already performing the same destruction step. | Wait for it to finish and reload the step. A claim left by a crashed request expires after 15 minutes. |
A copy recorded as refused: not a copy held by this run | The upload step names a file that was not uploaded to this run, for example after its file list was edited. | The platform will not delete it from this step. Remove the entry from the upload step, and destroy the file from the run that holds it. |
A copy recorded as probe failed: … | The delete was sent, but the check afterwards could not be made. On S3, credentials without s3:ListBucket are refused this check for a file that is already gone. | Fix the storage permission, then perform the step again. |
400 on map action submit at runtime | The submitted classification value is not one of the values declared in metadata.feedback.options. | The runtime rejects any value not present in the author-declared options list — fix the submitted value or update the options list in the extension and re-publish. |
Where to next
The parent page — anatomy of a template, complete example, build/upload steps, security caps.
Author the workflows that actionType: "workflow" references. Covers the workflowSlug rebind contract.
Download the full extension archives the examples on this page are pulled from.
The typed Object Types referenced by entity_link and by ontologyContract on tasks and actions.