# 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 5). 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. First sync with `on_first_sync = new_only` (default) **baselines**
  — old rows are never rung the moment a workflow goes live; `enrol_all` brings them in.
  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).
- **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.
