Custom steps for Bolt apps

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

With custom steps for Bolt apps, your app can create and process workflow steps that users later add in Workflow Builder. This guide goes through how to build a custom step for your app using the app settings. If you're looking to build a custom step using the Deno Slack SDK, direct your attention to our guide on custom steps for Deno Slack SDK apps.

Bolt custom steps are currently supported for JavaScript and for Python. Take a look at the templates for each:

There are two components of a custom step: the step definition in the app manifest and a listener to handle the function_executed event in your project code.

Opt in to org-ready apps

Before we create the step definition, we first need to opt in to organization-ready apps. The app must opt-in to org-ready apps to be able to add the custom step to its manifest. This can be done in one of two ways.

  • Set the manifest settings.org_deploy_enabled property to true
  • Alternatively, navigate to your apps, select your app, then under the Features section in the navigation, select Org Level Apps and then Opt-In.

Whichever method you use, the following will be reflected in the app manifest as such:

    "settings": {
        "org_deploy_enabled": true,
        ...
    }

Next, the app must be installed at the organization level. While it is possible to install the app at a workspace level, doing so means that the custom steps will not appear in Workflow Builder. To remedy this, install the app at the organization level.

If you are a developer who is not an admin of their organization, you will need to request an Org Admin to perform this installation at the organization level. To do this:

  • Navigate to your apps page and select the app you'd like to install.
  • Under Settings, select Collaborators.
  • Add an Org Admin as a collaborator

The Org Admin can then install your app directly at the org level from the app settings page.

Defining the custom step

A workflow step's definition contains information about the step, including its input_parameters, output_parameters, as well as display information.

Each step is defined in the functions object of the manifest. Each entry in the functions object is a key-value pair representing each step. The key is the step's callback_id, which is any string you wish to use to identify the step (max 100 characters), and the value contains the details listed in the table below for each separate custom step. We recommend using the step's name, like sample_step in the code example below for the step's callback_id.

Field Type Description Required?
title String A string to identify the step. Max 255 characters. Yes
description String A succinct summary of what your step does. No
input_parameters Object An object which describes one or more input parameters that will be available to your step. Each top-level property of this object defines the name of one input parameter available to your step. No
output_parameters Object An object which describes one or more output parameters that will be returned by your step. Each top-level property of this object defines the name of one output parameter your step makes available. No

Once you are in your app settings, navigate to Workflow Steps in the left nav. Click Add Step and fill out your step details, including callback ID, name, description, input parameters, and output parameters.

Defining input and output parameters

Step inputs and outputs (input_parameters and output_parameters) define what information goes into a step before it runs and what comes out of a step after it completes, respectively.

Both inputs and outputs adhere to the same schema and consist of a unique identifier and an object that describes the input or output.

Each input or output that belongs to input_parameters or output_parameters must have a unique key.

Field Type Description
type String Defines the data type and can fall into one of two categories: primitives or Slack-specific.
title String The label that appears in Workflow Builder when a user sets up this step in their workflow.
description String The description that accompanies the input when a user sets up this step in their workflow.
dynamic_options Object For custom steps dynamic options in Workflow Builder, define this property and point to a custom step designed to return the set of dynamic elements once the step is added to a workflow within Workflow Builder. Dynamic options in Workflow Builder can be rendered in one of two ways: as a drop-down menu (single-select or multi-select), or as a set of fields. Refer to custom steps dynamic options in Workflow Builder for more details.
is_required Boolean Indicates whether or not the input is required by the step in order to run. If it’s required and not provided, the user will not be able to save the configuration nor use the step in their workflow. This property is available only in v1 of the manifest. We recommend v2, using the required array as noted in the example above.
hint String Helper text that appears below the input when a user sets up this step in their workflow.

In addition, the dynamic_options field has two required properties:

Property Type Description
function String A reference to the custom step that should be used as a dynamic option.
inputs Object Maps the inputs from the custom step consuming the dynamic option to the inputs required by the step used as a dynamic option.

For example:

"inputs": {
  "category": {
    "value": "{{input_parameters.category}}"
  }
}

Once you've added your step details, save your changes, then navigate to App Manifest. Notice your new step configuration reflected in the function property!

Sample manifest

Here is a sample app manifest laying out a step definition. This definition tells Slack that the step in our workspace with the callback ID of sample_step belongs to our app, and that when it runs, we want to receive information about its execution event.

"functions": {
    "sample_step": {
        "title": "Sample step",
        "description": "Runs sample step",
        "input_parameters": {
          "properties": {
            "user_id": {
                "type": "slack#/types/user_id",
                "title": "User",
                "description": "Message recipient",
                "hint": "Select a user in the workspace",
                "name": "user_id"
            }
          },
          "required": {
            "user_id"
          }
        },
        "output_parameters": {
          "properties": {
            "user_id": {
                "type": "slack#/types/user_id",
                "title": "User",
                "description": "User that received the message",
                "name": "user_id"
            }
          },
          "required": {
            "user_id"
          }
        },
    }
}

Adding steps for existing apps

If you are adding custom steps to an existing app directly to the app manifest, you will also need to add the function_runtime property to the app manifest. Do this in the settings section as such:

"settings": {
	...
	"function_runtime": "remote"
}

If you are adding custom steps in the Workflow Steps section of the App Config as shown above, then this will be added automatically.

Listening to function executions

When your custom step is executed in a workflow, your app will receive a function_executed event. The callback provided to the function() method will be run when this event is received. See a sample of what the function_executed payload looks like here.

The callback is where you can access inputs, make third-party API calls, save information to a database, update the user’s Home tab, or set the output values that will be available to subsequent workflow steps by mapping values to the outputs object.

Your app must call complete() to indicate that the step’s execution was successful, or fail() to signal that the step failed to complete.

Notice in the example code here that the name of the step, sample_step, is the same as it is listed in the manifest above. This is required.

app.function('sample_step', async ({ client, inputs, complete, fail }) => {
  try {
    const { user_id } = inputs;

    await client.chat.postMessage({ 
      channel: user_id, 
      text: `Greetings <@${user_id}>!` 
    });

    await complete({ outputs: { user_id } });
  } 
  catch (error) {
    console.error(error);
    fail({ error: `Failed to complete the step: ${error}` });
  }
});
@app.function("sample_step")
def handle_sample_step_event(inputs: dict, fail: Fail, complete: Complete,logger: logging.Logger):
    user_id = inputs["user_id"]
    try:
        client.chat_postMessage( 
            channel=user_id, 
            text=f"Greetings <@{user_id}>!" 
        )
        complete({"user_id": user_id})
    except Exception as e:
        logger.exception(e)
        fail(f"Failed to complete the step: {e}")

Here's another example. Note in this snippet, the name of the step, create_issue, must be listed the same as it is listed in the manifest file.

app.function('create_issue', async ({ inputs, complete, fail }) => {
  try {
    const { project, issuetype, summary, description } = inputs;

    /** Prepare the URL to POST new issues to */
    const jiraBaseURL = process.env.JIRA_BASE_URL;
    const issueEndpoint = `https://${jiraBaseURL}/rest/api/latest/issue`;

    /** Set custom headers for the request */
    const headers = {
      Accept: 'application/json',
      Authorization: `Bearer ${process.env.JIRA_SERVICE_TOKEN}`,
      'Content-Type': 'application/json',
    };

    /** Provide information about the issue in the body */
    const body = JSON.stringify({
      fields: {
        project: Number.isInteger(project) ? { id: project } : { key: project },
        issuetype: Number.isInteger(issuetype) ? { id: issuetype } : { name: issuetype },
        description,
        summary,
      },
    });

    /** Create the issue on a project by POST request */
    const issue = await fetch(issueEndpoint, {
      method: 'POST',
      headers,
      body,
    }).then(async (res) => {
      if (res.status === 201) return res.json();
      throw new Error(`${res.status}: ${res.statusText}`);
    });

    /** Return a prepared output for the step */
    const outputs = {
      issue_id: issue.id,
      issue_key: issue.key,
      issue_url: `https://${jiraBaseURL}/browse/${issue.key}`,
    };
    await complete({ outputs });
  } catch (error) {
    console.error(error);
    await fail({ error });
  }
});
@app.function("create_issue")
def create_issue_callback(ack: Ack, inputs: dict, fail: Fail, complete: Complete, logger: logging.Logger):
    ack()
    JIRA_BASE_URL = os.getenv("JIRA_BASE_URL")

    headers = {
        "Authorization": f'Bearer {os.getenv("JIRA_SERVICE_TOKEN")}',
        "Accept": "application/json",
        "Content-Type": "application/json",
    }

    try:
        project: str = inputs["project"]
        issue_type: str = inputs["issuetype"]

        url = f"{JIRA_BASE_URL}/rest/api/latest/issue"

        payload = json.dumps(
            {
                "fields": {
                    "description": inputs["description"],
                    "issuetype": {"id" if issue_type.isdigit() else "name": issue_type},
                    "project": {"id" if project.isdigit() else "key": project},
                    "summary": inputs["summary"],
                },
            }
        )

        response = requests.post(url, data=payload, headers=headers)

        response.raise_for_status()
        json_data = json.loads(response.text)
        complete(outputs={
            "issue_id": json_data["id"],
            "issue_key": json_data["key"],
            "issue_url": f'https://{JIRA_BASE_URL}/browse/{json_data["key"]}'
        })
    except Exception as e:
        logger.exception(e)
        fail(f"Failed to handle a step request (error: {e})")

Anatomy of a function listener

The first argument (in our case above, sample_step) is the unique callback ID of the step. After receiving an event from Slack, this identifier is how your app knows which custom step handler to invoke. This callback_id also corresponds to the step definition provided in your manifest file.

The second argument is the callback function, or the logic that will run when your app receives notice from Slack that sample_step was run by a user—in the Slack client—as part of a workflow.

Field Description
client A WebClient instance used to make things happen in Slack. From sending messages to opening modals, client makes it all happen. For a full list of available methods, refer to the Web API methods. Read more about the WebClient for Bolt JS here and for Bolt for Python here.
complete A utility method that invokes functions.completeSuccess. This method indicates to Slack that a step has completed successfully without issue. When called, complete requires you include an outputs object that matches your step definition in output_parameters.
fail A utility method that invokes functions.completeError. True to its name, this method signals to Slack that a step has failed to complete. The fail method requires an argument of error to be sent along with it, which is used to help folks understand what went wrong.
inputs An alias for the input_parameters that were provided to the step upon execution.

Responding to interactivity

Interactive elements provided to the user from within the function() method’s callback are associated with that unique function_executed event. This association allows for the completion of steps at a later time, like once the user has clicked a button.

Incoming actions that are associated with a step have the same inputs, complete, and fail utilities as offered by the function() method.

// If associated with a step, step-specific utilities are made available 
app.action('approve_button', async ({ complete, fail }) => {
  // Signal the step has completed once the button is clicked  
  await complete({ outputs: { message: 'Request approved 👍' } });
});
# If associated with a step, step-specific utilities are made available 
@app.action("sample_click")
def handle_sample_click(context: BoltContext, complete: Complete, fail: Fail, logger: logging.Logger):
    try:
        # Signal the step has completed once the button is clicked
        complete({"user_id": context.actor_user_id})
    except Exception as e:
        logger.exception(e)
        fail(f"Failed to handle a step request (error: {e})")

Deploying a custom step

When you're ready to deploy your steps for wider use, you'll need to decide where to deploy, since Bolt apps are not hosted on the Slack infrastructure.

Not sure where to host your app? We recommend following the Heroku Deployment Guide.

Control step access

You can choose who has access to your custom steps. To define this, refer to Custom function access.

Distribution

Distribution works differently for Slack apps that contain custom steps when the app is within a standalone (non-Enterprise Grid) workspace versus within an Enterprise Grid organization.

  • Within a standalone workspace: Slack apps that contain custom steps can be installed on the same workspace and used within that workspace. We do not support distribution to other standalone workspaces (also known as public distribution).
  • Within an organization: Slack apps that contain custom steps should be org-ready (enabled for private distribution) and installed on the organization level. They must also be granted access to at least one workspace in the organization for the steps to appear in Workflow Builder.

Apps containing custom steps cannot be distributed publicly or submitted to the Slack Marketplace. We recommend sharing your code as a public repository in order to share custom steps in Bolt apps.

Related tutorials

Consider exploring these tutorials for creating a Bolt app with custom workflow steps or adding custom workflow steps to an existing Bolt app.