Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
9.3 KiB
Workflow Packages
v0.30.2
Workflow packages bundle a workflow definition, stage configuration, and
optional custom surfaces into a single .pkg file. They can be exported
from one instance and imported into another.
Concepts
A workflow is a multi-stage process (intake form → AI chat → human
review → completion). Each stage has a surface that renders the UI:
built-in surfaces (form, chat, review) or a custom package surface via
surface_pkg_id.
Exporting a Workflow
GET /api/v1/admin/workflows/:id/export
Returns a .pkg ZIP with:
manifest.json workflow manifest (type: "workflow")
js/ surface assets (if the workflow references package surfaces)
script.star optional Starlark handlers
The manifest includes the full workflow definition and all stages:
{
"id": "onboarding-flow",
"title": "Employee Onboarding",
"type": "workflow",
"version": "1.0.0",
"workflow_definition": {
"name": "Employee Onboarding",
"slug": "employee-onboarding",
"description": "New hire onboarding workflow",
"entry_mode": "public_link",
"stages": [
{
"name": "Intake",
"ordinal": 0,
"stage_mode": "form_only",
"history_mode": "full",
"auto_transition": false,
"form_template": {
"fields": [
{ "key": "full_name", "type": "text", "label": "Full Name", "required": true },
{ "key": "email", "type": "email", "label": "Email", "required": true },
{ "key": "department", "type": "select", "label": "Department",
"options": ["Engineering", "Sales", "Marketing"] }
]
}
},
{
"name": "AI Orientation",
"ordinal": 1,
"stage_mode": "chat_only",
"history_mode": "full",
"persona_id": null
},
{
"name": "Manager Review",
"ordinal": 2,
"stage_mode": "review",
"history_mode": "summary",
"surface_pkg_id": "review-dashboard"
}
]
}
}
Importing a Workflow
POST /api/v1/admin/packages/install
Content-Type: multipart/form-data
file=@onboarding-flow.pkg
The install handler detects type: "workflow" and:
- Creates the workflow from
workflow_definition - Creates all stages with their configuration
- Preserves
surface_pkg_idreferences (the referenced package must be installed separately) - Sets the workflow as inactive (admin activates manually)
Stage Modes
Each stage has a stage_mode that determines the built-in surface:
| Mode | Surface | Description |
|---|---|---|
chat_only |
Chat | AI conversation with the visitor |
form_only |
Form | Structured data collection, no AI |
form_chat |
Form + Chat | Form fields alongside AI chat |
review |
Review | Human reviewer sees collected data, approve/reject |
Custom Stage Surfaces (surface_pkg_id)
Any stage can override its built-in surface with a custom package
surface by setting surface_pkg_id to an installed package ID.
How It Works
- Admin sets
surface_pkg_idon a stage (via dropdown in stage editor or API) - When a visitor reaches that stage, the workflow template checks for
surface_pkg_id - If set, it dynamically loads
/surfaces/{pkg_id}/js/main.js - The package JS registers a surface via
sw.workflow.registerSurface(name, factory) - The surface is mounted into the stage container with context
Writing a Custom Surface
Create a package with js/main.js:
// js/main.js — Custom review dashboard surface
(function() {
sw.workflow.registerSurface('review-dashboard', function(container, ctx) {
// ctx contains:
// channelId — workflow instance channel
// sessionId — visitor session ID
// basePath — API base path
// stageMode — the stage_mode value
// formTemplate — form schema (if applicable)
// totalStages — number of stages in workflow
// currentStage — current stage index (0-based)
container.innerHTML = '<div class="review-panel">Loading...</div>';
// Fetch workflow data
sw.workflow.getContext(ctx.channelId).then(function(status) {
container.innerHTML = renderReview(status);
});
return {
mount: function() { /* called when surface attaches */ },
unmount: function() { /* called when surface detaches */ }
};
});
})();
Package manifest:
{
"id": "review-dashboard",
"title": "Review Dashboard",
"type": "surface",
"version": "1.0.0"
}
Setting surface_pkg_id
Via admin UI:
Admin → Workflows → select workflow → click Edit on a stage → Custom Surface Package dropdown → select a package.
The dropdown shows all installed packages of type surface, workflow,
or full. Stages with a custom surface show a pkg: <id> badge in the
stage list.
Via API:
# Set custom surface
curl -X PUT /api/v1/workflows/:wf_id/stages/:stage_id \
-H "Content-Type: application/json" \
-d '{
"name": "Review",
"ordinal": 2,
"stage_mode": "review",
"history_mode": "summary",
"surface_pkg_id": "review-dashboard"
}'
# Clear (revert to built-in)
curl -X PUT /api/v1/workflows/:wf_id/stages/:stage_id \
-d '{ "name": "Review", "ordinal": 2, "stage_mode": "review",
"history_mode": "summary", "surface_pkg_id": null }'
Note: stage update is PUT (full replace), not PATCH. All stage fields must be included in the request body.
Fallback Behavior
surface_pkg_id = null→ built-in surface based onstage_modesurface_pkg_idreferences a missing/disabled package → error logged, falls back to built-in surface- Package deleted →
ON DELETE SET NULLclears the reference automatically (PostgreSQL FK constraint)
Starlark Workflow Module
Extensions with the workflow.access permission can interact with
workflows from Starlark code.
workflow.get_definition(workflow_id)
Returns the workflow definition including all stages.
def on_tool_call(tool_name, params):
wf = workflow.get_definition(params["workflow_id"])
# wf: { id, name, slug, stages: [{ name, stage_mode, surface_pkg_id, ... }] }
return {"stages": len(wf["stages"])}
workflow.get_stage_data(channel_id)
Returns the current stage data for a workflow instance.
data = workflow.get_stage_data(channel_id)
# data: { current_stage, stage_data, ... }
workflow.advance(channel_id)
Programmatically advance to the next stage.
workflow.advance(channel_id)
workflow.reject(channel_id, reason)
Reject the current stage and go back.
workflow.reject(channel_id, "Missing required information")
Form Template Schema
Stages with form_only or form_chat mode use form_template to
define structured data collection.
Field Types
| Type | Renders As | Validation |
|---|---|---|
text |
Text input | min_length, max_length, pattern |
email |
Email input | Format validation |
number |
Number input | min, max |
date |
Date picker | min_date, max_date |
select |
Dropdown | options array required |
textarea |
Multi-line text | min_length, max_length |
checkbox |
Checkbox | — |
file |
File upload | — |
Field Schema
{
"fields": [
{
"key": "full_name",
"type": "text",
"label": "Full Name",
"required": true,
"placeholder": "Enter your name",
"min_length": 2,
"max_length": 100
},
{
"key": "department",
"type": "select",
"label": "Department",
"required": true,
"options": ["Engineering", "Sales", "Marketing", "HR"]
}
]
}
Validation Hooks
Packages with forms.validate permission can provide custom validation:
# script.star
def validate(fields, stage_data):
errors = []
if fields.get("email", "").endswith("@competitor.com"):
errors.append({"field": "email", "message": "Invalid email domain"})
return errors # empty list = valid
def on_submit(fields, stage_data):
# Called after validation passes
# Can transform or enrich data before it's saved
return fields
Database Schema
workflow_stages table
| Column | PG Type | SQLite Type | Description |
|---|---|---|---|
id |
UUID |
TEXT |
Stage ID |
workflow_id |
UUID |
TEXT |
Parent workflow FK |
ordinal |
INTEGER |
INTEGER |
Stage order (0-based) |
name |
TEXT |
TEXT |
Display name |
persona_id |
UUID |
TEXT |
AI persona FK (nullable) |
assignment_team_id |
UUID |
TEXT |
Team assignment FK (nullable) |
form_template |
JSONB |
TEXT |
Form field schema |
stage_mode |
TEXT |
TEXT |
chat_only|form_only|form_chat|review |
history_mode |
TEXT |
TEXT |
full|summary|fresh |
auto_transition |
BOOLEAN |
INTEGER |
Auto-advance on completion |
transition_rules |
JSONB |
TEXT |
Conditional transition config |
surface_pkg_id |
TEXT |
TEXT |
Custom surface package FK (nullable) |
created_at |
TIMESTAMPTZ |
TEXT |
Creation timestamp |
The surface_pkg_id column has a foreign key to packages(id) with
ON DELETE SET NULL in PostgreSQL. SQLite enforces FK constraints via
PRAGMA foreign_keys = ON.