# Appointment Engine · AI Agent (workflow builder)

## What it does

The AI Agent page (`/manage/appointment-engine/ai-agent`) is where an agency builds the
automations the Appointment Engine runs: each **workflow** is a graph of steps (trigger →
actions/conditions → ends) drawn on a canvas, seeded from a **preset template** or built blank,
and executed per lead by the runner. This doc covers the builder, the preset library, the
**Google Sheet trigger** (2026-09-01), and the 2026-09-02 build: the **appointment channel**
(zoom/showroom + auto Zoom meetings), the **per-attempt retry ladder**, the **WhatsApp AI
Brain bridge** (`action.whatsapp_ai`), the **CTWA trigger** (`trigger.ctwa`), the booking-race
hardening, and the portal IA rename (sidebar stage 2 = "AI conversations", stage 3 =
"Appointments"; AI Profiles moved under AI Agent; Showroom pages now live at `/appointments*`
with the old GETs redirecting).

## How it works

- **Pages.** `ai-agent` → `Workflows/Index.vue` (card list + "New workflow" modal whose preset
  chooser comes from `WorkflowTemplates::options()`); a card opens
  `ai-agent/workflows/{uuid}` → `Workflows/Builder.vue` (hand-rolled SVG canvas — palette,
  drag, wire, and a right-hand step drawer).
- **One registry drives everything.** `NodeCatalogue` defines each node type's group, palette
  placement, presentation and **typed field schema**; the drawer renders from it, the server
  validates from it (`WorkflowsController::rulesForField`), the runner reads config through it
  (`WorkflowNode::config()` falls back to schema defaults). Adding a node type = a catalogue
  entry + a `HandlerRegistry` mapping (`HandlerRegistry::missing()` must stay empty).
- **Templates are seed data, copied once.** `WorkflowTemplates::apply()` writes real
  `ae_workflow_nodes` / `ae_workflow_edges` in a transaction; config merges over
  `NodeCatalogue::defaults()`, so a template only states what it overrides.
- **Publish gate, not per-edit blocking.** A half-built graph always saves;
  `Workflow::problems()` blocks *going live* and names every fault in plain words.
- **Runner.** `ae:run-workflows` (every minute) → `Triggers::sweep()` starts runs from the
  inbound sources, then advances due `ae_workflow_runs` via `AdvanceWorkflowRun` →
  `WorkflowRunner` → the type's handler → a `StepResult` (next / wait / park / end / fail).
- **Edges.** One output = one connection (`unique(from_node_id, branch)`). `storeEdge`
  validates the branch against the **source node's own `outputs()`** (a fixed yes/no list here
  once made the call-outcome step's five branches undrawable — fixed 2026-09-01). Loops are
  refused unless they re-enter `action.ai_call`, whose attempts cap bounds them.

### The Google Sheet trigger (`trigger.google_sheet`)

Rows added to a Google Sheet become `ae_leads` (source `sheet`) and start the workflow.
The first row must be **headings**, one of them `phone` (name / email / project are picked up
when present — the same heading-matched mapping as the CSV import, shared in `LeadRowMapper`).
A row's own `project` column beats the workflow's project.

- **Two access modes**, chosen per step:
  - `link` — the sheet is set to "anyone with the link can view"; we poll its CSV export.
    No credentials at all. The tab comes from the URL's `#gid`.
  - `service_account` — a private sheet, read through the Sheets API. The agency pastes a
    Google service-account JSON key **once** on Settings → Connections (provider
    `google_sheets`, per-`group_id` like every `ae_connections` row — whitelabel grain), and
    shares each sheet with the service-account email the connection card shows after
    verifying. Verification mints a real token (`ConnectionVerifier::sheets()`), so
    "Connected" means the key cryptographically works. Token minting reuses the already
    installed `google/auth` package — there is no `google/apiclient` dependency.
- **Polling.** `Triggers::sheets()` runs inside the minute sweep but each node is throttled by
  its own `poll_minutes` (floor **1** — `Plan.php:220`, `Triggers.php:294`, `NodeCatalogue.php:245`; it was 5 before the owner asked for minute polling on 2026-09-07). Readers: `SharedLinkSheetReader` / `ServiceAccountSheetReader`
  behind the `SheetReader` interface, both translating failures into user-facing sentences
  (`SheetReadException`): not a Sheets URL · not shared (link mode detects Google's login HTML;
  SA mode names the service-account email) · Sheets API disabled · sheet deleted · no such tab ·
  no `phone` heading.
- **Cursor** (`ae_sheet_cursors`, one row per trigger node — runtime state must NOT live in node
  `config`, which the drawer rebuilds from schema keys on every save): `fingerprint` =
  sha1(sheet id | tab | mode), so changing the URL/tab/mode resets the cursor; `rows_seen`
  counts processed data rows; `baseline_rows` is the connect-time size of the sheet. With
  `on_first_sync = new_only` (default) every existing row is **imported into the lead book but
  never enrolled** — only rows at an index ≥ `baseline_rows` get a run, so the AI rings only
  leads that arrived AFTER the sheet was connected (the boundary survives multi-poll baselining
  of a huge sheet, which a plain `rows_seen` could not carry); `enrol_all` is the explicit
  opt-in to also call the backlog. Connecting is one button on the project page's **Leads tab**
  (`PUT projects/{id}/sheet` — creates the trigger node wired into the same first step the
  existing source feeds, then runs the first sync in-request via `Triggers::syncNode()`).
  Deletions clamp the cursor; re-seen rows are absorbed by the dedupe. **Edited rows above the
  cursor are never re-read**, and the sheet is **never written back to** — known limitations.
- **Dedupe.** Blank/duplicate phones in a batch are skipped; a `(lead, workflow)` pair is
  enrolled at most once EVER (`enrolOnce()`, any run status counts) because a pull source
  re-sees shifted rows.
- **Pacing — the money guard.** Two layers, per the
  [voice-agent handbook's money guards](/docs/modules_handbook/shared/voice-agent/readMe.md):
  the nth enrolment of a poll starts `intdiv(n, pace_per_minute)` minutes later (a PENDING run
  with a future `resume_at` is *scheduled* — `RunWorkflows` skips it, `WorkflowRunner` double-
  checks), and `AiCall` holds any dial over `appointment_engine.voice.dials_per_minute`
  (env `AE_DIALS_PER_MINUTE`, default 3/agency/minute) for a minute — quiet-hours waiters all
  resume at `call_start` sharp, and simultaneous rings are the pattern reputation systems flag.
- **Where errors surface.** Every failed read writes the sentence to the cursor's `last_error`;
  the step drawer shows a **Sheet status** box (last checked · rows read · the error) via
  `nodeRow()`'s `sheet_state`. The publish gate additionally blocks a malformed URL and the
  private mode without a verified Google connection — a sheet workflow that cannot read its
  sheet does nothing, silently, forever.
- **Conditional connection badge.** The catalogue's `needs` is null for this type;
  `WorkflowNode::needs()` answers per node (`google_sheets` only in SA mode), and the drawer
  tracks the unsaved `access` choice (`needsKey` in Builder.vue).

### The booking contract (`action.ai_call` → `ae_appointments`) — hardened 2026-09-02

- **Extraction contract.** The step's `AiCallProfile` must define an extraction field named
  one of `AiCall::APPOINTMENT_KEYS` (`appointment_time` first) — the **publish gate blocks**
  without it (`Workflow::aiCallProblems()`), because a silently-never-booking call step is the
  worst failure this product has. `RetellAgentSync::extractionWithGlobals()` auto-appends ISO
  8601 guidance to that field's provider payload (never stored on the profile — that would
  mint phantom settings versions).
- **The GOAL satisfies the contract by construction (2026-09-08).** A profile carrying the
  *Schedule an appointment* goal (`ai_call_profiles.objective`, see the
  [voice-agent handbook](/docs/modules_handbook/shared/voice-agent/readMe.md)) gets `appointment_time`
  and `meeting_type` appended to its provider payload at every Sync, so `Workflow::aiCallProblems()`
  accepts it without reading its field list; a goal-less profile is still checked by name.
  `AiCall::APPOINTMENT_KEYS` now IS `AppointmentObjective::TIME_ALIASES`, `AiCall::parseWhen()`
  delegates to the objective's ladder, and the booking channel resolves lead's answer → the goal's
  `default_meeting_type` → the step's `default_meeting_type` → showroom. The plan editor's brain
  picker rows carry an additive `goal` label (`WorkflowsController::profiles()`). The chat brain's
  goal stays on the PLAN (`Plan::CHAT_GOALS`); the voice brain's lives on the PROFILE — a call step
  has no plan-level voice objective, so the two never compete.
- **Race fix.** The booking lives in `call_analyzed`, but the run used to wake on `call_ended`
  and advance analysis-blind. Now: AE `RetellCallMapper::settle()` folds `call_analysis` when
  the payload carries it; the webhook does NOT wake a spoke-but-unanalysed call (the
  `call_analyzed` event wakes it, with the parked nudge + `AiCall`'s reconcile-and-one-5min-park
  backstop when that event never comes).
- **Parse ladder.** `AiCall::parseWhen()` — day-first slashed dates (5/9/2026 ≠ May 9), ISO,
  English natural language, then one Malay/Chinese normalisation pass (明天下午3点, esok 3
  petang). A daypart with no hour returns null ON PURPOSE (booking wrong is worse than not
  booking). **Every miss is logged to the run trail** (`event: booking`), never silent.
- **Channel.** `custom_analysis_data.meeting_type` (enum zoom/showroom on the profile — the
  drawer shows a hint when the profile lacks it) wins; the step's `default_meeting_type` fills
  silence. `channel = zoom` → a real Zoom meeting is created **at `scheduled_at`**
  (`ZoomServerService`, host = assigned admin else webinar host; duration
  `appointment_engine.zoom.meeting_minutes`) and pinned to the appointment
  (`zoom_meeting_id` + `meeting_link`). Zoom failure never loses the appointment —
  `channel='zoom' AND meeting_link IS NULL` (`zoomLinkPending()`) renders as "no link yet,
  create it by hand" and Telegram-nudges the team.
- **Templates.** `{link}` stays the DATE string; the join URL travels as **`{meeting_link}`**
  (falls back to the date so it is never blank — Meta refuses empty params). `end.booked`
  gained a `variables` field (default = the legacy three) shared by confirm + reminder.
  `action.zoom_invite` is appointment-aware: reuses/creates the meeting at the booked time,
  invents next-morning only when no appointment exists.

### The retry ladder (`retry_gaps`, field type `hours_list`)

Per-attempt gaps replace the flat `retry_gap_hours`: after miss 1 wait `gaps[0]`, after miss 2
`gaps[1]`, …; running off the end repeats the last rung. Rows in the drawer derive from
`max_attempts` (n−1 gaps, the `template_vars` pattern). Applies to `retry_mode='auto'` only —
flow mode still hands timing to the graph. **Back-compat:** an unedited old node's stored
`retry_gap_hours` is honoured via a raw-attribute read (the schema default `[24,48]` must not
shadow it); opening + saving a legacy node migrates it visibly. `call_attempts` is run-scoped
(shared across multiple ai_call nodes in one graph — pre-existing).

### The WhatsApp AI Brain bridge (`action.whatsapp_ai`)

The step DISPATCHES a host proactive flow and parks (`WAIT_WA_BOOKING`); it never talks
itself. Outputs `booked` / `no_booking`.

- **Config:** host `channel_id` + `flow_id` (new `flow` field type — ACTIVE, proactive-trigger,
  ai_profile + objective flows on that number), `default_meeting_type`, `give_up_hours`,
  optional AE `knowledge_id` (flattened into the run variables as `project_details`).
- **Host side (all additive, AE-agnostic):** `StartProactiveFlow` gained a `$meta` bag →
  `whatsapp_flow_runs.meta` carries `capture: 'booking'` + `ae: {run,lead,node}` (meta is never
  rendered or prompted — variables are). `WhatsappAiConfig` surfaces `capture` and appends the
  token instruction to the objective block; `GenerateAiReply` parses/strips
  **`[[BOOKED: YYYY-MM-DD HH:MM | zoom]]`** (`Src\Whatsapp\Support\BookingTokenParser` — 15min
  grace, 180d cap; a valid booking IS objective-met) and fires the generic
  `App\Events\Whatsapp\WhatsappBookingCaptured`. `src/Whatsapp` never learns the string
  "AppointmentEngine" — the join lives in `EventServiceProvider`.
- **AE side:** `App\Listeners\AppointmentEngine\RecordWhatsappBooking` (queued, tries 3) —
  correlates by run-meta uuid (fallback: group+phone), writes `ae_appointments`
  (`source='ai'`, `ae_call_id` NULL, **`wa_flow_run_id`** = the chat-side provenance +
  idempotency key; re-negotiated times update the same row), stamps the run context and
  `WorkflowRunner::resume(WAIT_WA_BOOKING)`. The handler's re-entries settle: booked / host
  run ended (reason on the ledger) / never started (30min grace) / give-up deadline (the chat
  itself lives on — a late booking still writes its row for `condition.booked`).
- **Publish gate** (`Workflow::whatsappAiProblems()`): flow alive, on the step's number,
  proactive, has AI + objective; on a Cloud number the opener must be an approved template
  (validated with the same `TemplateStepValidator` the send path runs).

### The CTWA trigger (`trigger.ctwa`)

Real Meta ad attribution: `CloudApiDriver::normalizeReferral()` whitelists
`messages[].referral` (source_id, ctwa_clid, headline, …) into
`whatsapp_messages.meta.referral` (historic traffic is retro-minable from `meta.raw.referral`).
The trigger matches on the referral itself — optional `ad_id` filter (comma-separated),
optional `fallback_keywords` for referral-less messages (a workflow has exactly ONE trigger, so
the fallback is a field, not a sibling node). **One sweep, one cursor**: `Triggers::whatsapp()`
serves both keyword and ctwa nodes off the same page (the query now takes TEXT messages OR any
type carrying a referral). Enrolment: `source='ctwa'`, first-touch `source_campaign` = ad id +
`ae_leads.ctwa_clid` (indexed; the Meta ads-reporting join key), never overwritten. The 72h
free entry-point window is deliberately NOT modelled yet (follow-up documented in the WS4
design; the data to backfill it is stored).

### The six presets (chooser order)

1. **`full_journey`** — the whole product end to end (ladder `[4,24]` now).
2. **`ctwa_ai_appointment`** (NEW) — chat-first CTWA: `trigger.ctwa` → `action.whatsapp_ai`
   (the window is open, the brain talks immediately) → booked → assign + confirm; `no_booking`
   → ONE low-capped voice escalation (2 attempts) → outcomes end at the agent, never loop back
   to chat (pestering guard).
3. **`ctwa_decision`** — the call-first CTWA shape, kept for owners who want deterministic
   template choreography; trigger swapped to `trigger.ctwa`, and its `gave_up` end now hands
   to `action.whatsapp_ai` ("gave up" stopped meaning goodbye).
4. **`google_sheet_calls`** — "Google Spreadsheet → AI Call", the owner's flagship: sheet
   trigger → `action.ai_call` (auto mode, ladder `[4,24]`, 3 attempts) →
   `condition.call_outcome`: **booked** → assign closer → confirm & remind (+ Zoom link when
   the lead chose Zoom); **objection / gave up / no answer** → `action.whatsapp_ai` (the brain
   IS the conversation the old two-round template nurture only approximated) → booked → assign,
   `no_booking` → lower tier; **invalid** → stop.
5. **`meta_form`** — ladder `[4]`.
6. **`closing_only`** — unchanged.

Presets seed structure; agency-specific pieces (AI profile, WhatsApp number, templates, the
conversation flow, the sheet URL) are left empty ON PURPOSE — the publish gate walks the user
through each with a named sentence.

## Related files

**Backend**
- `src/AppointmentEngine/NodeCatalogue.php` — node type registry (incl. `trigger.google_sheet`)
- `src/AppointmentEngine/WorkflowTemplates.php` — presets (incl. `google_sheet_calls`)
- `src/AppointmentEngine/Workflow.php` (`problems()` publish gate incl. `sheetProblems()`),
  `WorkflowNode.php` (`needs()`, `config()`, `missingConfig()`), `WorkflowEdge.php`,
  `WorkflowRun.php`, `WorkflowRunLog.php`, `Lead.php` (`SOURCE_SHEET`), `SheetCursor.php`
- `src/AppointmentEngine/Runner/Triggers.php` — the sweeps (`sheets()`, `pollSheet()`,
  `enrolSheetRows()`, `enrolOnce()`), `WorkflowRunner.php`, `HandlerRegistry.php`,
  `Handlers/AiCall.php` (dial governor), `Handlers/Passthrough.php`
- `src/AppointmentEngine/Services/Sheets/` — `SheetReader.php`, `SharedLinkSheetReader.php`,
  `ServiceAccountSheetReader.php`, `SheetRef.php`, `SheetReadException.php`
- `src/AppointmentEngine/Support/LeadRowMapper.php` — shared heading-matching + `e164()`
  (also used by `LeadsController` import and the Meta sweep)
- `src/AppointmentEngine/Connection.php` (provider `google_sheets`),
  `Services/ConnectionVerifier.php` (`sheets()`)
- `app/Http/Controllers/Manage/AppointmentEngine/WorkflowsController.php`,
  `SettingsController.php` (per-field credential max + `multiline`), `LeadsController.php`
- `app/Console/Commands/AppointmentEngine/RunWorkflows.php`,
  `app/Jobs/AppointmentEngine/AdvanceWorkflowRun.php`
- `config/appointment_engine.php` (`voice.dials_per_minute`)

**Frontend**
- `resources/js/Pages/Manage/AppointmentEngine/Workflows/Index.vue` — the AI Agent page
- `resources/js/Pages/Manage/AppointmentEngine/Workflows/Builder.vue` — canvas + step drawer
  (share-with hint, Sheet status box, reactive `needsKey`)
- `resources/js/Pages/Manage/AppointmentEngine/Settings/Connections.vue` — provider cards
  (multiline secret textarea for the JSON key)

**Migrations**
- `database/migrations/2026_08_30_100000_create_ae_workflow_tables.php`
- `database/migrations/2026_08_30_140000_add_workflow_runs_and_lead_source.php`
- `database/migrations/2026_08_30_150000_runner_columns_and_run_logs.php`
- `database/migrations/2026_09_01_100000_create_ae_sheet_cursors_table.php`
- `database/migrations/2026_09_02_100000_add_channel_to_ae_appointments_table.php`
  (channel / zoom_meeting_id / meeting_link / wa_flow_run_id)
- `database/migrations/2026_09_02_100001_add_ctwa_clid_to_ae_leads_table.php`

**2026-09-02 additions (backend)**
- `src/AppointmentEngine/Runner/Handlers/WhatsappAiTakeover.php` — the brain step handler
- `app/Listeners/AppointmentEngine/RecordWhatsappBooking.php` + `app/Providers/EventServiceProvider.php`
- Host (additive, AE-agnostic): `src/Whatsapp/Support/BookingTokenParser.php`,
  `app/Events/Whatsapp/WhatsappBookingCaptured.php`, `app/Jobs/Whatsapp/StartProactiveFlow.php`
  (`$meta`), `src/Whatsapp/Services/WhatsappAiConfig.php` (capture), `app/Jobs/Whatsapp/GenerateAiReply.php`
  (token), `src/Whatsapp/Drivers/CloudApiDriver.php` + `Data/NormalizedMessage.php` (referral),
  `app/Jobs/Whatsapp/ProcessInboundWhatsAppWebhook.php` (meta.referral)
- `src/VoiceAgent/Services/RetellAgentSync.php` (ISO suffix on booking extraction fields)

**Routes** — `routes/web.php` `manage/appointment-engine/ai-agent/*`
(`manage.appointment-engine.ai-agent.*`, gated `view-appointment-engine`); webhook
`routes/main.php` → `Webhooks\AppointmentEngineRetellController`.

**Nav (project-first, 2026-09-03):** FIVE entries, no stage labels — an agency holds the
product as "my projects": **Dashboard** (per-project funnel rows + Needs you + Today; the
commission hero left the page, its inputs stay in Settings → Billing) · **Projects** (the hub —
NEW: `/projects` index, one row per deal with leads/spoke/booked/attended; `/projects/{id}`
Show = funnel + three setup cards (Workflow / AI brain / Lead source) + the publish gate's own
problem sentences + Leads and Appointments tabs; `ProjectsController` +
`Src\AppointmentEngine\Support\ProjectFunnel` — the ONE aggregator Dashboard shares. AI
Agent's paths light this entry; the global libraries are quiet links on the hub, and
`/ai-agent?create=1&project={uuid}` opens the New-workflow modal pre-filled) · **Leads** ·
**Conversations** (the Calling strip, whose tabs now include the host WhatsApp ↗ and Zoom ↗
jumps) · **Appointments** (was Showroom; `/appointments*`, old `/showroom*` GETs redirect);
Setting pinned in the footer. AI Agent tabs: Workflows / **AI Profiles** (`/ai-agent/profiles*`,
old `/calls/ai-profiles*` GETs redirect — every write/XHR endpoint keeps its old path) /
Knowledge (`Partials/AiAgentTabs.vue`).

**The brain switcher (2026-09-03):** the Project Show page's AI-brain card carries a
Change/Choose control — `PUT projects/{id}/brain` (`ProjectsController::updateBrain`) moves
EVERY `action.ai_call` step across all of the deal's workflows to the chosen profile (one deal,
one brain), retracting each step's script/knowledge from the old profile's KB and pushing it
into the new one via `Src\AppointmentEngine\Services\ProfileKnowledge` (extracted from the
builder's save path, which now also retracts on swap — previously a swap left the old voice
still holding the deal's facts). The builder's step drawer remains the per-step override.

**Project key on leads (2026-09-03):** `ae_leads.ae_project_id` (nullable, indexed) is stamped
at enrolment from the workflow (`Triggers::lead()`, back-filled onto re-seen leads where
blank); the display string `ae_leads.project` stays. History rows with only the name are folded
into the hub's numbers by name — never backfilled, never trusted over the key.

## The CRM person bridge (`ae_leads.lead_id`, Phase 1 — 2026-09-06)

`ae_leads` stays this product's own working book, but every row now links to **THE lead** —
the CRM `leads` row (person = `users` + `user_profiles`) that all channel history (WhatsApp,
Zoom, calls, showroom F2F), enrichment and engagements already hang off. Owner-approved
convergence: end state is one person, one lead, AE state as a satellite.

- **Resolver:** `Src\AppointmentEngine\Services\CrmIdentity::ensureLinked()` — a thin wrapper
  over the host's `LeadLinker` (`SOURCE_APPOINTMENT_ENGINE`), so every identity guard (email is
  the primary key; an unproven phone may never RESOLVE a person; an owned number is never
  grafted) is the census-built one, not a re-invention. Trust ladder per AE source:
  CTWA/WhatsApp = `network_verified` (the number IS the WhatsApp sender); CSV/manual = `typed`
  (the admin uploaded or typed it — the CRM CSV import's own ruling); **sheet and Meta form =
  `unverified`** — a connected sheet's ROWS are a live public feed (Meta ads → Sheet), so its
  phones may never resolve a person (adversarial review 2026-09-06; the email column still
  resolves). An unverified lead is UPGRADED per row the moment the person ANSWERS an AI call on
  that number (`ae_calls.lead_spoke`, pinned to `to_number` so an edited phone drops the proof) —
  sheet leads converge onto their CRM person when contact becomes real, never before. Staff phones link to the staff-facet
  lead and never mint a customer lead. Failure never blocks intake — an unlinkable row stays
  unlinked (recoverable) rather than mis-merged (not).
- **Stamped at every birth/touch:** `Triggers::lead()` (both branches), AE Leads `store()` /
  `import()` / `update()` (identity change → `relink()`), and once more on Lead Show view.
  Backfill: `php artisan ae:link-leads` (`--dry` classifies via the linker's preview; `--existing-only` links without ever minting).
- **What the link unlocks now:** the AE Lead Show page mounts the CRM's own `IdentityTab`
  (enrichment card — served under a prop literally named `enrichment`, which its re-run poll
  requires) plus a deep link to the full CRM profile. Enrichment is manual-run (the paid
  web-search/AI stage is live on this box); nothing auto-enriches on link.
- **Phase 2b — a bridged lead renders the CRM's page itself (2026-09-06):** the AE lead show
  route now DELEGATES: `Manage\AppointmentEngine\LeadsController@show` calls the CRM
  `LeadsController@show` and returns its Inertia response with two prop overrides
  (`suite: 'appointment-engine'`, `backUrl`) — same component (`Manage/Leads/Show`), same
  payload, at the AE URL so the suite sidebar stays. The tree differs by suite inside
  `useLeadTabs(get, { suite })`: Appointments is promoted to a MAIN tab (and leaves the Sales
  strip + badge), Property Portal and Physical Events are dropped; Intelligence / Sales /
  Channel / Activity / Discussion remain. Every future change to the CRM lead page reflects in
  the AE suite for free. An UNLINKED lead (or a viewer the CRM's own gates would refuse —
  `viewLeadsAny` + `LeadVisibility`) falls back to the engine's native page, which keeps the
  Workflows + Timeline rails (the only view into runs for a lead with no CRM twin).
- **Phase 2 — the Channels card (2026-09-06):** a bridged lead's page shows every way the
  platform has talked to this person — WhatsApp / Messenger / Zoom / Phone Call / AI Caller /
  Showroom F2F, plus an engagements-and-bookings row. ZERO re-derivation: the card fetches the
  CRM's own `GET /manage/leads/{uuid}/quick` payload (the shared `LeadDetailModal`'s data source,
  self-gated by `viewLeadsAny` + LeadVisibility) and reads it through the CRM's own `useLeadTabs`
  composable, so rows, icons and counts can never disagree with the modal. The AI Caller row
  folds in the engine's own `ae_calls` ledger (`aeCallCount` prop). Each row calls
  `openLead(uuid, { tab: 'channel', ctab })` — the shared modal (mounted once in ManageLayout)
  opens on that exact channel without leaving the AE page.

## The appointment merge (2026-09-06, owner-approved)

The engine no longer keeps its own booking table: every AE booking is a row in **THE book —
`appointments`** (`Src\Appointment\Appointment`, written through its repository's vocabulary).
Eight nullable columns carried the engine's auditability over (`group_id`, `ae_lead_id`,
`ae_call_id`, `wa_flow_run_id` — the WhatsApp capture's idempotency key — `assigned_admin_id`,
`source`, `zoom_meeting_id`, `meeting_link`); a human-made CRM appointment simply leaves them
empty. The engine's dialect (channel `zoom`/`showroom`, string outcomes `attended`/`no_show`/
`cancelled`) maps onto the CRM's `type`/`outcome`/`status` integers in ONE place —
`Src\AppointmentEngine\Support\Booking` — cancelled is a STATUS there, and
`Booking::outcomeRecorded()` counts it as recorded (a cancelled booking must never park a
workflow waiting for an outcome nobody will write). AE suite pages read only engine-earned rows
(`whereNotNull(ae_lead_id)`); the CRM lead page reads the whole book, so an AI booking now shows
on the lead's Appointments tab the moment it is made (`lead_id` rides along via the lead bridge).
`ae_appointments` still exists but nothing writes or reads it — drop it once the merge has soaked.

## The SIMPLE EDITOR — the canvas is gone (owner redesign, 2026-09-06 evening)

The drag canvas below was replaced the same day it shipped ("too complicated"): the Workflow
tab is now a **flat step list in the Messages → Flows grammar**, and the graph became an
implementation detail. THE editable truth is **`ae_workflows.plan`** (json:
`Src\AppointmentEngine\Support\Plan` — entry + entry_config, steps[] of kind call|message each
with its own wait, a `chat` section, a `booked` section); on every save
(`PUT ai-agent/workflows/{id}/plan` → `savePlan()`) **`Services\PlanCompiler`** rebuilds the
node/edge graph the (unchanged) runner executes, with the BRANCHING RULES fixed in one place:
any step that books → the booked chain (optional assign → end.booked confirm/remind); a message
step is preceded by an `condition.booked` check so a booking already made suppresses the
nurture; a dead number → end.stop mark_lost; a missed call (its own retry ladder exhausted) →
simply the next step; list done → end.stop WITHOUT lost. `store()` takes `entry`
(sheet|ctwa|keyword) and compiles a one-step default plan; templates/presets are gone.
`planProps()` (public seam) serves the editor; `ProjectsController@show` exposes it as the
optional `planDetail` prop.

**Editor form III — the FLOWCHART IS the editor (owner, 2026-09-07 night).** The step-card
list retired the same day it matured: the Workflow tab now edits ON a centred flowchart.
Start / blocks / Stop run down the middle (waits editable in place on their connectors); an
AI-call block carries the runner's two FIXED decisions drawn as diamonds ("Answered call?" →
the step's real retry ladder; "Appointment scheduled?" → the Booked ✓ chain, drawn once at
the bottom); the chat brain is a dashed always-on note. Clicking ANY block opens one shared
`Drawer` with that block's fields (entry / call / template+preview / chat / booked) — adding
a step from the sticky right rail (AI caller / WhatsApp template buttons, plus the compact
Chat brain and Once booked cards) opens its drawer immediately. Same plan document, same
save, same compiler. The diamonds are FIXED branching drawn honestly — this is not the
rejected free-form canvas, and must never grow edge editing.

*(Superseded history below — the two-column card list this replaced:)*
**Editor layout (owner redesign, 2026-09-07): two columns.** LEFT is the flow as it happens in
time (entry + the step cards); RIGHT is a sticky rail holding the two ALWAYS-ON sections —
Chat brain and Once booked — so they stay visible while a long step list scrolls. A message
step offers **no "typed vs template" choice**: the mode is the CHANNEL's rule (a workflow
message fires when the customer has NOT replied, so a Cloud API number can only deliver an
approved template under the 24-hour window; a bridge number has no templates and types
freely) — the UI adapts to the chosen number and `save()` stamps `mode` from it. Every
template select renders **`Partials/TemplatePreview.vue`** (a WhatsApp-style bubble: header
hint, body with the step's variables substituted into the `{{n}}` blanks, footer, buttons) fed
by the picker rows' `preview` key — `WhatsappTemplate::previewParts()` via
`WorkflowsController::templates()` — so the chooser sees what the customer will receive.

**The chat brain runs as SHADOW FLOW RUNS since 2026-09-07** (owner decision: reuse the
host's flow-run machinery instead of the stateless resolve hook). Every AE workflow with
chat enabled owns ONE hidden host flow (`Services\ShadowFlow::sync`, marked
`settings.ae_workflow`, filtered off Messages → Flows; ACTIVE only while the workflow is
live) — zero steps, campaign-triggered, carrying the plan's WhatsappAiProfile + objective.
`Services\ChatTakeover::begin()` (called from WhatsappSender after the first send) opens one
run per conversation straight in AI phase, so `WhatsappAiConfig`'s NATIVE flow priority
answers replies — the AE resolve hook and `ChatBrain` are gone. What the run machinery buys:
a booking ends the takeover `ENDED_OBJECTIVE` (no more selling after booked), an inbox
handoff ends it, the idle reaper applies, the inbox flow-monitor panel shows it with its own
Stop, and the run rows are the takeover ledger. *(The project page's Chats tab that listed
them was removed 2026-09-07 with the AI Call Brain and WhatsApp Automation tabs — owner: the
flow editor owns brains and chat now, and the Leads tab is the person-level view. A project
Show page has THREE tabs: Leads, Appointments, Workflow. Each lead row carries a WhatsApp
button — `GET leads/{id}/chat` resolves the lead's most recently active conversation on
demand and redirects into the inbox, opened `target="_blank"`; no per-row lookup ever runs
in the table query. The old per-project brain switch (`updateBrain`/`updateWhatsapp`,
`PUT projects/{id}/brain|whatsapp`) is gone with the tabs — call steps pick their brain in
the plan editor.)*

**The goal is a STANDARD SERVICE, not a brain trait (2026-09-07).** The chat drawer's goal is
a picked option from `Plan::CHAT_GOALS` (only `appointment` today — more later), and everything
that makes it work rides the PLAN, never the profile: `Plan::normalize` writes the goal's
canonical objective into `plan.chat.objective` (a hand-tuned text is kept), `ShadowFlow::sync`
copies it onto the shadow flow, and `WhatsappAiConfig::activeFlowConfig` attaches
`objective` + `capture: 'booking'` (from the run meta `ChatTakeover::begin` stamps) to the
effective config, so `systemPrompt()` injects the objective block, `[[OBJECTIVE_MET]]`, and
the full `[[BOOKED: …]]` capture instructions (today's date, zoom/showroom question) for ANY
profile. Proven with a blank brain knowing only project facts: swap it into the plan, resolve —
objective from plan ✓, capture booking ✓, prompt carries all three blocks ✓, while the brain's
own instruction never mentions appointments. Swapping brains changes persona + knowledge only.

**Brains can be CREATED from the chat drawer (2026-09-07).** Under the brain select, "+ Create
a new brain" posts `POST /manage/messages/ai/profiles` (name, `extends_global: true`) then each
picked knowledge file to `POST …/profiles/{uuid}/documents` — the SAME endpoints Messages → AI
Setting uses; `AiProfilesController::storeProfile`/`storeDocument` gained `wantsJson()` branches
(`{id, uuid, name}` / `{uuid, name}`; failures → 422 with the sentence) so both surfaces share
one backend. The new brain is auto-selected and the pickers refresh via a
`router.reload({ only: ['planDetail'] })` guarded by `preserveFormOnce` — a pickers-only reload
must never re-seed unsaved canvas edits.

**…and EDITED there too (2026-09-07, same day).** With a brain selected, "Edit this brain"
opens name + persona instruction + Reply mode + the confidence bar + the knowledge-document
list (remove ×, add files) in place. ⚠️ **Reply mode matters here**: `whatsapp_ai_profiles.mode`
defaults NULL → MODE_DRAFT, and a DRAFT brain in a chat takeover only writes composer
suggestions — it never answers the customer. The drawer's create now sends `mode: 2`
(`StoreProfileRequest` accepts an optional mode), and a selected non-AUTO brain shows an amber
"will not answer by itself" warning pointing at the edit panel's Reply mode select. `planProps` ships `pickers.waProfiles` in the FULL `AiProfilePresenter`
shape (plus the picker's `value`/`label`), and the drawer talks to the same endpoints —
`PUT profiles/{uuid}` (now `wantsJson()`-aware, with the settings-bag merge) and
`DELETE documents/{uuid}`. ⚠️ The update endpoint expects the WHOLE profile — an unsent field
nulls and unsent `channels` DETACH — so the drawer passes untouched values straight back from
the presenter row.

**Canvas round of 2026-09-08 (owner):** (1) **Once booked is ONE card, drawn twice** —
`Partials/BookedChainCard.vue` renders the amber chain (Once booked ✓ › Assign › confirmation ›
Remind) both as the standalone block the chat brain's tie points at and, `compact`, on the Yes
branch of every "Appointment scheduled?" diamond, so the two can never drift; amber is the
Once-booked colour everywhere (emerald = WhatsApp templates, violet = AI caller, sky = chat
brain; a "not set yet" hint is rose). The Booked drawer is titled "Once booked" and reads as
two numbered sections — ① Confirmation (emerald, sent on booking) and ② Reminder (indigo,
"Sent N hours before" live) — under a Booked → ① → ② → Appointment timeline strip, plus a
"Where — showroom details" block. (2) **The rail lost its Chat brain and Once booked cards** —
both are edited from their canvas blocks; the rail is only "Add a step". (3) **Add a step by
DRAG, with a landing preview**: while a rail card is dragged, the wait pills hide and every gap
between blocks becomes a drop zone (`dropZones`, spanning prev-bottom → next-top so its top
edge never moves under the pointer); the hovered gap opens up — `posOf()` steps every main-line
key below it down by `GHOST_H[kind] + GAP_Y` (`previewShift`, saved positions untouched) — and
draws a dashed ghost of the block ("Release to insert it here — as step N"). Zone *i* inserts
before step *i*, the last one appends; `insertStep(kind, index)` splices, re-stacks
(`autoLayout(false)`) and opens the drawer; a click still appends. **An AI caller may appear
many times but never two in a row** (`canInsert`): a gap whose neighbour is a call step shows
"Not here — next to another AI caller" and refuses the drop, and a click that would append one
right after another explains itself in a rose notice under the rail instead of silently doing
nothing — the caller's retry ladder is the repetition, so back-to-back callers have no meaning.
WhatsApp templates are free anywhere.

### Deleting a lead = start them over — `Services\LeadEraser` (2026-09-09)

Owner: "when I delete the lead, all records go, so when the lead is in the spreadsheet again the
flow triggers again." The AE delete is now a HARD erase of everything the engine made about the
person — workflow runs (+ logs), calls, closer hand-offs, the AE appointments (soft, the CRM
book keeps its audit) and the chat takeover's shadow-flow runs (ended `idle` the host's way,
then removed, because `ChatTakeover::begin()` refuses to re-arm a conversation holding a
STOPPED / HANDOFF run) — then `forceDelete()` on the lead. The CRM person + engagement stay;
a re-created lead links back to them. Three keys had to be cleared for "again" to work: the
sheet reader matches rows to leads by phone, `enrolOnce` keys on (lead, workflow), and the
takeover's stay-stopped guard keys on ended runs.

**The sheet reader lets a deleted person back in.** Read rows are never re-read — except that
each poll now also looks at the rows BEFORE the cursor whose phone has no live lead in the
agency (`Triggers::returningRows`, one `whereIn` per 500 phones) and imports them again, keyed
by their real row index. A returning row enrols **regardless of the baseline** (`$startOver`
in `importSheetRows`): the backlog rule protects people the engine never worked, and deleting
is the explicit "start them over". "Check now" and the delete flash both say when this
happened. Consequence to know: delete a lead whose row is still on the sheet and the next poll
(every `poll_minutes`) brings them back and calls them — remove the row too if that is not
wanted; the confirm modal says so.

### Agency-first — `Support\AeScope` and the chooser (2026-09-08, later the same day)

Owner: super admin enters the suite and **chooses the agency first**; from then on the whole
suite is that agency's, exactly as one of its leaders sees it. `Support\AeScope` is GroupScope
plus one more source of truth — the session's chosen agency for platform staff
(`ae.agency_id`; `groupId()` = the user's own group, else the choice, else null = all). **Every
AE scoping call goes through `AeScope`** (`apply` / `applyShared` / `allows` / `groupId` — 53
call sites across the eight AE controllers, `FunnelSummary`, `ProjectFunnel`; never
`GroupScope` directly), so "acting as VF Realty" is honoured in one place, including the
`group_id` stamped on leads / workflows / projects a platform user creates while acting.
The `ae.agency` middleware (`EnsureAeAgencyChosen`, on the whole AE route group) sends
platform staff with no choice yet to `GET appointment-engine/agency` (`AgencyController`,
`Agency.vue`: one card per active agency with people / teams / projects, plus "All agencies —
platform view" = null); `POST agency` stores the choice. The dashboard's eyebrow names the
agency and offers **Switch agency** to platform staff. On wk everything the platform held —
projects, workflows, leads, runs, calls, connections, content, AI-call profiles, documents,
flow nodes, the AE appointments, WhatsApp channel #6 — was moved under **VF Realty**
(group 2, ex "Skyline Realty"; Legend and its dummy people deleted).

**Teams inside the agency — `ae_leads.team_id`.** A project page carries a team switcher
under the back button (All teams / Alpha / Bravo — `teamOptions()`, the acting agency's
teams); `?team=` (validated by `teamFilter()`) narrows `leadQuery()`, and with it every tab:
the leads, the appointments (they follow their lead), the band (`FunnelSummary::build(...,
$teamId)`) and the funnel (`ProjectFunnel::byProject(..., $teamId)`). A lead's team is stamped
from the closer's team when the engine assigns one (`CloserRotation::offer`) or the adder's
team for a manual add; wk's existing leads were backfilled onto **Bravo**, and Armani's VF
Realty closers are Bravo (Bella, Ben). Not yet: the LeadsTable's own search form drops
`?team=` (it rebuilds the URL from its base), and a manual Assign from the CRM modal does not
restamp the team.

### The AE is GROUP-BASED — what that means in code (2026-09-08)

Every AE read was already `GroupScope::apply`'d on `group_id` (projects, workflows, leads,
appointments, runs, the dashboard, `Setting::forGroup`): a platform viewer (no group) sees
everything, an agency member only their agency's rows. Two things were missing and are now in:

- **Projects are shared DOWNWARD, the FLG way.** A platform project (`ae_projects.group_id`
  NULL) is readable by every agency — `ProjectsController::sharedProjects()` (`applyShared`)
  serves index / show / the pickers, and the Dashboard, Leads, Showroom and Workflows project
  reads switched to `applyShared` too; writes keep the strict `projects()`. Each agency brings
  its OWN leads to it (stamped `group_id` at ingestion), its own appointments and runs, and its
  own closers (below). An agency's own projects stay its own.
- **Sales agents hold `view-appointment-engine`** (granted live + `SeedCommonRolesAction`):
  the console is their agency's activity, and every read inside it is scoped anyway.

**The closer board — `ae_project_closers` / `ProjectCloser`.** On a project's Leads tab, the
"Closers for this project" strip opens `Partials/ClosersModal.vue`: groups on the left (a
platform viewer also gets the platform's own bucket, group NULL), that group's sales leaders /
agents / GSA on the right grouped by team (role badge, Zoom chip), tick who closes, **Save for
{group}** → `PUT projects/{id}/closers` (`ProjectsController::updateClosers`: `manage-
appointments`, `GroupScope::allows` on the group — an agency user only their own, never the
platform bucket — and only people who belong to that group survive the save). The board prop
is `closerBoard()`; candidates come from `closerCandidates($groupId)`. This list is the
rotation's **default pool** (`project`): the lead's agency's list, else the platform's list
(`CloserRotation::projectCloserIds`). Demo data on wk: groups Legend (3) and Skyline Realty
(5, teams Alpha/Bravo), `*@legend.example.test` / `*@skyline.example.test`, password = email,
plus two demo leads per group on Armani Hallson.

Open items the owner may want next: an agency's OWN workflow for a shared project (today the
platform's workflow runs for the platform's leads; a group lead entering by the group's channel
follows the trigger's group — worth a dedicated pass), and a Group filter on the dashboard for
platform viewers.

### Auto-assigning the closer — `Services\CloserRotation` (2026-09-08)

The booked chain's "Assign the closer" step (`action.assign_agent`, handler `AssignAgent`) is a
thin shell over **`CloserRotation`**, the one place the engine picks a person. Four layers, in
order — owner chose round-robin, the rest was designed to fit what already exists:

1. **Pool** (`booked.assignment.pool_type`): `group` = everyone assignable in the lead's
   agency — the CRM's own definition (active manage users holding `sales-execution`, group-
   scoped; the same people the Assign modal offers); `team` = a Team from People → Groups
   (`Team::memberUserIds`) — which is why **Groups joined the suite's Team strip** (`AeTeamTabs`,
   the same page the Project suite reaches); `admins` = named people. Pickers `teams` /
   `closers` (with a Zoom flag) ride `planProps`.
2. **Filters** (`eligible()`): a Zoom booking only goes to a closer Zoom knows
   (`ZoomServerService::isAccountUser`, cached) so `ZoomMeetings::ensure()` — which runs in
   `EndBooked`, AFTER this step — hosts the meeting under the closer, not the webinar host;
   nobody holding another scheduled appointment within ±60 min (`CLASH_MINUTES`); nobody who
   already passed on / ignored THIS booking (the hand-off ledger).
3. **Strategy**: `round_robin` (default) = whoever was OFFERED least recently, read from
   `ae_closer_handoffs` (never-offered first, then oldest offer, ties on id) — no pointer to
   drift, so the rotation self-heals when people join or leave; `balance` = fewest upcoming
   appointments; the legacy `specific` / `rules` (`RoutingEngine`) still resolve from a node's
   old `mode`.
4. **Handshake**: the closer is told through `Notifier::sendToUsers('ae.lead_assigned', …)` —
   addressed, never the shared group — with **Accept** (`GET handoffs/{uuid}/accept`) and
   **Pass** (`GET …/pass`) links (`HandoffsController`; GET because they are tapped from a phone
   notification, both idempotent, the closer or a `manage-appointments` holder only, else 404)
   and `accept_minutes` to answer (default 15, 0 = assign directly). `ExpireCloserHandoff`,
   queued with that delay, passes a still-pending offer to the next closer; a Pass does so at
   once; when the pool runs dry the team gets `ae.team_alert` and the booking STAYS with the
   last person asked (never orphaned). A closer with no active personal Telegram subscribed to
   the event cannot be asked, so they are assigned directly (`hasTelegram()` — which also
   answers false when `NOTIFY_ENABLED` is off or the install has no `TELEGRAM_BOT_TOKEN`, as the
   wk dev box does not: there, every closer is assigned directly and no clock runs).

   ⚠️ Found on the way: the three `ae.*` events were registered `default => true` but never
   backfilled, so every personal Telegram chat that existed before them was unticked for all
   three — "lead assigned to you", the team alerts and the nudges had been going to nobody.
   `2026_09_08_110000_backfill_appointment_engine_subscriptions` fixes that the way the two
   earlier backfills do (additive insert, PERSONAL destinations only, existing rows untouched).

Every offer is a **`CloserHandoff`** row (`ae_closer_handoffs`: status pending / accepted /
passed / expired, attempt, expires_at, note) — the ledger behind the links, the rotation and the
run log. `offer()` writes the assignment everywhere it lives in one go: `ae_leads.assigned_admin_id`,
`appointments.assigned_admin_id`, the engagement's closer role (`CrmPipeline::syncCloser`), and —
for a bridged lead — the CRM's distribution history via `LeadDistributionService::assign()` with
the new `LeadAssignment::REASON_APPOINTMENT`, so `times_assigned`, the tier and the activity
trail stay one book (fail-soft: a CRM hiccup never un-books anyone). Settings live in
`plan.booked.assignment` (`Plan::ASSIGNMENT_DEFAULTS` / `normalizeAssignment()`), compiled onto
the node (`PlanCompiler`, `NodeCatalogue` fields), edited in the Once-booked drawer's
"Auto-assign a closer" section. Not built yet: showing the pending/expired hand-off on the lead
rows (today: the run log, the closer's Telegram, and the team alert).

**A Zoom booking gets its meeting in ONE place — `Services\ZoomMeetings::ensure()` (2026-09-08).**
The chat path exposed a gap: `RecordWhatsappBooking` creates the appointment link-less and the
plan's booked chain is just `end.booked` (+ assign), so a chat-captured Zoom booking sent
"Join link to follow" — the meeting-creation code lived only in AiCall's private method and
the ZoomInvite handler. It is now a service (host = the lead's assigned admin, else the Zoom
integration's webinar host; failure absorbed + `ae.nudge`), called by AiCall's booking and by
`EndBooked` BEFORE it builds `{venue}`/`{venue_link}`, so the confirmation carries the real
join URL. EndBooked also pins `context.appointment_id`, and `Text::values` renders from that
pinned row first (the lead's latest appointment is only the fallback) — the reminder, sent
hours later, must describe the booking it was queued for even if a newer one exists. Both
approved UTILITY templates (`appt_confirm` #56 / `appt_reminder` #57, five blanks =
`{name} {date} {time} {venue} {venue_link}`) are live on workflow 27 with the showroom
address/note/map link filled; verified end to end on the owner's own number for a showroom
booking (address + map search link) and a Zoom booking (meeting created, join link in the
message, reminder queued 16 h before).

**The reply-confidence gate (2026-09-07).** Per brain: `whatsapp_ai_profiles.settings.
confidence_threshold` (0–100, 0 = off; slider in both the drawer and Messages → AI Setting —
`AiProfilePresenter` exposes it, `UpdateProfileRequest` validates it, `updateProfile` merges it
into the settings bag). When > 0, `WhatsappAiConfig::systemPrompt()` asks the model to end each
reply with `[[CONFIDENCE: NN]]`; `GenerateAiReply` parses + strips the token UNCONDITIONALLY
(a model can parrot one from history) and, for a MODE_AUTO reply below the bar — with no
captured booking and no objective met, success is never blocked — calls `handOffUnsure()`:
the reply is HELD as the composer draft (`low_confidence:NN<bar` audit reason), the
conversation is stamped `ai_handoff_at` (the AI hard-stops on the thread until an admin
resumes it from the inbox), any flow run in its AI phase ends `ENDED_HANDOFF` (the AE
"stay stopped" guard honours it), and `Notifier::send('ae.ai_unsure', …)` buzzes subscribed
admins with the customer, brain, confidence and an inbox deep link. A missing token FAILS
OPEN (reply sends normally) so a model that forgets the instruction degrades to normal
behaviour, never to every chat going manual. The event is registered in `config/notify.php`
under the **AI Appointment System** group, `default => false` — the admin opts in on
Setting → Notifications, where the appointment suite (`?suite=appointment`) now renders ONLY
that group (a presentation filter in `Notifications/Index.vue`; the draft sets stay full, so
saving there never wipes the hidden groups' subscriptions).

**The Dashboard is the sales leader's seat (owner redesign, 2026-09-07; simplified again
the same evening).** One glance, one question: the LEFT is the shared
**`Partials/FunnelSummaryBand.vue`** — the same Leads / Automation / Result band every
project page opens with — summed over EVERY project by
**`Src\AppointmentEngine\Support\FunnelSummary`** (one builder, both surfaces, so the two
screens cannot disagree; `ProjectsController::summary` delegates to it) inside a
leader-picked range (`?range=month|7d|3m|custom&from&to`, `rangeWindow()`; events count by
their own timestamps, the lead SET is never date-narrowed). The earlier KPI cards,
week-compare tiles, Needs-you, project rows and today list are off the page — they live on
in the chat snapshot. The right rail mounts the shared
`Components/AiAgent/AgentChatPanel` as a **performance analyst**: `POST dashboard/chat`
(`DashboardChatRequest`) rebuilds the page's own snapshot fresh (stats, commission, compare,
14-day `trends()`, pipeline, projects, attention, today, `chatStats()` shadow-run tallies,
`refusals()` by reason) and answers through `AiClient` with prompt key
`AiRequest::PROMPT_AE_DASHBOARD_CHAT` (body `resources/prompts/ae_dashboard_chat.md`,
returns `{text, bars, table}` like the Copilot Analytics chat it is modelled on). **Nothing
spends on page load** — only an asked question calls the AI, and every turn lands in
`ai_requests`. `/dashboard/coverage` (and the `/coverage` alias) was REMOVED whole with
`CoverageController` — ask the analyst what got dropped instead; the SuiteTabs `dashboard`
section died with it (single tab).

**The dashboard opens with a HERO BOARD (owner, same evening)** — `hero()`: hello by name, a
five-step setup ring (WhatsApp connected / first project / a live workflow / first AI booking
/ commission value — each tile a door, next unfinished step as the CTA), flipping to
today-at-a-glance once all five are done. The commission nag row died with it. **Allocation
is not a page any more**: `/allocation` redirects to Leads `?team=incomplete` — a
LeadQueryRequest filter backed by **`CrmPipeline::scopeIncompleteTeam`** (no Engagement, or a
closing-mode role nobody holds; mirrors `resolvedClosingModeId`'s default-mode inference in
SQL). The dashboard's needs-you row counts from the same scope; staffing happens in the Team
cell's Assign modal. `AllocationController` + page deleted; the `RoutingRule` engine the
assign_agent step reads is untouched (no editor UI for now — like the call budget and the
block list).

**Setting is seven HOST pages now (owner reform, 2026-09-07 night).** The suite's own
Connections / Team / Billing trio is deleted whole (`SettingsController`, pages, routes —
`/settings/{any}` 302s to the new first tab), and the sidebar Setting entry opens
`/manage/messages/channels?suite=appointment`. Each host page mounts
**`Components/AppointmentEngine/AeSettingTabs.vue`** (a HubTabs config) INSTEAD of its usual
strip while `useSuite()` resolves `appointment` — the same v-if swap Devices already did for
the calls SectionTabs — so one page and one backend serve both suites: WhatsApp
(Channels/Tags/General sub-pills), Delivery APIs, Zoom, Meta Account, Devices, Closing Mode,
Notifications. ⚠️ Two things lost their UI with the trio and still RUN on stored data: the
`ae_connections` Google Sheets service-account key (sheet steps in link mode are unaffected;
private-sheet copy now says "use a link-shared sheet instead") and the Billing fields
(`commission_per_appointment` etc. — the hero's commission step was removed as a dead door;
the dashboard's money card still renders whenever the stored rate exists).

**Hidden-number (WhatsApp username) leads are SUPPORTED (owner, 2026-09-07 night).** A
contact whose `phone_e164` is null used to be skipped at the trigger gate — nobody worked
them at all. Now: `ae_leads.phone` is nullable and **`wa_contact_id` is the identity
anchor** (migration 2026_09_07_190000; `Lead::waContact()`). The keyword/CTWA sweep enrols
them (dedupe by contact when phoneless), `WhatsappSender` replies INTO the conversation they
started (its channel wins over the step's — a hidden number is reachable nowhere else), and
the **AI-call step skips itself** with a ledgered `Call::REFUSAL_NO_PHONE` refusal, handing
straight to the next step: the chat half of the flow is how these people get worked. The
phone-matched surfaces go dual-track (ChatTakeover stop buttons, the inbox card's lead
resolution, `leads/{id}/chat`) — phone first, contact anchor when there is none. Known gap:
`FunnelSummary::messagesSent` counts by phone, so a hidden-number lead's messages are not in
the band's WhatsApp tally.

**The suite's Conversations pages are gone too (owner, same day):** `/manage/
appointment-engine/calls` (+ `/human`, `/settings`, the blocked-list endpoints and
`CallsController` whole), and the `/whatsapp` + `/zoom` redirect aliases; the sidebar
"Conversations" entry and the SuiteTabs `calls` strip went with them. The host channel
layers (Messages / Zoom / Phone Call, entered `?suite=appointment`) are the conversation
surfaces now, and the AE dashboard's action items point there. ⚠️ Two runner mechanisms
those pages managed still RUN on their stored data but have no admin UI: the per-group
`daily_call_budget_usd` (Setting) and the `BlockedNumber` list — `AiCall` reads both on
every dial. AI Profiles keeps its `calls/ai-profiles/*` action routes (its pages live at
`ai-agent/profiles`).
**The inbox card's THREE stop buttons** (2026-09-07, owner decision — "messages and calls
stop together; they are one line"): the contact drawer's "Appointment automation" card
(`ae_automation` payload in InboxController — lead, open runs, plus `drips_running` /
`chat_running` counts of the lead's RUNNING flow runs split by `drip_completed_at`) offers,
each behind its own ConfirmModal wording:

- **Stop follow-ups** — `ChatTakeover::stopSequence`, `POST leads/{id}/stop-sequence`:
  stops every open AE run (scheduled messages AND calls — one sequence) and ends any host
  flow run still in its SCRIPTED drip phase on the lead's conversations. The AI keeps
  answering replies. Disabled when nothing is scheduled.
- **Stop AI replies** — `ChatTakeover::stopChat`, `POST leads/{id}/stop-chat`: ends every
  flow run ANSWERING as AI (drip completed) on the lead's conversations; the sequence keeps
  going. Disabled when the AI is not chatting.
- **Stop everything** — `ChatTakeover::stopForLead`, `POST leads/{id}/stop-automation`:
  both halves.

**An explicit stop is STICKY per conversation**: `begin()` refuses to open a new takeover on
a conversation whose shadow run was ended `ENDED_STOPPED` or `ENDED_HANDOFF` — otherwise the
sequence's next send would quietly re-arm the AI the admin just silenced. A booking-ended
run (`ENDED_OBJECTIVE`) does NOT silence — post-booking sends may take the chat again. There
is deliberately no un-silence control yet; re-enrolling the lead uses a fresh conversation's
runs anyway. The card shows only when the contact resolves to an AE lead; a thread with only
ordinary host flows keeps the flow-monitor panel's per-run Stop and the hand-off.

**The retired stateless hook, for history:** `plan.chat` names a **WhatsappAiProfile**
(the same brains Messages manages) and `Services\ChatBrain::resolveFor()` — hooked into
`WhatsappAiConfig::resolveFor` between the flow-run check and the channel default — takes over
any reply from a lead this workflow holds a run for, with `capture='booking'` and a booking
objective, so `[[BOOKED]]` → `WhatsappBookingCaptured` → `RecordWhatsappBooking` works with NO
host flow run (the listener's phone fallback correlates it). The listener also pulls forward
any future `resume_at` on the lead's waiting runs, so the booked chain (confirm + assign) runs
the moment a chat booking lands instead of at the next nurture tick. The old
`action.whatsapp_ai` node type still exists in the catalogue but the compiler never emits it.

Removed with the canvas: Builder.vue + BuilderCanvas.vue, the node/edge/layout routes and
controller methods; `GET ai-agent/workflows/{id}` 302s to the project tab. Pre-redesign
workflows were migrated (plan extracted from their graphs, real config kept) and recompiled.
Publish still gates on `Workflow::problems()` over the compiled graph — same sentences, now
listed above the form.

## The Workflow tab — the Builder canvas, embedded per project (2026-09-06)

The project Show page gained a **Workflow** tab that mounts the SAME drag canvas the Builder
page renders. The extraction is the ProfileDetail pattern applied twice over:
**`Workflows/Partials/BuilderCanvas.vue`** is the whole builder (palette, pan/zoom canvas,
wiring, settings drawer, problems panel, publish/delete) with a `#lead` slot for the host's
lead-in — the Builder page puts its back-link there, the tab puts its **workflow switcher**;
every write already posts to the workflow's OWN URL, so nothing depends on the mounting page.
Server side, `WorkflowsController::builderProps()` is the payload seam (public, the
`showProps()` precedent) and `ProjectsController@show` serves it through `Inertia::optional`
(`builderDetail`, keyed by `?wf=` — scoped to the deal's own workflows, a foreign uuid falls
back) plus `workflows` switcher cards (trigger badge via `triggerBadge()`). The tab frames the
deal's automation as **one workflow per ENTRY POINT**: empty state offers two cards — "Start
from a Google Sheet" (seeds the `google_sheet_calls` preset) and "Start from a WhatsApp ads
click" (`ctwa_decision`) — created via `store()`'s `stay` flag, which lands back on the tab
(`destroy()` honours the same flag). Multiple AI caller brains per project need no new
machinery: `action.ai_call`'s `profile_id` is per-step config, so two call steps in one flow
dial with two different brains. Fixed on the way through: `WorkflowsController` was missing
its `use Src\AppointmentEngine\Support\HostOptions;` import — `show()`/`updateNode()` would
have fataled on the class name resolving into the controller namespace.

**The canvas reads TOP-TO-BOTTOM in classic flowchart grammar** (owner request 2026-09-06 —
the old left-to-right beziers read as a tangle): input port top-centre, output ports along the
bottom, edges are ORTHOGONAL elbows (down, across, down; a backward retry loop routes around
the target's side) with the branch name written on the horizontal run, hand-drawn-flowchart
style. Card heights are measured from the DOM (`setNodeEl`/`hOf`) so wires leave the real
bottom edge. The toolbar gained **Auto-arrange** (`tidy()`): longest-path layering from the
trigger + barycenter sweeps + even spacing — enough Sugiyama without a library — persisted in
ONE write through `POST ai-agent/workflows/{id}/layout` (batched; unknown/foreign node uuids
skipped). `WorkflowTemplates::apply()` transposes its grid (col→Y along the flow, row→X lanes,
ROW step 170) so seeded flows land vertical from birth; pre-redesign canvases are one
Auto-arrange click away.

## Appointments — two views, one shared calendar (2026-09-06)

`/manage/appointment-engine/appointments/list` carries a **Table | Calendar** toggle (`?view=`).
Both are shapes of the SAME `appointmentQuery` scope (engine-earned rows, group-visible): the
table pages the worked list, the calendar draws one padded Mon–Sun month via
**`Manage/Calendar/Partials/MonthGrid.vue`** — extracted from `/manage/calendar`'s page for
exactly this reuse, so the two calendars are one component and every event is normalized by the
SAME `Appointment::toCalendarArray`. The day drawer + `EventDetailModal` are the CRM's own
(the drawer gained an opt-out `canCreate` prop — the engine's bookings come from the AI, so no
Create button here). Engine events have no `created_by` → they render view-only everywhere on a
calendar (`editable=false`); outcomes are recorded in the table view. Month nav = `?month=YYYY-MM`
(Carbon-guarded, malformed falls back to the current KL month).

### Search, filters and New appointment on the book (2026-09-07)

The list grew the lead book's own furniture (§14), because it is the same kind of screen:

- **`App\Http\Requests\Manage\AppointmentEngine\AppointmentQueryRequest`** — `search` (lead name,
  or phone with punctuation stripped, through `whereHas('aeLead')`), `state`, `channel`,
  `booked_by`, `project` (by **uuid**, never the id), `agent`, and a `date_from`/`date_to` range
  **on `scheduled_at`** — on this screen "last week" means last week's appointments, not last
  week's rows.
- **The `state` dimension is not just the outcome column.** How a person slices this book is
  "what still needs me / what is coming / how it ended", and only the third is an outcome:
  *needs marking off* and *upcoming* are the same un-recorded outcome told apart by the clock,
  and *cancelled* is a STATUS. All four live in one multi-select because they are one question,
  and they are mutually exclusive so the union never double-counts. Outcome states carry NAMED
  tokens (`state[]=closed`, not `state[]=4`): a JavaScript object hoists integer-like keys to the
  front of its own iteration order, so an int-keyed map renders the chips out of order however
  carefully PHP built it — the name and colour still come from `Appointment::OUTCOMES`.
- **`booked_by=human` means NOT the AI, null source included.** `AppointmentsController@store`
  writes no `source` at all, and the row already reads "Booked by a person" — a filter that
  tested `source = 'human'` would hide every hand-made booking from the label beside it.
- **One filtered scope feeds the table, the calendar AND the counters** (`$request->applyTo(...)`
  before the view branch). A calendar still showing rows the filter excluded is the same screen
  disagreeing with itself. `AppointmentsBook` navigation (view toggle, month step) therefore
  carries the CURRENT query string instead of rebuilding the URL from its props.
- **Chip counts run every filter EXCEPT their own** (`applyFiltersExcept`): counting the whole
  book beside a searched list reads as "48 more matches are hiding somewhere", and counting the
  filtered list would zero every sibling the moment one chip is clicked.
- **New appointment sits on the toggle row, in BOTH views** (owner request: the table had no way
  in). It is the same shared `AppointmentFormModal` the calendar day drawer opens — with no day
  clicked, so the date is typed rather than implied. The modal posts to `/manage/appointments`,
  whose store **stamps `ae_lead_id` from the chosen CRM lead**, which is why a hand-made booking
  appears in this engine-only book at all; one made for a CRM lead with no AE row lands in the
  CRM book alone (the button's title says so).

## Two call ledgers, one AI Calls list (2026-09-08)

**The bug this closed.** The engine writes its calls to `ae_calls`; the CRM's own AI caller writes
`ai_voice_calls`. `Support\AiCallerRow` merged the two on the LEAD page back on 2026-09-06 — but
the LIST at `/manage/calls/ai-calls` still read the CRM's table alone, and nothing said so. That
was invisible until the suite's own Conversations pages were removed (2026-09-07) and Channel →
Phone Call was pointed at that page: from then until now the Appointment Engine had **no screen
anywhere** showing its own calls (5 real calls, 4 of them answered, 71 transcript turns, all
unreachable), and the Dashboard's *Leads we declined to call* counted `ae_calls.refusal_reason`
while linking to a page that could never display one. `?suite=appointment` never filtered
anything on that page — it is a nav token (`useSuite`), not a scope.

**`Src\AppointmentEngine\Support\AiCallBook`** now owns the merged book. It lives in the ENGINE's
namespace for the same reason `AiCallerRow` does: this is the module that knows about both, and
the dependency runs AE → VoiceAgent, never back.

- **The DATABASE pages the union, not PHP.** Two projections of the same five sort columns
  (`ledger`, `row_id`, `sort_created`, `sort_duration`, `sort_cost`) are `unionAll`ed and the
  outer query orders, counts and pages that — so memory stays bounded however large either
  ledger grows, and the total is a real total rather than one book's. Only the resulting page's
  ids are hydrated, each through its own presenter, then re-ordered to match. Ordering carries
  `ledger` + `row_id` as final tiebreakers: rows tying on `created_at` **across two tables** are
  otherwise free to swap places between page 1 and page 2, and a call appears twice or vanishes —
  the same hazard the export rules name for `FromQuery` chunking.
- **The two status vocabularies are the SAME integers** (1 pending … 7 failed), which is what lets
  one status filter and one set of chips be honest across both. Keep them that way. The engine
  adds `0 = Not called`, which the CRM ledger has no value for.
- **Refused rows ARE listed here, unlike on the lead page.** `AiCallerRow::forCrmLead` excludes
  them because that tab is a conversation log — a call the AI declined to place said nothing to
  anybody. This page is an operations queue, where "we did not call these twelve people, and why"
  is what a person comes to find. They arrive under `Not called`, carrying the refusal reason in
  the `disconnection_reason` slot, so no refusal can be read as a conversation. The Dashboard's
  link now lands on them: `…/ai-calls?suite=appointment&status[]=0`.
- **Filters are stated twice, on purpose.** `AiCallQueryRequest` states them for `ai_voice_calls`;
  `AiCallBook::applyEngineFilters` states them for `ae_calls`. They cannot share an implementation
  — the CRM reaches a lead's name through `lead.user.profile.full_name`, the engine holds it on
  `ae_leads.name` — so each names the other in its docblock: change either, change both.
- **Scoping mirrors the page's own.** The engine half applies `GroupScope` on `ae_calls.group_id`
  plus, below full lead visibility, `whereHas('lead.crmLead', LeadVisibility::apply)`. An unbridged
  engine lead belongs to nobody's scope, so it stays visible to a full-visibility admin and hidden
  from a scoped one — exactly what the CRM half already does with its `lead_id IS NULL` rows.
- **Block/unblock writes to the list belonging to the caller that placed the call** —
  `ae_blocked_numbers` for an engine row, `ai_call_blocked_numbers` for a CRM one. Each dialler
  checks only its own list before dialling, so a block written to the other is a promise nothing
  reads. Unblocking compares DIGITS, the same rule `BlockedNumber::blocks()` uses.
- **Off is off.** Everything is gated on `features.appointment_engine_enabled`; with the suite
  disabled the page reads the one table it always did, down to the status list (verified).
- Both presenters now emit a `ledger` key (`AiCallPresenter::LEDGER_CRM` / `LEDGER_ENGINE`, the
  vocabulary living on the class that owns the row shape). The row shapes are **key-for-key
  identical**, so one Vue component renders either; the page only uses `ledger` to tone the
  profile chip and to say which caller placed the call.

## The engagement bridge — one team, one closer (2026-09-06)

The FOURTH convergence: an engine lead's sales pipeline IS a CRM **Engagement** (one row per
CRM lead × CRM project; roles in `engagement_assignments`, commission split derived from them).
`Src\AppointmentEngine\Services\CrmPipeline` is the resolver: **`ensureOpen()`** opens the
engagement the moment both bridges exist — called from the tail of `CrmIdentity::ensureLinked`
(OUTSIDE its already-linked early-return, since the project bridge can appear later), so every
birth/touch site gets it for free. It respects a soft-deleted engagement (an admin who removed
the lead from the pipeline stays obeyed) and never bumps `last_activity_at` on mere views
(exists-check before `EngagementRepository::open`). Backfill: `php artisan ae:link-pipelines --dry`.

**The engine's single "agent" maps onto the `closer` role** (`CrmPipeline::AGENT_ROLE`, owner
decision 2026-09-06) and syncs BOTH ways: the workflow's AssignAgent handler and the Allocation
screen call `syncCloser()`; `EngagementRepository::assign` gained a fail-soft AE cascade
(`syncAppointmentEngineAgent`) that writes a changed closer back to `ae_leads.assigned_admin_id`
and fills the lead's still-unassigned appointments (never overwriting a made pick). No loop:
the cascade re-writes the same holder syncCloser sent.

**Frontend**: both AE tables' Agent column is now the CRM's own **`TeamRolesCell`** (role
avatars) opening the CRM's own **`AssignEngagementModal`** (`PUT /manage/engagements/{uuid}/assign`
— LeadVisibility-gated, returns `back()`), gated on `can('manage-projects')` like the sales
pipeline. Rows carry `engagement` (built by `CrmPipeline::card`, assignments via the new shared
`Src\Engagement\Support\AssignmentCards` — the CRM's two `assignmentCards()` now delegate to it);
controllers ship `closingModes` / `pipelineRoles` / `assignableAdmins` via the CRM's own
`SharesClosingConfig` + `ResolvesAssignableManagers` concerns, and fetch each page's engagements
in ONE query (`CrmPipeline::mapForLeads`). An unbridged lead falls back to the single-agent text.

**Status flows one way through three layers** (owner decision 2026-09-06): the engine's
`ae_leads.stage` stays the MACHINE dial (workflow conditions, retry ladder, funnel counts read
it); the ENGAGEMENT's status is THE pipeline record; the CRM lead's Status stays the read-only
roll-up it already was (`SyncLeadStatusFromEngagements`). `CrmPipeline::advanceStatus()` is the
machine's only pen, and it is MONOTONIC + deferential: first AI contact → **Contacting** (wired
at the three `first_contacted_at` writers: AiCall, WhatsappSender, WhatsappAiTakeover), a booked
appointment → **Appointment Set** (both booking writers), and **Lost only from a `mark_lost`
end node while the deal is still New/Contacting** — a dead number may kill a deal, a person's
give-up may not, and nothing the machine writes ever pulls back a deal a person advanced past
it (Booked/Following Up/Converted/Lost are untouchable). Writes go through
`ChangeEngagementStatus` → `changeStatus`, so lost_at/won_at semantics and the CRM roll-up come
free. `ae:link-pipelines` also catches statuses up (appointment → Appointment Set, contacted →
Contacting; a LOST stage is deliberately not mirrored — dead-number vs give-up is unknowable
after the fact).

The whole book is **`Partials/AppointmentsBook.vue`** — the LeadsTable pattern applied to
bookings, grown to both views: ONE component (toggle + calendar + table), two hosts. The
Appointments page mounts it with `show-project` (whole book, every row names its deal, linked);
a project's own `?tab=appointments` mounts it with the column off (rows pre-filtered to the
deal) and `extraQuery: { tab: 'appointments' }` so the toggle/month nav never lose the tab.
The table inside is `Partials/AppointmentsTable.vue`; both hosts are fed the SAME row shape by
`Src\AppointmentEngine\Support\AppointmentRow`, the SAME worked-list order (past-and-unrecorded
first) and the SAME month payload by `Src\AppointmentEngine\Support\AppointmentCalendar`
(?view=calendar&month= on either URL). The project tab pages by **`?apage`** so paging
appointments never re-pages the leads tab, and its outcome buttons post to the same Showroom
recorder (which returns `back()`, so recording works from either page). Booking writers
(`AiCall::bookIfPromised`, `RecordWhatsappBooking`) stamp **`project_id`** from the RUN's
workflow project — the lead's own project only as the fallback (2026-09-18: a phone-deduped lead
is never moved between projects, so stamping from the lead booked a second project's call under
the first) — that is what makes BOTH calendars name the project (`toCalendarArray` →
`project_name`).

## Recorded visits retired; Channel section; Appointments-first (2026-09-06)

The `ae_visits` recording subsystem is GONE — page, CRUD routes, `ProcessShowroomVisit` job, the
`Visit` model and the three workflow node types (`human.record_visit`, `ai.analyse_visit`,
`human.follow_up`, plus `WAIT_FOLLOW_UP`) — recordings live in the host channel layers instead
(Showroom F2F / Zoom / Phone Call), which the AE sidebar now exposes under a **CHANNEL** section
(Messages / Zoom / Phone Call / Showroom F2F, `?suite=appointment`, host permissions; no Portal —
engine leads are not portal members; Conversations' prefixes narrowed so the two never light
together). Templates that seeded the trio now edge `attended --yes--> closed`. The `ae_visits`
TABLE remains (empty; drop later). Sidebar **Appointments now lands on the list**
(`/appointments` 302s to `/appointments/list`); the stats dashboard moved to
`/appointments/overview`, second tab.

## One outcome function (standardised 2026-09-06)

Every appointment outcome, from every surface, is written by **ONE function** —
`AppointmentRepository::recordOutcome()` — which owns the whole contract: the cancelled rule
(a cancelled appointment may not carry an outcome; enforced with an exception so no caller can
forget it) and the engine cascade (attended → AE lead to *showed up*; any recording wakes runs
parked on `WAIT_OUTCOME`) — wherever the click came from: the AE list, the CRM lead tab, or the
calendar modal (this closed a real split-brain: a CRM-side recording used to leave AE runs
parked forever). The AE list now speaks the **full CRM vocabulary** (Attended / No Show /
Follow-up Needed / **Closed** = converted / Not Closed, `Appointment::OUTCOMES`, colours
included — emerald is reserved for Closed, the outcome that means money); **Cancelled is the
separate STATUS fact** with its own toggle, which clears any outcome first. `Booking`'s dialect
remains only for logic checks (`aeOutcome`/`outcomeRecorded` in conditions and funnels).

## Project convergence Phase 1 (2026-09-06)

`ae_projects.project_id` (nullable, indexed) bridges each deal to **THE project** — the CRM
`projects` row (`Src\Property\Project`) owning commission rate/basis, prices, the catalogue
link and the whole engagements/bookings pipeline. `Src\AppointmentEngine\Services\CrmProject`
resolves it: name match within the group (trimmed, case-insensitive), else MINT through the
sales side's own `SalesProjectRepository::create` (origin custom, status active, commission
honestly blank for a human to set on Sales Projects). Wired at AE store() (born bridged),
show() (link-on-view), update() (a rename follows through — one concept, one name). Backfill:
`php artisan ae:link-projects` (`--dry`). Surfaces: the AE hub row's sub-line and the project
page header carry the commission chip (emerald when set; amber "No commission rate yet" when
blank — both link to `/manage/sales-projects/{uuid}`). Phase 2 done (2026-09-06): the AE project page's tab strip carries a trailing
**Sales & Bookings** link-tab (ShowTabs' `#tabs-end` slot, shown only when bridged) to
`/manage/appointment-engine/projects/{uuid}/sales`, which DELEGATES to the CRM
`SalesProjectsController@show` — same `Manage/SalesProjects/Show` component at the AE URL, with
three prop overrides: `suite`, `selfUrl` (so the pipeline table's sorting/filtering round-trips
to the AE URL instead of walking back to the CRM page — its `useResourceIndex` baseUrl now
honours it) and `backUrl`. Delete lands back on the AE hub in this suite. Gates: bridged +
`VIEW_PROJECTS` (polite redirect otherwise); the CRM show's own GroupScope check still runs.
