Custom functions for Bolt

Custom functions for Bolt are in beta and under active development.

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

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

There are two components of a custom function: the function definition in the app's manifest and a listener to handle the function execution event. Before we dive in, a word of caution.

New automation apps only
Custom functions for Bolt are only supported for apps newly created through the CLI. Retro-fitting this feature into an existing app could cause your app to break and no longer function.

Defining the custom function

To make a custom function available for use in Workflow Builder, the app’s manifest must contain the function's definition.

Update the app manifest

A function’s definition contains information about the function, including its callback_id (the function's name), input_parameters, output_parameters, as well as display information.

Field Type Description Required?
callback_id String A unique string identifier representing the function. No other functions in your application may share a callback ID. Changing a function's callback ID is not recommended, as the function will be removed from the app and created under the new callback ID, breaking anything referencing the old function. In the example below, the callback_id is "sample_function". Yes
title String A string to identify the function. Yes
description String A succinct summary of what your function does. No
input_parameters Object An object which describes one or more input parameters that will be available to your function. Each top-level property of this object defines the name of one input parameter available to your function. No
output_parameters Object An object which describes one or more output parameters that will be returned by your function. Each top-level property of this object defines the name of one output parameter your function makes available. No

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

"functions": {
    "sample_function": {
        "title": "Sample function",
        "description": "Runs sample function",
        "input_parameters": {
            "user_id": {
                "type": "slack#/types/user_id",
                "title": "User",
                "description": "Message recipient",
                "is_required": true,
                "hint": "Select a user in the workspace",
                "name": "user_id"
            }
        },
        "output_parameters": {
            "user_id": {
                "type": "slack#/types/user_id",
                "title": "User",
                "description": "User that completed the function",
                "is_required": true,
                "name": "user_id"
            }
        }
    }
}

Defining input and output parameters

Function inputs and outputs (input_parameters and output_parameters) define what information goes into a function before it runs and what comes out of a function 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 function as a step in their workflow.
description String The description that accompanies the input when a user sets up this function as a step in their workflow.
is_required Boolean Indicates whether or not the input is required by the function 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.
hint String Helper text that appears below the input when a user sets up this function as a step in their workflow.
name String Corresponds to the unique identifier (the property of the input/output definition object).

Listening to function executions

When your custom function is executed as a step 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 below.

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 function’s execution was successful, or fail() to signal that the function failed to complete.

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

app.function('sample_function', 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 handle a function request: ${error}` });
  }
});
@app.function("sample_function")
def handle_sample_function_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 handle a function request (error: {e})")

Here's another example. Note in this snippet, the name of the function, 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 function */
    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 function request (error: {e})")

Sample function_executed payload

{
  type: 'function_executed',
  function: {
    id: 'Fn123456789O',
    callback_id: 'sample_function',
    title: 'Sample function',
    description: 'Runs sample function',
    type: 'app',
    input_parameters: [
      {
        type: 'slack#/types/user_id',
        name: 'user_id',
        description: 'Message recipient',
        title: 'User',
        is_required: true
      }
    ],
    output_parameters: [
      {
        type: 'slack#/types/user_id',
        name: 'user_id',
        description: 'User that completed the function',
        title: 'Greeting',
        is_required: true
      }
    ],
    app_id: 'AP123456789',
    date_created: 1694727597,
    date_updated: 1698947481,
    date_deleted: 0
  },
  inputs: { user_id: 'USER12345678' },
  function_execution_id: 'Fx1234567O9L',
  workflow_execution_id: 'WxABC123DEF0',
  event_ts: '1698958075.998738',
  bot_access_token: 'abcd-1325532282098-1322446258629-6123648410839-527a1cab3979cad288c9e20330d212cf'
}

Anatomy of a function listener

The first argument (in our case above, sample_function) is the unique callback ID of the function. After receiving an event from Slack, this identifier is how your app knows when to respond. This callback_id also corresponds to the function 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_function 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.
complete A utility method and abstraction of functions.completeSuccess. This method indicates to Slack that a function has completed successfully without issue. When called, complete requires you include an outputs object that contains the key/value pairs sent from function to function within a workflow (assuming there is more than one step).
fail A utility method and an abstraction of functions.completeError. True to its name, this method signals to Slack that a function has failed to complete. Thefail 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 function 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 functions at a later time, like once the user has clicked a button.

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

// If associated with a function, function-specific utilities are made available 
app.action('approve_button', async ({ complete, fail }) => {
  // Signal the function has completed once the button is clicked  
  await complete({ outputs: { message: 'Request approved 👍' } });
});
# If associated with a function, function-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 function 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 function request (error: {e})")

Deploying a custom function

When you're ready to deploy your functions 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.

Specify deployment script

The `deploy` hook for custom functions for Bolt is in an experimental phase and under active development.

You have the option to specify a deploy hook for your app, such that when you run slack deploy, your specified instructions will run.

Specify these instructions in your app's slack.json file. For example:

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

The terminal command would then look like:

slack deploy --experiment bolt

The hook command can be customized to any script your heart desires—everything from bash scripts to Git hooks to Tofu configurations are valid! Go wild.