Create a custom function for Workflow Builder: Bolt for JavaScript

Intermediate

Developing automations requires a paid plan. Don't have a paid plan? Join the Developer Program and provision a sandbox with access to all Slack features for free.

Custom functions are how you define custom Workflow Builder steps.

In this tutorial, you'll use the Bolt for JavaScript SDK to develop a custom function, then wire it up as a workflow step in Workflow Builder.

When finished, you'll be ready to build scalable and innovative custom functions for anyone using Workflow Builder in your workspace.

Learning objectives

  1. Clone a starter template
  2. Start a local development server
  3. Use a sample custom function in Workflow Builder
  4. Define & implement a custom function's event listeners
  5. Configure a custom function in an app's manifest
  6. Deploy a custom function

What are we building?

In this tutorial, you'll be writing a custom function called Request Time Off. You'll add this custom function as a step in Workflow Builder, then test it out in your development workspace. Here's how it works:

  • When someone starts the workflow, Slack will notify your app that your custom function was invoked as part of a workflow
  • Your app will send a message with the request to a manager, along with two buttons (one to approve, one to deny)
  • When the manager clicks or taps one of the buttons, Slack will let your app know, and your app will respond by sharing the manager's decision in a follow-up message

Skip to the code
If you'd rather skip the tutorial and just head straight to the code, create a new app and use our Bolt JS function sample as a template. The sample custom function provided in the template will be a good place to start exploring!

Ready? Let's get started!

Step 1Setting up your tools

  • Before we begin, let's make sure you're set up for success.

    First — You’ll need a development workspace.

    Make sure you have a development workspace where you have permission to install apps.

    Second — You’ll need the current version of the Slack CLI.

    To complete this tutorial, you first need to install and configure the Slack CLI. Step-by-step instructions can be found in our Quickstart Guide.

    Run slack update to see if you have the latest version. If you don’t, you’ll be prompted to update. Go ahead and do that, then re-run slack update. You should see the following:

    You are using the latest Slack CLI and SDK
    

    Third — Your CLI should be authenticated in your development workspace.

    Check if your CLI is authenticated in your development workspace by running slack auth list. If you’ve previously authenticated into your workspace, you should see it listed in the terminal output.

    If you haven’t authenticated into your development workspace yet, run slack auth login, and follow the instructions. You can also follow the instructions here.

Step complete!

Step 2Clone the starter template

  • For this tutorial, We'll use boltfunc as the app name. For your app, be sure to use a unique name that will be easy for you to find in Workflow Builder, then use that name wherever you see boltfunc in this tutorial.

    Let's start by opening a terminal and cloning the starter template repository:

    slack create boltfunc -t slack-samples/bolt-js-custom-function-template
    

    When the CLI is finished cloning the template, change directories into your newly prepared app project:

    cd boltfunc
    

    If you're using VSCode (highly recommended), you can enter code . from your project's directory and VSCode will open your new project.

    You can also open a terminal window from inside VSCode like this: Ctrl + ~

Step complete!

Step 3Start your local development server

  • While building your app, you can see your changes appear in your workspace in real-time with slack run. The CLI will prompt you to select a workspace to install the app in during app creation. You'll know an app is the development version if its name has the string (local) appended to it.

    Try starting your development server now:

    $ slack run
    

    Since this is the first time you’re starting the development server for this app, you’ll be prompted to choose a local environment. Select the option to install to a new team, then select your development workspace.

    $ slack run
    ? Choose a local environment
    ❱ Install to a new team
    

    Once you select your development workspace, the CLI will do some work behind the scenes to get your app ready for local development.

    You'll know the local development server is up and running successfully when it emits a bunch of [DEBUG] statements to your terminal, the last one containing connected:ready.

    With your development server running, continue to the next step.

    Note: If you need to stop running the local development server, press <CTRL> + c to end the process.

Step complete!

Step 4Try the sample function in Workflow Builder

  • The starter project you cloned contains a sample custom function lovingly entitled “Sample function”. We’re going to build our own custom function later, but first, let’s see how a custom function defined in Bolt appears in Workflow Builder.

    Open Workflow Builder in your development workspace, and create a new workflow:

    Creating a new workflow

    Select "From a link in Slack" to configure this workflow to start when someone clicks its shortcut link:

    Starting a new workflow from a shortcut link

    Click the [Continue] button to confirm that this is workflow should start with a shortcut link:

    Confirming a new shortcut workflow setup

    Find the sample function provided in the template by searching for the name of your app (e.g., boltfunc) in the Steps search bar.

    Any custom function that your app has defined will be listed.

    Add the “Sample function” in the search results to the workflow:

    Adding the sample function to the workflow

    As soon as you add the “Sample function” step to the workflow, a modal will appear to configure the step's input—in this case, a user variable:

    Configuring the sample function's inputs

    Configure the user input to be “Person who used this workflow”, then click the [Save] button:

    Saving the sample function after configuring the user input

    Click the [Finish up] button, then provide a name and description for your workflow.

    Finally, click the [Publish] button:

    Publishing a workflow

    Copy the shortcut link, then exit Workflow Builder and paste the link to a message in any channel you’re in:

    Copying a workflow link

    After you send a message containing the shortcut link, the link will unfurl and you’ll see a [Start Workflow] button.

    Click the [Start Workflow] button:

    Starting your new workflow

    You should see a new direct message from your app:

    A new direct message from your app

    The message from your app asks you to click the [Complete function] button:

    A new direct message from your app

    Once you click the button, the direct message to you will be updated to let you know that the function interaction was successfully completed:

    Sample function finished successfully

    Now that we’ve gotten a feel for how we will use the custom function we’re writing in this tutorial, let’s go ahead and get started with actually defining our custom function.

Step complete!

Step 5Define a custom function's listeners

  • Now that we’ve seen how custom functions are used in Workflow Builder, let’s write one from scratch with the Bolt SDK for JavaScript.

    We’ll first describe our function so we know what we’re going to build, then we’ll implement two listener functions in our app code: one to let us know when the function starts, and one to let us know when someone clicks or taps one of the buttons we sent over.

    Describing our custom function

    We're going to write a custom function called Request Time Off.

    Inputs and outputs

    The function will take the following inputs:

    • Manager (as a Slack User ID)
    • Requestor (as a Slack User ID)

    The function will produce the following outputs:

    • The decision (approved or denied)
    • The manager who made the decision

    Behavior

    • When the function is invoked, a message will be sent to the provided manager with an option to approve or deny the time-off request.
    • When the manager takes an action (approves or denies the request), a message is posted with the decision and the manager who made the decision.

    With a clear path forward, let’s head to our code editor and begin building our custom function.

    Adding a function listener

    The first thing we’ll do when adding a custom function to our Bolt app is register a new function listener. In Bolt, a function listener allows developers to execute custom code in response to specific Slack events or actions by registering a method that handles predefined requests or commands. We register a function listener via the function method provided by our app instance.

    Follow along:

    1. Open your project’s app.js file in your code editor.
    2. Around line 13, between the initialization code for the app instance and the sample_function registration, define a new listener for our custom function:
    app.function('request_time_off', async ({ client, complete, fail }) => {
      // We'll implement this function soon...
    });
    

    Ignore any errors being reported right now, as they’ll go away once we implement this function.

    Before we do that, let’s take a closer look at what’s going on when we register a function listener.

    Anatomy of a .function() listener

    The function listener registration method (.function()) takes two arguments:

    • The first argument is the unique callback ID of the function. For our custom function, we’re using request_time_off. Every custom function you implement in an app needs to have a unique callback ID.
    • The second argument is an asynchronous callback function, where we define the logic that will run when Slack tells the app that a user in the Slack client started a workflow that contains the request_time_off custom function.

    The callback function offers various utilities that can be used to take action when a function execution event is received. The ones we’ll be using here are:

    • client provides access to Slack API methods — like chat.postMessage, which we’ll use later to send a message to a channel
    • inputs provides access to the workflow variables passed into the function when the workflow was started
    • fail is a utility method for indicating that the function invoked for the current workflow step had an error

    With that context, let’s implement the listener’s logic.

    Defining the function listener's callback logic

    When our function is executed, we want a message to be sent to the approving manager. That message should include information about the request, as well as two buttons that indicate their decision (approve or deny).

    Fill out your function listener’s logic with the code below:

    app.function('request_time_off', async ({ client, inputs, fail }) => {
      try {
        const { manager_id, submitter_id } = inputs;
    
        await client.chat.postMessage({
          channel: manager_id,
          text: `<@${submitter_id}> requested time off! What say you?`,
          blocks: [
            {
              type: 'section',
              text: {
                type: 'mrkdwn',
                text: `<@${submitter_id}> requested time off! What say you?`,
              },
            },
            {
              type: 'actions',
              elements: [
                {
                  type: 'button',
                  text: {
                    type: 'plain_text',
                    text: 'Approve',
                    emoji: true,
                  },
                  value: 'approve',
                  action_id: 'approve_button',
                },
                {
                  type: 'button',
                  text: {
                    type: 'plain_text',
                    text: 'Deny',
                    emoji: true,
                  },
                  value: 'deny',
                  action_id: 'deny_button',
                },
              ],
            },
          ],
        });
      } catch (error) {
        console.error(error);
        fail({ error: `Failed to handle a function request: ${error}` });
      }
    });
    

    When Slack tells your Bolt app that the request_time_off function was invoked, this function uses chat.postMessage to send a message to the manager_id channel (which means this will be sent as a DM to the Slack user whose ID == manager_id) with some text and blocks. The Block Kit elements being sent as part of the message are two buttons, one labeled ‘Approve’ (which sends the approve_button action ID) and the other labelled ‘Deny’ (which sends the deny_button action ID).

    Once the message is sent, your Bolt app will wait until the manager has made their decision. As soon as they click or tap one of the buttons, Slack will send back the action ID associated with the button that was pressed to your Bolt app.

    In order for your Bolt app to listen for these actions, we’ll now define an action listener.

    Adding an action listener

    The message we send to the approving manager above will include two buttons that indicate their approval or denial of the request.

    To listen for and respond to these button clicks, we'll add an .action() listener to app.js, right after the function listener you just defined:

    app.action(/^(approve_button|deny_button).*/, async ({ action, body, client, complete, fail }) => {
      // Your code will go here
    });
    

    Let’s now take a closer look at what’s going on when we register an action listener.

    Anatomy of an .action() listener

    Similar to a function listener, the action listener registration method (.action()) takes two arguments:

    • The first argument is the unique callback ID of the action that your app will respond to. In our case, because we want to execute the same logic for both buttons, we’re using a little bit of RegEx magic to listen for two callback IDs at the same time — approve_button and deny_button.
    • The second argument is an asynchronous callback function, where we define the logic that will run when Slack tells our app that the manager has clicked or tapped the Approve button or the Deny button.

    Just like the function listener’s callback function, the action listener’s callback function offers various utilities that can be used to take action when an action event is received. The ones we’ll be using here are:

    • client, which provides access to Slack API methods
    • action, which provides the action’s event payload
    • complete, which is a utility method indicating to Slack that the function behind the workflow step that was just invoked has completed successfully
    • fail, which is a utility method for indicating that the function invoked for the current workflow step had an error

    Let's go ahead and define the callback logic for our action listener.

    Defining the action listener's callback logic

    Recall that we sent over a message with two buttons back in the function listener.

    When one of the buttons is pressed, we want to complete the function, update the message, and define outputs that can be used for subsequent steps in Workflow Builder.

    Fill out your action listener’s logic with the code below:

    app.action(/^(approve_button|deny_button).*/, async ({ action, body, client, complete, fail }) => {
      const { channel, message, function_data: { inputs } } = body;
      const { manager_id, submitter_id } = inputs;
      const request_decision = action.value === 'approve';
    
      try {
        await complete({ outputs: { manager_id, submitter_id, request_decision } });
        await client.chat.update({
          channel: channel.id,
          ts: message.ts,
          text: `Request ${request_decision ? 'approved' : 'denied'}!`,
        });
      } catch (error) {
        console.error(error);
        fail({ error: `Failed to handle a function request: ${error}` });
      }
    });
    

    Slack will send an action event payload to your app when one of the buttons is clicked or tapped. In the action listener, we’ll extract all the information we can use, and if all goes well, let Slack know the function was successful by invoking complete. We’ll also handle cases where something goes wrong and produces an error.

    With the listeners and their callbacks all set up, it’s time to turn to our manifest.

Step complete!

Step 6Update the app manifest

  • An app’s manifest (manifest.json) contains the blueprint of the application, and, in the world of functions, helps Slack understand what it offers for use in Workflow Builder.

    Creating a function definition

    Since we’ve added a new function to our app, we want to make sure it’s accounted for in the manifest, otherwise Slack won’t know that we want to make it available for use in Workflow Builder. To do that, we’ll add a new function definition to our manifest.

    In your manifest.json file, add the following function definition for request_time_off to the functions key as you see below:

    // ... more manifest above
        "functions": {
            "request_time_off": {
                "title": "Request time off",
                "description": "Submit a request to take time off",
                "input_parameters": {
                   // Function inputs will go here
                },
                "output_parameters": {
                   // Function outputs will go here
                }
            }
        }
    // ...
    

    This definition tells Slack that the function in our workspace with the callback ID of request_time_off belongs to our app, and that when it runs, we want to receive information about its execution event.

    Slack expects functions to have input_parameters and output_parameters, so we’ll define those here in the manifest next.

    Updating the manifest with your custom function's inputs and outputs

    Let’s think back to what we’re trying to accomplish with this function:

    • We want this function to take in a manager’s name, as well as who submitted the request. These are the function’s inputs.
    • The function will send a message to the manager to make a decision about the request.
    • When the function completes (i.e., the manager approves or denies the request), we want to output the decision that was made, as well as who made that decision. These are the function’s outputs.

    Function inputs and outputs (input_parameters and output_parameters in manifest-speak) define what information goes into a function before it runs and what comes out of a function after it completes, respectively.

    Using the same variables that we’ve used in our code, let’s now add the following input_parameters and output_parameters to the request_time_off function definition:

    // ... more manifest above
        "functions": {
            "request_time_off": {
                "title": "Request time off",
                "description": "Submit a request to take time off",
                "input_parameters": {
                    "manager_id": {
                        "type": "slack#/types/user_id",
                        "title": "Manager",
                        "description": "Approving manager",
                        "is_required": true,
                        "hint": "Select a user in the workspace",
                        "name": "manager_id"
                    },
                    "submitter_id": {
                        "type": "slack#/types/user_id",
                        "title": "Submitting user",
                        "description": "User that submitted the request",
                        "is_required": true,
                        "name": "submitter_id"
                    }
                },
                "output_parameters": {
                    "manager_id": {
                        "type": "slack#/types/user_id",
                        "title": "Manager",
                        "description": "Approving manager",
                        "is_required": true,
                        "name": "manager_id"
                    },
                    "request_decision": {
                        "type": "boolean",
                        "title": "Request decision",
                        "description": "Decision to the request for time off",
                        "is_required": true,
                        "name": "request_decision"
                    },
                    "submitter_id": {
                        "type": "slack#/types/user_id",
                        "title": "Submitting user",
                        "description": "User that submitted the request",
                        "is_required": true,
                        "name": "submitter_id"
                    }
                }
            }
        }
    // ...
    
    "Show me where to place this code snippet"

    As you can see, both inputs and outputs adhere to the same schema (the shape of it, or expected properties), and consist of a unique identifier and an object that describes the input or output.

    This next part is important!

    Since you’ve changed your manifest, go ahead and restart your development server:

    1. Stop your development server: Ctrl + c
    2. Start your development server: slack run

    Manifest changes will only be reflected after you stop and restart your development server.

    With the manifest changes in place, navigate back to Workflow Builder, and search for your app name again (just like we did before), and you should see the new Request time off function that we’ve been building:

    Your custom function discoverable in Workflow Builder

    What would you like to happen when the request is approved or denied? Add the decision to a spreadsheet? Send a message to the submitting user?

    The possibilities are endless! Check out which steps you have available for immediate use in Workflow Builder, and if you can’t find what you’re looking for, you’re now well-equipped to create a custom step to make it a reality.

    Here's one possibility:

    1. First, remove the sample function step, then drag over the Request time off step into the workflow so that it is the first step in the workflow after the trigger.
    2. Add a Send Message step:
      • For Select a channel, choose Channel where the workflow was used
      • For Select a member of the channel, choose Submitting user
      • For Add a message, insert the Request decision variable
    3. Save and re-publish your workflow, then try it out in a channel.
Step complete!

Step 7Deploy your app

  • Now that you’ve got a working function, it’s time to deploy it to a production environment. Doing this will enable others to use your function as long as they have permissions.

    To use the CLI to deploy to infrastructure of your choice, edit the slack.json file to include a deploy hook:

    {
      "hooks": {
        "get-hooks": "npx -q --no-install -p @slack/cli-hooks slack-cli-get-hooks",
        "deploy": ""
      }
    }
    

    For the value of deploy, use the script needed to run that will see to the appropriate build and deployment process suited to your needs.

    To run this script, from the CLI, you can use the deploy command:

    slack deploy
    

    The above will run the script you've included for "deploy" in slack.json.

    If you're not sure where to host your app, consider following our guide for deploying Bolt apps to Heroku.

Step complete!

Step 8Where to go from here

  • That's it for this tutorial — we hope you learned a lot!

    Let's recap what we did:

    • We created a function listener and an action listener in our Bolt app code, then configured the manifest to use those listeners as part of a custom function.
    • That custom function was exposed to Workflow Builder for use as a step in a workflow.
    • All of this was made possible with the Bolt for JavaScript SDK (and your determination, of course)

    If you're interested in exploring how to create custom functions to use in Workflow Builder as steps with our Deno Slack SDK, too, a similar tutorial can be found here.

Step complete!