{
  "_description": "AUTHORITATIVE REFERENCE: Correct GHL workflow action attribute schemas. Derived from real working workflows built in GHL UI. ALL sessions MUST use these exact formats.",
  "_version": "2.0 \u2014 2026-03-24",
  "_critical_rules": [
    "NEVER use 'body' for emails \u2014 use 'html' with HTML markup",
    "NEVER use 'tagName' \u2014 use 'tags' array",
    "NEVER use 'duration' for waits \u2014 use startAfter object",
    "NEVER use singular min units \u2014 real GHL uses 'minutes'",
    "ALWAYS include 'attachments: []' on SMS actions",
    "ALWAYS include 'trackingOptions' on email actions",
    "ALWAYS include all 5 fields on wait actions (type, startAfter, isHybridAction, hybridActionType, transitions)",
    "ALWAYS use 'parent' AND 'parentKey' on branch nodes",
    "ALWAYS use 'sibling' arrays on branch-yes and branch-no nodes",
    "NEVER use if_else as a single flat action \u2014 it is only valid on condition-node, branch-yes, and branch-no nodes",
    "ALWAYS include workflow_id ARRAY in remove_from_workflow (not just workflowId string)",
    "For linear workflows, the buildActionChain needs BOTH next (forward) and parentKey (backward) on every action",
    "Keep workflows under 40 actions to avoid GHL UI renderer freezes",
    "Use separate exit workflows instead of inline if/else branching for nurture sequences"
  ],
  "add_contact_tag": {
    "example": {
      "tags": [
        "tag-name-here"
      ]
    },
    "notes": "Use 'tags' ARRAY, NOT 'tagName' string. Tag must exist in the location."
  },
  "remove_contact_tag": {
    "example": {
      "tags": [
        "tag-to-remove"
      ]
    },
    "notes": "Same format as add_contact_tag but action type is 'remove_contact_tag'."
  },
  "sms": {
    "example": {
      "body": "Hi {{contact.first_name}}, your message here.",
      "attachments": []
    },
    "notes": "Body supports GHL merge fields. 'attachments' is REQUIRED even if empty array."
  },
  "email": {
    "example": {
      "subject": "Your Subject Line",
      "html": "<p style=\"margin:0px; padding-left: 0px!important;\">Email body as HTML here.</p>",
      "trackingOptions": {
        "hasTrackingLinks": false,
        "hasUtmTracking": false,
        "hasTags": false
      }
    },
    "notes": "Use 'html' NOT 'body'. Content MUST be HTML with <p> tags. Subject supports merge fields. trackingOptions is REQUIRED."
  },
  "wait": {
    "example_1hour": {
      "type": "time",
      "startAfter": {
        "type": "hour",
        "value": 1,
        "when": "after"
      },
      "name": "Wait",
      "isHybridAction": true,
      "hybridActionType": "wait",
      "convertToMultipath": false,
      "transitions": []
    },
    "example_30min": {
      "type": "time",
      "startAfter": {
        "type": "minutes",
        "value": 30,
        "when": "after"
      },
      "name": "Wait",
      "isHybridAction": true,
      "hybridActionType": "wait",
      "convertToMultipath": false,
      "transitions": []
    },
    "example_3days": {
      "type": "time",
      "startAfter": {
        "type": "days",
        "value": 3,
        "when": "after"
      },
      "name": "Wait",
      "isHybridAction": true,
      "hybridActionType": "wait",
      "convertToMultipath": false,
      "transitions": []
    },
    "notes": "startAfter.type: 'minutes', 'hour', or 'days' \u2014 NOT consistently singular, and 'day' is rejected with 400 INVALID_FIELD_VALUE on a workflow that has a real action before the wait (live-verified 2026-08-29). ALL 5 fields required (type, startAfter, isHybridAction, hybridActionType, transitions). Derived from working GHL UI-built workflows."
  },
  "internal_notification": {
    "example": {
      "type": "notification",
      "notification": {
        "body": "Notification body with {{contact.first_name}} merge fields",
        "title": "Notification Title",
        "userType": "user",
        "redirectPage": "contact",
        "type": "send_notification",
        "selectedUser": "USER_ID"
      }
    },
    "notes": "Nested 'notification' object REQUIRED. \u26a0\ufe0f READ THE userType FIRST \u2014 the empty-string rule is SCOPED TO IT, and reading it unscoped makes this entry look like it contradicts intake-to-build/executor.ts. It does not. (a) userType:'user' \u2192 selectedUser MUST be a real user ID; an EMPTY STRING IS REJECTED (live-verified 2026-08-06; the old 'empty = all users' behaviour is gone). Use get_users. (b) userType:'all' \u2192 selectedUser:'' is the CORRECT and live-proven placeholder shape, not a defect; executor.ts:660-688 emits it deliberately when no staff member is resolvable yet, and executor.test.ts:1281-1289 pins it. Never flag (b) as missing a recipient. The ONLY broken combination is userType:'user' with a blank selectedUser. Use get_users to find IDs. EMAIL CHANNEL (verified live 2026-07-20, PWDJ workflow e91f28da): attributes.type is 'email' (NOT 'notification'), nested key is 'email' (NOT 'notification'), body field is 'html' (NOT 'body'), selectedUser is an ARRAY of user IDs, include attachments:[] and isCloned:false. A 'send_email' discriminator inside a 'notification' object saves but silently never sends.",
    "emailChannelExample": {
      "type": "email",
      "email": {
        "isCloned": false,
        "html": "<p style=\"margin:0px;\">We received a new Collector story.</p>",
        "subject": "New Painting Collector Story Received",
        "selectedUser": [
          "<userId>"
        ],
        "userType": "user",
        "attachments": []
      }
    }
  },
  "update_contact_field": {
    "example": {
      "type": "update_contact_field",
      "actionType": "update_field_data",
      "fields": [
        {
          "field": "CUSTOM_FIELD_ID",
          "value": "New Value",
          "title": "Field Display Name",
          "type": "text",
          "date": ""
        }
      ]
    },
    "notes": "'field' is the custom field ID, not the name. Use get_custom_fields to find IDs."
  },
  "add_notes": {
    "example": {
      "html": "<p style=\"padding-left: 0px!important;\">Note content here.</p>",
      "type": "add_notes"
    },
    "notes": "Content MUST be HTML."
  },
  "task-notification": {
    "example": {
      "assignedTo": "USER_ID",
      "title": "Task Title",
      "dueDate": "1",
      "body": "Task description text",
      "type": "task-notification",
      "__customInputs__": {}
    },
    "notes": "dueDate is days from now as string. assignedTo is a user ID. \u26a0\ufe0f THE TYPE MUST BE HYPHENATED. The underscore form `task_notification` SAVES, VALIDATES and PUBLISHES and is then SILENTLY SKIPPED AT RUNTIME \u2014 no task is ever created and no error is shown anywhere (live-verified 2026-06-12 on the GHL Command sale alert). If you searched this file for `task_notification` and found nothing, that empty result IS the bug: the key here is `task-notification`."
  },
  "assign_user": {
    "example": {
      "type": "assign_user",
      "user_list": [
        "USER_ID"
      ],
      "only_unassigned_contact": false,
      "traffic_split": "equally",
      "traffic_weightage": {
        "USER_ID": 1
      },
      "traffic_index": [
        {
          "id": "USER_ID",
          "indexes": [
            1
          ]
        }
      ],
      "total_index": 1
    },
    "notes": "[PROVEN LIVE 2026-08-27 on the MCP Testing sandbox: a scratch workflow carrying both a single-user and a two-user round-robin assign_user node saved, read back with every attribute intact (user_list, only_unassigned_contact, traffic_split, traffic_weightage, traffic_index, total_index) and both real user ids present, and PUBLISHED \u2014 GHL's own builder validator accepted it, which is the check that requires a non-empty user_list and verifies each id against the location's users. Scratch workflow deleted; the account was left byte-identical.] 'Assign to user' \u2014 the listed users become the contact owner. Linear actions-category node like add_contact_tag. user_list is REQUIRED and non-empty (GHL's builder validator: 'user_list_required'; every id is checked against the location's users \u2014 a dead id silently never assigns). Multiple users = round-robin; the equal-split bookkeeping the builder saves is weightage {id:1 each}, traffic_index [{id, indexes:[1..weight]}] with globally sequential indexes, total_index = sum of weights \u2014 single user: weightage {id:1}, index [{id, indexes:[1]}], total 1. only_unassigned_contact false always (re)assigns; true skips contacts that already have an owner. traffic_split 'unevenly' + custom weights and customUserList (custom-value mode) exist in the builder but are NOT built by us. LIVE TEST TO CLOSE: build one workflow with this node on a sandbox (MCP Testing), publish, run a test contact through, read back with get_workflow_full + get_contact \u2014 confirm the node persists byte-for-byte and the contact's assignedTo/owner becomes USER_ID; then remove this UNVERIFIED label."
  },
  "remove_from_workflow": {
    "example": {
      "workflowId": "TARGET_WORKFLOW_ID",
      "workflowName": "Target Workflow Name",
      "type": "remove_from_workflow",
      "workflow_id": [
        "TARGET_WORKFLOW_ID"
      ]
    },
    "notes": "Requires BOTH workflowId (string) AND workflow_id (ARRAY with same ID). Also needs type: 'remove_from_workflow' inside attributes. Derived from GHL UI."
  },
  "internal_create_opportunity": {
    "example": {
      "type": "internal_create_opportunity",
      "workflowsActionType": "INTERNAL",
      "attributes": {
        "type": "internal_create_opportunity",
        "pipelineId": "PIPELINE_ID",
        "__customInputFields__": [
          {
            "__customInputs__": {},
            "dataType": "SINGLE_OPTIONS",
            "filterField": "pipelineStageId",
            "value": "STAGE_ID",
            "valueFieldType": "select"
          }
        ],
        "__customInputs__": {}
      }
    },
    "notes": "CREATES a card (GHL's newer separate 'Create Opportunity' action). This minimal shape is live-proven to create at runtime (2026-07-24): pipelineId at the ATTRIBUTES level (not inside __customInputFields__), __customInputFields__ carrying pipelineStageId, workflowsActionType:'INTERNAL' at the NODE level. UI-built nodes also carry name/status/monetaryValue entries and work \u2014 copy a working node field-for-field rather than inventing entries. Without a name entry the card is named from the contact (observed live 2026-07-24). Use THIS for 'create an opportunity for the new lead'."
  },
  "internal_update_opportunity": {
    "example": {
      "__customInputFields__": [
        {
          "filterField": "pipelineId",
          "value": "PIPELINE_ID",
          "valueFieldType": "select",
          "dataType": "SINGLE_OPTIONS"
        },
        {
          "filterField": "pipelineStageId",
          "value": "STAGE_ID",
          "valueFieldType": "select",
          "dataType": "SINGLE_OPTIONS"
        }
      ],
      "workflowsActionType": "INTERNAL",
      "type": "internal_update_opportunity"
    },
    "notes": "GHL's combined 'Create/Update Opportunity'. With only pipelineId + pipelineStageId it CANNOT create a card \u2014 it moves one the contact already has; a contact with no opportunity (a new lead) gets nothing and the workflow continues as if it worked (customer report 2026-08-26). It creates only when __customInputFields__ also carry Opportunity name (GHL's doc: Name, Source, Status mandatory to create). GoHighLevel is phasing this combined action out for new workflows, so prefer internal_create_opportunity to create; use this node after find_opportunity, after a create step in the same workflow, or on an opportunity trigger. CREATABLE from scratch (re-enabled v3.41.0). The discriminator workflowsActionType:'INTERNAL' MUST sit at the NODE level, never nested in attributes \u2014 a nested copy makes GHL reject the node as 'action has a corrupted type' and silently fail the whole save. update_workflow_actions normalizes this for you (hoists workflowsActionType to the node level, scaffolds allowBackward + __customInputs__, gives each __customInputFields__ entry an __customInputs__). The shape below (workflowsActionType at the node level alongside type/name/attributes) is correct for both creating and round-tripping. Use pipeline and stage IDs (not names) \u2014 get_pipelines / list_pipelines_full to find them FIRST. CRITICAL: if the pipelineId or pipelineStageId don't exist in the target sub-account, GHL silently fails this action AND can kill subsequent actions. A synthesized node needs BOTH a pipelineId and a pipelineStageId entry; a node round-tripped via get_workflow_full keeps its id and passes through unchanged."
  },
  "_if_else_branching": {
    "notes": "if_else is a node type discriminator only. Do not send a single flat if_else action.",
    "condition_node": {
      "type": "if_else",
      "cat": "conditions",
      "nodeType": "condition-node",
      "next": [
        "YES_BRANCH_ID",
        "NO_BRANCH_ID"
      ],
      "attributes": {
        "currentRecipeType": "HAS_TAG",
        "operator": "and",
        "if": true,
        "conditionName": "HAS_TAG",
        "version": 2,
        "noneBranchName": "None",
        "branches": [
          {
            "id": "YES_BRANCH_ID",
            "name": "Branch name",
            "segments": [
              {
                "__segmentId": "SEGMENT_UUID",
                "operator": "and",
                "conditions": [
                  {
                    "conditionType": "contact_detail",
                    "conditionSubType": "tags",
                    "conditionOperator": "index-of-true",
                    "conditionValue": [
                      "tag-to-check"
                    ],
                    "__conditionId": "CONDITION_UUID",
                    "__customFieldType__": "standard",
                    "nestedDropdownTypes": [],
                    "allowIsOperatorTypes": [],
                    "ifElseNodeId": "CONDITION_NODE_ID",
                    "isWait": false
                  }
                ]
              }
            ]
          }
        ]
      }
    },
    "branch_yes": {
      "parentKey": "CONDITION_NODE_ID",
      "parent": "CONDITION_NODE_ID",
      "cat": "conditions",
      "nodeType": "branch-yes",
      "sibling": [
        "NO_BRANCH_ID"
      ],
      "next": "FIRST_YES_CHILD_ID",
      "attributes": {
        "if": false,
        "conditionName": "HAS_TAG",
        "operator": "and",
        "branches": []
      }
    },
    "branch_no": {
      "parentKey": "CONDITION_NODE_ID",
      "parent": "CONDITION_NODE_ID",
      "cat": "conditions",
      "nodeType": "branch-no",
      "sibling": [
        "YES_BRANCH_ID"
      ],
      "next": "FIRST_NO_CHILD_ID",
      "attributes": {
        "else": true,
        "conditionName": "HAS_TAG",
        "operator": "and",
        "branches": []
      }
    },
    "branch_child_actions": {
      "first_child": {
        "parent": "BRANCH_YES_OR_NO_ID",
        "parentKey": "BRANCH_YES_OR_NO_ID",
        "order": 0
      },
      "subsequent_child": {
        "parent": "BRANCH_YES_OR_NO_ID",
        "parentKey": "PREVIOUS_CHILD_ID",
        "order": 1
      }
    },
    "branch_notes": "Branch nodes need BOTH parentKey AND parent (same value). Sibling arrays cross-reference the other branch. Condition node next points to [YES, NO]. Branch node next is a string to the first child when non-empty. CAUTION: Complex branching (>2 if/else in one workflow) can freeze GHL's UI renderer. Use separate exit workflows for nurture sequences instead."
  },
  "workflow_goal": {
    "example": {
      "id": "<UUID>",
      "name": "Goal",
      "type": "workflow_goal",
      "attributes": {
        "op": "or",
        "segments": [
          {
            "op": "or",
            "conditions": [
              {
                "goal_condition": "review_request_clicked",
                "extras": {
                  "reviewTypes": [
                    "sms",
                    "email"
                  ],
                  "reviewLinkId": ""
                },
                "id": "<UUID>"
              }
            ]
          }
        ],
        "type": "workflow_goal",
        "action": "exit"
      }
    },
    "notes": "Goal-event nodes sit inline in the action chain \u2014 when the goal condition fires during workflow execution, the configured action runs (default: 'exit' terminates the workflow path). The previous action's `next` should point to the goal node's id; the goal node itself does NOT have a `next` field. Use build_goal_event to construct the node \u2014 it handles UUID generation and the nested op/segments/conditions shape. Verified goal_condition value: 'review_request_clicked' (with extras { reviewTypes: ['sms', 'email'], reviewLinkId: '' }). Other goal_condition strings from GHL's UI pass through verbatim; the full catalogue of goal conditions has not been captured yet.",
    "build_helper": "build_goal_event(goal_condition, extras?, action='exit', target_node_id?, name='Goal', op='or', inner_op='or')"
  },
  "_trigger_patterns": {
    "real_shape": {
      "id": "<UUID>",
      "date_added": "...",
      "deleted": false,
      "belongs_to": "workflow",
      "location_id": "LOCATION_ID",
      "origin_id": "<UUID>",
      "active": true,
      "workflow_id": "WORKFLOW_ID",
      "masterType": "highlevel",
      "name": "Contact Tag",
      "type": "contact_tag",
      "conditions": [
        {
          "operator": "index-of-true",
          "field": "tagsAdded",
          "value": "some-tag",
          "title": "Tag Added",
          "type": "select",
          "id": "tag-added"
        }
      ],
      "actions": [
        {
          "workflow_id": "WORKFLOW_ID",
          "type": "add_to_workflow"
        }
      ],
      "schedule_config": {},
      "date_updated": "..."
    },
    "customer_reply": {
      "type": "customer_reply",
      "conditions": []
    },
    "appointment": {
      "type": "appointment",
      "conditions": [
        {
          "operator": "eq",
          "field": "calendarId",
          "value": "CALENDAR_ID",
          "title": "Calendar",
          "type": "select",
          "id": "calendar"
        }
      ]
    },
    "contact_tag": {
      "type": "contact_tag",
      "conditions": [
        {
          "operator": "index-of-true",
          "field": "tagsAdded",
          "value": "tag-name",
          "title": "Tag Added",
          "type": "select",
          "id": "tag-added"
        }
      ]
    },
    "pipeline_stage_updated": {
      "type": "pipeline_stage_updated",
      "conditions": [
        {
          "operator": "eq",
          "field": "pipelineStageId",
          "value": "STAGE_ID",
          "title": "Pipeline Stage",
          "type": "select",
          "id": "pipeline-stage"
        }
      ]
    },
    "notes": "Workflow GET requests must include ?includeTriggers=true or triggers silently disappear. Each trigger type has type-specific condition fields."
  },
  "_workflow_design_patterns": {
    "nurture_sequence": "Use linear workflow with stopOnResponse:true. Create separate 'exit workflows' triggered by tags (appointment-booked, do-not-contact) that use remove_from_workflow to pull contacts out. Do NOT use inline if/else gates at every step \u2014 this creates too many actions and can freeze GHL.",
    "exit_workflow": "Small 3-4 step workflow: remove_from_workflow \u2192 note \u2192 tag \u2192 notify. Triggered by a tag being added to the contact.",
    "max_actions": "Keep workflows under 40 actions. GHL's UI renderer struggles with larger workflows. Split into multiple connected workflows if needed."
  },
  "add_to_workflow": {
    "example": {
      "type": "add_to_workflow",
      "name": "Add to: <Target Workflow Name>",
      "attributes": {
        "input_trigger_params": false,
        "type": "add_to_workflow",
        "workflow_id": "TARGET_WORKFLOW_ID"
      }
    },
    "notes": "COPIED FROM A LIVE PUBLISHED NODE on a real account (read-only, 2026-09-01) \u2014 not derived from docs. \u26a0\ufe0f THIS IS NOT THE SAME SHAPE AS remove_from_workflow, which is the adjacent action and the easy mistake: remove needs BOTH `workflowId` (camelCase string) AND `workflow_id` (ARRAY). add needs NEITHER \u2014 `workflow_id` is snake_case and a SINGULAR STRING, there is no `workflowId` and no `workflowName`. `input_trigger_params: false` is present in the working node; purpose unknown, carry it. \u26a0\ufe0f KNOWN DIVERGENCE, UNRESOLVED: src/intake-to-build/executor.ts:806 emits the REMOVE shape under the ADD type (camelCase workflowId + workflowName + workflow_id as an ARRAY, no input_trigger_params). Whether GHL accepts and FIRES that variant is [UNVERIFIED] \u2014 a shape that saves and validates is not evidence it runs (cf. task_notification, wait unit \"day\", internal_notification \"inapp\"). Prove it on a sandbox before trusting either form."
  }
}
