30 KiB
DESIGN — v0.27.0+: Debt Clearance, Extension Surfaces, Tasks
Status: Draft Scope: Deferred debt clearance (v0.27.0), Tasks / Autonomous Agents (v0.27.x), TBD pull-forward (v0.28.0) Depends on: Workflow Engine (v0.26.0), Dynamic Surfaces (v0.25.0), Permissions (v0.24.2) Current version: 0.26.5
Versioning Plan
v0.27.0 Debt Clearance: Extension Surface Routes + Workflow Polish
│
v0.27.1 Tasks Foundation: Service Channels + Scheduler
│
v0.27.2 Task Execution: Budgets + Admin Controls
│
v0.27.3 Task Chaining: Webhooks + Workflow-to-Workflow
│
v0.27.4 Personal Tasks: BYOK Scheduling + User Task UI
│
v0.28.0 Platform Polish (TBD pull-forward)
Code Audit — Items Already Shipped
The ROADMAP lists these as deferred but the code proves they're done:
| Item | Evidence | Listed as deferred in |
|---|---|---|
| Persona tool grant enforcement in completion handler | completion.go:928–949 — second-pass allowlist via GetToolGrants() |
v0.25.0, v0.26.0 |
| Version snapshot includes persona tool grants | workflows.go:263–273 — stageSnapshot.ToolGrants populated at publish |
v0.25.0 |
| Session cleanup job | Shipped in v0.26.0 (background goroutine, SESSION_EXPIRY_DAYS) |
v0.24.3 |
Action: Cross off all three in the ROADMAP's deferred sections and in the v0.25.0 / v0.26.0 milestone checklists.
v0.27.0 — Debt Clearance
All deferred items from v0.25.0 and v0.26.0 plus outstanding tech debt that blocks future work. No new features — just finishing what's owed.
Phase 1: Extension Surface Routes (/s/:slug)
The longest-deferred item (originally v0.21.3). Infrastructure exists:
surface_registry table, InstallSurface handler (zip upload + asset
extraction), ListEnabledSurfaces API. What's missing is the runtime
route mounting, template rendering, and frontend nav integration.
Problem: Hardcoded surface conditionals in base.html
Lines 61–66 and 118–122 of base.html use {{if eq .Surface "chat"}}
chains. Extension surfaces fall through to "Unknown surface". Same
pattern for CSS includes (line 25–26) and script includes (118–122).
Solution: Generic extension surface template + dynamic blocks
base.html changes:
{{if eq .Surface "chat"}}...
{{else if eq .Surface "admin"}}...
...
{{else if .Manifest}}{{template "surface-extension" .}}
{{else}}<div>Unknown surface: {{.Surface}}</div>
{{end}}
New template templates/surfaces/extension.html:
{{define "surface-extension"}}
<div id="extension-surface" class="extension-surface"
data-surface-id="{{.Surface}}"
data-manifest='{{.Manifest | toJSON}}'>
{{/* Extension JS mounts into this container */}}
<div id="extension-mount"></div>
</div>
{{end}}
{{define "css-extension"}}
{{/* Extension CSS loaded dynamically from surfacesDir */}}
{{if .Manifest}}
<link rel="stylesheet" href="{{.BasePath}}/surfaces/{{.Surface}}/css/main.css?v={{.Version}}">
{{end}}
{{end}}
{{define "scripts-extension"}}
{{if .Manifest}}
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/surfaces/{{.Surface}}/js/main.js?v={{.Version}}"></script>
{{end}}
{{end}}
Dynamic route registration at startup:
// pages.go — New() after registerCoreSurfaces():
func (e *Engine) loadExtensionSurfaces() {
if e.stores.Surfaces == nil {
return
}
ctx := context.Background()
surfaces, err := e.stores.Surfaces.List(ctx)
if err != nil {
log.Printf("[pages] Failed to load extension surfaces: %v", err)
return
}
for _, sr := range surfaces {
if sr.Source == "core" {
continue // Core surfaces already registered
}
route, _ := sr.Manifest["route"].(string)
if route == "" {
route = "/s/" + sr.ID // Default to /s/:id
}
manifest := SurfaceManifest{
ID: sr.ID,
Route: route,
Title: sr.Title,
Template: "surface-extension",
Auth: authFromManifest(sr.Manifest), // default "authenticated"
Layout: layoutFromManifest(sr.Manifest),
Source: "extension",
}
e.surfaces = append(e.surfaces, manifest)
}
log.Printf("[pages] Loaded %d extension surfaces from registry",
len(surfaces) - countCore(surfaces))
}
nginx config — serve extension static assets:
# Extension surface assets
location /surfaces/ {
alias /data/surfaces/;
expires 1h;
add_header Cache-Control "public, immutable";
}
Frontend nav integration:
ListEnabledSurfaces API already returns route + title for all enabled
surfaces. The sidebar/nav needs to render extension surfaces as
additional items. Extension surfaces appear in a "Surfaces" section
of the sidebar, below the core nav items.
// app.js or pages.js — on init:
const surfaces = await App.api.get('/api/v1/surfaces');
surfaces.filter(s => !['chat','admin','settings','editor','notes'].includes(s.id))
.forEach(s => renderExtensionNavItem(s));
Extension surface manifest contract (.surface archive):
my-dashboard.surface (zip)
├── manifest.json
├── js/
│ └── main.js ← entry point, mounts into #extension-mount
├── css/
│ └── main.css ← scoped styles
└── assets/ ← images, fonts, etc.
manifest.json:
{
"id": "my-dashboard",
"title": "Dashboard",
"route": "/s/dashboard",
"auth": "authenticated",
"layout": "single",
"components": ["chat-pane"],
"hooks": ["surface"]
}
The extension JS receives the standard ctx object (same extension API
from v0.11.0) plus access to platform components:
// main.js — extension surface entry point
(function() {
const mount = document.getElementById('extension-mount');
const manifest = JSON.parse(
document.getElementById('extension-surface').dataset.manifest
);
// Full access to platform primitives + components
const chatPane = window.ChatPane?.create(mount, { role: 'assist' });
// Build custom UI
mount.innerHTML = '<h1>My Dashboard</h1>';
})();
Admin UI additions:
The admin surfaces section (already exists) gains:
- Upload button (already wired to
InstallSurface) - Enable/disable toggles (already wired)
- Uninstall button (already wired to
DeleteSurface) - Route display showing the
/s/:slugURL
Deliverables:
surface-extensiontemplate (HTML + CSS + scripts blocks)base.htmlconditional chain extended with{{if .Manifest}}fallthroughloadExtensionSurfaces()in page engine- nginx location block for
/surfaces/static assets - Frontend nav renders extension surfaces from
ListEnabledSurfaces - Admin surfaces section shows upload/enable/disable/uninstall
- Sample
.surfacearchive (hello-world dashboard) for testing - CSP nonce propagation for extension scripts
Phase 2: Workflow Engine Polish
Eight items deferred from v0.26.0. All backend infrastructure exists; these are wiring, UI, and enforcement.
Channel header stage indicator + advance/reject controls
- Workflow channels show: stage name, step N of M, assignment info
- Advance / Reject buttons (permission-gated by
workflow.manage) - Renders in the chat header area (same slot as
ai_modecontext banner)
Stage persona auto-switch in chat UI
- On stage transition, chat UI updates the active persona display
- Model selector reflects the stage persona's bound model
- System prompt injection already works (v0.26.4); this is FE-only
Assignment notifications via WebSocket
- New workflow assignment →
workflow.assignedWS event to team members - Claim confirmation →
workflow.claimedWS event to claimer - Uses existing notification infrastructure (v0.20.0)
Round-robin auto-assignment
- Per-stage
transition_rules.auto_assign: "round_robin"inworkflow_stages - On stage entry: query team members, pick least-recently-assigned
workflow_assignments.assigned_topopulated automatically- Falls back to unassigned pool if round-robin fails
on_complete workflow chaining
- Column exists on
workflowstable (nullable JSONB, v0.26.1) - Schema:
{"action": "start_workflow", "target_slug": "...", "data_map": {...}} - On workflow completion: if
on_completeis non-null, start target workflow with mappedstage_datafrom completed instance - Reuses existing
StartInstance()path
Workflow retention enforcement
- Column exists on
workflowstable (retentionJSONB, v0.26.1) - Background sweep (extend staleness goroutine): completed instances
older than
retention.delete_after_days→ hard delete retention.mode = "archive"→ setworkflow_status = 'archived',ai_mode = 'off'(already implemented for channel archive)
Drag-and-drop stage reorder in builder
- Backend
PATCH /api/v1/workflows/:id/stages/reorderalready exists - Frontend: HTML5 drag events on stage list items in workflow builder
- Same DnD pattern as channel/folder reorder (v0.23.1)
Team-scoped workflow management UI
- Team admin settings surface gains "Workflows" section
- Lists workflows owned by the team, quick-edit name/description
- Links to full builder for stage editing
- Mirrors the admin-level builder but scoped to
team_id
Deliverables:
- Channel header workflow stage indicator component
- Advance/reject buttons with permission gate
- Stage persona auto-switch in chat UI
workflow.assigned/workflow.claimedWS events- Round-robin auto-assignment in stage transition handler
on_completechaining trigger in workflow completion path- Retention enforcement in staleness sweep goroutine
- Drag-and-drop stage reorder in workflow builder UI
- Team settings → Workflows section
Phase 3: Workspace + Editor Debt
Items deferred from v0.21.x that are low-hanging fruit.
.gitignorerespect in workspace indexing (workspace/indexer.go— skip paths matching patterns from.gitignorein workspace root)- Workspace settings UI: git config section in channel/project settings (remote URL, branch, auto-commit toggle)
- Pane state persistence per-user/per-project (save pane layout to
user_settingsJSONB, restore on surface load) - Editor drag-drop file reorder (workspace file tree — reorder via DnD, persist order in workspace metadata)
Explicitly deferred (not v0.27.0):
- User settings git credentials management UI — needs vault-encrypted git credential store (v0.28.0 candidate)
- Integration tests: clone/commit/push/pull — needs git binary in CI container
- Article-specific AI tools — needs article surface rebuild
- Mobile hamburger nav — needs responsive audit pass
Deliverables:
.gitignorefilter inworkspace/indexer.go- Git config section in workspace/project settings UI
- Pane layout persistence in
user_settings - Editor file tree DnD reorder
ROADMAP Cleanup
Update the ROADMAP itself:
- Cross off tool grant enforcement (3 locations)
- Move "Deferred to v0.27.0" items under their proper v0.27.0 section
- Remove duplicate entries (extension surface routes listed 4× across the file)
- Add v0.27.0 section with phase structure
- Update dependency graph
v0.27.1 — Tasks Foundation: Service Channels + Scheduler
The core primitive: a channel with no human participant, driven by a scheduler.
Service Channels
type: 'service' — a new channel type. Service channels:
- Have zero human participants (only persona participants)
- Cannot be joined by users (read-only view for authorized users)
- Are created by the scheduler or the
task_createtool - Inherit the workflow engine's stage model (optional — simple tasks skip stages entirely)
- Appear in a "Tasks" sidebar section (collapsed by default)
-- No new migration needed — channel type CHECK already allows extension.
-- Add 'service' to the channel type CHECK constraint:
ALTER TABLE channels DROP CONSTRAINT IF EXISTS channels_type_check;
ALTER TABLE channels ADD CONSTRAINT channels_type_check
CHECK (type IN ('direct','dm','group','channel','workflow','service'));
Task Definitions
A task is a lightweight scheduled job that creates a service channel and runs a completion (or instantiates a workflow).
tasks table:
CREATE TABLE tasks (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
team_id TEXT REFERENCES teams(id) ON DELETE SET NULL,
name TEXT NOT NULL,
description TEXT,
scope TEXT NOT NULL DEFAULT 'personal'
CHECK (scope IN ('personal', 'team', 'global')),
-- What to run
task_type TEXT NOT NULL DEFAULT 'prompt'
CHECK (task_type IN ('prompt', 'workflow')),
persona_id TEXT REFERENCES personas(id) ON DELETE SET NULL,
model_id TEXT, -- explicit model override (nullable)
system_prompt TEXT, -- additional system prompt (prepended)
user_prompt TEXT, -- the message to send
workflow_id TEXT REFERENCES workflows(id) ON DELETE SET NULL,
tool_grants JSONB, -- explicit tool allowlist (nullable = inherit all)
-- Schedule
schedule TEXT NOT NULL, -- cron expression (5-field)
timezone TEXT NOT NULL DEFAULT 'UTC',
is_active BOOLEAN NOT NULL DEFAULT true,
-- Execution policy
max_tokens INTEGER NOT NULL DEFAULT 4096,
max_tool_calls INTEGER NOT NULL DEFAULT 10,
max_wall_clock INTEGER NOT NULL DEFAULT 300, -- seconds
output_mode TEXT NOT NULL DEFAULT 'channel'
CHECK (output_mode IN ('channel', 'note', 'webhook')),
output_channel_id TEXT REFERENCES channels(id) ON DELETE SET NULL,
webhook_url TEXT,
-- Provider routing
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL,
-- Notifications
notify_on_complete BOOLEAN NOT NULL DEFAULT false,
notify_on_failure BOOLEAN NOT NULL DEFAULT true,
-- Bookkeeping
last_run_at TIMESTAMPTZ,
next_run_at TIMESTAMPTZ,
run_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_tasks_next_run ON tasks (next_run_at) WHERE is_active = true;
CREATE INDEX idx_tasks_owner ON tasks (owner_id);
Two task types:
-
Prompt task (
task_type = 'prompt'): Create a service channel, senduser_promptwithsystem_promptprepended, collect response. Simple, single-turn. Good for: news digest, stock screener, daily standup prep, report generation. -
Workflow task (
task_type = 'workflow'): Instantiate a workflow in a service channel. Multi-stage, tool-using, potentially long-running. Good for: data pipeline, automated review, research agent.
Scheduler
Background goroutine (same pattern as compaction scanner, staleness sweep):
type TaskScheduler struct {
stores store.Stores
interval time.Duration // poll interval: 30s
stop chan struct{}
}
func (s *TaskScheduler) Run() {
ticker := time.NewTicker(s.interval)
for {
select {
case <-ticker.C:
s.poll()
case <-s.stop:
return
}
}
}
func (s *TaskScheduler) poll() {
// SELECT * FROM tasks WHERE is_active AND next_run_at <= now()
// ORDER BY next_run_at LIMIT 10
due, _ := s.stores.Tasks.ListDue(ctx)
for _, task := range due {
go s.execute(task)
}
}
Cron parsing: use github.com/robfig/cron/v3 for 5-field expressions.
Compute next_run_at after each execution.
Provider Resolution for Tasks
Task execution needs a provider. Resolution order:
- Explicit
provider_config_idon task (user chose a specific provider) - Personal BYOK provider (if
scope = 'personal'and user has BYOK keys) - Team provider (if
scope = 'team') - Global provider (fallback)
- Routing policy (if task's model matches a routing policy)
This is the same resolution chain as completions, reusing the existing
resolveProvider() path in completion.go.
Migration
- 026_tasks.sql:
taskstable, service channel type extension
Deliverables:
taskstable + store interface + PG/SQLite implementationstype: 'service'channel typeTaskSchedulerbackground goroutine- Cron expression parsing +
next_run_atcomputation - Task execution path (create service channel, run completion)
- Provider resolution for task context
- Tasks sidebar section (read-only view of service channels)
v0.27.2 — Task Execution: Budgets + Admin Controls
Execution Budgets
Three limits enforced in the task execution path:
max_tokens— total output tokens. Tracked via existingusage_loginfrastructure. Execution halts mid-stream if exceeded.max_tool_calls— total tool invocations per run. Counter incremented instreamWithToolLoop. Execution halts on breach.max_wall_clock— seconds.context.WithTimeouton the execution goroutine. Hard kill on timeout.
Budget breach → task marked as budget_exceeded in run history,
notification to owner.
Task Run History
task_runs table:
CREATE TABLE task_runs (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
channel_id TEXT REFERENCES channels(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'running'
CHECK (status IN ('running','completed','failed',
'budget_exceeded','cancelled')),
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
tokens_used INTEGER DEFAULT 0,
tool_calls INTEGER DEFAULT 0,
wall_clock INTEGER DEFAULT 0, -- seconds
error TEXT
);
Admin Controls
Global config keys:
tasks.enabled— master kill switch (default: true)tasks.allow_personal— whether non-admin users can create personal tasks (default: false). Same pattern aspersonas.allow_personal.tasks.max_concurrent— max simultaneous task executions (default: 5)tasks.default_budget.max_tokens— default ceiling (overridable per task)tasks.default_budget.max_tool_calls— default ceilingtasks.default_budget.max_wall_clock— default ceiling (seconds)
Admin panel — Tasks section:
- List all tasks (filterable by owner, team, scope, status)
- View task run history with budget usage
- Pause/resume individual tasks
- Kill running executions
- Edit default budgets
- Toggle
tasks.allow_personal
Permission integration:
- New permission:
tasks.create— required to create tasks - New permission:
tasks.admin— required to manage others' tasks Everyonegroup getstasks.createiftasks.allow_personalis true
Deliverables:
- Execution budget enforcement (tokens, tool calls, wall clock)
task_runstable + store- Budget breach notification to task owner
- Global config keys for task admin controls
- Admin panel Tasks section (CRUD, history, kill switch)
tasks.createandtasks.adminpermissionstasks.allow_personaltoggle (mirrorspersonas.allow_personal)
v0.27.3 — Task Chaining: Webhooks + Workflow-to-Workflow
Completion Webhooks
When a task (or workflow) completes, optionally POST to an external URL:
type WebhookPayload struct {
TaskID string `json:"task_id"`
TaskName string `json:"task_name"`
Status string `json:"status"`
ChannelID string `json:"channel_id"`
CompletedAt time.Time `json:"completed_at"`
Output string `json:"output"` // last assistant message
StageData any `json:"stage_data"` // workflow stage data (if applicable)
}
- Webhook URL on task or workflow (
webhook_urlcolumn) - Retry: 3 attempts with exponential backoff (1s, 5s, 25s)
- Timeout: 10s per attempt
- HMAC signature header (
X-Switchboard-Signature) using a per-task secret
task_create Tool
AI-invocable tool that spawns sub-tasks:
{
"name": "task_create",
"description": "Create a new scheduled or one-shot task",
"parameters": {
"name": "string",
"prompt": "string",
"schedule": "string (cron or 'once')",
"persona": "string (handle, optional)",
"max_tokens": "integer (optional)"
}
}
RequireWorkfloworRequireTeampredicate (not available in personal chats — prevents runaway task creation)- One-shot tasks:
schedule = 'once',next_run_at = now() - Created tasks inherit the parent's scope (team/global)
- Depth limit: tasks cannot create tasks (no recursive spawning)
Workflow-to-Workflow Chaining
Wires the on_complete column (v0.26.1) into the task system:
- On workflow completion with
on_completeset: create a one-shot task that instantiates the target workflow with mapped data - Data mapping:
data_mapkeys inon_completeJSONB map sourcestage_datafields to target workflow's initialstage_data
Deliverables:
- Webhook delivery with retry + HMAC signature
task_createtool with depth limiton_completechaining via task system- Webhook secret generation per task/workflow
v0.27.4 — Personal Tasks: BYOK Scheduling + User Task UI
The user-facing task experience. Users create personal tasks that run against their BYOK providers.
Use Cases
- Morning news digest: Daily at 6am, prompt "Summarize top tech news",
output to a personal service channel. Persona: "News Analyst" with
web_searchtool grant. - Stock screener: Weekdays at market open, prompt "Check my watchlist for unusual pre-market activity", BYOK OpenAI key for cost control.
- Weekly project summary: Friday at 5pm, prompt "Summarize this
week's activity across my projects", uses
conversation_search+workspace_searchtools. - Daily standup prep: Weekday mornings, reviews yesterday's notes and generates standup talking points.
Settings Surface — Tasks Section
New section in user settings (same pattern as BYOK, Personas):
- Task list: name, schedule (human-readable), last run status, next run
- Create task: name, persona picker, model picker, prompt editor, schedule builder (preset crons + custom), budget overrides
- Task detail: run history, output channel link, edit, pause/delete
- BYOK indicator: shows which provider the task will use
Schedule Builder
Preset schedules + custom cron for power users:
| Preset | Cron |
|---|---|
| Every morning (6am) | 0 6 * * * |
| Weekday mornings (8am) | 0 8 * * 1-5 |
| Every hour | 0 * * * * |
| Weekly (Monday 9am) | 0 9 * * 1 |
| Monthly (1st at midnight) | 0 0 1 * * |
| Custom... | user-entered 5-field cron |
Timezone selector defaults to browser timezone.
BYOK Task Routing
Personal tasks prefer the user's BYOK provider:
- If task has explicit
provider_config_id→ use it - If user has a BYOK provider config for the task's model → use BYOK
- Fall through to team/global provider (if allowed by admin)
Admin can restrict personal tasks to BYOK-only via
tasks.personal_require_byok config key (default: false). When true,
personal tasks without a BYOK provider fail with a clear error instead
of falling through to org providers.
Output Modes
Three output destinations:
- Channel (default): Output goes to a persistent service channel. User views it like a read-only chat. Channel accumulates run outputs over time (scrollable history).
- Note: Output saved as a channel-scoped note (good for structured data, reports). Note title includes timestamp.
- Webhook: Output POSTed to external URL (for integration with other tools — Slack, email, etc.).
Task Sidebar Section
New sidebar section (below Channels, above Chats):
▾ Tasks (collapsible)
◷ Morning News Digest ✓ 6:02am
◷ Stock Screener ✓ 9:30am
◷ Weekly Summary ⏳ Fri 5pm
+ New task
Click opens the task's output channel. Status indicators: ✓ (last run succeeded), ✗ (failed), ⏳ (next run time), ▶ (running now).
Deliverables:
- Settings → Tasks section (CRUD, schedule builder, budget config)
- BYOK provider routing for personal tasks
tasks.personal_require_byokconfig key- Output modes: channel, note, webhook
- Tasks sidebar section with status indicators
- "Run Now" button for manual trigger
- Task output channel read-only view
v0.28.0 — Platform Polish (TBD Pull-Forward)
Candidates pulled from the TBD section, prioritized by value and dependency readiness.
Tier 1 — High value, dependencies met
Virtual scroll for long conversations Conversations with 500+ messages bog down the DOM. Virtual scroll renders only visible messages + buffer zone. Prerequisite for heavy task output channels.
KB auto-injection (context-aware)
Top-K chunk prepend to system prompt, context budget aware, per-channel
toggle. Useful for tasks that need domain knowledge without explicit
kb_search tool calls. Latency budgeting required (embedding lookup
adds ~200ms).
Helm chart
Raw k8s manifests with ${VAR} substitution → proper Helm chart.
values.yaml for replicas, image tags, ingress, storage, secrets,
feature flags. Subchart for dev/test Postgres. Target: helm install switchboard ./chart. Now makes sense because the feature set is
stabilizing post-tasks.
Per-provider model preferences
user_model_settings unique key is (user_id, model_id) — same model
from different providers shares one visibility toggle. Needs
provider_config_id dimension in DB constraint, store, API, and
frontend hiddenModels keying. Natural fit now that BYOK tasks need
per-provider model selection.
Tier 2 — Medium value, some design work needed
Memory compaction Summarize old memories to save context tokens. Confidence decay: reduce confidence over time, prune low-confidence entries. Natural extension of the memory system (v0.18.0).
capability_match routing policy
"Cheapest model with tool_calling" — a new routing policy type. Useful
for tasks that need tool use but don't need the strongest model.
New provider types via config file OpenAI-compatible endpoints registrable via YAML config (no Go code). Enables users to point at local LLMs (Ollama, vLLM, llama.cpp) without a provider code change.
Tier 3 — Future (v0.29+)
- Desktop app (Tauri) — large scope, independent track
- Full PWA with offline — needs service worker rewrite
- Plugin/extension marketplace — needs extension surface routes first (v0.27.0)
- Starlark/sidecar extension tiers — needs extension surface routes first
- Git credentials vault store — needs vault extension design
- Cross-persona memory sharing — needs memory system redesign
Design Decision: Task Permission Model
Tasks introduce a new resource type that intersects with multiple existing permission boundaries:
| Scope | Who can create | Provider used | Visibility |
|---|---|---|---|
| Personal | User (if tasks.allow_personal) |
User's BYOK or team fallback | Owner only |
| Team | User with tasks.create + team membership |
Team provider | Team members |
| Global | Admin | Global provider | All users (read) |
Personal tasks are the BYOK sweet spot. The admin toggle
(tasks.allow_personal) mirrors the existing personas.allow_personal
pattern — a single boolean in global config, surfaced in the admin
settings panel.
Why not just a permission? The tasks.create permission already
gates the ability. tasks.allow_personal is a policy control —
admins might allow task creation for team tasks but prohibit personal
tasks to prevent uncontrolled BYOK spending. Two orthogonal knobs.
Design Decision: Task vs. Workflow Relationship
Tasks and workflows are related but distinct:
| Task | Workflow | |
|---|---|---|
| Trigger | Cron schedule or task_create tool |
Human click or task trigger |
| Participants | Zero humans (service channel) | Humans + AI |
| Complexity | Single prompt or workflow instantiation | Multi-stage, assignment queue |
| Output | Channel, note, or webhook | Channel (interactive) |
| Lifecycle | Recurring (cron) or one-shot | Single instance |
A prompt task is simpler than a workflow — it's a scheduled completion. A workflow task is a task that instantiates a workflow. This keeps the task system lean (scheduling + budgets + output routing) and the workflow engine rich (stages + assignment + personas).
The task_type column makes this explicit. Prompt tasks don't need
workflows at all — they're a direct scheduler → completion path.
Workflow tasks reuse the full workflow engine.
Resolved Decisions
-
Task output retention. Use channel-level retention from v0.26.0. No per-task retention policy needed — the channel's
retentionJSONB handles archive/delete semantics. Notes created by task output survive channel removal (notes are channel-scoped but not cascade-deleted). Storage quotas per user are a future concern (v0.29+ candidate) — not blocking for initial task support. -
Concurrent task execution. Skip with a warning log. If
task_runshas a row withstatus = 'running'for the task, the scheduler skips that tick. Log:[scheduler] Skipping task %s — previous run still active. No queue — if a task consistently overruns its schedule, the user needs to adjust the cron interval or budget. -
Task templates. Ship 3–5 optional starter templates in v0.27.4. Presented as suggestions during task creation ("Start from a template..." option alongside blank task). Not auto-created — user must explicitly choose one. Templates are just pre-filled form values, not persistent DB records.
-
Task notifications. Opt-in per task. Two booleans on the
taskstable:notify_on_complete(default false),notify_on_failure(default true). Uses existing notification infrastructure (v0.20.0 bell + WebSocket push).