Orchestrate tasks

Orchestrate a multi-step, semi-automatic task in under 10 minutes
Airplane Tasks make it possible to orchestrate complex processes that span multiple steps and engage with human operators. This guide will walk you through writing a task that upgrades a company's billing plan. Along the way, we'll explore a number of important orchestration concepts like prompts, task 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 by Updating the CLI. You can check your version by running airplane version.

Set up a database

If you don't already have a database set up, you can create an Airplane Postgres database under the resource settings page.
In this example, we will name the database Demo DB.
On the resource settings page, you can run this query to create a table and insert data into it:
sql
Copied
1
CREATE TABLE accounts (
2
id SERIAL PRIMARY KEY,
3
company_name text,
4
signup_date timestamp with time zone DEFAULT now(),
5
total_dollars integer DEFAULT 0,
6
country text DEFAULT 'USA'::text
7
);
8
9
INSERT INTO accounts (id, company_name, signup_date, total_dollars, country)
10
VALUES
11
(0, 'Future Golf Partners', '2020-03-21 04:48:23.532+00', 1655427, 'Brazil'),
12
(1, 'Amalgamated Star LLC', '2020-07-16 00:40:30.103+00', 43403102, 'Canada'),
13
(2, 'Blue Sky Corp', '2019-04-18 10:14:24.71+00', 1304097, 'France'),
14
(3, 'Yeet Table Inc', '2019-10-01 10:06:39.013+00', 4036934, 'Mexico');

Create a task

We'll be developing in Studio, Airplane's development tool for building tasks and views. For this guide, we recommend using Studio with a local development server in a new, empty directory.
shell
Copied
1
# mkdir airplane-getting-started-orchestration
2
# cd airplane-getting-started-orchestration
3
airplane dev
Navigate to Studio in your browser.
To create a new task, click the New button in the upper right.
Select Task to create a new task.
Choose JavaScript or Python to specify a JavaScript or Python task. Name your task Demo: Upgrade company. You can add a description to the task if you like.
Click Create to create your task. This will create a few files:
  • {slug}.airplane.ts: Your task's entrypoint which stores configuration using inline config.
  • package.json: Defines your task's dependencies. Pre-configured with the Airplane JavaScript SDK SDK.
  • tsconfig.json: TypeScript configuration file that enables intellisense for your task's .ts files.

Develop locally

You can test your task locally by executing it in Studio. Click on the Execute button to run your task locally. You'll be able to see any logs and outputs from your task in Studio.
So far, our task is not doing anything interesting. Let's change that!

Query a DB

To upgrade a company's billing plan, we'll first need to know their ID. For this demo, we'll be using a pre-configured PostgreSQL Resource called Demo DB.
Update your task to look up a company's ID by name by updating {slug}.airplane.js with the following contents:
typescript
Copied
1
import airplane from "airplane";
2
3
export default airplane.task(
4
{
5
slug: "demo_upgrade_company",
6
name: "Demo: Upgrade company",
7
// Grant this task access to the Demo DB:
8
resources: ["demo_db"],
9
parameters: {
10
company: {
11
type: "shorttext",
12
name: "Company",
13
description: "Search query to find a company. Matches on company ID and name.",
14
required: false,
15
default: "",
16
},
17
},
18
},
19
async (params) => {
20
const run = await airplane.sql.query<{
21
id: number;
22
name: string;
23
signup_date: string;
24
}>(
25
"demo_db",
26
`
27
select
28
id, company_name as name, signup_date
29
from accounts
30
where
31
id::text ilike :query
32
or company_name ilike :query
33
order by company_name asc
34
`,
35
{ args: { query: "%" + (params.company ?? "") + "%" } },
36
);
37
const companies = run.output.Q1;
38
39
return companies;
40
},
41
);
Our task will now perform an SQL query on the Demo DB by using the SQL built-in. Go ahead and execute your task again to see the changes in action.
Built-ins expose commonplace operations—such as issuing API requests and sending emails—as SDK methods that can be accessed from tasks. To learn about the other built-ins, see Built-ins.
When your task called the built-in SDK, it created a child run—similar to how executing a task creates a run! This child run can be found from the Runs tab.
Up next, let's make this task interactive!

Prompts

Tasks can request additional parameters at runtime using Prompts. Prompts are dynamically created parameter forms, similar to what you see when executing a task.
Given our list of companies, we want the operator to pick which company to upgrade. We can do this with a prompt that has select options.
typescript
Copied
1
import airplane from "airplane";
2
3
export default airplane.task(
4
{
5
slug: "demo_upgrade_company",
6
name: "Demo: Upgrade company",
7
// Grant this task access to the Demo DB:
8
resources: ["demo_db"],
9
parameters: {
10
company: {
11
type: "shorttext",
12
name: "Company",
13
description: "Search query to find a company. Matches on company ID and name.",
14
required: false,
15
default: "",
16
},
17
},
18
},
19
async (params) => {
20
const run = await airplane.sql.query<{
21
id: number;
22
name: string;
23
signup_date: string;
24
}>(
25
"demo_db",
26
`
27
select
28
id, company_name as name, signup_date
29
from accounts
30
where
31
id::text ilike :query
32
or company_name ilike :query
33
order by company_name asc
34
`,
35
{ args: { query: "%" + (params.company ?? "") + "%" } },
36
);
37
const companies = run.output.Q1;
38
if (companies.length === 0) {
39
throw new Error("No companies found");
40
}
41
42
const { company_id } = await airplane.prompt({
43
company_id: {
44
type: "integer",
45
name: "Company",
46
options: companies.map((c) => ({ label: c.name, value: c.id })),
47
default: companies[0].id,
48
},
49
});
50
51
return company_id;
52
},
53
);
Execute this task again and you'll see that a parameter form is rendered in the run UI:
The run will wait until you pick a company before continuing.
If the company filter is specific enough that it matches only one company, we don't need to prompt the user! Let's tweak our task to conditionally skip the prompt in that case:
typescript
Copied
1
import airplane from "airplane";
2
3
export default airplane.task(
4
{
5
slug: "demo_upgrade_company",
6
name: "Demo: Upgrade company",
7
// Grant this task access to the Demo DB:
8
resources: ["demo_db"],
9
parameters: {
10
company: {
11
type: "shorttext",
12
name: "Company",
13
description: "Search query to find a company. Matches on company ID and name.",
14
required: false,
15
default: "",
16
},
17
},
18
},
19
async (params) => {
20
const run = await airplane.sql.query<{
21
id: number;
22
name: string;
23
signup_date: string;
24
}>(
25
"demo_db",
26
`
27
select
28
id, company_name as name, signup_date
29
from accounts
30
where
31
id::text ilike :query
32
or company_name ilike :query
33
order by company_name asc
34
`,
35
{ args: { query: "%" + (params.company ?? "") + "%" } },
36
);
37
const companies = run.output.Q1;
38
if (companies.length === 0) {
39
throw new Error("No companies found");
40
}
41
42
let company_id = companies[0].id;
43
if (companies.length > 1) {
44
company_id = (
45
await airplane.prompt({
46
company_id: {
47
type: "integer",
48
name: "Company",
49
options: companies.map((c) => ({ label: c.name, value: c.id })),
50
default: company_id,
51
},
52
})
53
).company_id;
54
}
55
56
return company_id;
57
},
58
);
If you execute this task with "Blue Sky Corp", the task will now skip the prompt! If you instead execute it with "Partner", this will match multiple companies, so you will still get prompted.
Great! We now have a specific company to upgrade. Let's extract this logic into a separate method to keep our main method short:
typescript
Copied
1
import airplane from "airplane";
2
3
export default airplane.task(
4
{
5
slug: "demo_upgrade_company",
6
name: "Demo: Upgrade company",
7
// Grant this task access to the Demo DB:
8
resources: ["demo_db"],
9
parameters: {
10
company: {
11
type: "shorttext",
12
name: "Company",
13
description: "Search query to find a company. Matches on company ID and name.",
14
required: false,
15
default: "",
16
},
17
},
18
},
19
async (params) => {
20
const companyID = await lookupCompany(params.company);
21
22
return companyID;
23
},
24
);
25
26
export async function lookupCompany(query: string) {
27
const run = await airplane.sql.query<{
28
id: number;
29
name: string;
30
signup_date: string;
31
}>(
32
"demo_db",
33
`
34
select
35
id, company_name as name, signup_date
36
from accounts
37
where
38
id::text ilike :query
39
or company_name ilike :query
40
order by company_name asc
41
`,
42
{ args: { query: "%" + (query ?? "") + "%" } },
43
);
44
const companies = run.output.Q1;
45
if (companies.length === 0) {
46
throw new Error("No companies found");
47
}
48
49
let company_id = companies[0].id;
50
if (companies.length > 1) {
51
company_id = (
52
await airplane.prompt({
53
company_id: {
54
type: "integer",
55
name: "Company",
56
options: companies.map((c) => ({ label: c.name, value: c.id })),
57
default: company_id,
58
},
59
})
60
).company_id;
61
}
62
63
return company_id;
64
}
With that done, let's move on to upgrading this company.

Execute tasks

Let's say another coworker already maintains a task for upgrading companies by ID. We don't want to re-implement that logic, so we'll call their task. For this demo, go ahead and define that task:
typescript
Copied
1
import airplane from "airplane";
2
3
export default airplane.task(/* ... */);
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
);
Since we declared the task using inline configuration, 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.
typescript
Copied
1
import airplane from "airplane";
2
3
export default airplane.task(
4
{
5
slug: "demo_upgrade_company",
6
name: "Demo: Upgrade company",
7
// Grant this task access to the Demo DB:
8
resources: ["demo_db"],
9
parameters: {
10
company: {
11
type: "shorttext",
12
name: "Company",
13
description: "Search query to find a company. Matches on company ID and name.",
14
required: false,
15
default: "",
16
},
17
num_seats: {
18
type: "integer",
19
name: "New seat count",
20
description: "How many total seats the company should have once upgraded.",
21
default: 10,
22
},
23
},
24
},
25
async (params) => {
26
const companyID = await lookupCompany(params.company);
27
28
const result = await upgradeCompanyByID({
29
company_id: companyID,
30
num_seats: params.num_seats,
31
});
32
33
return result.output;
34
},
35
);
36
37
export async function lookupCompany(query: string) {
38
/* ... */
39
}
40
41
export const upgradeCompanyByID = airplane.task(/* ... */);
Keep in mind that you can execute any task, e.g. Shell tasks, using airplane.execute directly. All you need is the task slug, which you can find in the header on the task's page:
Go ahead and execute the task and you'll see a new child task run:
Fantastic! 🎉 Our task now executes end-to-end and can "upgrade" a company's billing plan.

Handle failures

What happens if the upgrade task fails? By default, run failures will throw a error which bubbles up and marks the task 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.task(/* ... */);
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
throw new Error("Request timed out");
29
console.log("Done!");
30
31
return { companyID: params.company_id, numSeats: params.num_seats };
32
},
33
);
Go ahead and execute the task again. As you'll see, the task failed with our new error:
Let's handle this error from our task with exception handling 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 try our email built-ins instead.
typescript
Copied
1
import airplane, { RunTerminationError } from "airplane";
2
3
export default airplane.task(
4
{
5
slug: "demo_upgrade_company",
6
name: "Demo: Upgrade company",
7
// Grant this task access to the Demo DB:
8
resources: ["demo_db"],
9
parameters: {
10
company: {
11
type: "shorttext",
12
name: "Company",
13
description: "Search query to find a company. Matches on company ID and name.",
14
required: false,
15
default: "",
16
},
17
num_seats: {
18
type: "integer",
19
name: "New seat count",
20
description: "How many total seats the company should have once upgraded.",
21
default: 10,
22
},
23
},
24
},
25
async (params) => {
26
const companyID = await lookupCompany(params.company);
27
28
try {
29
const result = await upgradeCompanyByID({
30
company_id: companyID,
31
num_seats: params.num_seats,
32
});
33
34
return result.output;
35
} catch (err) {
36
const errMessage = err instanceof RunTerminationError ? err.run.output.error : String(err);
37
await airplane.slack.message(
38
// Swap this with any Slack channel. If the channel is private, you'll need to ensure the
39
// Airplane Slack user is invited.
40
"#test-airplane",
41
// You can use Slack markdown here:
42
// https://api.slack.com/reference/surfaces/formatting
43
`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`,
44
);
45
throw err;
46
}
47
},
48
);
49
50
export async function lookupCompany(query: string) {
51
/* ... */
52
}
53
54
export const upgradeCompanyByID = airplane.task(/* ... */);
Give this task another run and you'll see a Slack message appear:

Switching runtimes

The workflow runtime for Airplane Tasks is currently in beta, and minor details may change. We'd love to hear any feedback or requests at hello@airplane.dev.
By default, tasks run on the standard runtime which means that your script will continue to run in the background while waiting for a user to select a company.
By switching the top-level task to the workflow runtime, Airplane can automatically pause the run while waiting for operations to complete (e.g. waiting for the prompt to be submitted or a child run to complete).
In order pause and resume workflow runs, the workflow runtime applies a few restrictions on what workflow code can do. To learn more, see the workflow runtime overview.
To switch, set the runtime field. You won't see any changes when running this task locally, but the task will now be able to wait much longer for a response—up to 60 days!
typescript
Copied
1
import airplane, { RunTerminationError } from "airplane";
2
3
export default airplane.task(
4
{
5
slug: "demo_upgrade_company",
6
name: "Demo: Upgrade company",
7
// Grant this task access to the Demo DB:
8
resources: ["demo_db"],
9
runtime: "workflow",
10
parameters: {
11
company: {
12
type: "shorttext",
13
name: "Company",
14
description: "Search query to find a company. Matches on company ID and name.",
15
required: false,
16
default: "",
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
default: 10,
23
},
24
},
25
},
26
async (params) => {
27
const companyID = await lookupCompany(params.company);
28
29
try {
30
const result = await upgradeCompanyByID({
31
company_id: companyID,
32
num_seats: params.num_seats,
33
});
34
35
return result.output;
36
} catch (err) {
37
const errMessage = err instanceof RunTerminationError ? err.run.output.error : String(err);
38
await airplane.slack.message(
39
// Swap this with any Slack channel. If the channel is private, you'll need to ensure the
40
// Airplane Slack user is invited.
41
"#test-airplane",
42
// You can use Slack markdown here:
43
// https://api.slack.com/reference/surfaces/formatting
44
`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`,
45
);
46
throw err;
47
}
48
},
49
);
50
51
export async function lookupCompany(query: string) {
52
/* ... */
53
}
54
55
export const upgradeCompanyByID = airplane.task(/* ... */);

Deploy to Airplane

To wrap things up, go ahead and deploy your task:
shell
Copied
1
$ airplane deploy
Once deployed, go to your Library to see your task in action!

Wrapping up

As you've now seen, Tasks support many features for modeling complex processes. Processes that were previously built with graph-based orchestration tools can instead be written with code using Tasks!
To learn more, see: