@frockbot/plugin-routines 0.3.45 → 0.3.47

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-routines",
3
- "version": "0.3.45",
3
+ "version": "0.3.47",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,7 +21,8 @@
21
21
  "./shared": "./src/shared.ts",
22
22
  "./storage-keys": "./src/storage-keys.ts",
23
23
  "./store": "./src/store.ts",
24
- "./testing": "./src/testing.ts"
24
+ "./testing": "./src/testing.ts",
25
+ "./subscriptions": "./src/subscriptions.ts"
25
26
  },
26
27
  "frockbot": {
27
28
  "manifest": "./frockbot.json"
@@ -32,15 +33,16 @@
32
33
  "typecheck": "vue-tsc --noEmit -p tsconfig.json"
33
34
  },
34
35
  "dependencies": {
35
- "@frockbot/client-core": "0.3.45",
36
- "@frockbot/client-ui": "0.3.45",
37
- "@frockbot/configuration-core": "0.3.45",
38
- "@frockbot/kernel-agent-loop": "0.3.45",
39
- "@frockbot/kernel-contracts": "0.3.45",
40
- "@frockbot/plugin-shell": "0.3.45",
36
+ "@frockbot/client-core": "0.3.47",
37
+ "@frockbot/client-ui": "0.3.47",
38
+ "@frockbot/configuration-core": "0.3.47",
39
+ "@frockbot/kernel-agent-loop": "0.3.47",
40
+ "@frockbot/kernel-contracts": "0.3.47",
41
+ "@frockbot/plugin-shell": "0.3.47",
41
42
  "cordis": "4.0.0-rc.8",
42
43
  "croner": "10.0.1",
43
- "vue": "3.5.41"
44
+ "vue": "3.5.41",
45
+ "@frockbot/connection-core": "0.3.47"
44
46
  },
45
47
  "devDependencies": {
46
48
  "@types/bun": "1.4.0",
package/src/agent.ts CHANGED
@@ -1,3 +1,8 @@
1
+ import {
2
+ decodeConnectionTriggerV1,
3
+ type ConnectionTriggerV1,
4
+ type ConnectionTriggerCatalogV1,
5
+ } from "@frockbot/connection-core";
1
6
  // The Routines runtime Contribution: one tool, `routine_manage`.
2
7
  //
3
8
  // GrokBot's `update_state target=routine {create,update,pause,resume,delete}`
@@ -50,6 +55,7 @@ export interface RoutinesRuntimeHostV1 {
50
55
  botId: string;
51
56
  writer?: RoutineWriterIdentityV1;
52
57
  list(): Promise<RoutineListViewV1>;
58
+ listTriggers?(): Promise<ConnectionTriggerCatalogV1>;
53
59
  execute(
54
60
  command: RoutineCommandV1,
55
61
  writer: RoutineWriterV1,
@@ -63,6 +69,7 @@ export const ROUTINE_MANAGE_ACTIONS = [
63
69
  "resume",
64
70
  "delete",
65
71
  "run_now",
72
+ "list_triggers",
66
73
  ] as const;
67
74
 
68
75
  export type RoutineManageActionV1 = (typeof ROUTINE_MANAGE_ACTIONS)[number];
@@ -92,10 +99,28 @@ const ROUTINE_MANAGE_INPUT_SCHEMA = {
92
99
  "A five-field cron expression, or @hourly, @daily, @weekly, @monthly, or @every 15m. Optionally prefixed with CRON_TZ=<zone>. A Routine has a schedule or a webhook trigger, never both.",
93
100
  },
94
101
  trigger: {
95
- type: "string",
96
- enum: ["webhook"],
102
+ oneOf: [
103
+ { type: "string", enum: ["webhook"] },
104
+ {
105
+ type: "object",
106
+ properties: {
107
+ composio: {
108
+ type: "object",
109
+ properties: {
110
+ connectionId: { type: "string" },
111
+ triggerType: { type: "string" },
112
+ config: { type: "object" },
113
+ },
114
+ required: ["connectionId", "triggerType", "config"],
115
+ additionalProperties: false,
116
+ },
117
+ },
118
+ required: ["composio"],
119
+ additionalProperties: false,
120
+ },
121
+ ],
97
122
  description:
98
- "Fire on a delivered webhook instead of on a clock. A Routine has a schedule or a trigger, never both.",
123
+ "Fire on an event from an existing connected account. Use list_triggers first to find the account, event type and configuration schema. A Routine has a schedule or a trigger, never both.",
99
124
  },
100
125
  timezone: {
101
126
  type: "string",
@@ -125,7 +150,7 @@ interface RoutineManageInputV1 {
125
150
  name?: string;
126
151
  prompt?: string;
127
152
  schedule?: string;
128
- trigger?: "webhook";
153
+ trigger?: "webhook" | { composio: ConnectionTriggerV1 };
129
154
  timezone?: string;
130
155
  userAsked?: boolean;
131
156
  }
@@ -164,8 +189,18 @@ function decodeRoutineManageInputV1(input: unknown): RoutineManageInputV1 {
164
189
  }
165
190
  return candidate;
166
191
  };
167
- if (value.trigger !== undefined && value.trigger !== "webhook") {
168
- throw new RoutineDecodeError('routine_manage trigger must be "webhook"');
192
+ let trigger: RoutineManageInputV1["trigger"];
193
+ if (value.trigger === "webhook") trigger = "webhook";
194
+ else if (value.trigger !== undefined) {
195
+ if (
196
+ !value.trigger ||
197
+ typeof value.trigger !== "object" ||
198
+ Array.isArray(value.trigger) ||
199
+ Object.keys(value.trigger).some((key) => key !== "composio") ||
200
+ !("composio" in value.trigger)
201
+ )
202
+ throw new RoutineDecodeError("Choose a service event or webhook trigger");
203
+ trigger = { composio: decodeConnectionTriggerV1(value.trigger.composio) };
169
204
  }
170
205
  if (value.userAsked !== undefined && typeof value.userAsked !== "boolean") {
171
206
  throw new RoutineDecodeError("routine_manage userAsked must be a boolean");
@@ -182,7 +217,7 @@ function decodeRoutineManageInputV1(input: unknown): RoutineManageInputV1 {
182
217
  ...(optional("schedule") === undefined
183
218
  ? {}
184
219
  : { schedule: optional("schedule")! }),
185
- ...(value.trigger === undefined ? {} : { trigger: "webhook" as const }),
220
+ ...(trigger === undefined ? {} : { trigger }),
186
221
  ...(optional("timezone") === undefined
187
222
  ? {}
188
223
  : { timezone: optional("timezone")! }),
@@ -228,7 +263,12 @@ export function routineManageCommandV1(
228
263
  ...(input.schedule === undefined ? {} : { schedule: input.schedule }),
229
264
  ...(input.trigger === undefined
230
265
  ? {}
231
- : { trigger: { kind: input.trigger } }),
266
+ : {
267
+ trigger:
268
+ input.trigger === "webhook"
269
+ ? { kind: "webhook" }
270
+ : { kind: "connection", ...input.trigger.composio },
271
+ }),
232
272
  ...(input.timezone === undefined ? {} : { timezone: input.timezone }),
233
273
  });
234
274
  }
@@ -247,7 +287,12 @@ export function routineManageCommandV1(
247
287
  ...(input.schedule === undefined ? {} : { schedule: input.schedule }),
248
288
  ...(input.trigger === undefined
249
289
  ? {}
250
- : { trigger: { kind: input.trigger } }),
290
+ : {
291
+ trigger:
292
+ input.trigger === "webhook"
293
+ ? { kind: "webhook" }
294
+ : { kind: "connection", ...input.trigger.composio },
295
+ }),
251
296
  ...(input.timezone === undefined ? {} : { timezone: input.timezone }),
252
297
  });
253
298
  }
@@ -295,7 +340,7 @@ export function createRoutineManageTool(
295
340
  // video roles. See `@frockbot/plugin-subagents` `SUBAGENT_TOOL_REACH_V1`.
296
341
  admission: { subagentRoles: ["executor"] },
297
342
  description: [
298
- "Create, edit, pause, resume, delete, or immediately run one of your own Routines.",
343
+ "Create, edit, pause, resume, delete, or immediately run one of your own Routines. Use list_triggers to list events and configuration schemas on the User’s existing connected accounts.",
299
344
  "A Routine is a standing instruction that fires on a schedule or on a delivered webhook,",
300
345
  `as its own Turn rather than inside this conversation. Names are at most ${ROUTINE_NAME_MAX_LENGTH}`,
301
346
  `characters and prompts at most ${ROUTINE_PROMPT_MAX_LENGTH}.`,
@@ -322,6 +367,13 @@ export function createRoutineManageTool(
322
367
  let command: RoutineCommandV1;
323
368
  try {
324
369
  decoded = decodeRoutineManageInputV1(input);
370
+ if (decoded.action === "list_triggers")
371
+ return {
372
+ content: JSON.stringify(
373
+ (await host.listTriggers?.()) ?? { schemaVersion: 1, items: [] },
374
+ ),
375
+ isError: false,
376
+ };
325
377
  command = routineManageCommandV1(decoded, {
326
378
  botId: host.botId,
327
379
  commandId: routineToolCommandIdV1(context.effectId),
@@ -374,7 +426,9 @@ export function createRoutineManageTool(
374
426
  const routine = receipt.routine;
375
427
  const timing = routine.schedule
376
428
  ? `schedule ${routine.schedule} (${routine.timezone})`
377
- : "webhook trigger";
429
+ : routine.trigger?.kind === "connection"
430
+ ? (routine.eventName ?? "a service event")
431
+ : "webhook trigger";
378
432
  return {
379
433
  content: [
380
434
  `Routine "${routine.name}" (${routine.routineId}) is ${
package/src/backend.ts CHANGED
@@ -1,3 +1,7 @@
1
+ import {
2
+ decodeConnectionTriggerCatalogV1,
3
+ type ConnectionTriggerCatalogV1,
4
+ } from "@frockbot/connection-core";
1
5
  // The Routines gateway Contribution: the authenticated HTTP surface.
2
6
  //
3
7
  // Three routes, all Bot-scoped and all beside `/api/bots/:id/settings`:
@@ -73,6 +77,10 @@ export interface RoutinesGatewayHost {
73
77
  },
74
78
  ): Promise<RoutineHookDeliveryReceiptV1>;
75
79
  listRoutines(userId: string, botId: string): Promise<RoutineListViewV1>;
80
+ listTriggers?(
81
+ userId: string,
82
+ botId: string,
83
+ ): Promise<ConnectionTriggerCatalogV1>;
76
84
  executeRoutineCommand(
77
85
  userId: string,
78
86
  botId: string,
@@ -117,6 +125,7 @@ export interface RoutinesBackendRouteContribution {
117
125
  }
118
126
 
119
127
  const ROUTINES = /^\/api\/bots\/([^/]+)\/routines$/;
128
+ const ROUTINE_TRIGGERS = /^\/api\/bots\/([^/]+)\/routines\/triggers$/;
120
129
  const ROUTINE_INBOX = /^\/api\/bots\/([^/]+)\/routines\/inbox$/;
121
130
  const ROUTINE_RUNS = /^\/api\/bots\/([^/]+)\/routines\/([^/]+)\/runs$/;
122
131
  const ROUTINE_HOOK = /^\/api\/bots\/([^/]+)\/routines\/([^/]+)\/hook$/;
@@ -270,16 +279,31 @@ export function createRoutinesBackendContribution(
270
279
  packageId: "routines",
271
280
  async route(request, url, context) {
272
281
  if (!context.userId) return undefined;
282
+ const triggers = ROUTINE_TRIGGERS.exec(url.pathname);
273
283
  const list = ROUTINES.exec(url.pathname);
274
284
  const inbox = ROUTINE_INBOX.exec(url.pathname);
275
285
  const runs = ROUTINE_RUNS.exec(url.pathname);
276
286
  const run = ROUTINE_RUN.exec(url.pathname);
277
- if (!list && !inbox && !runs && !run) return undefined;
287
+ if (!triggers && !list && !inbox && !runs && !run) return undefined;
278
288
  if ([...url.searchParams.keys()].length > 0) {
279
289
  return jsonError(400, "Routine routes take no query parameters");
280
290
  }
281
291
  try {
282
- const botId = pathSegment((list ?? inbox ?? runs ?? run)![1]!);
292
+ const botId = pathSegment(
293
+ (triggers ?? list ?? inbox ?? runs ?? run)![1]!,
294
+ );
295
+ if (triggers) {
296
+ if (request.method !== "GET")
297
+ return jsonError(405, "method not allowed");
298
+ return Response.json(
299
+ decodeConnectionTriggerCatalogV1(
300
+ (await host.listTriggers?.(context.userId, botId)) ?? {
301
+ schemaVersion: 1,
302
+ items: [],
303
+ },
304
+ ),
305
+ );
306
+ }
283
307
  if (run) {
284
308
  if (request.method !== "GET") {
285
309
  return jsonError(405, "method not allowed");
@@ -18,7 +18,8 @@ import {
18
18
  } from "@frockbot/client-core";
19
19
  import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
20
20
  import { settingsLinkV1 } from "@frockbot/plugin-shell/settings-links";
21
- import { computed, inject, reactive, ref, watch } from "vue";
21
+ import { computed, inject, reactive, ref, toRaw, watch } from "vue";
22
+ import { triggerObject } from "@frockbot/connection-core";
22
23
  import type { RoutineViewV1 } from "../shared.js";
23
24
  import { routinesStateKey } from "./state.js";
24
25
 
@@ -56,11 +57,83 @@ const form = reactive({
56
57
  routineId: undefined as string | undefined,
57
58
  name: "",
58
59
  prompt: "",
59
- timing: "schedule" as "schedule" | "webhook",
60
+ timing: "schedule" as "schedule" | "webhook" | "connection",
60
61
  schedule: "",
62
+ connectionId: "",
63
+ triggerType: "",
64
+ config: {} as Record<string, unknown>,
61
65
  timezone: "UTC",
62
66
  });
63
67
 
68
+ const accounts = computed(() => [
69
+ ...new Map(
70
+ (routines.value.triggers ?? []).map((item) => [item.connectionId, item]),
71
+ ).values(),
72
+ ]);
73
+ const events = computed(() =>
74
+ (routines.value.triggers ?? []).filter(
75
+ (item) => item.connectionId === form.connectionId,
76
+ ),
77
+ );
78
+ const selectedEvent = computed(() =>
79
+ events.value.find((item) => item.triggerType === form.triggerType),
80
+ );
81
+ const eventFields = computed(() => {
82
+ const schema = selectedEvent.value?.configSchema;
83
+ if (!schema || !triggerObject(schema.properties)) return [];
84
+ return Object.entries(schema.properties).flatMap(([key, value]) =>
85
+ triggerObject(value)
86
+ ? [
87
+ {
88
+ key,
89
+ label: (typeof value.title === "string" ? value.title : key)
90
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
91
+ .replaceAll("_", " ")
92
+ .replace(/^./, (letter) => letter.toUpperCase()),
93
+ hint:
94
+ typeof value.description === "string"
95
+ ? value.description
96
+ : undefined,
97
+ type: value.type,
98
+ options: Array.isArray(value.enum)
99
+ ? value.enum.filter(
100
+ (item): item is string => typeof item === "string",
101
+ )
102
+ : [],
103
+ required:
104
+ Array.isArray(schema.required) && schema.required.includes(key),
105
+ },
106
+ ]
107
+ : [],
108
+ );
109
+ });
110
+ function selectEvent(): void {
111
+ form.config = {};
112
+ const properties = selectedEvent.value?.configSchema.properties;
113
+ if (triggerObject(properties))
114
+ for (const [key, value] of Object.entries(properties)) {
115
+ if (triggerObject(value) && value.default !== undefined)
116
+ form.config[key] = structuredClone(toRaw(value.default));
117
+ }
118
+ }
119
+ function selectAccount(): void {
120
+ form.triggerType = events.value[0]?.triggerType ?? "";
121
+ selectEvent();
122
+ }
123
+ function eventState(routine: RoutineViewV1): string {
124
+ return (
125
+ {
126
+ active: "Listening",
127
+ starting: "Starting…",
128
+ paused: "Paused",
129
+ missing: "Listening stopped — edit and save to restart",
130
+ failed: "Could not start listening — review the event settings",
131
+ unavailable: "Connection unavailable — check Connectors",
132
+ }[routine.eventStatus ?? "unavailable"] ??
133
+ "Connection unavailable — check Connectors"
134
+ );
135
+ }
136
+
64
137
  /**
65
138
  * The reason the last save was refused, held beside the form rather than in
66
139
  * the section header. The header sits above every Routine card, so on a Bot
@@ -125,7 +198,9 @@ async function copyHook(): Promise<void> {
125
198
  function summary(routine: RoutineViewV1): string {
126
199
  return routine.schedule
127
200
  ? `${routine.schedule} · ${routine.timezone}`
128
- : "Webhook trigger";
201
+ : routine.trigger?.kind === "connection"
202
+ ? (routine.eventName ?? "Connected account event")
203
+ : "Webhook trigger";
129
204
  }
130
205
 
131
206
  /** A durable moment, read in the Routine's own zone — the one it fires on. */
@@ -134,10 +209,13 @@ function moment(routine: RoutineViewV1, iso: string): string {
134
209
  }
135
210
 
136
211
  function startCreate(): void {
212
+ if (botId.value) void routines.value.load(botId.value);
137
213
  form.routineId = undefined;
138
214
  form.name = "";
139
215
  form.prompt = "";
140
216
  form.timing = "schedule";
217
+ form.connectionId = accounts.value[0]?.connectionId ?? "";
218
+ selectAccount();
141
219
  form.schedule = "0 9 * * *";
142
220
  // The reader's own zone, not UTC: a schedule is almost always meant in the
143
221
  // day the person writing it is living in, and the Bot picks the same when it
@@ -151,7 +229,16 @@ function startEdit(routine: RoutineViewV1): void {
151
229
  form.routineId = routine.routineId;
152
230
  form.name = routine.name;
153
231
  form.prompt = routine.prompt;
154
- form.timing = routine.schedule ? "schedule" : "webhook";
232
+ form.timing = routine.schedule
233
+ ? "schedule"
234
+ : routine.trigger?.kind === "connection"
235
+ ? "connection"
236
+ : "webhook";
237
+ if (routine.trigger?.kind === "connection") {
238
+ form.connectionId = routine.trigger.connectionId;
239
+ form.triggerType = routine.trigger.triggerType;
240
+ form.config = structuredClone(toRaw(routine.trigger.config));
241
+ }
155
242
  form.schedule = routine.schedule ?? "";
156
243
  form.timezone = routine.timezone;
157
244
  saveError.value = undefined;
@@ -163,13 +250,46 @@ async function submit(): Promise<void> {
163
250
  if (!id) return;
164
251
  saveError.value = undefined;
165
252
  try {
253
+ if (form.timing === "connection" && !selectedEvent.value)
254
+ throw new Error("Choose an available account and event");
255
+ const config = Object.fromEntries(
256
+ Object.entries(form.config).filter(
257
+ ([, value]) => value !== "" && value !== undefined,
258
+ ),
259
+ );
260
+ for (const field of eventFields.value) {
261
+ if (field.required && config[field.key] === undefined)
262
+ throw new Error(`Enter ${field.label.toLowerCase()}`);
263
+ if (
264
+ config[field.key] !== undefined &&
265
+ (field.type === "array" || field.type === "object") &&
266
+ typeof config[field.key] === "string"
267
+ ) {
268
+ try {
269
+ config[field.key] = JSON.parse(String(config[field.key]));
270
+ } catch {
271
+ throw new Error(
272
+ `Check ${field.label.toLowerCase()}: enter valid JSON`,
273
+ );
274
+ }
275
+ }
276
+ }
166
277
  await routines.value.save(id, {
167
278
  ...(form.routineId ? { routineId: form.routineId } : {}),
168
279
  name: form.name.trim(),
169
280
  prompt: form.prompt.trim(),
170
281
  ...(form.timing === "schedule"
171
282
  ? { schedule: form.schedule.trim() }
172
- : { trigger: { kind: "webhook" as const } }),
283
+ : form.timing === "connection"
284
+ ? {
285
+ trigger: {
286
+ kind: "connection" as const,
287
+ connectionId: form.connectionId,
288
+ triggerType: form.triggerType,
289
+ config,
290
+ },
291
+ }
292
+ : { trigger: { kind: "webhook" as const } }),
173
293
  timezone: form.timezone.trim(),
174
294
  });
175
295
  formOpen.value = false;
@@ -185,7 +305,9 @@ async function submit(): Promise<void> {
185
305
  saveError.value =
186
306
  serverRefusalMessageV1(error) ??
187
307
  routines.value.error ??
188
- presentClientFailureV1(error, "save the Routine");
308
+ (error instanceof Error
309
+ ? error.message
310
+ : presentClientFailureV1(error, "save the Routine"));
189
311
  }
190
312
  }
191
313
 
@@ -232,8 +354,8 @@ async function toggleLog(routineId: string): Promise<void> {
232
354
  <span class="routines__intro">
233
355
  <strong>Routines</strong>
234
356
  <small>
235
- Standing instructions this Bot runs on a schedule, or when a webhook
236
- fires.
357
+ Standing instructions this Bot runs on a schedule or when something
358
+ happens.
237
359
  </small>
238
360
  </span>
239
361
  <UiButton type="button" :disabled="!botId" @click="startCreate">
@@ -291,6 +413,13 @@ async function toggleLog(routineId: string): Promise<void> {
291
413
  </UiButton>
292
414
  </div>
293
415
  </div>
416
+ <p
417
+ v-if="routine.trigger?.kind === 'connection'"
418
+ class="routines__note"
419
+ role="status"
420
+ >
421
+ {{ eventState(routine) }}
422
+ </p>
294
423
  <dl class="routine-card__facts">
295
424
  <div>
296
425
  <dt>Next run</dt>
@@ -301,7 +430,7 @@ async function toggleLog(routineId: string): Promise<void> {
301
430
  <template v-else>—</template>
302
431
  </dd>
303
432
  </div>
304
- <div v-if="routine.trigger">
433
+ <div v-if="routine.trigger?.kind === 'webhook'">
305
434
  <dt>Webhook key</dt>
306
435
  <dd>
307
436
  {{
@@ -354,7 +483,7 @@ async function toggleLog(routineId: string): Promise<void> {
354
483
  Run now
355
484
  </UiButton>
356
485
  <UiButton
357
- v-if="routine.trigger"
486
+ v-if="routine.trigger?.kind === 'webhook'"
358
487
  type="button"
359
488
  :disabled="routines.busy"
360
489
  @click="botId && routines.rotateKey(botId, routine.routineId)"
@@ -362,7 +491,7 @@ async function toggleLog(routineId: string): Promise<void> {
362
491
  {{ routine.hookKeyVersion ? "Rotate key" : "Mint key" }}
363
492
  </UiButton>
364
493
  <UiButton
365
- v-if="routine.trigger && routine.hookKeyVersion"
494
+ v-if="routine.trigger?.kind === 'webhook' && routine.hookKeyVersion"
366
495
  type="button"
367
496
  :disabled="routines.busy"
368
497
  @click="botId && routines.revokeKey(botId, routine.routineId)"
@@ -469,7 +598,111 @@ async function toggleLog(routineId: string): Promise<void> {
469
598
  <input v-model="form.timing" type="radio" value="webhook" />
470
599
  <span>A webhook</span>
471
600
  </label>
601
+ <label>
602
+ <input v-model="form.timing" type="radio" value="connection" />
603
+ <span>An event in a connected account</span>
604
+ </label>
472
605
  </fieldset>
606
+ <template v-if="form.timing === 'connection'">
607
+ <p
608
+ v-if="routines.triggerError"
609
+ class="routine-form__error"
610
+ role="alert"
611
+ >
612
+ {{ routines.triggerError }}
613
+ </p>
614
+ <p v-else-if="!accounts.length" class="routines__note">
615
+ No events are available for your connected accounts yet.
616
+ </p>
617
+ <template v-else>
618
+ <UiField label="Account">
619
+ <select v-model="form.connectionId" @change="selectAccount">
620
+ <option disabled value="">Choose an account</option>
621
+ <option
622
+ v-for="account in accounts"
623
+ :key="account.connectionId"
624
+ :value="account.connectionId"
625
+ >
626
+ {{ account.connectorName }} · {{ account.accountName }}
627
+ </option>
628
+ </select>
629
+ </UiField>
630
+ <UiField label="When" :hint="selectedEvent?.description">
631
+ <select v-model="form.triggerType" @change="selectEvent">
632
+ <option disabled value="">Choose an event</option>
633
+ <option
634
+ v-for="event in events"
635
+ :key="event.triggerType"
636
+ :value="event.triggerType"
637
+ >
638
+ {{ event.name }}
639
+ </option>
640
+ </select>
641
+ </UiField>
642
+ <details
643
+ v-if="eventFields.length"
644
+ :key="form.triggerType"
645
+ class="routine-form__options"
646
+ :open="eventFields.some((field) => field.required)"
647
+ >
648
+ <summary>Event options</summary>
649
+ <UiField
650
+ v-for="field in eventFields"
651
+ :key="field.key"
652
+ :label="field.label"
653
+ :hint="field.hint"
654
+ >
655
+ <select
656
+ v-if="field.options.length"
657
+ v-model="form.config[field.key]"
658
+ :required="field.required"
659
+ >
660
+ <option v-if="!field.required" value="">Any</option>
661
+ <option
662
+ v-for="option in field.options"
663
+ :key="option"
664
+ :value="option"
665
+ >
666
+ {{ option }}
667
+ </option>
668
+ </select>
669
+ <input
670
+ v-else-if="field.type === 'boolean'"
671
+ v-model="form.config[field.key]"
672
+ type="checkbox"
673
+ />
674
+ <input
675
+ v-else-if="field.type === 'number' || field.type === 'integer'"
676
+ v-model.number="form.config[field.key]"
677
+ type="number"
678
+ :step="field.type === 'integer' ? 1 : 'any'"
679
+ :required="field.required"
680
+ />
681
+ <textarea
682
+ v-else-if="field.type === 'array' || field.type === 'object'"
683
+ :value="
684
+ typeof form.config[field.key] === 'string'
685
+ ? String(form.config[field.key])
686
+ : JSON.stringify(form.config[field.key], null, 2)
687
+ "
688
+ @input="
689
+ form.config[field.key] = (
690
+ $event.target as HTMLTextAreaElement
691
+ ).value
692
+ "
693
+ :required="field.required"
694
+ rows="3"
695
+ />
696
+ <input
697
+ v-else
698
+ v-model="form.config[field.key]"
699
+ :required="field.required"
700
+ maxlength="8000"
701
+ />
702
+ </UiField>
703
+ </details>
704
+ </template>
705
+ </template>
473
706
  <UiField
474
707
  v-if="form.timing === 'schedule'"
475
708
  label="Schedule"
@@ -493,7 +726,7 @@ async function toggleLog(routineId: string): Promise<void> {
493
726
  >
494
727
  {{ scheduleError }}
495
728
  </p>
496
- <p v-else class="routines__note">
729
+ <p v-if="form.timing === 'webhook'" class="routines__note">
497
730
  A delivery key is minted when the Routine is saved, and shown once.
498
731
  </p>
499
732
  <UiField label="Time zone">
@@ -754,7 +987,8 @@ async function toggleLog(routineId: string): Promise<void> {
754
987
 
755
988
  .routine-form__timing {
756
989
  display: flex;
757
- gap: 12px;
990
+ flex-direction: column;
991
+ gap: 8px;
758
992
  border: 0;
759
993
  margin: 0;
760
994
  padding: 0;
@@ -773,4 +1007,23 @@ async function toggleLog(routineId: string): Promise<void> {
773
1007
  align-items: center;
774
1008
  gap: 6px;
775
1009
  }
1010
+
1011
+ .routine-form__options {
1012
+ color: var(--frock-text-muted);
1013
+ font-size: var(--frock-text-sm);
1014
+ }
1015
+
1016
+ .routine-form__options summary {
1017
+ cursor: pointer;
1018
+ font-weight: 600;
1019
+ }
1020
+
1021
+ .routine-form__options :deep(.ui-field) {
1022
+ margin-top: 12px;
1023
+ }
1024
+
1025
+ .routine-form__options input[type="checkbox"] {
1026
+ width: auto;
1027
+ align-self: flex-start;
1028
+ }
776
1029
  </style>
@@ -63,7 +63,9 @@ watch(
63
63
  <li v-for="routine in rows" :key="routine.routineId">
64
64
  <span>
65
65
  <strong>{{ routine.name }}</strong>
66
- <small>{{ routine.schedule ?? "Webhook" }}</small>
66
+ <small>{{
67
+ routine.schedule ?? routine.eventName ?? "Webhook"
68
+ }}</small>
67
69
  </span>
68
70
  <small>{{ routine.enabled ? "On" : "Paused" }}</small>
69
71
  </li>