Tasks
The 6th platform primitive — visible, assignable units of work for agents and humans.
Overview
Tasks are the Sprinter Platform's canonical unit of work. Every "when X happens, have Y do Z" pattern in the system — field extraction, status triggers, field actions, automation entities, heartbeat attention scanning — maps to a Task.
Think of a task like a task in Asana, except agents and humans are equal participants. A task has a name, natural-language instructions, an assignee (agent or human or nobody), a trigger (manual, event, or cron), and an output contract. It lives in its own tasks table with proper typed columns, FK constraints, partial indexes, and RLS — not buried in JSONB config.
Before tasks, "what work should happen" was scattered across seven mechanisms totalling ~1,500 lines of trigger and handler code:
| Mechanism | Where it lived | Migration status |
|---|---|---|
| Field extraction instructions | Entity type field config JSON (deleted Phase 7) | Migrated to tasks |
EntityTypeConfig.statusTriggers | Entity type config JSON | Migrated to tasks |
FieldConfig.actions | Field config JSON | Migrated to tasks |
| Automations | tasks table (trigger_type and trigger_config) | Already in tasks |
| agent-task / agent-goal entity types | entities table |
| Three-source heartbeat attention scanning | features/agents/lib/attention-context.ts |
After tasks, one table, one module, one concept. Work is visible in the UI, not hidden in background jobs.
What tasks unlock:
- A tenant-wide task backlog — what needs doing, who is doing what
- Per-user and per-agent workload views — "what's on Tyler's plate?"
- Visible, toggleable extraction tasks — not invisible background magic
- Entity-type tasks that run when records are created or fields change
- Standalone tasks not attached to any record — "set up CRM integration"
- Every run is an inspectable chat conversation
- Agents claim unassigned tasks during heartbeat; humans pick them up from their backlog
Task UX Surfaces
The current task experience has two layers:
- Task entity screens —
/tasksand task entity detail pages render the canonical Task entity type. These screens use Task entity fields such asstatus,priority,delegation_state,assignee_id,due_date,reversible_until,guardrails, andreadiness_cache. - Action/task blocks —
checklist,task-tree, andplannerblocks render the operational task table used by automation and scheduling flows.
The /tasks list screen keeps the generic data table and split view as the
source of truth, but frames them with a task queue header: active preset,
visible row count, open count, due-soon count, and agent-assisted count. Presets
remain URL-backed filters, so links and browser navigation still behave like the
regular entity list.
Task entity detail pages include a built-in Plan tab. It uses the parent
task's child Task entities and blocked_by relations to show a list/DAG
execution plan, plan completion, readiness, reversible window, guardrails,
subtasks, and recent sessions.
The view template picker exposes four task-scoped fixtures for QA and customer setup:
| Template | Page type | Purpose |
|---|---|---|
| Task Queue Command | list | Compact triage surface with summary cards, queue table, priority mix, and delegation mix |
| Task Status Board | list | Kanban-first board grouped by Task status |
| Task Delegation Board | list | Agent-work board grouped by delegation_state |
| Task Workspace Pulse | workspace | Workspace pulse with task metrics, charts, planner, and recent movement |
Key Concepts
TaskRecord
The TypeScript type for a row in the tasks table, with typed overrides for JSONB columns:
type TaskRecord = {
id: string;
tenant_id: string;
// Scope
entity_type_id: string | null; // null = standalone task
entity_id: string | null; // null = type-level, not record-level
// Hierarchy (Todoist model)
parent_id: string | null;
depends_on: string[]; // slugs of sibling tasks that must complete first
sort_order: number;
// Definition
name: string;
slug: string;
description: string | null;
instructions: string | null; // natural-language SOP for the assignee
completion_criteria: string | null;
// Assignment
agent_slug: string | null; // agent assignment
assigned_to: string | null; // human assignment (FK to profiles)
// both null = unassigned, claimable by any agent or human
// Trigger
trigger_type: TaskTriggerType;
trigger_config: Record<string, unknown>;
// Output contract
output_type: WorkflowOutputType | null;
output_config: TaskOutputConfig;
// State
status: TaskStatus;
is_system: boolean; // auto-created by syncSystemTasks() from entity type field configs
last_run_at: string | null;
last_run_status: string | null;
run_count: number;
metadata: TaskMetadata;
created_by: string | null;
created_at: string;
updated_at: string;
};Trigger types
| Value | When it fires |
|---|---|
manual | User clicks a button or agent calls manageTasks with action trigger |
entity_created | An entity of the scoped type is created |
entity_updated | Any field on a scoped entity changes |
field_changed | A specific field on a scoped entity changes (replaces statusTriggers) |
cron | Scheduled via cron expression in trigger_config.schedule |
webhook | Incoming HTTP POST to the task's webhook endpoint |
trigger_config shape per type:
// field_changed
{ fieldKey: "status", fromValue?: "draft", toValue?: "published" }
// cron
{ schedule: "0 9 * * 1" } // every Monday 9am
// webhook
{ secret: "whsec_..." }Statuses
| Value | Meaning |
|---|---|
draft | Defined but not yet active — won't fire or appear in agent backlogs |
active | Live — fires on trigger, appears in agent backlogs |
paused | Temporarily suppressed — trigger events ignored |
disabled | Permanently off — archived but visible |
completed | One-time task that has run to completion |
Output types
Tasks share the platform action/session output contract vocabulary:
| Value | What the task produces |
|---|---|
response | Field-scoped or scored response output submitted through the unified response flow |
entity | A new entity record |
entities | Multiple new entity records |
relation-entity | A new entity linked as a relation |
document | A document attached to the scoped entity |
status | A field_changed update (e.g. status transitions) |
none | Side-effect only (webhook, notification, etc.) |
output_config carries the specifics:
interface TaskOutputConfig {
fields?: string[]; // canonical field targets for output_type "response"
criteria_set_ids?: string[]; // optional scoring overlays for response tasks
entityTypeSlug?: string; // for output_type "entity" / "entities"
sources?: ExtractionSource[];
}TaskMetadata
Optional runtime configuration stored as JSONB in metadata:
interface TaskMetadata {
maxSteps?: number;
inputSchema?: Record<string, unknown>;
consensus?: {
agentSlugs: string[];
strategy: "majority" | "unanimous" | "best-confidence";
};
refinement?: {
enabled: boolean;
judgeAgent?: string;
maxIterations?: number;
qualityThreshold?: number;
refinementPrompt?: string;
};
fieldKey?: string; // for system extraction tasks linked to a specific entity field
requiresApproval?: boolean;
}TaskTree and TaskNode
The resolved hierarchy used for display. resolveTaskTree() returns a TaskTree with nested TaskNode objects. Each node includes the task record and its last run status.
interface TaskTree {
roots: TaskNode[];
}
interface TaskNode {
task: TaskRecord;
children: TaskNode[];
lastRun?: {
id: string;
status: WorkflowNodeStatus;
chat_id: string | null;
completed_at: string | null;
};
}System tasks
Tasks where is_system = true are auto-created by syncSystemTasks() from entity type field configurations. They are distinguished in the UI and cannot be deleted by users (RLS policy enforces this). System tasks are updated automatically when field configurations change.
How It Works
Lifecycle
draft → active → [trigger fires] → running → completed / failed
↑ ↓
paused [chat created for audit trail]- A task is created with
status: "active"(or"draft"for work-in-progress definitions). - An Inngest event (
task/trigger) dispatches execution when the trigger condition is met. - The session executor creates a
sessionlinked to the task viatask_id, emittingsession_eventsfor real-time tracking. - A chat is created and the agent runtime executes with the task's instructions as the system prompt.
- The run's chat is available for inspection, follow-up, or hand-off.
last_run_at,last_run_status, andrun_countare updated on the task record.
Trigger dispatch
Two Inngest functions handle action/task dispatch:
action-dispatch— listens forentity/createdandentity/updatedevents. Queries all active actions for the affected entity type and filters by trigger condition. CallstriggerTask()for matching actions and dispatchessession/execute.action-cron— runs on a schedule, queries scheduled actions whosetrigger_config.schedulematches the current time usingshouldRunNow(), callstriggerTask(), and dispatchessession/execute.
triggerTask() creates the parent/child session tree for an action and hands execution to the session-executor Inngest function.
Parent/child hierarchy
A "workflow" is just a parent task with children — no separate concept needed. Parent tasks are containers; child tasks are the individual work units. The hierarchy follows the Todoist model: one level of parent reference (parent_id), sort_order for sibling ordering, and depends_on (an array of sibling slugs) for sequential execution.
When a parent task is triggered, all its active children are resolved, sorted by depends_on dependencies using Kahn's topological sort, and executed through parent/child sessions for parallel or sequential work.
Extraction system tasks
Extraction is authored exclusively as tasks. System tasks (is_system = true) are the mechanism:
syncSystemTasks(entityTypeId)is called after any entity type config change to create, update, or remove system tasks.- For each field that requires extraction, one system task exists with
output_type: "response",output_config.fields, an assignedagent_slug, and natural-languageinstructions. - Extraction instructions are authored via the task editor (
/actions/by-slug/extract-{field}?entityTypeId=...) or themanageTasksadmin tool — not in field config. - Fields marked in
entity.metadata.lockedFieldsare skipped during execution.
Audit trail
Every task run creates a chat. The sessions table has a chat_id column (added by migration 20260410000001) linking the run to the conversation. Users can navigate from any run in the task history to the full agent reasoning trace.
Day planner
The planner is a composite UI that shows today's tasks laid out on a vertical time rail. It lives at /actions/planner (hardcoded Next.js route) and is also available as a planner block type embeddable in any view.
Architecture:
/actions/planner page
└─ <PlannerBlock initialData={...} /> (server-fetched SSR data)
└─ DndContext (shared — spans both panes)
├─ PlannerList (left: unscheduled + today's scheduled)
│ └─ useDraggable on each task card
└─ DayTimeline (right: hour rail 6am–10pm)
├─ Droppable slot zones (15-min grid)
└─ DayTimelineEvent (draggable body + pointer-events resize handle)The shared DndContext on PlannerBlock is the key architectural decision — it allows drag events to cross the list/timeline boundary. Each draggable item carries { type: "task", taskId, duration? } and each drop target carries { type: "slot", slotTime: Date } or { type: "list" }. The onDragEnd handler on PlannerBlock dispatches to either useScheduleTask or useUnscheduleTask based on the combination.
Drag interactions:
| From | To | Effect |
|---|---|---|
| List | Timeline slot | Sets scheduled_start = slotTime, scheduled_end = slotTime + duration. Optimistic update. |
| Timeline event | Timeline slot | Preserves duration; mutates both timestamps together. |
| Timeline event bottom handle | (pointer events, not dnd-kit) | Mutates scheduled_end only; snaps to 15-min grid; min duration enforced. |
| Timeline event | List | Clears scheduled_start and scheduled_end. |
Time math helpers (features/actions/components/planner/time-math.ts):
pxToTime(pxY, { dayStart, dayEnd, pxPerHour, date, snapMinutes }): Date
timeToPx(iso, { dayStart, dayEnd, pxPerHour, date }): number
snapToSlot(date, snapMinutes): Date
clampEnd(start, end, snapMinutes): Date
durationMinutes(start, end): number
addMinutes(date, minutes): DateAll functions are pure and timezone-aware (take an explicit Date argument). 17 unit tests cover all helpers, including boundary values and cross-midnight edge cases.
Overlap layout: events in the same time slot are distributed across up to 2 side-by-side columns (half-width each) using a greedy left-fill algorithm. A third or later overlapping event collapses with a +N badge.
/actions/planner page: server component at app/(app)/actions/planner/page.tsx. Calls requireAuth(), fetches today's planner data via getPlannerData(), and passes it as initialData to <PlannerBlock />. dynamic = "force-dynamic" prevents stale caching of the "today" date. A skeleton loading UI is provided in loading.tsx.
Generic TimelineEvent seam: DayTimeline, DayTimelineEvent, and PlannerList render any record that conforms to the TimelineEvent contract ({ id, name, scheduled_start, scheduled_end, agent_slug? }) — defined in features/actions/components/planner/timeline-event.ts. PlannerBlock projects TaskRecord → TimelineEvent via toTimelineEvent() in to-timeline-event.ts. This is the only place that knows both shapes. Future entity-sourced planners (or any other data source) can reuse the timeline primitives by supplying a different adapter; the primitives themselves have no dependency on TaskRecord or the tasks hooks.
Routine editor — shared body editor + action-registry page
app/(app)/actions/[id]/page.tsx is the canonical action-registry
detail page. User-facing Task entities use the generic entity detail route
(/task/[id] or tenant-scoped equivalent); action configs never live under
/tasks/[id].
The new routine surface is a page, not a sheet modal. It composes:
RoutineEditor(features/actions/components/routines/routine-editor.tsx) — page-level editor for action-registry rows. Keeps the breadcrumb/header actions (run now, pause/resume, copy ID, delete), task status badge, trigger/output badges, compactPipelineStrip, and a right rail for live + recent sessions.BodyMarkdownEditor(components/ui/body-markdown-editor.tsx) — shared markdown note surface now used by both routines and entities. It owns the Tiptap view/edit toggle, save/cancel flow, wikilink + slash-command extensions, media upload, read-only empty-state handling, and prop-sync behavior.EntityBodyEditor(features/entities/components/entity-body-editor.tsx) — now a thin persistence wrapper aroundBodyMarkdownEditorthat addsuseUpdateEntity()+ realtimemarkLocalSave().TaskEditorOutput/TriggerConfigFields/OutputConfigFields/TaskTemplateForm— retained as the reusable action-authoring primitives.RoutineEditormounts them directly instead of routing edits through the oldTaskEditorsheet.- Recent/live session surfaces —
RoutineEditorabsorbs the old timeline/live-activity responsibilities with a slimmer live transcript card and a recent-runs list that still supports retry, cancel, open-session, and conversation drill-down.
The old TaskDetailPage, TaskEditor, TaskBodyEditor, TaskRunTimeline, TaskLiveActivity, TaskPropertiesPanel, TaskConfigSection, TaskSubtasksSection, and the DAG helpers were deleted once the new page was live.
Intentional boundary: the action-registry page no longer owns inline subtask creation or editable DAG authoring. That richer work-item UX belongs to Task entities after ADR-0004. The routine page keeps the PipelineStrip as a compact topology summary for root routines, while Task entities keep the fuller subtask/dependency experience on /task/[id].
Assignment Model
The assignment model makes agents and humans equal participants:
agent_slug | assigned_to | Meaning |
|---|---|---|
| set | null | Assigned to a specific agent — only that agent executes |
| null | set | Assigned to a specific human — appears in their task queue |
| set | set | Both — agent executes, human is the responsible owner |
| null | null | Unassigned — claimable by any agent during heartbeat or any human from the backlog |
Agent claiming during heartbeat:
Heartbeat agents call getClaimableTasks() instead of the previous three-source attention scan. This function returns active tasks where either agent_slug matches the agent or the task is fully unassigned. Tasks with unsatisfied depends_on predecessors are excluded.
Human assignment:
Human-assigned tasks appear in the /tasks page filtered to the authenticated user. The tasks block on entity detail pages shows all tasks for that record, grouped by assignment status.
API Reference
Routes
| Method | Path | Description |
|---|---|---|
GET | /api/actions | List actions (filterable by entity_type_id, entity_id, agent_slug, assigned_to, status) |
POST | /api/actions | Create an action. Body validated by TaskCreateSchema. |
GET | /api/actions/[id] | Get a single action with resolved tree |
PATCH | /api/actions/[id] | Update action fields. Body validated by TaskUpdateSchema. |
DELETE | /api/actions/[id] | Delete a non-system action |
POST | /api/actions/[id]/trigger | Manually trigger an action regardless of its trigger_type |
Server functions
// CRUD
createTask(input: z.infer<typeof TaskCreateSchema>): Promise<TaskRecord>
updateTask(id: string, patch: z.infer<typeof TaskUpdateSchema>): Promise<TaskRecord>
deleteTask(id: string): Promise<void>
listTasks(filters: TaskFilters): Promise<TaskRecord[]>
// Tree resolution
resolveTaskTree(rootTaskId: string): Promise<TaskTree>
// Returns all descendants nested under the given root task.
// Agent claiming
getClaimableTasks(agentSlug: string, tenantId: string): Promise<TaskRecord[]>
// Returns active tasks assigned to the agent or unassigned, with depends_on satisfied.
// Execution
triggerTask(taskId: string, context?: TaskTriggerContext): Promise<void>
// Compiles the task (and children) to a workflow and dispatches via Inngest.
// System task sync
syncSystemTasks(entityTypeId: string): Promise<void>
// Creates/updates/removes system tasks to match the entity type's field extraction configuration.Zod schemas
Both schemas are exported from features/actions/types.ts and used at the API route boundary:
// For POST /api/actions
TaskCreateSchema; // requires: tenant_id, name, slug
// For PATCH /api/actions/[id]
TaskUpdateSchema; // all fields optionalDatabase table
The tasks table has partial indexes for all common access patterns:
| Index | Query pattern |
|---|---|
idx_tasks_tenant | All tasks for a tenant |
idx_tasks_type | Tasks on an entity type |
idx_tasks_entity | Tasks on a specific record |
idx_tasks_parent | Children of a parent task |
idx_tasks_trigger | Active tasks by trigger type (for dispatch) |
idx_tasks_cron | Active cron tasks (for scheduler) |
idx_tasks_agent | Active tasks by agent_slug (for heartbeat) |
idx_tasks_user | Tasks assigned to a human (for backlog) |
idx_tasks_backlog | Active unassigned tasks (tenant backlog) |
idx_tasks_scheduled | Tasks with a scheduled_start (planner queries) |
idx_tasks_due | Tasks with a due_at (deadline queries) |
Scheduling fields
Migration 20260411000010_task_scheduling.sql adds four nullable columns to tasks:
| Column | Type | Purpose |
|---|---|---|
due_at | timestamptz | Deadline — independent of when work is planned |
scheduled_start | timestamptz | When the task is planned to start |
scheduled_end | timestamptz | When the task is planned to finish |
priority | text (CHECK IN ('p0','p1','p2','p3','p4')) | Linear-style priority rank |
TASK_PRIORITIES (['p0','p1','p2','p3','p4']) and the TaskPriority union type are exported from features/actions/types.ts.
TaskRecord, TaskCreateSchema, and TaskUpdateSchema all include the four fields as optional/nullable. createTask(), updateTask(), and listTasks() pass them through.
listTasks filters
listTasks() accepts two additional filters alongside the existing ones:
scheduledBetween?: { from: Date; to: Date }
// Returns tasks where scheduled_start >= from AND scheduled_start < to
unscheduledOnly?: boolean
// Returns tasks where scheduled_start IS NULLThese are used by getPlannerData() to split tasks into the two planner buckets.
Planner data layer
features/actions/server/planner.ts exports:
getPlannerData(input: {
tenantId: string;
userId: string;
dateISO: string; // "2026-04-11"
filter: { scope: "mine" | "all" | "agent"; agentSlug?: string };
entityTypeId?: string;
entityId?: string;
}): Promise<PlannerData>Returns two task buckets for the given day:
scheduled— tasks withscheduled_startwithin the calendar day boundaryunscheduled— active/draft tasks withscheduled_start IS NULL, limited to 50 ordered by priority
features/actions/hooks/use-planner-data.ts exports:
plannerQueryKey(input): QueryKey
usePlannerData(input, opts?): UseQueryResult<PlannerData>The hook invalidates on task mutations and subscribes to real-time tasks table updates filtered by tenant_id.
Schedule and unschedule mutations
features/actions/hooks/use-tasks.ts exports two new mutations:
useScheduleTask(): UseMutationResult
// Mutation: { id, scheduled_start, scheduled_end }
// Applies an optimistic update immediately, then PATCH /api/actions/[id]
useUnscheduleTask(): UseMutationResult
// Mutation: { id }
// Clears scheduled_start and scheduled_endBoth mutations invalidate plannerQueryKey on settle.
Planner API route
GET /api/actions/planner — Zod-validated, accepts dateISO, scope, agentSlug, entityTypeId, entityId as query params. Returns PlannerData. Uses requireAuth() for tenant context.
Task Blocks
Three block types in the block system are tightly coupled to tasks. They are registered in features/blocks/ with source: "custom" and a role: "display" flag, making them available in the block palette.
task-tree block
features/blocks/components/task-tree-block.tsx — nested Todoist-style task list.
- Drag-to-reorder siblings vertically (via
@dnd-kit/sortable+dnd-kit-sortable-tree) - Drag horizontally on a task row to indent (increase
parent_iddepth) or outdent - Click checkbox to toggle
statusbetweenactiveandcompleted - Inline add row at bottom; Enter = new sibling, Tab = indent, Shift+Tab = outdent
- Deferred reorder persistence (batches
sort_orderPATCH calls on drag-end, not on every move) - Config:
{ entityTypeId?, entityId?, showSystem?, showCompleted?, sort? }
Block metadata: requirement: "none" (fetches its own data), role: "display", defaultSize: "full".
checklist block
features/blocks/components/checklist-block.tsx — flat, fast checkbox list (~133 lines).
- Click checkbox to complete; strike-through + muted color for done items
- Inline "Add a task…" row at bottom; Enter creates and focuses next blank
- Cmd+Enter (or Ctrl+Enter) on an item: mark complete and advance focus to next
- Config:
{ parentTaskId?, entityTypeId?, entityId?, showAdd? }
Block metadata: requirement: "none", role: "both" (can act as an input surface), defaultSize: "full".
planner block
features/blocks/components/planner/planner-block.tsx — composite day-planner block (see Day planner above for full architecture).
- Config:
{ dayRangeStart?, dayRangeEnd?, slotMinutes?, defaultDurationMinutes?, filter?, date?, entityTypeId?, entityId? } - Cannot be decomposed into separate blocks because the shared DndContext must span both panes
- Used directly by the
/actions/plannerpage and embeddable in any view that wants a day-planning surface
Block metadata: requirement: "none", role: "both", defaultSize: "full".
For Agents
manageTasks tool
The manageTasks admin tool (Tier 2, governed) gives agents full task management capability:
manageTasks({ action: "create", data: { name, slug, instructions, ... } })
manageTasks({ action: "update", id: "<task-id>", data: { status: "paused" } })
manageTasks({ action: "delete", id: "<task-id>" })
manageTasks({ action: "list", entity_type_id?: "...", agent_slug?: "..." })
manageTasks({ action: "trigger", id: "<task-id>" })How agents claim tasks during heartbeat
During each heartbeat cycle, instead of scanning legacy workflow or goal entity tables, the agent calls getClaimableTasks():
1. Query: active tasks where agent_slug = me OR (agent_slug IS NULL AND assigned_to IS NULL)
2. Filter: exclude tasks whose depends_on predecessors haven't completed
3. Claim: atomic update to set status = "running" on the chosen task
4. Execute: triggerTask(taskId) -> action/session executor -> session transcript for audit trail
5. Complete: update task last_run_status, run_countCreating tasks as an agent
Entity-type task (runs for all records of a type on creation):
manageTasks({
action: "create",
data: {
name: "Extract company summary",
slug: "extract-summary",
entity_type_id: "<company-type-id>",
trigger_type: "entity_created",
output_type: "response",
output_config: { fields: ["summary"] },
instructions: "Write a 2-sentence summary of the company based on its name, website, and any documents.",
agent_slug: "analyst"
}
})Standalone task (no entity scope, tenant-wide):
manageTasks({
action: "create",
data: {
name: "Reconcile Q1 pipeline",
slug: "reconcile-q1-pipeline",
trigger_type: "manual",
output_type: "none",
instructions: "Review all opportunities in 'proposal' stage and update status based on last contact date.",
assigned_to: "<user-id>"
}
})Parent task with children (workflow decomposition):
// Create parent
manageTasks({ action: "create", data: { name: "Generate social posts", slug: "social-posts", trigger_type: "entity_created" } })
// Create children (set parent_id, depends_on for ordering)
manageTasks({ action: "create", data: { parent_id: "<parent-id>", name: "LinkedIn post", slug: "linkedin", sort_order: 0 } })
manageTasks({ action: "create", data: { parent_id: "<parent-id>", name: "Twitter thread", slug: "twitter", depends_on: ["linkedin"], sort_order: 1 } })Design Decisions
Why "tasks" not "actions"
"Task" is the universal term for a unit of work with a lifecycle and assignee (Asana, Todoist, Linear, Jira). "Action" implies a one-time event. The A2A Protocol (Google's agent interoperability standard) uses "Task" as its fundamental unit, and our TaskRecord maps directly to an A2A Task. Alignment with the emerging standard matters for future agent interoperability.
Separate table, not entity type content
The automation entity type stored workflow config in entity.content — untyped JSONB with no indexing or FK constraints. The tasks table has typed columns, nine partial indexes for common queries, and FK relationships to entity_types, entities, and profiles. Querying "what cron tasks does this tenant have?" or "what tasks are assigned to this agent?" is a single indexed scan, not a full-table JSONB search.
Parent/child not flat dependencies
Three approaches were evaluated: flat context[] arrays (CrewAI style), a separate workflows table (v1 spec), and parent/child self-reference (Todoist model). The self-reference wins because a "workflow" is just a parent task with children — no separate concept needed. One table, one module.
Extraction instructions moved from fields to tasks
FieldConfig.extraction coupled field definition (schema, type) with behavior (agent, prompt, sources). Tasks decouple these: fields define structure, tasks define behavior. This allows different tenants to attach different extraction logic to shared entity types. System tasks (is_system: true) are created and maintained by syncSystemTasks() to represent each field's extraction work as a visible, manageable task.
Every run is a chat conversation
Run auditability via sessions and session_events means users can always inspect why a task produced a particular result -- the agent's reasoning, tool calls, elicitation, and any errors are all in the session transcript. This aligns with Anthropic Managed Agents where sessions are conversations, and makes handoff between agents trivial.
Reuse the session executor
The sessions and session_events tables are the unified execution layer. Every manual or triggered task run creates a session that traces back to its originating task. The DAG execution compiler natively steps through parent and child tasks seamlessly.
Scheduling: start + end, not start + duration
scheduled_start and scheduled_end are stored directly. Duration is a computed UI value. This matches the iCal DTEND standard, Google Calendar, and Outlook. Range queries become trivial (WHERE scheduled_end > $from AND scheduled_start < $to). Full-calendar and other calendar libraries emit a new end timestamp (not a new duration) on resize events, so there is no conversion step.
Shared DndContext on the planner composite
The day planner requires dragging a task card from the left list into a right-side time slot. @dnd-kit only resolves drag events within the same DndContext. Two separate contexts (one per pane) cannot communicate. The PlannerBlock composite owns the single DndContext that wraps both PlannerList and DayTimeline. This is why the planner cannot be split into two separately-configured blocks.
TimelineEvent contract — decoupling UI from TaskRecord
DayTimeline, DayTimelineEvent, and PlannerList do not import TaskRecord. They accept a narrower TimelineEvent interface ({ id, name, scheduled_start, scheduled_end, agent_slug? }) defined in features/actions/components/planner/timeline-event.ts. PlannerBlock is the only place that projects tasks onto this contract, via the toTimelineEvent() adapter. This is a deliberate seam: a future entity-sourced planner (e.g. opportunities with a target close date) can reuse the same UI primitives by providing a different adapter — no changes to the timeline, list, or event components. The same pattern applies if we later unify tasks and entities under the "records" umbrella.
2-column greedy overlap layout, not interval-tree fan-out
Google Calendar's N-column overlap algorithm uses a full interval tree to pack events into the minimum number of columns. The planner uses a simpler greedy left-fill approach that caps at 2 columns. Rationale: the day planner is primarily for planning work, not for high-density calendar display. Two-column is readable and covers the common case. A third+ overlapping event shows a +N badge. Full interval-tree layout is deferred.
Hardcoded /actions/planner, not a db-driven view
The shared DndContext cannot cleanly span two blocks rendered through the surface pipeline (where each block is an independent React tree). The planner is itself a block, so wrapping it in a view would add indirection with no benefit. The hardcoded page renders <PlannerBlock /> directly — zero view config, instant SSR data, and the block remains embeddable elsewhere if needed.
Calendar surface deletion
features/views/surfaces/calendar-surface.tsx (432 lines) distributed blocks across a month grid — a concept distinct from showing calendar events. It had zero references in seeds or existing view configs. Removing it eliminates dead code and prevents future confusion between "calendar layout" and "calendar events". In-DB views referencing surface_type = 'calendar' fall back to grid with a console warning (defensive guard — no audit hit found).
Task entities and action registry are separate
User-facing work items are Task entities (entities[type=task]) rendered by
the entity system. Automation configs live in public.actions; actions
produce sessions and can create or update Task entities. The /tasks page
reads Task entities, while /actions/** manages action-registry rows. There
is no dual-write between a Task entity and an action row for the same work
item.
RLS: system actions cannot be deleted by users
The action-registry delete policy includes is_system = false as a condition.
System actions (auto-created from field configs) are managed by the platform;
users can toggle their lifecycle status but cannot delete them directly.
syncSystemTasks() handles deletion when the underlying field config is
removed.
Trade-offs
Accepted costs:
- Migration effort — the legacy
FieldConfig.extractionwas migrated to task rows (completed Phase 7).syncSystemTasks()now owns this sync going forward. - New table to maintain — RLS, indexes, type generation (
pnpm db:typesafter each migration).
What became harder:
- Authoring extraction instructions requires the task editor or
manageTaskstool rather than editing field config JSON directly. The tradeoff is full visibility and auditability of extraction work.
What became much easier:
- Visibility into all work — one query to the
taskstable, not seven code paths - Human-agent interchangeability — same assignment model for both
- Per-tenant extraction customization — different tenants can attach different tasks to shared entity types
- Agent workload management —
getClaimableTasks()replaces three scattered Inngest queries
Related Modules
- Session System — the execution layer that tasks compile into and dispatch through, backed by the unified sessions table
- Agent System — agents claim and execute tasks during heartbeat cycles
- Entity System — entities and entity types are the scope for most tasks; entity type field configs drive system task creation via
syncSystemTasks() - Chat — every task run creates a chat for the audit trail
- Inngest —
task-dispatchandtask-cronfunctions handle async trigger evaluation documents/ADR-001-tasks-primitive.md— full architecture decision record with alternatives considered
Today surface
The /today page is amble's human+agent workspace briefing — a typography-first greeting, what your agents did overnight, what's on your plate, and the top candidates to delegate next. Designed to answer three questions on one surface: what did my agents ship overnight? what needs me today? what should I hand off next?
Layout
A two-column grid (desktop): the main column stacks Big 3 → QuickAdd → Overnight → NeedsYou → Plate → TaskEntities → CompletedToday. The right rail (TodayRightRail) stacks DelegationCopilot → AskCopilot → AutomationRatio → InFlight. TodayPageClient owns hooks + keyboard shortcuts + undo stack; TodayMainColumn and TodayRightRail are pure presentation.
Header (typography-first greeting)
TodayHeader renders a date eyebrow, a large first-name greeting ("Good morning, Tyler."), a prose ribbon sentence that composes bolded numbers with clauses the component omits when the underlying stat is zero or undefined (never substitutes a placeholder), and a horizontal stat ribbon bordered top+bottom. Inputs flow through the pure deriveTodayProseStats(data) helper so prose and ribbon never disagree. Optional stats (hoursReclaimed, delegationStreak) are undefined in Cut 1 and are OMITTED from both the prose and ribbon until a server-backed source lands.
Vocabulary (humans + agents)
| Term | Meaning |
|---|---|
| Todo | A one-shot task planned once (trigger_type='manual', no children) |
| Routine | A recurring or multi-step task (cron / event trigger OR has children) |
| Subtask | A step inside a parent task (parent_id set) |
| Occurrence | One session of a routine scoped to a date — UX-only term, not a DB concept |
Classification is derived via classifyTask(task, childCount) in features/actions/lib/classify.ts — there is no task_kind DB column; structural role falls out of parent_id, trigger_type, and child count.
Completion semantics
| Action | What changes |
|---|---|
| Check off a Todo | task.status = 'completed' via PATCH /api/actions/[id] |
| Check off an Occurrence | task.status = 'completed' via PATCH /api/actions/[id] |
| Check off a Subtask | Same mechanics as its kind within the parent |
Both todos and routine occurrences route through the unified task PATCH endpoint — there is no separate sessions PATCH. Completion optimistically removes the row from the query cache; a 5-second undo toast appears, and Cmd/Ctrl+Z anywhere on the page (except inside editable text) reverses the most recent completion. The undo stack is LIFO and shared across todos and occurrences.
Data
getTodayData({ tenantId, userId, dateISO, tzOffsetMinutes? }) in features/actions/server/today.ts returns:
| Field | Source |
|---|---|
overdue / todayTodos | Task entities plus any unmigrated public.actions rows pending migration |
todayRoutineOccurrences | Scheduled-for-today sessions from the action/routine registry |
todayTaskEntities | Raw entity rows (assigned or owned) for callers deriving secondary views |
completedToday | Merged entity + legacy completions within the user's local day |
bigThree | Top 3 root task entities by priority + due date |
needsYou | HITL asks (agent blocked on user) + overdue user todos |
inFlight | Tenant-wide in-flight agent sessions (workspace pulse) |
automation | Merged entity + legacy automation ratio for this week |
overnightActivity | ≤6 OvernightFeedItem[] from listOvernightActivity on user's primary role |
delegationCandidates | Top 3 DelegationCandidate[] from the shared work-model delegation queue |
useTodayData(dateISO, tzOffsetMinutes?) is the React Query hook + todayDataQueryKey(dateISO) for cache invalidation. overnightActivity and delegationCandidates degrade to empty arrays when the user has no role assignment — never a crash, never a synthesized role. The server-backed queries are shared with /command-center/delegate (and, once wired, /command-center/my-role) so rankings stay consistent across surfaces.
Section components
TodayHeader— date eyebrow + greeting + prose ribbon + stat ribbonBigThreeSection/BigThreeCard— top 3 root tasks with progress barsQuickAddInput— permission-gated quick-captureOvernightAgentsSection— 3-col responsive grid of overnight agent activity cards (empty state collapses)NeedsYouSection— HITL + overdue merged list with red-dot emphasisPlateSection— today's todos + routine occurrencesTaskEntitiesSection— full assigned/owned task entity listCompletedTodaySection— collapsed "Completed today · N" toggleDelegationCopilotCard— readiness-scored candidates with Delegate CTAAskCopilotCard— 4 quick-prompt buttons deep-linking to/chat?q=AutomationRatioCard— this week's agent share with deltaInFlightSection— tenant-wide in-flight agent sessions
Automation hub (routines view)
The Automation tab at /schedule?tab=routines is amble's single source of truth for every AI action running in the background — anything not directly triggered by a user click. Each card renders a horizontal pipeline:
[Trigger] → [Stage 1] → [Stage 2] → [Output KPI]- Stages are DAG depth levels derived from subtask
depends_on. Parallel subtasks at the same depth stack inside one stage. - WIP dots show active sessions (status
running,waiting_human,awaiting_tool,pending). Long-running and stuck sessions remain visible until they resolve. - Throughput is the 7d count of the stage's output: entities created, responses promoted, or completed runs depending on
output_type. - Output KPI is the aggregate across the leaf stages.
Grouping + pause/resume
Routines are grouped by status in this order: Active → Paused → Draft → Disabled → Completed. Paused and disabled routines render dimmed so live work stands out without hiding inventory.
Every card has an inline Switch that toggles the task status between active and paused. The toggle is optimistic (invalidates both tasks and routines query keys) and drives the client hook useSetTaskStatus() in features/actions/hooks/use-tasks.ts, which fans out a PATCH /api/actions/[id] with { status }.
Default filter
Fresh loads show all automated routines (trigger_type != 'manual') regardless of status or sub-structure. Users narrow via the Filters popover.
Historical note: the default filter previously pinned
statuses: ["active"], which made paused routines disappear and looked like a load failure. The default was loosened on 2026-04-17 so the view always shows every background action the user has authored.
The same PipelineStrip component is embedded on the action detail page for any top-level action (compact variant).