---
title: Mission Control — automation and the internals (part 5)
---

# Mission Control — automation and the internals (part 5)

## What it is

This is part 5 of the [Mission Control](mission-control.md) page. It is the how-it-is-built half: the automation engine and the internals behind a board.

## Where to find it

Automations are configured against a board inside **Mission Control**; the rest of this part is reached from code or the command line rather than from a screen.

## How it behaves

### Board automation engine (Phase 5C)

Phase 5C adds an **event-driven automation engine** that fires action chains when board data changes. The sync service snapshots board state before each sync, diffs after, and emits typed `BoardEvent` objects (26 event types: `item_created`, `item_deleted`, `item_archived`, `item_unarchived`, `item_name_changed`, `item_status_changed`, `item_assigned`, `item_due_date_passed`, `item_moved_to_group`, `item_column_changed`, `date_approaching`, `schedule`, `sprint_started`, `sprint_ended`, `comment_added`, `approval_changed`, `webhook_received`, `column_created`, `column_renamed`, `column_deleted`, `link_created`, `link_deleted`, `session_completed`, `group_created`, `group_deleted`, `group_renamed`). The automation engine matches events against user-defined rules in `pm_board_automations` and dispatches action chains through the existing Omniscio automation dispatcher.

**Condition evaluation:** each rule carries a `conditionConfig` — either `{ method: 'always' }` (fire unconditionally, zero overhead) or `{ method: 'conditions', root: ConditionGroup }` (recursive AND/OR tree of column conditions). After a trigger matches, the engine evaluates the condition tree against the triggering item's column values before dispatching actions. Ten operators: equals, not_equals, contains, not_contains, greater_than, less_than, is_empty, is_not_empty, in_list, not_in_list. Cross-board conditions (`scope: 'linked'`) resolve linked items via `connect_boards` columns. Safety limits: max 3 nesting depth, max 10 conditions, max 3 cross-board refs, 5-second timeout. On evaluation failure, the rule is skipped (fail-closed — the action does NOT fire). The **ConditionBuilder** UI (ConditionBuilder.tsx, ConditionGroupNode.tsx, ConditionRuleRow.tsx) renders a visual AND/OR tree editor inside the AutomationBuilder modal.

**Safety controls:** master toggle (`pmAutomationsEnabled`, default off), event dedup (SQLite `pm_automation_processed_events` with 7-day retention), 5-second per-rule cooldown, condition evaluation with safety limits, stop-on-match, and always-persist run records (even on failure).

A **due-date checker** (`pm-due-date-checker`) runs every 15 minutes, scans date columns for overdue items, and feeds `item_due_date_passed` events into the engine. Ten **pm.\* action types** are defined in `ActionType` (`src/shared/types/automations.ts`): `pm.move-item`, `pm.set-column-value`, `pm.update-linked-status`, `pm.add-comment`, `pm.create-item`, `pm.create-subitem`, `pm.duplicate-item`, `pm.notify`, `pm.spawn-session`, and `pm.if-else` (conditional control flow).

**CLI routes:** `GET/POST/PATCH/DELETE /pm/automations` — all auth-gated and rate-limited; POST/PATCH are Zod-validated via `parseBodyOr400`, while GET and DELETE validate params with manual checks only. **IPC:** 6 channels for CRUD + presets + run history, all Zod-validated with push on mutation. Twenty-five **preset templates** in four categories — **notify** (status-done-notify, assigned-to-me-inbox, done-notify-creator, date-approaching-reminder, column-changed-notify), **organize** (new-item-assign, due-date-overdue-move, bug-triage, review-request-notify, new-item-subtask-checklist, done-duplicate-to-review, moved-in-progress-kickoff), **escalate** (blocked-create-session, stale-issue-alert, overdue-escalation), **automate** (sprint-rollover, date-approaching-ai-prep, new-item-ai-plan, recurring-task, approval-done-move, approval-rejected-notify).

**Per-rule approval:** non-cost-bearing rules default to `approval_status = 'approved'`, while cost-bearing rules (those whose action triggers `isCostBearingAction`) auto-gate to `pending_approval` without an explicit parameter. Pending rules are excluded from `listActiveBoardAutomations()` until approved. A `pm-automation-approval` inbox source surfaces pending rules in the unified inbox (gated on `missionControlEnabled` + `pmAutomationsEnabled`). IPC channels: `pm:board-automation:pending-list` (cross-board pending query) and `pm:board-automation:set-approval` (approve/reject).

**Feature gating:** registered as `'pm-automations'` in `UNRELEASED_FEATURES` with status `'in-development'`. The renderer UI (Phase 5F) lives in `src/plugins/mission-control/web/features/automations/` (AutomationsPage, AutomationBuilder, PresetGallery, TriggerPicker, ActionPicker, RunHistoryPanel, and more).

- **Contract:** [pm-automation-contract.md](../../.claude/memory/contracts/pm-automation-contract.md)
- **Key files:** `src/main/services/pm/automation/pm-automation-service.ts` (engine), `src/main/services/pm/automation/pm-condition-evaluator.ts` (condition tree evaluator), `src/main/services/pm/pm-board-diff.ts` (diff), `src/main/services/pm/date-scanning/pm-due-date-checker.ts` (cron), `src/main/services/pm/automation/pm-automation-queries.ts` (CRUD), `src/main/services/pm/automation/pm-automation-presets.ts` (presets), `src/main/ipc/pm-automation-handlers.ts` (IPC), `src/main/services/cli/cli-server-pm-automation-routes.ts` (CLI)

### PM event channel bridge

Bridges PM board events into the **channel automation engine** (`automations` table), adding a fourth trigger type `on_pm_event` alongside `on_message`, `manual`, and `on_session_state`. This lets users create channel automations like "when a task goes overdue, nudge me in inbox" using the same rules UI as any other automation.

**How it works:** the PM sync service calls `bridgePmEventsToChannelAutomations(events)` fire-and-forget after each sync. The bridge checks both `automationsEnabled` and `pmAutomationsEnabled`, queries `listEnabledPmEventAutomations()` (channel automations with `triggerType: 'on_pm_event'`), matches events against each rule's `PmEventConfig` (event type + board scope + optional board/column filter), enforces a 5-second per-rule cooldown, deduplicates within a batch using `bridge:`-prefixed keys, and dispatches through `dispatchActionChain()` passing the `BoardEvent` as v2 trigger data so actions can read `itemId`/`boardId` from `ctx.trigger`. Errors are caught per-automation and never propagate to the sync pipeline.

**Three v2 actions** for PM-triggered automations:

- `pm.update-linked-status` — updates a column value on the triggering item (reads `itemId` from `ctx.trigger`; skips gracefully if no trigger context)
- `pm.add-comment` — adds a comment/update to the triggering item (same trigger skip behavior)
- `pm.create-item` — creates a new item on a specified board (does NOT require trigger context)

**Four preset templates** in the channel automation presets: Overdue Task Nudge (`pm-overdue-nudge`), Status Change Update (`pm-status-changed-update`), Comment on New Items (`pm-new-item-comment`, v2), Assignment Notification (`pm-assignment-alert`).

**Architecture notes:** the bridge is SEPARATE from the board automation engine (Phase 5C) — board automations use `pm_board_automations` table, channel bridge uses the `automations` table with `pm_event_config` column. The `dispatchActionChain` function accepts an optional `v2TriggerData` parameter that populates `trigger` on the v2 action context.

- **Contract:** [pm-automation-contract.md](../../.claude/memory/contracts/pm-automation-contract.md) (§ PM event channel bridge)
- **Key files:** `src/main/services/pm/pm-event-channel-bridge.ts` (bridge), `src/main/services/automation/v2/dispatcher.ts` (v2TriggerData), `src/main/services/automation/v2/actions/pm-update-linked-status.ts`, `pm-add-comment.ts`, `pm-create-item.ts` (actions), `src/main/services/automation/legacy/automation-presets.ts` (presets), `src/main/db/migrations/20260720203018-add-pm-event-config-to-automations.ts` (migration)

### Workflow Engine Nodes (Phase 5H)

Phase 5H integrates PM boards into the **Workflow Engine** with a poll-based trigger and five action nodes. The trigger watcher polls all synced boards every 60 seconds, computing deltas to fire workflows. The actions proxy writes through to the Mission Control backend via `pmAuthedFetch`.

**Trigger node** (`trigger.pm_item`): configurable event filter (`item_status_changed`, `item_assigned`, `item_created`, `item_overdue`), optional `boardId`/`columnId` scoping. First tick establishes a baseline (no false-fires on start). Dedup by `pm-trigger:boardId:itemId:event:value`. Kill switch: `AMC_DISABLE_PM_WORKFLOW_TRIGGER`.

**Action nodes:**

- `action.pm_create_item` — create an item on a board (title, group, column values via JSON)
- `action.pm_transition` — set a column value (status transition)
- `action.pm_comment` — add an update/comment to an item
- `action.pm_assign` — assign a person to an item (personsAndTeams column)
- `action.pm_search` — search items by name/text (reads from local SQLite mirror)

**Architecture:** nodes are auto-registered via Vite's `import.meta.glob('./nodes/*-node.ts')`. Side effects route through `WorkflowEnginePorts` (never raw DB/fetch from a node). `'pm'` is added to `WORKFLOW_TRIGGER_SOURCES` and renders as "PM Board" in the run-history viewer.

- **Contract:** [pm-workflow-nodes-contract.md](../../.claude/memory/contracts/pm-workflow-nodes-contract.md)
- **Key files:** `src/main/services/workflow-engine/pm-item-trigger.ts` (watcher), `src/main/services/workflow-engine/nodes/pm-*-node.ts` (6 node files), `src/main/services/workflow-engine/ports.ts` (port implementations), `src/main/services/workflow-engine/types.ts` (port interface)

## For agents

### Local SQLite mirror (Phase 5A)

Phase 5A adds a **local SQLite mirror** of Mission Control board data for offline access and as the foundation for automation (Phase 5B), CLI (5C), inbox (5D), and UI (5F). Six tables — `pm_boards` (includes `account_id` for multi-tenancy scoping), `pm_groups`, `pm_items`, `pm_columns`, `pm_column_values`, `pm_sync_state` — cache the data returned by `GET /boards/:id/full`. These tables are a **read cache**, not the authority; the Mission Control backend (Postgres) remains the source of truth. All PM queries that read child tables (items, events, embeddings, inbox events) JOIN through `pm_boards` and filter on `b.account_id = ?` to enforce tenant isolation.

A background **sync service** (`pm-sync-service`) runs on a 5-minute heartbeat, re-fetching any board whose `last_sync_at` is older than 5 minutes. Each sync writes all tables in a **single SQLite transaction** (no partial state) and soft-deletes items no longer present in the response. A WebSocket connection to the Mission Control backend's `/realtime` endpoint listens for `board-changed` events and triggers an immediate re-sync of the affected board, so changes made in the Mission Control UI appear locally within seconds.

The sync service is **gated** on `missionControlEnabled` — if Mission Control is disabled, the service does nothing. If the user is not signed into Omniscio's Firebase auth, `pmAuthedFetch` calls will fail gracefully (no explicit credential check in the sync entry point). Auth is handled by a main-process token manager (`pm-auth`) that obtains Firebase ID tokens via `getFirebaseIdToken()` from global auth, caches them in-process, and retries on 401.

### CLI surface (Phase 5B)

Phase 5B added the initial CLI routes under `/pm/` on the control server (`127.0.0.1:19519`) so any agent session can read and write board data programmatically (subsequent phases expanded the surface to 96+ routes across 21+ files). Read endpoints serve from the local SQLite mirror tables; write endpoints proxy through to the Mission Control backend REST API via `pm-auth`.

**Read routes** (bearer + read-budgeted 60/min): `GET /pm/boards` (list), `GET /pm/boards/:id` (detail with groups + columns), `GET /pm/boards/:id/items` (items with enriched column values and filters for status/assignee/overdue/group/archived/limit), `GET /pm/search?q=` (cross-board item search by name or column text).

**Write routes** (bearer + mutation-limited 10/min, apply-immediately): `POST /pm/boards/:id/items` (create item), `PATCH /pm/items/:id` (update name/group), `PUT /pm/items/:id/columns/:colId` (set cell value), `DELETE /pm/items/:id`, `POST /pm/boards/:id/columns` (add column). All writes are proxied to the Mission Control backend and require a valid Mission Control session.

**Board create writes the local mirror too** (`POST /pm/boards`): the create is proxied to the Mission Control backend, and on success the returned board is ALSO written to the local `pm_boards` mirror in the same request, keyed by the **backend's** id. This is not an optimisation — `GET /pm/boards` (`listBoards`) and every `:id`-scoped board route (`getBoard`) read local SQLite ONLY, so without the write-through a caller got back a board id that resolved nowhere on this machine and every follow-up call answered "Board not found" until a sync cycle ran. Two guards keep it safe: a create the backend accepts but returns no id for is logged and skipped (never a 5xx over a board that genuinely exists remotely), and a `(workspace_id, name)` slot already held by a _different_ live local board is logged and skipped rather than tripping the partial unique index — the existing board is never re-keyed or soft-deleted to make room, and sync reconciles it. The backend-success path also emits `pm:sync-updated`, so the new board appears without waiting for a sync tick, matching what the offline local-fallback path already did. Invariant + locking tests: `backend-create-writes-mirror` in [pm-sync-contract.md](../../.claude/memory/contracts/pm-sync-contract.md).

**Workspace sync route** (bearer + mutation-limited): `POST /pm/workspaces/:id/sync` fetches all boards in a workspace from the Mission Control backend and syncs each one into the local mirror. Continues past individual board failures and returns `{ synced, failed, total }`. This is the entry point for initial workspace-level sync — callers no longer need to know individual board IDs.

All routes are feature-gated on `missionControlEnabled` (404 when off). `cli-server-pm-routes.ts` is a barrel aggregator that calls 32 registration functions — all PM CLI routes are registered through this single barrel via `registerPmCliRoutes()`. Mutation routes use Zod schemas via `parseBodyOr400`; many GET routes pass raw query params without Zod validation. Read queries are in `pm-queries.ts`. The spoke doc for agents is [pm.md](../../.claude/skills/omniscio-control/pm.md).

### How it works

Auth is **unified with Omniscio's Global Auth** (Firebase) — users sign in once via Omniscio and get access to all boards; there is no separate Mission Control login. The plugin sends Firebase ID tokens (obtained from the main process via IPC) as Bearer auth to a **separately-hosted backend** (`amc-back/`, a Bun + Elysia + Postgres + Drizzle service; Omniscio does not bundle it). The backend URL **defaults** to the live Cloud Run target in the plugin (`web/lib/runtime-config.ts` → `getApiUrl()`), but is overridable via the `VITE_API_URL` build env (e.g. a local dev backend) — a fixed deploy target by default, not a user setting. The backend verifies tokens via `admin.auth().verifyIdToken()` (dual-auth: also accepts legacy JWT during migration). Firebase tokens auto-refresh (1hr expiry, handled by the main process).

### Code references

- **Isolated app:** [`src/plugins/mission-control/web/`](../../src/plugins/mission-control/web/) — `amc/AmcMissionControlApp.tsx` (composition root), `lib/api.ts` (authedFetch), `lib/runtime-config.ts`, `lib/session-bridge.ts` (Firebase auth bridge). Framework-free shared code in `src/plugins/mission-control/shared/` (`@mission-control/shared`). Isolated from Omniscio tooling — own `tsconfig.json` (`npm run typecheck:mission-control`), scoped Tailwind (`tailwind.mission-control.config.js` → `npm run build:mission-control-css`), excluded from Omniscio's tsconfig/eslint/prettier.
- **Omniscio glue (normal tooling scope):** [`src/renderer/src/features/mission-control/`](../../src/renderer/src/features/mission-control/) — `MissionControlPanel.tsx` (panel host) + `vendored-mission-control.d.ts` (typecheck boundary: the ambient `@mission-control/web/*` modules) + `mission-control-host-bridge.ts` (the host bridge types, reached through `missionControlHostWindow()`). Vite aliases `@mission-control/web` + `@mission-control/shared` in `electron.vite.renderer-shared.ts`.
- **Sentinel + registry:** `MISSION_CONTROL_PROJECT_ID = '__mission_control__'` in `src/shared/virtual-project-ids.ts`; the `id: 'mission-control'` manifest in `src/shared/integrations/mission-control.ts` (auto-indexed into `integration-registry.generated.ts`) + `src/renderer/src/integrations/ui-registry.ts` (`panelOwnsLayout: true`, lazy `MissionControlPanel`).
- **Settings:** `missionControlEnabled` in `src/shared/types/settings/mission-control-settings.ts` (+ the Zod slice). Auth is via Omniscio's global Firebase auth — no Mission Control-specific credential.
- **IPC:** `mission-control:get-firebase-token` channel (constant in `src/shared/ipc-channels/mission-control-auth.ts`; handler in `src/main/ipc/mission-control-auth-handlers.ts`) provides Firebase ID tokens to the plugin.
- **Backend:** a separate Cloud Run service, maintained outside this repo.

## Related

- [Mission Control](mission-control.md) — the overview page this continues.
- [mc-automations.md](mc-automations.md) — the automation engine on its own page.
- [monday-cloud.md](monday-cloud.md) — the integration that connects a hosted board service instead.

