Documentation source
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](../../adr/0004-task-entity-and-action-registry) —
> 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 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()`
```ts
import { listEntityActions } from "@/features/blocks/server/list-entity-actions";
const actions = await listEntityActions({
tenantId,
entityId: entity?.id,
entityTypeId: entityType?.id,
});
```
Query shape:
```sql
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_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: `/` 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:
```ts
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.
## Reliability & Visibility (Circuit Breaker)
Every action row carries breaker state so runaway failures auto-pause
themselves and admins can see what's happening. State lives on
`public.actions` and transitions go through one SQL function —
`record_action_outcome` — fired by the `sessions_terminal_breaker` trigger
on every terminal session transition. Migrations:
`20260511010000_actions_breaker.sql` (state + function + trigger),
`20260511020000_actions_dedupe_extraction.sql` (one-shot dedupe of orphan
auto-extraction rows + partial unique index to prevent recurrence).
### Breaker state on `actions`
| Column | Meaning |
| --- | --- |
| `consecutive_failures` | Count since last completed run. 5 trips the breaker. |
| `last_failure_at` | Most recent terminal failure timestamp. |
| `auto_paused_at` | Stamped when status flips to `paused` by the breaker. |
| `auto_paused_until` | Set only on `cost_cap` pauses — next UTC midnight. |
| `pause_reason` | `consecutive_failures` \| `cost_cap` \| `cascade_block` \| `admin` |
`pause_reason='cost_cap'` is sticky to the cap window (auto-resumes when
`auto_paused_until` lapses, cleared by `action-cron`'s per-minute sweep).
`pause_reason='consecutive_failures'` persists until success or admin
reset — there's no auto-resume.
### Dispatch gates
All three dispatch paths consult the breaker before firing:
- **`action-tick`** calls `checkBreakerAndClaim(actionId)` before the
queue / agent / single-fire branch. Race-safe defense against
late-pause where the action was status=`active` when the tick was
emitted but flipped to `paused` before tick fire.
- **`action-cron`** runs `resumeAutoPausedCostCapActions()` first (clears
lapsed cost-cap pauses), then filters cron candidates by
`status='active' AND auto_paused_at IS NULL`.
- **`action-dispatch`** (entity-event path) adds the same
`.is("auto_paused_at", null)` filter to its matching-tasks SELECT.
### Cascade short-circuit
When `session-executor` finds blocked-pending children and ANY sibling
session already failed with a cost-cap error, the children are marked
`status='skipped'` (NOT `failed`) so their action breakers DON'T
increment for an upstream root cause. The parent's `error_message` is
re-cast as a cost-cap message so its breaker classifies correctly
(`auto-pause-until-UTC-midnight`). Kills the 5–7× cascade multiplier
that DOC's hourly cron produced in May 2026.
### Admin surface — `/admin/actions`
- **Header KPIs** (24h): runs (% fail), paused count, $ spent (action-
driven cost), in-progress sessions.
- **Filter chips:** all / failing / auto-paused / cost-capped /
admin-paused / healthy.
- **Per-action row:** name, trigger, status pill (with `resumes
HH:MM UTC` on cost-cap), 24h runs/failures/cost, last error, row
actions (Pause / Resume / reset breaker).
- Lives under AI & Agents in the admin sidebar. Permission:
`actions.team.read` for the page, `actions.team.update` for mutations.
### Server actions
`features/actions/server/pause-resume.ts`:
- `pauseAction(actionId)` — admin halt. `status='paused'`,
`pause_reason='admin'`. Persists until manual resume.
- `resumeAction(actionId)` — clears ALL breaker state. Sledgehammer.
- `resetBreaker(actionId)` — alias for `resumeAction` (UX clarity).
- `killSession(sessionId)` — marks an in-flight session
`status='abandoned'` with `error_message='Killed by admin'`. The
trigger excludes `abandoned` from breaker accounting — admin kills
don't blame the action.
### Reaper
The existing `session-reaper` Inngest cron continues to write
`status='expired'` on zombie sessions. The new trigger classifies
`expired` as a failed outcome — breaker reacts automatically, no
code change to the reaper.
## 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](../../adr/0016-action-queues-unify-orchestration) —
> Action queues unify orchestration. See also the spec at
> `documents/work/2026-04-28-kanban-orchestrator-layer/spec.md`.
### Anatomy of `queue_config`
```ts
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
```ts
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](#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:
```sql
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
```
## Related Modules
- [Tasks](./tasks) — the user-facing Task entity schema and lifecycle.
- [Sessions](./sessions) — the execution records an action fires.
- [Block System](./block-system) — how blocks are registered, resolved, and
rendered on entity detail pages.
- [Auth & Permissions](./auth-permissions) — `app_permission` enum and
role grants.