Changeset 0.30.2 cs2 (#202)

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
2026-03-18 18:05:58 +00:00
committed by xcaliber
parent 7b0b6eb061
commit 5883cb50e2
17 changed files with 1231 additions and 31 deletions

View File

@@ -1,4 +1,4 @@
# Architecture — Chat Switchboard v0.26
# Architecture — Chat Switchboard v0.30.2
## Deployment Modes

View File

@@ -1,9 +1,10 @@
# DESIGN — Surface & Extension Architecture
**Status:** Accepted (Phases 12 implemented in v0.25.1)
**Scope:** Primitives, Components, Surfaces, Extension hooks, Themes
**Depends on:** v0.22.5 (Go template engine), v0.22.7 (ChatPane), v0.25.1 (audit)
**Status:** Accepted (Phases 12 implemented in v0.25.1)
**Scope:** Primitives, Components, Surfaces, Extension hooks, Themes
**Depends on:** v0.22.5 (Go template engine), v0.22.7 (ChatPane), v0.25.1 (audit)
**Informs:** v0.22.8+, EXTENSIONS.md rewrite, ARCHITECTURE.md update, ICD-API, ICD-SURFACE
**Note:** For current package/surface authoring, see [PACKAGES.md](PACKAGES.md) and [EXTENSION-SURFACES.md](EXTENSION-SURFACES.md). For workflow surfaces, see [WORKFLOW-PACKAGES.md](WORKFLOW-PACKAGES.md).
---

View File

@@ -1,8 +1,9 @@
# Extension Surfaces — Authoring Guide
**Version:** v0.27.0+
**Version:** v0.30.2
**Audience:** Developers building custom surfaces for Chat Switchboard
**Prerequisite:** Admin access to install surfaces
**See also:** [PACKAGES.md](PACKAGES.md) for the `.pkg` archive format, [SDK.md](SDK.md) for the `sw.*` API
---

View File

@@ -1,8 +1,8 @@
# Chat Switchboard — Extension System Specification
**Version:** 0.3
**Status:** Browser tier (Tier 0) implemented in v0.11.0. Starlark and Sidecar tiers planned.
**See also:** [ARCHITECTURE.md](ARCHITECTURE.md), [ROADMAP.md](ROADMAP.md)
**Version:** 0.4
**Status:** All tiers implemented (Browser v0.11.0, Starlark v0.25.0, Sidecar v0.26.0). v0.30.2 adds workflow packages and SDK.
**See also:** [ARCHITECTURE.md](ARCHITECTURE.md), [ROADMAP.md](ROADMAP.md), [PACKAGES.md](PACKAGES.md), [SDK.md](SDK.md)
---

275
docs/PACKAGES.md Normal file
View File

@@ -0,0 +1,275 @@
# Package Format & Manifest Reference
> v0.30.2
A `.pkg` file is a ZIP archive containing a `manifest.json` and optional
assets. Packages extend Chat Switchboard with surfaces (routable UIs),
extensions (server-side hooks/tools), or workflows.
## Archive Structure
```
manifest.json required — package metadata and configuration
js/ optional — JavaScript assets (served at /surfaces/:id/js/)
main.js entry point (loaded by surface template)
css/ optional — stylesheets
assets/ optional — images, icons, etc.
script.star optional — Starlark script (extension/sidecar tiers)
migrations/ optional — Starlark migration scripts (keyed by version)
1.star
2.star
```
## Building & Installing
```bash
# Build from packages/ directory
bash packages/build.sh my-package # one package
bash packages/build.sh # all packages
# Install via API
curl -X POST http://localhost:3000/api/v1/admin/packages/install \
-H "Authorization: Bearer $TOKEN" \
-F file=@dist/my-package.pkg
# Install via admin UI
# Admin → System → Packages → Upload
```
## Manifest Schema
### Required Fields
| Field | Type | Description |
|-------|------|-------------|
| `id` | `string` | Unique package identifier (kebab-case). Used as directory name and DB key. |
| `title` | `string` | Human-readable display name. |
### Optional Fields
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `type` | `string` | `"surface"` | Package type (see below). |
| `version` | `string` | `""` | Semver version string. |
| `description` | `string` | `""` | Short description shown in admin UI. |
| `author` | `string` | `""` | Author name or org. |
| `tier` | `string` | `"browser"` | Execution tier: `browser`, `starlark`, `sidecar`. |
| `route` | `string` | — | URL path for surface routing (e.g. `/s/my-surface`). |
| `auth` | `string` | `"authenticated"` | Auth requirement: `authenticated` or `public`. |
| `layout` | `string` | `"single"` | Page layout template. |
| `permissions` | `string[]` | `[]` | Required permissions (see Permissions below). |
| `tools` | `object[]` | — | Server-side tool declarations for AI tool-use. |
| `hooks` | `string[]` | — | Lifecycle hooks: `surface`, `on_install`, `on_uninstall`. |
| `settings` | `object` | — | Settings schema for admin-configurable options. |
| `schema_version` | `integer` | `0` | Current data schema version (for migrations). |
| `migrations` | `object` | — | Starlark migration scripts keyed by target version. |
| `network_access` | `string[]` | — | Allowed external hostnames (sidecar tier). |
| `api_routes` | `object[]` | — | Custom HTTP routes handled by Starlark `on_request`. |
| `db_tables` | `object[]` | — | Extension-owned database tables. |
| `pipes` | `object` | — | Filter pipeline registrations. |
| `components` | `string[]` | — | UI component identifiers. |
| `workflow_definition` | `object` | — | Embedded workflow definition (type `"workflow"` only). |
## Package Types
### `surface` (default)
A routable page served at the `route` path. The browser loads
`js/main.js` and renders into `#extension-mount`.
```json
{
"id": "my-dashboard",
"title": "My Dashboard",
"type": "surface",
"route": "/s/my-dashboard",
"auth": "authenticated",
"layout": "single",
"version": "1.0.0"
}
```
### `extension`
Server-side logic without a routable UI. Runs Starlark code with
granted permissions.
```json
{
"id": "auto-tagger",
"title": "Auto Tagger",
"type": "extension",
"tier": "starlark",
"version": "1.0.0",
"permissions": ["filters.pre_completion"],
"tools": [
{
"name": "tag_message",
"description": "Auto-tag messages based on content"
}
]
}
```
### `full`
Both a surface and an extension. Has a routable page and server-side
hooks/tools.
```json
{
"id": "analytics",
"title": "Analytics Suite",
"type": "full",
"tier": "starlark",
"route": "/s/analytics",
"version": "1.0.0",
"permissions": ["db.read", "db.write"],
"db_tables": [
{ "name": "events", "columns": { "ts": "text", "event": "text", "data": "text" } }
]
}
```
### `workflow`
A packaged workflow definition with optional surfaces and handlers.
See [WORKFLOW-PACKAGES.md](WORKFLOW-PACKAGES.md) for details.
```json
{
"id": "onboarding-flow",
"title": "Employee Onboarding",
"type": "workflow",
"version": "1.0.0",
"workflow_definition": {
"name": "Employee Onboarding",
"slug": "employee-onboarding",
"entry_mode": "public_link",
"stages": [...]
}
}
```
## Permissions
Declared in `manifest.permissions`. Admins grant/revoke per-package in
the admin UI.
| Permission | Description |
|-----------|-------------|
| `secrets.read` | Read secrets from the vault |
| `notifications.send` | Send push notifications |
| `filters.pre_completion` | Intercept messages before AI completion |
| `db.read` | Read from extension-owned tables |
| `db.write` | Write to extension-owned tables |
| `api.http` | Make outbound HTTP requests (sidecar tier) |
| `provider.complete` | Call LLM completion APIs |
| `forms.validate` | Validate workflow form submissions |
| `workflow.access` | Access workflow definitions and stage data |
## Settings Schema
Packages can declare admin-configurable settings. The schema drives a
form in the admin UI under System → Packages → Settings.
```json
{
"settings": {
"api_key": {
"type": "string",
"label": "API Key",
"description": "External service API key",
"required": true
},
"max_results": {
"type": "number",
"label": "Max Results",
"default": 10
},
"enabled": {
"type": "boolean",
"label": "Enable Feature",
"default": true
}
}
}
```
Settings are stored in the `package_settings` column and accessible from
Starlark via `settings.get("key")`.
## Data Migrations
Packages with `db_tables` can version their schema using Starlark
migration scripts. The engine runs migrations sequentially on
install/upgrade and rejects downgrades.
```json
{
"schema_version": 2,
"migrations": {
"1": "def migrate(db):\n db.query('CREATE TABLE ...')\n",
"2": "def migrate(db):\n db.query('ALTER TABLE ...')\n"
}
}
```
Or reference files in the archive:
```
migrations/
1.star # def migrate(db): ...
2.star # def migrate(db): ...
```
## Extension Database Tables
Tables are namespaced to `ext_{pkg_id}_{table}` to prevent collisions.
Platform views provide read-only access to core data:
| View | Columns |
|------|---------|
| `ext_view_users` | `id`, `display_name`, `email` |
| `ext_view_channels` | `id`, `title`, `type`, `team_id` |
The `ext_data_tables` catalog tracks all extension-owned tables for
install/uninstall lifecycle management.
## Server-Side Tools
Extensions can declare tools for AI tool-use. The `on_tool_call` entry
point in the Starlark script handles dispatching.
```json
{
"tools": [
{
"name": "lookup_customer",
"description": "Look up customer by email",
"parameters": {
"email": { "type": "string", "required": true }
}
}
]
}
```
```python
# script.star
def on_tool_call(tool_name, params):
if tool_name == "lookup_customer":
result = http.get("https://api.example.com/customers?email=" + params["email"])
return {"name": result["name"], "plan": result["plan"]}
```
## User-Installable Packages
Packages can be scoped to individual users or teams (v0.30.0). Only
`browser`-tier packages support user installation. Scope options:
| Scope | Visibility |
|-------|-----------|
| `global` | All users (admin-installed only) |
| `team` | Team members only |
| `personal` | Installing user only |

View File

@@ -33,10 +33,10 @@ v0.9.xv0.28.7 Foundation through Platform Polish ✅
v0.29.0 Starlark Sandbox ✅ v0.32.0 Multi-Replica HA
v0.29.1 API Extensions ✅ v0.33.0 Observability
v0.29.2 DB Extensions ✅ v0.34.0 Data Portability
v0.29.3 Workflow Forms
v0.30.0 Package Lifecycle
v0.30.1 SDK Adoption
v0.30.2 Workflow Packages
v0.29.3 Workflow Forms
v0.30.0 Package Lifecycle
v0.30.1 SDK Adoption
v0.30.2 Workflow Packages
v0.31.0 Editor Package │
│ │
══════╪═══════════════════════════════╪══════
@@ -217,7 +217,7 @@ Depends on: v0.29.2, v0.29.0 (Starlark validators).
- [x] Form builder in workflow admin (visual field editor)
- [x] Cross-visitor isolation E2E test (deferred from v0.28.4)
### v0.30.0 — Package Lifecycle
### v0.30.0 — Package Lifecycle
Lifecycle sophistication for `.pkg` format.
@@ -229,7 +229,7 @@ Depends on: v0.29.2.
- [x] Package marketplace (discovery, not hosting)
- [x] User-installable packages (RBAC-gated, team/personal scope)
### v0.30.1 — SDK Adoption
### v0.30.1 — SDK Adoption
Migrate core surfaces to `sw.*` SDK.
@@ -240,16 +240,20 @@ Depends on: v0.28.5, v0.30.0.
- [x] Phase 5 FE decomp: `import`/`export` statements
- [x] Memory compaction: summarize, confidence decay, prune
### v0.30.2 — Workflow Packages
### v0.30.2 — Workflow Packages
Workflows as installable packages. Visual builder for team admins.
Depends on: v0.29.3, v0.30.0, v0.30.1.
- [ ] Stage surfaces: form, chat, review, custom `.pkg`
- [ ] `.pkg` type `"workflow"` bundles definition + surfaces + handlers
- [ ] Team admin workflow builder (visual, no JSON editing)
- [ ] `sw.workflow` SDK namespace
- [x] Stage surfaces: form, chat, review, custom `.pkg`
- [x] `.pkg` type `"workflow"` bundles definition + surfaces + handlers
- [x] Team admin workflow builder (visual, no JSON editing)
- [x] `sw.workflow` SDK namespace
- [x] ICD test fixes (auth rate limiter burst 30→8, vault UEK pre-warm)
- [x] Starlark `workflow.*` module (get_definition, get_stage_data, advance, reject)
- [x] Workflow package export/import (.pkg round-trip)
- [x] E2E tests: export/import, surface_pkg_id persistence
### v0.31.0 — Editor Package
@@ -323,6 +327,7 @@ Depends on: v0.32.0 (multi-replica metrics aggregation).
- [ ] Alerting rules: OOM recovery, provider down, pool exhaustion,
task failure rate
- [ ] Admin dashboard surface: real-time health (built-in, no Grafana)
- [ ] Swagger/OpenAPI: auto-generated spec from route definitions, served at /api/docs
### v0.34.0 — Data Portability

311
docs/SDK.md Normal file
View File

@@ -0,0 +1,311 @@
# Switchboard SDK Reference
> `switchboard-sdk.js` — v0.30.2
The Switchboard SDK provides a unified API for surfaces and extensions.
It wraps platform internals (API, Events, Theme, ChatPane, etc.) into a
single `sw` namespace so authors never need to import platform files
directly.
## Initialization
```js
// Automatic — the SDK initializes on page load.
// Access via the global `sw` alias:
sw.api.get('/api/v1/channels').then(console.log);
```
The SDK emits a `sw:ready` CustomEvent on `document` when initialized.
Extensions loaded after boot can listen for it:
```js
document.addEventListener('sw:ready', (e) => {
const sw = e.detail.sw;
});
```
---
## sw.user
Current authenticated user (read-only getter).
```js
const u = sw.user;
// { id, username, display_name, email, role, avatar }
```
Returns `null` if not authenticated.
## sw.isAdmin
`true` if the current user has the `admin` role.
---
## sw.api
REST client with automatic auth header injection and 401 retry.
| Method | Signature | Returns |
|--------|-----------|---------|
| `get` | `(path, opts?)` | `Promise<object>` |
| `post` | `(path, body, opts?)` | `Promise<object>` |
| `put` | `(path, body, opts?)` | `Promise<object>` |
| `del` | `(path, opts?)` | `Promise<object>` |
| `stream` | `(path, body, signal?)` | `Promise<Response>` |
`opts` accepts `{ signal: AbortSignal }` for cancellation.
### Examples
```js
// List channels
const channels = await sw.api.get('/api/v1/channels');
// Create a channel
const ch = await sw.api.post('/api/v1/channels', {
title: 'Support',
type: 'direct'
});
// Streaming completion
const resp = await sw.api.stream('/api/v1/completions', {
channel_id: ch.id,
message: 'Hello'
});
const reader = resp.body.getReader();
```
---
## sw.on / sw.once / sw.off / sw.emit
EventBus integration. Supports wildcard patterns.
```js
// Subscribe
const unsub = sw.on('chat.message.*', (payload) => {
console.log('New message:', payload);
});
// One-shot
sw.once('channel.created', (ch) => { ... });
// Unsubscribe
sw.off('chat.message.*', handler);
// Emit
sw.emit('custom.event', { data: 123 });
```
### Common Events
| Event | Payload | Description |
|-------|---------|-------------|
| `chat.message.sent` | `{ message }` | User sent a message |
| `chat.message.received` | `{ message }` | AI response received |
| `channel.created` | `{ channel }` | New channel created |
| `channel.switched` | `{ channelId }` | Active channel changed |
| `theme.changed` | `{ theme }` | Theme mode changed |
---
## sw.theme
Theme observation and control.
| Property/Method | Type | Description |
|-----------------|------|-------------|
| `current` | `string` (getter) | Resolved theme: `'dark'` or `'light'` |
| `mode` | `string` (getter) | User preference: `'dark'`, `'light'`, or `'system'` |
| `set(mode)` | `void` | Set theme mode |
| `on('change', fn)` | `() => void` | Subscribe to theme changes; returns unsubscribe fn |
```js
// React to theme changes
const unsub = sw.theme.on('change', (resolved) => {
document.body.classList.toggle('dark', resolved === 'dark');
});
```
---
## sw.toast / sw.confirm / sw.modal
UI primitives for notifications and dialogs.
```js
// Toast notification
sw.toast('Saved successfully', 'success'); // types: success, error, info, warn
sw.toast('Something went wrong', 'error');
// Confirmation dialog (returns Promise<boolean>)
const ok = await sw.confirm('Delete this item?');
// Modal
sw.modal.open(htmlContentOrElementId);
sw.modal.close(id);
```
---
## sw.chat(container, opts)
Mount a ChatPane instance into a DOM element.
```js
const pane = sw.chat(document.getElementById('my-chat'), {
channelId: 'abc-123',
standalone: true, // default: true
});
// pane.renderMessages(), pane.destroy(), etc.
```
**Options:**
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `channelId` | `string` | `null` | Channel to load |
| `standalone` | `boolean` | `true` | Standalone mode (own input area) |
---
## sw.notes(container, opts)
Mount a NotePanel instance into a DOM element.
```js
const panel = sw.notes(document.getElementById('my-notes'), {
projectId: 'proj-123',
});
// panel.loadNotesList(), panel.openNoteEditor(id), panel.destroy()
```
---
## sw.panels()
Access the PanelRegistry for sidebar panel management.
```js
const panels = sw.panels();
panels.open('notes');
panels.toggle('editor');
panels.isOpen('notes'); // boolean
panels.active(); // current panel name or null
panels.cycle(); // cycle to next panel
panels.register('custom', { ... });
```
---
## sw.workflow
Workflow stage surface management (v0.30.2).
### sw.workflow(container, opts)
Mount a workflow stage surface.
```js
sw.workflow(document.getElementById('stage-mount'), {
channelId: 'ch-abc',
stageMode: 'form_only', // chat_only | form_only | form_chat | review
surfacePkgId: 'my-surface', // optional: custom package surface
formTemplate: { fields: [...] },
});
```
### sw.workflow.registerSurface(name, factory)
Register a custom stage surface from a package.
```js
sw.workflow.registerSurface('intake-form', (container, ctx) => {
// ctx: { channelId, basePath, stageMode, formTemplate, ... }
container.innerHTML = '<h2>Custom Intake</h2>';
return {
mount() { /* called on attach */ },
unmount() { /* called on detach */ },
};
});
```
### sw.workflow.getContext(channelId)
Fetch workflow instance status.
```js
const status = await sw.workflow.getContext('ch-abc');
// { workflow_id, current_stage, stage_data, ... }
```
### sw.workflow.advance(channelId, data?)
Advance to the next stage, optionally merging data.
```js
await sw.workflow.advance('ch-abc', { approved: true });
```
### sw.workflow.reject(channelId, reason)
Reject the current stage (go back).
```js
await sw.workflow.reject('ch-abc', 'Missing required documents');
```
---
## sw.pipe
Filter pipeline for intercepting chat messages at three stages:
pre-send, post-receive (stream), and post-render.
### Registration
```js
// Pre-send filter (runs before message is sent to API)
sw.pipe.pre(50, (ctx) => {
// ctx: { message, channel, metadata }
ctx.message += '\n\n[via my extension]';
return ctx; // return ctx to continue, null to halt
});
// Stream filter (runs on each SSE chunk)
sw.pipe.stream(50, (ctx) => {
// ctx: { chunk, channel, accumulated }
return ctx;
});
// Render filter (runs after markdown rendering)
sw.pipe.render(50, (ctx) => {
// ctx: { html, message, channel }
ctx.html = ctx.html.replace(/TODO/g, '<mark>TODO</mark>');
return ctx;
});
```
**Priority:** lower numbers run first. Convention: 0-49 system, 50-99
extensions, 100+ user customizations.
**Scope:** restrict a filter to specific channel types:
```js
sw.pipe.render(50, myFilter, {
scope: { channelType: ['direct', 'group'] },
source: 'my-extension',
});
```
### Introspection
```js
sw.pipe.list();
// { pre: [...], stream: [...], render: [...] }
// Each entry: { priority, source, scope, calls, avgMs, errors }
```

330
docs/WORKFLOW-PACKAGES.md Normal file
View File

@@ -0,0 +1,330 @@
# 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 = '<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:
```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: <id>` 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`.