@notionhq/apps 0.0.26 → 0.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/calendar.generated.d.ts +1908 -680
  2. package/dist/calendar.generated.d.ts.map +1 -1
  3. package/dist/calendar.generated.js +27 -0
  4. package/dist/connection-trigger-definitions.generated.d.ts +12 -0
  5. package/dist/connection-trigger-definitions.generated.d.ts.map +1 -1
  6. package/dist/connection-trigger-definitions.generated.js +15 -0
  7. package/dist/connections.d.ts +1 -0
  8. package/dist/connections.d.ts.map +1 -1
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +2 -0
  12. package/dist/mail.generated.d.ts +4590 -0
  13. package/dist/mail.generated.d.ts.map +1 -0
  14. package/dist/mail.generated.js +252 -0
  15. package/dist/providers.generated.d.ts +4 -0
  16. package/dist/providers.generated.d.ts.map +1 -1
  17. package/dist/providers.generated.js +7 -0
  18. package/dist/triggers.generated.d.ts +34 -16
  19. package/dist/triggers.generated.d.ts.map +1 -1
  20. package/dist/triggers.generated.js +12 -9
  21. package/dist/workflow.d.ts +61 -1
  22. package/dist/workflow.d.ts.map +1 -1
  23. package/dist/workflow.js +109 -1
  24. package/docs/CONNECTIONS.md +38 -3
  25. package/docs/workflow-inputs.md +28 -0
  26. package/package.json +1 -1
  27. package/src/calendar.generated.ts +2033 -737
  28. package/src/connection-trigger-definitions.generated.ts +18 -0
  29. package/src/connections.test.ts +35 -0
  30. package/src/index.ts +1 -0
  31. package/src/mail.generated.ts +5095 -0
  32. package/src/providers.generated.ts +8 -0
  33. package/src/triggers.generated.ts +27 -18
  34. package/src/workflow-connections-types.test.ts +16 -0
  35. package/src/workflow.test.ts +210 -0
  36. package/src/workflow.ts +226 -3
@@ -2,6 +2,7 @@
2
2
  import type { ConnectionActionInvoker } from "./connection-actions.js"
3
3
  import type { WorkflowConnection } from "./connections.js"
4
4
  import { CalendarConnectionClient } from "./calendar.generated.js"
5
+ import { MailConnectionClient } from "./mail.generated.js"
5
6
  import { SlackConnectionClient } from "./slack.generated.js"
6
7
  import { GoogleDriveOauthConnectionClient } from "./googleDriveOauth.generated.js"
7
8
  import { DiscordConnectionClient } from "./discord.generated.js"
@@ -15,6 +16,7 @@ import { OutlookConnectionClient } from "./outlook.generated.js"
15
16
  import { SalesforceConnectionClient } from "./salesforce.generated.js"
16
17
  export const connections = {
17
18
  calendar,
19
+ mail,
18
20
  slack,
19
21
  googleDriveOauth,
20
22
  discord,
@@ -29,6 +31,7 @@ export const connections = {
29
31
  }
30
32
  export type ConnectionClients = {
31
33
  calendar: CalendarConnectionClient
34
+ mail: MailConnectionClient
32
35
  slack: SlackConnectionClient
33
36
  googleDriveOauth: GoogleDriveOauthConnectionClient
34
37
  discord: DiscordConnectionClient
@@ -48,6 +51,8 @@ export function createConnectionClient(
48
51
  switch (provider) {
49
52
  case "calendar":
50
53
  return new CalendarConnectionClient(invoker)
54
+ case "mail":
55
+ return new MailConnectionClient(invoker)
51
56
  case "slack":
52
57
  return new SlackConnectionClient(invoker)
53
58
  case "googleDriveOauth":
@@ -77,6 +82,9 @@ export function createConnectionClient(
77
82
  function calendar(): WorkflowConnection<"calendar"> {
78
83
  return { type: "calendar" }
79
84
  }
85
+ function mail(): WorkflowConnection<"mail"> {
86
+ return { type: "mail" }
87
+ }
80
88
  function slack(): WorkflowConnection<"slack"> {
81
89
  return { type: "slack" }
82
90
  }
@@ -102,19 +102,25 @@ export type DiscordInteractionTrigger<TKey extends string = string> = {
102
102
  connectionKey?: TKey;
103
103
  };
104
104
 
105
- /** An email was received. */
106
- export type MailEmailReceivedTrigger = {
105
+ /** Triggers immediately when a new email is received in Notion Mail */
106
+ export type MailEmailReceivedTrigger<TKey extends string = string> = {
107
107
  type: "mail.email.received";
108
+ /** Key of the declared mail connection. */
109
+ connectionKey?: TKey;
108
110
  };
109
111
 
110
- /** An email was sent. */
111
- export type MailEmailSentTrigger = {
112
+ /** Triggers immediately when a new email is sent from Notion Mail */
113
+ export type MailEmailSentTrigger<TKey extends string = string> = {
112
114
  type: "mail.email.sent";
115
+ /** Key of the declared mail connection. */
116
+ connectionKey?: TKey;
113
117
  };
114
118
 
115
- /** A label was applied to an email. */
116
- export type MailLabelAppliedTrigger = {
119
+ /** Triggers immediately when a label is applied to an email in Notion Mail */
120
+ export type MailLabelAppliedTrigger<TKey extends string = string> = {
117
121
  type: "mail.label.applied";
122
+ /** Key of the declared mail connection. */
123
+ connectionKey?: TKey;
118
124
  };
119
125
 
120
126
  /** Triggers immediately when a calendar event is created */
@@ -248,33 +254,36 @@ export const triggers = {
248
254
  /**
249
255
  * Declare that a workflow can run on `mail.email.received` events.
250
256
  *
251
- * An email was received.
257
+ * Triggers immediately when a new email is received in Notion Mail
252
258
  */
253
- mailEmailReceived(): MailEmailReceivedTrigger {
259
+ mailEmailReceived<const TKey extends string = never>(options: { connectionKey: TKey } | undefined = undefined): MailEmailReceivedTrigger<TKey> {
254
260
  return {
255
261
  type: "mail.email.received",
262
+ ...(options === undefined ? {} : { connectionKey: options.connectionKey }),
256
263
  };
257
264
  },
258
265
 
259
266
  /**
260
267
  * Declare that a workflow can run on `mail.email.sent` events.
261
268
  *
262
- * An email was sent.
269
+ * Triggers immediately when a new email is sent from Notion Mail
263
270
  */
264
- mailEmailSent(): MailEmailSentTrigger {
271
+ mailEmailSent<const TKey extends string = never>(options: { connectionKey: TKey } | undefined = undefined): MailEmailSentTrigger<TKey> {
265
272
  return {
266
273
  type: "mail.email.sent",
274
+ ...(options === undefined ? {} : { connectionKey: options.connectionKey }),
267
275
  };
268
276
  },
269
277
 
270
278
  /**
271
279
  * Declare that a workflow can run on `mail.label.applied` events.
272
280
  *
273
- * A label was applied to an email.
281
+ * Triggers immediately when a label is applied to an email in Notion Mail
274
282
  */
275
- mailLabelApplied(): MailLabelAppliedTrigger {
283
+ mailLabelApplied<const TKey extends string = never>(options: { connectionKey: TKey } | undefined = undefined): MailLabelAppliedTrigger<TKey> {
276
284
  return {
277
285
  type: "mail.label.applied",
286
+ ...(options === undefined ? {} : { connectionKey: options.connectionKey }),
278
287
  };
279
288
  },
280
289
 
@@ -443,9 +452,9 @@ export type WorkflowTriggerCreators<TConnections extends WorkflowConnectionDecla
443
452
  slackReactionAdded(options?: { connectionKey: WorkflowConnectionKeys<TConnections, "slack"> }): SlackReactionAddedTrigger<WorkflowConnectionKeys<TConnections, "slack">>;
444
453
  slackAppMention(options?: { connectionKey: WorkflowConnectionKeys<TConnections, "slack"> }): SlackAppMentionTrigger<WorkflowConnectionKeys<TConnections, "slack">>;
445
454
  discordInteraction(options?: { connectionKey: WorkflowConnectionKeys<TConnections, "discord"> }): DiscordInteractionTrigger<WorkflowConnectionKeys<TConnections, "discord">>;
446
- mailEmailReceived(): MailEmailReceivedTrigger;
447
- mailEmailSent(): MailEmailSentTrigger;
448
- mailLabelApplied(): MailLabelAppliedTrigger;
455
+ mailEmailReceived(options?: { connectionKey: WorkflowConnectionKeys<TConnections, "mail"> }): MailEmailReceivedTrigger<WorkflowConnectionKeys<TConnections, "mail">>;
456
+ mailEmailSent(options?: { connectionKey: WorkflowConnectionKeys<TConnections, "mail"> }): MailEmailSentTrigger<WorkflowConnectionKeys<TConnections, "mail">>;
457
+ mailLabelApplied(options?: { connectionKey: WorkflowConnectionKeys<TConnections, "mail"> }): MailLabelAppliedTrigger<WorkflowConnectionKeys<TConnections, "mail">>;
449
458
  calendarEventCreated(options?: { connectionKey: WorkflowConnectionKeys<TConnections, "calendar"> }): CalendarEventCreatedTrigger<WorkflowConnectionKeys<TConnections, "calendar">>;
450
459
  calendarEventUpdated(options?: { connectionKey: WorkflowConnectionKeys<TConnections, "calendar"> }): CalendarEventUpdatedTrigger<WorkflowConnectionKeys<TConnections, "calendar">>;
451
460
  calendarEventCanceled(options?: { connectionKey: WorkflowConnectionKeys<TConnections, "calendar"> }): CalendarEventCanceledTrigger<WorkflowConnectionKeys<TConnections, "calendar">>;
@@ -468,9 +477,9 @@ export type WorkflowTriggerForConnections<TConnections extends WorkflowConnectio
468
477
  | SlackReactionAddedTrigger<WorkflowConnectionKeys<TConnections, "slack">>
469
478
  | SlackAppMentionTrigger<WorkflowConnectionKeys<TConnections, "slack">>
470
479
  | DiscordInteractionTrigger<WorkflowConnectionKeys<TConnections, "discord">>
471
- | MailEmailReceivedTrigger
472
- | MailEmailSentTrigger
473
- | MailLabelAppliedTrigger
480
+ | MailEmailReceivedTrigger<WorkflowConnectionKeys<TConnections, "mail">>
481
+ | MailEmailSentTrigger<WorkflowConnectionKeys<TConnections, "mail">>
482
+ | MailLabelAppliedTrigger<WorkflowConnectionKeys<TConnections, "mail">>
474
483
  | CalendarEventCreatedTrigger<WorkflowConnectionKeys<TConnections, "calendar">>
475
484
  | CalendarEventUpdatedTrigger<WorkflowConnectionKeys<TConnections, "calendar">>
476
485
  | CalendarEventCanceledTrigger<WorkflowConnectionKeys<TConnections, "calendar">>
@@ -3,6 +3,19 @@ import { triggers } from "./triggers.generated.js";
3
3
  import { connections, workflow, type WorkflowConnectionDeclarations } from "./workflow.js";
4
4
 
5
5
  it("infers only the declared provider clients", () => {
6
+ workflow({
7
+ name: "Mail",
8
+ description: "Read the configured mailbox",
9
+ connections: { inbox: connections.mail() },
10
+ triggers: ({ triggers }) => [triggers.mailEmailReceived({ connectionKey: "inbox" })],
11
+ handler(_event, context) {
12
+ expectTypeOf<keyof typeof context.connections>().toEqualTypeOf<"inbox">();
13
+ expectTypeOf(context.connections.inbox.searchEmails).toBeFunction();
14
+ expectTypeOf(context.connections.inbox.listLabels).toBeFunction();
15
+ expectTypeOf(context.connections.inbox.sendNewEmail).toBeFunction();
16
+ expectTypeOf(context.connections.inbox.createMailBlock).toBeFunction();
17
+ },
18
+ });
6
19
  workflow({
7
20
  name: "Calendar",
8
21
  description: "Calendar only",
@@ -11,6 +24,9 @@ it("infers only the declared provider clients", () => {
11
24
  handler(_event, context) {
12
25
  expectTypeOf<keyof typeof context.connections>().toEqualTypeOf<"work">();
13
26
  expectTypeOf(context.connections.work.listCalendars).toBeFunction();
27
+ expectTypeOf(context.connections.work.createEvents).toBeFunction();
28
+ expectTypeOf(context.connections.work.updateEvents).toBeFunction();
29
+ expectTypeOf(context.connections.work.cancelEvents).toBeFunction();
14
30
  },
15
31
  });
16
32
  const requirements = {
@@ -14,9 +14,11 @@ import { page } from "./notion-as-code/page.js";
14
14
  import {
15
15
  access,
16
16
  connections,
17
+ input,
17
18
  workflow,
18
19
  type StepContext,
19
20
  type WorkflowContext,
21
+ type WorkflowInputDate,
20
22
  type WorkflowWaitDuration,
21
23
  } from "./workflow.js";
22
24
 
@@ -1017,6 +1019,214 @@ describe("wait.until", () => {
1017
1019
  });
1018
1020
  });
1019
1021
 
1022
+ describe("wait.forInput", () => {
1023
+ it("stops the workflow with a typed form schema", async () => {
1024
+ let continued = false;
1025
+ const definition = workflow({
1026
+ name: "Collect launch details",
1027
+ description: "Waits for a person to provide launch details",
1028
+ triggers: [triggers.notionPageCreated()],
1029
+ handler: async (_event, context) => {
1030
+ await context.wait.forInput("Launch details", {
1031
+ input: {
1032
+ summary: input.text(),
1033
+ priority: input.select(["low", "medium", "high"]),
1034
+ notes: input.text({ optional: true }),
1035
+ teams: input.multiSelect(["Design", "Engineering"]),
1036
+ },
1037
+ });
1038
+ continued = true;
1039
+ },
1040
+ });
1041
+
1042
+ await expect(definition.handler(event, { concreteOutput: true })).resolves.toEqual({
1043
+ status: "waiting",
1044
+ wait: {
1045
+ type: "input",
1046
+ name: "Launch details",
1047
+ stepKey: workflowStepKey(["wait", "input", "Launch details"]),
1048
+ input: {
1049
+ summary: { type: "text" },
1050
+ priority: {
1051
+ type: "select",
1052
+ options: ["low", "medium", "high"],
1053
+ },
1054
+ notes: { type: "text", optional: true },
1055
+ teams: { type: "multi_select", options: ["Design", "Engineering"] },
1056
+ },
1057
+ },
1058
+ });
1059
+ expect(continued).toBe(false);
1060
+ });
1061
+
1062
+ it("returns the typed response stored in its durable checkpoint", async () => {
1063
+ const directory = await createCheckpointDirectory();
1064
+ const stepKey = workflowStepKey(["wait", "input", "Launch details"]);
1065
+ await writeFile(
1066
+ checkpointPath(directory, stepKey),
1067
+ JSON.stringify({
1068
+ version: 0,
1069
+ key: stepKey,
1070
+ value: {
1071
+ value: {
1072
+ summary: "Ship the new workflow",
1073
+ priority: "high",
1074
+ labels: ["Design", "Engineering"],
1075
+ owners: ["first-user-id", "second-user-id"],
1076
+ window: {
1077
+ start: "2026-09-22T09:00",
1078
+ end: "2026-09-23T17:00",
1079
+ timeZone: "America/Los_Angeles",
1080
+ },
1081
+ attachments: ["https://example.com/plan.pdf", "attachment-block-id"],
1082
+ },
1083
+ respondedBy: "user-id",
1084
+ respondedAt: "2026-09-18T12:00:00.000Z",
1085
+ },
1086
+ sourceRunId: "input-run",
1087
+ sourceStepId: "input-step",
1088
+ }),
1089
+ );
1090
+
1091
+ let capturedPriority: "low" | "medium" | "high" | undefined;
1092
+ let capturedInputs: unknown;
1093
+ const definition = workflow({
1094
+ name: "Collect launch details",
1095
+ description: "Waits for a person to provide launch details",
1096
+ triggers: [triggers.notionPageCreated()],
1097
+ handler: async (_event, context) => {
1098
+ const response = await context.wait.forInput("Launch details", {
1099
+ input: {
1100
+ summary: input.text(),
1101
+ priority: input.select(["low", "medium", "high"]),
1102
+ labels: input.multiSelect(["Design", "Engineering"]),
1103
+ owners: input.person(),
1104
+ notes: input.text({ optional: true }),
1105
+ window: input.date(),
1106
+ attachments: input.files({ optional: true }),
1107
+ },
1108
+ });
1109
+ expectTypeOf(response.value.summary).toEqualTypeOf<string>();
1110
+ expectTypeOf(response.value.priority).toEqualTypeOf<"low" | "medium" | "high">();
1111
+ capturedPriority = response.value.priority;
1112
+ expectTypeOf(response.value.labels).toEqualTypeOf<("Design" | "Engineering")[]>();
1113
+ expectTypeOf(response.value.owners).toEqualTypeOf<string[]>();
1114
+ expectTypeOf(response.value.window).toEqualTypeOf<WorkflowInputDate>();
1115
+ expectTypeOf(response.value.attachments).toEqualTypeOf<string[] | undefined>();
1116
+ capturedInputs = response.value;
1117
+ },
1118
+ });
1119
+
1120
+ await expect(definition.handler(event, { concreteOutput: true })).resolves.toEqual({
1121
+ status: "success",
1122
+ });
1123
+ expect(capturedPriority).toBe("high");
1124
+ expect(capturedInputs).toEqual({
1125
+ summary: "Ship the new workflow",
1126
+ priority: "high",
1127
+ labels: ["Design", "Engineering"],
1128
+ owners: ["first-user-id", "second-user-id"],
1129
+ window: {
1130
+ start: "2026-09-22T09:00",
1131
+ end: "2026-09-23T17:00",
1132
+ timeZone: "America/Los_Angeles",
1133
+ },
1134
+ attachments: ["https://example.com/plan.pdf", "attachment-block-id"],
1135
+ });
1136
+ });
1137
+
1138
+ it.each([
1139
+ { extra: { admin: true } },
1140
+ { extra: { options: { admin: true } } },
1141
+ { extra: { ["__proto__"]: { admin: true } } },
1142
+ ])("rejects undeclared response fields: $extra", async ({ extra }) => {
1143
+ const directory = await createCheckpointDirectory();
1144
+ const stepKey = workflowStepKey(["wait", "input", "Review"]);
1145
+ await writeFile(
1146
+ checkpointPath(directory, stepKey),
1147
+ JSON.stringify({
1148
+ version: 0,
1149
+ key: stepKey,
1150
+ value: {
1151
+ value: { priority: "high", ...extra },
1152
+ respondedBy: "user-id",
1153
+ respondedAt: "2026-09-18T12:00:00.000Z",
1154
+ },
1155
+ sourceRunId: "input-run",
1156
+ sourceStepId: "input-step",
1157
+ }),
1158
+ );
1159
+ const definition = workflow({
1160
+ name: "Review",
1161
+ description: "Rejects undeclared input",
1162
+ triggers: [triggers.notionPageCreated()],
1163
+ handler: async (_event, context) => {
1164
+ await context.wait.forInput("Review", {
1165
+ input: {
1166
+ priority: input.select(["low", "high"]),
1167
+ notes: input.text({ optional: true }),
1168
+ },
1169
+ });
1170
+ },
1171
+ });
1172
+ await expect(definition.handler(event, { concreteOutput: true })).rejects.toThrow(
1173
+ "does not match its schema",
1174
+ );
1175
+ });
1176
+
1177
+ it.each([
1178
+ { name: "unknown select", field: input.select(["low", "medium", "high"]), value: "urgent" },
1179
+ {
1180
+ name: "unknown multi-select",
1181
+ field: input.multiSelect(["Design", "Engineering"]),
1182
+ value: ["Design", "Other"],
1183
+ },
1184
+ { name: "scalar multi-select", field: input.multiSelect(["Design"]), value: "Design" },
1185
+ { name: "scalar person", field: input.person(), value: "user-id" },
1186
+ { name: "invalid person array", field: input.person(), value: ["user-id", 123] },
1187
+ { name: "date string", field: input.date(), value: "2026-09-22" },
1188
+ {
1189
+ name: "invalid date timezone",
1190
+ field: input.date(),
1191
+ value: { start: "2026-09-22", timeZone: 123 },
1192
+ },
1193
+ ])("rejects a checkpoint with $name", async ({ field, value }) => {
1194
+ const directory = await createCheckpointDirectory();
1195
+ const stepKey = workflowStepKey(["wait", "input", "Launch details"]);
1196
+ await writeFile(
1197
+ checkpointPath(directory, stepKey),
1198
+ JSON.stringify({
1199
+ version: 0,
1200
+ key: stepKey,
1201
+ value: {
1202
+ value: { priority: value },
1203
+ respondedBy: "user-id",
1204
+ respondedAt: "2026-09-18T12:00:00.000Z",
1205
+ },
1206
+ sourceRunId: "input-run",
1207
+ sourceStepId: "input-step",
1208
+ }),
1209
+ );
1210
+
1211
+ const definition = workflow({
1212
+ name: "Collect launch details",
1213
+ description: "Waits for a person to provide launch details",
1214
+ triggers: [triggers.notionPageCreated()],
1215
+ handler: async (_event, context) => {
1216
+ await context.wait.forInput("Launch details", {
1217
+ input: {
1218
+ priority: field,
1219
+ },
1220
+ });
1221
+ },
1222
+ });
1223
+
1224
+ await expect(definition.handler(event, { concreteOutput: true })).rejects.toThrow(
1225
+ "does not match its schema",
1226
+ );
1227
+ });
1228
+ });
1229
+
1020
1230
  async function createCheckpointDirectory(): Promise<string> {
1021
1231
  checkpointDirectory = await mkdtemp(join(tmpdir(), "apps-step-checkpoints-"));
1022
1232
  process.env.NOTION_WORKFLOW_STEP_DIRECTORY = checkpointDirectory;
package/src/workflow.ts CHANGED
@@ -53,6 +53,100 @@ export type WorkflowWaitDuration = {
53
53
 
54
54
  type WorkflowWaitKey = string | string[];
55
55
 
56
+ type WorkflowInputFieldOptions = {
57
+ optional?: boolean;
58
+ };
59
+
60
+ declare const workflowInputValueType: unique symbol;
61
+
62
+ export type WorkflowInputField<T> = {
63
+ type: "text" | "number" | "checkbox" | "select" | "multi_select" | "date" | "person" | "files";
64
+ optional?: boolean;
65
+ options?: readonly string[];
66
+ readonly [workflowInputValueType]?: T;
67
+ };
68
+
69
+ type WorkflowInputValue<
70
+ T,
71
+ TOptions extends WorkflowInputFieldOptions | undefined,
72
+ > = TOptions extends { optional: true } ? T | undefined : T;
73
+
74
+ /** Date-only or local date-time endpoints, with the selected IANA time zone. */
75
+ export type WorkflowInputDate = {
76
+ start: string;
77
+ end?: string;
78
+ timeZone?: string;
79
+ };
80
+ export const input = {
81
+ text<const TOptions extends WorkflowInputFieldOptions | undefined>(
82
+ options?: TOptions,
83
+ ): WorkflowInputField<WorkflowInputValue<string, TOptions>> {
84
+ return { type: "text", ...options };
85
+ },
86
+ number<const TOptions extends WorkflowInputFieldOptions | undefined>(
87
+ options?: TOptions,
88
+ ): WorkflowInputField<WorkflowInputValue<number, TOptions>> {
89
+ return { type: "number", ...options };
90
+ },
91
+ checkbox<const TOptions extends WorkflowInputFieldOptions | undefined>(
92
+ options?: TOptions,
93
+ ): WorkflowInputField<WorkflowInputValue<boolean, TOptions>> {
94
+ return { type: "checkbox", ...options };
95
+ },
96
+ select<
97
+ const TOptions extends readonly [string, ...string[]],
98
+ const TFieldOptions extends WorkflowInputFieldOptions | undefined,
99
+ >(
100
+ values: TOptions,
101
+ options?: TFieldOptions,
102
+ ): WorkflowInputField<WorkflowInputValue<TOptions[number], TFieldOptions>> {
103
+ return { type: "select", options: values, ...options };
104
+ },
105
+ date<const TOptions extends WorkflowInputFieldOptions | undefined>(
106
+ options?: TOptions,
107
+ ): WorkflowInputField<WorkflowInputValue<WorkflowInputDate, TOptions>> {
108
+ return { type: "date", ...options };
109
+ },
110
+ multiSelect<
111
+ const TOptions extends readonly [string, ...string[]],
112
+ const TFieldOptions extends WorkflowInputFieldOptions | undefined,
113
+ >(
114
+ values: TOptions,
115
+ options?: TFieldOptions,
116
+ ): WorkflowInputField<WorkflowInputValue<TOptions[number][], TFieldOptions>> {
117
+ return { type: "multi_select", options: values, ...options };
118
+ },
119
+ /** Returns the user ID of every selected person. */
120
+ person<const TOptions extends WorkflowInputFieldOptions | undefined>(
121
+ options?: TOptions,
122
+ ): WorkflowInputField<WorkflowInputValue<string[], TOptions>> {
123
+ return { type: "person", ...options };
124
+ },
125
+ /** Returns each attachment's URL or Notion attachment block ID. */
126
+ files<const TOptions extends WorkflowInputFieldOptions | undefined>(
127
+ options?: TOptions,
128
+ ): WorkflowInputField<WorkflowInputValue<string[], TOptions>> {
129
+ return { type: "files", ...options };
130
+ },
131
+ };
132
+
133
+ export type WorkflowInputSchema = Record<string, WorkflowInputField<unknown>>;
134
+
135
+ export type WorkflowInputValues<TSchema extends WorkflowInputSchema> = {
136
+ [Key in keyof TSchema]: TSchema[Key] extends WorkflowInputField<infer Value> ? Value : never;
137
+ };
138
+
139
+ export type WorkflowInputResponse<TSchema extends WorkflowInputSchema> = {
140
+ value: WorkflowInputValues<TSchema>;
141
+ respondedBy: string;
142
+ respondedAt: string;
143
+ };
144
+
145
+ export type WorkflowWaitForInputOptions<TSchema extends WorkflowInputSchema> = {
146
+ input: TSchema;
147
+ key?: WorkflowWaitKey;
148
+ };
149
+
56
150
  export type WorkflowWaitUntilOptions =
57
151
  | {
58
152
  at: Date;
@@ -72,7 +166,19 @@ export type WorkflowWaitUntilResult = {
72
166
  };
73
167
  };
74
168
 
75
- type WorkflowHandlerResult = { status: "success" } | WorkflowWaitUntilResult;
169
+ export type WorkflowWaitForInputResult = {
170
+ status: "waiting";
171
+ wait: {
172
+ type: "input";
173
+ name: string;
174
+ stepKey: string;
175
+ input: WorkflowInputSchema;
176
+ };
177
+ };
178
+
179
+ type WorkflowWaitResult = WorkflowWaitUntilResult | WorkflowWaitForInputResult;
180
+
181
+ type WorkflowHandlerResult = { status: "success" } | WorkflowWaitResult;
76
182
 
77
183
  const MAX_WORKFLOW_WAIT_MS = 7 * 24 * 60 * 60 * 1_000;
78
184
 
@@ -360,6 +466,15 @@ type WorkflowWait = {
360
466
  * date has been reached.
361
467
  */
362
468
  until(name: string, options: WorkflowWaitUntilOptions): Promise<void>;
469
+ /**
470
+ * Suspend the workflow until a person submits values matching the input
471
+ * schema. The returned value is restored from the durable workflow
472
+ * checkpoint when the request is fulfilled.
473
+ */
474
+ forInput<const TSchema extends WorkflowInputSchema>(
475
+ name: string,
476
+ options: WorkflowWaitForInputOptions<TSchema>,
477
+ ): Promise<WorkflowInputResponse<TSchema>>;
363
478
  };
364
479
 
365
480
  /** Context passed to a workflow handler. */
@@ -528,13 +643,22 @@ function createStep(
528
643
  }
529
644
 
530
645
  class WorkflowWaitInterrupt extends Error {
531
- constructor(readonly result: WorkflowWaitUntilResult) {
532
- super(`Workflow is waiting until ${new Date(result.wait.resumeAtMs).toISOString()}`);
646
+ constructor(readonly result: WorkflowWaitResult) {
647
+ super(workflowWaitMessage(result));
533
648
  this.name = "WorkflowWaitInterrupt";
534
649
  }
535
650
  }
536
651
 
652
+ function workflowWaitMessage(result: WorkflowWaitResult): string {
653
+ if (result.wait.type === "until") {
654
+ return `Workflow is waiting until ${new Date(result.wait.resumeAtMs).toISOString()}`;
655
+ }
656
+ return `Workflow is waiting for input: ${result.wait.name}`;
657
+ }
658
+
537
659
  function createWait(step: WorkflowStep): WorkflowWait {
660
+ const inputWaitNameByKey = new Map<string, string>();
661
+
538
662
  return {
539
663
  async until(name, options) {
540
664
  const keyInput = options.key ?? name;
@@ -560,9 +684,108 @@ function createWait(step: WorkflowStep): WorkflowWait {
560
684
  },
561
685
  });
562
686
  },
687
+ async forInput(name, options) {
688
+ const keyInput = options.key ?? name;
689
+ const stepKey = createWorkflowStepKey([
690
+ "wait",
691
+ "input",
692
+ ...(typeof keyInput === "string" ? [keyInput] : keyInput),
693
+ ]);
694
+ const existingName = inputWaitNameByKey.get(stepKey);
695
+ if (existingName !== undefined) {
696
+ throw new Error(
697
+ `Workflow input wait "${name}" has the same key as "${existingName}". Provide a unique name or an explicit key.`,
698
+ );
699
+ }
700
+ inputWaitNameByKey.set(stepKey, name);
701
+
702
+ const checkpoint = await readWorkflowStepCheckpoint(stepKey);
703
+ if (checkpoint) {
704
+ if (!isWorkflowInputResponse(checkpoint.value, options.input)) {
705
+ throw new Error(
706
+ `Workflow input checkpoint ${hashWorkflowStepKey(stepKey)} does not match its schema.`,
707
+ );
708
+ }
709
+ return checkpoint.value;
710
+ }
711
+
712
+ throw new WorkflowWaitInterrupt({
713
+ status: "waiting",
714
+ wait: {
715
+ type: "input",
716
+ name,
717
+ stepKey,
718
+ input: options.input,
719
+ },
720
+ });
721
+ },
563
722
  };
564
723
  }
565
724
 
725
+ function isWorkflowInputResponse<TSchema extends WorkflowInputSchema>(
726
+ value: unknown,
727
+ schema: TSchema,
728
+ ): value is WorkflowInputResponse<TSchema> {
729
+ if (
730
+ !isRecord(value) ||
731
+ !isRecord(value.value) ||
732
+ typeof value.respondedBy !== "string" ||
733
+ typeof value.respondedAt !== "string"
734
+ ) {
735
+ return false;
736
+ }
737
+
738
+ if (Object.keys(value.value).some((key) => !Object.hasOwn(schema, key))) {
739
+ return false;
740
+ }
741
+
742
+ for (const [key, field] of Object.entries(schema)) {
743
+ const fieldValue = value.value[key];
744
+ if (fieldValue === undefined && field.optional === true) {
745
+ continue;
746
+ }
747
+ if (!isWorkflowInputFieldValue(fieldValue, field)) {
748
+ return false;
749
+ }
750
+ }
751
+ return true;
752
+ }
753
+
754
+ function isWorkflowInputFieldValue(value: unknown, field: WorkflowInputField<unknown>): boolean {
755
+ switch (field.type) {
756
+ case "text":
757
+ return typeof value === "string";
758
+ case "date":
759
+ return (
760
+ isRecord(value) &&
761
+ typeof value.start === "string" &&
762
+ (value.end === undefined || typeof value.end === "string") &&
763
+ (value.timeZone === undefined || typeof value.timeZone === "string")
764
+ );
765
+ case "number":
766
+ return typeof value === "number" && Number.isFinite(value);
767
+ case "checkbox":
768
+ return typeof value === "boolean";
769
+ case "select":
770
+ return (
771
+ typeof value === "string" &&
772
+ field.options !== undefined &&
773
+ field.options.includes(value)
774
+ );
775
+ case "person":
776
+ case "files":
777
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
778
+ case "multi_select": {
779
+ const options = field.options;
780
+ return (
781
+ Array.isArray(value) &&
782
+ options !== undefined &&
783
+ value.every((item) => typeof item === "string" && options.includes(item))
784
+ );
785
+ }
786
+ }
787
+ }
788
+
566
789
  function resolveWaitUntilMs(options: WorkflowWaitUntilOptions): number {
567
790
  const now = Date.now();
568
791
  if ("at" in options) {