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
actionstable 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_idorentity_type_idtargeting 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 gate — actions.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 ASCThe 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> }.
requirePermission("actions.team.run")— 403 on miss.- If
entityIdis supplied,SELECT id FROM entities WHERE id = $entityId AND tenant_id = $tenantIdvia the user-scoped client. 403 on miss. triggerTask({ taskId, tenantId, entityId, triggeredBy: "manual" })— creates a parent Session + child sessions.inngest.send({ name: "session/execute", data: { parentSessionId } }).- 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_type —
manual - entity_type_id or entity_id — what this button applies to
- status —
active - 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:
/andcmd+k(entity-page only);escto 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/runningnext 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:
| Permission | Who gets it | Action |
|---|---|---|
actions.team.run | member, editor, admin, owner | Click an action button → POST trigger |
actions.team.read | member, editor, admin, owner | See actions in lists / block results |
actions.team.create | editor, admin, owner | Author new actions |
actions.team.update | editor, admin, owner | Edit existing actions |
actions.team.delete | admin, owner | Remove 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_iddiffers → 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 branchRelated Modules
- 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 & Permissions —
app_permissionenum and role grants.