# GoHighLevel and CRM: booking in the chat, pushing contacts, owners and pipelines

> **You want.** *"agendar directamente en el chat GoHighLevel herramientas disponibilidad reservar
> teléfono"* · *"Activar agendar por chat con GoHighLevel para el agente de Instagram"* ·
> *"Conectar la agenda (GHL booking) de varias subcuentas a un mismo agente"* · *"Sincronizar
> contactos de Ninjo con GoHighLevel usando property actions (CRM_PUSH_CONTACT, CRM_FIELD_UPDATE y
> CRM_CONTACT_TAG)"* · *"CRM_PUSH_CONTACT fields sent to GHL contact name instagram opportunity name
> owner"* · *"CRM_STAGE_TRANSITION contact not pushed GoHighLevel create opportunity duplicate contact
> booking agent"* · *"GHL: el push contact usa el email compartido en vez del placeholder"* ·
> *"assign owner user to contact or opportunity GoHighLevel assigned to"* · *"asignar conversación
> al humano que responde, routing owner assignment when human replies"* · *"CRM routing rules
> assignment owner criteria first responder"* · *"agent save contact email field tool update
> contact info"* · *"update_contact_info save email native contact field agent"* · *"built-in tool
> save_contact_data email phone contact record agent writes"*.
>
> **Short answer.** "GoHighLevel" and "CRM" are three separate things. Work out which one the
> operator means before making any call. Owner assignment exists only in Ninjō's native CRM, never
> in GoHighLevel. The agent has no tool that saves an email or phone. The platform picks them up
> from the lead's messages on its own.

## Which of the three you are touching

| Thing | What it is | Connect with | Configure with |
|---|---|---|---|
| **GoHighLevel booking** | GHL calendars: tracks bookings and, with one Studio checkbox, lets the agent book in the chat | `create_connect_link(provider:"gohighlevel_booking")` | `list_booking_calendars` / `set_booking_calendars` (+ Studio checkbox) |
| **GoHighLevel CRM sync** | Pushes Ninjō contacts and opportunities into a GHL sub-account | `create_connect_link(provider:"gohighlevel_crm")` | `list_crm_connections` → `upsert_property_action`, or `manage_crm_sync` auto-sync |
| **Ninjō native CRM** | Ninjō's own pipelines, teams, owners and routing rules. Nothing is written to GHL | nothing, it is built in | `get_crm_overview` and the `*_crm_*` tools (`get_crm_pipelines`, `execute_crm_assignment`, `manage_crm_routing`…) |

The two GHL connections are separate OAuth grants. Having booking connected does not give you
the CRM sync, and having the CRM sync connected does not give you booking.

## A. Booking in the chat ("agendar por chat")

1. `create_connect_link(provider:"gohighlevel_booking")` → hand the URL to the owner.
2. `list_booking_calendars(platform:"gohighlevel")`. The argument is **`platform`**, not
   `provider`. Read the calendar names back to the owner.
3. `set_booking_calendars(platform:"gohighlevel", calendar_ids:[…])`. This replaces the whole
   selection. With no calendars selected, nothing is tracked and no booking tools load.
4. **Studio only, no MCP tool:** `/studio/{influencerId}/integrations/gohighlevel` → tick
   **"Permitir que los agentes agenden directamente en el chat"**. It is off by default.
5. `update_prompt` / `deploy_agent_sdk`: name the tools in the prompt and remove the booking link
   (Studio's own help text says it plainly: if the prompt still says to send the link, the agent
   sends the link). If you selected more than one calendar, the prompt has to say which calendar
   is for what. Otherwise the agent picks by calendar name.

What loads: `get_ghl_calendar_availability`, `book_ghl_appointment`, `cancel_ghl_appointment`,
`reschedule_ghl_appointment`. Facts that matter:

- **Every v5 agent in the workspace gets them.** There is no per-agent switch (Google Calendar has
  one, GHL does not), so the prompt alone decides which agents book. Non-v5 agents never get them.
- **The channel does not matter.** An Instagram agent books the same way a WhatsApp agent does.
- **They load only in a conversation with a contact.** `list_agent_tools` never lists them, and
  `generate_synthetic_conversations` never gets them, so a synthetic run invents slots or stalls.
  Test booking with a real DM from a test account, then check `list_schedules(source: "GO_HIGH_LEVEL")`.
- **One GHL sub-account per workspace.** A workspace holds a single GHL booking connection with a
  single location. Calendars from several sub-accounts cannot feed one agent. Each extra
  sub-account needs its own workspace.
- Booking finds the lead's GHL contact by email/phone, or creates it, and stores the link. A later
  CRM push then updates that contact instead of creating a duplicate.

## B. Pushing contacts to the GoHighLevel CRM

1. `create_connect_link(provider:"gohighlevel_crm")`. Nothing else is needed after OAuth.
2. `list_crm_connections()` → `sync_connection_id`. If you want an opportunity:
   `list_crm_pipelines` → `list_crm_stages` → `crm_stage_id`.
3. `list_custom_properties` → choose a **boolean** gate property (for example a qualified flag).
4. `upsert_property_action({ action_type:"CRM_PUSH_CONTACT", custom_property_id, operator:"EQUALS",
   value_boolean:true, crm_sync_connection_id, crm_create_opportunity:true, crm_stage_id })`.
   An opportunity is only created when **both** `crm_create_opportunity` and `crm_stage_id` are
   set.
5. Tags and fields: `CRM_CONTACT_TAG` and `CRM_FIELD_UPDATE` on the same connection, **gated so
   they fire after the push**. A tag action on a contact that was never pushed fails with "not
   linked to this CRM — add a Push Contact action before the tag action".

Alternative to step 4: `manage_crm_sync({command:{intent:"toggle-auto-sync", syncConnectionId,
enabled:true, stageId}})` pushes every contact that writes in and is not synced yet. This is the
only path that re-pushes when an email or phone shows up later. A property action pushes once, when
its condition fires.

**What the push sends to GHL** (and nothing more):

- `firstName`, `lastName`, `phone` (the contact's phone, otherwise its WhatsApp number), `email`.
- **Email fallback:** the real email if there is one. If there is no email and no phone, it uses
  `<instagram_username>@placeholder.com` (or `<facebook_id>@placeholder.com`). If there is a phone
  but no email, no email is sent.
- Tags `ninjo-agent` and `instagram:<username>`; `source: "instagram"` for IG leads.
- Custom fields `Instagram`, `Ninjo Link` (a Studio deep link) and `Ninjo Contact ID`. They are
  created in GHL if they don't exist yet.
- Opportunity: name = the contact's full name, or "Opportunity" when there is none. Status
  `open`. Skipped when the contact already has **any** open opportunity, in Ninjō or in GHL.
- **No owner.** The push never sets GHL's `assignedTo`. Ninjō does not assign GHL owners.

**Duplicates.** Before creating, the push looks for an existing GHL contact: first by the
`Ninjo Contact ID` field, then by email (or the placeholder), then by phone. The first match is
reused. So a shared email, such as the business's own or a relative's, merges different leads into
one GHL contact. The placeholder is only a fallback for contacts with no email and no phone. It
never replaces a real email.

`CRM_STAGE_TRANSITION`: with `crm_create_if_not_exists:false` and no opportunity in that
pipeline, it fails with "no opportunity found". Set `crm_create_if_not_exists:true`: it then
resolves or creates the contact the same way and reuses an open opportunity in that pipeline if
there is one.

## C. Native CRM: owners, routing, pipelines

These tools need an OAuth login as a real person. API keys and user-less credentials don't get
them. Start with `get_crm_overview`: it returns your permissions and which areas are switched on
for the workspace.

- **Owner.** A contact has no owner. Ownership belongs to an exact conversation or pipeline record.
  To read it: `get_crm_assignment`. To set it: `execute_crm_assignment({idempotencyKey,
  command:{type:"ASSIGN", subject:{type:"CONVERSATION", conversationId}, userId,
  policy:"ONLY_IF_UNASSIGNED", expectedRevision}})`. `CLAIM_SELF` claims it for yourself.
- **Routing rules** (`get_crm_routing` → `manage_crm_routing`) fire on contact or conversation
  facts (`criteria`). The target is a user, `AUTO` (deterministically the first eligible team
  member, not round-robin) or `TEAM_ONLY`. There is no "first human who replies" mode. Human
  claims are protected from automation.
- **Pipelines.** `get_crm_pipelines` → `execute_crm_pipeline_command`. Use `add-contact` only when
  the contact has no active record in that pipeline. Otherwise use `move-record` with the record's
  `expectedRevision`.

## The agent does not write contact fields

No tool loaded by the agent writes email, phone or name. The platform fills them on its own, two
ways:

- **Live, from the message.** On every inbound message that reaches an agent, the platform takes
  the **first valid email and phone in the lead's last message** and saves them on the contact. It
  does the same with whatever email a lead types, even someone else's. A picture never counts: an
  image's text is the vision model's description, not the lead's words.
- **From an email property (since 2026-10-15).** A string custom property whose codename contains
  `email`, `e-mail` or `correo` (for example `email_shared`) is copied into the contact's email
  when the evaluator fills it, **only while the contact's email is empty**. It never overwrites an
  email the lead typed or someone set by hand. Use this when leads give their email in a way the
  live extractor misses.

With auto-sync on, a new email or phone triggers a new push. To send any other value to GHL,
define a custom property (the evaluator fills it) and send it with `CRM_FIELD_UPDATE`
(`crm_value_source` omitted = the property's value). An operator can correct a contact with
`replace_crm_contact` or `set_crm_contact_property`.

## Invariant

- Booking: `list_booking_calendars` shows ≥1 `selected:true`. A test chat books, and
  `list_schedules(source:"GO_HIGH_LEVEL")` shows the row. The row is written by GHL's webhook
  seconds later, not by the tool.
- Push: `list_property_actions` shows the action with its `crm_push_contact` block. In GHL the
  contact carries the `ninjo-agent` tag and a filled `Ninjo Contact ID`.

## How it goes wrong

- **409 `ACTIVE_RECORD_EXISTS`** (148 calls from two operators in one week, 2026-10). The calls
  used `add-contact` on a contact that is already active in that pipeline. Retrying changes
  nothing. Read the record (`get_crm_pipelines` with `recordId`), then `move-record`, or
  `close-record` first if a new cycle is what you want.
- **409 `IDEMPOTENCY_KEY_REUSED`**: the same `idempotencyKey` was sent with a different payload.
  Use a fresh key for every distinct command. Reuse a key only to retry the identical payload.
- **403 "CRM account access or permission denied"**. If `get_crm_overview` itself returns 403,
  the signed-in user has no access to that workspace: not the owner, not an org admin, and not
  shared in. If `get_crm_overview` works but another read returns 403, the user's role lacks that
  permission (for example `get_crm_teams` needs `manage:teams`). An area that is switched off
  answers 404, not 403.
- **`list_booking_calendars` "expected 'gohighlevel' | 'calendly', received undefined"**: the
  `platform` argument is missing. `create_connect_link`'s `provider` value does not go here.
- **`unrecognized_keys` on `reason`**: the native CRM tools reject unknown arguments, and
  `reason` is not a top-level field on them. It exists only inside some commands (ASSIGN,
  UNASSIGN, close-record, transfer-record).
- **Booking connected, agent still sends the link**: either the Studio checkbox is off or the
  prompt still tells the agent to send the link. The OAuth connection alone never loads the tools.
- **Booking tools answer "reconnect"**: the GHL grant expired. `create_connect_link(provider:
  "gohighlevel_booking", reconnect:true)`.
- **Owner expected in GHL**: Ninjō never sets it. Assign the owner inside GoHighLevel, or use
  native ownership in Ninjō.

See also: `connecting-integrations` (every link flow, booking is fail-closed),
`property-actions` (condition, token and field contract), `messaging-channels` (GHL as the
channel the agent answers through: a different thing), `how-to/custom-http-tools` (your own
API), `business-hours-timezones` (Google Calendar, the other in-chat booking).
