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.
To make a custom function available for use in Workflow Builder, the app’s manifest must contain the function's definition.
First, the app must opt-in to org-wide apps to be able to add the custom function to its manifest. This can be done in one of two ways.
settings.org_deploy_enabled
property to true
or,Next, the app must be installed at the org 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 org level.
If you are a developer who is not an admin of their workspace, you will need to request an admin to perform this installation at the org-level. To do this:
The admin can then install your app directly at the org level from the app config page.
A function's definition contains information about the function, including its input_parameters
, output_parameters
, as well as display information. Each function is identified in the functions
property of the manifest by its callback_id
, which is any string you wish to use to identify the function (max 100 characters). We recommend using the function's name, like sample_function
in the code example below.
Field | Type | Description | Required? |
---|---|---|---|
title |
String | A string to identify the function. Max 255 characters. | 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 |
parameter_callback_id |
String | The unique ID for the property of the input_parameters or output_parameters definition object, which allows functions to contain an object where unique keys correspond to an individual functions. The structure is as follows: functions.<callback_id>.input_parameters.<parameter_callback_id>.* . |
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": {
"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 completed the function",
"name": "user_id"
}
},
"required": {
"user_id"
}
},
}
}
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. 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 function as a step in their workflow. |
name |
String | A legacy field that corresponded to the unique key. Use the parameter_callback_id field instead. |
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})")
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'
}
Note the bot_access_token
in the payload. This is a "just in time" workflow token, which are a subset of bot tokens. To learn more about workflow tokens, refer to token types.
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. |
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})")
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.
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.