Skip to main content

Playbook

slack_message_with_hitl — Agent Guidance

What it does

Posts a message to Slack with buttons you define, pauses the workflow, and resumes when a human clicks a button. Use it whenever a workflow needs a human decision before proceeding.

Mental model

  1. You define what the human sees (text, optional blocks, + buttons) and what data each button carries (emit).
  2. The system posts the message, pauses the workflow, and waits.
  3. A human clicks a button.
  4. The workflow resumes. The next step receives selected_option (the button id) and event_payload (the button’s emit data merged with Slack metadata).
That’s it. One input, one pause, one output.

blocks (Block Kit)

You can provide custom Slack Block Kit blocks to control the message layout. When blocks is provided, it replaces the default section block generated from text. The system always appends:
  • A status context block (hourglass / checkmark)
  • An actions block with your buttons (injected with routing metadata)
Do NOT include actions blocks in your blocks array — interactive buttons are managed via the buttons field. When blocks is provided, text is still required — Slack uses it as the notification preview and accessibility fallback.

Example with blocks

{
  "channel": "C0123456789",
  "text": "New lead: Sarah Chen, VP Marketing at Stripe",
  "blocks": [
    {
      "type": "header",
      "text": { "type": "plain_text", "text": "New Lead for Review" }
    },
    {
      "type": "section",
      "fields": [
        { "type": "mrkdwn", "text": "*Name:*\nSarah Chen" },
        { "type": "mrkdwn", "text": "*Title:*\nVP Marketing" },
        { "type": "mrkdwn", "text": "*Company:*\nStripe" },
        { "type": "mrkdwn", "text": "*Email:*\nsarah@stripe.com" }
      ]
    },
    { "type": "divider" }
  ],
  "buttons": [
    {
      "id": "approve",
      "label": "Approve",
      "style": "primary",
      "emit": { "decision": "approved" }
    },
    {
      "id": "reject",
      "label": "Reject",
      "style": "danger",
      "emit": { "decision": "rejected" }
    }
  ],
  "allow_edit": true
}

Supported block types

All Slack Block Kit block types are supported: section, header, divider, context, image, rich_text, actions (non-HITL only), input, video, table, markdown, file, context_actions, task_card, plan. See the Block Kit reference for full specifications.

Button schema

{
  "buttons": [
    {
      "id": "hubspot",
      "label": "Push to HubSpot",
      "style": "primary",
      "emit": { "crm": "hubspot", "action": "create_contact" }
    },
    {
      "id": "skip",
      "label": "Skip",
      "style": "danger"
    }
  ]
}
Each button has:
FieldRequiredDescription
idyesUnique identifier. Returned as selected_option when clicked. Cannot be "edit" (reserved).
labelyesText shown on the button in Slack.
styleno"primary" (green) or "danger" (red). Omit for neutral gray.
emitnoArbitrary key-value data merged into event_payload when this button is clicked.
Rules:
  • 1–5 buttons required.
  • Button ids must be unique.
  • "edit" is a reserved id — use allow_edit instead.
  • Unknown fields on buttons are rejected (additionalProperties: false).

What the next step receives

When a human clicks a button, the workflow resumes with this output:
{
  "resumed": true,
  "timed_out": false,
  "selected_option": "hubspot",
  "event_payload": {
    "crm": "hubspot",
    "action": "create_contact",
    "selected_option": "hubspot",
    "slack_user_id": "U0123456789",
    "slack_channel_id": "C0123456789"
  },
  "final_text": "Lead: Sarah Chen, VP Marketing at Stripe",
  "edited": false,
  "signal": "workflow:run_1:block_2:slack:hitl",
  "actor": {
    "slack_user_id": "U0123456789",
    "slack_channel_id": "C0123456789"
  },
  "message_ref": {
    "channel": "C0123456789",
    "ts": "1712501111.000100"
  }
}
  • selected_option — the id of the button that was clicked.
  • event_payload — the button’s emit data merged with Slack signal metadata (selected_option, final_text, edited, slack_user_id, slack_channel_id, etc). If the button has no emit, event_payload only contains the signal metadata. Merge precedence: emit keys overwrite signal metadata keys on collision — avoid using selected_option, final_text, edited, slack_user_id, or slack_channel_id as emit keys.
  • final_text — the message text at the time of the click (may differ from the original if the human used Edit).
  • edited — whether the human modified the draft text before clicking.
Branch on selected_option in the next workflow step to decide what to do.

allow_edit

Set "allow_edit": true to add an Edit button that opens a Slack modal where the human can modify the text field. Editing does NOT resume the workflow — the human must still click one of your defined buttons after editing. Defaults to false. Use it when the text field contains a draft the human should be able to revise (e.g., a draft email, a message template).

post_interaction

Controls what happens to the Slack message after any button is clicked:
{
  "post_interaction": {
    "disable_buttons": true,
    "replace_message": "Decision recorded."
  }
}
  • disable_buttons — remove all buttons from the message after a click (default: true).
  • replace_message — replace the entire message text with this string.
  • replace_blocks — replace the entire message with custom Slack blocks (advanced).
This applies to all buttons. There is no per-button post_interaction.

Timeout

{
  "timeout": "30m",
  "on_timeout": "return_null",
  "timeout_emit": { "reason": "timed_out" }
}
  • timeout — how long to wait for a click (default: 24h). Examples: "30m", "2h", "24h".
  • on_timeout"return_null" (resume with timed_out: true) or "error" (fail the workflow step).
  • timeout_emit — data returned as event_payload on timeout.

Common patterns

Simple confirmation gate (1 button)

{
  "channel": "C0123456789",
  "text": "About to enrich 500 leads. This will cost ~$25.",
  "buttons": [{ "id": "confirm", "label": "Go ahead", "style": "primary" }]
}

Approve / reject with editable draft

{
  "channel": "C0123456789",
  "text": "Hi John, we'd love to set up a call to discuss...",
  "buttons": [
    {
      "id": "approve",
      "label": "Send",
      "style": "primary",
      "emit": { "decision": "send" }
    },
    {
      "id": "reject",
      "label": "Drop",
      "style": "danger",
      "emit": { "decision": "drop" }
    }
  ],
  "allow_edit": true
}

Multi-option CRM routing (5 buttons)

{
  "channel": "C0123456789",
  "text": "New lead: Sarah Chen, VP Marketing at Stripe. Which CRM?",
  "buttons": [
    {
      "id": "hubspot",
      "label": "HubSpot",
      "style": "primary",
      "emit": { "crm": "hubspot" }
    },
    {
      "id": "salesforce",
      "label": "Salesforce",
      "emit": { "crm": "salesforce" }
    },
    { "id": "pipedrive", "label": "Pipedrive", "emit": { "crm": "pipedrive" } },
    { "id": "attio", "label": "Attio", "emit": { "crm": "attio" } },
    { "id": "skip", "label": "Skip Lead", "style": "danger" }
  ]
}
Next step branches: if selected_option === "skip" → end; otherwise event_payload.crm tells you which API to call.

Using HITL in a workflow

A Deepline workflow is a commands array. Each command has an alias, a tool, and a payload. Important — row access differs by step type:
Step typeAccess patternExample
HITL (slack_message_with_hitl)row.<alias>.selected_optionrow.qualify.selected_option
JavaScript (run_javascript)row.<alias>.result.your_fieldrow.draft_email.result.email_body
HITL output is stored flat — no .result wrapper. JS output is wrapped in { result, status, meta }. Template interpolation in tool payloads uses {{...}} mustache syntax, NOT ${...} JS template literals:
"text": "{{row.draft_email.result.email_body}}"     ← correct
"text": "${row.draft_email.result.email_body}"       ← WRONG, won't interpolate
Here’s a 3-step workflow: qualify a lead via HITL, branch on the decision, then post the draft email for approval.
{
  "name": "lead_qualify_and_outbound",
  "publish": true,
  "config": {
    "version": 1,
    "commands": [
      {
        "alias": "qualify",
        "tool": "slack_message_with_hitl",
        "payload": {
          "channel": "C07V9TZ4MSQ",
          "text": "New lead: Sarah Chen, VP Marketing at Stripe.\nEmail: sarah@stripe.com\n\nHow should we classify this lead?",
          "buttons": [
            {
              "id": "hot",
              "label": "Hot Lead",
              "style": "primary",
              "emit": { "qualification": "hot" }
            },
            {
              "id": "warm",
              "label": "Warm Lead",
              "emit": { "qualification": "warm" }
            },
            { "id": "disqualify", "label": "Disqualify", "style": "danger" }
          ],
          "timeout": "2h",
          "on_timeout": "return_null"
        }
      },
      {
        "alias": "draft_email",
        "tool": "run_javascript",
        "payload": {
          "code": "const q = row.qualify; if (q.timed_out || q.selected_option === 'disqualify') return { skip: true }; const isHot = q.selected_option === 'hot'; const body = isHot ? 'Hi Sarah, I\\'d love to set up a quick demo call this week...' : 'Hi Sarah, we published a playbook you might find useful...'; return { skip: false, email_body: body, qualification: q.selected_option };"
        }
      },
      {
        "alias": "approve_email",
        "tool": "slack_message_with_hitl",
        "payload": {
          "channel": "C07V9TZ4MSQ",
          "text": "{{row.draft_email.result.email_body}}",
          "buttons": [
            {
              "id": "send",
              "label": "Approve & Send",
              "style": "primary",
              "emit": { "decision": "send" }
            },
            {
              "id": "drop",
              "label": "Drop",
              "style": "danger",
              "emit": { "decision": "drop" }
            }
          ],
          "allow_edit": true,
          "timeout": "4h",
          "on_timeout": "return_null"
        }
      }
    ]
  }
}

How data flows between steps

Step 1 (qualify)                    Step 2 (draft_email)                Step 3 (approve_email)
┌──────────────────┐               ┌──────────────────┐               ┌──────────────────┐
│ HITL: 3 buttons  │               │ JS: reads Step 1 │               │ HITL: approve or │
│                  │──output──────▶│ via row.qualify   │──output──────▶│ drop the email   │
│ Waits for human  │               │ (flat, no .result)│               │ allow_edit: true  │
└──────────────────┘               └──────────────────┘               └──────────────────┘
  • Step 1 posts to Slack with 3 buttons, pauses, resumes when human clicks.
  • Step 2 is a run_javascript step. It reads Step 1’s HITL output at row.qualify.selected_option (flat — no .result). If the human disqualified the lead or it timed out, it returns { skip: true }. Otherwise it constructs an email draft.
  • Step 3 is another HITL step. The text field uses {{row.draft_email.result.email_body}} (mustache template, .result because it’s reading a JS step). allow_edit: true lets the human revise it. After approval, row.approve_email.final_text contains the final email body.

Key patterns

Reading prior HITL output: row.<alias>.selected_option gives you the button ID. row.<alias>.event_payload gives you the button’s emit data. row.<alias>.final_text gives you the (possibly edited) text. No .result wrapper. Reading prior JS output: row.<alias>.result.your_field — JS steps wrap output in { result, status, meta }. Template interpolation in payloads: Use {{row.<alias>.field}} mustache syntax. For JS outputs: {{row.<alias>.result.field}}. For HITL outputs: {{row.<alias>.selected_option}}. Branching: Use a run_javascript step between HITL steps to inspect the prior result and decide what to do next. Return a flag like { skip: true } that downstream steps can check. Passing data forward: You don’t need to stuff lead data into every button’s emit. The workflow engine carries all step outputs in row. Any step can read any prior step’s output via the patterns above.