Documentation source
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** — `/tasks` and task entity detail pages render the
canonical Task entity type. These screens use Task entity fields such as
`status`, `priority`, `delegation_state`, `assignee_id`, `due_date`,
`reversible_until`, `guardrails`, and `readiness_cache`.
- **Action/task blocks** — `checklist`, `task-tree`, and `planner` blocks 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:
```typescript
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:
```typescript
// 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:
```typescript
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`:
```typescript
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.
```typescript
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]
```
1. A task is created with `status: "active"` (or `"draft"` for work-in-progress definitions).
2. An Inngest event (`task/trigger`) dispatches execution when the trigger condition is met.
3. The session executor creates a `session` linked to the task via `task_id`, emitting `session_events` for real-time tracking.
4. A chat is created and the agent runtime executes with the task's instructions as the system prompt.
5. The run's chat is available for inspection, follow-up, or hand-off.
6. `last_run_at`, `last_run_status`, and `run_count` are updated on the task record.
### Trigger dispatch
Two Inngest functions handle action/task dispatch:
- **`action-dispatch`** — listens for `entity/created` and `entity/updated` events. Queries all active actions for the affected entity type and filters by trigger condition. Calls `triggerTask()` for matching actions and dispatches `session/execute`.
- **`action-cron`** — runs on a schedule, queries scheduled actions whose `trigger_config.schedule` matches the current time using `shouldRunNow()`, calls `triggerTask()`, and dispatches `session/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 assigned `agent_slug`, and natural-language `instructions`.
- Extraction instructions are authored via the task editor (`/actions/by-slug/extract-{field}?entityTypeId=...`) or the `manageTasks` admin tool — not in field config.
- Fields marked in `entity.metadata.lockedFields` are 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`):
```typescript
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): Date
```
All 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, compact `PipelineStrip`, 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 around `BodyMarkdownEditor` that adds `useUpdateEntity()` + realtime `markLocalSave()`.
- **`TaskEditorOutput` / `TriggerConfigFields` / `OutputConfigFields` / `TaskTemplateForm`** — retained as the reusable action-authoring primitives. `RoutineEditor` mounts them directly instead of routing edits through the old `TaskEditor` sheet.
- **Recent/live session surfaces** — `RoutineEditor` absorbs 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
```typescript
// 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:
```typescript
// For POST /api/actions
TaskCreateSchema; // requires: tenant_id, name, slug
// For PATCH /api/actions/[id]
TaskUpdateSchema; // all fields optional
```
### Database 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:
```typescript
scheduledBetween?: { from: Date; to: Date }
// Returns tasks where scheduled_start >= from AND scheduled_start < to
unscheduledOnly?: boolean
// Returns tasks where scheduled_start IS NULL
```
These are used by `getPlannerData()` to split tasks into the two planner buckets.
### Planner data layer
`features/actions/server/planner.ts` exports:
```typescript
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 with `scheduled_start` within the calendar day boundary
- **`unscheduled`** — active/draft tasks with `scheduled_start IS NULL`, limited to 50 ordered by priority
`features/actions/hooks/use-planner-data.ts` exports:
```typescript
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:
```typescript
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_end
```
Both 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_id` depth) or outdent
- Click checkbox to toggle `status` between `active` and `completed`
- Inline add row at bottom; Enter = new sibling, Tab = indent, Shift+Tab = outdent
- Deferred reorder persistence (batches `sort_order` PATCH 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](#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/planner` page 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_count
```
### Creating 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.extraction` was migrated to task rows (completed Phase 7). `syncSystemTasks()` now owns this sync going forward.
- New table to maintain — RLS, indexes, type generation (`pnpm db:types` after each migration).
**What became harder:**
- Authoring extraction instructions requires the task editor or `manageTasks` tool 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 `tasks` table, 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](/docs/features/response-system) — the execution layer that tasks compile into and dispatch through, backed by the unified sessions table
- [Agent System](/docs/features/agent-system) — agents claim and execute tasks during heartbeat cycles
- [Entity System](/docs/features/entity-system) — entities and entity types are the scope for most tasks; entity type field configs drive system task creation via `syncSystemTasks()`
- [Chat](/docs/features/chat) — every task run creates a chat for the audit trail
- [Inngest](/docs/integrations/inngest) — `task-dispatch` and `task-cron` functions 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 ribbon
- `BigThreeSection` / `BigThreeCard` — top 3 root tasks with progress bars
- `QuickAddInput` — permission-gated quick-capture
- `OvernightAgentsSection` — 3-col responsive grid of overnight agent activity cards (empty state collapses)
- `NeedsYouSection` — HITL + overdue merged list with red-dot emphasis
- `PlateSection` — today's todos + routine occurrences
- `TaskEntitiesSection` — full assigned/owned task entity list
- `CompletedTodaySection` — collapsed "Completed today · N" toggle
- `DelegationCopilotCard` — readiness-scored candidates with Delegate CTA
- `AskCopilotCard` — 4 quick-prompt buttons deep-linking to `/chat?q=`
- `AutomationRatioCard` — this week's agent share with delta
- `InFlightSection` — 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).