Skip to content

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.

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).

\{ nodeType: string, label: string, config: object \} — position is auto-computed and can be omitted

\{ 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)

{
"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
}
]
}

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 the nodes array.
  • No signal:intake or signal:message in nodes — these anchor nodes are preserved automatically in signal-agent workflows. Connect them to your nodes via anchorEdges instead.
  • Valid node types — every nodeType must exist in the vocabulary below.
  • Label required — every node must have a non-empty string label.
  • Config required — every node must have a config object (use \{\} for nodes with no config).
  • Valid edge indices — edge sourceIndex and targetIndex must be valid 0-based indices into the nodes array. No self-loops.
  • flow:if edges — all outgoing edges from a flow:if node must have label: "true" or label: "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-fixesflow:wait without durationMs defaults to 86 400 000 ms (1 day); linkedin:reactive with an invalid timeoutDuration defaults to 168 h; linkedin:connection_request with noteMode: "ai" is silently downgraded to "none".
  • anchorEdges — for signal-agent graphs, anchorEdges connect the preserved anchor node to your graph. Each must have a valid targetIndex and a label in ["dm", "cr", "reply", "liked", "no_action"].

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": {}
}

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": {}
}

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": {}
}

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": {}
}

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"
}
}

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."
}
}

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
}
}

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": {}
}

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."
}
}

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"
}
}

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"
}
}

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"
}
}

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"
}
}

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": {}
}

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
}
}

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."
}
}

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"
}
}

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."
}
}

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": {}
}

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": {}
}

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": {}
}

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": {}
}

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
}
}

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": {}
}

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
}
}

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"
}
}

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"
}
}

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
}
}

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
}
]
}
}

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}}."
}
}

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}}"
}
}
}