@dench.com/cli 3.11.6 → 3.11.7-staging.488

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/app-sdk.d.ts CHANGED
@@ -80,6 +80,10 @@ export interface ApiRequests {
80
80
  name?: string;
81
81
  /** IANA timezone, e.g. America/Los_Angeles. Sets a Mon-Fri 9-18 send window in it; without one the draft has no send window. */
82
82
  timezone?: string;
83
+ /** Mailboxes to send from instead of your own running ones. Checked as the draft is created: one you can't use creates nothing. */
84
+ emailMailboxAccountIds?: string[];
85
+ /** LinkedIn accounts to send from instead of your own running ones. Checked as the draft is created. */
86
+ linkedinAccountIds?: string[];
83
87
  };
84
88
  /** Search people by filters without adding CRM records or campaign recipients. Search credits apply; this is not a free provider preview. — `dench campaign search` — POST /api/v1/campaigns/audience/search */
85
89
  "campaigns.audience.search": {
@@ -569,8 +573,10 @@ export interface ApiRequests {
569
573
  };
570
574
  steps?: Array<{
571
575
  type: "email" | "wait" | "linkedin_connection" | "linkedin_message";
576
+ /** Hours to wait, 1 to 1440. Required on a wait step. */
572
577
  waitHours?: number;
573
578
  emailSubject?: string;
579
+ /** Plain text only; draft steps keep no HTML body. */
574
580
  emailTextBody?: string;
575
581
  emailReplyToPrevious?: boolean;
576
582
  onlyIf?: "always" | "connected" | "not_connected";
package/campaign.ts CHANGED
@@ -339,6 +339,14 @@ function unknownFlags(args: readonly string[], command: string): void {
339
339
  if (error instanceof CliArgError) throw new CampaignCliError(error.message);
340
340
  throw error;
341
341
  }
342
+ // Every caller reads its positionals first, so anything left is a typo or
343
+ // a value missing its flag; refuse it before a write.
344
+ const extra = args.find((token) => token !== "--");
345
+ if (extra !== undefined) {
346
+ throw new CampaignCliError(
347
+ `Unexpected argument for ${command}: ${JSON.stringify(extra)}. Quote a name with spaces, or check for a value missing its flag. Run \`dench campaign help\` for usage.`,
348
+ );
349
+ }
342
350
  }
343
351
 
344
352
  async function callQuery(
@@ -746,11 +754,13 @@ export function parseStepsJson(
746
754
  export { stepToInput };
747
755
 
748
756
  /**
749
- * A `--start`/`--end` value. A full timestamp is an instant already; a bare
750
- * date (`2026-09-03`) stays a date until the campaign's timezone is known,
751
- * because midnight in Los Angeles is not midnight in UTC.
757
+ * A `--start`/`--end` value. A timestamp with an offset is an instant
758
+ * already; a bare date (`2026-09-03`) or a date-time without an offset
759
+ * (`2026-09-03T09:00`) stays wall-clock time until the campaign's timezone
760
+ * is known, because 09:00 in Los Angeles is not 09:00 in UTC (nor wherever
761
+ * the CLI happens to run).
752
762
  */
753
- export type WhenFlag = number | { date: string };
763
+ export type WhenFlag = number | { date: string; time?: string };
754
764
 
755
765
  export type ScheduleFlags = {
756
766
  timezone?: string;
@@ -761,28 +771,54 @@ export type ScheduleFlags = {
761
771
  windows: string[];
762
772
  };
763
773
 
764
- const DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/;
774
+ /**
775
+ * ISO 8601 only: a date, optionally `T` + time, optionally an offset. Every
776
+ * part is checked, so February 30 or 25:00 is refused rather than rolled
777
+ * over by `Date.parse`, and other formats (09/03/2026) are refused outright.
778
+ */
779
+ const ISO_WHEN =
780
+ /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(\.\d{1,3})?)?(Z|[+-]\d{2}:?\d{2})?)?$/;
781
+
782
+ function isCalendarDate(year: number, month: number, day: number): boolean {
783
+ const utc = new Date(Date.UTC(year, month - 1, day));
784
+ return (
785
+ utc.getUTCFullYear() === year &&
786
+ utc.getUTCMonth() === month - 1 &&
787
+ utc.getUTCDate() === day
788
+ );
789
+ }
765
790
 
766
791
  function parseWhenFlag(value: string, flag: string): WhenFlag {
767
792
  const trimmed = value.trim();
768
- const dateOnly = trimmed.match(DATE_ONLY);
769
- if (dateOnly) {
770
- const [, year, month, day] = dateOnly.map(Number);
771
- const utc = new Date(Date.UTC(year, month - 1, day));
772
- if (
773
- utc.getUTCFullYear() !== year ||
774
- utc.getUTCMonth() !== month - 1 ||
775
- utc.getUTCDate() !== day
776
- ) {
777
- throw new CampaignCliError(`${flag}: "${value}" is not a calendar date.`);
778
- }
779
- return { date: trimmed };
793
+ const match = trimmed.match(ISO_WHEN);
794
+ if (!match) {
795
+ throw new CampaignCliError(
796
+ `${flag} must be an ISO 8601 date or date-time, e.g. 2026-09-03, 2026-09-03T09:00 (in the campaign's timezone) or 2026-09-03T09:00:00-07:00 (got "${value}").`,
797
+ );
798
+ }
799
+ const [, year, month, day, hour, minute, second, , offset] = match;
800
+ if (!isCalendarDate(Number(year), Number(month), Number(day))) {
801
+ throw new CampaignCliError(`${flag}: "${value}" is not a calendar date.`);
802
+ }
803
+ const date = `${year}-${month}-${day}`;
804
+ if (hour === undefined) return { date };
805
+ if (
806
+ Number(hour) > 23 ||
807
+ Number(minute) > 59 ||
808
+ Number(second ?? 0) > 59 ||
809
+ (offset !== undefined &&
810
+ offset !== "Z" &&
811
+ (Number(offset.slice(1, 3)) > 14 || Number(offset.slice(-2)) > 59))
812
+ ) {
813
+ throw new CampaignCliError(`${flag}: "${value}" is not a valid time.`);
780
814
  }
815
+ const time = `${hour}:${minute}:${second ?? "00"}`;
816
+ // No offset: the campaign's timezone decides the instant, never the
817
+ // machine the CLI runs on.
818
+ if (offset === undefined) return { date, time };
781
819
  const parsed = Date.parse(trimmed);
782
820
  if (!Number.isFinite(parsed)) {
783
- throw new CampaignCliError(
784
- `${flag} must be an ISO 8601 date or date-time, e.g. 2026-09-03 or 2026-09-03T09:00:00-07:00 (got "${value}").`,
785
- );
821
+ throw new CampaignCliError(`${flag}: "${value}" is not a valid date-time.`);
786
822
  }
787
823
  return parsed;
788
824
  }
@@ -812,26 +848,64 @@ function timezoneOffsetMs(ms: number, timeZone: string): number {
812
848
  return wallClockAsUtc - (ms - (ms % 1000));
813
849
  }
814
850
 
815
- /** Midnight at the start of `date` (YYYY-MM-DD) in `timezone`, as epoch ms. */
816
- export function zonedMidnight(date: string, timezone: string): number {
851
+ const DAY_MS = 86_400_000;
852
+
853
+ /**
854
+ * The instant a wall-clock time (`date` YYYY-MM-DD, `time` HH:MM[:SS]) has
855
+ * in `timezone`, as epoch ms. A time a DST change repeats takes its first
856
+ * instant; a time it skips (some zones skip midnight) takes the first instant
857
+ * after the gap, which is still on that date.
858
+ */
859
+ export function zonedTime(
860
+ date: string,
861
+ timezone: string,
862
+ time = "00:00:00",
863
+ ): number {
817
864
  const timeZone = assertIanaTimezone(timezone);
818
865
  const [year, month, day] = date.split("-").map(Number);
819
- const utcMidnight = Date.UTC(year, month - 1, day);
820
- const offset = timezoneOffsetMs(utcMidnight, timeZone);
821
- const guess = utcMidnight - offset;
822
- // Around a DST change the offset in force at the guess differs from the
823
- // one at UTC midnight; a second pass settles on the zone's own midnight.
824
- const settled = timezoneOffsetMs(guess, timeZone);
825
- return settled === offset ? guess : utcMidnight - settled;
866
+ const [hour = 0, minute = 0, second = 0] = time.split(":").map(Number);
867
+ const wall = Date.UTC(year, month - 1, day, hour, minute, second);
868
+ // The offsets in force around that wall time; each one that maps back to
869
+ // it is a reading of it.
870
+ const offsets = [
871
+ ...new Set(
872
+ [wall - DAY_MS, wall, wall + DAY_MS].map((ms) =>
873
+ timezoneOffsetMs(ms, timeZone),
874
+ ),
875
+ ),
876
+ ];
877
+ const readings = offsets
878
+ .map((offset) => wall - offset)
879
+ .filter((ms) => timezoneOffsetMs(ms, timeZone) === wall - ms);
880
+ if (readings.length) return Math.min(...readings);
881
+ // Skipped: the clock jumped from the smaller offset to the larger one
882
+ // somewhere in [wall - larger, wall - smaller]. The jump is the answer.
883
+ const before = Math.min(...offsets);
884
+ let lo = wall - Math.max(...offsets);
885
+ let hi = wall - before;
886
+ while (hi - lo > 1000) {
887
+ const mid = Math.floor((lo + hi) / 2);
888
+ if (timezoneOffsetMs(mid, timeZone) === before) lo = mid;
889
+ else hi = mid;
890
+ }
891
+ return hi - (hi % 1000);
892
+ }
893
+
894
+ /** Midnight at the start of `date` (YYYY-MM-DD) in `timezone`, as epoch ms. */
895
+ export function zonedMidnight(date: string, timezone: string): number {
896
+ return zonedTime(date, timezone);
826
897
  }
827
898
 
828
899
  function resolveWhen(when: WhenFlag | null, timezone: string): number | null {
829
900
  if (when === null || typeof when === "number") return when;
830
- return zonedMidnight(when.date, timezone);
901
+ return zonedTime(when.date, timezone, when.time);
831
902
  }
832
903
 
904
+ /** Orders two values of the same kind (both instants, or both wall-clock). */
833
905
  function whenOrder(when: WhenFlag): number {
834
- return typeof when === "number" ? when : Date.parse(when.date);
906
+ return typeof when === "number"
907
+ ? when
908
+ : Date.parse(`${when.date}T${when.time ?? "00:00:00"}Z`);
835
909
  }
836
910
 
837
911
  const END_BEFORE_START = "--end must be after --start.";
@@ -1460,10 +1534,32 @@ async function runCreate(ctx: CampaignCliContext): Promise<void> {
1460
1534
  ...senderPools,
1461
1535
  })) as { sequenceId: string; references?: unknown[] };
1462
1536
  if (hasScheduleChanges(patch)) {
1463
- await callMutation(ctx, `${OUTREACH}:updateDraft`, {
1464
- sequenceId: created.sequenceId,
1465
- ...patch,
1466
- });
1537
+ try {
1538
+ await callMutation(ctx, `${OUTREACH}:updateDraft`, {
1539
+ sequenceId: created.sequenceId,
1540
+ ...patch,
1541
+ });
1542
+ } catch (error) {
1543
+ // The draft exists; say which one, so the schedule can be set on it
1544
+ // rather than creating another.
1545
+ const reason = error instanceof Error ? error.message : String(error);
1546
+ if (ctx.jsonOutput) {
1547
+ out(
1548
+ ctx,
1549
+ {
1550
+ sequenceId: created.sequenceId,
1551
+ name,
1552
+ status: "draft",
1553
+ scheduleSaved: false,
1554
+ error: reason,
1555
+ },
1556
+ [],
1557
+ );
1558
+ }
1559
+ throw new CampaignCliError(
1560
+ `Created draft campaign "${name}" [${created.sequenceId}], but its schedule wasn't saved: ${reason}. Set it with: dench campaign set ${created.sequenceId} <the same schedule flags>`,
1561
+ );
1562
+ }
1467
1563
  }
1468
1564
  const schedules = patch.sendSchedules ?? [businessHoursSchedule(timezone)];
1469
1565
  const payload = {
@@ -1966,13 +2062,30 @@ async function runStepsEdit(
1966
2062
  return;
1967
2063
  }
1968
2064
  if (draft) {
1969
- await callMutation(ctx, `${OUTREACH}:replaceSteps`, {
1970
- sequenceId: row._id,
1971
- steps: plan.steps,
1972
- ...(workflow || plan.steps.some(hasWorkflowShape)
1973
- ? { stepModel: "workflow" }
1974
- : {}),
1975
- });
2065
+ // The versions this edit was planned against: a step another editor
2066
+ // saved since the read makes the replace fail rather than undo it.
2067
+ const expectedSteps = sortedSteps(detail).map((step) => ({
2068
+ id: String(step._id),
2069
+ updatedAt: Number(step.updatedAt),
2070
+ }));
2071
+ try {
2072
+ await callMutation(ctx, `${OUTREACH}:replaceSteps`, {
2073
+ sequenceId: row._id,
2074
+ steps: plan.steps,
2075
+ expectedSteps,
2076
+ ...(workflow || plan.steps.some(hasWorkflowShape)
2077
+ ? { stepModel: "workflow" }
2078
+ : {}),
2079
+ });
2080
+ } catch (error) {
2081
+ const message = error instanceof Error ? error.message : String(error);
2082
+ if (/changed in another editor/.test(message)) {
2083
+ throw new CampaignCliError(
2084
+ `"${row.name}"'s steps changed since this edit read them, so nothing was saved. Read them again with dench campaign steps get ${row._id} --with-ids, redo the edit, and run it again.`,
2085
+ );
2086
+ }
2087
+ throw error;
2088
+ }
1976
2089
  } else {
1977
2090
  await callMutation(ctx, `${OUTREACH}:updateSteps`, {
1978
2091
  sequenceId: row._id,
@@ -4454,7 +4567,8 @@ Send window spec (--window, repeatable, all in --timezone):
4454
4567
  "Mon-Fri 9-18" "Mon,Wed,Fri 10-16" "Weekdays 9am-5pm" "Sat-Sun 10-14"
4455
4568
 
4456
4569
  --start / --end take ISO 8601. A bare date (2026-10-01) is midnight at the start
4457
- of that day in the campaign's timezone; add a time and offset to be exact.
4570
+ of that day in the campaign's timezone, and a time without an offset
4571
+ (2026-10-01T09:00) is that time there too; add an offset to pin the instant.
4458
4572
 
4459
4573
  steps.json: a JSON array of steps, in order. Types: email, wait, linkedin_visit,
4460
4574
  linkedin_connection, linkedin_message, linkedin_like_post, linkedin_inmail,
@@ -2310,22 +2310,48 @@ const composioToolRun = z.object({
2310
2310
  * One list-filter entry: a plain string includes the value; the object
2311
2311
  * form supports per-value `exclude` / `exactMatch`.
2312
2312
  */
2313
- const searchFilterItem = z.union([
2314
- z.string(),
2315
- z.object({
2316
- value: z.string(),
2317
- exclude: z.boolean().optional(),
2318
- exactMatch: z.boolean().optional(),
2319
- }),
2320
- ]);
2313
+ const searchFilterItem = z.union(
2314
+ [
2315
+ z.string(),
2316
+ // Strict: a misspelled control (`excluded`) must fail, not be stripped
2317
+ // into an include.
2318
+ z
2319
+ .object({
2320
+ value: z.string(),
2321
+ exclude: z.boolean().optional(),
2322
+ exactMatch: z.boolean().optional(),
2323
+ })
2324
+ .strict(),
2325
+ ],
2326
+ {
2327
+ error: (issue) => {
2328
+ // Name what was wrong with the object form ("Unrecognized key:
2329
+ // excluded"); the default union message is only "Invalid input".
2330
+ const branches = (issue as { errors?: { message: string }[][] }).errors;
2331
+ const detail = branches?.[1]?.map((entry) => entry.message).join("; ");
2332
+ return `Use a string or {"value": "…", "exclude": true, "exactMatch": true}${detail ? ` (${detail})` : ""}`;
2333
+ },
2334
+ },
2335
+ );
2321
2336
 
2322
2337
  const FILTER_OPTIONS_NOTE =
2323
2338
  "Accepted values: see `dench leads filters` or https://dench.com/references/find-filter-options.json.";
2324
2339
 
2325
- const searchRange = z.object({
2326
- min: z.number().int().nonnegative().optional(),
2327
- max: z.number().int().nonnegative().optional(),
2328
- });
2340
+ const searchRange = z
2341
+ .object({
2342
+ min: z.number().int().nonnegative().optional(),
2343
+ max: z.number().int().nonnegative().optional(),
2344
+ })
2345
+ // A range has no exclude: an extra key would silently turn into a
2346
+ // positive range.
2347
+ .strict()
2348
+ .refine(
2349
+ (range) =>
2350
+ range.min === undefined ||
2351
+ range.max === undefined ||
2352
+ range.min <= range.max,
2353
+ { message: "Provide a range with min no greater than max." },
2354
+ );
2329
2355
 
2330
2356
  const peopleSearch = z.object({
2331
2357
  linkedinUrl: z.string().optional(),
@@ -2508,34 +2534,43 @@ const campaignTargeting = z.object({
2508
2534
  companies: z.array(z.string()).optional(),
2509
2535
  companyTypes: z.array(z.string()).optional(),
2510
2536
  });
2511
- const campaignSignalTerms = z.object({
2512
- post_engagement: z
2513
- .array(z.string())
2514
- .optional()
2515
- .describe(
2516
- 'Post topics, phrased as what the buyer does ("using AI for outbound"), not bare product names.',
2517
- ),
2518
- keyword: z.array(z.string()).optional(),
2519
- hiring: z.array(z.string()).optional().describe("Roles being hired for."),
2520
- voice_engagement: z
2521
- .array(z.string())
2522
- .optional()
2523
- .describe("LinkedIn profile URLs of market voices whose posts to watch."),
2524
- company_engagement: z
2525
- .array(z.string())
2526
- .optional()
2527
- .describe("LinkedIn company page URLs of companies and competitors."),
2528
- brand_engagement: z
2529
- .array(z.string())
2530
- .optional()
2531
- .describe("The sender's own LinkedIn company page URL."),
2532
- exact: z
2533
- .array(z.enum(["post_engagement", "keyword"]))
2534
- .optional()
2535
- .describe(
2536
- "Topic signals searched by their exact keywords only (no drafted phrases).",
2537
- ),
2538
- });
2537
+ /**
2538
+ * Targeting a request sends. Strict: a key the builder can't represent
2539
+ * (seniorities, companyHeadquarters, companyDomains…) is refused, not
2540
+ * dropped, since dropping it would quietly widen the audience.
2541
+ */
2542
+ const campaignTargetingInput = campaignTargeting.strict();
2543
+ const campaignSignalTerms = z
2544
+ .object({
2545
+ post_engagement: z
2546
+ .array(z.string())
2547
+ .optional()
2548
+ .describe(
2549
+ 'Post topics, phrased as what the buyer does ("using AI for outbound"), not bare product names.',
2550
+ ),
2551
+ keyword: z.array(z.string()).optional(),
2552
+ hiring: z.array(z.string()).optional().describe("Roles being hired for."),
2553
+ voice_engagement: z
2554
+ .array(z.string())
2555
+ .optional()
2556
+ .describe("LinkedIn profile URLs of market voices whose posts to watch."),
2557
+ company_engagement: z
2558
+ .array(z.string())
2559
+ .optional()
2560
+ .describe("LinkedIn company page URLs of companies and competitors."),
2561
+ brand_engagement: z
2562
+ .array(z.string())
2563
+ .optional()
2564
+ .describe("The sender's own LinkedIn company page URL."),
2565
+ exact: z
2566
+ .array(z.enum(["post_engagement", "keyword"]))
2567
+ .optional()
2568
+ .describe(
2569
+ "Topic signals searched by their exact keywords only (no drafted phrases).",
2570
+ ),
2571
+ })
2572
+ // A term list under a signal it doesn't recognize is refused, not dropped.
2573
+ .strict();
2539
2574
  const campaignDraftSource = z
2540
2575
  .enum(["signals", "lookalike", "existing", "crm", "search"])
2541
2576
  .describe(
@@ -2575,19 +2610,40 @@ const campaignOutreachBrief = z.object({
2575
2610
  tone: z.enum(["conversational", "warm", "direct", "formal"]),
2576
2611
  channels: z.enum(["both", "email", "linkedin"]),
2577
2612
  });
2578
- const campaignDraftStep = z.object({
2579
- type: z.enum(["email", "wait", "linkedin_connection", "linkedin_message"]),
2580
- waitHours: z.number().optional(),
2581
- emailSubject: z.string().optional(),
2582
- emailTextBody: z.string().optional(),
2583
- emailReplyToPrevious: z.boolean().optional(),
2584
- onlyIf: z.enum(["always", "connected", "not_connected"]).optional(),
2585
- linkedinMessageBody: z.string().optional(),
2586
- linkedinMessageMode: z
2587
- .enum(["skip_if_not_connected", "wait_until_connected"])
2588
- .optional(),
2589
- connectionTimeoutHours: z.number().optional(),
2590
- });
2613
+ // Strict, with the bounds saving enforces: a field or value it can't keep
2614
+ // (an HTML body, a 13th step, a negative wait) is refused, not dropped.
2615
+ const campaignDraftStep = z
2616
+ .object({
2617
+ type: z.enum(["email", "wait", "linkedin_connection", "linkedin_message"]),
2618
+ waitHours: z
2619
+ .number()
2620
+ .min(1)
2621
+ .max(24 * 60)
2622
+ .optional()
2623
+ .describe("Hours to wait, 1 to 1440. Required on a wait step."),
2624
+ emailSubject: z.string().max(200).optional(),
2625
+ emailTextBody: z
2626
+ .string()
2627
+ .max(5000)
2628
+ .optional()
2629
+ .describe("Plain text only; draft steps keep no HTML body."),
2630
+ emailReplyToPrevious: z.boolean().optional(),
2631
+ onlyIf: z.enum(["always", "connected", "not_connected"]).optional(),
2632
+ linkedinMessageBody: z.string().max(5000).optional(),
2633
+ linkedinMessageMode: z
2634
+ .enum(["skip_if_not_connected", "wait_until_connected"])
2635
+ .optional(),
2636
+ connectionTimeoutHours: z
2637
+ .number()
2638
+ .min(1)
2639
+ .max(24 * 60)
2640
+ .optional(),
2641
+ })
2642
+ .strict()
2643
+ .refine((step) => step.type !== "wait" || step.waitHours !== undefined, {
2644
+ message: "A wait step needs waitHours (1 to 1440).",
2645
+ path: ["waitHours"],
2646
+ });
2591
2647
  const campaignDraftSending = z.object({
2592
2648
  emailMailboxAccountIds: z.array(z.string()),
2593
2649
  linkedinAccountIds: z.array(z.string()),
@@ -3174,6 +3230,20 @@ export const requestSchemas: Record<string, ZodTypeAny> = {
3174
3230
  .describe(
3175
3231
  "IANA timezone, e.g. America/Los_Angeles. Sets a Mon-Fri 9-18 send window in it; without one the draft has no send window.",
3176
3232
  ),
3233
+ emailMailboxAccountIds: z
3234
+ .array(z.string().min(1))
3235
+ .max(50)
3236
+ .optional()
3237
+ .describe(
3238
+ "Mailboxes to send from instead of your own running ones. Checked as the draft is created: one you can't use creates nothing.",
3239
+ ),
3240
+ linkedinAccountIds: z
3241
+ .array(z.string().min(1))
3242
+ .max(50)
3243
+ .optional()
3244
+ .describe(
3245
+ "LinkedIn accounts to send from instead of your own running ones. Checked as the draft is created.",
3246
+ ),
3177
3247
  })
3178
3248
  .strict(),
3179
3249
  "campaigns.audience.search": z
@@ -3210,7 +3280,7 @@ export const requestSchemas: Record<string, ZodTypeAny> = {
3210
3280
  cadence: z.enum(["daily", "weekly", "monthly"]).optional(),
3211
3281
  findMoreMatching: z.boolean().optional(),
3212
3282
  approvalMode: campaignApprovalMode.optional(),
3213
- targeting: campaignTargeting.optional(),
3283
+ targeting: campaignTargetingInput.optional(),
3214
3284
  signal: z
3215
3285
  .object({ signal: campaignSignal, enabled: z.boolean() })
3216
3286
  .optional(),
@@ -3244,7 +3314,7 @@ export const requestSchemas: Record<string, ZodTypeAny> = {
3244
3314
  "campaigns.builder.save": z
3245
3315
  .object({
3246
3316
  clientRequestId: campaignClientRequestId,
3247
- targeting: campaignTargeting,
3317
+ targeting: campaignTargetingInput,
3248
3318
  signals: z.array(campaignSignal),
3249
3319
  signalTerms: campaignSignalTerms.optional(),
3250
3320
  findMoreMatching: z
@@ -3287,7 +3357,10 @@ export const requestSchemas: Record<string, ZodTypeAny> = {
3287
3357
  "ai: generate the sequence from the brief. manual: write steps.",
3288
3358
  ),
3289
3359
  brief: campaignOutreachBrief.optional(),
3290
- steps: z.array(campaignDraftStep).optional(),
3360
+ steps: z
3361
+ .array(campaignDraftStep)
3362
+ .max(12, "A sequence can have up to 12 steps.")
3363
+ .optional(),
3291
3364
  sending: campaignDraftSending.optional(),
3292
3365
  approvalMode: campaignApprovalMode.optional(),
3293
3366
  })
@@ -3332,7 +3405,7 @@ export const requestSchemas: Record<string, ZodTypeAny> = {
3332
3405
  "campaigns.builder.estimate": z
3333
3406
  .object({
3334
3407
  kind: z.enum(["people", "week"]),
3335
- targeting: campaignTargeting,
3408
+ targeting: campaignTargetingInput,
3336
3409
  signals: z.array(campaignSignal).optional(),
3337
3410
  signalTerms: campaignSignalTerms.optional(),
3338
3411
  })