Sprinter Docs

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:

MechanismWhere it livedMigration status
Field extraction instructionsEntity type field config JSON (deleted Phase 7)Migrated to tasks
EntityTypeConfig.statusTriggersEntity type config JSONMigrated to tasks
FieldConfig.actionsField config JSONMigrated to tasks
Automationstasks 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 blockschecklist, 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:

TemplatePage typePurpose
Task Queue CommandlistCompact triage surface with summary cards, queue table, priority mix, and delegation mix
Task Status BoardlistKanban-first board grouped by Task status
Task Delegation BoardlistAgent-work board grouped by delegation_state
Task Workspace PulseworkspaceWorkspace 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

ValueWhen it fires
manualUser clicks a button or agent calls manageTasks with action trigger
entity_createdAn entity of the scoped type is created
entity_updatedAny field on a scoped entity changes
field_changedA specific field on a scoped entity changes (replaces statusTriggers)
cronScheduled via cron expression in trigger_config.schedule
webhookIncoming 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

ValueMeaning
draftDefined but not yet active — won't fire or appear in agent backlogs
activeLive — fires on trigger, appears in agent backlogs
pausedTemporarily suppressed — trigger events ignored
disabledPermanently off — archived but visible
completedOne-time task that has run to completion

Output types

Tasks share the platform action/session output contract vocabulary:

ValueWhat the task produces
responseField-scoped or scored response output submitted through the unified response flow
entityA new entity record
entitiesMultiple new entity records
relation-entityA new entity linked as a relation
documentA document attached to the scoped entity
statusA field_changed update (e.g. status transitions)
noneSide-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]
  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:

FromToEffect
ListTimeline slotSets scheduled_start = slotTime, scheduled_end = slotTime + duration. Optimistic update.
Timeline eventTimeline slotPreserves 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 eventListClears 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): 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 TaskRecordTimelineEvent 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 surfacesRoutineEditor 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_slugassigned_toMeaning
setnullAssigned to a specific agent — only that agent executes
nullsetAssigned to a specific human — appears in their task queue
setsetBoth — agent executes, human is the responsible owner
nullnullUnassigned — 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

MethodPathDescription
GET/api/actionsList actions (filterable by entity_type_id, entity_id, agent_slug, assigned_to, status)
POST/api/actionsCreate 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]/triggerManually 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 optional

Database table

The tasks table has partial indexes for all common access patterns:

IndexQuery pattern
idx_tasks_tenantAll tasks for a tenant
idx_tasks_typeTasks on an entity type
idx_tasks_entityTasks on a specific record
idx_tasks_parentChildren of a parent task
idx_tasks_triggerActive tasks by trigger type (for dispatch)
idx_tasks_cronActive cron tasks (for scheduler)
idx_tasks_agentActive tasks by agent_slug (for heartbeat)
idx_tasks_userTasks assigned to a human (for backlog)
idx_tasks_backlogActive unassigned tasks (tenant backlog)
idx_tasks_scheduledTasks with a scheduled_start (planner queries)
idx_tasks_dueTasks with a due_at (deadline queries)

Scheduling fields

Migration 20260411000010_task_scheduling.sql adds four nullable columns to tasks:

ColumnTypePurpose
due_attimestamptzDeadline — independent of when work is planned
scheduled_starttimestamptzWhen the task is planned to start
scheduled_endtimestamptzWhen the task is planned to finish
prioritytext (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 NULL

These 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 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:

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_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 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
  • 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
  • Inngesttask-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)

TermMeaning
TodoA one-shot task planned once (trigger_type='manual', no children)
RoutineA recurring or multi-step task (cron / event trigger OR has children)
SubtaskA step inside a parent task (parent_id set)
OccurrenceOne 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

ActionWhat changes
Check off a Todotask.status = 'completed' via PATCH /api/actions/[id]
Check off an Occurrencetask.status = 'completed' via PATCH /api/actions/[id]
Check off a SubtaskSame 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:

FieldSource
overdue / todayTodosTask entities plus any unmigrated public.actions rows pending migration
todayRoutineOccurrencesScheduled-for-today sessions from the action/routine registry
todayTaskEntitiesRaw entity rows (assigned or owned) for callers deriving secondary views
completedTodayMerged entity + legacy completions within the user's local day
bigThreeTop 3 root task entities by priority + due date
needsYouHITL asks (agent blocked on user) + overdue user todos
inFlightTenant-wide in-flight agent sessions (workspace pulse)
automationMerged entity + legacy automation ratio for this week
overnightActivity≤6 OvernightFeedItem[] from listOvernightActivity on user's primary role
delegationCandidatesTop 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).

On this page