GoliathDevelopersGet an API key
Reference/Workflowsgenerated from the gateway manifest

updateWorkflowGraph

Replace a workflow version's node/edge graph AND its triggers.

POST/api/v1/callWRITEidempotent · 24 hruns as the key's owner
View as Markdown

Replace a workflow version's node/edge graph AND its triggers (to edit, patchWorkflowGraph). Edit the DRAFT (createOrReturnWorkflowDraft). A PAUSED version is writable too, off the normal path. workflowGraph is the superjson-serialized WorkflowGraph shape returned by getWorkflowVersionGraph; send back a modified copy of the existing graph, same serialized field names (node/edge ids are caller-minted strings). An OMITTED top-level key keeps its stored value ({nodes, edges} alone is valid); a trigger needs only {config}; a text/email step lacking its channel's communicationConfig gets the editor default. A valid graph needs at least a trigger node and an end node joined by an edge; every node is {id, type, config} and every edge is {id, config, parentNodeId, childNodeId} (edge config is {} except conditional branches, below; a Wait until signed step: {path}). NODE VOCABULARY: every authorable step type (NOT just text steps), generated from the registry the writer validates against. STRUCTURAL STEPS: the same in every domain. "trigger" (The graph entry point. Every valid graph has exactly one, and every run starts here. It does not decide WHEN the workflow fires: the graph-level triggers array does. config {}: no settings); "conditional" (A two-way fork decided by a filter. Give it TWO outgoing edges: {eval:true} is taken when the record matches, {eval:false} otherwise. config {filter {id, operator "AND" | "OR", negated?, conditions, groups}, uiMode? "PROPERTY" | "USER" | "DEAL" | "TAG" | "CUSTOM_FIELD" | "CONTRACTS" | "CUSTOM_CONDITION" | "RUN_VALUES", filterMode? "simple" | "manual", valueConditions? {match "all" | "any", conditions}}); "ai_conditional" (The same two-edge {eval:true}/{eval:false} fork as conditional, decided by a MODEL reading the record. Every evaluation is a billed model call, so prefer conditional wherever a filter answers. config {prompt, model "gpt-6-astra" | "gpt-6-sol" | "gpt-6-luna" | "gpt-6.1-sol" | "claude-fable-5-1" | "claude-opus-5-5" | "claude-sonnet-5-5" | "claude-haiku-5-5" | "gemini-3.8-flash" | "jev-1.13.0", context {contact?, deal?, property?, appointment?, organization?, event? {mode:"whole"} | {mode:"fields", paths}}}); "wait" (Hold the run until a deadline, optionally ending early on a predicate. mode picks the deadline (fixed | range | datetime | record_date | recurring); until or the interrupt* trio is the early exit. A wait has exactly ONE outgoing edge: early exit and timeout share it. config {mode:"fixed", seconds, secondsTableColumnId?} | {mode:"range", minSeconds, maxSeconds} | {mode:"datetime", date, time {hour, minute}, timezone? "Etc/GMT+12" | "Pacific/Pago_Pago" | "Pacific/Honolulu" | "America/Anchorage" | "America/Los_Angeles" | "America/Denver" | "America/Phoenix" | "America/Chicago" | "America/New_York" | "America/Halifax" | "America/Sao_Paulo" | "America/Noronha" | "Atlantic/Azores" | "Etc/UTC" | "Europe/Berlin" | "Europe/Athens" | "Europe/Moscow" | "Asia/Dubai" | "Asia/Karachi" | "Asia/Dhaka" | "Asia/Bangkok" | "Asia/Shanghai" | "Asia/Tokyo" | "Australia/Sydney" | "Pacific/Noumea" | "Pacific/Auckland"} | {mode:"record_date", field, customFieldId?, offsetSeconds, ifPassed? "advance" | "stop", graceSeconds?} | {mode:"recurring", window {timezone (same values as the first "timezone" above), days, timezoneMode? "RECIPIENT" | "CONFIGURED", monthDays?}}, plus on every shape: interruptEnabled?, interruptFilterMode? "simple" | "manual", interruptFilter {id, operator "AND" | "OR", negated?, conditions, groups}, until? {kind:"reply", channels? ("CALL" | "TEXT" | "EMAIL")[]} | {kind:"action", action, targetId?}); "end" (Terminates the run. A valid graph needs at least one, reachable from the trigger. config {} (no settings); EXCEPTION) on EVENT, "conditional" config is a uiMode union. Absent/"EVENT_PAYLOAD" = payload arm, NO filter: {groupOperator?: "AND"|"OR", conditions [{accessor: string, operator: string, values?: string[], mode?: "any"|"all"}]}; accessor a dot/bracket path into the webhook body ("event." aliases its root), operator equals|not_equals|contains|not_contains|is_set|is_not_set, values an ARRAY (a bare string is rejected) required except for is_set/is_not_set, mode lowercase where groupOperator is uppercase. PROPERTY|USER|DEAL|TAG|CUSTOM_FIELD|CUSTOM_CONDITION = contact arm: {uiMode, filter}, a CONTACT (not EVENT) FilterGroup over the contact the find/create step resolved. A step type you find in a stored graph but not documented for its domain below is RETIRED (it parses so old graphs still load, and its executor stops the run): never author one, and never copy one into a new graph. PROPERTY actions: "create_contact_action" (Skiptrace the property's owner and FIND-OR-CREATE the contact: so it lands on contacts that already have an owner. It spends a skiptrace credit, and an org out of credits STOPS the run. The daily skip-trace permit is refunded on that stop only on a PROPERTY_SIGNAL_SKIP_TRACE graph, the only kind that took one. Finding nobody advances the run. config {target? "OWNER" | "LISTING_AGENT", contactListIds?, triggerContactWorkflowGroupIds?, contributorUserIds?, assignAgent? {mode:"single_user", userId?} | {mode:"round_robin", userIds} | {mode:"workspace_lead_routing"}, contactListTableColumnId?, triggerContactWorkflowTableColumnId?, assignAgentTableColumnId?, contributorTableColumnId?, replaceExistingAgent, deal? {title, missingVariableBehavior? "END_WORKFLOW" | "SEND_FALLBACK", fallbackDealTitle?, description?, pipelineId, stageId, assigneeUserIds?, usePointPersonAssignee?, propertyConfig "none" | "add_all_verified_properties" | "add_all_properties" | "add_first_verified_property"}, autoReskiptraceOnNewInformation?}); "add_to_list_action" (Add the property this run is about to one or more property lists. config {listIds, listTableColumnId?}); "send_slack_action" (Post a Block Kit message about this property into a Slack channel. config {slackChannelId?, slackChannelName?, message? {text, blocks?}, textBody?}); "send_webhook_action" (POST the property's record to one or more URLs. A 4xx other than 429 STOPS the run quietly; anything else FAILS it. config {urls?, auth? {kind:"HEADER", headerName, secret} | {kind:"HMAC", secret}, fields?}); "property_ai_employee_action" (Run one AI employee on THIS PROPERTY once, without waiting for it; it messages no one from here. config {agentId, instructions?}); CONTACT actions: "text_action" (Send an SMS to the contact, and allowed ONLY where the communication channel is TEXT: any other workflow rejects it. How many numbers it targets and when it may send are workflow-level (config.communicationConfig); who it sends from is too, unless the step carries its own senderOverride. config {mode:"single", templateId?, textBody?, skipIfEmptyBody?} | {mode:"multiple", textBodies} | {mode:"generative", systemPrompt}, plus on every shape: missingVariableBehavior? "END_WORKFLOW" | "SEND_FALLBACK", timeSensitive?, senderOverride? {type:"UNSET"} | {type:"POINT_PERSON"} | {type:"PHONE", fromPhoneNumberId} | {type:"RANDOM_PHONE", fromPhoneNumberIds}, fallbackTextBody?, senderTableColumnId?, templateTableColumnId?, attachments? [{uploadedFileId, fileName, contentType, fileSize}]); "email_action" (Send an email to the contact; allowed ONLY in an EMAIL-channel workflow. Recipient count, send window and sender are workflow-level (config.communicationConfig) unless the step sets senderOverride. config {generate:true, systemPrompt} | {generate:false, templateId, templateTableColumnId?} | {generate:false, emailBody, emailBodyFormat?:"TIPTAP_JSON", emailSubject?}, plus on every shape: missingVariableBehavior? "END_WORKFLOW" | "SEND_FALLBACK", timeSensitive?, senderOverride? {type:"UNSET"} | {type:"POINT_PERSON"} | {type:"IDENTITY", fromEmail, fromClientId} | {type:"RANDOM_IDENTITY", identities}, fallbackEmailSubject?, fallbackEmailBody?, attachments? [{uploadedFileId, fileName, contentType, fileSize, page?}]); "notification_action" (Text a TEAMMATE about this contact: never the contact. It always sends from the internal notifications line, so the workflow sender does not apply. config {recipientType:"POINT_PERSON"} | {recipientType:"SPECIFIC_USER", recipientUserIds?, recipientUserId?} | {recipientType:"PHONE_NUMBER", recipientPhoneNumber}, plus on every shape: missingVariableBehavior? "END_WORKFLOW" | "SEND_FALLBACK" | "OMIT_BLANKS", fallbackTextBody?, textBody); "ai_employee_action" (No start: a schedule ADDS the employee and pins the cadence, runNow alone only runs, neither only ADDS (it answers later replies). start: runs now, never adds. config {agentId, runNow?, instructions?, waitForCompletion?, schedule? {cronExpression, timezone, maxRuns}, start? "ANSWER" | "RUN_NOW", waitTimeoutMinutes?}); "set_custom_field_action" (Write one value onto one of the contact's custom fields. customFieldType picks which value field the config must carry; the write is stamped as an AUTOMATION change. config {customFieldType:"TEXT", textValue} | {customFieldType:"NUMBER", numberValue} | {customFieldType:"DOLLAR", numberValue} | {customFieldType:"DATE", dateIso, relative?:"RUN_DATE"} | {customFieldType:"DROPDOWN", dropdownOptionId} | {customFieldType:"LINK", textValue}, plus on every shape: customFieldId, customFieldName); "tags_action" (Apply tags (and/or add the contact to lists) by id. config {tagIds, contactListTableColumnId?}); "add_user_group_action" (Add the contact to one or more teams, optionally auto-assigning an Agent from one of them. config {userGroupIds, agentAssignment? {kind:"NONE"} | {kind:"TEAM_ROUND_ROBIN", userGroupId} | {kind:"SPECIFIC_USER", userId}}); "contributors_action" (Attach users to the contact, as the single Agent or as Participants. BOTH booleans are required: an omission used to mean notify-and-displace, which is not what a new node means. config {contributors [{userId, status "REVOKED" | "POINT_PERSON" | "PARTICIPANT"}], roundRobin? {userIds, role "REVOKED" | "POINT_PERSON" | "PARTICIPANT"}, sendNotificationToContributor, replaceExistingAgent}); "task_action" (Create a CRM task about this contact, either spelled out here or from a content template. config {mode:"inline", name, taskType, dueOffset {amount, unit "DAYS" | "WEEKS" | "MONTHS"}, preferredTimeOfDay {hour, minute, timezone (same values as the first "timezone" above)}, description?} | {mode:"template", templateId}, plus on every shape: assigneeUserIds, assigneeTableColumnId?, usePointPersonAssignee?, createGoogleCalendarEvent?); "appointment_action" (Book an appointment with this contact at an offset from now. config {name, startOffset {amount, unit "DAYS" | "WEEKS" | "MONTHS"}, preferredStartTimeOfDay {hour, minute, timezone (same values as the first "timezone" above)}, durationMinutes, description?, attendeeUserIds, attendeeTableColumnId?, usePointPersonAssignee?, createGoogleCalendarEvent?}); "deal_action" (Open a deal for this contact in a named pipeline and stage. config {title, missingVariableBehavior? "END_WORKFLOW" | "SEND_FALLBACK", fallbackDealTitle?, description?, pipelineId, stageId, assigneeUserIds?, usePointPersonAssignee?, propertyConfig "none" | "add_all_verified_properties" | "add_all_properties" | "add_first_verified_property", skipIfContactHasOpenDealInPipeline?}); "update_deal_action" (Move the contact's EXISTING open deals on one board to a stage, or archive them: on one board, or on every board with anyBoard. Creates nothing: deal_action opens a deal. config {operation "move_stage" | "archive", pipelineId, anyBoard?, stageId?}); "send_webhook_action" (POST the contact's record to one or more URLs. A 4xx other than 429 STOPS the run quietly; anything else FAILS it and pages. config {urls?, phoneCompliance? "NONE" | "EXCLUDE_DNC" | "EXCLUDE_LITIGATOR" | "EXCLUDE_DNC_OR_LITIGATOR" | "EXCLUDE_DNC_AND_LITIGATOR" | "EXCLUDE_DNC_AND_LITIGATOR_ONLY", payloadBlocks? {calls?, texts?, notes?, deals?, properties?}, auth? {kind:"HEADER", headerName, secret} | {kind:"HMAC", secret}, fields?, credentialId?}); "send_slack_action" (Post a Block Kit message about this contact into a Slack channel. config {slackChannelId?, slackChannelName?, message? {text, blocks?}}); "trigger_contact_workflow_action" (Start other CONTACT workflows on this contact, even where their own trigger would not match. config {workflowGroupIds, workflowGroupTableColumnId?}); "enrich_action" (Skiptrace the contact and fold the result back onto it: the same enrichment the Enrich button runs. Already-enriched contacts dedupe server-side, so reaching this step twice neither re-skiptraces nor charges twice. It spends a SKIPTRACE credit per skiptrace that actually runs, and the author must hold the skiptrace capability. config {}: no settings); "archive_contact_action" (Archive the contact, as the Archive button does. A text back un-archives them. Put it last. config {}: no settings); "mark_do_not_contact_action" (Mark the contact Do Not Contact. It stops every live run for the contact, this one included: put it last in its branch. config {reason?}); "send_mail_action" (Mail one letter to the run's contact through the org's connected mail account. A refusal (no usable address, unverified template, disconnected account) is recorded and the run continues. config {campaignRef {providerKey:"OPEN_LETTER_CONNECT", templateId, productId, returnAddressSettings, rosOfferPercentage} | {providerKey:"LOB", product:"LETTER", templateId, printOptions} | {providerKey:"POSTGRID", product:"LETTER", templateId, printOptions}, campaignLabel, connectionId, sender {version:1, firstName, lastName, companyName, line1, line2, city, state, zip, countryCode:"US"}, mergeFields [{key, required, value {kind:"LITERAL", text} | {kind:"FIELD", field}}], addressSource "CONTACT_ONLY" | "CONTACT_THEN_OWNER_MAILING", sendTiming {mode:"IMMEDIATE"} | {mode:"PROVIDER_SCHEDULED", releaseAfterDays}}); "add_to_email_campaign_action" (Add the run's contact as a lead to a campaign in the org's connected cold-email account, which sends on its own schedule. A refusal is recorded; the run continues. config {connectionId, campaign {providerKey "INSTANTLY" | "SMARTLEAD" | "LEMLIST" | "REPLYIO", campaignId, campaignName}, variables [{key, field "COMPANY_NAME" | "PROPERTY_ADDRESS" | "PROPERTY_CITY" | "PROPERTY_STATE" | "PROPERTY_ZIP" | "MAILING_ADDRESS"}], launchAfterAdd?}); "go_to_action" (Terminal: loops back to an earlier step on its branch. Needs 1h of Waits and an exit (reply exit or a branch to an End). config {targetNodeId}); "contact_send_contract_action" (Send a contract from a template to this contact; a blank required value holds it. config {templateId, sender {mode:"record_owner"} | {mode:"user", userId}, ifAlreadyOut "KEEP_AND_RESEND" | "KEEP_QUIETLY" | "REPLACE" | null, signing? {expiryDays, dailyReminders, linkChannel "TEXT_FIRST" | "EMAIL_FIRST", senderSignsLast}}); "contact_wait_for_contract_action" (Wait on the sent contract; edge path signed|declined|not_signed. config {timeoutSeconds}); DEAL actions: "assign_to_user_action" (Assign users to the deal this run is about. config {userIds, userTableColumnId?}); "notification_action" (Text teammates about this deal. Always sends from the internal notifications line. config {recipientType:"DEAL_USERS"} | {recipientType:"SPECIFIC_USERS", userIds}, plus on every shape: recipientTableColumnId?, missingVariableBehavior? "END_WORKFLOW" | "SEND_FALLBACK", fallbackTextBody?, textBody); "trigger_contact_workflow_action" (Enroll each of this deal's CONTACTS in the listed CONTACT workflows; not the deal itself; no contacts starts nothing. Each passes the usual enrolment gates (do-not-contact, one live run per workflow, consent). config {workflowGroupIds, workflowGroupTableColumnId?, roleId?}); "trigger_deal_workflow_action" (Enroll THIS DEAL in other DEAL workflows (trigger_contact_workflow_action enrolls its contacts). Each target passes the deal enrolment door (trigger window, dry-run flag, one live run per deal); this graph's group and every group upstream of the run are never targets. config {workflowGroupIds, workflowGroupTableColumnId?}); "create_deal_action" (Open a NEW deal, cloning the properties of the deal this run is about. config {title, missingVariableBehavior? "END_WORKFLOW" | "SEND_FALLBACK", fallbackDealTitle?, description?, pipelineId, stageId, assigneeUserIds?, usePointPersonAssignee?, propertyConfig "none" | "add_all_verified_properties" | "add_all_properties" | "add_first_verified_property", copyDealValues?}); "send_slack_action" (Post a Block Kit message about this deal into a Slack channel. DEAL merge fields only. config {slackChannelId?, slackChannelName?, message? {text, blocks?}}); "send_webhook_action" (POST the deal's record to one or more URLs. A 4xx other than 429 STOPS the run quietly; anything else FAILS it. config {urls?, auth? {kind:"HEADER", headerName, secret} | {kind:"HMAC", secret}, fields?}); "deal_set_custom_field_action" (Write one value onto a deal custom field; customFieldType picks the value field. Pipeline-scoped: the run STOPS if the deal is in another pipeline or the field/option is deleted. config {customFieldType:"TEXT", textValue} | {customFieldType:"NUMBER", numberValue} | {customFieldType:"DOLLAR", numberValue} | {customFieldType:"DATE", dateIso, relative?:"RUN_DATE"} | {customFieldType:"DROPDOWN", dropdownOptionId} | {customFieldType:"LINK", textValue}, plus on every shape: pipelineId, customFieldId, customFieldName); "deal_add_note_action" (Add a system-attributed note to the deal's activity feed. A blank rendered note is skipped and the run continues. config {body, missingVariableBehavior? "END_WORKFLOW" | "SEND_FALLBACK", fallbackBody?}); "deal_task_action" (Create a task linked to THIS DEAL (not its contacts), spelled out here or from a content template. config {mode:"inline", name, taskType, dueOffset {amount, unit "DAYS" | "WEEKS" | "MONTHS"}, preferredTimeOfDay {hour, minute, timezone (same values as the first "timezone" above)}, description?} | {mode:"template", templateId}, plus on every shape: assigneeUserIds, assigneeTableColumnId?, assignDealUsers?, createGoogleCalendarEvent?); "deal_appointment_action" (Book an appointment linked to THIS DEAL at an offset from now. The run STOPS (NO_ATTENDEES) when the named users, the table column and the deal's users resolve to nobody. config {name, startOffset {amount, unit "DAYS" | "WEEKS" | "MONTHS"}, preferredStartTimeOfDay {hour, minute, timezone (same values as the first "timezone" above)}, durationMinutes, description?, attendeeUserIds, attendeeTableColumnId?, inviteDealUsers?, createGoogleCalendarEvent?}); "deal_send_contract_action" (Send a contract from a template for this deal; a blank required value holds it. config {templateId, sender {mode:"record_owner"} | {mode:"user", userId}, ifAlreadyOut "KEEP_AND_RESEND" | "KEEP_QUIETLY" | "REPLACE" | null, signing? {expiryDays, dailyReminders, linkChannel "TEXT_FIRST" | "EMAIL_FIRST", senderSignsLast}, includeSecondContact?, contactARoleId?, contactBRoleId?}); "deal_wait_for_contract_action" (Wait on the sent contract; edge path signed|declined|not_signed. config {timeoutSeconds}); "deal_ai_employee_action" (Run one AI employee on THIS DEAL once, without waiting for it; it messages no contact from here. config {agentId, instructions?}); APPOINTMENT actions: "text_action" (Text the appointment's contact. Deliberately narrower than the CONTACT text step: it IGNORES the workflow send window and the daily-cap cooldown, and has no AI or variations mode. The sender comes from the meeting's own participants, booking host first. config {missingVariableBehavior? "END_WORKFLOW" | "SEND_FALLBACK", fallbackTextBody?, senderTableColumnId?, templateTableColumnId?, mode:"single", templateId?, textBody?}); "trigger_contact_workflow_action" (Start CONTACT workflows for this appointment's contacts. config {workflowGroupIds, workflowGroupTableColumnId?}); "send_slack_action" (Post a Block Kit message about this appointment to a Slack channel. It loads the TASK alone, so contact merge fields render literally here. config {slackChannelId?, slackChannelName?, message? {text, blocks?}}); "send_webhook_action" (POST the appointment's record to one or more URLs. A 4xx other than 429 STOPS the run quietly; anything else FAILS it. config {urls?, auth? {kind:"HEADER", headerName, secret} | {kind:"HMAC", secret}, fields?}); EVENT actions: "find_create_contact_action" (Find or create the payload's contact; later steps run on it. config {mode:"field_mapping", nameAccessor, phoneAccessors?, emailAccessors?, propertyAddressAccessors?, mailingAddressAccessors?, noteAccessors?, customFieldMappings? [{customFieldId?, attributionKey?, accessor?, literalValue?, valueMappings?, unmappedValuePolicy?}], tagMappings? [{kind, accessor?, literalValue?, valueMappings?, unmappedValuePolicy?}], webFormConsent? {consentAccessor?}, phoneAccessor?, emailAccessor?, propertyAddressAccessor?, noteAccessor?} | {mode:"transformer", hint?}, plus on every shape: triggerContactWorkflowGroupIds?, triggerContactWorkflow? {workflowGroupId}, source?, contactMatchRule? {keys}); "event_send_slack_action" (Post a plain-text Slack message built from the inbound payload. A distinct node from the shared Block Kit one. config {slackChannelId?, slackChannelName?, message}); "event_find_property_action" (Resolve a property from the inbound payload, then act on it. A FLAT config with string mode fields on purpose (nested unions blow the TypeScript complexity ceiling once distributed across every domain) so which fields are required per mode is enforced by the validator and the executor, not by the shape. config {resolutionMode, addressAccessor?, line1Accessor?, cityAccessor?, stateAccessor?, zipAccessor?, countyNameAccessor?, parcelAccessor?, countyFipsAccessor?, transformerHint?, lastMappedResolutionMode?, actionType, listIds?, triggerContactWorkflowGroupIds?}); "event_contact_status_action" (Flag the contact this run is anchored to: mark it do-not-contact, or mark one of its phone numbers wrong. It acts on the contact an earlier find/create step resolved, never resolving one; with no contact it records that and advances. It does NOT stop the run; an end node does. config {action, phoneAccessor?, appliesToOutcomes?, outcomeAccessor?}); "event_contact_fields_action" (Write custom-field values on the contact this run is anchored to. It never resolves or creates one, so it can sit on a branch that must not mint anybody; with no contact it records that and advances. config {customFieldMappings? [{customFieldId?, attributionKey?, accessor?, literalValue?, valueMappings?, unmappedValuePolicy?}]}); "event_attach_call_recording" (Download the recording the payload names and file it on the contact an earlier step resolved, as a Call. It resolves no contact of its own, and every skip or failure is recorded and advanced rather than failing the run. config {recordingUrlAccessor, recordingIdAccessor?, phoneAccessor?, occurredAtAccessor?, directionAccessor?, durationSecondsAccessor?}); SLACK actions: "slack_reply_action" (Post back into Slack: into the thread the run came from, or into a fixed channel. config {target "triggering_thread" | "channel", slackChannelId?, slackChannelName?, message}); CONTRACT actions: "contract_move_deal_action" (Move the contract's deal to a stage, the way dragging its card does. A contract with no deal skips the step and the run carries on. config {pipelineId, stageId}); "contract_tag_signers_action" (Tag the contract's signers (and/or add them to lists). config {signers "contact_a" | "contact_b" | "each_signer", tagIds}); "contract_start_workflow_action" (Hand the contract's contacts to an existing Contact workflow (a seller nurture after a decline). That workflow keeps its own rules. config {signers "contact_a" | "contact_b" | "each_signer", workflowGroupIds}); "contract_notify_action" (Text teammates about the contract. Nobody to reach skips it. config {teammates ("sender" | "deal_users" | "contact_a_agent")[], userIds, textBody, missingVariableBehavior? "END_WORKFLOW" | "SEND_FALLBACK" | "OMIT_BLANKS", fallbackTextBody?}); "contract_remind_signers_action" (Send a fresh link to each signer whose turn it is. Skipped when nobody is waiting. config {}: no settings); "contract_extend_expiry_action" (Move the contract's expiry later. Skipped unless out for signature. config {days}); "contract_void_action" (Void the contract; no link works again. Skipped once signed or closed. config {reason});THAT FilterGroup IS THE FULL SAVED-FILTER VOCABULARY FOR THE DOMAIN, not an automation-only subset. For CONTACT, PROPERTY and DEAL that vocabulary is enumerable: listFilterFieldNames returns it complete, and any field it lists a conditional can evaluate. NEVER tell a user an automation cannot consider some attribute without reading that list for the workflow DOMAIN first: "it cannot see it" is usually a domain mismatch (last sold date, year built, estimated value are PROPERTY fields, not contact fields). APPOINTMENT conditionals take their own full filter group, but listFilterFieldNames and listFilterFields do not accept APPOINTMENT. That is a gap in DISCOVERY, never evidence about the capability: copy a conditional already in the graph; if you cannot confirm a field, say so rather than that it cannot be done. AI CONDITION CONTEXT: an "ai_conditional" step's context is a Context Layer SELECTION, not a list of ids: an object keyed by any of contact | deal | property | appointment | organization, each mapping to that source's slice flags (e.g. {contact:{includeTextMessages:true, includeNotes:false}}). A key's PRESENCE includes it; {} means that source with its defaults. Only sources the RUN carries an id for resolve (a CONTACT run has a contact), so one the domain never carries renders nothing. The step is available on APPOINTMENT graphs although "conditional" is not. A sixth key event is EVENT-only, and on an EVENT graph the ONE source that renders (an EVENT run carries no record): {mode:"whole"} sends the whole webhook body, {mode:"fields",paths:["call.outcome"]} only the named accessor paths (1-25, none reading the whole payload; use whole for that). {event:{}} is refused, and event on any other domain is a publish error. EVENT MAPPINGS: two rules mapping rows are REJECTED for. (1) EXACTLY ONE destination and EXACTLY ONE value source per row: a customFieldMappings row names either a customFieldId or an attributionKey (one of the org's reserved, listing-hidden attribution fields: utm_source, utm_medium, utm_campaign, utm_term, utm_content, gclid, fbclid, msclkid, ttclid, li_fat_id, landingUrl, referrerUrl), and either an accessor or a literalValue. A row naming two of either is dropped and counted at execution, never resolved by precedence. (2) A tagMappings row resolves its value to a tag or list BY NAME and CREATES one when the org has none, bounded at 25 distinct values per row and refusing values that are obviously not category names (a uuid, an email, a url, a long-digit platform id, over 80 characters); tags and lists share one org-wide namespace, so a name a list holds cannot become a tag and that row is dropped. Both counts ride the run's FIND_CREATE_CONTACT_RESOLVED audit event as tagsWritten/tagsDropped. MERGE FIELDS: the COMPLETE per-domain vocabulary a body may draw on. Substituted at run time: textBody, textBodies, emailBody, emailSubject, a Slack block's text and a Slack button's url, plus a deal node's title and a task node's name and description. NOT substituted, so braces there are left alone: a generative node's systemPrompt (an instruction to a model, not a template) and a deal node's description. Keys are matched EXACTLY, case-sensitively. There is NO dotted record namespace: {{contact.address1}}, {{contact.firstName}} and {{deal.title}} are not merge fields (the SLACK keys below are literal dotted names, not a namespace you may extend). Do not infer a key from another domain's spelling either: the shapes differ (CONTACT has contactPropertyAddress, PROPERTY has full_address); use a key off this list or none. An unknown token is NOT a missing variable and does NOT render blank: braces reach the reader verbatim (except a notify step on OMIT_BLANKS, which blanks them). promoteWorkflowDraft REFUSES a draft carrying one in a SCANNED FIELD (textBody, textBodies, emailBody (including one stored as emailBodyFormat "TIPTAP_JSON", whose merge fields are document pills rather than braces and are read as such), the WORKFLOW-LEVEL email subject on communicationConfig and a NODE'S OWN one, a fallbackTextBody under SEND_FALLBACK, an email step's fallbackEmailSubject and fallbackEmailBody, under SEND_FALLBACK and only where a PRIMARY can miss a variable (the EFFECTIVE subject) the workflow's unless sendInSameThread is false (or a body the graph carries), a Slack block's text or its button url, a deal node's title and fallbackDealTitle on a CONTACT or DEAL graph, and an inline task node's name and description) and updateWorkflowGraph returns that same judgement as workflowGroup.draftValidation on every write: a reading of the GROUP'S DRAFT, which is the version you just wrote whenever you followed the normal path and edited the draft. Edit a PAUSED version instead and the field still answers about the draft, so it is null when there is none and about a DIFFERENT version when there is: it is not a verdict on your write in that case, and nothing in the response says so ({isValid, errors}, each unrenderable token named with the field it sits in and the supported key it most likely meant). READ IT on the response to the write and fix what it names, rather than discovering it when activation fails. THAT LIST OF SCANNED FIELDS IS CLOSED, and it is still SHORTER than the list of fields that are substituted. Anything substituted at run time but absent from it is checked by nothing, so a clean draftValidation is not evidence about it: check those against the vocabulary below yourself. What remains unscanned, and NOT an exhaustive list: a PROPERTY create-contact step's deal title and fallback title (substituted against the NEW CONTACT and the workflow user, not against the PROPERTY graph's own vocabulary, so the gate has no single vocabulary to judge them by and deliberately does not try); a Slack reply node's message; and any body that lives in a referenced templateId rather than in the graph: the graph then carries no body for the scan to look at. In every one of them a wrong key promotes clean. What the recipient sees differs by path: most render through the token renderer and arrive as LITERAL BRACES, but a Slack reply node's message is interpolated by its own function, which replaces any {{slack.<field>}} it does not recognise with an EMPTY STRING: so a typo there shows as missing text, not a broken template. PROPERTY: {{line1}}, {{line2}}, {{propertyCity}}, {{state}}, {{zip}}, {{full_address}}, {{property_url}}, {{beds}}, {{baths}}, {{sqft}}. CONTACT: {{fullName}}, {{firstName}}, {{lastName}}, {{contactUrl}}, {{contactPhoneNumber}}, {{contactEmail}}, {{contactPropertyAddress}}, {{contactPropertyStreet}}, {{contactPropertyFullAddress}}, {{contactPropertyCity}}, {{contactPropertyZipcode}}, {{contactPropertyAuctionDate}}, {{contactPropertyForeclosureStage}}, {{contactPropertyAmountOwed}}, {{userFullName}}, {{userFirstName}}, {{userLastName}}, {{userPhoneNumber}}, {{userEmail}}, {{userOrganizationName}}. DEAL: {{dealTitle}}, {{dealUrl}}, {{dealPipelineName}}, {{dealStageName}}, {{dealPrice}}, {{dealCloseDate}}, {{dealPropertyLine1}}, {{dealPropertyLine2}}, {{dealPropertyCity}}, {{dealPropertyState}}, {{dealPropertyZip}}, {{dealPropertyFullAddress}}, {{dealPropertyCounty}}, {{dealPropertyApn}}, {{dealPropertyLegalDescription}}, {{dealContactFullName}}, {{dealContactFirstName}}, {{dealContactLastName}}. APPOINTMENT: {{appointmentStartTime}}, {{appointmentEndTime}}, {{appointmentMeetingLink}}, {{appointmentLocation}}, {{appointmentDurationMinutes}}, every CONTACT key above. EVENT: no merge fields at all: a {{...}} in an EVENT body is an ACCESSOR PATH into the inbound webhook payload (see the EVENT actions above), not a merge field, and is never validated against this vocabulary. SLACK: {{slack.text}}, {{slack.userName}}, {{slack.channelName}}, {{slack.permalink}}. CONTRACT: {{contractName}}, {{contractSigningLink}}, {{contractUrl}}, {{contractSignedPdfUrl}}, {{contractDeclineReason}}, every CONTACT key above, every DEAL key above. Two conditional keys, and neither is legal on every graph: {{agentName}} resolves ONLY where the graph's TEXT sender is a marketing campaign, and a contact CUSTOM FIELD (addressable as {{ContactTag_<uuid>}}, where the uuid is a CUSTOM-FIELD id from listContactCustomFields and NOT a tag id from searchContactTags, however much the prefix suggests otherwise (ContactTag is the table that stores tags, lists and custom fields alike, so the GRN is named for the table rather than for the thing; a real tag id passes the shape check, is accepted by promote, and renders empty)) resolves ONLY on CONTACT and APPOINTMENT and CONTRACT graphs, the ones a send loads contact context for: and on an APPOINTMENT graph only from the TEXT REMINDER, which renders with the task, the contact and the user. An appointment Slack post loads the task alone, so every CONTACT key there, tags included, renders literally even though promote accepts it: the gate judges per DOMAIN, and this is the one place a domain is wider than one of its own nodes. Used outside its condition either one is unrenderable and blocks promote exactly like an unknown key.Triggers are managed through the triggers array: each entry is upserted by its id (mint a UUID to add one) and omitted entries are detached; only config syncs (name is cosmetic). Trigger config shapes: {type: "manual"}, {type: "created"}, {type: "mutation", filter: <FilterGroup>}, {type: "match", filter: <FilterGroup>}; a CONTACT match whose root AND holds one lastActivityAt or lastNoteTakenAt {comparison:"MORE_THAN", number, timeUnit} and no other relative date is the "no activity for N days" trigger: an hourly sweep enrolls each contact as it crosses N; CONTACT-only: {type: "inbound_text"|"inbound_call", twilioPhoneNumberIds: [...]}, {type: "inbound_email", connectedAccountIds?: [...]}; EVENT workflows have exactly one trigger shape, {type: "event_received"}: the workflow's minted webhook URL is the trigger. Minimal valid drip example (node/edge shape): nodes: [{id:"n1",type:"trigger",config:{}}, {id:"n2",type:"text_action",config:{mode:"single",textBody:"Hi {{firstName}}"}}, {id:"n3",type:"wait",config:{mode:"fixed",seconds:172800,interruptEnabled:false,interruptFilter:{id:"f1",operator:"AND",conditions:[],groups:[]}}}, {id:"n4",type:"end",config:{}}], edges: [{id:"e1",config:{},parentNodeId:"n1",childNodeId:"n2"}, {id:"e2",config:{},parentNodeId:"n2",childNodeId:"n3"}, {id:"e3",config:{},parentNodeId:"n3",childNodeId:"n4"}]. PROMOTE GOTCHA: promoteWorkflowDraft rejects the default UNSET sender (SenderNotChosen): set a real one (e.g. {type: "POINT_PERSON"}) first; getWorkflow.draftValidation lists every blocker. GRAPH CONFIG: graph.config, beside nodes/edges/triggers, NOT per-node. Every domain: triggerOperator "AND"|"OR" (several triggers: all or any); triggerWindow, which limits when ENROLLMENT may happen; exclusiveEnrollment true STOPS the record's runs in every OTHER workflow (not prior versions of this one) when it enters this one: "only one campaign at a time per lead"; inert on EVENT graphs. activation.triggerEligibleRecordsNow is RETIRED: parsed, read by nothing, enrolls nobody: do not write it. CONTACT graphs add exitConditions.exitConditionTypes: any of "any_inbound_communication", "inbound_text", "inbound_email", "contact_archived", "deals_closed", "outbound_from_human", "outbound_from_ai_employee", "outbound_from_other_workflow", "contact_signed_contract", "property_listed", "property_off_market"; there is no per-node way. The "outbound_from_*" three end the run when somebody ELSE messages the contact, never on its own workflow group's message. On APPOINTMENT omit exitConditions: any value, even {}, is rejected. communicationConfig is NOT contact-only: APPOINTMENT graphs take it too, for a reminder's quiet hours and sender. SEND WINDOW: the sending schedule is not in the graph: getWorkflow.sendWindow, set by updateWorkflowSendWindow. It and triggerWindow share the shape {timezone, days:[{day:"MON".."SUN", intervals:[{start:{hour,minute}, end:{hour,minute}}]}], timezoneMode?}. Each day has its OWN intervals ("weekdays 9-5, Sundays 12-4" is one window); an absent day is CLOSED, an interval never passes midnight (end after start), and an end of {hour:0,minute:0} is midnight at the END of the day. The older {daysOfWeek?, startTime?, endTime?} (same hours each listed day) is still ACCEPTED, but days is what is stored and read back. hour AND minute are BOTH REQUIRED on each time ({hour:9} alone is rejected). Clock times, not offsets: "weekdays 9-6" is one days entry per MON..FRI with {start:{hour:9,minute:0}, end:{hour:18,minute:0}}. No sending schedule falls back to MON-FRI 09:00-19:00 org time, so no weekends. timezoneMode is WHOSE clock the times are read on: "BUSINESS" the ORGANISATION's timezone, read live (New York when unset), and what an OMITTED sendWindow.timezoneMode reads as; "RECIPIENT" each recipient's zone from their phone's area code (toll-free, non-NANP numbers and EMAIL fall back to the organisation's); "CONFIGURED" the window's own timezone, fixed. triggerWindow.timezoneMode accepts only "RECIPIENT"|"CONFIGURED" ("BUSINESS" there is REJECTED by the graph parser) and is inert: its times are always read in its own timezone. The window is final: no quiet hours are added on top, for TEXT or EMAIL. SENDER: communicationConfig.text.sender / .email.sender, both allowed at once. A mode name alone is not a valid sender: most modes carry required fields. TEXT: {type:"UNSET"} | {type:"POINT_PERSON"} | {type:"MEETING_OWNER"} (appointment graphs) | {type:"PHONE", fromPhoneNumberId} | {type:"RANDOM_PHONE", fromPhoneNumberIds:[...]} | {type:"LOCAL_PRESENCE", scope, fallback}: LOCAL_PRESENCE picks a team number whose area code matches the recipient, and BOTH scope and fallback are required: scope is {kind:"ALL_TEAM"} | {kind:"SPECIFIC", fromPhoneNumberIds} | {kind:"EXCLUDE", excludedPhoneNumberIds}, and fallback is {type:"POINT_PERSON"} | {type:"PHONE", fromPhoneNumberId} | {type:"RANDOM_PHONE", fromPhoneNumberIds:[...]}. EMAIL: {type:"UNSET"} | {type:"POINT_PERSON"} | {type:"IDENTITY", fromEmail, fromClientId} | {type:"RANDOM_IDENTITY", identities:[...]}. UNSET is the creation default and activation is GATED on replacing it. A text_action or email_action step may carry its OWN config.senderOverride for that step alone (absent inherits): the same TEXT/EMAIL arms above except MEETING_OWNER and LOCAL_PRESENCE. SYSTEM CATEGORY: createWorkflow's separate systemCategory variable, not in the graph. All domains allow CUSTOM workflows, PROPERTY included, so null is right unless creating from a category's template. PROPERTY's one category, "PROPERTY_SIGNAL_SKIP_TRACE", is the skip-trace template with a daily cap; a null PROPERTY workflow is blank, started manually, by a property signal or by a custom data pipeline. A non-null systemCategory requires a templateId on the same create ("A system-category workflow needs a template id" does NOT mean property workflows are unsupported). NO domain offers "APPOINTMENT_REMINDER" or "INTEGRATION_LEAD_HANDLING" at create (READ-only): a reminder is createAppointmentReminder (isGlobal: true for every appointment); the second is minted on integration connect, then LOCKED against adding, deleting or rewiring a step.

curl https://server.goliathdata.com/api/v1/call \
  -H "Authorization: Bearer $GOLIATH_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "operationId": "updateWorkflowGraph",
    "variables": {
      "workflowAutomationId": "<workflowAutomationId>",
      "workflowGraph": "<SuperJSON>"
    }
  }'
import { GoliathClient } from '@goliath-data/sdk'

const goliath = new GoliathClient({
  apiKey: process.env.GOLIATH_API_KEY!,
  baseUrl: 'https://server.goliathdata.com',
})

const result = await goliath.workflows.updateWorkflowGraph(
  {
    workflowAutomationId: '<workflowAutomationId>',
    workflowGraph: '<SuperJSON>'
  },
  { idempotencyKey: crypto.randomUUID() },
)
const res = await fetch('https://server.goliathdata.com/api/v1/call', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.GOLIATH_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': crypto.randomUUID(),
  },
  body: JSON.stringify({
    operationId: 'updateWorkflowGraph',
    variables: {
      workflowAutomationId: '<workflowAutomationId>',
      workflowGraph: '<SuperJSON>'
    }
  }),
})
const { data, errors } = await res.json()

Variables

The example sends only what the schema requires. Some operations also need one of several optional variables (one filter or another, say); the description above says which.

workflowAutomationIdID!required
workflowGraphSuperJSON!required
expectedUpdatedAtDateTime
acknowledgeGraphWeakeningBoolean

Response

What comes back under data: a list shows as one entry, and a value as its type.

response shape
{
  "data": {
    "workflowAutomationsMutation": {
      "updateWorkflowAutomationGraph": {
        "id": "<ID>",
        "workflowGroupId": "<ID>",
        "name": "…",
        "status": "DRAFT",
        "stepKinds": [
          "…"
        ],
        "updatedAt": "2026-01-01T00:00:00.000Z",
        "workflowGroup": {
          "id": "<ID>",
          "status": "DRAFT",
          "draftValidation": {
            "isValid": true,
            "errors": [
              "…"
            ]
          }
        }
      }
    }
  }
}

Errors

Idempotency

Send an Idempotency-Key of 1 to 255 characters. A finished request retried with the same key replays its stored response, marked Idempotency-Replayed: true; one still in flight returns 409 idempotency_conflict. Keys expire after 24 hours. Write without fear →