# AI Appointment System (Appointment Engine)

**Portal:** Manage · **Routes:** `manage.appointment-engine.*` (`/manage/appointment-engine/*`) · **Nav:** its own **suite** — picked on the Hub (`/manage/dashboard`), then a sidebar of Dashboard / Projects / Leads / Appointments + a Channel section + Team + Setting · **Namespace:** `Src\AppointmentEngine` · **Runner:** `ae:run-workflows`, every minute

> This is the module's front door. Four companion docs carry the detail:
>
> | Doc | What it answers |
> |---|---|
> | [data-model.md](/docs/modules_handbook/manage/appointment-engine/data-model.md) | Every table, column, constant and relationship |
> | [bridges.md](/docs/modules_handbook/manage/appointment-engine/bridges.md) | How an AE lead, an AI call, an appointment, a CRM lead and a WhatsApp conversation are tied together |
> | [runner.md](/docs/modules_handbook/manage/appointment-engine/runner.md) | The backend logic: intake → plan/compiler → runner → handlers → closer rotation → scoping |
> | [surfaces.md](/docs/modules_handbook/manage/appointment-engine/surfaces.md) | Every route, controller and screen |
> | [services.md](/docs/modules_handbook/manage/appointment-engine/services.md) | The read-models behind every figure, the voice integration, connections and the knowledge pipeline |
> | [changelog.md](/docs/modules_handbook/manage/appointment-engine/changelog.md) | What was built, changed and deliberately removed, with dates |
>
> Also here: [ai-agent/readMe.md](/docs/modules_handbook/manage/appointment-engine/ai-agent/readMe.md) — the workflow **builder** in depth (node catalogue, the plan editor, the Google Sheet trigger, presets) — and [spec.md](/docs/modules_handbook/manage/appointment-engine/spec.md), the original v1.0 build brief, kept for its product reasoning (historical: several screens it describes were later retired).

## What it does

It replaces the human labour between *a lead arriving* and *an appointment in the diary*. A Malaysian property agency's lead lands from a Google Sheet, a WhatsApp ad, a keyword message or a Meta lead form; the engine then **calls them with an AI voice agent**, **follows up on WhatsApp with approved templates**, lets an **AI chat brain answer whatever they write back**, and — the moment a specific day and time is agreed on either channel — **books the appointment, hands it to a closer, confirms it and reminds them before it**.

The commission logic behind it: an agency's split is roughly ads 40% / appointment-setting 20% / closing 40%. This product automates the middle 20%, so the agent leader keeps it.

Everything is **per agency** (`group_id`) and, inside an agency, splittable **per team**. A platform super-admin chooses which agency they are working in on entering the suite; from then on the whole suite reads and writes as that agency — see [Agency-first](/docs/modules_handbook/manage/appointment-engine/runner.md#agency-first--support-aescope).

## How it works

Eight moving parts, in the order a lead meets them:

1. **A trigger puts the lead in the book.** `Runner\Triggers` sweeps every minute: a new Google Sheet row, a WhatsApp keyword, a click-to-WhatsApp ad, a Meta lead form — plus manual add and CSV import from the Leads screen, and (2026-09-10) the project's **Subsale owner listing**, which nothing polls: a person starts an `owners` flow for chosen owners on the project's Owners tab (`Triggers::startOwner`), paced, and that tab shows every owner's run with Pause / Resume / History. Each becomes an `ae_leads` row (deduped by phone) and, if the workflow is live, a `ae_workflow_runs` row.
2. **A plan describes the automation; a compiler turns it into a graph.** An admin edits one **plan document** (`Support\Plan`) on the project's Workflow tab — a flat list of steps plus the always-on chat brain and the once-booked settings. `Services\PlanCompiler` rebuilds the real `ae_workflow_nodes` / `ae_workflow_edges` graph from it on every save, and the branching rules (what happens when a call is not answered, when an appointment is already booked, when a number is dead) are **fixed in the compiler**, not drawn by hand.
3. **The runner walks the graph.** `ae:run-workflows` picks up due runs → `AdvanceWorkflowRun` → `Runner\WorkflowRunner` → the node's handler → a `StepResult` (next / wait / park / end / fail). A run parks on an external event (a call settling, a booking landing) and is woken by whatever produces it.
4. **The AI caller dials.** `Runner\Handlers\AiCall` places the call through the platform's one entry point, `App\Actions\PlaceAiVoiceCall` (the shared Retell credential), records every attempt and every *refusal* (quiet hours, daily budget, blocked number, no phone) on the shared `ai_voice_calls` ledger with the engine's own columns (`ae_lead_id`, `group_id`, `refusal_reason`, `outcome`, `lead_spoke`), and retries on a per-attempt ladder. The host Retell webhook settles the call; `Services\EngineCallHooks` wakes the parked run, and the handler reads the appointment out of the analysis. (Until 2026-09-09 the engine kept its own `ae_calls` and its own webhook door — see the changelog.)
5. **WhatsApp follows up, and the chat brain answers.** `Runner\WhatsappSender` sends approved templates through the host WhatsApp module; `Services\ChatTakeover` opens a real host **flow run** of the workflow's hidden **shadow flow**, so the plan's brain answers replies with the plan's objective and a booking-capture token — the host's own AI machinery, not a copy.
6. **A booking is captured, wherever it happens.** From the call (analysis field) or from the chat (`[[BOOKED: …]]` token → event → `RecordWhatsappBooking`), one `appointments` row is written — the CRM's own table since the 2026-09-06 merge — and the run is resumed onto the booked chain.
7. **A closer is assigned.** `Services\CloserRotation` picks from the project's per-agency closer list, filters (Zoom booking → needs Zoom; no clash within the hour; nobody who already passed), rotates round-robin by the hand-off ledger, and offers it on Telegram with **Accept / Pass** links and a clock.
8. **The confirmation goes out, and the reminder is queued.** `Runner\Handlers\EndBooked` makes the Zoom meeting if the booking is a Zoom one, writes the venue tokens, sends the confirmation template and schedules the reminder N hours before.

Around those eight: the **project page** is where a deal's leads, appointments, workflow and closers live; the **dashboard** is the sales leader's seat with a grounded AI analyst; the **inbox** shows any lead's live position in its flow.

### What it is NOT

- It is **not the CRM**. `ae_leads` is its own lead book — the one engine table that is deliberately *bridged* to the CRM (`ae_leads.lead_id`) rather than merged into it, because `leads` is one row per human platform-wide while the engine dedupes per agency, and the identity resolver refuses to force-merge an unverified phone onto someone's account: see [bridges.md](/docs/modules_handbook/manage/appointment-engine/bridges.md). Everything else the engine once duplicated — projects, calls, appointments, the do-not-call list — lives in the platform's own table since the 2026-09-09 tidy.
- It is **not a second WhatsApp stack**. Every send, template, conversation and AI reply is the host [WhatsApp](/docs/modules_handbook/manage/messages/whatsapp/readMe.md) module's; the engine only decides *what to send and when*.
- It is **not a second voice stack**. Calls are placed through the platform's `PlaceAiVoiceCall` on the shared Retell credential and land on the platform-wide `ai_voice_calls` ledger; the engine's rows are the ones carrying an `ae_lead_id`.
- It is **not a second project book**. A deal is a CRM `projects` row (`Src\Property\Project`); the engine hangs its workflows, content, documents and closer lists off it.

## Related files

**Backend — models** (`src/AppointmentEngine/`)
`Lead.php` · `ProjectCloser.php` · `Workflow.php` · `WorkflowNode.php` · `WorkflowEdge.php` · `WorkflowRun.php` · `WorkflowRunLog.php` · `CloserHandoff.php` · `SheetCursor.php` · `ContentItem.php` · `Document.php` · `NodeCatalogue.php` · `WorkflowTemplates.php` — plus the platform models the engine writes directly since 2026-09-09: `Src\Property\Project` (`aeWorkflows()`, `aeLeads()`, `aeContentItems()`, `aeClosers()`), `Src\VoiceAgent\AiVoiceCall` (`aeLead()`, `channel`, `refusal_reason`, `outcome`, `lead_spoke`), `Src\VoiceAgent\AiCallBlockedNumber` (`group_id`), `Src\Appointment\Appointment`

**Backend — the runner** (`src/AppointmentEngine/Runner/`)
`WorkflowRunner.php` · `Triggers.php` · `HandlerRegistry.php` · `NodeHandler.php` · `StepResult.php` · `Text.php` · `Notify.php` · `WhatsappSender.php` · `Handlers/` (`AiCall`, `SendWhatsapp`, `WhatsappAiTakeover`, `AssignAgent`, `Wait`, `ZoomInvite`, `SendEmail`, `NotifyTeam`, `Qualify`, `SetStage`, `RecordOutcome`, `ConditionAnswered`, `ConditionAttended`, `ConditionBooked`, `ConditionCallOutcome`, `ConditionField`, `ConditionReplied`, `EndBooked`, `EndStop`, `Passthrough`)

**Backend — services** (`src/AppointmentEngine/Services/`)
`PlanCompiler.php` · `ChatTakeover.php` · `ShadowFlow.php` · `CloserRotation.php` · `LeadEraser.php` · `ZoomMeetings.php` · `EngineCallHooks.php` · `RunControl.php` (pause / resume one run) · `ProfileKnowledge.php` · `DocumentReader.php` · `CrmIdentity.php` · `CrmPipeline.php` · `Sheets/` (`SheetRef`, `SheetReader`, `SharedLinkSheetReader`, `ServiceAccountSheetReader`, `SheetReadException`)

**Backend — support** (`src/AppointmentEngine/Support/`)
`Plan.php` · `AeScope.php` · `Booking.php` · `FunnelSummary.php` · `ProjectFunnel.php` · `RunProgress.php` · `OwnerBoard.php` (the Owners tab's payload) · `LeadPresenter.php` · `LeadEngagement.php` · `LeadRowMapper.php` · `AppointmentRow.php` · `AppointmentCalendar.php` · `HostOptions.php`

**Backend — controllers** (`app/Http/Controllers/Manage/AppointmentEngine/`)
`DashboardController.php` · `ProjectsController.php` · `OwnersController.php` (the Subsale owner listing: the question after create, the delegated listing page, start / pause / resume per owner) · `LeadsController.php` · `WorkflowsController.php` · `ShowroomController.php` · `AgencyController.php` · `HandoffsController.php` · `AiProfilesController.php` · `ContentController.php` · `IngestController.php` · `ConsoleController.php`
Webhooks: none of its own since 2026-09-09 — the platform's `Webhooks\RetellWebhookController` (`POST /webhooks/retell/voice`) settles the engine's calls and `AiCallAftermath` hands them to `Services\EngineCallHooks`
Requests: `app/Http/Requests/Manage/AppointmentEngine/` (`LeadQueryRequest`, `AppointmentQueryRequest`, `DashboardChatRequest`)
Middleware: `app/Http/Middleware/EnsureAeAgencyChosen.php` (alias `ae.agency`)

**Backend — jobs, commands, listeners**
`app/Jobs/AppointmentEngine/` (`AdvanceWorkflowRun`, `SendWorkflowWhatsapp`, `ExpireCloserHandoff`, `ClassifyDocument`, `SyncProfileKnowledge`) · `app/Console/Commands/AppointmentEngine/` (`RunWorkflows`, `LinkLeads`, `LinkPipelines`) · `app/Listeners/AppointmentEngine/RecordWhatsappBooking.php`

**Frontend** (`resources/js/Pages/Manage/AppointmentEngine/`)
`Dashboard.vue` · `Agency.vue` · `Projects/Index.vue` · `Projects/Show.vue` · `Projects/OwnerListing.vue` (the import-owners question after create) · `Projects/Partials/` (`WorkflowTab`, `OwnersTab`, `ClosersModal`, `SheetLinkModal`, `BookedChainCard`, `TemplatePreview`) · `Leads/Index.vue` · `Leads/Show.vue` · `Showroom/Appointments.vue` · `Showroom/Dashboard.vue` · `Workflows/Index.vue` · `Content/Index.vue` · `Ingest/Index.vue` · `Console.vue` · `Calls/AiProfiles/` (Index, Show, Partials) · `Partials/` (`LeadsTable`, `AppointmentsBook`, `AppointmentsTable`, `FunnelSummaryBand`, `LeadFormModal`, `AiAgentTabs`, `SuiteTabs`)
Shared strips: `resources/js/Components/AppointmentEngine/` (`AeSettingTabs.vue`, `AeTeamTabs.vue`)

**Migrations** — `database/migrations/2026_08_29_*` (the original eleven: engine tables, calls, threads, appointments, content, documents, connections, settings, routing rules, flow nodes), the 2026-08-30 → 2026-09-08 additions (workflow tables, sheet cursors, the CRM bridge columns, closer hand-offs, project closers, `team_id`), and the four `2026_09_09_*` tidy migrations that dropped the dead tables and merged `ae_projects`, `ae_blocked_numbers` and `ae_calls` into the platform's own (the [changelog](/docs/modules_handbook/manage/appointment-engine/changelog.md) entry for 2026-09-09 is the map). The engine's tables today: `ae_leads`, `ae_workflows`, `ae_workflow_nodes`, `ae_workflow_edges`, `ae_workflow_runs`, `ae_workflow_run_logs`, `ae_content_items`, `ae_documents`, `ae_project_closers`, `ae_closer_handoffs`, `ae_sheet_cursors`. Full history in [data-model.md](/docs/modules_handbook/manage/appointment-engine/data-model.md).

**Config / permissions** — `config/appointment_engine.php` (Meta webhook secrets, the Google Sheets service-account key for private sheets, the engine's own voice budget + pacing, Zoom meeting length; the Retell credential itself is the platform's, on Messages → Settings → Delivery APIs) · permissions `view-appointment-engine` / `manage-appointment-engine` (+ the shared `view-appointments` / `manage-appointments`), granted in `app/Actions/SeedCommonRolesAction.php`.

**See also:** [WhatsApp](/docs/modules_handbook/manage/messages/whatsapp/readMe.md) (every send and the AI brain) · [Voice agent](/docs/modules_handbook/shared/voice-agent/readMe.md) (the caller) · [Zoom](/docs/modules_handbook/manage/zoom/readMe.md) (meetings) · [Notify](/docs/modules_handbook/shared/notify/readMe.md) (`ae.*` Telegram alerts) · [Groups](/docs/modules_handbook/manage/people/groups/readMe.md) (the agency/team structure) · [Engagement](/docs/modules_handbook/manage/engagement/readMe.md) (the CRM pipeline the engine writes into).
