Workflow Node Vocabulary
Complete vocabulary of workflow node types for building automation graphs. Use campaignstack_get_workflow_node_types to fetch this data at runtime, or pass a graph object to campaignstack_create_workflow / campaignstack_update_workflow.
Graph Shape
Section titled “Graph Shape”A workflow graph is { nodes, edges }. nodes is an ordered array of WorkflowNode objects. edges connect nodes by their 0-based array indices. The source:entry anchor node is always auto-created — do not include it in nodes. Use sourceConfig to configure lead sources (providers, dailyCap, exclusions).
Node fields
Section titled “Node fields”\{ nodeType: string, label: string, config: object \} — position is auto-computed and can be omitted
Edge fields
Section titled “Edge fields”\{ sourceIndex: number, targetIndex: number, label?: string \} — label is required for branching nodes (e.g. 'true'/'false' for flow:if, 'accepted'/'timeout' for linkedin:reactive with connection_accepted)
Minimal Example Graph
Section titled “Minimal Example Graph”{ "nodes": [ { "nodeType": "linkedin:profile_view", "label": "View Profile", "config": {} }, { "nodeType": "flow:wait", "label": "Wait 3 Days", "config": { "waitType": "duration", "durationMs": 259200000 } }, { "nodeType": "linkedin:connection_request", "label": "Send Connection Request", "config": { "noteMode": "none" } } ], "edges": [ { "sourceIndex": 0, "targetIndex": 1 }, { "sourceIndex": 1, "targetIndex": 2 } ]}Validation Rules
Section titled “Validation Rules”Rules enforced by campaignstack_create_workflow and campaignstack_update_workflow:
- At least one node — the graph must contain at least one node.
- No source:entry in nodes — the source:entry anchor node is always auto-created from
sourceConfig. Do not include it in thenodesarray. - No signal:intake or signal:message in nodes — these anchor nodes are preserved automatically in signal-agent workflows. Connect them to your nodes via
anchorEdgesinstead. - Valid node types — every
nodeTypemust exist in the vocabulary below. - Label required — every node must have a non-empty string
label. - Config required — every node must have a
configobject (use\{\}for nodes with no config). - Valid edge indices — edge
sourceIndexandtargetIndexmust be valid 0-based indices into thenodesarray. No self-loops. - flow:if edges — all outgoing edges from a
flow:ifnode must havelabel: "true"orlabel: "false". - Multi-exit edges — outgoing edges from branching nodes (e.g.
review:content,signal:message,linkedin:reactive) must use the node’s declared exit labels (see each node’s exits field below). - Single-exit nodes — all other nodes may have at most 1 outgoing edge.
- Auto-fixes —
flow:waitwithoutdurationMsdefaults to 86 400 000 ms (1 day);linkedin:reactivewith an invalidtimeoutDurationdefaults to 168 h;linkedin:connection_requestwithnoteMode: "ai"is silently downgraded to"none". - anchorEdges — for signal-agent graphs,
anchorEdgesconnect the preserved anchor node to your graph. Each must have a validtargetIndexand alabelin["dm", "cr", "reply", "liked", "no_action"].
Node Types (31 types)
Section titled “Node Types (31 types)”linkedin:profile_view
Section titled “linkedin:profile_view”Label: Profile View | Category: linkedin | Credit cost: 10 cr
Views the lead’s LinkedIn profile to trigger a notification
Config:
{}Example:
{ "nodeType": "linkedin:profile_view", "label": "View Profile", "config": {}}linkedin:post_like
Section titled “linkedin:post_like”Label: Post Like | Category: linkedin | Credit cost: 10 cr
Finds and likes the lead’s most recent LinkedIn post
Config:
{}Example:
{ "nodeType": "linkedin:post_like", "label": "Like Post", "config": {}}linkedin:post_comment
Section titled “linkedin:post_comment”Label: Post Comment | Category: linkedin | Credit cost: 10 cr
Reads the lead’s latest post and leaves a relevant comment
Config:
{ commentText?: string }Example:
{ "nodeType": "linkedin:post_comment", "label": "Comment on Post", "config": {}}linkedin:reply_comment
Section titled “linkedin:reply_comment”Label: Reply to Comment | Category: linkedin | Credit cost: 10 cr
Replies to a specific comment on a LinkedIn post — navigates to the post, finds the comment by author name and text, and posts the approved reply
Config:
{}Example:
{ "nodeType": "linkedin:reply_comment", "label": "Reply to Comment", "config": {}}linkedin:connection_request
Section titled “linkedin:connection_request”Label: Connection Request | Category: linkedin | Credit cost: 10 cr
Sends a connection request. Choose between AI-personalized, template, or no note
Config:
{ noteMode: "none"|"static", noteTemplate?: string } — for a personalized note, build ai:craft_note → review:content → linkedin:connection_request { noteMode: "static" } (the note flows from context). The connection_request node does not self-generate AI notes.Example:
{ "nodeType": "linkedin:connection_request", "label": "Send Connection Request", "config": { "noteMode": "none" }}linkedin:send_message
Section titled “linkedin:send_message”Label: Send Message | Category: linkedin | Credit cost: 10 cr
Sends a direct message to the lead. Supports AI-generated or template text
Config:
{ messageTemplate?: string, useAi?: boolean, attachmentStorageId?: Id<"_storage"> } — send literal messageTemplate or approved upstream ai:craft_message content. Do not generate only { useAi: true }; for automatic personalized DMs use linkedin:research_and_message.Example:
{ "nodeType": "linkedin:send_message", "label": "Send Message", "config": { "messageTemplate": "Hi {{lead:firstName}}, I noticed your work at {{lead:company}} and wanted to connect." }}linkedin:send_followup
Section titled “linkedin:send_followup”Label: Send Follow-up | Category: linkedin | Credit cost: 10 cr
Sends a follow-up message in an existing conversation thread
Config:
{ messageTemplate?: string, useAi?: boolean } — send follow-up in existing conversation thread. Requires a prior send_message or research_and_message node upstream so a conversation exists. useAi=true calls craft_followup for personalized text.Example:
{ "nodeType": "linkedin:send_followup", "label": "Send Follow-up", "config": { "useAi": true }}linkedin:inbox_read
Section titled “linkedin:inbox_read”Label: Inbox Read | Category: linkedin | Credit cost: 0 cr
Reads recent messages from a conversation with the lead
Config:
{ pollIntervalMs?: number (in milliseconds, e.g. 60000 = 1 minute) }Example:
{ "nodeType": "linkedin:inbox_read", "label": "Read Inbox", "config": {}}linkedin:research_and_message
Section titled “linkedin:research_and_message”Label: Research & Message | Category: linkedin | Credit cost: 15 cr
Researches the lead’s profile and posts, then sends a personalized message based on findings
Config:
{ messageInstructions?: string, maxPosts?: number }Example:
{ "nodeType": "linkedin:research_and_message", "label": "Research & Message", "config": { "messageInstructions": "Focus on their recent posts and company news." }}signal:intake
Section titled “signal:intake”Label: Signal Intake | Category: signal | Credit cost: 15 cr
Decides the best action for a signal (DM, CR, reply, like, or skip) and crafts content — downstream nodes handle actual sending
Note: This node type is not available in the visual workflow builder. It is used internally by signal-agent workflows.
Config:
{ crNoteMode?: "none"|"static"|"with_note", crNoteTemplate?: string, reviewMode?: "on"|"off", publicReplyEnabled?: boolean, allowConnectionRequest?: boolean } — signal-agent only. Exits: dm, cr, liked, no_action, plus reply when publicReplyEnabled is true. allowConnectionRequest: false downgrades would-be CRs to liked.Example:
{ "nodeType": "signal:intake", "label": "Signal Intake", "config": { "crNoteMode": "none", "reviewMode": "off" }}signal:message
Section titled “signal:message”Label: Message Signal Intake | Category: signal | Credit cost: 10 cr
Receives an inbound LinkedIn message, decides the best response, and crafts a reply — downstream nodes handle review and sending
Config:
{ reviewMode: "on"|"off", handleInMail?: boolean } — message signal agent only. Exits: respond, meeting_scheduled (inbound message carried a calendar link and a meeting was booked), escalate.Exits: respond, meeting_scheduled, escalate
Example:
{ "nodeType": "signal:message", "label": "Message Signal Intake", "config": { "reviewMode": "off" }}group:intake
Section titled “group:intake”Label: Group Intake | Category: group | Credit cost: 0 cr
Entry point for group watch workflows — routes ICP-matching group members to the outreach chain. Optionally parks leads for human review before outreach.
Note: This node type is not available in the visual workflow builder. It is used internally by signal-agent workflows.
Config:
{ reviewMode?: "on"|"off" }Example:
{ "nodeType": "group:intake", "label": "Group Intake", "config": { "reviewMode": "off" }}connection:intake
Section titled “connection:intake”Label: Connection Intake | Category: connection | Credit cost: 0 cr
Entry point for connection-watch workflows — receives newly accepted connections and routes them to the welcome chain (craft → review → send)
Note: This node type is not available in the visual workflow builder. It is used internally by signal-agent workflows.
Config:
{ reviewMode?: "on"|"off" } — connection-watch signal agent only. The review gate lives on the crafted message, not the intake.Example:
{ "nodeType": "connection:intake", "label": "Connection Intake", "config": { "reviewMode": "off" }}linkedin:contact_info
Section titled “linkedin:contact_info”Label: Extract Contact Info | Category: linkedin | Credit cost: 10 cr
Extracts email, phone, and website from LinkedIn Contact Info overlay. 1st-degree connections only.
Config:
{}Example:
{ "nodeType": "linkedin:contact_info", "label": "Extract Contact Info", "config": {}}linkedin:reactive
Section titled “linkedin:reactive”Label: Reactive Node | Category: linkedin | Credit cost: 0 cr
Parks the lead and waits for a LinkedIn event (e.g. message reply, connection acceptance) before continuing
Config:
{ eventType: "message_received"|"connection_accepted", timeoutDuration: number (in hours, e.g. 168 = 7 days, 336 = 14 days; valid range: 1-720), responseMode?: "auto_respond"|"draft_and_review", autoBooking?: boolean } — reply and escalation are always enabled. autoBooking: when the lead sends a scheduling link, book a mutual slot via the account’s connected calendar (auto_respond only; needs a calendar-connected account). Escalation parks the lead (creates a pending action, no exit). connection_accepted exits: accepted, timeout. message_received + draft_and_review exits: draft_ready, timeout. message_received + auto_respond exits: replied, timeout (+ meeting_scheduled when autoBooking enabled).Example:
{ "nodeType": "linkedin:reactive", "label": "Wait for Reply", "config": { "eventType": "message_received", "timeoutDuration": 168 }}linkedin:reactive_respond
Section titled “linkedin:reactive_respond”Label: Reactive Responder | Category: linkedin | Credit cost: 10 cr
Browser-based agent that reads a new LinkedIn message and responds using configured tools. Routes to sent_message, needs_human, or no_action based on which tool was used.
Config:
{ instructions?: string, enabledTools?: string[] } — runner responder used after a reactive message event.Example:
{ "nodeType": "linkedin:reactive_respond", "label": "Respond to Reply", "config": { "instructions": "Be helpful and professional." }}google:email_send
Section titled “google:email_send”Label: Send Email | Category: google | Credit cost: 0 cr
Sends an email via Gmail. Static mode resolves {{lead:*}} template variables. Pair with ai:craft_email for AI-personalized copy.
Config:
{ mode?: "static"|"crafted"|"auto_generate", googleAccountId?: string, subject?: string, bodyTemplate?: string, htmlBodyTemplate?: string, attachmentIds?: string[], autoGenerateInstructions?: string, blockOnFailure?: boolean } — static mode resolves {{lead:firstName}}, {{lead:company}}, etc. in subject/body. crafted mode reads upstream craftedSubject/craftedBody from an ai:craft_email node. auto_generate mode generates subject+body via AI in one step (15 credits, same as ai:craft_email); use autoGenerateInstructions to guide the AI. Fails if lead has no email address. Best practices: use static mode for low-context leads (name + email only, no profile data). Keep body under 80 words, subject 2-4 words lowercase, zero selling language (no "free", "guaranteed", "exclusive"). For multi-email sequences, place a flow:wait { waitType: "email_event", emailEventType: "click", timeoutHours: 96 } after each send so leads who click exit the sequence and only non-engagers receive the next email. Each email in a sequence should target a separate pain point rather than repeating the same pitch. blockOnFailure defaults to true on the backend.Example:
{ "nodeType": "google:email_send", "label": "Send Email", "config": { "mode": "crafted" }}ai:autonomous
Section titled “ai:autonomous”Label: Autonomous Agent | Category: ai | Credit cost: 10 cr
Executes custom instructions using any combination of LinkedIn actions in a single session
Config:
{ instructions: string }Example:
{ "nodeType": "ai:autonomous", "label": "Autonomous Agent", "config": { "instructions": "Research the lead's profile and send a personalized message referencing their recent activity." }}ai:craft_note
Section titled “ai:craft_note”Label: Craft Connection Note | Category: ai | Credit cost: 5 cr
Generates a personalized connection request note (max 300 chars)
Config:
{ instructions?: string }Example:
{ "nodeType": "ai:craft_note", "label": "Craft Connection Note", "config": {}}ai:craft_message
Section titled “ai:craft_message”Label: Craft Message | Category: ai | Credit cost: 5 cr
Generates a personalized outreach message based on lead profile and campaign goals
Config:
{ instructions?: string, messageTemplate?: string }Example:
{ "nodeType": "ai:craft_message", "label": "Craft Message", "config": {}}ai:craft_comment
Section titled “ai:craft_comment”Label: Craft Comment | Category: ai | Credit cost: 5 cr
Generates a relevant comment for a lead’s latest post
Config:
{ instructions?: string }Example:
{ "nodeType": "ai:craft_comment", "label": "Craft Comment", "config": {}}ai:reply_conversation
Section titled “ai:reply_conversation”Label: Reply to Conversation | Category: ai | Credit cost: 5 cr
Generates a contextual follow-up reply based on conversation history
Config:
{ instructions?: string }Example:
{ "nodeType": "ai:reply_conversation", "label": "Reply to Conversation", "config": {}}ai:craft_email
Section titled “ai:craft_email”Label: Craft Email | Category: ai | Credit cost: 15 cr
Generates a personalized email (subject + plain text body, with optional HTML) based on lead profile, campaign goals, and workspace voice
Config:
{ instructions?: string, tone?: string, includeSignature?: boolean } — generates craftedSubject + craftedBody (plain text) + craftedHtmlBody (HTML, only when instructions or voice mention HTML/formatting). Pair with a google:email_send node to deliver the email. Use crafted mode when leads have rich profile data (headline, company, experience) worth personalizing against. For leads with only name + email (no profile context), static mode on google:email_send is better because AI will produce nearly identical generic emails and waste credits.Example:
{ "nodeType": "ai:craft_email", "label": "Craft Email", "config": { "tone": "professional", "includeSignature": true }}review:content
Section titled “review:content”Label: Review Content | Category: review | Credit cost: 0 cr
Pauses workflow for human review of AI-generated content. Supports approve, edit, retry, and reject actions.
Config:
{ contentNodeId?: string } — optional reference to upstream ai:* node (auto-detected if omitted)Exits: rejected, approved
Example:
{ "nodeType": "review:content", "label": "Review Content", "config": {}}flow:wait
Section titled “flow:wait”Label: Wait | Category: flow | Credit cost: 0 cr
Pause lead progression for a configurable duration with jitter, or wait for an email open/click event
Config:
{ waitType?: "duration"|"email_event", durationMs?: number (ms, e.g. 86400000 = 1 day — required for duration mode), emailEventType?: "open"|"click"|"any" (default "any" — for email_event mode), timeoutHours?: number (default 72 — timeout before advancing) } — duration mode waits a fixed time (single exit). email_event mode has TWO exits: "triggered" (lead opened/clicked the email) and "timeout" (no engagement within timeoutHours). Pair email_event mode with google:email_send upstream.Example:
{ "nodeType": "flow:wait", "label": "Wait 3 Days", "config": { "waitType": "duration", "durationMs": 259200000 }}flow:if
Section titled “flow:if”Label: If / Condition | Category: flow | Credit cost: 0 cr
One-shot agent check of a condition (e.g., connection accepted, message replied)
Config:
{ conditionType: "connection_accepted"|"message_replied"|"custom", expression?: string }Exits: false, true
Example:
{ "nodeType": "flow:if", "label": "Check Connection Accepted", "config": { "conditionType": "connection_accepted" }}flow:filter
Section titled “flow:filter”Label: Filter | Category: flow | Credit cost: 0 cr
Gate lead flow based on rules (e.g., ICP score threshold, profile fields)
Config:
{ rules: Array<{ field, operator: "equals"|"contains"|"gt"|"lt"|"exists"|"not_exists", value }>, logic: "and"|"or" }Example:
{ "nodeType": "flow:filter", "label": "Filter by ICP Score", "config": { "rules": [ { "field": "icpScore", "operator": "gt", "value": 50 } ], "logic": "and" }}enrich:wait
Section titled “enrich:wait”Label: Wait for Enrichment | Category: enrich | Credit cost: 0 cr
Park lead until profile enrichment completes, then proceed. Skips instantly if lead already has enrichment data.
Note: This node type is not available in the visual workflow builder. It is used internally by signal-agent workflows.
Config:
{ timeoutMinutes?: number (default 30) } — waits for lead enrichment to complete. Exits immediately if lead already has enrichment data. Timeout exit proceeds with whatever data is available.Exits: enriched, timeout
Example:
{ "nodeType": "enrich:wait", "label": "Wait for Enrichment", "config": { "timeoutMinutes": 30 }}flow:split
Section titled “flow:split”Label: Split | Category: flow | Credit cost: 0 cr
Distribute leads across 2-5 weighted branches for A/B testing. Assignment is deterministic — the same lead always takes the same branch.
Config:
{ branches: Array<{ label: string, weight: number }>, autoOptimize?: boolean, minLeadsPerBranch?: number } — 2 to 5 branches, weights must sum to 100. Exit handle IDs are branch indexes as strings ("0", "1", ...). When autoOptimize is true, a daily cron re-weights branches via Thompson Sampling on downstream positive exits once every branch has at least minLeadsPerBranch leads (default 20); lastOptimizedAt is set by the optimizer, do not set it manually.Example:
{ "nodeType": "flow:split", "label": "A/B Split", "config": { "branches": [ { "label": "A", "weight": 50 }, { "label": "B", "weight": 50 } ] }}notify:user
Section titled “notify:user”Label: Notify Me | Category: notify | Credit cost: 0 cr
Sends a notification to workspace members (in-app + email per their preferences) when a lead reaches this step — the message goes to YOU, never to the lead
Config:
{ subject: string, message: string } — both support {{variable}} interpolation: lead.firstName, lead.lastName, lead.company, lead.title, campaign.name, workflow.name, node.label. Unknown variables render empty. Recipients follow the workspace's workflow-notification audience setting (default: all members); there is no per-node recipient picker. Single exit, zero credits, never budget-gated.Example:
{ "nodeType": "notify:user", "label": "Notify Me", "config": { "subject": "{{lead.firstName}} {{lead.lastName}} reached {{node.label}}", "message": "{{lead.firstName}} {{lead.lastName}} ({{lead.title}} at {{lead.company}}) just reached {{node.label}} in {{workflow.name}}." }}integration:webhook
Section titled “integration:webhook”Label: Webhook | Category: integration | Credit cost: 0 cr
Sends lead data to an HTTP endpoint or pre-configured service (Slack, Instantly, etc.). Select a template for quick setup or use custom config with POST/PUT/PATCH and template variables.
Config:
{ url: string, method?: "POST"|"PUT"|"PATCH", headers?: Array<{ key, value }>, bodyTemplate?: string, blockOnFailure?: boolean, timeoutMs?: number } for custom webhooks, OR use a template: { templateId: "slack", templateFields: { webhookUrl: "https://hooks.slack.com/...", messageTemplate?: "mrkdwn text with {{lead:fullName}} etc." }, blockOnFailure?: boolean } or { templateId: "instantly-add-lead", templateFields: { campaignId: "uuid" }, blockOnFailure?: false }. Templates handle URL, method, headers, and body format automatically.Example:
{ "nodeType": "integration:webhook", "label": "Notify Slack", "config": { "templateId": "slack", "templateFields": { "webhookUrl": "https://hooks.slack.com/services/YOUR/WEBHOOK/URL", "messageTemplate": "New lead: {{lead:fullName}} from {{lead:company}}" } }}