v0.6.5: Renderer pipeline, docs rewrite, architecture diagrams
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 6s
CI/CD / test-go-pg (pull_request) Successful in 2m43s
CI/CD / test-sqlite (pull_request) Successful in 2m51s
CI/CD / build-and-deploy (pull_request) Successful in 1m13s

Lift block rendering to kernel SDK primitives (sw.renderers + sw.markdown)
so all surfaces share one markdown pipeline. Rewrite docs for external
audience — remove all fork history references. Add Mermaid architecture
diagrams, CONTRIBUTING guide, and extension tutorial.

- sw.renderers SDK module: kernel-level renderer registry
- sw.markdown SDK module: unified marked v16 + DOMPurify pipeline
- Browser extension script loader for renderer injection
- Notes + Docs surfaces migrated to sw.markdown.renderSync()
- 4 renderer extensions rewritten to IIFE + sw.renderers.register()
- 6 Mermaid diagrams in ARCHITECTURE.md
- CONTRIBUTING.md + TUTORIAL-FIRST-EXTENSION.md
- DESIGN-WORKFLOWS.md replaces fork-era design doc
- Surface alias routes removed from main.go
- ICD/SDK runners migrated to /admin/packages/ endpoints
- 13 new renderer tests
- Docs, CHANGELOG, ROADMAP cleaned of fork references

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 15:26:44 +00:00
parent 36d6158940
commit 2adaabe5fa
32 changed files with 1769 additions and 1974 deletions

View File

@@ -25,6 +25,32 @@ package installer. Surfaces (UI pages), tools, filters, triggers, and
providers are all packages. The editor, chat, and admin UI will themselves
be installable surface packages.
## System Architecture Overview
```mermaid
graph TD
Browser["Browser (Preact + htm)"]
Server["Go Server (Gin)"]
DB["Database (SQLite / Postgres)"]
Browser -->|HTTP / WebSocket| Server
subgraph Server Layers
Handlers["HTTP Handlers"]
Sandbox["Starlark Sandbox"]
Store["Store Interface"]
EventBus["Event Bus"]
end
Server --> Handlers
Handlers --> Store
Handlers --> Sandbox
Sandbox --> Store
Handlers --> EventBus
Store --> DB
EventBus -->|SSE / WS| Browser
```
## Kernel Components
### Identity & Auth
@@ -36,6 +62,31 @@ permissions, settings, and data against.
Auth modes: `builtin` (password), `mtls` (client cert), `oidc` (Keycloak
et al.). Mode is set at deploy time via `AUTH_MODE` env var.
### Request Flow
```mermaid
sequenceDiagram
participant C as Client
participant CORS as CORS Middleware
participant Auth as Auth Middleware
participant H as Handler
participant S as Store
participant DB as Database
C->>CORS: HTTP Request
CORS->>Auth: Pass through
Auth->>Auth: Validate token / session
alt Unauthorized
Auth-->>C: 401 JSON error
end
Auth->>H: Authenticated context
H->>S: Store method call
S->>DB: SQL query
DB-->>S: Rows
S-->>H: Typed result
H-->>C: JSON response
```
### Teams & Groups
Teams provide horizontal isolation — users see only their team's resources.
@@ -63,6 +114,26 @@ The unified registry for all installable content. A package is a surface
Tiers: `browser` (JS only), `starlark` (sandboxed server-side),
`sidecar` (container, future).
#### Extension Lifecycle
```mermaid
graph TD
Upload["Package Upload (.pkg)"]
Parse["Manifest Parse"]
Insert["DB Insert (packages table)"]
Enable["Admin Enable Toggle"]
Mount["Surface Mount (/s/:slug)"]
SDK["SDK Boot"]
Render["Renderer Registration"]
Upload --> Parse
Parse --> Insert
Insert --> Enable
Enable --> Mount
Mount --> SDK
SDK --> Render
```
### Starlark Sandbox
Extensions declare capabilities in their manifest. The admin grants or
@@ -128,6 +199,27 @@ Server-sent events to WebSocket clients. Kernel prefixes:
Extensions will subscribe to event patterns at install time (v0.2.0).
Match expressions start as exact strings, grow to globs later.
#### Realtime Event Flow
```mermaid
sequenceDiagram
participant C as Client
participant WS as WebSocket Hub
participant PG as PG LISTEN/NOTIFY
participant Other as Other Node
C->>WS: WS connect + subscribe
Note over WS: Local fan-out to subscribers
WS-->>C: Event push
Other->>PG: NOTIFY channel, payload
PG->>WS: LISTEN callback
WS-->>C: Fan-out to local subscribers
WS->>PG: NOTIFY channel, payload
PG->>Other: LISTEN callback
```
### Storage & Notifications
**Object storage**: PVC (local disk) or S3-compatible. Used by extensions
@@ -156,6 +248,26 @@ SQLite parity rules: `boolToInt` for boolean binding, `store.NewID()` for
INSERT RETURNING, no `NULLS FIRST`, no boolean literals, no `$N` reuse,
`database.ST()`/`database.SNT()` wrappers for time scanning.
## Settings Cascade
```mermaid
graph TD
Global["Global Scope (admin)"]
Team["Team Override"]
User["User Override"]
RBAC{"RBAC Gate: who can set at what scope?"}
Flag{"user_overridable flag"}
Effective["Effective Value"]
Global --> RBAC
RBAC -->|allowed| Team
RBAC -->|denied| Effective
Team --> Flag
Flag -->|true| User
Flag -->|false| Effective
User --> Effective
```
## Frontend
Preact (3KB) + htm (tagged template literals). No build step, no bundler
@@ -174,3 +286,25 @@ with DaemonSet DinD runners testing both PG and SQLite pipelines.
Registry: `registry.gobha.me:5000/xcaliber/switchboard-core`
Namespace: `gobha-ai-chat`
### Cluster Topology
```mermaid
graph TD
N1["Node 1"]
N2["Node 2"]
N3["Node 3"]
PG["PG UNLOGGED Table (cluster_nodes)"]
Sweep["Stale Sweep Goroutine"]
Notify["PG LISTEN/NOTIFY"]
N1 -->|heartbeat INSERT/UPDATE| PG
N2 -->|heartbeat INSERT/UPDATE| PG
N3 -->|heartbeat INSERT/UPDATE| PG
Sweep -->|DELETE nodes not seen| PG
PG -->|join/leave events| Notify
Notify --> N1
Notify --> N2
Notify --> N3
```

View File

@@ -1,806 +0,0 @@
# Workflow Redesign — v0.2.6
**Status:** Phase A complete (v0.3.0 — schema + stage CRUD modernization)
**Branch:** `feat/workflow-redesign-v0.2.6`
**Date:** 2026-03-27
**Context:** Mapping viable ideas from chat-switchboard v0.39.x onto core's extension-first architecture.
---
## 1. Guiding Principles
1. **Workflows are kernel.** Definitions, instances, the stage graph, form validation, and
assignment queues are platform primitives — not extensions.
2. **Execution is delegated.** How a stage renders or collects data is an extension concern.
The kernel says *what* a stage needs; a surface package provides the *how*.
3. **No chat assumptions.** The kernel never references personas, channels, or AI providers.
A chat extension can participate in workflows, but the workflow engine does not depend on it.
4. **Public access is a platform capability.** Anonymous/public sessions are not workflow-specific —
they are a kernel-level feature that any surface can opt into. Workflows *consume* public
access; they don't own it. See §15.
5. **Edit existing migrations.** Per the design-decisions log: no migration chains until the
schema is in production. New tables get new files; modified tables are edited in place.
---
## 2. Disposition Index
Every item below is classified:
| Tag | Meaning |
|-----|---------|
| **🗑 TRASH** | Remove from core. Vestigial chat-switchboard concept that doesn't fit. |
| **✏️ MOD** | Exists in core today — modify in place. |
| ** ADD** | New to core. Requires new code/tables. |
| **✅ KEEP** | Already correct in core. No changes needed. |
---
## 3. Schema Changes
### 3a. `007_workflows.sql` — edit in place
#### `workflows` table
| Column | Disposition | Action |
|--------|-------------|--------|
| `entry_mode` | ✏️ MOD | Current values: `public_link`, `team_only`. Keep the column but change its meaning — it becomes a workflow-level default/flag that says "this workflow has at least one public stage." The real visibility control moves to per-stage `audience`. See note below. |
| All other columns | ✅ KEEP | Clean. No chat coupling. |
**Note on `entry_mode`:** Chat-switchboard treated this as a binary toggle — the whole
workflow was either public or not. Real workflows mix audiences: internal setup → public
form → internal review → public confirmation. The per-stage `audience` field (see below)
is the source of truth. `entry_mode` on the workflow becomes a convenience flag: if any
stage has `audience = 'public'`, the workflow is public-entry eligible. This avoids a
full schema break — existing code that checks `entry_mode` still works, it just gets set
automatically when stages are saved.
#### `workflow_stages` table
| Column | Disposition | Action |
|--------|-------------|--------|
| `persona_id` | 🗑 TRASH | Drop column. Personas are a chat-extension concept. If a stage needs an AI participant, the stage's `surface_pkg_id` extension handles that internally. The comment in the migration already acknowledges this ("personas are extensions now") but the column is still there. Remove it. |
| `stage_mode` CHECK | ✏️ MOD | Current: `form_only`, `form_chat`, `review`, `custom`. Replace with: `form`, `review`, `delegated`, `automated`. See §4 for definitions. `form_chat` is trash (chat coupling). `form_only` renames to `form`. `custom` renames to `delegated` (clearer intent). `automated` is new (no UI, Starlark-only). |
| `history_mode` | 🗑 TRASH | Drop column. This controlled how much chat history a persona saw across stages. Core has no chat history. If a delegated surface needs context management, it reads `stage_data` — that's the contract. |
| `audience` | ADD | `TEXT NOT NULL DEFAULT 'team'` with CHECK `('team', 'public', 'system')`. Controls who interacts with this stage. `team` = authenticated team members only (internal). `public` = anonymous visitors via entry token (the "public-ish" stage). `system` = no human interaction, automated only. A workflow can freely alternate: team→public→team→public. The kernel enforces this: public stages are accessible via token auth, team stages require JWT, system stages have no UI. |
| `stage_type` | ADD | `TEXT NOT NULL DEFAULT 'simple'` with CHECK `('simple', 'dynamic', 'automated')`. Drives the graph engine: `simple` = declarative rules only, `dynamic` = Starlark hook resolves next stage, `automated` = no human UI, fires hook on entry and auto-advances. |
| `starlark_hook` | ADD | `TEXT` (nullable). Package-qualified entry point for dynamic/automated stages. Format: `package_id:entry_point`. NULL for simple stages. |
| `branch_rules` | ADD | `JSONB NOT NULL DEFAULT '[]'`. Replaces the overloaded `transition_rules` for simple stage branching. Array of `{field, op, value, target_stage}`. Evaluated before `starlark_hook`, before ordinal fallback. |
| `transition_rules` | ✏️ MOD | Rename to `stage_config`. This JSONB blob was doing double duty (routing rules + on_advance hooks + auto_assign). Routing moves to `branch_rules`. What remains is stage-specific config that the kernel or surface reads: `{on_advance: {package_id, entry_point}, auto_assign: "round_robin"}`. |
| `assignment_team_id` | ✅ KEEP | Clean. Kernel concept. |
| `form_template` | ✅ KEEP | Kernel concern — structured data schema. |
| `auto_transition` | ✅ KEEP | Kernel concern — skip human interaction. |
| `surface_pkg_id` | ✅ KEEP | The delegation pointer. Required for `delegated` mode stages. |
| `sla_seconds` | ✅ KEEP | Kernel concern — time budget per stage. |
#### `workflow_versions` table
| Column | Disposition | Notes |
|--------|-------------|-------|
| All existing columns | ✅ KEEP | Immutable snapshots. No changes needed. |
### 3b. `007_workflows.sql` — add new tables (same file)
#### ADD `workflow_instances`
Replaces the channel-based instance model from chat-switchboard. This is the execution record.
```sql
CREATE TABLE IF NOT EXISTS workflow_instances (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workflow_id UUID NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
workflow_version INTEGER NOT NULL,
current_stage INTEGER NOT NULL DEFAULT 0,
stage_data JSONB NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'completed', 'cancelled', 'stale', 'error')),
started_by UUID REFERENCES users(id) ON DELETE SET NULL,
entry_token TEXT, -- for public_link resumption
metadata JSONB NOT NULL DEFAULT '{}',
stage_entered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_wf_instances_workflow
ON workflow_instances(workflow_id, status);
CREATE INDEX IF NOT EXISTS idx_wf_instances_status
ON workflow_instances(status) WHERE status = 'active';
CREATE UNIQUE INDEX IF NOT EXISTS idx_wf_instances_token
ON workflow_instances(entry_token) WHERE entry_token IS NOT NULL;
DROP TRIGGER IF EXISTS wf_instances_updated_at ON workflow_instances;
CREATE TRIGGER wf_instances_updated_at BEFORE UPDATE ON workflow_instances
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
```
**Rationale:** Chat-switchboard used `channels` as instances — that table carried message trees,
AI context windows, participant lists, and other chat concerns. Core needs a purpose-built table
that holds only workflow execution state: which version, which stage, accumulated data, lifecycle status.
#### ADD `workflow_assignments`
Dropped during the fork ("channel-dependent, rebuild as needed" — see 007 header comment).
Now rebuilt as a kernel primitive.
```sql
CREATE TABLE IF NOT EXISTS workflow_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
instance_id UUID NOT NULL REFERENCES workflow_instances(id) ON DELETE CASCADE,
stage INTEGER NOT NULL,
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
assigned_to UUID REFERENCES users(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'unassigned'
CHECK (status IN ('unassigned', 'claimed', 'completed', 'cancelled')),
review_data JSONB NOT NULL DEFAULT '{}',
claimed_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_wf_assignments_instance
ON workflow_assignments(instance_id, stage);
CREATE INDEX IF NOT EXISTS idx_wf_assignments_team_status
ON workflow_assignments(team_id, status) WHERE status IN ('unassigned', 'claimed');
CREATE INDEX IF NOT EXISTS idx_wf_assignments_user
ON workflow_assignments(assigned_to) WHERE assigned_to IS NOT NULL;
```
**Claim lock:** Same optimistic pattern from chat-switchboard:
`UPDATE ... WHERE id = $1 AND status = 'unassigned'` — if `rows_affected == 0`, already claimed.
### 3c. New migration file: `012_workflow_instances.sql`
Wait — per the principle, new *tables* get new files. But we're also editing 007. Decision:
**Put the new tables in 007_workflows.sql.** The schema isn't in production. Keep all workflow
DDL in one file. When the schema *is* in production (post-MVP), new additions get numbered
migrations. This is consistent with the existing design-decisions log entry:
"No new migrations pre-MVP."
---
## 4. Stage Mode Redesign
### Current (trash/rename)
| Current Mode | Disposition | Rationale |
|-------------|-------------|-----------|
| `form_only` | ✏️ MOD → `form` | Rename. Drop the `_only` suffix — there's no `form_chat` to distinguish from anymore. |
| `form_chat` | 🗑 TRASH | Chat coupling. If you want a form + AI conversation, use `delegated` with a chat surface package. |
| `review` | ✅ KEEP | Team member reviews accumulated data. Pure kernel concept. |
| `custom` | ✏️ MOD → `delegated` | Rename for clarity. "Custom" is vague. "Delegated" makes the contract explicit: the kernel delegates stage execution to `surface_pkg_id`. |
### New modes
| Mode | Disposition | Description |
|------|-------------|-------------|
| `form` | ✏️ MOD (renamed) | Kernel renders `form_template`, validates submission, merges into `stage_data`. No extension needed. |
| `review` | ✅ KEEP | Assignment queue stage. Team member claims, reviews `stage_data`, adds `review_data`, completes. Kernel-rendered. |
| `delegated` | ✏️ MOD (renamed) | Kernel hands off to `surface_pkg_id`. The surface package receives the instance context (stage_data, form_template, metadata) and calls back to advance. This is where chat, AI, or any custom UX lives. |
| `automated` | ADD | No UI. On stage entry, kernel fires `starlark_hook`, merges returned data into `stage_data`, and auto-advances. For enrichment, API calls, validation, routing decisions. |
### Stage type vs stage mode
These are orthogonal:
- **`stage_type`** controls *how the next stage is chosen*: `simple` (declarative branch_rules), `dynamic` (Starlark hook returns target), `automated` (hook + auto-advance).
- **`stage_mode`** controls *how the stage executes*: `form`, `review`, `delegated`, `automated`.
An `automated` stage_type with `automated` stage_mode is the common case for system stages,
but you can have a `dynamic` stage_type with `form` stage_mode (the form collects data, then a
Starlark hook decides where to go next based on the submission).
---
## 5. Model Changes
### `server/models/workflow.go`
#### WorkflowStage struct — ✏️ MOD
```go
// TRASH: Remove these fields
// PersonaID *string `json:"persona_id,omitempty"`
// HistoryMode string `json:"history_mode"`
// MOD: Rename transition_rules → stage_config
// TransitionRules json.RawMessage `json:"transition_rules"`
StageConfig json.RawMessage `json:"stage_config"`
// ADD: New fields
Audience string `json:"audience"` // team | public | system
StageType string `json:"stage_type"` // simple | dynamic | automated
StarlarkHook *string `json:"starlark_hook,omitempty"` // package_id:entry_point
BranchRules json.RawMessage `json:"branch_rules"` // [{field, op, value, target_stage}]
```
#### Stage mode constants — ✏️ MOD
```go
// TRASH
// StageModeFormOnly = "form_only"
// StageModeFormChat = "form_chat"
// MOD (rename)
StageModeForm = "form" // was form_only
StageModeDelegated = "delegated" // was custom
// KEEP
StageModeReview = "review"
// ADD
StageModeAutomated = "automated"
// ADD: Stage type constants
StageTypeSimple = "simple"
StageTypeDynamic = "dynamic"
StageTypeAutomated = "automated"
// ADD: Audience constants
AudienceTeam = "team"
AudiencePublic = "public"
AudienceSystem = "system"
```
#### ADD: WorkflowInstance model
```go
type WorkflowInstance struct {
ID string `json:"id"`
WorkflowID string `json:"workflow_id"`
WorkflowVersion int `json:"workflow_version"`
CurrentStage int `json:"current_stage"`
StageData json.RawMessage `json:"stage_data"`
Status string `json:"status"` // active | completed | cancelled | stale | error
StartedBy *string `json:"started_by,omitempty"`
EntryToken *string `json:"entry_token,omitempty"`
Metadata json.RawMessage `json:"metadata"`
StageEnteredAt time.Time `json:"stage_entered_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
```
#### ADD: WorkflowAssignment model
```go
type WorkflowAssignment struct {
ID string `json:"id"`
InstanceID string `json:"instance_id"`
Stage int `json:"stage"`
TeamID string `json:"team_id"`
AssignedTo *string `json:"assigned_to,omitempty"`
Status string `json:"status"` // unassigned | claimed | completed | cancelled
ReviewData json.RawMessage `json:"review_data"`
ClaimedAt *time.Time `json:"claimed_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
```
#### TypedFormTemplate, FormField, FormValidation — ✅ KEEP
All form types are clean kernel code. No changes.
#### ValidateFormData — ✅ KEEP
Pure data validation. No chat coupling.
---
## 6. Store Interface Changes
### `server/store/workflow_iface.go` — ✏️ MOD + ADD
Current interface is definition-only (CRUD workflows + stages + versions).
Add instance and assignment operations.
```go
type WorkflowStore interface {
// ── Definition CRUD (KEEP — no changes) ──────────
Create(ctx context.Context, w *models.Workflow) error
GetByID(ctx context.Context, id string) (*models.Workflow, error)
GetBySlug(ctx context.Context, teamID *string, slug string) (*models.Workflow, error)
Update(ctx context.Context, id string, patch models.WorkflowPatch) error
Delete(ctx context.Context, id string) error
ListForTeam(ctx context.Context, teamID string) ([]models.Workflow, error)
ListGlobal(ctx context.Context) ([]models.Workflow, error)
// ── Stage CRUD (KEEP — no changes) ───────────────
CreateStage(ctx context.Context, s *models.WorkflowStage) error
ListStages(ctx context.Context, workflowID string) ([]models.WorkflowStage, error)
UpdateStage(ctx context.Context, s *models.WorkflowStage) error
DeleteStage(ctx context.Context, id string) error
ReorderStages(ctx context.Context, workflowID string, orderedIDs []string) error
// ── Versioning (KEEP — no changes) ───────────────
Publish(ctx context.Context, v *models.WorkflowVersion) error
GetVersion(ctx context.Context, workflowID string, versionNumber int) (*models.WorkflowVersion, error)
GetLatestVersion(ctx context.Context, workflowID string) (*models.WorkflowVersion, error)
// ── Instances (ADD) ──────────────────────────────
CreateInstance(ctx context.Context, inst *models.WorkflowInstance) error
GetInstance(ctx context.Context, id string) (*models.WorkflowInstance, error)
GetInstanceByToken(ctx context.Context, token string) (*models.WorkflowInstance, error)
UpdateInstance(ctx context.Context, id string, patch models.InstancePatch) error
ListInstances(ctx context.Context, workflowID string, status string) ([]models.WorkflowInstance, error)
AdvanceStage(ctx context.Context, id string, nextStage int, mergeData json.RawMessage) error
CompleteInstance(ctx context.Context, id string) error
CancelInstance(ctx context.Context, id string) error
MarkStale(ctx context.Context, olderThan time.Duration) (int, error)
// ── Assignments (ADD) ────────────────────────────
CreateAssignment(ctx context.Context, a *models.WorkflowAssignment) error
ClaimAssignment(ctx context.Context, id string, userID string) error // optimistic lock
UnclaimAssignment(ctx context.Context, id string) error
CompleteAssignment(ctx context.Context, id string, reviewData json.RawMessage) error
CancelAssignment(ctx context.Context, id string) error
ListAssignmentsByTeam(ctx context.Context, teamID string, status string) ([]models.WorkflowAssignment, error)
ListAssignmentsByUser(ctx context.Context, userID string) ([]models.WorkflowAssignment, error)
ListAssignmentsByInstance(ctx context.Context, instanceID string) ([]models.WorkflowAssignment, error)
}
```
---
## 7. Handler Changes
### `server/handlers/workflows.go` — ✏️ MOD
**Stage CRUD updates** — reflect new field names in CreateStage/UpdateStage:
- Remove `persona_id` and `history_mode` from bind/validation.
- Add `stage_type`, `starlark_hook`, `branch_rules` to bind/validation.
- Rename `transition_rules``stage_config` in bind/validation.
- Update `stage_mode` CHECK to new set: `form`, `review`, `delegated`, `automated`.
- Validate: `delegated` requires `surface_pkg_id`. `dynamic`/`automated` stage_type requires `starlark_hook`.
### `server/handlers/workflow_hooks.go` — ✏️ MOD
**Rename:** `channelID` parameter → `instanceID` in `FireOnAdvanceHook` signature.
The hook context dict changes from `{channel_id, ...}` to `{instance_id, ...}`.
The `jsonToStarlark` helper referenced on line 81 lives in
`server/handlers/starlark_helpers.go`. Consider consolidating with `goToStarlark` in
`server/triggers/event.go` — two independent Go→Starlark converters is tech debt.
### `server/handlers/workflow_team.go` — ✅ KEEP
Team-scoped wrappers. Clean delegation pattern. No changes.
### `server/handlers/workflow_packages.go` — ✏️ MOD
Update `workflowPkgStage` struct:
- Remove `PersonaID`, `HistoryMode` fields.
- Add `StageType`, `StarlarkHook`, `BranchRules` fields.
- Rename `TransitionRules``StageConfig`.
Update `ExportWorkflowPackage` and `InstallWorkflowFromManifest` accordingly.
### ADD `server/handlers/workflow_instances.go`
New handler file for instance lifecycle:
```
POST /api/v1/workflows/:id/start → Start (create instance from latest published version)
GET /api/v1/workflows/instances/:iid → GetInstance
POST /api/v1/workflows/instances/:iid/advance → Advance (submit stage data, resolve next)
POST /api/v1/workflows/instances/:iid/cancel → Cancel
GET /api/v1/workflows/:id/instances → ListInstances (by workflow, optionally by status)
```
Public entry (for `public_link` workflows):
```
POST /api/v1/workflows/entry/:slug → StartPublic (no auth, generates entry_token)
GET /api/v1/workflows/entry/:token → ResumePublic (retrieve instance by token)
POST /api/v1/workflows/entry/:token/advance → AdvancePublic (submit data via token)
```
### ADD `server/handlers/workflow_assignments.go`
New handler file for the assignment queue:
```
GET /api/v1/teams/:teamId/assignments → ListTeamAssignments (filterable by status)
POST /api/v1/teams/:teamId/assignments/:id/claim → Claim
POST /api/v1/teams/:teamId/assignments/:id/unclaim → Unclaim
POST /api/v1/teams/:teamId/assignments/:id/complete → Complete (with review_data)
POST /api/v1/teams/:teamId/assignments/:id/cancel → Cancel
GET /api/v1/assignments/mine → ListMyAssignments (across teams)
```
---
## 8. Routing Engine Changes
### `server/workflow/routing.go` — ✏️ MOD
The existing `ResolveNextStage` evaluates `transition_rules.conditions[]`. This needs to
become a two-phase resolution that respects the new `stage_type`:
```
Phase 1: Evaluate branch_rules (declarative, for simple + dynamic types)
Phase 2: If no match AND stage_type == dynamic → fire starlark_hook
Phase 3: Fallback → currentStage + 1
```
For `automated` stage_type, the engine fires `starlark_hook` on entry (not for routing — for
data enrichment), then routes via branch_rules or ordinal fallback. The routing call happens
*after* the hook returns.
**Concrete changes:**
- `ResolveNextStage` signature: add `stageType string, starlarkHook *string, runner *sandbox.Runner` parameters.
- Extract branch_rules evaluation from the current `TransitionRulesWithConditions` (which reads from `transition_rules` JSON) into a dedicated function that reads from the new `branch_rules` field.
- `TransitionRulesWithConditions` struct → rename to `StageConfig`, remove `Conditions` field (moved to branch_rules), keep `AutoAssign` and `OnAdvance`.
### `server/workflow/` — ADD `engine.go`
New file: the stage execution engine. Orchestrates the advance lifecycle:
```
1. Load instance + version snapshot
2. Validate current stage allows advancement (status checks)
3. If current stage has form_template → validate submitted data
4. Merge submitted data into stage_data
5. Fire on_advance hook (if configured in stage_config)
6. Resolve next stage (branch_rules → starlark_hook → ordinal)
7. If next stage is past last stage → complete instance
8. If next stage is automated → fire its hook, recurse to step 6
9. If next stage has assignment_team_id → create WorkflowAssignment
10. Update instance (current_stage, stage_data, stage_entered_at)
11. Emit bus events (workflow.advanced, workflow.assigned, workflow.completed)
```
### `server/workflow/` — ADD `automated.go`
Handles `automated` stage execution: fire the Starlark hook, merge returned data, and
auto-advance. Includes a cycle guard (max 10 consecutive automated stages) to prevent
infinite loops from misconfigured workflows.
---
## 9. Event Bus Updates
### `server/events/types.go` — ✏️ MOD
Current workflow events are fine but incomplete. Add:
| Event | Direction | Disposition | Trigger |
|-------|-----------|-------------|---------|
| `workflow.assigned` | DirToClient | ✅ KEEP | Assignment created |
| `workflow.claimed` | DirToClient | ✅ KEEP | Assignment claimed |
| `workflow.advanced` | DirToClient | ✅ KEEP | Stage transition |
| `workflow.completed` | DirToClient | ✅ KEEP | Instance completed |
| `workflow.cancelled` | DirToClient | ADD | Instance cancelled |
| `workflow.started` | DirToClient | ADD | Instance created |
| `workflow.sla.warning` | DirToClient | ADD | SLA at 80% threshold |
| `workflow.sla.breached` | DirToClient | ADD | SLA exceeded |
| `workflow.error` | DirLocal | ADD | Hook execution failure |
---
## 10. Starlark Module Updates
### `server/sandbox/workflow_module.go` — ✏️ MOD
The `BuildWorkflowModule` (in `workflow_module.go`, not `modules.go`) currently exists but
targets the old channel-based model. It currently only exposes `get_definition`.
Rebuild to expose instance-oriented operations:
```python
# Available to extensions with workflow.read or workflow.write permission
workflow.get_instance(instance_id) # → instance dict
workflow.get_stage_data(instance_id) # → stage_data dict
workflow.set_stage_data(instance_id, data) # → merge into stage_data
workflow.advance(instance_id) # → trigger advance (write perm)
workflow.get_assignments(instance_id) # → assignment list
```
The old `workflow_advance()` function that chat-switchboard's personas called is gone.
Extensions call `workflow.advance()` which delegates to the same engine as the HTTP handler.
---
## 11. Salvage Map from chat-switchboard v0.39.x
What was planned there, and what happens to each idea in core:
### v0.39.0 — Stage Graph Engine + Form Builder
| Component | Disposition | Core equivalent |
|-----------|-------------|-----------------|
| `stage_type` enum | ADD | Adopted directly: `simple`, `dynamic`, `automated` |
| `BranchRule` struct | ADD | Adopted as `branch_rules` JSONB on `workflow_stages` |
| Starlark hook integration | ADD | `starlark_hook` column + engine integration |
| Graph engine (`server/engine/graph.go`) | ADD | `server/workflow/engine.go` in core |
| Automated stage runner | ADD | `server/workflow/automated.go` in core |
| Visual form builder (5 Preact components) | 🗑 TRASH *for now* | The form builder was tightly coupled to chat-switchboard's stage editor surface. Core doesn't ship a workflow editor surface (that's an extension). The form *schema* is kernel; the form *builder UI* is a surface package concern. Revisit when a workflow-admin surface is built. |
| Stage type badges in UI | 🗑 TRASH | Same — UI is extension territory. |
### v0.39.1 — Stage Reorder + Bulk Ops
| Component | Disposition | Core equivalent |
|-----------|-------------|-----------------|
| Drag-and-drop reorder | 🗑 TRASH | UI concern. The kernel `ReorderStages` endpoint already exists. |
| Bulk assignment ops | ADD (later) | Useful but not blocking. Defer to v0.2.7+. The individual claim/unclaim/cancel endpoints come first. |
### v0.39.2 — SLA Alerting
| Component | Disposition | Core equivalent |
|-----------|-------------|-----------------|
| Periodic SLA scanner | ADD | Kernel scheduled goroutine (like the existing staleness sweep). Query active instances with `sla_seconds` configured, compute breach from `stage_entered_at`. |
| `sla_notified_at` on instances | ADD | Add to `workflow_instances` schema. Prevents duplicate notifications. |
| Notifications on breach | ADD | Use existing `notifications` table + bus event (`workflow.sla.breached`). |
| Webhook on breach | ADD | Fire workflow's `webhook_url` if configured. |
| Pulsing badge animation | 🗑 TRASH | UI concern for a surface package. |
### v0.39.3 — Visitor Portal
| Component | Disposition | Core equivalent |
|-----------|-------------|-----------------|
| Public entry endpoint | ADD | `/api/v1/workflows/entry/:slug` — kernel handles anonymous session creation + token issuance. |
| Session resume via token | ADD | `entry_token` on `workflow_instances` + `GetInstanceByToken` store method. |
| Branded entry page | 🗑 TRASH | Surface package. The kernel stores `branding` JSONB — a portal surface reads it. |
| Progress indicator | 🗑 TRASH | Surface package reads stage list, computes "step N of M" locally. |
| Email notifications | 🗑 TRASH *for now* | Requires SMTP infrastructure. Defer to post-MVP or make it a connection-based extension (use ext_connections for SMTP config, fire via Starlark hook). |
### v0.39.4 — Templates + Clone
| Component | Disposition | Core equivalent |
|-----------|-------------|-----------------|
| Clone endpoint | ADD | `POST /api/v1/workflows/:id/clone` — deep copy workflow + stages. Straightforward. |
| Built-in templates table | 🗑 TRASH | Core doesn't ship opinionated templates. Templates are workflow packages (`.pkg` archives). A "template gallery" is a surface. |
| Template picker UI | 🗑 TRASH | Surface concern. |
### v0.39.5 — Analytics
| Component | Disposition | Core equivalent |
|-----------|-------------|-----------------|
| Analytics query endpoint | ADD (later) | `GET /api/v1/workflows/:id/analytics?period=7d` — kernel exposes raw metrics (throughput, cycle time, stage dwell, SLA compliance) derived from instance + assignment timestamps. Defer to v0.2.8+ or v0.3.x. |
| Analytics dashboard UI | 🗑 TRASH | Surface package. |
---
## 12. Implementation Order
All items within v0.2.6. Ordered by dependency:
### Phase A: Schema + Models
1. ✏️ Edit `007_workflows.sql`: drop `persona_id`, `history_mode`; add `audience`, `stage_type`, `starlark_hook`, `branch_rules`; rename `transition_rules``stage_config`; update `stage_mode` CHECK.
2. ✏️ Edit `007_workflows.sql` (same file): add `workflow_instances` and `workflow_assignments` tables.
3. ✏️ Edit `007_workflows.sql` (SQLite dialect): mirror changes.
4. ✏️ Update `server/models/workflow.go`: remove trashed fields, add new structs, update constants.
### Phase B: Store Layer
5. ✏️ Update `server/store/workflow_iface.go`: add instance + assignment methods.
6. Implement Postgres store: `server/store/postgres/workflow_instances.go`, `workflow_assignments.go`.
7. Implement SQLite store: `server/store/sqlite/workflow_instances.go`, `workflow_assignments.go`.
### Phase C: Engine
8. ✏️ Update `server/workflow/routing.go`: branch_rules evaluation, starlark_hook integration.
9. Add `server/workflow/engine.go`: advance lifecycle orchestration.
10. Add `server/workflow/automated.go`: automated stage execution + cycle guard.
11. ✏️ Update `server/handlers/workflow_hooks.go`: rename channel → instance references.
### Phase D: Handlers + Routes
12. ✏️ Update `server/handlers/workflows.go`: stage CRUD field changes.
13. ✏️ Update `server/handlers/workflow_packages.go`: package struct field changes.
14. Add `server/handlers/workflow_instances.go`: instance lifecycle endpoints.
15. Add `server/handlers/workflow_assignments.go`: assignment queue endpoints.
16. ✏️ Wire new routes in `server/main.go`.
### Phase E: Events + Starlark
17. ✏️ Update `server/events/types.go`: add new workflow events.
18. ✏️ Update `server/sandbox/modules.go`: rebuild workflow Starlark module.
### Phase F: Background Jobs
19. Add SLA scanner goroutine (check active instances, fire notifications + events on breach).
20. Add staleness sweep for workflow instances (reuse pattern from chat-switchboard).
### Phase G: Verification
21. Integration tests: instance lifecycle, stage advancement, branching, automated stages, assignments, SLA breach.
22. `go build ./...` clean.
23. Update ICD (OpenAPI spec) with new endpoints.
24. Update ROADMAP.md + CHANGELOG.md.
---
## 13. What We're NOT Doing (Explicit Deferrals)
| Item | Why | Revisit |
|------|-----|---------|
| Visual form builder UI | Surface concern, no workflow-admin surface yet | When a workflow-admin surface ships |
| Drag-and-drop stage reorder UI | Surface concern | Same |
| Email notifications | Needs SMTP infrastructure or connection-based ext | Post-MVP |
| Built-in workflow templates | Core doesn't ship opinionated content | Package gallery |
| Analytics dashboard | Nice-to-have, kernel endpoint can come first | v0.3.x |
| Bulk assignment operations | Individual ops first | v0.2.7+ |
| Workflow chaining (`on_complete`) | Already in schema, needs engine wiring | v0.2.7 |
| Visitor portal surface | Surface package, not kernel | Extension track |
---
## 14. Migration Policy Reminder
From the design-decisions log:
> **No new migrations pre-MVP.** Edit existing migration SQL files in place.
> No migration chains until schema is in production.
For this redesign:
- **`007_workflows.sql`**: Edit in place for column changes AND add new tables to the same file.
- **No `012_*.sql`**: Everything goes in 007.
- **Both dialects**: Postgres and SQLite must be kept in sync.
The only time a new numbered migration file is created is when a truly new,
unrelated subsystem is added (like 011_triggers.sql was for the trigger system).
Workflow instances and assignments are part of the workflow subsystem → they go in 007.
---
## 15. Public Surfaces — Kernel Capability, Not Workflow Feature
### The problem with workflow-owned public access
Chat-switchboard treated public access as a binary workflow toggle: `entry_mode: public_link`
made the whole workflow public. But real workflows alternate audiences — internal setup →
public-facing customer form → internal review → public confirmation page. And public access
isn't even a workflow-only need: feedback forms, status pages, and surveys all need anonymous
sessions without being workflows.
### Per-stage audience (the v0.2.6 solution)
The `audience` field on `workflow_stages` is the source of truth:
| Audience | Who interacts | Auth required | Example |
|----------|--------------|---------------|---------|
| `team` | Authenticated team members | JWT | Internal triage, setup, review |
| `public` | Anonymous visitors | Entry token | Customer intake form, confirmation page |
| `system` | Nobody (automated) | N/A | API enrichment, routing decision |
A single workflow freely alternates:
```
Stage 0: "Setup" audience=team (agent configures parameters)
Stage 1: "Customer Form" audience=public (customer fills out intake)
Stage 2: "Internal Review" audience=team (team reviews submission)
Stage 3: "Confirmation" audience=public (customer sees result)
```
The engine enforces boundaries:
- When the instance enters a `public` stage, the visitor's token grants access. Team members
can also see it (they have elevated access).
- When the instance enters a `team` stage, the visitor's view freezes. They see "being
processed" or whatever the portal surface shows. The token is still valid for resumption
when the next `public` stage arrives.
- When the instance enters a `system` stage, nobody interacts — the automated hook fires
and the engine advances.
### The `entry_mode` column
Stays on the `workflows` table but becomes a derived convenience flag. If any stage has
`audience = 'public'`, the workflow is public-entry eligible. The kernel can auto-compute
this when stages are saved, or treat it as an explicit admin toggle that gates whether
public stages are actually reachable. Either way, it's no longer the primary access control
mechanism — `audience` on each stage is.
### Platform-level public access (v0.2.7+ concern)
The broader vision: public access as a **package-level manifest capability** so any surface
(not just workflows) can serve anonymous visitors. A package declares `"public": true` in
its manifest, and the kernel mounts a public route namespace (`/p/:package_slug/*`) with
anonymous session middleware, token issuance, and rate limiting. Workflows consume this
capability — they don't own it.
For v0.2.6, the workflow-specific public entry routes are sufficient:
```
POST /api/v1/workflows/entry/:slug → StartPublic
GET /api/v1/workflows/entry/:token → ResumePublic
POST /api/v1/workflows/entry/:token/advance → AdvancePublic
```
The anonymous session management (entry_token on `workflow_instances`, IP rate limiting)
lives in the workflow handler for now. Generalization to a platform-level `public_sessions`
table happens when non-workflow surfaces need it.
### Visitor view contract
When a public stage is active, the kernel API returns only what the visitor should see:
- The current stage's `form_template` (if mode is `form`)
- The stage name and ordinal (for progress indicators)
- Accumulated `stage_data` keys marked as `visitor_visible` (future: field-level ACL)
- The `branding` JSONB from the workflow
Internal stages, team assignments, review data, and other instances' data are never
exposed through the token-authenticated endpoints. The surface package renders whatever
the kernel returns — it doesn't need to filter.
---
## 16. Branch Rules — Routing, Skipping, and Convergence
### How branch_rules work
Each stage has a `branch_rules` JSONB array. On stage completion, the engine evaluates rules
top-to-bottom against accumulated `stage_data`. First match wins. No match → ordinal + 1.
```json
[
{"field": "priority", "op": "eq", "value": "urgent", "target_stage": "Emergency Review"},
{"field": "amount", "op": "gt", "value": 10000, "target_stage": "Manager Approval"},
{"field": "type", "op": "in", "value": ["a","b"], "target_stage": "Fast Track"}
]
```
`target_stage` accepts either a **stage name** (case-insensitive) or a **numeric ordinal**.
The engine tries name resolution first, falls back to numeric. This is already implemented
in `routing.go``resolveTarget()`.
### Skipping stages
Branch rules can target any stage, not just the next one. If a workflow has stages
0→1→2→3→4 and stage 1's rules say `target_stage: "Stage 4"`, stages 2 and 3 are skipped
for that instance. The engine doesn't care about gaps — it sets `current_stage` to whatever
the rule resolved to.
Linear workflows (no branch_rules on any stage) advance 0→1→2→3→4 by the ordinal + 1
fallback. Adding a single branch rule to any stage introduces conditional skipping without
changing the linear stages.
### Convergence (diamond patterns)
Two different branches can target the same downstream stage:
```
Stage 0: Intake Form
branch_rules: [
{field: "region", op: "eq", value: "US", target_stage: "US Processing"},
{field: "region", op: "eq", value: "EU", target_stage: "EU Processing"}
]
Stage 1: US Processing (ordinal 1)
branch_rules: [{field: "*", op: "exists", target_stage: "Final Review"}]
(always converge after processing)
Stage 2: EU Processing (ordinal 2)
branch_rules: [{field: "*", op: "exists", target_stage: "Final Review"}]
(always converge after processing)
Stage 3: Final Review (ordinal 3)
(both branches land here)
```
No special syntax needed. The routing engine doesn't track "which branch am I on" — it just
resolves the target and moves the instance there. The accumulated `stage_data` carries
everything downstream stages need to know about what happened upstream.
### Backward jumps
Branch rules can also target earlier stages (lower ordinals) for retry/correction loops.
The cycle guard (max 10 consecutive automated stages) in `automated.go` prevents infinite
loops for automated stages. For human-facing stages, backward jumps are intentional — the
human breaks the cycle by submitting different data.
### Operators
Full set, inherited from the existing `routing.go` implementation:
| Op | Description | Example |
|----|-------------|---------|
| `eq` | Loose equality (string comparison) | `"status" eq "approved"` |
| `neq` | Not equal | `"status" neq "rejected"` |
| `gt` | Greater than (numeric) | `"amount" gt 10000` |
| `lt` | Less than (numeric) | `"amount" lt 100` |
| `gte` | Greater or equal | `"score" gte 80` |
| `lte` | Less or equal | `"score" lte 20` |
| `in` | Value in array | `"category" in ["a","b","c"]` |
| `contains` | Substring match | `"notes" contains "urgent"` |
| `exists` | Field present in stage_data | `"approval_date" exists` |
| `not_exists` | Field absent | `"rejection_reason" not_exists` |

111
docs/DESIGN-WORKFLOWS.md Normal file
View File

@@ -0,0 +1,111 @@
# Workflow Engine — Design
The workflow engine provides multi-stage, form-driven processes with team
validation, SLA enforcement, and public entry. Workflows are kernel primitives;
workflow **surfaces** (admin UI, public landing pages) are extension packages.
---
## Core Concepts
### Workflow Definition
A workflow is a named process template owned by a team. Each workflow defines an
ordered list of **stages** that instances progress through.
| Field | Description |
|-------|-------------|
| `name` | Human-readable workflow name |
| `slug` | URL-safe identifier (unique per scope) |
| `scope` | Owning team or org |
| `stages` | Ordered list of stage definitions |
| `entry_mode` | `internal` (authenticated users) or `public_link` (landing page) |
| `sla_hours` | Optional SLA deadline for instance completion |
### Stage Types
Each stage has a `mode` that determines how participants interact:
| Mode | Behavior |
|------|----------|
| `form_only` | Structured form submission. Stage advances when form is complete. |
| `form_chat` | Form plus threaded discussion. Useful for review with comments. |
| `review` | Approval/rejection gate. Designated reviewers must sign off. |
| `custom` | Delegates entirely to a surface package. Enables extension-driven UIs. |
Stages declare an `assignee_mode` (individual, team, role-based) and optional
form schemas (JSON Schema validated at submission time).
### Workflow Instances
An instance is a running execution of a workflow definition. It tracks:
- Current stage index and overall status (`active`, `completed`, `cancelled`, `stale`)
- Per-stage completion records with timestamps and actor IDs
- Form data submitted at each stage
- Assignment and claim history
Instances use optimistic locking (`claimed_by` + `claimed_at`) to prevent
concurrent stage advancement. A claim expires after a configurable timeout.
---
## Multi-Party Validation (Signoff Table)
The `workflow_signoffs` table enables multi-party approval gates:
| Column | Purpose |
|--------|---------|
| `instance_id` | Which instance |
| `stage_index` | Which stage requires signoff |
| `user_id` | Who signed |
| `decision` | `approved` or `rejected` |
| `comment` | Optional rationale |
| `signed_at` | Timestamp |
The engine checks signoff requirements before advancing past a `review` stage.
Requirements are configurable: unanimous, majority, or N-of-M.
---
## SLA Scanner + Staleness Sweep
Two periodic background jobs maintain workflow health:
**SLA Scanner** — Runs on a configurable interval. Identifies instances that have
exceeded their workflow's `sla_hours` deadline. Fires a `workflow.sla.breached`
event (consumed by notification extensions or triggers).
**Staleness Sweep** — Identifies instances that have been idle (no stage
advancement) beyond a configurable threshold. Marks them as `stale` and fires
`workflow.instance.stale`. Stale instances can be resumed or cancelled.
Both jobs are distributed-safe: all cluster nodes run them, but operations are
idempotent (UPDATE with WHERE guards).
---
## Public Entry
Workflows with `entry_mode: public_link` are accessible at:
```
/w/:scope/:slug
```
The landing page renders the first stage's form without requiring authentication.
On submission, the kernel creates an instance and stores the form data. Subsequent
stages may require authentication depending on their assignee configuration.
Public entry enables use cases like intake forms, support requests, and
application submissions where the initiator is external.
---
## Extension Points
- **Surface packages** provide workflow admin UIs and participant views
- **Trigger extensions** can react to workflow events (`instance.created`,
`stage.advanced`, `sla.breached`, `instance.completed`)
- **Custom stage mode** delegates rendering and advancement logic entirely to
an extension, enabling arbitrary UIs within a workflow stage

View File

@@ -0,0 +1,119 @@
# Build Your First Browser Extension
This tutorial walks through building a browser extension that renders custom
code blocks, modeled on the CSV Table Viewer that ships with Switchboard.
**Prerequisites:** A running Switchboard instance, a text editor, and `zip`.
## Step 1: Create the Directory
```sh
mkdir -p my-extension/js
```
## Step 2: Write the Manifest
Create `my-extension/manifest.json`:
```json
{
"id": "my-extension",
"title": "My Extension",
"version": "0.1.0",
"type": "extension",
"tier": "browser",
"author": "you",
"description": "Renders demo code blocks as styled HTML",
"permissions": [],
"settings": {}
}
```
- **id** -- unique identifier, used as the install key
- **type** -- `extension` for browser-only packages; `full` or `surface` for
packages with backend routes
- **tier** -- `browser` for client-side JS; `starlark` for server-side scripting
## Step 3: Write the Browser Script
Create `my-extension/js/script.js`. Browser extensions use the IIFE pattern
and register with the SDK through `sw.renderers`:
```js
(function () {
'use strict';
function register() {
if (!window.sw?.renderers) return;
sw.renderers.register('demo-block', {
type: 'block',
priority: 10,
match(lang) {
return (lang || '').toLowerCase() === 'demo';
},
render(lang, code, container) {
container.innerHTML =
'<div style="padding:12px;background:var(--bg-2);' +
'border:1px solid var(--border);border-radius:8px">' +
'<strong>Demo:</strong> ' + code +
'</div>';
}
});
}
if (window.sw?._sdk) {
register();
} else {
document.addEventListener('sw:ready', register, { once: true });
}
})();
```
The IIFE wrapper keeps variables out of global scope. `sw.renderers.register`
takes a name and an options object: `type: 'block'` targets fenced code blocks,
`match` checks the language tag, and `render` receives the language, raw code,
and a container element. The `sw:ready` event fires once the SDK initializes;
if already loaded, register immediately. Use CSS variables like `var(--bg-2)`
and `var(--border)` to follow the active theme.
## Step 4: Package It
```sh
cd my-extension
zip -r ../my-extension.pkg manifest.json js/
```
The `.pkg` format is a ZIP with `manifest.json` at the root. Optional
directories: `js/`, `css/`, `assets/`. If working inside `packages/`, use
the build script instead: `bash build.sh my-extension`.
## Step 5: Install It
```sh
curl -X POST http://localhost:3000/api/v1/admin/packages/install \
-H "Authorization: Bearer <token>" \
-F "file=@my-extension.pkg"
```
You can also install through the Admin UI under the Packages section.
## Step 6: Enable and Test
1. Open the admin panel, navigate to Packages, confirm "My Extension" is enabled.
2. Go to any markdown surface (Chat, Notes, etc.).
3. Enter a fenced code block with the `demo` language tag.
You should see styled output instead of a plain code block.
## Going Further
- **Add CSS** -- create a `css/` directory; stylesheets are injected automatically.
- **Post-renderers** -- register with `type: 'post'` to run after block renderers
finish (the Mermaid extension uses this for async SVG rendering).
- **Settings** -- declare a `settings` object in the manifest for admin-configurable values.
- **Server-side logic** -- set `tier: "starlark"` and add `script.star` for
backend API routes and database tables.
See `PACKAGE-FORMAT.md` for the full manifest spec and `EXTENSION-GUIDE.md`
for advanced patterns.