How an inbound message becomes a reply, and how the scheduling engine decides what slots to offer. Diagrams reflect the code as it stands (apps/pipeline apps/server packages/db). Amber notes mark the gaps behind the two live failures (KL Chan, Gabriel).
Four apps share one Neon Postgres. apps/server owns the database; the chatbot (apps/pipeline) is DB-free and reaches data only through server endpoints. The response brain calls tools; the worker fetches "turn scope" (who the customer is) before the models run.
flowchart LR
cust(["Customer · WhatsApp"]) --- meta["Meta Cloud API"]
subgraph PIPE["apps/pipeline · 3002 · DB-free"]
wh["webhook.ts
verify + dedupe"]
q[("BullMQ · Redis")]
wk["worker.ts
orchestrate turn"]
tr["triage.ts
qwen3-30b"]
rb["response.ts
brain · gpt-4o-mini
+ tool loop + validator"]
end
subgraph SRV["apps/server · 3001 · DB owner"]
tool["/ai-tool endpoints/"]
svc["services:
turn-scope · KB
findAvailableSlots"]
end
db[("Neon Postgres
conversations · messages
appointments · bays
service_items · knowledge")]
dash["apps/dashboard · 3000
staff inbox"]
meta --> wh --> q --> wk
wk --> tr --> rb
wk -- "turn scope" --> tool
rb -- "tool calls" --> tool
tool --> svc --> db
rb -- "reply" --> meta
dash --> db
meta --- cust
The path a single message takes. Each model runs once per turn; the brain may loop through tools before it replies. The amber points are where context is missing or unchecked — the root of the failures.
sequenceDiagram autonumber actor C as Customer participant M as Meta API participant W as pipeline webhook participant K as worker participant T as triage (qwen) participant B as brain (gpt-4o-mini) participant S as server /ai-tool participant E as findAvailableSlots participant D as Postgres C->>M: "book oil + brake, tmr afternoon" M->>W: webhook (verify, dedupe) W->>K: enqueue + dispatch K->>S: get turn scope S->>D: customer, vehicles, history K->>T: classify + detect language + entities T-->>K: booking · lang=en · serviceHints · "tomorrow" Note over K,B: context = system + language + customer + KB + history + msg
NO current date/time · GAP G3 K->>B: build context + run tool loop B->>S: check_availability {date:"tomorrow", serviceHints} Note right of S: tool args have NO time-of-day field · GAP G2 S->>E: findAvailableSlots E->>D: hours, bays, appts, service durations E-->>S: first 3 slots from earliest open · GAP G1 S-->>B: 09:00 · 09:30 · 10:00 Note over B: validator checks IDs/prices/"booked"
NOT the offered slots · GAP G5 B-->>M: "only morning slots tomorrow" M-->>C: reply (afternoon was free)
findAvailableSlots()The whole "do we have a slot" decision lives in one function (packages/db/src/services/slot-proposal.ts). It walks forward in time and returns the first 3 slots it finds. A time is available if any of the 4 bays is free for the full job duration. Defaults: lead 120 min, horizon 60 days, granularity 30 min, slot limit 3.
flowchart TD A([check_availability
date? · serviceHints? · plate?]) --> B["Load org
timezone · business hours
lead 120m · horizon 60d · gran 30m"] B --> C["Sum service durations
oil 60 + brake 90 = 150m
read bookingWindows · maxPerDay"] C --> D["Load active bays = 4"] D --> E["Load appointments in window"] E --> F["Start day = earliest bookable
(or resolved dateHint if later)"] F --> G{"slots < 3
AND within horizon?"} G -- no --> Z([return slots]) G -- yes --> H{"day open
not holiday
not at daily cap?"} H -- no --> N["next day"] --> G H -- yes --> I{"step +30m from open
job fits before close?"} I -- no --> N I -- yes --> J{"within service
bookingWindows?"} J -- no --> I J -- yes --> K{"any of 4 bays
free for 150m?"} K -- no --> I K -- yes --> L["add slot"] --> Mq{"have 3 slots?"} Mq -- yes --> Z Mq -- no --> I
date — there is no "afternoon" or "2pm". A customer's preferred window can't be expressed, so the engine answers a different question than the one asked.Scheduling is a resource problem: bays × time × job duration, constrained by hours and rules. These are the tables/fields the engine touches. Workshop rules (hours, lead, horizon, granularity) live in Organization.settings JSON; per-service rules live on the service item.
erDiagram
ORGANIZATION ||--o{ BAY : "has"
ORGANIZATION ||--o{ SERVICE_ITEM : "offers"
ORGANIZATION ||--o{ APPOINTMENT : "owns"
BAY ||--o{ APPOINTMENT : "assigned to"
APPOINTMENT ||--o{ APPOINTMENT_SERVICE : "includes"
SERVICE_ITEM ||--o{ APPOINTMENT_SERVICE : "listed in"
ORGANIZATION {
json settings "businessHours per day · booking{lead,horizon,gran} · timezone"
}
BAY {
string name
bool isActive "4 active"
}
SERVICE_ITEM {
int estimatedDurationMin "how long it blocks a bay"
json bookingWindows "time-of-day limit (unused here)"
int maxPerDay "daily cap (unused here)"
}
APPOINTMENT {
datetime startTime
datetime endTime
uuid bayId "which bay"
}
APPOINTMENT_SERVICE {
uuid serviceItemId
}
Two live chats, one shared root: the tool can't express what the customer wants, and the brain has no time sense or reality-check to compensate. Layer tells you where the fix belongs.