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).