GHL → OpenAI SMS Orchestrator

Multi-tenant service that turns tagged inbound GoHighLevel messages into governed OpenAI replies sent back to the same conversation.

v1.0.0Updated Jul 11, 2026, 11:34 AM UTC

GHL → OpenAI SMS Orchestrator

One-liner: A multi-tenant Next.js service that watches GoHighLevel (GHL) for inbound customer messages, and — only for contacts carrying a configured trigger tag — generates a governed AI reply with OpenAI and sends it back into the same GHL conversation.

This page is the canonical, code-audited reference for this service. It is written for a brand-new employee: it explains what the program is, why it exists, exactly what triggers it and where its data comes from, how it works step-by-step (mapped 1:1 to the code), how to operate it, how to troubleshoot it, and how to extend it.

Everything below describes what the code actually does today, not what it was originally intended to do. Where the legacy README.md disagrees with the code, this page notes the discrepancy explicitly.


Live sync feed (for the central hub)

This page is exposed as a machine-readable feed so a central "brain" hub can mirror it.

  • Feed URL: <deployed base>/api/directory (e.g. https://<your-vercel-domain>/api/directory)
  • Page id: ghl-openai-orchestrator
  • Source file: docs/wiki/ghl-openai-orchestrator.md
  • Implementation: app/api/directory/route.ts

Consumer contract

GET /api/directory returns JSON:

{
  "id": "ghl-openai-orchestrator",
  "title": "GHL → OpenAI SMS Orchestrator",
  "summary": "Multi-tenant service that turns tagged inbound GoHighLevel messages into governed OpenAI replies sent back to the same conversation.",
  "version": "1.0.0",
  "content_hash": "sha256:<hex of the markdown bytes>",
  "generated_at": "<ISO-8601 timestamp>",
  "source": {
    "repo": "benjamin1160/openai-wrapper",
    "path": "docs/wiki/ghl-openai-orchestrator.md",
    "service_url": "<deployed base>/api/directory"
  },
  "format": "markdown",
  "markdown": "<full file contents>"
}

How a consumer (the hub) should poll it:

  1. Poll on a schedule (e.g. every few minutes). Send the last-known hash in If-None-Match: "sha256:<hex>". If nothing changed you get 304 Not Modified (no body) and can skip the rest.
  2. On 200, compare content_hash (equivalently the ETag header, which is the same sha256:<hex> string) against your stored copy.
  3. If it differs, replace your stored copy with the new markdown and record the new content_hash.
  4. version only changes when the contract / structure changes — day-to-day wording edits move content_hash but leave version at 1.0.0. Diff on content_hash, not on version.

Other supported behaviors:

  • GET /api/directory?format=md returns the raw file as text/markdown (same ETag, also honors If-None-Match).
  • Auth (optional): the endpoint is public by default. If the environment variable DIRECTORY_FEED_TOKEN is set, every request must present it as Authorization: Bearer <token> or ?token=<token>. The comparison is constant-time. Missing/blank → 401; wrong value → 403.

1. What this program is

This is not a chatbot widget. It is an AI orchestration layer that sits between GoHighLevel (a CRM / marketing platform used by mobile-home dealers, modular builders, and community/park operators) and OpenAI.

  • It is a Next.js 15 (App Router) application deployed on Vercel.
  • It is multi-tenant: one deployment serves many GHL sub-accounts ("locations"), each called a tenant in our schema.
  • For each inbound customer SMS/DM it assembles a tightly-governed prompt (master behavior script + compliance filter + per-tenant facts + task list + conversation history), calls OpenAI, runs the draft through guardrails and a compliance middleware, and — if it passes — sends the reply back into GHL.
  • It also runs nurture/follow-up automations (short-term follow-ups, multi-step sequences, and long-term nurture) on a cron schedule.
  • It gives operators an admin dashboard and clients a client portal with kill switches, prompt/task config, audit logs, and per-conversation drill-down.

Package name: ghl-openai-orchestration (package.json).

2. Why it exists

Dealers get a high volume of inbound SMS leads and cannot answer them all quickly, especially after hours. A raw "let the LLM reply to everyone" approach is legally and operationally dangerous (TCPA/A2P rules, price/financing claims, off-brand promises). This service exists to make automated replies safe and governed:

  • Opt-in per lead via a GHL tag, so the AI never replies to a contact who hasn't been explicitly enrolled.
  • Layered, non-editable guardrails (a hardcoded compliance filter that can't be removed via any UI, plus pre/post guardrails and a compliance judge).
  • Multiple kill switches (per-lead, per-tenant, per-corporation, and a global webhook kill switch) so a human can stop the machine instantly.
  • Full auditability — every webhook, gate decision, OpenAI call, tool call, and delivery is written to audit_logs.

3. What triggers this service, and where its data comes from

READ THIS SECTION CAREFULLY. The single most common documentation error is mis-stating the trigger. The prose, the diagram, and the step list below are all kept in agreement with each other and with the code.

3.1 The trigger (precise)

The only thing that causes this service to generate and send an AI reply is:

An inbound message event from GHL for a contact whose GHL tags include the tenant's configured trigger tag (default ai-enabled) and do not include the disable tag (default ai-disabled).

Concretely:

  1. GHL Marketplace POSTs a signed webhook to POST /api/integration/webhooks (app/api/integration/webhooks/route.ts).
  2. That handler only acts on message events. It classifies the event with isInboundMessageEvent / isOutboundMessageEvent (lib/ghl/webhooks.ts). The recognized inbound type strings are exactly InboundMessage and inbound_message (INBOUND_MESSAGE_TYPES).
  3. Every other event type — ContactUpdate, OpportunityUpdate, ConversationUnreadUpdate, ConversationProviderUpdate, bookings, etc. — is dropped without doing AI work (returns {"status":"ignored"}). It is not a trigger.
  4. Recognized message events are not processed inline. The webhook only enqueues a row into the pending_webhooks table and returns 200 immediately. The actual AI pipeline runs later in the cron POST /api/cron/process-pending-webhooks (every minute).
  5. In that cron (app/api/cron/process-pending-webhooks/route.ts → processRow), a row proceeds to OpenAI only if all of these hold: it is an inbound event (isInbound), it has a non-empty body, the tenant has a non-empty aiTriggerTag, the contact's tags include that trigger tag (re-fetched from GHL with backoff if not present on the payload), the contact does not carry the aiDisableTag, the message isn't a duplicate, and the contact hasn't tripped the send-failure circuit breaker.

⚠️ Common-mistake check (the "booking vs. new lead" class of bug): This service is not triggered by a booking, a new contact/lead being created, an opportunity stage change, or an unread-counter update. Those webhook types are explicitly ignored. The trigger is an inbound message that clears the trigger-tag gate.

There are two secondary trigger paths, both safety nets and both gated the same way before any reply is sent:

  • Cron self-heal for dropped webhooks. poll-unread-messages and check-unread-messages (both every 5 min) search GHL for unread inbound-last conversations the live webhook may have missed. poll-unread-messages enqueues synthetic pending_webhooks rows; check-unread-messages can call runGeneration directly with source: "cron_unread_reminder". The trigger-tag gate, kill switches, and schedule still apply.
  • Long-Term-Nurture enrollment webhook. POST /api/integration/webhooks/ltn/[tenantId] (app/api/integration/webhooks/ltn/[tenantId]/route.ts) is a separate, per-tenant webhook fired by a GHL workflow when a contact enters/exits LTN. It does not generate a reply on receipt — it only creates/closes a long_term_nurtures enrollment that the LTN cron later acts on.

3.2 Where the data comes from

DatumSource
Inbound message text + contactId / conversationId / messageId / locationId / channelThe GHL webhook payload, normalized by normalizeWebhookPayload (lib/ghl/webhooks.ts)
Contact tags (the trigger/disable gate)Webhook payload when present (extractContactTags); otherwise re-fetched live from GHL GET /contacts/{id} (getContact, lib/ghl/conversations.ts)
Contact identity + synced custom fieldsGHL Contacts API via ensureContactSynced (lib/ghl/contactSync.ts)
Conversation history (memory across turns)Mirrored into Postgres messages (from webhooks + the sync crons), read by getConversationHistoryForPrompt (lib/ghl/sync.ts)
Master behavior script (identity/tone/flow)Hardcoded in code, not the DB: lib/prompts/masterBehavior*.ts, selected by tenant.scriptType / per-conversation script tags
Hardcoded compliance filterHardcoded in code: lib/compliance/hardFilter.ts (always appended; cannot be removed via any UI)
Per-tenant additive rules, task prompts, location facts, FAQs, community-agent profilesPostgres: prompt_configs, task_prompts, tenant_facts, tenant_faqs, community_agents
The generated replyOpenAI Chat Completions API (lib/openai/index.ts); model = tenant.modelOverride or OPENAI_MODEL (default gpt-4o)
OAuth access tokens used to call GHLPostgres ghl_connections, AES-256-GCM encrypted at rest (lib/ghl/encryption.ts)
The delivered replySent via GHL POST /conversations/messages (sendMessageToGHL, lib/ghl/send-message.ts)

3.3 Trigger + data-flow diagram

This diagram matches §3.1/§3.2 and §4 exactly.

flowchart TD
    A["Lead sends inbound SMS/DM in GHL"] -->|"Signed webhook: InboundMessage"| B["POST /api/integration/webhooks"]
    B -->|"Non-message event types\n(ContactUpdate, Opportunity, booking, unread...)"| X["Ignored — NOT a trigger"]
    B -->|"Inbound/Outbound message event"| C{"Tenant resolved\n& OAuth active?"}
    C -->|"no"| X2["Stub tenant / ignored"]
    C -->|"yes"| D["INSERT into pending_webhooks\n(debounced; return 200)"]

    subgraph CRON["cron: process-pending-webhooks (every 1 min)"]
      D --> E["Dequeue row (newest-first, lease)"]
      E --> F["Mirror message + dedupe"]
      F --> G{"Inbound + has body\n+ has trigger tag\n+ NOT disable tag?"}
      G -->|"no"| Y["Skip (logged to audit_logs)"]
      G -->|"yes"| H["ensureContactSynced (GHL Contacts API)"]
      H --> I["runGeneration()"]
    end

    subgraph GEN["lib/generation"]
      I --> J{"Kill switches / schedule / pre-guardrail"}
      J -->|"blocked"| Z["No send; per-lead killswitch may engage"]
      J -->|"ok"| K["assemblePrompt (master + compliance + facts + history)"]
      K --> L["OpenAI Chat Completions (+ tools)"]
      L --> M["post-guardrail + compliance middleware"]
    end

    M -->|"approved finalOutput"| N["sendMessageToGHL → POST /conversations/messages"]
    N --> O["Record message_deliveries + audit_logs;\nensure follow-up coverage"]

4. How it works, step by step (1:1 with the code)

Phase A — Webhook intake (synchronous, fast)

File: app/api/integration/webhooks/route.ts

  1. Global kill switch. If WEBHOOK_KILL_SWITCH=true, return 200 immediately before reading the body or touching the DB (env.webhookKillSwitch()).
  2. Signature verification. RSA-SHA256 over the raw body against GHL's public key, header x-wh-signature (verifyWebhookSignature). Mode is controlled by GHL_WEBHOOK_VERIFY_MODE: enforce (401 on mismatch), warn (log + accept, default), or off.
  3. Parse + classify. normalizeWebhookPayload, then isInboundMessageEvent / isOutboundMessageEvent / getWebhookHandler. Anything that isn't a message event and has no registered handler is ignored early (a message-shaped but unrecognized type leaves a webhook.unhandled_event breadcrumb).
  4. Resolve tenant by ghlLocationId. An unknown but well-formed location auto-creates a stub tenant (no tokens) so it appears in the dashboard for the operator to finish OAuth.
  5. Quarantine. If the tenant has no active row in ghl_connections, return tenant_not_connected (no enqueue) — without a token the cron couldn't act anyway.
  6. Enqueue. Insert a pending_webhooks row. For inbound events, stamp not_before_at = now + tenant.aiDebounceSeconds (default 70s) and mark any earlier pending inbound rows on the same conversation skipped (debounce-supersede), so a burst of quick texts collapses into one reply. Return {"status":"accepted","reason":"ai_pipeline_queued"}.

Phase B — Queue drain (asynchronous, the real worker)

File: app/api/cron/process-pending-webhooks/route.ts (every minute; maxDuration=300)

  1. Authorize via CRON_SECRET (Bearer header or ?secret=); allowed if the secret is unset.
  2. Recover stale leases, then claim a small batch (BATCH_SIZE=3, TIME_BUDGET_MS=45_000, LEASE_MS=5min, MAX_ATTEMPTS=5), newest-first, skipping rows whose not_before_at is still in the future.
  3. For each row (processRow): log webhook.received; mirror the message (recordWebhookMessage) and skip duplicates; on inbound, auto-exit LTN and active follow-up sequences; pulse the inbound_message / outbound_message tag; run any lifecycle handler (app.installed / app.uninstalled).
  4. Gate to the AI pipeline (inbound only): require a body; require aiTriggerTag to be configured and present on the contact (re-polling GHL with backoff per GHL_TAG_POLL_*); skip if the aiDisableTag is present (per-lead kill switch); skip duplicates by sourceMessageId; skip if the contact has ≥ 2 prior message.failed events (send-failure circuit breaker). Then ensureContactSynced and call runGeneration.

Phase C — Generation pipeline

File: lib/generation/index.ts → runGeneration

  1. Create a generation_requests row (status="processing") and log generation.requested.
  2. Kill switches & schedule: tenant must exist, not be isPaused, and be status="active"; the AI schedule gate (lib/aiSchedule) blocks out-of-window messages when enabled; the corporation kill switch (corporation_kill_switches keyed by ghlCompanyId) blocks all tenants under a corporation.
  3. Pre-guardrail (checkGuardrails(..., "pre")). If the lead called out the bot as AI, send one canned apology (AI_CALLOUT_RESPONSE), engage the per-lead kill switch, and stop. A hard pre-block also engages the per-lead kill switch and stops.
  4. Resolve tools (resolveToolsForTenant) and assemble the prompt (assemblePrompt, lib/prompts/index.ts): master behavior → tone/compliance rules → location facts → community-agent profile → answered FAQs → lead details → (Messenger phone-capture) → hardcoded compliance filter → task list, plus conversation history in the user prompt. Assembly throws if the compliance-filter marker is missing.
  5. Call OpenAI (generateCompletion, lib/openai/index.ts): a bounded tool-use loop (MAX_TOOL_ROUNDS=3), with calendar-date context injected for booking tools, in the contact's display timezone.
  6. Post-guardrail (checkGuardrails(..., "post")) then outbound compliance middleware (enforceOutboundCompliance, lib/compliance/middleware.ts), which may pass, swap in a safe fallback / judge replacement, or silently suppress the send.
  7. Tool-failure suppression: if any tool errored, null the reply and flag for review (the draft is untrustworthy).
  8. Store the generations row (prompts, guardrail results, tool invocations, finalApprovedOutput), update request status, and engage the per-lead kill switch only on hard post-guardrail blocks.

Phase D — Delivery

Back in processRow:

  1. If result.finalOutput is non-null and not blocked, send to GHL (sendMessageToGHLPOST /conversations/messages) with an idempotency key, record a message_deliveries row, mirror the outbound message, log message.sent / message.failed, schedule a retry on retryable failures, and ensure short-term follow-up coverage (ensureFollowupCoverage).
  2. Roll per-row counts into the sync_ticks summary for the 5-minute window.

5. The nurture / follow-up crons

All cron endpoints require CRON_SECRET (or allow all if unset) and are wired in vercel.json.

CronScheduleWhat it doesGenerates text via
process-pending-webhooks* * * * *Drains the inbound queue → AI reply (Phases B–D).runGeneration
sync-messages*/5 * * * *Rotational full-history pull per tenant (ordered by cronLastSyncedAt); enqueues missed webhooks.
poll-unread-messages*/5 * * * *Cheap unread search per tenant; enqueues missed inbounds.
check-unread-messages*/5 * * * *Replies to inbound-last unread convs the webhook missed (quiet hours 10:00–18:00 ET; send caps).runGeneration (cron_unread_reminder)
reconcile-messages0 * * * *Forced 24h re-sync to catch out-of-order/back-dated messages.
process-follow-ups* * * * *Sends due one-off scheduled_follow_ups (cancels if the lead replied / tenant paused; business-hours aware).pre-written text
process-followup-sequences* * * * *Advances multi-step follow_up_enrollments; final step auto-enrolls into LTN. Exits exited_stage when the contact's GHL opportunity sits in an excluded pipeline stage (Hot Lead / Active Deal / ... — lib/followUps/stageGate.ts; per-tenant config on Settings → Follow-Up Targeting).generateFollowUpMessage
process-long-term-nurtures0 * * * *Sends LTN touches (rotating angles; strict M–F 9–5 local + US holidays; re-checks the LTN tag). LTN is open-ended: monthly for the first 6 touches, then every 60 and 90 days forever — exits only on reply, tag removal, admin action, or the contact's opportunity moving into an excluded pipeline stage (exited_stage, same stage gate as the sequence cron).generateLtnMessage
sweep-nurture-coverage0 7 * * *Coverage guarantee: enrolls any contact with a conversation, no active sequence/LTN enrollment, and no message activity for 14+ days (skips operator opt-outs — latest LTN row exited_tag_removed/exited_admin — and contacts whose GHL opportunity sits in an excluded pipeline stage). Routes to LTN by default, or through the default short-term sequence first when tenants.sweep_enroll_target='sequence'. Batch of 200/run, round-robin across tenants (one backlog can't starve the fleet); admins can drain a single tenant on demand via the Queue page's "Sweep old leads now" button (POST /api/admin/cron/run-nurture-sweep). First touches staggered over 30 days (LTN) / 7 days (sequence).
retry-deliveries* * * * *Retries failed GHL deliveries (processPendingRetries).

Two cross-cutting rules keep the queues honest (lib/followUps/rescheduleOnOutbound.ts, lib/followUps/stageGate.ts):

  • External outbound pushes queued sends. When a manual follow-up or a mass text (GHL workflow/campaign/bulk) is mirrored for a contact, the active sequence enrollment's nextSendAt and the LTN row's nextCheckInAt are re-anchored to that message (+ the step delay / cadence in effect) — only ever later, never earlier, so echoes of our own sends and backfilled history are no-ops. One-off scheduled_follow_ups are deliberately untouched (their text encodes a specific promise).
  • Pipeline-stage gate. Contacts whose GHL opportunity sits in an excluded stage (auto name-match on Hot Lead / Active Deal / Under Contract / Won / Sold, or explicit stages picked on Settings → Follow-Up Targeting; a won opportunity always excludes) are exited from sequences/LTN at send time and skipped by the sweep. GHL failures defer rather than exit, and the daily sweep re-enrolls the contact once the opportunity leaves the stage.

6. Data model (Postgres / Supabase via Drizzle)

README discrepancy: the legacy README.md says "SQLite via Drizzle." The code uses PostgreSQL (postgres driver + drizzle-orm/postgres-js, lib/db/index.ts), pooled through Supabase Supavisor (max:1, prepare:false). Connection string from POSTGRES_URL (fallback DATABASE_URL). Schema lives in lib/db/schema.ts; migrations in lib/db/migrate.ts.

Key tables (see lib/db/schema.ts for the full set):

  • tenants — one GHL sub-account. Holds the gate config (aiTriggerTag default ai-enabled, aiDisableTag default ai-disabled), isPaused + pauseReason, status, scriptType, modelOverride, AI-schedule fields, aiDebounceSeconds (default 70), LTN config, and cronLastSyncedAt.
  • ghl_connections — encrypted OAuth tokens per tenant; connectionStatus.
  • pending_webhooks — the inbound queue (status pending→processing→ done/skipped/failed, attempts, leasedUntil, isInbound, notBeforeAt).
  • conversations, messages, contacts — local mirror of GHL data.
  • prompt_configs, task_prompts, tenant_facts, tenant_faqs, community_agents — per-tenant prompt inputs. community_agents models a sub-account that runs multiple communities (one row each: routing match_tags, free-form profile, and an optional per-community GHL calendar). At generation the agent whose match_tags intersect the contact's tags is selected (its profile + calendar win); a roster of all communities is shown to the model. The assign_to_community LLM tool pins a lead to one community mid-conversation — it applies that community's routing tag (and strips the others), so every later turn and the tour booking use the right calendar. Same-turn bookings follow via a mutable calendar override on the tool context.
  • generation_requests, generations — every request + full generation record (prompts, guardrail results, tool calls, approved output).
  • message_deliveries — outbound delivery + retry tracking.
  • scheduled_follow_ups, follow_up_sequences, follow_up_sequence_steps, follow_up_enrollments, long_term_nurtures, ltn_angles — nurture state.
  • corporation_kill_switches — master switch per ghlCompanyId.
  • users, user_tenants, oauth_states, pending_connections, tenant_calendar_selections, audit_logs, sync_ticks — auth, OAuth, calendars, audit, and per-window cron summaries.

7. Operating it (SOPs)

Auth model (middleware.ts). Public paths: /login, /legal, auth/OAuth/webhook routes, /api/cron/, /api/integration/retry-deliveries, and /api/directory (the feed). Dashboard is admin-only; the client portal is client/admin; other /api/* routes require a JWT, with clients restricted to /api/auth/* and /api/client/*.

Onboard a tenant. Operator runs the GHL OAuth flow (/api/integration/oauth/start/callback). A stub tenant auto-appears on first webhook; finishing OAuth binds tokens and flips connectionStatus to active. Then configure the trigger tag, script type, location facts, and task prompts in the dashboard.

Turn the AI on for a lead. Add the tenant's trigger tag (default ai-enabled) to the contact in GHL. The next inbound message will generate a reply.

Stop the AI — escalating scope:

  • One lead: add the aiDisableTag (default ai-disabled) in GHL (the pipeline also auto-engages this on hard guardrail blocks / AI call-outs).
  • One tenant: set isPaused=true (with a pauseReason) via the dashboard / /api/client/tenants/[id]/pause or kill-switch endpoints.
  • A whole corporation: /api/admin/corporation-kill-switch.
  • The entire ingest (emergency): set WEBHOOK_KILL_SWITCH=true in Vercel — webhooks are 200-ack'd and dropped until you unset it.

Queue & health admin endpoints: /api/admin/queue-status, /api/admin/queue-drain, /api/admin/db-health, /api/admin/diagnose-message, /api/admin/run-migrations.

External tenant-stats feed. GET /api/integration/tenant-stats?locationId=<ghl-location-id> (app/api/integration/tenant-stats/route.ts) is an unauthenticated read-only JSON endpoint that the off-platform monthly-report UI consumes. The caller identifies the sub-account by GHL location id — the route looks the tenant up by ghl_location_id and returns:

  • tenant{ id, name, ghlLocationId, isPaused, status }
  • fetchedAt — ISO-8601 timestamp the response was assembled at
  • unreadMessages — live GHL count (status=unread)
  • calls — inbound/outbound counts across 24h / 7d / 30d, from the local messages mirror (channel ∈ call/phone/voice/phone_call)
  • pipelines — every GHL pipeline with its stages + open opportunity counts
  • leads30d — # contacts created in the last 30 days (POST /contacts/search with a dateAdded range filter)
  • landLeads30d — same as leads30d plus a tags contains "land_owner" filter
  • appointmentsScheduled30d — count across every calendar on the location of events whose appointmentStatus ∈ {confirmed, new, showed, noshow, cancelled} in the last 30 days (GET /calendars/GET /calendars/events per calendar)
  • showRate30dshowed / (showed + noshow) from the same event aggregate; value: null, error: null when the denominator is 0

Every metric uses the shared MetricEnvelope shape { value: <number|object|null>, error: <string|null> } and is wrapped in its own try/catch — one upstream failure only nulls one field. On a failed GHL call, error is set to a short code (ghl_timeout, ghl_unauthorized, ghl_rate_limited, ghl_upstream_error) so the UI can render "-" with a fetch-failed hint instead of breaking. Cost-per-lead is computed off-platform and is intentionally not part of this response.

Security: the endpoint is public — GHL location ids aren't secrets but they're not easily guessable either. Acceptable for trusted internal integrations; if exposing to less-trusted consumers, gate it via an API key in middleware.ts.

Deploy / migrate. Vercel build runs next build. Apply DB changes with pnpm db:migrate (or hit /api/admin/run-migrations). The crons in vercel.json are provisioned automatically by Vercel.


8. Troubleshooting

  • "The AI isn't replying to a lead." Walk the gate in order: is the contact carrying the trigger tag? Does it also carry the disable tag? Is the tenant paused / not active / corp-killed? Is the message outside the AI schedule? Did a pre-guardrail / AI call-out fire? Each decision is in audit_logs (webhook.unhandled_event with entityType:"ai_gate", generation.blocked, etc.). The dashboard conversation drill-down (/api/conversations?includeLogs=true) stitches these together per message.
  • "Replies are slow." Expected: there's a aiDebounceSeconds wait (default 70s) plus up to a minute for the queue cron. Bursts intentionally collapse into one reply.
  • "Queue is backed up." Check /api/admin/queue-status; the cron processes newest-first (so fresh messages aren't buried) and is bounded (BATCH_SIZE=3). Old junk drains in the background or via queue-drain.
  • "enqueue_failed / queue read failed." The pending_webhooks table is probably missing — run migrations (/api/admin/run-migrations).
  • "Webhook signatures failing." In warn mode they're logged and accepted; confirm logs show consistent verification before flipping GHL_WEBHOOK_VERIFY_MODE=enforce. If GHL rotated the key, set GHL_WEBHOOK_PUBLIC_KEY.
  • "Reply sent but lead never got it." Look at message_deliveries (deliveryStatus="failed", failureReason) and message.failed audit rows; retryable failures are re-attempted by retry-deliveries. After 2 failures the circuit breaker stops burning OpenAI calls for that contact.
  • "Dashboard timing out under load." The DB pool is small (max:1 per invocation); heavy webhook bursts compete with it. This is exactly why AI work was moved off the webhook path into the queue cron.

9. Extending it

  • Recognize a new GHL message event type: add it to INBOUND_MESSAGE_TYPES / OUTBOUND_MESSAGE_TYPES in lib/ghl/webhooks.ts (and KNOWN_NON_MESSAGE_EVENT_TYPES if it's noise).
  • Handle a new lifecycle event: registerWebhookHandler("<type>", ...) in lib/ghl/webhooks.ts; the queue cron will dispatch it.
  • Add a new behavior script: add a masterBehavior.<x>.ts, extend the scriptType enum in lib/db/schema.ts, and wire it into resolveMasterBehaviorPrompt (lib/prompts/index.ts).
  • Add an LLM tool: register it in lib/tools/registry.ts; resolution + per-tenant config flow through lib/tools/resolve.ts into generateCompletion.
  • Add a guardrail / compliance rule: pre/post rules in lib/guardrails; the non-editable barrier in lib/compliance/hardFilter.ts; the judge in lib/compliance/.
  • Add a cron: create app/api/cron/<name>/route.ts (guard with CRON_SECRET, set a maxDuration + time budget) and add it to vercel.json.

10. Tech stack

  • Framework: Next.js 15 (App Router) on Vercel; Vercel Cron for schedules.
  • Language: TypeScript.
  • Database: PostgreSQL (Supabase) via Drizzle ORM (postgres-js).
  • Auth: JWT (jose); bcrypt password hashing.
  • AI: OpenAI SDK (Chat Completions, function tools).
  • CRM integration: GoHighLevel LeadConnector API (OAuth 2.0; webhooks).
  • Crypto: AES-256-GCM for OAuth tokens at rest; RSA-SHA256 webhook verification.
  • UI: Tailwind CSS 4 + shadcn/ui + Recharts.

This document is the source of truth for the /api/directory feed (id: ghl-openai-orchestrator). Edit this file to update the feed; the hub diffs on content_hash.