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 Upgrading the CLI. You can check your version by running airplane version.

Create a task

Once you've installed and logged in to the CLI, navigate to the folder where you want to create your task and run:
shell
Copied
1
$ airplane tasks init --inline
2
# Use "Demo: Upgrade company" for the name.
3
# Select "JavaScript" or "Python" for the task kind.
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

Next, you can test your task using 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 task from the left-hand sidebar and try executing it. This will run your task locally and show its logs and output in the 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
resources: ["demo_db"],
8
// Grant this task access to the Demo DB:
9
resources: ["demo_db"],
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
const result = await upgradeCompanyByID({
30
company_id: companyID,
31
num_seats: params.num_seats,
32
});
33
34
return result.output;
35
}
36
);
37
38
export async function lookupCompany(query: string) {
39
/* ... */
40
}
41
42
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
resources: ["demo_db"],
8
// Grant this task access to the Demo DB:
9
resources: ["demo_db"],
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(/* ... */);
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
resources: ["demo_db"],
8
// Grant this task access to the Demo DB:
9
resources: ["demo_db"],
10
runtime: "workflow",
11
parameters: {
12
company: {
13
type: "shorttext",
14
name: "Company",
15
description: "Search query to find a company. Matches on company ID and name.",
16
required: false,
17
default: "",
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
default: 10,
24
},
25
},
26
},
27
async (params) => {
28
const companyID = await lookupCompany(params.company);
29
30
try {
31
const result = await upgradeCompanyByID({
32
company_id: companyID,
33
num_seats: params.num_seats,
34
});
35
36
return result.output;
37
} catch (err) {
38
const errMessage = err instanceof RunTerminationError ? err.run.output.error : String(err);
39
await airplane.slack.message(
40
// Swap this with any Slack channel. If the channel is private, you'll need to ensure the
41
// Airplane Slack user is invited.
42
"#test-airplane",
43
// You can use Slack markdown here:
44
// https://api.slack.com/reference/surfaces/formatting
45
`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`
46
);
47
throw err;
48
}
49
}
50
);
51
52
export async function lookupCompany(query: string) {
53
/* ... */
54
}
55
56
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: