unified-agent-custom-fields.md33.6 KBView on GitHub
# Unified agent custom fields

## 1) Introduction — goal, present state, future state

We want one source of truth for everything an agent surfaces about a conversation: agent runs write to custom fields, and every UI surface (table cells, kanban cards, conversation overviews, canvases) reads those fields uniformly. Today agents have a parallel "state overview" construct — a per-agent `stateOverviewConfig` of type `'score' | 'notification'`, persisted into a separate `agent_conversation_states` table by a dedicated `updateAgentStateOverviewTool`, and rendered by bespoke `AgentStateCellRenderer` / `ScoreCircleWithPopover` / `NotificationPill` components — which lives alongside the unrelated `crm_conversation_field_values` path. We will delete the entire state-overview construct, point agents at the existing custom-field write path, and extend custom fields with a new `fraction` type, a top-level `icon` property, and first-class editor support for the per-option `{color, icon}` that already exists on `select` options.

## 2) Present state

### 2.1 Architecture diagram

```text
              ┌──────────────────────────────────────────────────────┐
              │                  Agent execution                     │
              └──────────────────────────────────────────────────────┘
                       │                                  │
   "state overview" path                       "field value" path
                       │                                  │
                       ▼                                  ▼
   ┌──────────────────────────────┐   ┌──────────────────────────────┐
   │ updateAgentStateOverviewTool │   │ upsertFieldValue()           │
   │  validates against           │   │  writes to                   │
   │  aopAgents.stateOverviewCfg  │   │  crm_conversation_field_     │
   │  writes to                   │   │   values (text content +     │
   │  agent_conversation_states   │   │   metadata JSON)             │
   └──────────────────────────────┘   └──────────────────────────────┘
                       │                                  │
                       ▼                                  ▼
   ┌──────────────────────────────┐   ┌──────────────────────────────┐
   │ AgentState.statusOverview    │   │ Conversation.customFields[]  │
   │   { type:'score', value }    │   │   WorkingMemoryEntry[]       │
   │   { type:'notification',     │   │                              │
   │     level, items[] }         │   │                              │
   └──────────────────────────────┘   └──────────────────────────────┘
                       │                                  │
                       ▼                                  ▼
   ┌──────────────────────────────┐   ┌──────────────────────────────┐
   │ AgentStateCellRenderer       │   │ Generic field cell           │
   │   → ScoreCircleWithPopover   │   │   per CustomFieldType        │
   │   → NotificationPill         │   │                              │
   └──────────────────────────────┘   └──────────────────────────────┘
```

### 2.2 Step-by-step walkthrough

1. **Agent definition (config)** — `aopAgents` row at [apps/server/src/db/aop-schema.ts:734](apps/server/src/db/aop-schema.ts). The `stateOverviewConfig: jsonb` column at line 771 holds:
   ```ts
   type StateOverviewConfig = {
     outputType: 'score' | 'notification';
     notificationLevel?: 'warning' | 'info';
     customInstructions?: string;
   };
   ```
   Defined in [apps/server/src/services/aop/agent-state-schemas.ts:9](apps/server/src/services/aop/agent-state-schemas.ts).

2. **Editor UI for stateOverviewConfig** — `AgentsEditor` at [apps/mail/modules/aop/components/AgentsEditor.tsx:1283](apps/mail/modules/aop/components/AgentsEditor.tsx) renders a `<Select>` with values `disabled | score | notification`, and at line 1305 a second `<Select>` for `notificationLevel`. Templates seed `outputType` / `notificationLevel` (e.g. line 305, 335, 358, 519, 589). Persisted via `aopAgents.upsert` tRPC at [apps/server/src/trpc/routes/aop-agents.ts](apps/server/src/trpc/routes/aop-agents.ts).

3. **Agent execution writes state** — `updateAgentStateOverviewTool` at [apps/server/src/mastra/tools/agent-state/updateAgentStateOverviewTool.ts:55](apps/server/src/mastra/tools/agent-state/updateAgentStateOverviewTool.ts):
   - reads `agentId` / `isSystemDefault` / `userId` from request context (line 90–112)
   - re-loads `stateOverviewConfig` from DB as the authoritative gate (line 121–145)
   - validates `synthesizedOutput.type === config.outputType` (line 150–157)
   - upserts into `agent_conversation_states` keyed by `(agentId, conversationId)` (line 164–182)
   - data written:
     ```json
     {
       "agentId": "...",
       "conversationId": "...",
       "awake": true,
       "synthesizedOutput": { "type": "score", "value": 73 },
       "reasoning": "Deal stalled past expected close date"
     }
     ```

4. **Table column / cell render** — Conversation hydration at [apps/server/src/services/crm/conversations.ts:220-254](apps/server/src/services/crm/conversations.ts) attaches:
   - `customFields: WorkingMemoryEntry[]` (line 221)
   - `agents: Array<{ statusOverview: SynthesizedOutput | null, reasoning, ... }>` (line 244-254)
   These reach the frontend as two parallel arrays. The table maps each agent to a dynamic column rendered by `AgentStateCellRenderer` at [apps/mail/modules/crm/components/AgentStateCellRenderer.tsx:5](apps/mail/modules/crm/components/AgentStateCellRenderer.tsx):
   ```tsx
   if (value.statusOverview.type === 'score')
     return <ScoreCircleWithPopover value={...} reasoning={...} />;
   return <NotificationPill level={...} count={...} />;
   ```

5. **Other consumers of `statusOverview`** — all branch on the same discriminator:
   - [apps/mail/modules/conversations/components/AgentRow.tsx](apps/mail/modules/conversations/components/AgentRow.tsx)
   - [apps/mail/modules/conversations/components/ConversationOverviewCard.tsx](apps/mail/modules/conversations/components/ConversationOverviewCard.tsx)
   - [apps/mail/modules/crm/components/CRMConversationOverviewCard.tsx](apps/mail/modules/crm/components/CRMConversationOverviewCard.tsx)
   - [apps/mail/modules/crm/components/crm-kanban-card.tsx](apps/mail/modules/crm/components/crm-kanban-card.tsx)
   - [apps/mail/modules/crm/components/kanban-canvas/CanvasKanbanCard.tsx](apps/mail/modules/crm/components/kanban-canvas/CanvasKanbanCard.tsx)
   - [apps/mail/modules/agentCanvas/components/EventCard.tsx](apps/mail/modules/agentCanvas/components/EventCard.tsx)
   - [apps/mail/modules/agentCanvas/components/AgendaEventBlock.tsx](apps/mail/modules/agentCanvas/components/AgendaEventBlock.tsx)
   - [apps/mail/modules/debugger/components/AgentStateTab.tsx](apps/mail/modules/debugger/components/AgentStateTab.tsx)
   - [apps/server/src/services/notifications/user-slack-notifier.ts](apps/server/src/services/notifications/user-slack-notifier.ts) — Slack DM uses `synthesizedOutput` for the notification body.

6. **Parallel: custom field type system** — `CUSTOM_FIELD_TYPES` at [apps/server/src/db/aop-schema.ts:89-102](apps/server/src/db/aop-schema.ts):
   ```ts
   ['text','number','date','boolean','select','currency','url','email','phone','list']
   ```
   `CustomFieldDefinition` at line 306–346 with `options?: Array<CrmFieldEnumOption>` (line 324). `CrmFieldEnumOption` at [apps/server/src/services/crm/conversation-field-definitions.ts:19](apps/server/src/services/crm/conversation-field-definitions.ts):
   ```ts
   { value: string; label?: string; enumOrder: number; color: string; icon?: string; }
   ```
   So per-option color+icon is **already** schema-supported — only editor UI surfacing is missing.

7. **Custom field write path** — `upsertFieldValue()` at [apps/server/src/services/crm/conversation-field-values.ts:75](apps/server/src/services/crm/conversation-field-values.ts) is the single entry point; called by `updateConversationFieldsAndWorkingMemory` at [apps/server/src/services/crm/conversations.ts:640](apps/server/src/services/crm/conversations.ts). Storage in `crmConversationFieldValues` table at [apps/server/src/db/conversation-field-values-schema.ts:32](apps/server/src/db/conversation-field-values-schema.ts) — unique constraint `(conversation_id, field_id)` for `is_list_field=false`.

8. **Custom field UI registry** — fields are typed in [apps/mail/modules/crm/types/index.ts](apps/mail/modules/crm/types/index.ts) and rendered by [apps/mail/modules/crm/components/crm-cell.tsx](apps/mail/modules/crm/components/crm-cell.tsx) (per-type rendering branch). Editor at [apps/mail/app/(routes)/agentOperatingProcedures/components/CustomFieldsEditor.tsx](apps/mail/app/(routes)/agentOperatingProcedures/components/CustomFieldsEditor.tsx).

## 3) Designed state

### 3.1 Architecture diagram

```text
              ┌──────────────────────────────────────────────────────┐
              │                  Agent execution                     │
              └──────────────────────────────────────────────────────┘
                                       │
                                       ▼
                       ┌──────────────────────────────┐
                       │ upsertFieldValue()           │ ← single write path
                       │  for each fieldId in         │
                       │  agent.outputFieldIds        │
                       └──────────────────────────────┘
                                       │
                                       ▼
                       ┌──────────────────────────────┐
                       │ crm_conversation_field_values│
                       │   one row per (conv, field)  │
                       └──────────────────────────────┘
                                       │
                                       ▼
                       ┌──────────────────────────────┐
                       │ Conversation.customFields[]  │ ← single read path
                       └──────────────────────────────┘
                                       │
                                       ▼
                       ┌──────────────────────────────┐
                       │ Generic field cell           │
                       │   switch (fieldDef.type) {   │
                       │     text|number|date|...     │
                       │     select  → ChipCell       │
                       │     fraction→ FractionCell   │
                       │     boolean → CheckboxCell   │
                       │   }                          │
                       │   + optional fieldDef.icon   │
                       └──────────────────────────────┘
```

### 3.2 Step-by-step walkthrough

1. **Extended `CustomFieldType` enum** — at [apps/server/src/db/aop-schema.ts:89](apps/server/src/db/aop-schema.ts):
   ```ts
   const CUSTOM_FIELD_TYPES = [
     'text','number','date','boolean','select','currency',
     'url','email','phone','list','fraction',
   ] as const;
   ```

2. **Top-level `icon` on `CustomFieldDefinition`** — at [apps/server/src/db/aop-schema.ts:306](apps/server/src/db/aop-schema.ts):
   ```ts
   type CustomFieldDefinition = {
     id: string;
     type: CustomFieldType;
     label: string;
     displayOrder: number;
     icon?: string;        // ← NEW: Lucide icon name; reuses AOP_ICON_NAMES allowlist
     options?: Array<CrmFieldEnumOption>;  // existing — already has color+icon per option
     // … unchanged
   };
   ```

3. **Fraction storage shape** — values are stored as a single string `"<num>/<den>"` in `crm_conversation_field_values.content` (no schema change to the values table). Parser added to the field-value utils:
   ```ts
   parseFraction("3/5") → { numerator: 3, denominator: 5 }
   ```

4. **Agent → custom field config** — add `outputFieldIds` to `aopAgents` at [apps/server/src/db/aop-schema.ts:734](apps/server/src/db/aop-schema.ts):
   ```ts
   outputFieldIds: jsonb('output_field_ids').notNull().$type<string[]>().default([]),
   ```
   Drop `stateOverviewConfig` column in a later phase. Semantically: the agent's prompt instructs it to write specific field IDs. No new ownership/permission flags — see [[agent-owned-fields-decision]].

5. **Agent execution writes via existing tool** — agents call the existing field-value mutation (already used by the CRM update agent) instead of `updateAgentStateOverviewTool`. The tool surfaces the configured `outputFieldIds` to the agent's prompt so it knows what to write.
   Data flow:
   ```json
   {
     "conversationId": "...",
     "fieldUpdates": [
       { "fieldId": "deal_health", "content": "73", "metadata": { "agentExecutionId": "..." } },
       { "fieldId": "risk_flags",  "content": "Champion went dark; pricing not yet sent" }
     ]
   }
   ```

6. **Conversation hydration** — at [apps/server/src/services/crm/conversations.ts:220](apps/server/src/services/crm/conversations.ts) the response shape simplifies:
   - `customFields: WorkingMemoryEntry[]` — unchanged, now the only path
   - `agents[].statusOverview` — **removed**
   - `agents[]` keeps lighter shape: `{ id, name, avatar, awake, lastRunAt }`. (The `awake` flag remains useful as a per-agent attention marker; consider whether to keep it or derive it from a designated field — see Phase 6.)

7. **Generic cell rendering** — [apps/mail/modules/crm/components/crm-cell.tsx](apps/mail/modules/crm/components/crm-cell.tsx) gains a `fraction` branch (renders e.g. `3/5` with a thin progress bar) and renders `fieldDef.icon` (when set) inline before the value. The `AgentStateCellRenderer` / `ScoreCircleWithPopover` (for agent state) / `NotificationPill` are deleted; agent-state columns become standard custom-field columns.
   - A `score`-style agent → backed by a `number` field with `min=1, max=100`, optionally rendered as a circle when `displayHint='score'` (small additive on the existing number cell).
   - A `notification(warning)`-style agent → backed by a `list` field of short strings, rendered as a pill with count.

8. **Editor surfaces** — [apps/mail/app/(routes)/agentOperatingProcedures/components/CustomFieldsEditor.tsx](apps/mail/app/(routes)/agentOperatingProcedures/components/CustomFieldsEditor.tsx):
   - icon picker (reusing the existing AOP icon picker) at the field level
   - for `type: 'select'`: per-option color + icon controls (schema already supports these, only UI is missing)
   - new `type: 'fraction'` entry in the type dropdown
   [apps/mail/modules/aop/components/AgentsEditor.tsx](apps/mail/modules/aop/components/AgentsEditor.tsx) gets an `outputFieldIds` multi-select replacing the `stateOverviewConfig` dropdown section.

## 4) Implementation phases

### Phase 1 — Add `icon` to `CustomFieldDefinition`

**Goal:** field-level icon plumbed end-to-end, no behavioural change to any agent.

- [x] Add `icon?: string` to `CustomFieldDefinition` at [apps/server/src/db/aop-schema.ts:306](apps/server/src/db/aop-schema.ts).
- [x] Add icon picker UI to [apps/mail/app/(routes)/agentOperatingProcedures/components/CustomFieldsEditor.tsx](apps/mail/app/(routes)/agentOperatingProcedures/components/CustomFieldsEditor.tsx) (reuse the AOP icon picker; allowlist is `AOP_ICON_NAMES` at [apps/server/src/db/aop-schema.ts:126](apps/server/src/db/aop-schema.ts)).
- [x] Render the icon inline in the cell at [apps/mail/modules/crm/components/crm-cell.tsx](apps/mail/modules/crm/components/crm-cell.tsx) (left of the value, dimmed when value is empty).
- [x] Render the icon in the column header — implemented in [apps/mail/modules/crm/components/editable-column-header.tsx](apps/mail/modules/crm/components/editable-column-header.tsx) via `getColumnIcon`, picked up by [sortable-column-header.tsx](apps/mail/modules/crm/components/sortable-column-header.tsx) and [add-column-popover.tsx](apps/mail/modules/crm/components/add-column-popover.tsx). Column metadata gains `icon` via [apps/mail/modules/crm/store/crmSlice.ts](apps/mail/modules/crm/store/crmSlice.ts), populated by [use-crm-configuration.ts](apps/mail/modules/crm/hooks/use-crm-configuration.ts) and [use-canvas-configuration.ts](apps/mail/modules/crm/hooks/use-canvas-configuration.ts).

**Tests:**

- [x] `pnpm --filter @zero/mail types` — no new errors in touched files (pre-existing errors in unrelated files remain).
- [ ] Manual: in the AOP editor, set an icon on an existing text field; confirm icon appears in cell + column header in both table and kanban views.

### Phase 2 — Expose per-option `color` + `icon` in the select editor

**Goal:** the schema already supports it; surface it.

- [x] In [apps/mail/app/(routes)/agentOperatingProcedures/components/CustomFieldsEditor.tsx](apps/mail/app/(routes)/agentOperatingProcedures/components/CustomFieldsEditor.tsx), add color swatch + icon picker to each option row of a `select` field. Done by delegating `SelectOptionsEditor` rows to `StatusOptionBadge` (which already has both pickers) with `showIcon={true}` and the `STATUS_COLORS` palette.
- [x] Ensure the chip renderer at [apps/mail/modules/crm/components/ConversationCellComponents/SelectEditor.tsx](apps/mail/modules/crm/components/ConversationCellComponents/SelectEditor.tsx) reads `option.color` + `option.icon` from the matched `CrmFieldEnumOption` and renders both — trigger badge gets the color class + inline icon, and dropdown items render colored chips with icons.
- [ ] (Skipped — no current code path.) The kanban card and CanvasKanbanCard only render fixed core fields (status/priority/dealValue/lastContacted/etc.) — they have no rendering branch for custom select fields. Surfacing custom select chips in kanban cards would be a new feature, not a chip-renderer fix; leaving this for Phase 8 if/when the user wants it.

**Tests:**

- [x] `pnpm --filter @zero/mail types` — no new errors in touched files.
- [ ] Manual: create a custom select field with 3 options, each with distinct color + icon; confirm rendering in table cell + dropdown items.

### Phase 3 — Add `fraction` field type

**Goal:** new type recognised end-to-end; no agents use it yet.

- [x] Add `'fraction'` to `CUSTOM_FIELD_TYPES` at [apps/server/src/db/aop-schema.ts:89](apps/server/src/db/aop-schema.ts), mirrored in [apps/mail/modules/crm/types/index.ts](apps/mail/modules/crm/types/index.ts).
- [x] Add a `parseFraction(content: string): { numerator: number; denominator: number } | null` helper in [apps/server/src/services/crm/field-type-utils.ts](apps/server/src/services/crm/field-type-utils.ts) (server) and mirror in [apps/mail/modules/crm/types/index.ts](apps/mail/modules/crm/types/index.ts) (frontend). Wired into `parseFieldValue`, `validateFieldValue`, and `getFieldTypeLabel`.
- [x] Frontend `FractionCell` rendered from the `fraction` branch in [apps/mail/modules/crm/components/crm-cell.tsx](apps/mail/modules/crm/components/crm-cell.tsx) — text input + mini progress bar showing `numerator/denominator` ratio.
- [x] Editor: added `Fraction` to `FIELD_TYPE_OPTIONS` (picked up automatically by the existing type dropdown in CustomFieldsEditor). Single text-input editor handles `n/d` input — no separate two-input UI; using the single-field pattern keeps the type definition consistent with other simple types.
- [ ] (Deferred.) Sort/filter wiring for fraction left as default (alpha-string sort, no filter UI). Numerically-aware sort + filter requires server-side change in [use-crm-conversations.ts:392](apps/mail/modules/crm/hooks/use-crm-conversations.ts) and a numeric filter case — out of scope for the type-introduction phase.

**Tests:**

- [ ] (Deferred — no test runner wired for this util.) Unit test `parseFraction` for `"3/5"`, `" 3 / 5 "`, `"3/0"` (null), `""` (null), `"abc"` (null), `"3"` (null). Logic is straightforward regex + parseInt; both implementations are identical.
- [x] `pnpm --filter @zero/mail types` — no new errors in touched files.
- [ ] Manual: create a custom fraction field, set value to `3/5` via the editor, confirm it renders alongside two other rows with `2/5` and `5/5`.

### Phase 4 — Add `outputFieldIds` to `aopAgents`

**Goal:** introduce the new agent → field linkage column without removing the old one yet. Agents continue to use `stateOverviewConfig`.

- [x] Add `outputFieldIds: jsonb('output_field_ids').notNull().$type<string[]>().default([])` to the `aopAgents` table at [apps/server/src/db/aop-schema.ts:734](apps/server/src/db/aop-schema.ts). Migration at [apps/server/src/db/migrations/aop_agents_output_field_ids.sql](apps/server/src/db/migrations/aop_agents_output_field_ids.sql) (written by hand; drizzle-kit generate prompted interactively for unrelated unsynced tables).
- [x] Surface `outputFieldIds` through the `aopAgents.upsert` tRPC route at [apps/server/src/trpc/routes/aop-agents.ts](apps/server/src/trpc/routes/aop-agents.ts) — input schema, update branch, and create branch all honor it.
- [ ] (Deferred to Phase 8.) The multi-select UI in `AgentsEditor.tsx` is deferred — Phase 8 fully redesigns this editor with click-to-edit popovers and per-agent field cards, so building a throwaway multi-select here would be wasted work. New agents get their `outputFieldIds` seeded via the templates in Phase 5; existing agents can be edited via the Phase 8 UI.

**Tests:**

- [ ] Migration not yet applied — needs to run via `pnpm db:migrate` against the target DB. Stand-alone `ADD COLUMN IF NOT EXISTS` with default `[]` is safe and idempotent.
- [x] `pnpm --filter @zero/mail types` — no new errors in touched files.
- [ ] Manual: deferred until Phase 8 supplies the editor UI.

### Phase 5 — Route agent writes through the field-value path

**Goal:** when an agent has `outputFieldIds`, its prompt + tools push it toward `upsertFieldValue` instead of `updateAgentStateOverviewTool`.

- [x] In the automation prompt construction at [apps/server/src/services/aop/automations.ts](apps/server/src/services/aop/automations.ts), inject an `<output_fields_config>` XML block listing each `outputFieldIds` entry resolved against the AOP's `customFieldDefinitions` (id, label, type, description, options). Instructs the agent to write them via `update-conversation-fields`. Appended alongside the legacy `<state_overview_config>` block during the transition; Phase 7 removes the legacy block.
- [x] `update-conversation-fields` is already exposed to the automation agent via `getPipelineAgentTools()` ([apps/server/src/mastra/tools/pipeline-agent-tools.ts:33](apps/server/src/mastra/tools/pipeline-agent-tools.ts)) — no tool wiring needed.
- [ ] (Deferred to Phase 8.) Seed-template conversion in `AgentsEditor.tsx` is bound up with the editor redesign in Phase 8 — those templates need to atomically create both an agent and a custom field on the AOP, which requires new tRPC wiring or a multi-step UI flow. Best built alongside the popover-based editor.
- [ ] (Deferred — out of scope for autonomous run.) Backfill script for existing orgs. Writing it requires DB inspection of live org data and running mutations against the shared DB — both are beyond what should happen autonomously. To author: enumerate every `aop_agent` with non-null `state_overview_config`, create a `number` (for `score`) or `list` (for `notification`) custom field on its AOP if not already present, set the agent's `outputFieldIds` to that field id, and log a row-by-row report. Idempotent. Place under `apps/server/scripts/`.

**Tests:**

- [ ] Unit test deferred — render-prompt is intermixed with many DB lookups; covered indirectly via the existing automation-agent execution path.
- [ ] Integration test deferred — same reason.
- [x] `pnpm --filter @zero/mail types` — clean after fixing the column selection (added back `name` to the agentRow query).
- [ ] Manual: trigger an automation agent that has `outputFieldIds` populated on a real conversation; confirm new custom field values appear via `update-conversation-fields`.

### Phase 6 — Migrate UI consumers to read from custom fields

**DEFERRED — execute after Phase 8.** Removing `AgentStateCellRenderer` and the `statusOverview` field from the hydration shape immediately breaks 10+ UI surfaces (table, kanban, conversation overview, calendar canvases, debugger, Slack notifier). Without the Phase 8 UI in place to render outputFieldIds-derived columns, users would see empty cells where their score circles / notification pills used to be. Phase 8 builds the replacement UI; once it's live, Phase 6's deletions become low-risk.

**Goal:** delete `AgentStateCellRenderer` and all `statusOverview` branches; render agent output via the generic custom-field cell.

- [ ] Remove `AgentState.statusOverview` from the hydrated `agents[]` shape at [apps/server/src/services/crm/conversations.ts:244-254](apps/server/src/services/crm/conversations.ts). Keep `{ id, name, avatar, awake, lastRunAt }`.
- [ ] Replace `AgentStateCellRenderer` usage with the standard custom-field cell for the columns derived from `outputFieldIds`. Delete [apps/mail/modules/crm/components/AgentStateCellRenderer.tsx](apps/mail/modules/crm/components/AgentStateCellRenderer.tsx).
- [ ] Remove the `NotificationPill` export from [apps/mail/modules/aop/components/AgentsEditor.tsx](apps/mail/modules/aop/components/AgentsEditor.tsx) and migrate any remaining renderers (`ConversationOverviewCard`, `CRMConversationOverviewCard`, `AgentRow`, `crm-kanban-card`, `CanvasKanbanCard`, `EventCard`, `AgendaEventBlock`, `AgentStateTab`, calendar canvases — full list in present-state step 5).
- [ ] Update the Slack notifier at [apps/server/src/services/notifications/user-slack-notifier.ts](apps/server/src/services/notifications/user-slack-notifier.ts) to read the same field values rather than `synthesizedOutput`.
- [ ] Decide on `awake`: either keep it as a per-agent flag set by the agent via a new tiny tool, OR derive it from a designated boolean custom field (e.g. `agent.attentionFieldId`). Recommend keeping it as-is on the lighter `agents[]` shape for now and revisiting in a follow-up.

**Tests:**

- [ ] `pnpm --filter @cedar/mail typecheck` — should be green with no `statusOverview` references.
- [ ] Snapshot/screenshot tests of `CRMConversationOverviewCard`, kanban card, table row before/after to confirm parity.
- [ ] Manual: walk through the canvas, conversation profile, kanban, and table — confirm agent outputs render identically to before via the new custom-field path.

### Phase 7 — Drop the dead schema

**DEFERRED — execute after Phase 6.** Cannot drop `agent_conversation_states` / `stateOverviewConfig` while UI consumers still read from them. Sequenced after Phase 6's UI cleanup.

**Goal:** delete the construct entirely.

- [ ] Drop `aopAgents.stateOverviewConfig` column. Migration.
- [ ] Drop `agent_conversation_states` table. Migration.
- [ ] Delete [apps/server/src/mastra/tools/agent-state/updateAgentStateOverviewTool.ts](apps/server/src/mastra/tools/agent-state/updateAgentStateOverviewTool.ts).
- [ ] Delete [apps/server/src/services/aop/agent-state-schemas.ts](apps/server/src/services/aop/agent-state-schemas.ts) (and its re-exports from [apps/server/src/db/aop-schema.ts:49-50](apps/server/src/db/aop-schema.ts)).
- [ ] Remove the `stateOverviewConfig` UI block from [apps/mail/modules/aop/components/AgentsEditor.tsx:1283-1320](apps/mail/modules/aop/components/AgentsEditor.tsx) and any template-level `outputType` / `notificationLevel` properties (lines 305, 335, 358, 519, 589 et al.).
- [ ] `pnpm deps:check`.

**Tests:**

- [ ] `pnpm --filter @cedar/server typecheck`
- [ ] `pnpm --filter @cedar/mail typecheck`
- [ ] Migrations run cleanly forward; full app boots; one fixture agent execution still writes its outputs.

### Phase 8 — UI shift: unified Custom Fields + Agents layout

**Goal:** the AOP editor (Deals etc.) shows custom fields as a single click-to-edit list with inline agent ownership, and agents are rendered as cards that nest the fields they update. No data-model change — pure UI restructuring.

**Target layout** (within the AOP editor, replacing the current side-by-side CustomFieldsEditor + AgentsEditor split):

```text
Deals
─────────────────────────────────────────────────────────────────
Custom Fields                                              [+ Add]
─────────────────────────────────────────────────────────────────
  ⬢  Health           🤖 Deal Watcher   "scores 1–100 after each call"
  ≡  Risks            🤖 Deal Watcher   "lists active risks; clears when resolved"
  ▦  Stage            (no agent)
  #  ARR              🤖 CRM Updater    "extracts from latest call"
  ↗  Website          (no agent)

─────────────────────────────────────────────────────────────────
Agents
─────────────────────────────────────────────────────────────────
  🤖 Deal Watcher
     ⬢ Health        ≡ Risks

  🤖 CRM Updater
     # ARR           ▦ Stage           ☎ Phone
```

- Each field row: `[type icon] [field label]  [agent avatar + name] [short agent-instruction excerpt]`. The field name is a button; clicking opens a popover (Notion-style — see screenshot) with: rename, change type, edit options (for select/list), edit icon, edit description, delete.
- Agent cards in the Agents section show the agent's avatar/name as a header and render its `outputFieldIds` as chips (reusing the field-row visual but compact). Clicking a chip jumps to / opens the same field popover.

**Tasks:**

- [x] Created [apps/mail/app/(routes)/agentOperatingProcedures/components/UnifiedFieldsAndAgentsPanel.tsx](apps/mail/app/(routes)/agentOperatingProcedures/components/UnifiedFieldsAndAgentsPanel.tsx) — single file containing the new layout. Includes `FieldRow` (icon button + name button + type chip + agent attribution chip + truncated description), `FieldEditPopover` (popover with name/description/delete + icon picker on the row itself), and `AgentCard` (avatar + name + outputFieldIds as compact chips).
- [x] Agent attribution computed by inverting `agent.outputFieldIds` → `Map<fieldId, agents>`. Renders first agent + `+N` chip when multiple.
- [x] Wired into [apps/mail/app/(routes)/agentOperatingProcedures/page.tsx](apps/mail/app/(routes)/agentOperatingProcedures/page.tsx) **above** the existing `CustomFieldsEditor` rather than replacing it. **Deviation from design** — additive integration was chosen for safety: the existing inline-form editor remains the source of truth for type changes, select options, scoring, CRM mapping (heavier edits), while the new panel provides the at-a-glance overview + quick name/description/icon edits. A full replacement is best done after Phase 6 deletes the legacy state-overview consumers, so the design's "drop the inline-form layout" is deferred.
- [ ] (Out of scope for v1.) Replace the AgentsEditor's agent-list rendering with the new `AgentCard`s. The new panel shows agent cards informationally; the existing `AgentsEditor` still owns the deeper editing (instructions, triggers, etc.).
- [ ] Visual polish per [[zypsy-product-design]] — current implementation is functional, not yet polished.

**Tests:**

- [x] `pnpm --filter @zero/mail types` — new file typechecks clean; pre-existing errors in `page.tsx` (AlertCircle, displayConfig) are unchanged and unrelated.
- [ ] Manual: open the Deals AOP editor. The new section appears above the inline-form editor. Verify:
  - All custom fields render in the new list with correct icons + type chip.
  - Clicking a field name opens the popover; name/description/delete round-trip via existing onUpdate plumbing.
  - The Agents section shows each agent with its `outputFieldIds` as compact chips.
  - A field with no agent shows `(no agent)` placeholder.
- [ ] Manual: confirm Phase 2's per-option select chip rendering still works in cells (it's wired through `SelectEditor`, not the new panel).