/** * Deep per-operation request/response schemas for the Dench API v1. * * These are the source of truth for: * - OpenAPI generation (scripts/generate-openapi.ts) -> interactive * Mintlify playground parameters, request bodies, and responses. * - Runtime validation in the API v1 dispatcher. * - The generated curl guide (public/references/API.md). * * Auth args (`sessionToken` / `apiKey` / `runId`) are intentionally * omitted here: the dispatcher injects them from the bearer token and * `x-dench-run-id` header. Path-parameter fields (e.g. `objectName`, * `entryId`) ARE included so they validate and reach the backend; any * decorative path segment a backend function does not accept is left out * so strict parsing drops it before the Convex call. * * Schemas mirror the real Convex `args` validators and gateway request * shapes. They avoid `.refine()`/`.transform()` so `z.toJSONSchema` * stays lossless for the playground. */ import { type ZodTypeAny, z } from "zod"; import { CONDITION_CHECKS, CONDITION_OPERATORS, checkCampaignSteps, checkStepEdits, hasWorkflowShape, LINKEDIN_REACTIONS, MAX_ACCEPTANCE_WAIT_HOURS, MAX_CAMPAIGN_STEPS, MAX_INMAIL_BODY_CHARS, MAX_INMAIL_SUBJECT_CHARS, MAX_LIKE_POST_AGE_DAYS, MAX_LIKE_POST_COUNT, MAX_WAIT_HOURS, MIN_ACCEPTANCE_WAIT_HOURS, MIN_WITHDRAW_AFTER_HOURS, STEP_TYPES, WORKFLOW_END, } from "./campaign-steps"; const nativeEmailRecipient = z.object({ email: z.string(), name: z.string().optional(), }); const nativeEmailAttachment = z.object({ storageId: z.string(), name: z.string(), contentType: z.string(), size: z.number(), }); const nativeEmailContent = { to: z.array(nativeEmailRecipient).optional(), cc: z.array(nativeEmailRecipient).optional(), bcc: z.array(nativeEmailRecipient).optional(), subject: z.string().optional(), htmlBody: z.string().optional(), textBody: z.string().optional(), attachments: z.array(nativeEmailAttachment).optional(), }; const nativeEmailCreate = z.object({ organizationId: z.string().optional(), threadId: z.string().optional(), mailbox: z.string().optional(), requestId: z.string(), ...nativeEmailContent, }); const inboxHeader = z.object({ messageHandle: z.string(), threadHandle: z.string(), mailboxId: z.string(), from: nativeEmailRecipient, to: z.array(nativeEmailRecipient), subject: z.string(), at: z.number(), dateSource: z.enum(["sentAt", "createdAt"]), direction: z.enum(["inbound", "outbound"]), status: z.string(), hasAttachments: z.boolean(), headersTruncated: z.boolean(), }); const inboxCoverage = z.object({ source: z.literal("synced"), scope: z.literal("mailbox_linked_messages"), mailboxId: z.string(), mailboxEmail: z.string(), mailboxStatus: z.string(), ingestionMode: z.string(), phase: z.string(), syncStatus: z.string().nullable(), lastPolledAt: z.number().nullable(), lastBackfillCompletedAt: z.number().nullable(), oldestSeenAt: z.number().nullable(), backfillStatus: z.string().nullable(), backfillCapped: z.boolean(), providerComplete: z.literal(false), privacyLimited: z.boolean(), }); const inboxPaging = { limit: z.coerce.number().int().min(1).max(50).optional(), cursor: z.string().optional(), }; const inboxPage = z.object({ page: z.array(inboxHeader), continueCursor: z.string().nullable(), isDone: z.boolean(), coverage: inboxCoverage, order: z.literal("sentAt_desc_then_undated"), }); const nativeEmailReceipt = z .object({ ok: z.boolean(), kind: z.literal("email"), draftId: z.string(), syncStatus: z.string(), revision: z.number(), }) .passthrough(); // --------------------------------------------------------------------------- // Shared enums + fragments (mirror convex validators) // --------------------------------------------------------------------------- export const FIELD_TYPES = [ "text", "email", "phone", "url", "number", "boolean", "date", "richtext", "file", "user", "enum", "relation", "tags", "action", ] as const; const fieldType = z .enum(FIELD_TYPES) .describe( "Field data type. `relation` needs relatedObjectName + relationshipType; `enum` uses enumValues/enumColors; `user` cells store one member userId or an array of member userIds; long prose belongs in the protected `Notes` richtext field.", ); const campaignCalculation = z .object({ kind: z.literal("campaign_activity"), metric: z.enum([ "email_opened", "email_clicked", "replied", "linkedin_connected", ]), campaignId: z .string() .min(1) .optional() .describe( "Same-workspace multichannel campaign ID readable by the viewer. Omit for any accessible multichannel campaign.", ), }) .strict() .describe( "Read-only campaign activity boolean on people or lead (through Contact), not an expression. Cannot be required, indexed, enriched or have a default value.", ); const viewKind = z .enum(["table", "kanban", "calendar", "timeline", "gallery", "list"]) .describe("Default view rendered for the object."); const relationshipType = z .enum(["many_to_one", "many_to_many"]) .describe("Relation cardinality for a `relation` field."); /** A new field's settings: `crm.fields.create`, and each of `crm.objects.create`'s `fields`. */ const newCrmField = { name: z.string(), type: fieldType.optional().describe("Defaults to text."), required: z.boolean().optional(), defaultValue: z.string().optional(), relatedObjectName: z .string() .optional() .describe("Target object for `relation` fields."), relationshipType: relationshipType.optional(), enumValues: z .array(z.string()) .optional() .describe("Options for `enum` fields."), enumColors: z .array(z.string()) .optional() .describe("Hex colors aligned to enumValues."), enumMultiple: z.boolean().optional().describe("Allow multi-select enum."), description: z.string().optional(), indexed: z.boolean().optional().describe("Index for filter/search/sort."), }; const memoryKind = z .enum([ "fact", "decision", "preference", "goal", "tool_note", "other", "convention", "project_context", "agent_lesson", "docs_knowledge", "open_goal", ]) .describe("Memory classification."); const memorySensitivity = z .enum(["normal", "broad", "sensitive"]) .describe("Sensitivity tier; `sensitive` memories land in needs-review."); const memoryVisibility = z .enum(["user", "org"]) .describe("Who can see the memory. Defaults to `user`."); const riskLevel = z.enum(["low", "medium", "high"]).describe("Risk level."); const repeatWeekday = z.enum(["mo", "tu", "we", "th", "fr", "sa", "su"]); /** Structured repeat rule for tasks (mirrors convex/lib/crm/taskRepeatRule). */ const taskRepeatRuleSchema = z.discriminatedUnion("freq", [ z.object({ freq: z.literal("daily"), interval: z.number().int().min(1) }), z.object({ freq: z.literal("weekly"), interval: z.number().int().min(1), byWeekday: z.array(repeatWeekday).min(1), }), z.object({ freq: z.literal("monthly"), interval: z.number().int().min(1), byMonthDay: z .number() .int() .describe("1–31, or -1 for the last day of the month.") .optional(), bySetPos: z .object({ ordinal: z.number().int().describe("1–4, or -1 for the last week."), weekday: repeatWeekday, }) .optional(), }), z.object({ freq: z.literal("yearly"), interval: z.number().int().min(1), month: z.number().int().min(1).max(12), day: z.number().int().min(1).max(31), }), ]); const approvalDecision = z .enum(["approved", "rejected"]) .describe("Human decision for an approval request."); const signInIntent = z .enum(["join_existing", "create_workspace"]) .describe("Whether to join an existing workspace or create a new one."); const reportType = z .enum(["pie", "bar", "line", "table"]) .describe("Chart type for the generated report."); const reportMetric = z .enum(["count", "sum", "avg", "min", "max"]) .describe("Aggregation metric. Defaults to `count`."); const overlapPolicy = z .enum(["skip", "queue", "kill_old"]) .describe( "What to do when a previous run is still active. Defaults to `skip`.", ); const cronTarget = z.literal("chat_turn").describe("Routine run target."); const scheduleSpec = z .union([ z.object({ kind: z.literal("every"), everyMs: z .number() .int() .positive() .describe("Interval in milliseconds (1s to 365d)."), anchorMs: z .number() .int() .optional() .describe("Optional anchor timestamp for the recurring fire."), }), z.object({ kind: z.literal("cron"), expr: z.string().describe("5-field POSIX cron expression."), tz: z .string() .optional() .describe("IANA timezone, e.g. America/New_York."), }), z.object({ kind: z.literal("at"), at: z.string().describe("ISO 8601 timestamp for a one-shot run."), }), ]) .describe( "Schedule: recurring (`every`), cron expression, or one-shot (`at`).", ); const dataMap = z .record(z.string(), z.unknown()) .describe( "Field-name -> value map. For `user` fields use one member userId or a userId array; preserve every assignee.", ); /** * The personal assignee scope — the same two arguments on every read that * supports it. Never part of the saved view; the caller's preference. */ const assigneeScopeRequest = { assignedTo: z .string() .optional() .describe( 'Personal scope: "me" (the signed-in member / the human behind an agent session), "unassigned", or a member userId or email. Adds one has_any / is_empty rule on the object\'s assignee user field; rows, totalCount, sums and analytics all follow it. Never saved onto the view.', ), assigneeField: z .string() .optional() .describe( 'Which `user` field "assigned" means. Default: the analytics owner role, else the task `Assignee`, else a user field named like owner/assignee, else the first user field.', ), }; const assigneeScopeResponse = z .object({ field: z.string().describe("The user field the scope keyed on."), target: z.union([ z.object({ kind: z.literal("user"), userId: z.string() }), z.object({ kind: z.literal("unassigned") }), ]), }) .nullable() .describe("The personal assignee scope that was applied, or null."); const cellValue = z .unknown() .describe( "Cell value. Enum=option string, tags=string array, user=one member userId or a userId array, relation=related entry id, boolean=true/false.", ); const enrichmentConfig = z .object({ category: z.enum(["people", "company"]), key: z.string(), apolloPath: z.string(), inputFieldName: z.string().optional(), }) .describe( "Enrichment binding that lets the field auto-fill from a provider.", ); const crmOp = z .union([ z.object({ type: z.literal("entries.create"), objectName: z.string(), data: dataMap, }), z.object({ type: z.literal("entries.update"), entryId: z.string(), data: dataMap, }), z.object({ type: z.literal("entries.delete"), entryId: z.string(), }), ]) .describe("A single CRM batch/transaction operation."); // --------------------------------------------------------------------------- // Email fragments (mirror convex/functions/emailCampaigns*.ts + gateway SES) // --------------------------------------------------------------------------- const emailAddress = z.object({ email: z.string().describe("Recipient email address."), name: z.string().optional().describe("Optional display name."), }); const emailIdentityType = z .enum(["domain", "email"]) .describe( "Identity scope. `domain` verifies via DNS (DKIM); `email` verifies a single mailbox via a confirmation email.", ); const rawMessageStatus = z .enum([ "queued", "sent", "failed", "delivered", "opened", "clicked", "bounced", "complained", ]) .describe("Raw email message lifecycle status."); const templateStatus = z .enum(["draft", "active", "archived"]) .describe("Template lifecycle status. Defaults to `active`."); const campaignRecipientInput = z.object({ email: z.string().describe("Recipient email address."), displayName: z.string().optional(), personEntryId: z .string() .optional() .describe("Optional CRM people entry to link the send to."), companyEntryId: z.string().optional(), personalization: z .record(z.string(), z.unknown()) .optional() .describe("Values for {{variable}} placeholders in the template."), }); // --------------------------------------------------------------------------- // Outreach campaign fragments (mirror convex/functions/outreachSequences.ts) // --------------------------------------------------------------------------- const campaignSenderPools = { emailMailboxAccountIds: z .array(z.string().trim().min(1)) .max(50) .optional() .describe( "Campaign email sender pool: up to 50 emailMailboxAccounts ids both you and the campaign owner may use. Omit to preserve existing configuration; [] clears the pool. A person's sender stays fixed for their conversation. Launch validates sender availability.", ), linkedinAccountIds: z .array(z.string().trim().min(1)) .max(50) .optional() .describe( "Campaign LinkedIn sender pool: up to 50 linkedinAccounts ids both you and the campaign owner may use. Omit to preserve existing configuration; [] clears the pool. Legacy step senders remain until a pool is configured.", ), }; const campaignLineVariable = z .object({ key: z .string() .describe( "The {{key}} used in steps: a letter, then letters, digits or _ (up to 40). Not a built-in field like firstName or company.", ), label: z.string().describe("The line's name, up to 60 characters."), prompt: z .string() .describe("What the writer should say, up to 600 characters."), fallback: z .string() .describe( "Sent when there is nothing specific to say; up to 300 characters, no {{variables}}.", ), fallbackFrom: z .string() .optional() .describe("A field to use before the fallback, e.g. Company."), maxChars: z .number() .int() .min(20) .max(300) .optional() .describe("The longest line, default 200 characters."), }) .strict() .describe("One personalized line."); const campaignLineTemplate = z .object({ variables: z.array(campaignLineVariable).min(1).max(5), context: z .object({ sells: z.string().describe("What you sell, up to 1000 characters."), offer: z .string() .optional() .describe("The offer or call to action, up to 300 characters."), }) .strict(), }) .strict(); const campaignLineBackfill = z.object({ version: z.number(), eligible: z.number().describe("People who would get a line."), estimatedCents: z.number().describe("About 3 cents a person."), needsConfirm: z .boolean() .describe( "True when nothing was written: confirm with campaigns.personalization.write and confirmed: true.", ), queued: z.number().describe("People whose lines are being written now."), }); const campaignPreviewedLine = z .object({ key: z.string().describe("The line's {{key}}."), label: z.string(), value: z .string() .describe("What this person gets: the written line, or the fallback."), state: z .enum([ "written", "edited", "writing", "paused", "fallback", "not_written", ]) .describe( "written or edited: the line is final. fallback: nothing specific was found, so the fallback is used. writing, paused and not_written: the fallback shows until the line is written.", ), source: z .enum(["company", "news", "signal", "profile", "fallback"]) .optional() .describe("What the line was written from."), maxChars: z.number(), rewriting: z .enum(["writing", "paused"]) .optional() .describe("A new line is being written over the current one."), }) .passthrough(); const campaignMessagePreview = z .object({ channel: z.enum(["email", "linkedin"]), subject: z.string().optional().describe("Email steps."), textBody: z.string().optional().describe("Email steps: plain text."), htmlBody: z.string().optional().describe("Email steps: HTML."), messageBody: z .string() .optional() .describe( "LinkedIn steps: the note or message, or a line saying no note or message is sent.", ), fromName: z.string().optional().describe("The sending account."), fromEmail: z.string().optional(), toName: z.string(), toEmail: z.string().optional(), missingFields: z .array(z.string()) .describe( "{{merge fields}} this person has no value for; fill them with campaigns.people.setField.", ), warnings: z.array(z.string()), skippedReason: z .string() .optional() .describe( "Set when the person would skip this step, e.g. no email address.", ), highlights: z.array(z.string()).optional(), personalized: z.array(campaignPreviewedLine).optional(), }) .passthrough(); const campaignSenderLabel = z .object({ accountId: z.string(), label: z.string().describe("The mailbox address or LinkedIn name."), status: z.string(), }) .nullable(); const campaignMembership = z .object({ enrollment: z .record(z.string(), z.unknown()) .describe( "The person in that campaign: _id, sequenceId, status, currentStepIndex, repliedAt.", ), sequenceName: z.string(), isOwner: z.boolean(), canManage: z.boolean(), senders: z .object({ email: campaignSenderLabel, linkedin: campaignSenderLabel }) .describe("The accounts that send to this person."), }) .passthrough(); const campaignMailboxSummary = z .object({ _id: z .string() .describe("The emailMailboxAccountId to put in emailMailboxAccountIds."), email: z.string(), provider: z.enum(["gmail", "outlook"]), status: z.enum(["running", "disconnected", "errored"]), statusReason: z.string().optional(), warmupStatus: z .enum(["skipped", "pending_auth", "warming", "ready", "error"]) .describe( "ready or skipped: sends at its cap. warming: sends less until readyAt. pending_auth and error need the owner in the app.", ), readyAt: z .number() .optional() .describe( "When warm-up finished, or is expected to finish while warming (epoch ms).", ), dailyColdSendCap: z .number() .describe( "The most cold emails a day; the mailbox owner sets it in the app.", ), remainingToday: z.number().describe("Cold emails it can still send today."), ownerName: z.string(), isOwner: z.boolean(), visibility: z.enum(["private", "shared"]), }) .passthrough() .describe("One mailbox a campaign can send from."); const campaignLinkedinSenderSummary = z .object({ linkedinAccountId: z .string() .describe( "Local sender id for linkedinAccountIds, never a provider credential.", ), displayName: z.string(), status: z.enum(["running", "paused", "disconnected", "errored"]), ownerName: z.string(), isOwner: z.boolean(), visibility: z.enum(["private", "shared"]), remainingToday: z .object({ invites: z .number() .nonnegative() .describe( "Daily capacity, also bounded by the rolling weekly invite limit.", ), messages: z.number().nonnegative(), profiles: z.number().nonnegative(), }) .strict(), remainingInvitesThisWeek: z.number().nonnegative(), availability: z .enum([ "ready", "paused", "disconnected", "errored", "disabled", "circuit_break", "spacing", "outside_window", "daily_cap", ]) .describe( "Account availability snapshot. Queued work, campaign limits and launch/dispatch checks still apply.", ), }) .strict(); const campaignSendSchedule = z .object({ name: z.string().describe('Label, e.g. "Mon-Fri 9-18".'), timezone: z.string().describe("IANA timezone, e.g. America/Los_Angeles."), startHour: z .number() .int() .min(0) .max(23) .describe("First hour sends may go out (0-23)."), endHour: z .number() .int() .min(1) .max(24) .describe("Hour sends stop (1-24); must be after startHour."), weekdays: z .array(z.number().int().min(0).max(6)) .min(1) .describe("Days sends may go out: 0 = Sunday ... 6 = Saturday."), }) .describe( "One send window. A campaign has one to eight; the first one's timezone is the campaign's.", ); const campaignStepType = z .enum(STEP_TYPES) .describe( "Step type. condition (If / else) and wait_for_connection (Wait for them to accept) send nothing; they continue on yesNext or noNext and make the campaign a workflow.", ); const workflowTarget = (exit: string) => z .string() .min(1) .optional() .describe( `Workflow steps: ${exit}: another step's key, or "${WORKFLOW_END}" to stop there. Fixed once the campaign launches.`, ); const campaignStepModel = z .enum(["linear", "workflow"]) .describe( "linear: steps run in order. workflow: steps have keys and paths (next, yesNext, noNext) and may include condition and wait_for_connection. Left out, a workflow stays one and steps with keys, paths or those types make one; nothing turns a workflow back into a list.", ); const campaignStepInput = z .object({ type: campaignStepType, waitHours: z .number() .optional() .describe("wait steps: hours to wait before the next step."), emailMailboxAccountId: z .string() .optional() .describe( "Legacy email step sender. Pool campaigns choose senders in emailMailboxAccountIds on the campaign, not on individual steps.", ), emailSubject: z .string() .optional() .describe("email steps: subject; {{Column}} merge tokens allowed."), emailHtmlBody: z .string() .optional() .describe( "email steps: HTML body. Sent instead of emailTextBody when both are set, so when you edit the text, update or drop this too.", ), emailTextBody: z .string() .optional() .describe( "email steps: plain-text body; used as the email when there is no emailHtmlBody.", ), emailReplyToPrevious: z .boolean() .optional() .describe( "email steps: thread under the previous email step instead of a new subject.", ), onlyIf: z .enum(["always", "connected", "not_connected"]) .optional() .describe( "email and linkedin_like_post only: always (default), connected or not_connected. InMail automatically skips connected people; do not set a condition on it.", ), linkedinAccountId: z .string() .optional() .describe( "Legacy LinkedIn step sender (also on wait_for_connection, and on a condition that checks linkedin_connected). Pool campaigns use the campaign's linkedinAccountIds instead.", ), linkedinMessageBody: z .string() .optional() .describe( `linkedin_connection: the invite note (optional, up to 300 chars; sent without it if LinkedIn refuses the note); linkedin_message: the required message (up to 3000 chars); linkedin_inmail: the required message (up to ${MAX_INMAIL_BODY_CHARS} chars).`, ), linkedinMessageMode: z .enum(["skip_if_not_connected", "wait_until_connected"]) .optional() .describe( "linkedin_message: what to do when the invite is still pending.", ), connectionTimeoutHours: z .number() .min(1) .max(MAX_WAIT_HOURS) .optional() .describe( `linkedin_message with linkedinMessageMode=wait_until_connected: wait 1-${MAX_WAIT_HOURS} hours for acceptance (default 720); after a connection step this mode is the default in a linear campaign. wait_for_connection: wait ${MIN_ACCEPTANCE_WAIT_HOURS}-${MAX_ACCEPTANCE_WAIT_HOURS} hours (1-90 days, default 720) before taking the Not accepted path.`, ), likeReaction: z .enum(LINKEDIN_REACTIONS) .optional() .describe( "linkedin_like_post only: the reaction to leave (default like).", ), likeMaxPostAgeDays: z .number() .int() .min(1) .max(MAX_LIKE_POST_AGE_DAYS) .optional() .describe( `linkedin_like_post only: select the newest eligible original post from the last 1-${MAX_LIKE_POST_AGE_DAYS} days (default 30).`, ), likeSkipPreviousWait: z .boolean() .optional() .describe( "linkedin_like_post only: before the immediately preceding wait starts, check for eligible posts. If none exist, skip that wait and the like step, keeping following waits. Defaults to false when omitted.", ), likeSkipNextWait: z .boolean() .optional() .describe( "linkedin_like_post only: skip consecutive following wait steps whenever the reaction step is skipped (default true). False keeps those waits.", ), likeCount: z .number() .int() .min(1) .max(MAX_LIKE_POST_COUNT) .optional() .describe( `linkedin_like_post only: how many recent posts to react to, 1-${MAX_LIKE_POST_COUNT} (default 1).`, ), inmailSubject: z .string() .optional() .describe( `linkedin_inmail only: optional subject, up to ${MAX_INMAIL_SUBJECT_CHARS} characters; merge tokens allowed. Clear with an empty string.`, ), withdrawAfterHours: z .number() .min(MIN_WITHDRAW_AFTER_HOURS) .max(MAX_WAIT_HOURS) .nullable() .optional() .describe( `linkedin_connection only: withdraw a still-pending invitation sent by this campaign after ${MIN_WITHDRAW_AFTER_HOURS}-${MAX_WAIT_HOURS} hours (7-90 days). Omitted means off on creation; null turns it off on edit.`, ), key: z .string() .min(1) .optional() .describe( `Workflow steps: this step's id, unique in the campaign, which next, yesNext and noNext point at (not "${WORKFLOW_END}"). Left out, one is made.`, ), next: workflowTarget( "where a step that isn't a condition or wait_for_connection goes; left out, the following step", ), yesNext: workflowTarget( "condition and wait_for_connection only, required: where the Yes (Accepted) path goes", ), noNext: workflowTarget( "condition and wait_for_connection only, required: where the No (Not accepted) path goes", ), check: z .enum(CONDITION_CHECKS) .optional() .describe( "condition only, required: linkedin_connected, has_email, opened_last_email (needs an email before it on every path) or field_value. Fixed once the campaign launches.", ), conditionField: z .string() .optional() .describe( 'condition with check=field_value: the lead field to check, like "Job Title" or "Source" (the campaign itself changes Status, Last touch and LinkedIn connection). Fixed once the campaign launches.', ), conditionOperator: z .enum(CONDITION_OPERATORS) .optional() .describe( "condition with check=field_value: is, is_not, contains, is_empty or is_not_empty (all case-insensitive). Fixed once the campaign launches.", ), conditionValue: z .string() .optional() .describe( "condition with check=field_value and is, is_not or contains: the text to compare with. Editable after launch.", ), }) .describe("One campaign step, in order."); const campaignTimelineRun = z .object({ _id: z.string(), stepId: z.string().describe("The step's _id, as in steps[]."), stepIndex: z.number().describe("The step's place, from 0."), type: campaignStepType, status: z .enum([ "queued", "running", "waiting", "retry", "sent", "skipped", "failed", "unknown", ]) .describe( "sent: done (a wait that finished is sent too). skipped: see result.reason. retry: will be tried again at retryAt. unknown: needs a look.", ), scheduledAt: z.number().optional(), startedAt: z.number().optional(), waitUntil: z.number().optional(), retryAt: z.number().optional(), completedAt: z.number().optional(), emailMessageId: z .string() .optional() .describe("The email this run sent, in emailMessages[]."), lastError: z.string().optional(), timeoutAt: z .number() .optional() .describe("wait_for_connection: when it stops waiting for the accept."), result: z .unknown() .optional() .describe( 'For a skipped step, { reason } says why. A finished condition or wait_for_connection has { branch: "yes" | "no" } (Yes / Accepted or No / Not accepted), and { reason } for some No paths.', ), }) .passthrough(); const campaignPersonTimeline = z .object({ enrollment: z .record(z.string(), z.unknown()) .describe("The person in this campaign: status, lastError, repliedAt."), sequence: z.record(z.string(), z.unknown()).nullable(), steps: z .array(campaignStepInput.passthrough()) .describe("The campaign's steps in order, each with its _id."), runs: z .array(campaignTimelineRun) .describe("One run for each step the person has reached, in order."), emailMessages: z .array(z.record(z.string(), z.unknown())) .describe( "The emails those runs sent: subject, textBody, status, sentAt, firstOpenedAt, firstClickedAt.", ), linkedinMessages: z .array(z.record(z.string(), z.unknown())) .describe( "The LinkedIn messages those runs sent: text, sentAt, deliveredAt, readAt, outreachStepRunId.", ), }) .passthrough(); const campaignStatus = z .enum(["draft", "active", "paused", "completed", "archived"]) .describe("Campaign lifecycle status."); const campaignRow = z .object({ _id: z.string(), name: z.string(), status: campaignStatus, stopOnReply: z.boolean(), ...campaignSenderPools, timezone: z.string().optional(), startsAt: z.number().optional(), endsAt: z.number().optional(), sendSchedules: z.array(campaignSendSchedule).optional(), dailySendCap: z.number().optional(), stepCount: z.number(), stepModel: campaignStepModel.optional(), totalEnrolled: z.number(), activeCount: z.number(), pausedCount: z.number(), finishedCount: z.number(), repliedCount: z.number(), failedCount: z.number(), stoppedCount: z.number(), launchedAt: z.number().optional(), ownerName: z.string().optional(), isOwner: z.boolean().optional(), createdAt: z.number(), updatedAt: z.number(), }) .passthrough(); const campaignEnrollResult = z.object({ added: z.number(), requested: z.number(), cappedAtLimit: z .number() .nullable() .describe( "The campaign's people cap when it cut the batch short (300), else null.", ), duplicates: z.number().describe("Already in this campaign."), merged: z .number() .describe("Duplicates that gained a missing email or LinkedIn URL."), duplicatesElsewhere: z .number() .describe( "Active in another campaign; skipped unless checkDuplicatesAcrossWorkspace is false.", ), suppressed: z.number(), invalid: z.number(), skippedNoIdentity: z .number() .describe("No usable email or LinkedIn URL on the record."), skippedSample: z.number(), skippedMissingInfo: z .number() .describe( "Launched campaigns only: a merge token the steps use is empty for them.", ), missingInfoSample: z .object({ displayName: z.string().optional(), missingKeys: z.array(z.string()), }) .optional(), }); const campaignLaunchBlocker = z .object({ code: z.string(), scope: z.enum(["sequence", "step", "person"]), target: z.enum(["sequence", "people", "schedule"]), title: z.string(), hint: z.string().optional(), stepIndex: z.number().optional(), people: z.array( z.object({ enrollmentId: z.string().optional(), name: z.string() }), ), count: z.number(), }) .passthrough(); const campaignReadiness = z .object({ ready: z.boolean(), fingerprint: z .string() .describe( "Pass this to `launch`; it changes whenever the campaign changes.", ), blockingReasons: z.array(z.string()), blockers: z.array(campaignLaunchBlocker), warnings: z.array(z.string()), stepCount: z.number(), audienceCount: z.number(), usesEmail: z.boolean(), usesLinkedin: z.boolean(), }) .passthrough(); // --------------------------------------------------------------------------- // Response fragments. These mirror the real backend return shapes (Convex // `returns` validators / handler code / gateway response mapping). Objects // built with `.passthrough()` may carry additional fields at runtime. // --------------------------------------------------------------------------- const okResponse = z.object({ ok: z.boolean() }); const fieldsMap = z .record(z.string(), z.unknown()) .describe("Field-name -> cell value map."); // -- auth / workspace ---------------------------------------------------- const organizationRef = z.object({ id: z.string(), name: z.string(), slug: z.string(), }); const agentRef = z.object({ id: z.string(), name: z.string(), kind: z.string(), }); const artifactRow = z.object({ id: z.string(), runId: z.string(), title: z.string(), type: z.enum([ "note", "draft", "report", "task_suggestion", "patch", "other", ]), status: z.enum(["draft", "approved", "rejected", "applied", "archived"]), content: z.string(), createdAt: z.number(), updatedAt: z.number(), }); const workspaceStatusPayload = z .object({ workspace: z.object({ id: z.string(), name: z.string(), slug: z.string().optional(), }), agent: agentRef, rules: z.array( z.object({ id: z.string(), title: z.string(), body: z.string(), scope: z.enum(["organization", "project"]), }), ), activeAgents: z.array( agentRef.extend({ lastSeenAt: z.number().optional() }), ), pendingApprovals: z.array( z .object({ id: z.string(), title: z.string(), reason: z.string(), kind: z.string().optional(), risk: riskLevel, agentId: z.string().optional(), createdAt: z.number(), }) .passthrough(), ), recentArtifacts: z.array(artifactRow), suggestedWork: z.array( artifactRow.extend({ nextCommands: z.array(z.string()) }), ), memory: z.array( z.object({ id: z.string(), key: z.string(), kind: memoryKind, text: z.string(), tags: z.array(z.string()), status: z.enum(["active", "needs_review", "archived"]), updatedAt: z.number(), }), ), chatAgents: z.object({ nextCommands: z.array(z.string()), activeRuns: z.array( z .object({ id: z.string(), goal: z.string(), status: z.string(), model: z.string(), createdAt: z.number(), }) .passthrough(), ), templates: z.array(z.string()), }), tools: z.object({ discovery: z.array(z.string()), cli: z.array(z.string()), approvalPolicy: z.string(), }), nextActions: z.array(z.string()), }) .describe( "Workspace orientation payload shared by /context, /status, /agents, and /approvals.", ); const workspaceInstructionsResponse = z.object({ instructions: z .string() .describe( "Markdown standing instructions for external agents connected to this workspace.", ), workspace: z.object({ name: z.string(), slug: z.string().optional(), }), }); const memoryDoc = z .object({ _id: z.string(), _creationTime: z.number(), key: z.string(), kind: memoryKind, text: z.string(), tags: z.array(z.string()), status: z.enum(["active", "needs_review", "archived"]), source: z.string(), visibility: memoryVisibility.optional(), confidence: z.number().optional(), importance: z.number().optional(), createdAt: z.number(), updatedAt: z.number(), }) .passthrough() .describe("A workspace memory document."); // -- billing --------------------------------------------------------------- const billingStatusResponse = z .object({ workspace: organizationRef, availableCreditsCents: z.number(), creditConsumedCents: z.number(), billedCents: z.number(), onDemandSpendCents: z.number(), monthlySpendLimitCents: z.number(), spendLimitRemainingCents: z.number(), subscriptionStatus: z.string().nullable(), stripeCustomerId: z.string().nullable(), grants: z.array( z.object({ id: z.string(), source: z.enum([ "starter", "trial", "monthly", "topup", "manual", "referral", ]), category: z.enum(["promotional", "paid"]), status: z.enum(["pending", "granted", "depleted", "expired", "voided"]), amountCents: z.number(), usedCents: z.number(), remainingCents: z.number(), effectiveAt: z.number(), expiresAt: z.number().nullable(), }), ), nextCommands: z.array(z.string()), }) .passthrough(); // -- chat -------------------------------------------------------------------- const chatThreadRow = z.object({ threadId: z.string(), title: z.string(), lastMessageAt: z.number(), createdAt: z.number(), status: z.enum(["idle", "streaming", "error"]), visibility: z.enum(["private", "shared"]), createdByUserId: z.string(), archivedAt: z.number().nullable(), fileContextPath: z.string().nullable(), parentThreadId: z .string() .nullable() .describe("Parent thread when this is a spawned sub-agent."), nestingDepth: z.number().nullable(), summary: z.string().nullable(), activeRunId: z.string().nullable(), }); const chatSpawnResponse = z.object({ threadId: z.string(), runId: z.string(), workflowRunId: z.string().nullable(), threadUrl: z.string().describe("Web URL of the thread."), streamUrl: z.string().describe("SSE stream URL for the live response."), yolo: z.boolean(), }); // -- crm --------------------------------------------------------------------- const savedViewSortRule = z.object({ field: z.string().describe("Field name to sort by."), direction: z.enum(["asc", "desc"]), }); /** * SavedView definition as stored in `crmObjects.savedViews` and * validated by `convex/lib/savedView.ts`. `filters` is a FilterGroup: * `{id, conjunction: "and"|"or", rules: [{id, field, operator, * value?}]}` with one level of nested groups allowed. */ const savedViewDefinition = z .object({ name: z .string() .min(1) .max(100) .describe("View name (upsert key; shows in the view dropdown)."), view_type: viewKind.optional(), filters: z .record(z.string(), z.unknown()) .optional() .describe( 'FilterGroup, e.g. {"id":"g1","conjunction":"and","rules":[{"id":"r1","field":"Status","operator":"is","value":"Hot"}]}. ' + 'Date rules support operator "date_preset" with value "today", "yesterday", "this_week", ' + '"last_week", "this_month", "last_month", "this_quarter", "last_quarter", "this_year", or ' + '"last_year", plus "relative_past"/"relative_next" with relativeAmount + relativeUnit ' + "(days|weeks|months|quarters|years). Rules may target the system timestamps " + '"created_at" / "updated_at", e.g. {"field":"created_at","operator":"relative_past","relativeAmount":7,"relativeUnit":"days"}.', ), sort: z.array(savedViewSortRule).optional(), columns: z .array(z.string()) .optional() .describe("Visible-column ordering. Omit to keep the default set."), settings: z .record(z.string(), z.unknown()) .optional() .describe( "View-type settings (kanbanField, calendarDateField, timelineStartField, …).", ), pinnedEntryIds: z .array(z.string()) .optional() .describe( "Pinned-list mode: show EXACTLY these entry ids (mutually exclusive with filters).", ), }) .passthrough(); const objectVisibility = z .enum(["workspace", "owner_only", "owner_plus_members"]) .describe( "Who can read the object's records. Always reported as the value the object behaves as: `meeting` defaults to owner_plus_members, `calendar_event` to owner_only, anything else to workspace.", ); const crmObjectDoc = z .object({ _id: z.string(), _creationTime: z.number(), organizationId: z.string(), name: z.string(), description: z.string().optional(), defaultView: viewKind, sortOrder: z.number(), immutable: z.boolean(), visibility: objectVisibility.optional(), icon: z.string().optional(), viewSettings: z.record(z.string(), z.unknown()).optional(), savedViews: z.array(z.record(z.string(), z.unknown())).optional(), entryCount: z.number().optional(), createdAt: z.number(), updatedAt: z.number(), }) .passthrough() .describe("A CRM object (table) document."); const crmFieldDoc = z .object({ _id: z.string(), _creationTime: z.number(), organizationId: z.string(), objectId: z.string(), name: z.string(), description: z.string().optional(), type: fieldType, required: z.boolean(), defaultValue: z.string().optional(), calculation: campaignCalculation.optional(), relatedObjectId: z.string().optional(), relationshipType: relationshipType.optional(), enumValues: z.array(z.string()).optional(), enumColors: z.array(z.string()).optional(), enumMultiple: z.boolean().optional(), sortOrder: z.number(), indexed: z.boolean().optional(), createdAt: z.number(), updatedAt: z.number(), }) .passthrough() .describe("A CRM field (column) document."); // Presentation metadata is separate from CRM field maps and optional on raw results. const referenceIdentity = { label: z.string(), href: z.string(), url: z.string().optional(), imageUrl: z.string().optional(), objectIcon: z.string().optional(), }; const workspaceReference = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("crm-entry"), objectName: z.string(), entryId: z.string(), ...referenceIdentity, }), z.object({ kind: z.literal("crm-object"), objectName: z.string(), ...referenceIdentity, }), z.object({ kind: z.literal("campaign"), sequenceId: z.string(), ...referenceIdentity, }), ]); const workspaceReferenceFields = { references: z.array(workspaceReference).max(100), referenceCoverage: z.object({ returned: z.number().int().nonnegative(), available: z.number().int().nonnegative(), truncated: z.boolean(), }), }; const optionalWorkspaceReferenceFields = { references: workspaceReferenceFields.references.optional(), referenceCoverage: workspaceReferenceFields.referenceCoverage.optional(), }; const withReferencesRequest = { withReferences: z .boolean() .optional() .describe( "Include bounded navigation references. Array reads return {data, references, referenceCoverage}; existing result envelopes keep their fields.", ), }; function referenceListResponse(row: T) { return z.union([ z.array(row), z.object({ data: z.array(row), ...workspaceReferenceFields }), ]); } const crmEntryShape = z .object({ id: z.string(), objectId: z.string(), fields: fieldsMap, createdAt: z.number(), updatedAt: z.number(), updatedBy: z.string(), sortOrder: z.number(), }) .describe("A materialized CRM entry (row)."); const entryWriteResponse = z.object({ entryId: z.string(), ...optionalWorkspaceReferenceFields, }); const commentActorShape = z .object({ kind: z.string(), label: z.string(), displayName: z.string().optional(), imageUrl: z.string().optional(), userId: z.string().optional(), isDench: z.boolean(), }) .passthrough(); const commentShape = z .object({ _id: z.string(), entryId: z.string(), parentCommentId: z.string().optional(), rootCommentId: z.string().optional(), depth: z.number(), body: z.string().describe("Markdown."), actor: commentActorShape, mentionedUserIds: z.array(z.string()), mentionsDench: z.boolean(), replyCount: z.number(), reactionSummary: z.array( z.object({ emoji: z.string(), count: z.number() }), ), viewerReactions: z.array(z.string()), editedAt: z.number().optional(), deletedAt: z.number().optional(), denchStatus: z.enum(["pending", "failed", "done"]).optional(), canEdit: z.boolean(), createdAt: z.number(), }) .passthrough() .describe("A comment on a CRM entry."); const commentPage = z.object({ page: z.array(commentShape), isDone: z.boolean(), continueCursor: z.string(), }); const notificationShape = z .object({ _id: z.string(), kind: z.string(), title: z.string(), body: z.string().optional(), link: z.string().optional(), readAt: z.number().optional(), archivedAt: z.number().optional(), createdAt: z.number(), }) .passthrough(); const syncSettingsShape = z .object({ detectAccounts: z.boolean(), linkedinContacts: z.boolean(), slackConnectAccounts: z.boolean(), calendarContacts: z.boolean(), dossier: z.boolean(), emailTasks: z.boolean(), leadAutoQualify: z.boolean(), intelligence: z.boolean(), }) .describe( "Settings → Sync, resolved: absent switches read as true. Off stops new work; nothing already created is removed.", ); const syncSettingsPatchShape = z.object({ detectAccounts: z .boolean() .optional() .describe( "Accounts from email — new prospects and customers who email you become accounts and contacts.", ), linkedinContacts: z .boolean() .optional() .describe( "Contacts from LinkedIn — everyone you message on LinkedIn becomes a contact, with their company.", ), slackConnectAccounts: z .boolean() .optional() .describe( "Accounts from Slack Connect — a shared channel binds to the company on the other side.", ), calendarContacts: z .boolean() .optional() .describe( "Contacts from calendar — external invitees on upcoming meetings become contacts.", ), dossier: z .boolean() .optional() .describe( "Research dossier — Dench researches each new account into its Notes.", ), emailTasks: z .boolean() .optional() .describe( "Tasks from email — replies and follow-ups you owe become tasks; support issues become cases.", ), leadAutoQualify: z .boolean() .optional() .describe( "Qualify leads that book — a lead who books a meeting moves to Qualified and converts.", ), intelligence: z .boolean() .optional() .describe( "Summaries and Brief — conversations are summarised and each record keeps a Brief.", ), }); const notificationPreferencesShape = z.object({ inAppEnabled: z.boolean(), emailEnabled: z.boolean(), perKind: z.record( z.string(), z.object({ inApp: z.boolean().optional(), email: z.boolean().optional() }), ), timezone: z.string().nullable(), digestWindowMinutes: z.number(), maxEmailsPerDay: z.number(), }); const crmBatchResults = z.object({ ...optionalWorkspaceReferenceFields, results: z.array( z .object({ entryId: z.string(), deleted: z.boolean().optional(), }) .passthrough(), ), count: z.number(), }); const presenceState = z.enum(["online", "away", "offline"]); const memberShape = z.object({ userId: z.string(), name: z.string().nullable(), email: z.string().nullable(), image: z.string().nullable(), tagline: z.string().nullable(), timezone: z .string() .nullable() .describe("IANA zone, when the member set one."), presence: presenceState, lastActiveAt: z.number().nullable(), role: z.string(), joinedAt: z.number(), }); const memberProfileShape = z.object({ userId: z.string(), name: z.string().nullable(), email: z.string().nullable(), image: z.string().nullable(), tagline: z.string().nullable(), role: z.enum(["ADMIN", "MEMBER"]), joinedAt: z.number(), timezone: z .object({ value: z.string(), source: z.enum(["auto", "manual", "agent"]), updatedAt: z.number(), }) .nullable(), presence: z.object({ state: presenceState, lastActiveAt: z.number().nullable(), }), openTaskCount: z.number().describe("Capped at 500."), openTaskCountCapped: z .boolean() .optional() .describe("True when openTaskCount stopped at its cap."), commentCount: z.number().describe("Capped at 200."), changeCount: z.number().describe("Capped at 200."), lastContributionAt: z.number().nullable(), isSelf: z.boolean(), }); const memberActivityRow = z.union([ z.object({ kind: z.literal("comment"), id: z.string(), entryId: z.string(), objectName: z.string(), entryLabel: z.string(), body: z.string(), parentCommentId: z.string().optional(), deleted: z.boolean(), at: z.number(), }), z .object({ kind: z.literal("change"), id: z.string(), entryId: z.string(), objectName: z.string(), entryLabel: z.string(), changeKind: z.enum(["create", "update", "delete"]), changes: z.array( z.object({ fieldName: z.string(), before: z.unknown().optional(), after: z.unknown().optional(), }), ), at: z.number(), }) .passthrough(), ]); // -- routines ------------------------------------------------------------------ const cronJobShape = z .object({ id: z.string(), name: z.string(), description: z.string().optional(), enabled: z.boolean(), deleteAfterRun: z.boolean(), createdAtMs: z.number(), updatedAtMs: z.number(), schedule: scheduleSpec.optional(), sessionTarget: z.string(), wakeMode: z.string(), payload: z.union([ z.object({ kind: z.literal("agentTurn"), message: z.string() }), z.object({ kind: z.literal("systemEvent"), text: z.string() }), ]), state: z.object({ nextRunAtMs: z.number().optional(), runningAtMs: z.number().optional(), lastRunAtMs: z.number().optional(), lastStatus: z.enum(["ok", "error", "skipped"]).optional(), lastError: z.string().optional(), lastDurationMs: z.number().optional(), }), }) .passthrough() .describe("A routine (cron trigger)."); const cronRunEntry = z .object({ ts: z.number(), jobId: z.string(), action: z.string(), status: z.enum(["ok", "error", "skipped", "running"]).optional(), error: z.string().optional(), summary: z.string().optional(), sessionId: z.string().optional(), runAtMs: z.number().optional(), durationMs: z.number().optional(), nextRunAtMs: z.number().optional(), }) .passthrough(); const cronWriteResponse = z.object({ jobId: z.string(), nextFireAt: z.number().nullable().optional(), }); // -- files ----------------------------------------------------------------- const fileTreeRow = z .object({ _id: z.string(), _creationTime: z.number(), path: z.string(), parentPath: z.string(), isDir: z.boolean(), size: z.number().optional(), mtime: z.number().optional(), contentHash: z.string().optional(), storageId: z.string().optional(), lastModifiedBy: z.string().optional(), virtual: z .boolean() .optional() .describe("True for synthesized magic files (IDENTITY.md etc.)."), }) .passthrough() .describe("A workspace file-tree row."); // -- email ------------------------------------------------------------------- const campaignStatusEnum = z .enum([ "draft", "pending_approval", "scheduled", "sending", "paused", "completed", "cancelled", "failed", ]) .describe("Campaign lifecycle status."); const deliveryVerificationStatus = z .enum(["pending", "verified", "failed", "disabled"]) .describe("Provider-side (DNS/mailbox) verification status."); const denchVerificationStatus = z .enum(["pending", "verified", "expired", "disabled"]) .describe("Workspace-side sender verification status."); const dnsRecordShape = z.object({ type: z.string().describe("Record type, e.g. CNAME or TXT."), name: z.string(), value: z.string(), purpose: z.string().describe("Why the record is needed (DKIM/SPF/DMARC)."), }); const emailIdentityRow = z.object({ identityId: z.string(), type: emailIdentityType, domain: z.string().optional(), fromEmail: z.string(), fromName: z.string().optional(), deliveryVerificationStatus, denchVerificationStatus, disabled: z.boolean(), disabledAt: z.number().optional(), createdAt: z.number(), }); const identitySummaryShape = z.object({ identityId: z.string(), fromEmail: z.string(), domain: z.string().optional(), disabled: z.boolean(), }); const emailMessageShape = z .object({ messageId: z.string(), status: rawMessageStatus, fromEmail: z.string(), fromName: z.string().optional(), to: z.array(emailAddress), cc: z.array(emailAddress).optional(), bcc: z.array(emailAddress).optional(), subject: z.string(), inReplyTo: z.string().optional(), references: z.array(z.string()).optional(), providerMessageId: z.string().optional(), rfcMessageId: z.string().optional(), recipientCount: z.number(), openCount: z.number(), clickCount: z.number(), sentAt: z.number().optional(), deliveredAt: z.number().optional(), firstOpenedAt: z.number().optional(), lastOpenedAt: z.number().optional(), firstClickedAt: z.number().optional(), bouncedAt: z.number().optional(), complainedAt: z.number().optional(), lastError: z.string().optional(), createdAt: z.number(), }) .describe("A raw email message with delivery + engagement counters."); const campaignSummaryShape = z.object({ campaignId: z.string(), status: campaignStatusEnum, totalRecipients: z.number(), queuedCount: z.number(), sendingCount: z.number(), sentCount: z.number(), deliveredCount: z.number(), openedCount: z.number(), clickedCount: z.number(), bouncedCount: z.number(), complainedCount: z.number(), failedCount: z.number(), suppressedCount: z.number(), unsubscribedCount: z.number(), }); const campaignDoc = z .object({ _id: z.string(), _creationTime: z.number(), organizationId: z.string(), name: z.string(), status: campaignStatusEnum, sendingIdentityId: z.string(), templateId: z.string(), subjectSnapshot: z.string(), htmlSnapshot: z.string(), textSnapshot: z.string().optional(), fromEmailSnapshot: z.string(), fromNameSnapshot: z.string().optional(), replyToEmailSnapshot: z.string().optional(), scheduledAt: z.number().optional(), submittedAt: z.number().optional(), startedAt: z.number().optional(), completedAt: z.number().optional(), cancelledAt: z.number().optional(), createdBy: z.string(), totalRecipients: z.number(), queuedCount: z.number(), sendingCount: z.number(), sentCount: z.number(), deliveredCount: z.number(), openedCount: z.number(), clickedCount: z.number(), bouncedCount: z.number(), complainedCount: z.number(), failedCount: z.number(), suppressedCount: z.number(), unsubscribedCount: z.number(), maxRecipients: z.number(), batchSize: z.number(), lastError: z.string().optional(), createdAt: z.number(), updatedAt: z.number(), }) .passthrough() .describe("The full campaign document with counters."); // -- gateway (Exa, images, Composio, enrichment) ---------------------------- const exaResultItem = z .object({ url: z.string(), title: z.string().optional(), publishedDate: z.string().optional(), author: z.string().optional(), score: z.number().optional(), text: z.string().optional(), summary: z.string().optional(), highlights: z.array(z.string()).optional(), image: z.string().optional(), favicon: z.string().optional(), id: z.string().optional(), entities: z .array(z.record(z.string(), z.unknown())) .optional() .describe( "Structured entity metadata returned by category=people or category=company.", ), }) .passthrough() .describe("A web search result."); const exaSearchResponse = z .object({ results: z.array(exaResultItem), resolvedSearchType: z.string().optional(), autopromptString: z.string().optional(), requestId: z.string().optional(), costDollars: z.record(z.string(), z.unknown()).optional(), }) .passthrough() .describe("A web search response."); const exaAnswerResponse = z .object({ answer: z.string(), citations: z.array(exaResultItem).optional(), costDollars: z.record(z.string(), z.unknown()).optional(), }) .passthrough() .describe( "An answer with citations. With stream: true the response is text/event-stream instead.", ); const imageEnvelope = z .object({ ok: z.boolean(), model: z.string(), endpoint: z.enum(["generations", "edits"]), prompt: z.string(), revisedPrompt: z.string().optional(), quality: z.string().optional(), requestedSize: z.string().optional(), format: z.string().optional(), mimeType: z.string().optional(), estimatedCostUsd: z.number().optional(), b64_json: z.string().describe("Base64-encoded image bytes."), storageId: z.string().optional(), path: z.string().describe("Workspace path where the image was saved."), signedUrl: z.string().nullable(), contentHash: z.string().optional(), sizeBytes: z.number().optional(), usage: z .object({ inputTokens: z.number().optional(), outputTokens: z.number().optional(), totalTokens: z.number().optional(), }) .passthrough() .optional(), }) .passthrough() .describe("Gateway image envelope wrapping the OpenAI image result."); const composioConnection = z .object({ id: z.string().nullable(), connectionId: z.string().nullable(), toolkit: z .object({ slug: z.string().nullable(), name: z.string().nullable(), }) .passthrough(), status: z.string().nullable().describe('e.g. "ACTIVE".'), createdAt: z.string().nullable(), updatedAt: z.string().nullable(), account: z .record(z.string(), z.unknown()) .describe("Normalized account identity (stableId, label, email, ...)."), reconnect: z.record(z.string(), z.unknown()).optional(), }) .passthrough() .describe("A connected app."); const composioConnectionsResponse = z .object({ items: z.array(composioConnection), connections: z .array(composioConnection) .describe("Alias of items for older clients."), raw: z .unknown() .describe( "Deprecated: the unprocessed provider response. Do not depend on it.", ), }) .passthrough(); const toolSearchResponse = z .object({ success: z.boolean().optional(), error: z.string().nullable().optional(), results: z .array( z .object({ use_case: z.string(), primary_tool_slugs: z.array(z.string()).optional(), related_tool_slugs: z.array(z.string()).optional(), toolkits: z.array(z.string()).optional(), execution_guidance: z.string().optional(), recommended_plan_steps: z.array(z.string()).optional(), known_pitfalls: z.array(z.string()).optional(), }) .passthrough(), ) .optional(), toolkit_connection_statuses: z .array( z .object({ toolkit: z.string(), has_active_connection: z.boolean(), status_message: z.string().nullable().optional(), accounts: z.array(z.record(z.string(), z.unknown())).optional(), }) .passthrough(), ) .optional(), tool_schemas: z .record(z.string(), z.record(z.string(), z.unknown())) .optional() .describe( "Tool slug -> schema with gateway-added execution_ref for tool.run.", ), session: z .object({ id: z.string() }) .passthrough() .optional() .describe("Tool-router session; reuse session.id for follow-up calls."), items: z.array(z.record(z.string(), z.unknown())).optional(), nativeAvailable: z.boolean().optional(), available: z.boolean().optional(), execution_ref_version: z.number().optional(), }) .passthrough() .describe("Tool search response with gateway enrichments."); const toolRunResponse = z .object({ data: z .unknown() .optional() .describe("Tool output (provider passthrough)."), error: z.unknown().nullable().optional(), log_id: z.string().optional(), execution_mode: z.string().optional(), tool_slug: z.string().optional(), tool_router_session_id: z.string().optional(), toolkit: z.string().optional(), account: z.string().optional(), }) .passthrough() .describe("Tool execution result with gateway metadata."); const enrichmentMetadata = z .object({ total: z .number() .optional() .describe( "Total matches for the search. Stable across pages; absent when only an estimate exists.", ), totalIsEstimate: z .boolean() .optional() .describe("True when the total is a lower bound rather than exact."), offset: z.number().optional(), nextPageToken: z .string() .optional() .describe( "Opaque token for the next page. Pass it back as pageToken; absent on the last page.", ), }) .passthrough() .optional(); const searchBillingResponse = z.object({ estimatedCents: z .number() .int() .nonnegative() .describe("Search cost ceiling authorized by Dench billing, in cents."), chargedCents: z .number() .int() .nonnegative() .describe( "Actual workspace search charge reported by Dench billing, in cents.", ), }); const enrichedPeopleResponse = z .object({ source: z.enum(["live", "cache"]), people: z .array( z .object({ fullName: z.string().optional(), firstName: z.string().optional(), lastName: z.string().optional(), headline: z.string().optional(), seniority: z.string().optional(), jobFunctions: z.array(z.string()).optional(), currentCompanyName: z.string().optional(), currentCompanyWebsite: z.string().optional(), linkedinID: z.string().optional(), emails: z.array(z.record(z.string(), z.unknown())).optional(), phones: z.array(z.record(z.string(), z.unknown())).optional(), }) .passthrough(), ) .describe("Search results normalized to the CRM upsert shape."), resolvedPersonIds: z.array(z.string()), creditsCharged: z.number(), billing: searchBillingResponse.optional(), metadata: enrichmentMetadata, }) .passthrough(); const enrichedCompaniesResponse = z .object({ source: z.enum(["live", "cache"]), companies: z .array( z .object({ name: z.string().optional(), website: z.string().optional(), description: z.string().optional(), founded: z.number().optional(), headcount: z.number().optional(), industry: z.string().optional(), linkedinUrl: z.string().optional(), linkedinID: z.string().optional(), }) .passthrough(), ) .describe("Search results normalized to the CRM upsert shape."), resolvedCompanyIds: z.array(z.string()), creditsCharged: z.number(), billing: searchBillingResponse.optional(), metadata: enrichmentMetadata, }) .passthrough(); // -- agent config ------------------------------------------------------------- const timedContent = z.object({ content: z.string(), updatedAt: z.number(), }); const agentConfigBlock = z.object({ identity: timedContent.nullable(), organisation: timedContent.nullable(), memoryAggregate: timedContent.nullable(), toolsNotes: timedContent.nullable(), bootstrap: z.object({ completed: z.boolean(), completedAt: z.number().optional(), template: z.string().optional(), }), }); const agentConfigPayload = z .object({ agentConfig: agentConfigBlock, userProfile: timedContent.nullable(), userTimezone: z .object({ value: z.string(), source: z.enum(["auto", "manual", "agent"]), updatedAt: z.number(), }) .nullable(), organizationId: z.string(), }) .passthrough() .describe( "Agent harness config payload shared by every /agent-config GET endpoint.", ); // --------------------------------------------------------------------------- // Gateway request shapes (re-declared without refine/transform) // --------------------------------------------------------------------------- const exaSearch = z.object({ query: z.string().describe("Search query."), type: z .enum(["auto", "neural", "fast", "deep", "deep-reasoning", "instant"]) .optional(), numResults: z.number().int().min(1).max(100).optional(), category: z .enum([ "company", "research paper", "news", "personal site", "financial report", "people", ]) .optional() .describe( "Entity categories return structured entities. Domain filters are unsupported for people; published-date filters are unsupported for people and company.", ), includeDomains: z .array(z.string()) .optional() .describe("Unsupported for category=people."), excludeDomains: z .array(z.string()) .optional() .describe("Unsupported for category=people."), startPublishedDate: z .string() .optional() .describe("Unsupported for category=people and category=company."), endPublishedDate: z .string() .optional() .describe("Unsupported for category=people and category=company."), includeText: z.array(z.string()).optional(), excludeText: z.array(z.string()).optional(), userLocation: z.string().optional(), moderation: z.boolean().optional(), }); const exaContents = z.object({ urls: z.array(z.string()).describe("URLs to fetch (max 10)."), text: z.boolean().optional(), summary: z .object({ query: z.string().optional() }) .optional() .describe("Optional per-URL summary."), }); const exaAnswer = z.object({ query: z.string().describe("Question to answer with web search."), text: z.boolean().optional(), stream: z.boolean().optional(), outputSchema: z.record(z.string(), z.unknown()).optional(), }); const imageGenerate = z.object({ prompt: z.string().min(1).max(32_000).describe("Image prompt."), size: z.string().optional().describe("e.g. 1024x1024, 1536x1024, auto."), quality: z.enum(["auto", "low", "medium", "high"]).optional(), format: z.enum(["png", "jpeg", "webp"]).optional(), output_compression: z.number().int().min(0).max(100).optional(), background: z.enum(["auto", "opaque", "transparent"]).optional(), savePath: z .string() .optional() .describe("Workspace path to write the image."), }); const imageEdit = imageGenerate.extend({ input_images_base64: z .array(z.string()) .min(1) .max(8) .describe("Base64-encoded source images (1-8)."), mask_base64: z.string().optional().describe("Optional base64 mask image."), }); const composioConnect = z .object({ toolkit: z .string() .min(1) .optional() .describe("Toolkit slug, e.g. github, gmail, stripe."), toolkit_slug: z.string().min(1).optional(), callback_url: z .string() .optional() .describe("Optional OAuth callback URL."), returnTo: z .string() .optional() .describe("Native OAuth return path within the workspace."), }) .refine((value) => Boolean(value.toolkit || value.toolkit_slug), { message: "A toolkit is required.", path: ["toolkit"], }); const composioToolSearch = z.object({ queries: z .array( z.object({ use_case: z.string().min(1).describe("Natural-language use case."), known_fields: z.string().optional(), }), ) .min(1) .max(7) .optional(), query: z.string().min(1).optional(), toolkit: z.string().min(1).optional(), toolkit_slug: z.string().min(1).optional(), limit: z.number().int().min(1).max(100).optional(), important: z.boolean().optional(), session: z .object({ id: z.string().optional(), generate_id: z.boolean().optional() }) .optional(), account: z.string().optional(), model: z.string().optional(), }); const composioToolRun = z.object({ execution_ref: z .string() .optional() .describe( "Execution ref from a prior search, OR pass session_id+tool_slug.", ), session_id: z.string().optional(), tool_slug: z .string() .optional() .describe("Tool slug, e.g. SLACK_LIST_CHANNELS."), arguments: z .record(z.string(), z.unknown()) .optional() .describe("Tool arguments object."), account: z.string().optional(), connected_account_id: z.string().optional(), approvalId: z.string().optional(), }); /** * One list-filter entry: a plain string includes the value; the object * form supports per-value `exclude` / `exactMatch`. */ const searchFilterItem = z.union( [ z.string(), // Strict: a misspelled control (`excluded`) must fail, not be stripped // into an include. z .object({ value: z.string(), exclude: z.boolean().optional(), exactMatch: z.boolean().optional(), }) .strict(), ], { error: (issue) => { // Name what was wrong with the object form ("Unrecognized key: // excluded"); the default union message is only "Invalid input". const branches = (issue as { errors?: { message: string }[][] }).errors; const detail = branches?.[1]?.map((entry) => entry.message).join("; "); return `Use a string or {"value": "…", "exclude": true, "exactMatch": true}${detail ? ` (${detail})` : ""}`; }, }, ); const FILTER_OPTIONS_NOTE = "Accepted values: see `dench leads filters` or https://dench.com/references/find-filter-options.json."; const searchRange = z .object({ min: z.number().int().nonnegative().optional(), max: z.number().int().nonnegative().optional(), }) // A range has no exclude: an extra key would silently turn into a // positive range. .strict() .refine( (range) => range.min === undefined || range.max === undefined || range.min <= range.max, { message: "Provide a range with min no greater than max." }, ); const peopleSearch = z.object({ linkedinUrl: z.string().optional(), fullName: z.string().optional(), firstName: z.string().optional(), lastName: z.string().optional(), keywords: z .string() .optional() .describe( `Current-title text only. For profile or work descriptions, use profileKeywords. ${FILTER_OPTIONS_NOTE}`, ), companyDomain: z.string().optional(), companyName: z.string().optional(), titles: z .array(searchFilterItem) .max(50) .optional() .describe(`Current job titles (free text). ${FILTER_OPTIONS_NOTE}`), pastTitles: z .array(searchFilterItem) .max(50) .optional() .describe("Past job titles (free text)."), personNames: z.array(searchFilterItem).max(20).optional(), pastSeniorities: z .array(searchFilterItem) .max(20) .optional() .describe( `Seniority in any past role, independent of pastCompanyNames/pastTitles. Current seniority vocabulary plus Founding Team. Founder includes Co-Founder; C-level and Founding Team do not imply founder. ${FILTER_OPTIONS_NOTE}`, ), profileKeywords: z .array(searchFilterItem) .max(50) .optional() .describe( "Text in headline/about or any current/past work description. OR includes, global exclusions. Always loose text matching; missing evidence does not match.", ), companyKeywords: z .array(searchFilterItem) .max(50) .optional() .describe( "Current employer business focus/model (e.g. SaaS, B2B, Cloud), from description/tagline/business tags; distinct from legal companyTypes. Same employer as other current-job filters. Always loose text matching. May need another filter to retrieve candidates.", ), seniorities: z .array(searchFilterItem) .max(20) .optional() .describe( "Exact enum: Owner, Founder, C-level, Partner, VP, Head, Director, Manager, Senior.", ), jobFunctions: z .array(searchFilterItem) .max(20) .optional() .describe( `Exact enum, e.g. Sales, Marketing, Software. ${FILTER_OPTIONS_NOTE}`, ), subFunctions: z .array(searchFilterItem) .max(50) .optional() .describe( `Exact enum, e.g. SDR/BDR, Demand Generation. ${FILTER_OPTIONS_NOTE}`, ), industries: z .array(searchFilterItem) .max(50) .optional() .describe(`Exact enum, e.g. Software Development. ${FILTER_OPTIONS_NOTE}`), locations: z .array(searchFilterItem) .max(50) .optional() .describe( `Person locations — cities, states, countries, or metros (free text). ${FILTER_OPTIONS_NOTE}`, ), headcountRanges: z .array(searchFilterItem) .max(20) .optional() .describe( "Exact buckets: 1-10, 11-50, 51-200, 201-500, 501-1000, 1001-5000, 5001-10000, 10001+.", ), companyNames: z.array(searchFilterItem).max(50).optional(), pastCompanyNames: z.array(searchFilterItem).max(50).optional(), companyDomains: z.array(searchFilterItem).max(50).optional(), companyHeadquarters: z .array(searchFilterItem) .max(50) .optional() .describe( `Company HQ locations — cities, states, countries, or metros (free text). ${FILTER_OPTIONS_NOTE}`, ), companyTypes: z .array(searchFilterItem) .max(10) .optional() .describe( "Exact enum: Partnership, Nonprofit, Educational, Privately Held, Public Company, Self-Owned, Self-Employed, Government Agency.", ), universities: z .array(searchFilterItem) .max(50) .optional() .describe(`Universities attended (free text). ${FILTER_OPTIONS_NOTE}`), skills: z .array(searchFilterItem) .max(50) .optional() .describe(`Profile skills (free text). ${FILTER_OPTIONS_NOTE}`), languages: z .array(searchFilterItem) .max(20) .optional() .describe(`Spoken languages, e.g. English, German. ${FILTER_OPTIONS_NOTE}`), yearsInRole: searchRange.optional(), yearsAtCompany: searchRange.optional(), daysSinceJobChange: searchRange.optional(), foundedYears: searchRange .optional() .describe("Company founded-year range, e.g. {min: 2015, max: 2024}."), exactMatch: z .boolean() .optional() .describe( "Match free-text filters as whole strings. Default false: titles " + "['Founder'] also returns 'Co-Founder' and 'Founder & CEO'. Set " + "true to narrow. Domains, LinkedIn URLs, enum filters and numeric " + "ranges are always exact; keywords is always a loose text probe.", ), limit: z.number().int().positive().max(5000).optional(), offset: z.number().int().nonnegative().optional(), pageToken: z .string() .optional() .describe( "Opaque token from a previous response's metadata.nextPageToken.", ), preferCache: z.boolean().optional(), }); export const campaignAudienceFiltersSchema = peopleSearch .omit({ limit: true, offset: true, pageToken: true, }) .strict(); const campaignSignal = z.enum([ "post_engagement", "keyword", "hiring", "funding", "launch", "new_leader", "voice_engagement", "company_engagement", "brand_engagement", "lookalike", ]); /** Signals that take terms: topics, roles, or LinkedIn profile/page URLs. */ const campaignTermSignal = z.enum([ "post_engagement", "keyword", "hiring", "voice_engagement", "company_engagement", "brand_engagement", ]); const campaignTargeting = z.object({ titles: z.array(z.string()), locations: z.array(z.string()), companySizes: z.array(z.string()), industries: z.array(z.string()), excludes: z.array(z.string()), companies: z.array(z.string()).optional(), companyTypes: z.array(z.string()).optional(), }); /** * Targeting a request sends. Strict: a key the builder can't represent * (seniorities, companyHeadquarters, companyDomains…) is refused, not * dropped, since dropping it would quietly widen the audience. */ const campaignTargetingInput = campaignTargeting.strict(); const campaignSignalTerms = z .object({ post_engagement: z .array(z.string()) .optional() .describe( 'Post topics, phrased as what the buyer does ("using AI for outbound"), not bare product names.', ), keyword: z.array(z.string()).optional(), hiring: z.array(z.string()).optional().describe("Roles being hired for."), voice_engagement: z .array(z.string()) .optional() .describe("LinkedIn profile URLs of market voices whose posts to watch."), company_engagement: z .array(z.string()) .optional() .describe("LinkedIn company page URLs of companies and competitors."), brand_engagement: z .array(z.string()) .optional() .describe("The sender's own LinkedIn company page URL."), exact: z .array(z.enum(["post_engagement", "keyword"])) .optional() .describe( "Topic signals searched by their exact keywords only (no drafted phrases).", ), }) // A term list under a signal it doesn't recognize is refused, not dropped. .strict(); const campaignDraftSource = z .enum(["signals", "lookalike", "existing", "crm", "search"]) .describe( "How people come in: buying signals, people like one best lead, a pasted list or CSV, CRM records, or a people search.", ); const campaignDraftScreen = z.enum([ "source", "signals", "lookalike", "target", "messages", "outreach", "review", ]); const campaignLookalike = z.object({ seed: z.object({ name: z.string(), linkedinUrl: z.string(), title: z.string().optional(), companyName: z.string().optional(), companyDomain: z.string().optional(), avatarUrl: z.string().optional(), }), title: z.string(), similarRoles: z.boolean(), roles: z.array(z.string()), location: z.string().optional(), industry: z.string().optional(), relatedIndustries: z.boolean(), industries: z.array(z.string()), companySizes: z.array(z.string()), companies: z.array(z.object({ name: z.string(), domain: z.string() })), }); const campaignOutreachBrief = z.object({ goal: z.string().describe("What a reply should lead to, e.g. a demo."), helpWith: z.string().describe("The problem the product solves for them."), tone: z.enum(["conversational", "warm", "direct", "formal"]), channels: z.enum(["both", "email", "linkedin"]), }); // Strict, with the bounds saving enforces: a field or value it can't keep // (an HTML body, a 13th step, a negative wait) is refused, not dropped. const campaignDraftStep = z .object({ type: z.enum(["email", "wait", "linkedin_connection", "linkedin_message"]), waitHours: z .number() .min(1) .max(24 * 60) .optional() .describe("Hours to wait, 1 to 1440. Required on a wait step."), emailSubject: z.string().max(200).optional(), emailTextBody: z .string() .max(5000) .optional() .describe("Plain text only; draft steps keep no HTML body."), emailReplyToPrevious: z.boolean().optional(), onlyIf: z.enum(["always", "connected", "not_connected"]).optional(), linkedinMessageBody: z.string().max(5000).optional(), linkedinMessageMode: z .enum(["skip_if_not_connected", "wait_until_connected"]) .optional(), connectionTimeoutHours: z .number() .min(1) .max(24 * 60) .optional(), }) .strict() .refine((step) => step.type !== "wait" || step.waitHours !== undefined, { message: "A wait step needs waitHours (1 to 1440).", path: ["waitHours"], }); const campaignDraftSending = z.object({ emailMailboxAccountIds: z.array(z.string()), linkedinAccountIds: z.array(z.string()), timezone: z.string(), startHour: z.number().int().min(0).max(24), endHour: z.number().int().min(0).max(24), weekdays: z .array(z.number().int().min(0).max(6)) .describe("0 is Sunday, 6 is Saturday."), dailySendCap: z.number().int().min(1).max(100).optional(), }); const campaignApprovalMode = z .enum(["auto", "manual"]) .describe( "auto: new people a signal finds start the sequence on their own. manual: they wait for approval.", ); const campaignClientRequestId = z .string() .trim() .min(8) .max(64) .describe( "Your stable id for this request, 8-64 characters. Retrying with the same id returns the same draft instead of creating another.", ); const campaignRecipient = z .object({ email: z.string().optional(), linkedinProfileUrl: z.string().optional(), displayName: z.string().optional(), personalization: z .record(z.string(), z.unknown()) .optional() .describe( 'Merge values keyed by name, e.g. {"Company":"Acme"} for {{Company}}.', ), }) .strict() .describe( "One person. Needs email and/or linkedinProfileUrl; unknown keys are rejected, not dropped.", ); const campaignBatchResult = z.object({ added: z.number(), requested: z.number(), cappedAtLimit: z.number().nullable(), duplicates: z.number(), duplicatesElsewhere: z.number(), suppressed: z.number(), invalid: z.number(), skippedNoIdentity: z.number(), skippedMissingInfo: z.number(), missingInfoSample: z.unknown().optional(), }); const campaignSearchPage = z.object({ rows: z.array(z.record(z.string(), z.unknown())), people: z.array(z.unknown()), recipients: z.array(campaignRecipient), metadata: z.record(z.string(), z.unknown()), estimatedCents: z .number() .nullable() .describe( "The search cost ceiling reported by Dench billing, in cents. Null means the receipt is unavailable.", ), chargedCents: z .number() .nullable() .describe( "The actual search charge reported by Dench billing, in cents. Null means the receipt is unavailable; results remain valid.", ), }); const companySearch = z.object({ domain: z.string().optional(), name: z.string().optional(), linkedinUrl: z.string().optional(), keywords: z.string().optional(), industries: z .array(z.string()) .max(50) .optional() .describe(`Exact enum, e.g. Software Development. ${FILTER_OPTIONS_NOTE}`), locations: z .array(z.string()) .max(50) .optional() .describe( `Cities, states, countries, or metros (free text). ${FILTER_OPTIONS_NOTE}`, ), headcountRanges: z .array(z.string()) .max(20) .optional() .describe( "Exact buckets: 1-10, 11-50, 51-200, 201-500, 501-1000, 1001-5000, 5001-10000, 10001+.", ), exactMatch: z .boolean() .optional() .describe( "Match free-text filters (name, locations, keywords) as whole " + "strings. Default false. Domain, linkedinUrl and enum filters are " + "always exact.", ), limit: z.number().int().positive().max(5000).optional(), offset: z.number().int().nonnegative().optional(), pageToken: z .string() .optional() .describe( "Opaque token from a previous response's metadata.nextPageToken.", ), preferCache: z.boolean().optional(), }); const jobsSearch = z.object({ titles: z .array(z.string()) .max(50) .optional() .describe(`Job titles (free text). ${FILTER_OPTIONS_NOTE}`), locations: z .array(z.string()) .max(50) .optional() .describe( `Cities, states, countries, or metros (free text). ${FILTER_OPTIONS_NOTE}`, ), companyDomain: z.string().optional(), companyName: z.string().optional(), companyLinkedinUrl: z.string().optional(), keywords: z.string().optional(), postedAfter: z.string().optional(), postedBefore: z .string() .optional() .describe( "Inclusive posting end date (YYYY-MM-DD); includes the whole UTC day.", ), limit: z.number().int().positive().max(5000).optional(), pageToken: z .string() .optional() .describe( "Opaque token from a previous response's metadata.nextPageToken.", ), preferCache: z.boolean().optional(), }); const jobsSearchResponse = z .object({ source: z.enum(["live", "cache"]), jobs: z .array( z .object({ title: z.string().optional(), company: z.string().optional(), location: z.string().optional(), url: z.string().optional(), workplaceType: z.string().optional(), postedAt: z.string().optional(), }) .passthrough(), ) .describe("Job postings in the canonical job_posting shape."), metadata: enrichmentMetadata, }) .passthrough(); const companyRelatedKind = z.enum([ "employees", "founders", "funding", "investors", "acquisitions", "investments", ]); const profileLookup = z .union([ z.object({ linkedinUrl: z.string().url() }).strict(), z.object({ email: z.string().email() }).strict(), z.object({ domain: z.string().trim().min(1) }).strict(), ]) .describe( "Exactly one identifier per lookup. People: linkedinUrl or email. Companies: domain or linkedinUrl. The gateway validates the identifier against kind.", ); const enrichmentCharge = z.object({ charged_cents: z.number().int().nonnegative(), }); // --------------------------------------------------------------------------- // Request schemas keyed by operation id // --------------------------------------------------------------------------- /** * Strict no-input request schema. Used for operations whose backend takes * no caller-supplied fields (auth args are injected by the dispatcher). * Strict parsing drops stray query params / body keys so Convex argument * validators never see unexpected args. */ const noInput = z.object({}); // -- crm analytics ----------------------------------------------------------- // Mirrors `convex/lib/crm/analyticsConfig.ts` (the validators the mutations // enforce) and the `computePanels` result shape. A panel names a metric, an // optional group-by, an optional date bucketing and optional filters — no // SQL. Synthetic `$createdAt` (row creation time) and `$location` (the // `location` role, resolved through one relation hop) may be used anywhere a // field name is accepted. const analyticsPeriod = z .enum([ "all", "this_week", "this_month", "last_month", "this_quarter", "last_quarter", "this_year", ]) .describe( 'Period preset. Scopes every panel by a date_preset on the object\'s closeDate role (else $createdAt) and fills previousValue from the previous calendar interval. "all" = no scope.', ); const roleDoc = { amount: 'Numeric field holding the deal value ("Amount") — the "Value field" in the setup sheet.', stage: 'Enum field driving the pipeline ("Stage" / "Status") — the "Progress field".', wonValues: "`stage` options that count as won (closed, successful).", lostValues: "`stage` options that count as lost (closed, gone).", convertedValues: '`stage` options that count as converted (a lead reached "Qualified").', closeDate: "Date field for time bucketing, forecasts and the period pill (falls back to $createdAt).", owner: 'User field naming who owns each record ("Owner"). Rankings on it default to a narrow card and draw member faces.', location: 'Place-name field maps plot. May be a relation path like "Account.HQ Location" (one hop through a relation).', recurrence: "How a won amount recurs: annual treats Amount as ARR, monthly as MRR, one_time disables MRR/ARR panels.", stageProbabilities: "Chance of closing per OPEN stage, 0..1, for the weighted forecast. Unlisted stages are spaced evenly; won = 1, lost = 0.", }; const recurrence = z.enum(["monthly", "annual", "one_time"]); const analyticsFieldRoles = z .object({ amount: z.string().optional().describe(roleDoc.amount), stage: z.string().optional().describe(roleDoc.stage), wonValues: z.array(z.string()).optional().describe(roleDoc.wonValues), lostValues: z.array(z.string()).optional().describe(roleDoc.lostValues), convertedValues: z .array(z.string()) .optional() .describe(roleDoc.convertedValues), closeDate: z.string().optional().describe(roleDoc.closeDate), owner: z.string().optional().describe(roleDoc.owner), location: z.string().optional().describe(roleDoc.location), recurrence: recurrence.optional().describe(roleDoc.recurrence), stageProbabilities: z .record(z.string(), z.number()) .optional() .describe(roleDoc.stageProbabilities), }) .describe( "The object's analytics setup, shared by every chart and every view: the semantic field roles derived metrics (win rate, won $, open pipeline, forecast, MRR/ARR), maps and card defaults read.", ); const analyticsFieldRolesPatch = z .object({ amount: z.string().nullable().optional().describe(roleDoc.amount), stage: z.string().nullable().optional().describe(roleDoc.stage), wonValues: z .array(z.string()) .nullable() .optional() .describe(roleDoc.wonValues), lostValues: z .array(z.string()) .nullable() .optional() .describe(roleDoc.lostValues), convertedValues: z .array(z.string()) .nullable() .optional() .describe(roleDoc.convertedValues), closeDate: z.string().nullable().optional().describe(roleDoc.closeDate), owner: z.string().nullable().optional().describe(roleDoc.owner), location: z.string().nullable().optional().describe(roleDoc.location), recurrence: recurrence.nullable().optional().describe(roleDoc.recurrence), stageProbabilities: z .record(z.string(), z.number()) .nullable() .optional() .describe(roleDoc.stageProbabilities), }) .describe( "Field roles as an edit: the same keys; null clears one. Applied onto the stored mapping with merge: true, otherwise replaces it.", ); const analyticsPanelType = z.enum([ "stat", "bar", "line", "area", "donut", "funnel", "leaderboard", "forecast", "map", ]); const analyticsPanel = z .object({ id: z .string() .describe("Stable id (template panels use `tpl: