Getting started

Build a workflow in JavaScript in under 15 minutes
Airplane Workflows are currently in private beta. If you'd like access as an early tester, please fill out the beta request form.
This guide will get you up and running with an example workflow that modifies a team's billing plan. Along the way, we'll explore a number of important workflow concepts like composition and failure handling.

Before you begin

If you haven't yet, first install the Airplane CLI by following the instructions at Installing the CLI.
If you have already installed the Airplane CLI, ensure you have the latest version (at least v0.3.51) by Upgrading the CLI. You can check your version by running airplane version.

Create a workflow

Once you've installed and logged in to the CLI, navigate to the folder where you want to create your workflow and run:
shell
Copied
1
$ airplane tasks init --workflow
This will create a few files:
  • <slug>.airplane.ts: Your workflow's entrypoint which stores configuration using inline config.
  • package.json: Defines your workflow's dependencies. Pre-configured with the airplane SDK.
  • tsconfig.json: TypeScript configuration file that enables intellisense for your workflow's .ts files.

Develop locally

Next, you can test your workflow using the Studio. Start the studio by running the following command:
shell
Copied
1
$ airplane dev
You will now be able to access the studio from your browser. Open your workflow from the left-hand sidebar and execute it. This will run your workflow locally and show its logs and output in the studio.
Open <slug>.airplane.ts to see the workflow stub that was created:
typescript
Copied
1
import airplane from "airplane";
2
3
export default airplane.workflow(
4
{
5
slug: "demo_upgrade_company",
6
name: "Demo: Upgrade company",
7
},
8
async () => {
9
const data = [
10
{ id: 1, name: "Gabriel Davis", role: "Dentist" },
11
{ id: 2, name: "Carolyn Garcia", role: "Sales" },
12
{ id: 3, name: "Frances Hernandez", role: "Astronaut" },
13
{ id: 4, name: "Melissa Rodriguez", role: "Engineer" },
14
{ id: 5, name: "Jacob Hall", role: "Engineer" },
15
{ id: 6, name: "Andrea Lopez", role: "Astronaut" },
16
];
17
18
// Sort the data in ascending order by name.
19
data.sort((u1, u2) => {
20
return u1.name.localeCompare(u2.name);
21
});
22
23
// You can return data to show output to users.
24
// Output documentation: https://docs.airplane.dev/tasks/output
25
return data;
26
}
27
);
When your workflow executed, it created a workflow run. Workflow runs have the same interface as task runs! The core difference is that workflows execute in a different underlying runtime that can be arbitrarily stopped and restarted without losing state. This powers features like Prompts and allows workflows to execute indefinitely.
Keep in mind that this requires your workflow code to be deterministic; in other words, the actual "work" (e.g. network calls) all happens in other tasks or via built-ins.
See Runtime for more information on the underlying workflow runtime.
So far, our workflow is not doing anything interesting. Let's change that!

Query a DB

Our goal is to write a workflow for upgrading a team's billing plan. To do that, we'll first need to know which team to upgrade. Most often, an operator will know the company name but not the company's internal ID. Let's get that ID from our database. For this demo, we'll be using the demo DB; it's a pre-configured PostgreSQL Resource available to every team on Airplane.
The easiest way to perform a DB query is via a built-in. Built-ins expose commonplace operations, such as issuing HTTP API requests or sending email, as SDK methods that can be accessed from workflows and tasks.
Let's update our workflow to use the SQL query built-in to pull up a list of companies:
typescript
Copied
1
import airplane from "airplane";
2
3
export default airplane.workflow(
4
{
5
slug: "demo_upgrade_company",
6
name: "Demo: Upgrade company",
7
resources: ["demo_db"],
8
},
9
async () => {
10
const run = await airplane.sql.query<{
11
id: number;
12
name: string;
13
total_dollars: number;
14
signup_date: string;
15
user_count: number;
16
}>(
17
"demo_db",
18
`
19
select
20
id, company_name as name, signup_date
21
from accounts
22
order by company_name asc
23
`
24
);
25
const companies = run.output.Q1;
26
27
return companies;
28
}
29
);
Notice how we attached the demo DB resource to this workflow. This grants the workflow access to the database.
You will need to restart the studio by re-running airplane dev to pick up the configuration change (attaching demo_db). All other changes are automatically picked up!
Go ahead and execute your workflow again to see the changes in action:
When the workflow executed, it created a workflow run. These runs are shown within the Runs tab:
Click on the SQL Query run to open it:
As you can see, built-in runs are similar to normal task runs! You can access errors, logs, and output from this page to debug built-in calls. To learn more, see the Built-ins overview.
Up next, let's make this workflow interactive!

Prompts

A powerful feature of workflows is that they can interact with users through Prompts. Prompts are dynamically created parameter forms, similar to what you see when executing a task or workflow.
Given our list of companies, we want the operator to pick which company to upgrade. We can do this with a Select:
typescript
Copied
1
import airplane from "airplane";
2
3
export default airplane.workflow(
4
{
5
slug: "demo_upgrade_company",
6
name: "Demo: Upgrade company",
7
resources: ["demo_db"],
8
},
9
async () => {
10
const run = await airplane.sql.query<{
11
id: number;
12
name: string;
13
total_dollars: number;
14
signup_date: string;
15
user_count: number;
16
}>(
17
"demo_db",
18
`
19
select
20
id, company_name as name, signup_date
21
from accounts
22
order by company_name asc
23
`
24
);
25
const companies = run.output.Q1;
26
27
const company = await airplane.prompt.select("Company", companies, {
28
optionToLabel: (company) => company.name,
29
description: "Select the company to upgrade.",
30
default: companies[0],
31
});
32
33
return company;
34
}
35
);
Execute this workflow again and you'll see that a parameter form is rendered in the run UI:
When you submit a choice, this selection is passed back to your workflow and it continues where it left off!
Note that this dropdown shows all companies from the database. Let's add an optional company name filter. Whenever the filter is specific enough, we can skip the prompt entirely!
Go ahead and make the following changes:
typescript
Copied
1
import airplane from "airplane";
2
3
export default airplane.workflow(
4
{
5
slug: "demo_upgrade_company",
6
name: "Demo: Upgrade company",
7
resources: ["demo_db"],
8
parameters: {
9
company: {
10
type: "shorttext",
11
name: "Company",
12
description: "Search query to find a company. Matches on company ID and name.",
13
required: false,
14
default: "",
15
},
16
},
17
},
18
async (params) => {
19
const run = await airplane.sql.query<{
20
id: number;
21
name: string;
22
signup_date: string;
23
}>(
24
"demo_db",
25
`
26
select
27
id, company_name as name, signup_date
28
from accounts
29
where
30
id::text ilike :query
31
or company_name ilike :query
32
order by company_name asc
33
`,
34
{ args: { query: "%" + params.company + "%" } }
35
);
36
const companies = run.output.Q1;
37
38
let company = companies[0];
39
if (companies.length !== 1) {
40
company = await airplane.prompt.select("Company", companies, {
41
optionToLabel: (company) => company.name,
42
description: "Select the company to upgrade.",
43
default: company,
44
});
45
}
46
47
return company;
48
}
49
);
We've made a few changes, so let's recap:
  • We added a Company parameter to the workflow and updated our SQL query to incorporate it.
  • We made the select call conditional; only prompt the user if we couldn't find an exact match.
You will need to restart the studio by re-running airplane dev to pick up the configuration change (adding the Company parameter).
Execute the workflow again to see the changes in action. For Company, try "Partner"!
If you use a value that matches none of the companies in the database, note that the workflow automatically fails!
This is the default behavior when calling select without options. This can be overriden by making the parameter optional or by skipping the prompt altogether with a if (!companies.length) check.
Great! We now have a specific company to upgrade. However, our workflow's main method is getting quite long -- let's extract it into a function!

Extract with functions

Since workflows are "just code", we can easily extract and re-use logic with functions:
typescript
Copied
1
import airplane from "airplane";
2
3
export default airplane.workflow(
4
{
5
slug: "demo_upgrade_company",
6
name: "Demo: Upgrade company",
7
resources: ["demo_db"],
8
parameters: {
9
company: {
10
type: "shorttext",
11
name: "Company",
12
description: "Search query to find a company. Matches on company ID and name.",
13
required: false,
14
default: "",
15
},
16
},
17
},
18
async (params) => {
19
const company = await lookupCompany(params.company ?? "");
20
21
return company;
22
}
23
);
24
25
export async function lookupCompany(query: string) {
26
const run = await airplane.sql.query<{
27
id: number;
28
name: string;
29
signup_date: string;
30
}>(
31
"demo_db",
32
`
33
select
34
id, company_name as name, signup_date
35
from accounts
36
where
37
id::text ilike :query
38
or company_name ilike :query
39
order by company_name asc
40
`,
41
{ args: { query: "%" + query + "%" } }
42
);
43
const companies = run.output.Q1;
44
45
let company = companies[0];
46
if (companies.length !== 1) {
47
company = await airplane.prompt.select("Company", companies, {
48
optionToLabel: (company) => company.name,
49
description: "Select the company to upgrade.",
50
default: company,
51
});
52
}
53
54
return company;
55
}
This is a perk of writing workflows as code rather than DAG-based tools such as Runbooks! You can easily package up parts of your workflow, test them in isolation, and re-use them elsewhere.
With that done, let's move on to upgrading this company.

Execute tasks

The core difference between tasks and workflows is that tasks perform operations while workflows orchestrate operations. In other words, the only "work" that a workflow does is programmatically schedule built-ins and tasks.
Up to this point, we've only used built-in operations. These work well for common operations, but for everything else you can use tasks.
For this demo, we'll use the actual upgrade operation as an example of a custom operation implemented in a separate task.
Go ahead and define a new JS task:
typescript
Copied
1
import airplane from "airplane";
2
3
export default airplane.workflow(/* ... */);
4
5
export async function lookupCompany(query: string) {
6
/* ... */
7
}
8
9
export const upgradeCompanyByID = airplane.task(
10
{
11
slug: "demo_upgrade_company_by_id",
12
name: "Demo: Upgrade company by ID",
13
parameters: {
14
company_id: {
15
type: "integer",
16
name: "Company ID",
17
description: "The ID of the company to upgrade.",
18
},
19
num_seats: {
20
type: "integer",
21
name: "New seat count",
22
description: "How many total seats the company should have once upgraded.",
23
},
24
},
25
},
26
async (params) => {
27
console.log(`Performing upgrade...`);
28
console.log(`Done!`);
29
30
return { companyID: params.company_id, numSeats: params.num_seats };
31
}
32
);
We can now call this task from our workflow:
typescript
Copied
1
import airplane from "airplane";
2
3
export default airplane.workflow(
4
{
5
slug: "demo_upgrade_company",
6
name: "Demo: Upgrade company",
7
resources: ["demo_db"],
8
parameters: {
9
company: {
10
type: "shorttext",
11
name: "Company",
12
description: "Search query to find a company. Matches on company ID and name.",
13
required: false,
14
default: "",
15
},
16
num_seats: {
17
type: "integer",
18
name: "New seat count",
19
description: "How many total seats the company should have once upgraded.",
20
},
21
},
22
},
23
async (params) => {
24
const company = await lookupCompany(params.company ?? "");
25
26
const run = await airplane.execute<{ companyID: number; numSeats: number }>(
27
"demo_upgrade_company_by_id",
28
{
29
company_id: company.id,
30
num_seats: params.num_seats,
31
}
32
);
33
34
return run.output;
35
}
36
);
37
38
export async function lookupCompany(query: string) {
39
/* ... */
40
}
41
42
export const upgradeCompanyByID = airplane.task(/* ... */);
Since we declared the task using inline config, we can execute it with a direct function call! This is syntactic sugar for performing an airplane.execute call; the task is still executed and a run is still created.
Keep in mind that you can execute any task, e.g. Shell tasks, using airplane.execute! All you need is the task slug, which you can find in the header on the task's page:
typescript
Copied
1
import airplane from "airplane";
2
3
export default airplane.workflow(
4
{
5
slug: "demo_upgrade_company",
6
name: "Demo: Upgrade company",
7
resources: ["demo_db"],
8
parameters: {
9
company: {
10
type: "shorttext",
11
name: "Company",
12
description: "Search query to find a company. Matches on company ID and name.",
13
required: false,
14
default: "",
15
},
16
num_seats: {
17
type: "integer",
18
name: "New seat count",
19
description: "How many total seats the company should have once upgraded.",
20
},
21
},
22
},
23
async (params) => {
24
const company = await lookupCompany(params.company ?? "");
25
26
const result = await upgradeCompanyByID({
27
company_id: company.id,
28
num_seats: params.num_seats,
29
});
30
31
return result;
32
}
33
);
34
35
export async function lookupCompany(query: string) {
36
/* ... */
37
}
38
39
export const upgradeCompanyByID = airplane.task(/* ... */);
Go ahead and execute the task and you'll see a new child task run:
Fantastic! 🎉 Our workflow now executes end-to-end and can "upgrade" a company's billing plan.

Handle failures

What happens if the child task fails? By default, run failures will throw a RunTerminationError that bubbles up and marks the workflow as failed. However, you can easily catch this error and handle (or ignore) it!
Let's simulate a bug in the underlying task:
typescript
Copied
1
import airplane from "airplane";
2
3
export default airplane.workflow(/* ... */);
4
5
export async function lookupCompany(query: string) {
6
/* ... */
7
}
8
9
export const upgradeCompanyByID = airplane.task(
10
{
11
slug: "demo_upgrade_company_by_id",
12
parameters: {
13
company_id: {
14
type: "integer",
15
name: "Company ID",
16
description: "The ID of the company to upgrade.",
17
},
18
num_seats: {
19
type: "integer",
20
name: "New seat count",
21
description: "How many total seats the company should have once upgraded.",
22
},
23
},
24
},
25
async (params) => {
26
console.log(`Performing upgrade...`);
27
throw new Error(`Request timed out`);
28
console.log(`Done!`);
29
30
return { companyID: params.company_id, numSeats: params.num_seats };
31
}
32
);
Go ahead and execute the workflow again. As you'll see, the workflow failed with our new error:
Let's handle this error from our workflow with a try/catch and send a nicely formatted error message to the team via Slack!
To run the next example, you'll need to have Slack connected to Airplane.
If your team does not use Slack, you can replace this example with a console.log or you can explore our email built-ins.
typescript
Copied
1
import airplane, { RunTerminationError } from "airplane";
2
3
export default airplane.workflow(
4
{
5
slug: "demo_upgrade_company",
6
name: "Demo: Upgrade company",
7
resources: ["demo_db"],
8
parameters: {
9
company: {
10
type: "shorttext",
11
name: "Company",
12
description: "Search query to find a company. Matches on company ID and name.",
13
required: false,
14
default: "",
15
},
16
num_seats: {
17
type: "integer",
18
name: "New seat count",
19
description: "How many total seats the company should have once upgraded.",
20
},
21
},
22
},
23
async (params) => {
24
const company = await lookupCompany(params.company ?? "");
25
26
try {
27
const result = await upgradeCompanyByID({
28
company_id: company.id,
29
num_seats: params.num_seats,
30
});
31
32
return result;
33
} catch (err) {
34
const errMessage = err instanceof RunTerminationError ? err.run.output.error : String(err);
35
await airplane.slack.message(
36
// Swap this with any Slack channel. If the channel is private, you'll need to ensure the
37
// Airplane Slack user is invited.
38
"#test-airplane",
39
// You can use Slack markdown here:
40
// https://api.slack.com/reference/surfaces/formatting
41
`Failed to upgrade company "${company.name}". <https://app.airplane.dev/runs/${process.env.AIRPLANE_RUN_ID}|View in Airplane>.\n\n\`\`\`\n${errMessage}\n\`\`\`\n`
42
);
43
throw err;
44
}
45
}
46
);
47
48
export async function lookupCompany(query: string) {
49
/* ... */
50
}
51
52
export const upgradeCompanyByID = airplane.task(/* ... */);
Give this workflow another run and you'll see a Slack message appear:

Deploy to Airplane

Congrats! You've successfully built your first Airplane Workflow.
To wrap things up, go ahead and deploy your workflow:
shell
Copied
1
$ airplane deploy
Once deployed, go to your Library to see your workflow in action!