# 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: ```json { "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: 1. Creates the workflow from `workflow_definition` 2. Creates all stages with their configuration 3. Preserves `surface_pkg_id` references (the referenced package must be installed separately) 4. 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 1. Admin sets `surface_pkg_id` on a stage (via dropdown in stage editor or API) 2. When a visitor reaches that stage, the workflow template checks for `surface_pkg_id` 3. If set, it dynamically loads `/surfaces/{pkg_id}/js/main.js` 4. The package JS registers a surface via `sw.workflow.registerSurface(name, factory)` 5. The surface is mounted into the stage container with context ### Writing a Custom Surface Create a package with `js/main.js`: ```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 = '
Loading...
'; // 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: ```json { "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: ` badge in the stage list. **Via API:** ```bash # 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 on `stage_mode` - `surface_pkg_id` references a missing/disabled package → error logged, falls back to built-in surface - Package deleted → `ON DELETE SET NULL` clears 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. ```python 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. ```python data = workflow.get_stage_data(channel_id) # data: { current_stage, stage_data, ... } ``` ### workflow.advance(channel_id) Programmatically advance to the next stage. ```python workflow.advance(channel_id) ``` ### workflow.reject(channel_id, reason) Reject the current stage and go back. ```python 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 ```json { "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: ```python # 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`.