Sprinter Docs

Actions

Manual automations registered in the actions table and surfaced as buttons on entity detail pages.

Actions

Actions are the user-facing name for manual-trigger rows in the actions (action registry) table. They appear as buttons on entity detail pages — click one to fire a Session that targets the current record. They exist so agents don't have to be chatted at for every repeatable operation: "run outreach on this account", "draft a brief from this document", "scan this opportunity for signals".

Reference: ADR-0004 — Task entities are user-facing work items; the actions table is the action / automation registry; Sessions are execution records.

Overview

Action = actions row with:

  • trigger_type = 'manual' (fires on button click, not schedule or event)
  • status = 'active' (draft / paused / disabled rows are hidden)
  • entity_id or entity_type_id targeting the current entity or its type

When a user clicks an action on a Content Piece detail page, the flow is:

user click
  → POST /api/actions/[id]/trigger  { entityId }
  → requirePermission("actions.team.run")
  → tenant-scoped entity existence check (RLS)
  → triggerTask() creates a parent Session + child sessions
  → Inngest SESSION_EXECUTE event
  → session-executor claims sessions, agents run, output contract writes entities
  → toast with link to /sessions/[parentSessionId]

Key Concepts

Manual trigger — actions are defined in the actions table with trigger_type='manual'. Scheduled (cron) or event-driven rows are routines and don't render as buttons.

Targeting — an action either targets a specific entity (entity_id set — "this button fires on exactly this record") or an entity type (entity_type_id set — "this button fires on every record of this type"). On an entity detail page, both groups surface together: the current entity's specific actions plus all type-level actions (entity_id IS NULL). An action with both entity_id and entity_type_id set is treated as entity-specific — it does not bleed into sibling entities of the same type.

Permission gateactions.team.run (added in the Task-as-Entity Cut 1 migration 20260422000002_actions_permissions.sql). The gate lives in app/api/actions/[id]/trigger/route.ts. Before Cut 1 the gate was entities.team.update, which conflated editing entities with firing automations — an editor on entities should not automatically gain the ability to run every action in the tenant.

Cross-tenant IDOR guard — the trigger route validates that the supplied entityId belongs to the caller's tenant using the user-scoped Supabase client before calling triggerTask(). RLS enforces tenancy on the read; a member of tenant A cannot pass an entityId from tenant B.

How It Works

<EntityActions /> block

features/blocks/components/entity-actions-block.tsx is a client component registered as block type entity-actions. It renders one <Button> per action. The server-side resolver (features/blocks/definitions/entity-actions.ts) queries the actions via listEntityActions() from features/blocks/server/list-entity-actions.ts.

The component is zero-footprint — when no actions match the entity's tenant, type, or id, it renders null. Entity detail pages that have no automations show no Actions card at all.

Server helper — listEntityActions()

import { listEntityActions } from "@/features/blocks/server/list-entity-actions";

const actions = await listEntityActions({
  tenantId,
  entityId: entity?.id,
  entityTypeId: entityType?.id,
});

Query shape:

SELECT id, name, slug, description
FROM actions
WHERE tenant_id = $tenantId
  AND status = 'active'
  AND trigger_type = 'manual'
  AND (
    entity_id = $entityId
    OR (entity_id IS NULL AND entity_type_id = $entityTypeId)
  )
ORDER BY name ASC

The entity_id IS NULL AND entity_type_id = … branch is the fix for the sibling-leak case: an action scoped to entity Y of type T must not show on entity X of the same type. Type-level actions (no entity_id) stay visible to all entities of the type.

Uses the user-scoped Supabase client — RLS enforces the tenancy read. The function also UUID-validates inputs at its boundary so a non-UUID value can never expand the PostgREST .or() filter string. Fail-soft: any error returns [] so a broken action row never crashes an entity detail page.

Trigger route — POST /api/actions/[id]/trigger

Body: { entityId?: uuid, userInput?: Record<string, unknown> }.

  1. requirePermission("actions.team.run") — 403 on miss.
  2. If entityId is supplied, SELECT id FROM entities WHERE id = $entityId AND tenant_id = $tenantId via the user-scoped client. 403 on miss.
  3. triggerTask({ taskId, tenantId, entityId, triggeredBy: "manual" }) — creates a parent Session + child sessions.
  4. inngest.send({ name: "session/execute", data: { parentSessionId } }).
  5. Responds 201 with { parentSessionId, chatId, sessionCount }.

Creating an Action

You can create an action via the manageTasks admin tool or the /actions/new UI. Minimum fields:

  • name — human label shown on the button ("Generate outreach")
  • slug — stable URL identifier (generate-outreach)
  • trigger_typemanual
  • entity_type_id or entity_id — what this button applies to
  • statusactive
  • output_type + output_config — what the Session produces (response, entity, entities, relation-entity, document, status, none)
  • agent_slug — optional, assigns the Session to a specific agent

Once saved, the button appears automatically on every detail page of the targeted entity type (or on the single entity if entity_id is set), as long as the entity detail view renders an entity-actions block.

Discovering actions on an entity (cmd+K palette)

Every entity detail page mounts an <EntityCommandPalette />. Press / (when not focused in an input) or cmd+k to open a scoped command list of every active manual-trigger action targeting this record or its type. Selecting an action immediately opens an inline session transcript; the global app-shell cmd+k palette defers to the entity palette while one is mounted, so the same key always does the most-local thing.

  • Hotkeys: / and cmd+k (entity-page only); esc to close.
  • Empty state: a "No actions defined for {type} yet — Create one" link routes to /actions/new?entityTypeId=....
  • Fuzzy filter: matches title, slug, and description.
  • Last-run chip: shows completed / failed / running next to actions that have been exercised at least once.
  • FormSpec branch: actions whose metadata declares a FormSpec route to /actions/by-slug/{slug}?entityId=... so the user can fill inputs in the full editor (Cut 1 fallback; in-place FormSpec drawer is a follow-up).

The palette query goes through GET /api/entities/[id]/actions, which is gated on actions.team.read and RLS-scoped — viewers and other roles without read access on actions get 403, and the route 404s on entities the caller can't read so nothing about it can be used to enumerate IDs. Data shape:

GET /api/entities/[id]/actions
→ {
    actions: Array<{
      id: string
      slug: string
      title: string
      description: string | null
      icon: string | null
      output_type: 'response' | 'entity' | 'entities' | 'relation-entity' | 'document' | 'status' | 'none'
      requires_form: boolean        // FormSpec defined on metadata
      last_run_at: string | null
      last_run_status: 'completed' | 'failed' | 'running' | null
    }>
  }

Backed by listEntityScopedActions() in features/blocks/server/list-entity-actions.ts. Sorted by recency-of-use first (last_run_at desc, nulls last), then title.

Auto-attached action chips

The default detail layout (the bento layout — no custom view) auto- renders an <AutoEntityActions /> chip bar above the body. It uses the same React Query hook as the palette (useEntityScopedActions), so the two surfaces share one cache entry. Renders nothing when the entity has zero scoped actions, so action-less entity types stay zero-footprint.

The chip bar mounts on every entity-detail navigation, so the hook's staleTime is 60s at this call site (the palette uses 0). Task CRUD mutations explicitly invalidate entityScopedActionsQueryKey(), which keeps the bar accurate without per-visit refetches.

Custom views opt out: when the active view's resolved blocks already include an explicit entity-actions block, the auto bar steps aside so users never see two chip rows. Tenants who want the chip bar on a custom view can add the entity-actions block to the view's blocks list; tenants who don't want it leave the block out and the auto bar stays away.

Inline session transcript

Clicking an action button (or selecting one from the palette) opens a right-side <Sheet> that mounts <SessionTranscript sessionId={...} /> once the trigger POST resolves. Until then a skeleton renders for the optimistic feel. Mobile (max-width: 768px) keeps the existing toast → /sessions/[id] link flow because the Sheet would be cramped.

If the trigger POST 4xx/5xxs, an inline error renders inside the Sheet and toast.error() fires for visibility — the Sheet stays open so the user can retry from the same context.

Permissions

app_permission values added in supabase/migrations/20260422000002_actions_permissions.sql:

PermissionWho gets itAction
actions.team.runmember, editor, admin, ownerClick an action button → POST trigger
actions.team.readmember, editor, admin, ownerSee actions in lists / block results
actions.team.createeditor, admin, ownerAuthor new actions
actions.team.updateeditor, admin, ownerEdit existing actions
actions.team.deleteadmin, ownerRemove actions

For Agents

Agents with the manageTasks tool can create, update, and delete actions. When an agent asks a user to run an action on an entity, the agent should direct the user to the entity detail page and reference the button by name — the agent does not need to fire the action itself, and in fact can't without the actions.team.run permission on its own role.

Design Decisions

Zero-footprint empty state — when no actions apply to an entity, the block renders nothing rather than "No actions available". Cleaner detail pages, and most entities in most tenants have zero actions until a user or admin authors them.

actions.team.run is separate from entities.team.update — editing an entity is a different permission than firing an automation on it. A content producer might be able to edit records but not fire a "publish to LinkedIn" action. Before the Cut 3 security fix, the trigger route used entities.team.update as its gate, which tied these two concerns together incorrectly.

Server-side resolver, client-side render — the block definition lives in features/blocks/definitions/index.server.ts because its resolver pulls in the server-scoped Supabase client. The component itself is a plain client component that receives block.data.actions as props and handles the POST / toast flow.

Inline session transcript (desktop) + toast fallback (mobile) — desktop selects from the palette open a right-side <Sheet> that mounts <SessionTranscript /> so the user watches the agent run in context. On mobile (≤ md:), the Sheet is cramped, so we fall back to the original toast → /sessions/[id] link flow. The chip bar always uses the toast-and-link flow so it stays predictable across viewports.

Single visible action surface per page — the entity detail hero used to render an <EntityTaskActions /> "Run action" dropdown alongside the chip bar and palette. Three surfaces of the same data made the page feel busy and made it hard to reason about which surface fired which trigger. The dropdown was retired in favor of the chip bar (visual discovery) + palette (keyboard discovery).

/api/actions/[id]/trigger is the canonical trigger endpoint. The old /api/tasks/** compatibility routes have been retired.

Action Queues

A queue is a property of a cron action — not a separate primitive. When a cron tasks row carries queue_config jsonb, the unified action-tick worker fans out a target action per item matching the configured data source, with per-column policies, atomic slot accounting, and cancel-on-source-change. Heartbeats are cron actions assigned to an agent; when queue_config is null but assigned_agent_id is set, the same worker invokes the agent's autonomous run. There is no separate view_scan trigger and no features/orchestrator/ module.

Reference: ADR-0016 — Action queues unify orchestration. See also the spec at documents/work/2026-04-28-kanban-orchestrator-layer/spec.md.

Anatomy of queue_config

import type { DataSourceConfig } from "@/features/blocks/types";

export type QueueConfig = {
  // The backlog. Same shape as views.data_sources entries — no new vocabulary.
  source: DataSourceConfig;

  // UI anchor — when set, the kanban that renders source_view_id /
  // source_name shows the overlay tied to this queue. Anchor uniqueness
  // is enforced at the DB layer (see below).
  source_view_id?: string;
  source_name?: string;

  // Required. Applied when an item's group_by value has no override.
  default_policy: ColumnPolicy;

  // Optional. Per-column overrides keyed by the data source's group_by value.
  policies?: Record<string, ColumnPolicy>;

  // The action this queue dispatches when policies don't override.
  // Per-policy overrides set their own target_action_id.
  target_action_id?: string;

  // When the entity's group_by value changes mid-flight, cancel running
  // sessions in the previous policy. Default true.
  cancel_on_source_change: boolean;
};

source is an inline DataSourceConfig snapshot — the same shape used by views.data_sources entries. Editing the kanban does NOT auto-mutate the queue (predictability over auto-sync); the drawer surfaces a "kanban data source has diverged" warning when they drift.

ColumnPolicy fields

export type ColumnPolicy = {
  // Required when the policy is non-terminal.
  target_action_id?: string;

  // 1..50. Cap on in-flight sessions per (action, entity) pair.
  concurrency_limit?: number;       // default 3

  // 1..5. Server-enforced via DB CHECK constraint on queue_config.
  max_turns?: number;               // default 3

  // 0..20. Failed-run retries before the column shows "exhausted".
  max_retries?: number;             // default 3

  // 1..1440. How long a failed entity is excluded from the retry pool.
  retry_backoff_minutes?: number;   // default 5

  // Terminal columns never dispatch. Default false.
  terminal?: boolean;
};

max_turns ≤ 5 is enforced by a DB CHECK constraint on tasks.queue_config so a corrupted client cannot author an unbounded loop. Continuation gates also re-evaluate per turn — see Continuation below.

Anchor uniqueness

A partial unique index on (tenant_id, queue_config->>'source_view_id', queue_config->>'source_name') WHERE queue_config IS NOT NULL AND status = 'active' enforces at most one active queue per kanban anchor. The POST /api/actions route rejects overlapping anchors with a clear error before the index ever fires; the index is the defense-in-depth.

Atomic slot accounting

The tick worker uses a partial unique index plus INSERT ... ON CONFLICT DO NOTHING to claim a session for each (target action, entity) pair:

CREATE UNIQUE INDEX uniq_sessions_active_target_entity
  ON sessions (parent_action_id, entity_id)
  WHERE status IN ('pending', 'running');

A 23505 unique-violation means "slot already claimed" — the worker silently skips that item this tick. No advisory locks, no two-phase commit, no overshoot.

Cancel-on-source-change

The existing actionDispatch worker handles entity_updated events. When an entity's group_by value transitions out of an active policy (including into a terminal column), in-flight sessions for that entity are abandoned via cancelSessionsForEntity({ actionId, entityId, reason: "queue-source-changed" }). The cancel helper lives in features/sessions/server/cancel-session.ts — one canonical cancel path, nothing queue-specific in sessions/.

Continuation

After each turn of any session, shouldContinueSession re-evaluates the gate. For queue-spawned sessions (those with metadata.spawned_by_queue set) the worker re-resolves the policy against the entity's CURRENT group_by value:

  • Column became terminal → exit.
  • Entity moved to a different policy whose target_action_id differs → exit.
  • turn_count >= policy.max_turns → exit.
  • Otherwise → continue on the warm thread.

One function, every session, every trigger type — there is no queue-specific path inside features/sessions/.

"Automate this" — kanban UX entry point

Any kanban view bound to a named data source carries an "Automate" button on the toolbar (gated by actions.team.create). Clicking it opens a queue-config drawer with the kanban's data source pre-filled and read-only. The drawer collects default_policy plus any per-column overrides, then POST /api/actions creates a cron tasks row with the queue attached. The kanban "comes alive": column header chips show {running} / {queued} / {retry-pending}, and active cards render an "agent working" badge. Realtime subscriptions on actions and sessions update the overlay sub-second.

Per-column agent assignment falls out of this — set a different target_action_id per policies[column], and each target action carries its own assigned_agent_id. No new schema for "which agent does this column."

File layout

features/actions/
├── server/
│   └── queue/
│       ├── dispatch.ts          runQueueTick + tryClaimDispatch
│       ├── resolve-policy.ts    resolvePolicy(queue, groupValue)
│       └── cancel.ts            cancel-on-source-change branch helpers
├── hooks/
│   └── use-queue-status.ts      column counts (running / queued / retry)
└── types.ts                     QueueConfig + ColumnPolicy zod schemas

features/blocks/components/kanban/overlay/
├── automate-button.tsx          toolbar entry point
├── queue-config-drawer.tsx      create / edit drawer
├── column-status-chip.tsx       per-column header chip
└── card-running-badge.tsx       per-card badge

features/inngest/functions/
├── action-cron.ts               every-minute fan-out for cron actions
├── action-tick.ts               one-action-per-tick handler (queue / agent / single)
└── action-dispatch.ts           extended: cancel-on-source-change branch
  • Tasks — the user-facing Task entity schema and lifecycle.
  • Sessions — the execution records an action fires.
  • Block System — how blocks are registered, resolved, and rendered on entity detail pages.
  • Auth & Permissionsapp_permission enum and role grants.

On this page