/** * The curated `window.dench` surface — the ONE place that says which * methods the App SDK exposes, what each needs, and what it wraps. * * Three consumers read this file (via `scripts/generate-app-sdk.ts`): * * • `cli/lib/app-sdk-catalog.generated.ts` — method → permission map * shared by the browser host (`src/lib/workspace/app-permissions.ts`) * and `dench apps validate`. * • `cli/app-sdk.d.ts` — the `Dench` type shipped with `@dench.com/cli`. * • `public/references/APPS.md` — the generated method/permission index. * * Two kinds of method: * * • `op` — a thin alias over a registry operation (`cli/lib/ * command-registry.ts`). The host forwards it through * `POST /api/apps/sdk` exactly like `dench.api.call(op, args)`; * only the argument shape differs (positional sugar). Its * permission is derived from the operation unless overridden. * • `host` — implemented by the iframe host itself (UI, store, events, * clipboard, streaming chat, geo). Never reachable via * `dench.api`. * * The bridge script in `src/lib/workspace/app-bridge.ts` is still written * by hand (positional sugar cannot be generated), and * `app-bridge-parity.test.ts` asserts it matches this file exactly. */ export const APP_SDK_VERSION = "2.0.0"; export type AppSdkMethodSpec = { /** Permission scope, or `null` for none. Omit on `op` methods to derive. */ permission?: string | null; /** Registry operation id this alias wraps. */ op?: string; /** Host-only method (no registry operation behind it). */ host?: true; /** * SDK-side sugar that computes the operation id at call time and goes * through `api.call` (so the host only ever sees the operation). The * permission listed is the family it reaches. */ viaApi?: true; /** One-line description (d.ts JSDoc + APPS.md index). */ doc: string; /** TypeScript parameter list for the d.ts. */ args: string; /** TypeScript return type for the d.ts (default `Promise`). */ returns?: string; /** Deprecation note; the method still works for one major version. */ deprecated?: string; }; export type AppSdkSurface = Record>; const R = (op: string) => `Promise>`; const A = (op: string) => `Req<${JSON.stringify(op)}>`; const Rest = (op: string, ...omit: string[]) => `Omit, ${omit.map((k) => JSON.stringify(k)).join(" | ")}>`; export const APP_SDK_SURFACE: AppSdkSurface = { app: { getManifest: { host: true, permission: null, doc: "The parsed .dench.yaml of this app.", args: "", returns: "Promise", }, getTheme: { host: true, permission: null, doc: 'Current workspace theme ("light" | "dark").', args: "", returns: 'Promise<"light" | "dark">', }, }, context: { getWorkspace: { host: true, permission: null, doc: "Workspace name/slug the app is running in.", args: "", returns: "Promise<{ name: string; slug: string | null }>", }, getAppInfo: { host: true, permission: null, doc: "App path, folder slug, granted permissions and manifest.", args: "", returns: "Promise<{ appPath: string; folderName: string; permissions: string[]; manifest: AppManifest }>", }, }, api: { call: { host: true, permission: null, doc: 'Call ANY public /api/v1 operation by id (e.g. "crm.query"). Permission is the operation\'s own scope.', args: "operationId: K, args?: Req", returns: "Promise>", }, operations: { host: true, permission: null, doc: "Runtime catalog of every callable operation: id, cli, method, path, permission.", args: "opts?: { group?: string }", returns: "Promise", }, }, objects: { list: { host: true, permission: "crm:read", doc: "Paged rows of an object (flat `id` + field-on-row shape).", args: "name: string, opts?: ObjectsListOptions", returns: "Promise", }, query: { host: true, permission: "crm:read", doc: "Filter/sort/select rows with the query DSL (max 1000). Aggregate with analytics.compute instead.", args: "dsl: ObjectsQuery", returns: "Promise", }, get: { host: true, permission: "crm:read", doc: "One entry by id.", args: "name: string, entryId: string", returns: "Promise", }, create: { host: true, permission: "crm:write", doc: "Create an entry.", args: "name: string, fields: Record", returns: "Promise", }, update: { host: true, permission: "crm:write", doc: "Patch an entry's fields.", args: "name: string, entryId: string, fields: Record", returns: "Promise<{ ok: boolean }>", }, delete: { host: true, permission: "crm:write", doc: "Delete one entry.", args: "name: string, entryId: string", returns: "Promise<{ ok: boolean }>", }, bulkDelete: { host: true, permission: "crm:write", doc: "Delete many entries.", args: "name: string, entryIds: string[]", returns: "Promise<{ ok: boolean; deleted?: number }>", }, getSchema: { host: true, permission: "crm:read", doc: "Object definition: fields, types, enum options, relations.", args: "name: string", returns: "Promise", }, getOptions: { host: true, permission: "crm:read", doc: "Relation picker options (id + label) for an object, optionally filtered.", args: "name: string, query?: string", returns: "Promise>", }, }, analytics: { get: { op: "crm.analytics.get", doc: "Stored dashboard: panels, field roles, collapsed flag (+ the view's own dashboard when viewName is set).", args: `objectName: string, opts?: ${Rest("crm.analytics.get", "objectName")}`, returns: R("crm.analytics.get"), }, compute: { op: "crm.analytics.compute", doc: "Pre-bucketed KPIs/charts/maps — the strip's own math. Stored dashboard, `panelIds`, or ad-hoc `panels`; `period` and `drillFilters` expand server-side.", args: `objectName: string, opts?: ${Rest("crm.analytics.compute", "objectName")}`, returns: R("crm.analytics.compute"), }, save: { op: "crm.analytics.save", doc: "Create or update a stored panel (object dashboard, or the view's when viewName is set — first save forks an inheriting view).", args: `objectName: string, panelId: string, panel: Req<"crm.analytics.save">["panel"], opts?: ${Rest("crm.analytics.save", "objectName", "panelId", "panel")}`, returns: R("crm.analytics.save"), }, update: { op: "crm.analytics.update", doc: "Edit part of a stored panel (title, type, metric, groupBy, dateField, filters, derived, span, hidden, sortOrder …); null clears an optional key.", args: `objectName: string, panelId: string, patch: Req<"crm.analytics.update">["patch"], opts?: ${Rest("crm.analytics.update", "objectName", "panelId", "patch")}`, returns: R("crm.analytics.update"), }, hide: { op: "crm.analytics.hide", doc: "Take a chart off the strip above the table; it stays in the full analytics view.", args: `objectName: string, panelId: string, opts?: ${Rest("crm.analytics.hide", "objectName", "panelId")}`, returns: R("crm.analytics.hide"), }, show: { op: "crm.analytics.show", doc: "Bring a hidden chart back onto the strip.", args: `objectName: string, panelId: string, opts?: ${Rest("crm.analytics.show", "objectName", "panelId")}`, returns: R("crm.analytics.show"), }, resize: { op: "crm.analytics.resize", doc: "Set a card's width: span 2 (third) | 3 (half) | 4 (two thirds) | 6 (full) | null (default for the chart kind).", args: `objectName: string, panelId: string, span: Req<"crm.analytics.resize">["span"], opts?: ${Rest("crm.analytics.resize", "objectName", "panelId", "span")}`, returns: R("crm.analytics.resize"), }, delete: { op: "crm.analytics.delete", doc: "Delete a stored panel.", args: `objectName: string, panelId: string, opts?: ${Rest("crm.analytics.delete", "objectName", "panelId")}`, returns: R("crm.analytics.delete"), }, reorder: { op: "crm.analytics.reorder", doc: "Persist the full panel order.", args: `objectName: string, panelIds: string[], opts?: ${Rest("crm.analytics.reorder", "objectName", "panelIds")}`, returns: R("crm.analytics.reorder"), }, roles: { op: "crm.analytics.roles", doc: "Set the object's analytics setup shared by every chart and view: amount / stage / won-lost-converted / stageProbabilities / closeDate / recurrence / owner / location. Pass merge: true to change only the keys given (null clears one).", args: `objectName: string, fieldRoles: Req<"crm.analytics.roles">["fieldRoles"], opts?: ${Rest("crm.analytics.roles", "objectName", "fieldRoles")}`, returns: R("crm.analytics.roles"), }, collapse: { op: "crm.analytics.collapse", doc: "Collapse the team-shared strip for this object.", args: "objectName: string", returns: R("crm.analytics.collapse"), }, expand: { op: "crm.analytics.expand", doc: "Expand the team-shared strip for this object.", args: "objectName: string", returns: R("crm.analytics.expand"), }, scope: { op: "crm.analytics.scope", doc: 'Flip a saved view between its "own" dashboard and the object\'s "shared" one.', args: `objectName: string, viewName: string, mode: Req<"crm.analytics.scope">["mode"]`, returns: R("crm.analytics.scope"), }, }, comments: { list: { op: "crm.comments.list", doc: "Root comments on an entry (with reply previews), paged.", args: `objectName: string, entryId: string, opts?: ${Rest("crm.comments.list", "objectName", "entryId")}`, returns: R("crm.comments.list"), }, replies: { op: "crm.comments.replies", doc: "Replies under a root comment.", args: `commentId: string, opts?: ${Rest("crm.comments.replies", "commentId")}`, returns: R("crm.comments.replies"), }, get: { op: "crm.comments.get", doc: "One comment.", args: "commentId: string", returns: R("crm.comments.get"), }, create: { op: "crm.comments.create", doc: "Post a markdown comment (mention members with [@Name](/?member=), Dench with [@Dench](/?member=dench)).", args: `objectName: string, entryId: string, body: string, opts?: ${Rest("crm.comments.create", "objectName", "entryId", "body")}`, returns: R("crm.comments.create"), }, update: { op: "crm.comments.update", doc: "Edit a comment body.", args: "commentId: string, body: string", returns: R("crm.comments.update"), }, delete: { op: "crm.comments.delete", doc: "Delete a comment.", args: "commentId: string", returns: R("crm.comments.delete"), }, react: { op: "crm.comments.react", doc: "Toggle an emoji reaction.", args: "commentId: string, emoji: string", returns: R("crm.comments.react"), }, }, watch: { get: { op: "crm.watch.get", doc: "Am I following this entry?", args: "objectName: string, entryId: string", returns: R("crm.watch.get"), }, set: { op: "crm.watch.set", doc: "Follow (default) or unfollow an entry.", args: "objectName: string, entryId: string, watching?: boolean", returns: R("crm.watch.set"), }, }, views: { list: { op: "crm.views.list", doc: "Saved views of an object.", args: "objectName: string", returns: R("crm.views.list"), }, get: { op: "crm.views.get", doc: "One saved view (filters, sort, columns, analytics).", args: "objectName: string, viewName: string", returns: R("crm.views.get"), }, entries: { op: "crm.views.entries", doc: "Rows as the saved view shows them.", args: `objectName: string, viewName: string, opts?: ${Rest("crm.views.entries", "objectName", "viewName")}`, returns: R("crm.views.entries"), }, create: { op: "crm.views.create", doc: "Create a saved view.", args: `objectName: string, view: ${Rest("crm.views.create", "objectName")}`, returns: R("crm.views.create"), }, update: { op: "crm.views.update", doc: "Update a saved view.", args: `objectName: string, viewName: string, patch: ${Rest("crm.views.update", "objectName", "viewName")}`, returns: R("crm.views.update"), }, delete: { op: "crm.views.delete", doc: "Delete a saved view.", args: "objectName: string, viewName: string", returns: R("crm.views.delete"), }, }, tasks: { mine: { op: "crm.tasks.mine", doc: "Open tasks assigned to me.", args: `opts?: ${A("crm.tasks.mine")}`, returns: R("crm.tasks.mine"), }, forEntry: { op: "crm.tasks.forEntry", doc: "Tasks linked to an entry.", args: "objectName: string, entryId: string", returns: R("crm.tasks.forEntry"), }, create: { op: "crm.tasks.createForEntry", doc: "Create a task on an entry.", args: `objectName: string, entryId: string, task: ${Rest("crm.tasks.createForEntry", "objectName", "entryId")}`, returns: R("crm.tasks.createForEntry"), }, complete: { op: "crm.tasks.complete", doc: "Mark a task done.", args: "entryId: string", returns: R("crm.tasks.complete"), }, reopen: { op: "crm.tasks.reopen", doc: "Reopen a task.", args: "entryId: string", returns: R("crm.tasks.reopen"), }, setPriority: { op: "crm.tasks.setPriority", doc: "Set a task's priority (one of the workspace's options) or clear it with null.", args: "entryId: string, priority: string | null", returns: R("crm.tasks.setPriority"), }, subtasks: { op: "crm.tasks.subtasks.list", doc: "A task's subtasks.", args: "entryId: string", returns: R("crm.tasks.subtasks.list"), }, createSubtask: { op: "crm.tasks.subtasks.create", doc: "Create a subtask under a task (one level deep; inherits the parent's record relations).", args: `entryId: string, subtask: ${Rest("crm.tasks.subtasks.create", "entryId")}`, returns: R("crm.tasks.subtasks.create"), }, setParent: { op: "crm.tasks.setParent", doc: "Nest a task under a parent, or promote it with null.", args: "entryId: string, parentEntryId: string | null", returns: R("crm.tasks.setParent"), }, delete: { op: "crm.tasks.delete", doc: 'Delete a task; its subtasks go with it unless subtasks is "keep".', args: 'entryId: string, subtasks?: "delete" | "keep"', returns: R("crm.tasks.delete"), }, getRepeat: { op: "crm.tasks.repeat.get", doc: "How a task repeats (rule, RRULE, trigger, ends, next due), or null.", args: "entryId: string", returns: R("crm.tasks.repeat.get"), }, setRepeat: { op: "crm.tasks.repeat.set", doc: "Make a task repeat or change how it repeats (JSON rule or RRULE). An undated task gets its due date anchored on the first matching day from today.", args: `entryId: string, repeat: ${Rest("crm.tasks.repeat.set", "entryId")}`, returns: R("crm.tasks.repeat.set"), }, clearRepeat: { op: "crm.tasks.repeat.clear", doc: "Stop a task repeating.", args: "entryId: string", returns: R("crm.tasks.repeat.clear"), }, skipRepeat: { op: "crm.tasks.repeat.skip", doc: "Skip the current occurrence; the next one is created now.", args: "entryId: string", returns: R("crm.tasks.repeat.skip"), }, pauseRepeat: { op: "crm.tasks.repeat.pause", doc: "Pause a repeating task.", args: "entryId: string", returns: R("crm.tasks.repeat.pause"), }, resumeRepeat: { op: "crm.tasks.repeat.resume", doc: "Resume a paused repeating task.", args: "entryId: string", returns: R("crm.tasks.repeat.resume"), }, series: { op: "crm.tasks.series.list", doc: "Every repeating-task series in the workspace.", args: `opts?: ${A("crm.tasks.series.list")}`, returns: R("crm.tasks.series.list"), }, seriesOccurrences: { op: "crm.tasks.series.occurrences", doc: "Every occurrence of one series.", args: "seriesId: string", returns: R("crm.tasks.series.occurrences"), }, }, notifications: { list: { op: "notifications.list", doc: "My notifications (inbox | unread | archived | all | mentions).", args: `opts?: ${A("notifications.list")}`, returns: R("notifications.list"), }, unreadCount: { op: "notifications.unreadCount", doc: "Unread badge count.", args: "", returns: R("notifications.unreadCount"), }, read: { op: "notifications.read", doc: "Mark one read (or unread).", args: "notificationId: string, read?: boolean", returns: R("notifications.read"), }, readAll: { op: "notifications.readAll", doc: "Mark everything read.", args: "", returns: R("notifications.readAll"), }, archive: { op: "notifications.archive", doc: "Archive one notification.", args: "notificationId: string", returns: R("notifications.archive"), }, getPreferences: { op: "notifications.preferences.get", doc: "My notification preferences.", args: "", returns: R("notifications.preferences.get"), }, updatePreferences: { op: "notifications.preferences.update", doc: "Update my notification preferences.", args: `patch: ${A("notifications.preferences.update")}`, returns: R("notifications.preferences.update"), }, }, members: { get: { op: "members.get", doc: "A member's profile.", args: "userId: string", returns: R("members.get"), }, presence: { op: "members.presence", doc: "Who is online / away / offline.", args: "", returns: R("members.presence"), }, activity: { op: "members.activity", doc: "A member's recent CRM activity.", args: `userId: string, opts?: ${Rest("members.activity", "userId")}`, returns: R("members.activity"), }, tasks: { op: "members.tasks", doc: "A member's open tasks.", args: `userId: string, opts?: ${Rest("members.tasks", "userId")}`, returns: R("members.tasks"), }, me: { op: "me.profile.get", doc: "My own profile.", args: "", returns: R("me.profile.get"), }, updateMe: { op: "me.profile.update", doc: "Update my name / tagline / timezone.", args: `patch: ${A("me.profile.update")}`, returns: R("me.profile.update"), }, }, meetings: { list: { op: "meetings.list", doc: "Meetings (CRM `meeting` rows), newest first.", args: `opts?: ${A("meetings.list")}`, returns: R("meetings.list"), }, transcript: { op: "meetings.transcript.list", doc: "Transcript segments of a meeting.", args: `meetingEntryId: string, opts?: ${Rest("meetings.transcript.list", "meetingEntryId")}`, returns: R("meetings.transcript.list"), }, actionItems: { op: "meetings.actionItems.list", doc: "Action items of a meeting.", args: "meetingEntryId: string", returns: R("meetings.actionItems.list"), }, addActionItems: { op: "meetings.actionItems.add", doc: "Add action items to a meeting.", args: `meetingEntryId: string, items: Req<"meetings.actionItems.add">["items"]`, returns: R("meetings.actionItems.add"), }, setActionItemDone: { op: "meetings.actionItems.setDone", doc: "Check or uncheck an action item.", args: "itemId: string, done: boolean", returns: R("meetings.actionItems.setDone"), }, participants: { op: "meetings.participants.list", doc: "Participants of a meeting.", args: "meetingEntryId: string", returns: R("meetings.participants.list"), }, sharing: { op: "meetings.sharing.get", doc: "Whether a meeting is shared with the whole workspace.", args: "meetingEntryId: string", returns: R("meetings.sharing.get"), }, setSharing: { op: "meetings.sharing.set", doc: "Share a meeting with the workspace, or take it back to owner + invitees.", args: "meetingEntryId: string, shared: boolean", returns: R("meetings.sharing.set"), }, }, lists: { list: { op: "lists.list", doc: "Saved lead lists.", args: `opts?: ${A("lists.list")}`, returns: R("lists.list"), }, get: { op: "lists.get", doc: "One saved list with its rows.", args: "artifactId: string", returns: R("lists.get"), }, rename: { op: "lists.rename", doc: "Rename a list.", args: "artifactId: string, title: string", returns: R("lists.rename"), }, delete: { op: "lists.delete", doc: "Delete a list.", args: "artifactId: string", returns: R("lists.delete"), }, }, campaigns: { create: { op: "campaigns.create", doc: "Create a draft campaign.", args: `opts: ${A("campaigns.create")}`, returns: R("campaigns.create"), }, draft: { op: "campaigns.drafts.create", doc: "Create an idempotent draft with available sender pools.", args: `opts: ${A("campaigns.drafts.create")}`, returns: R("campaigns.drafts.create"), }, search: { op: "campaigns.audience.search", doc: "Search people without enrolling them. Search credits apply.", args: `opts: ${A("campaigns.audience.search")}`, returns: R("campaigns.audience.search"), }, addPeople: { op: "campaigns.people.addBatch", doc: "Add up to 50 selected recipients without running another search.", args: `opts: ${A("campaigns.people.addBatch")}`, returns: R("campaigns.people.addBatch"), }, addFromSearch: { op: "campaigns.people.addFromSearch", doc: "Search and add up to 50 people to an existing campaign.", args: `opts: ${A("campaigns.people.addFromSearch")}`, returns: R("campaigns.people.addFromSearch"), }, sources: { op: "campaigns.sources.get", doc: "Read the campaign's lead source.", args: "sequenceId: string", returns: R("campaigns.sources.get"), }, sourceSignals: { op: "campaigns.sources.signals", doc: "Read signal choices and suggestions.", args: "sequenceId: string", returns: R("campaigns.sources.signals"), }, updateSources: { op: "campaigns.sources.update", doc: "Update targeting, signals, cadence or source status.", args: `opts: ${A("campaigns.sources.update")}`, returns: R("campaigns.sources.update"), }, previewSignal: { op: "campaigns.sources.preview", doc: "Preview a signal without enrolling people. Provider credits may apply.", args: `opts: ${A("campaigns.sources.preview")}`, returns: R("campaigns.sources.preview"), }, profile: { op: "campaigns.builder.profile", doc: "Read suggested campaign targeting.", args: "", returns: R("campaigns.builder.profile"), }, saveDraft: { op: "campaigns.builder.save", doc: "Save signal-draft targeting and choices.", args: `opts: ${A("campaigns.builder.save")}`, returns: R("campaigns.builder.save"), }, getDraft: { op: "campaigns.builder.get", doc: "Read preview people, messages and build progress.", args: "draftId: string", returns: R("campaigns.builder.get"), }, buildDraft: { op: "campaigns.builder.build", doc: "Start a billable signal draft build.", args: "draftId: string", returns: R("campaigns.builder.build"), }, createFromDraft: { op: "campaigns.builder.create", doc: "Create a campaign from a reviewed signal draft, without launching.", args: `opts: ${A("campaigns.builder.create")}`, returns: R("campaigns.builder.create"), }, setDraftOpener: { op: "campaigns.builder.opener", doc: "Edit a preview person's opening line.", args: `opts: ${A("campaigns.builder.opener")}`, returns: R("campaigns.builder.opener"), }, autofill: { op: "campaigns.builder.autofill", doc: "Translate a description into targeting. AI credits apply.", args: `opts: ${A("campaigns.builder.autofill")}`, returns: R("campaigns.builder.autofill"), }, savedDrafts: { op: "campaigns.builder.drafts", doc: "List saved, unfinished campaign drafts.", args: "", returns: R("campaigns.builder.drafts"), }, resumeDraft: { op: "campaigns.builder.resume", doc: "Read a saved draft's setup to resume it.", args: "draftId: string", returns: R("campaigns.builder.resume"), }, discardDraft: { op: "campaigns.builder.discard", doc: "Discard a saved draft.", args: "draftId: string", returns: R("campaigns.builder.discard"), }, saveOutreach: { op: "campaigns.builder.outreach", doc: "Save a draft's steps, brief, senders, schedule and approval mode.", args: `opts: ${A("campaigns.builder.outreach")}`, returns: R("campaigns.builder.outreach"), }, generateSequence: { op: "campaigns.builder.sequence", doc: "Generate a draft's sequence from a brief. AI credits apply.", args: `opts: ${A("campaigns.builder.sequence")}`, returns: R("campaigns.builder.sequence"), }, lookalike: { op: "campaigns.builder.lookalike", doc: "Set up a lookalike from a best lead's LinkedIn URL. Provider credits apply.", args: `opts: ${A("campaigns.builder.lookalike")}`, returns: R("campaigns.builder.lookalike"), }, suggestVoices: { op: "campaigns.builder.voices", doc: "Suggest LinkedIn experts to watch for the topics. AI credits apply.", args: `opts: ${A("campaigns.builder.voices")}`, returns: R("campaigns.builder.voices"), }, runSources: { op: "campaigns.sources.run", doc: "Run a live campaign's signals now. Provider credits apply.", args: "sequenceId: string", returns: R("campaigns.sources.run"), }, pendingReview: { op: "campaigns.sources.pending", doc: "People a signal found who wait for approval.", args: "sequenceId: string", returns: R("campaigns.sources.pending"), }, reviewPeople: { op: "campaigns.sources.review", doc: "Approve or reject people waiting for review.", args: `opts: ${A("campaigns.sources.review")}`, returns: R("campaigns.sources.review"), }, todayQueue: { op: "campaigns.queue", doc: "Who the campaign plans to contact today.", args: "sequenceId: string", returns: R("campaigns.queue"), }, estimate: { op: "campaigns.builder.estimate", doc: "Estimate audience or signal volume.", args: `opts: ${A("campaigns.builder.estimate")}`, returns: R("campaigns.builder.estimate"), }, mailboxes: { op: "campaigns.accounts.email", doc: "Mailboxes campaigns can send from, with warm-up and daily cap.", args: "", returns: R("campaigns.accounts.email"), }, linkedinAccounts: { op: "campaigns.accounts.linkedin", doc: "LinkedIn accounts campaigns can send from, with capacity.", args: "", returns: R("campaigns.accounts.linkedin"), }, list: { op: "campaigns.list", doc: "LinkedIn / multi-channel outreach campaigns.", args: "", returns: R("campaigns.list"), }, get: { op: "campaigns.get", doc: "One campaign.", args: "sequenceId: string", returns: R("campaigns.get"), }, setSteps: { op: "campaigns.steps.replace", doc: "Replace every step of a draft campaign.", args: `opts: ${A("campaigns.steps.replace")}`, returns: R("campaigns.steps.replace"), }, editSteps: { op: "campaigns.steps.update", doc: "Edit launched steps in place by stepId and updatedAt.", args: `opts: ${A("campaigns.steps.update")}`, returns: R("campaigns.steps.update"), }, duplicate: { op: "campaigns.duplicate", doc: "Copy a campaign into a new draft without its people.", args: "sequenceId: string", returns: R("campaigns.duplicate"), }, analytics: { op: "campaigns.analytics", doc: "Campaign funnel numbers.", args: "sequenceId: string", returns: R("campaigns.analytics"), }, events: { op: "campaigns.events", doc: "Campaign event log.", args: `sequenceId: string, opts?: ${Rest("campaigns.events", "sequenceId")}`, returns: R("campaigns.events"), }, pause: { op: "campaigns.pause", doc: "Pause a campaign.", args: "sequenceId: string", returns: R("campaigns.pause"), }, resume: { op: "campaigns.resume", doc: "Resume a campaign.", args: "sequenceId: string", returns: R("campaigns.resume"), }, archive: { op: "campaigns.archive", doc: "Archive (the app's Delete) a campaign; nothing is erased.", args: "sequenceId: string", returns: R("campaigns.archive"), }, previewMessage: { op: "campaigns.preview", doc: "The message one person gets at one step, fields filled in. Sends nothing.", args: "sequenceId: string, enrollmentId: string, stepId: string", returns: R("campaigns.preview"), }, personalization: { op: "campaigns.personalization.get", doc: "Personalized lines, their {{keys}} and how many are written.", args: "sequenceId: string", returns: R("campaigns.personalization.get"), }, setPersonalization: { op: "campaigns.personalization.set", doc: "Define up to 5 personalized lines. Writing them costs credits.", args: `opts: ${A("campaigns.personalization.set")}`, returns: R("campaigns.personalization.set"), }, clearPersonalization: { op: "campaigns.personalization.clear", doc: "Turn personalized lines off.", args: "sequenceId: string", returns: R("campaigns.personalization.clear"), }, writeLines: { op: "campaigns.personalization.write", doc: "Write missing (or all) personalized lines. Costs credits.", args: `opts: ${A("campaigns.personalization.write")}`, returns: R("campaigns.personalization.write"), }, editLine: { op: "campaigns.personalization.edit", doc: "Set one person's personalized line by hand.", args: "enrollmentId: string, key: string, value: string", returns: R("campaigns.personalization.edit"), }, rewriteLine: { op: "campaigns.personalization.rewrite", doc: "Write a fresh line for one person. Costs credits.", args: "enrollmentId: string, key?: string", returns: R("campaigns.personalization.rewrite"), }, syncPeople: { op: "campaigns.people.sync", doc: "Fill people's empty contact details from their CRM records.", args: "sequenceId: string", returns: R("campaigns.people.sync"), }, resumeAllPeople: { op: "campaigns.people.resumeAll", doc: "Resume every paused person in an active campaign.", args: "sequenceId: string", returns: R("campaigns.people.resumeAll"), }, setPersonEmail: { op: "campaigns.people.setEmail", doc: "Fix a draft person's email address.", args: "enrollmentId: string, email: string", returns: R("campaigns.people.setEmail"), }, setPersonLinkedin: { op: "campaigns.people.setLinkedin", doc: "Fix a draft person's LinkedIn profile URL.", args: "enrollmentId: string, linkedinProfileUrl: string", returns: R("campaigns.people.setLinkedin"), }, setPersonField: { op: "campaigns.people.setField", doc: "Fill one {{merge field}} for a draft person.", args: "enrollmentId: string, key: string, value: string", returns: R("campaigns.people.setField"), }, pipeline: { op: "campaigns.pipeline", doc: "Where the campaign's people sit in the lead pipeline.", args: "sequenceId: string", returns: R("campaigns.pipeline"), }, campaignsForRecord: { op: "campaigns.people.forRecord", doc: "The campaigns a CRM record is in, with its status in each.", args: "entryId: string", returns: R("campaigns.people.forRecord"), }, personTimeline: { op: "campaigns.people.timeline", doc: "Everything a campaign did for one person, step by step.", args: "enrollmentId: string", returns: R("campaigns.people.timeline"), }, removePerson: { op: "campaigns.people.remove", doc: "Remove a never-contacted person from a draft campaign.", args: "enrollmentId: string", returns: R("campaigns.people.remove"), }, }, email: { createDraft: { op: "email.drafts.create", doc: "Create a native mailbox draft with a stable request receipt.", args: `draft: ${A("email.drafts.create")}`, returns: R("email.drafts.create"), }, draft: { op: "email.drafts.get", doc: "Read a native email draft and sync status.", args: "draftId: string", returns: R("email.drafts.get"), }, updateDraft: { op: "email.drafts.update", doc: "Save the next email draft revision.", args: `input: ${A("email.drafts.update")}`, returns: R("email.drafts.update"), }, sendDraft: { op: "email.drafts.send", doc: "Send the frozen native email draft revision.", args: `input: ${A("email.drafts.send")}`, returns: R("email.drafts.send"), }, senders: { op: "email.drafts.senders", doc: "List eligible native mailboxes.", args: `opts?: ${A("email.drafts.senders")}`, returns: R("email.drafts.senders"), }, uploadAttachment: { op: "email.drafts.upload", doc: "Upload attachment bytes for a native draft.", args: `input: ${A("email.drafts.upload")}`, returns: R("email.drafts.upload"), }, signature: { op: "email.drafts.signature", doc: "Load the selected mailbox signature for visible insertion.", args: "draftId: string", returns: R("email.drafts.signature"), }, sendFromMailbox: { op: "email.native.send", doc: "Explicitly send through a native Gmail or Outlook mailbox.", args: `message: ${A("email.native.send")}`, returns: R("email.native.send"), }, send: { op: "email.send", doc: "Send one email from a verified identity.", args: `message: ${A("email.send")}`, returns: R("email.send"), }, messages: { op: "email.message.list", doc: "Sent messages.", args: `opts?: ${A("email.message.list")}`, returns: R("email.message.list"), }, message: { op: "email.message.get", doc: "One sent message with delivery events.", args: "messageId: string", returns: R("email.message.get"), }, campaigns: { op: "email.campaign.list", doc: "Email campaigns.", args: `opts?: ${A("email.campaign.list")}`, returns: R("email.campaign.list"), }, campaign: { op: "email.campaign.get", doc: "One email campaign.", args: "campaignId: string", returns: R("email.campaign.get"), }, identities: { op: "email.identity.list", doc: "Verified sending identities.", args: "", returns: R("email.identity.list"), }, }, billing: { status: { op: "billing.status", doc: "Plan, AI balance and usage for the workspace.", args: "", returns: R("billing.status"), }, topup: { op: "billing.topup", doc: "Start an AI credit top-up (returns a Stripe URL).", args: `opts?: ${A("billing.topup")}`, returns: R("billing.topup"), }, upgrade: { op: "billing.upgrade", doc: "Upgrade the plan (returns a Stripe Checkout URL or updates in place).", args: `opts?: ${A("billing.upgrade")}`, returns: R("billing.upgrade"), }, }, approvals: { list: { op: "workspace.approvals.list", doc: "Pending approvals in the workspace.", args: "", returns: R("workspace.approvals.list"), }, request: { op: "approval.request", doc: "Ask a human to approve an action.", args: `request: ${A("approval.request")}`, returns: R("approval.request"), }, decide: { op: "approval.decide", doc: "Approve or reject a pending approval.", args: `approvalId: string, decision: Req<"approval.decide">["decision"], opts?: ${Rest("approval.decide", "approvalId", "decision")}`, returns: R("approval.decide"), }, }, memory: { get: { host: true, permission: "memory:read", doc: "The workspace's saved memories (legacy shape).", args: "", deprecated: "Use memory.search(query).", }, search: { op: "memory.search", doc: "Full-text search over saved memories.", args: `query: string, opts?: ${Rest("memory.search", "query")}`, returns: R("memory.search"), }, save: { op: "memory.save", doc: "Save a memory under a stable key.", args: `memory: ${A("memory.save")}`, returns: R("memory.save"), }, }, workspace: { context: { op: "workspace.context", doc: "What this workspace has: objects, agents, integrations, next commands.", args: "", returns: R("workspace.context"), }, status: { op: "workspace.status", doc: "Workspace status snapshot.", args: "", returns: R("workspace.status"), }, agents: { op: "workspace.agents", doc: "Agents in this workspace.", args: "", returns: R("workspace.agents"), }, artifacts: { op: "workspace.artifacts.list", doc: "Recent artifacts (reports, docs, lists).", args: `opts?: ${A("workspace.artifacts.list")}`, returns: R("workspace.artifacts.list"), }, instructions: { op: "workspace.instructions", doc: "Workspace instructions the agent follows.", args: "", returns: R("workspace.instructions"), }, }, search: { web: { op: "search.web", doc: "Web search (gateway).", args: `query: string, opts?: ${Rest("search.web", "query")}`, returns: R("search.web"), }, contents: { op: "search.contents", doc: "Fetch page contents for search results.", args: `opts: ${A("search.contents")}`, returns: R("search.contents"), }, answer: { op: "search.answer", doc: "Answer a question from web sources.", args: `query: string, opts?: ${Rest("search.answer", "query")}`, returns: R("search.answer"), }, }, web: { scrape: { op: "web.scrape", doc: "Scrape a URL to Markdown.", args: `url: string, opts?: ${Rest("web.scrape", "url")}`, returns: R("web.scrape"), }, brand: { op: "web.brand", doc: "Brand kit (logo, colors, fonts) for a domain.", args: `domain: string, opts?: ${Rest("web.brand", "domain")}`, returns: R("web.brand"), }, screenshot: { op: "web.screenshot", doc: "Screenshot a rendered page.", args: `url: string, opts?: ${Rest("web.screenshot", "url")}`, returns: R("web.screenshot"), }, extract: { op: "web.extract", doc: "Extract structured data from a page.", args: `opts: ${A("web.extract")}`, returns: R("web.extract"), }, }, images: { generate: { op: "image.generate", doc: "Generate an image from a prompt.", args: `opts: ${A("image.generate")}`, returns: R("image.generate"), }, edit: { op: "image.edit", doc: "Edit an image with a prompt.", args: `opts: ${A("image.edit")}`, returns: R("image.edit"), }, }, agentConfig: { show: { viaApi: true, permission: "agent-config:read", doc: 'Read one config section: "identity" | "organisation" | "user" | "tools" | "mem" | "bootstrap".', args: "section: AgentConfigSection", }, edit: { viaApi: true, permission: "agent-config:write", doc: "Replace one config section's markdown.", args: "section: AgentConfigSection, content: string", }, }, files: { read: { op: "files.content", doc: "Read a workspace file (text inline; binary → `downloadable: true`).", args: "path: string", returns: R("files.content"), }, list: { op: "files.list", doc: "List a directory (default /).", args: "dir?: string", returns: R("files.list"), }, write: { op: "files.write", doc: "Write a UTF-8 text file (creates parents).", args: `path: string, content: string, opts?: ${Rest("files.write", "path", "content")}`, returns: R("files.write"), }, delete: { op: "files.delete", doc: "Delete a file or directory.", args: "path: string", returns: R("files.delete"), }, mkdir: { op: "files.mkdir", doc: "Create a directory (and parents).", args: "path: string", returns: R("files.mkdir"), }, move: { op: "files.move", doc: "Move / rename a file or directory.", args: "from: string, to: string", returns: R("files.move"), }, download: { op: "files.downloadUrl", doc: "Short-lived signed URL for a file's bytes.", args: "path: string", returns: R("files.downloadUrl"), }, }, chat: { createSession: { host: true, permission: "ai:chat", doc: "Create an empty chat thread; returns its id.", args: "title?: string", returns: "Promise<{ id: string; threadId: string; _id: string }>", }, send: { host: true, permission: "ai:chat", doc: "Send a message to a thread. With `onEvent`, text-delta chunks stream in; resolves with the full reply.", args: "sessionId: string, message: string, opts?: { onEvent?: (event: ChatStreamEvent) => void }", returns: "Promise<{ text: string; threadId: string }>", }, getHistory: { op: "chat.read", doc: "Messages in a thread (via chat.read).", args: "sessionId: string, opts?: { limit?: number }", returns: R("chat.read"), }, getSessions: { op: "chat.list", doc: "Recent threads.", args: `opts?: ${A("chat.list")}`, returns: R("chat.list"), }, abort: { host: true, permission: "ai:chat", doc: "Stop the active run on a thread.", args: "sessionId: string", returns: "Promise", }, isActive: { host: true, permission: "ai:chat", doc: "Whether the thread has a running turn.", args: "sessionId: string", returns: "Promise", }, list: { op: "chat.list", doc: "Recent threads (alias of getSessions).", args: `opts?: ${A("chat.list")}`, returns: R("chat.list"), }, search: { op: "chat.search", doc: "Full-text search across past messages.", args: `query: string, opts?: ${Rest("chat.search", "query")}`, returns: R("chat.search"), }, rename: { op: "chat.rename", doc: "Rename a thread.", args: "threadId: string, title: string", returns: R("chat.rename"), }, delete: { op: "chat.delete", doc: "Delete one or more threads.", args: "threadIds: string | string[]", returns: R("chat.delete"), }, }, agent: { send: { host: true, permission: "agent:invoke", doc: "Start a new agent turn in a fresh thread. Resolves once the run is accepted.", args: "message: string, opts?: { title?: string; model?: string; yolo?: boolean }", returns: "Promise<{ threadId: string; runId: string | null }>", }, }, tool: { register: { host: true, permission: "agent:invoke", doc: "Expose a function the workspace agent can call while this app is open.", args: "name: string, handler: (args: unknown) => unknown | Promise, opts?: { description?: string; inputSchema?: unknown }", returns: "Promise<{ ok: boolean }>", }, }, ui: { toast: { host: true, permission: "ui", doc: "Show a toast.", args: 'message: string, opts?: { type?: "success" | "error" | "info" }', returns: "Promise<{ ok: boolean }>", }, navigate: { host: true, permission: "ui", doc: "Navigate the workspace to a path (e.g. /objects/company).", args: "path: string", returns: "Promise<{ ok: boolean }>", }, openEntry: { host: true, permission: "ui", doc: "Open a CRM record in the workspace.", args: "objectName: string, entryId: string", returns: "Promise<{ ok: boolean }>", }, setTitle: { host: true, permission: "ui", doc: "Set the app tab title.", args: "title: string", returns: "Promise<{ ok: boolean }>", }, confirm: { host: true, permission: "ui", doc: "Native confirm dialog.", args: "message: string", returns: "Promise", }, prompt: { host: true, permission: "ui", doc: "Native prompt dialog.", args: "message: string, defaultValue?: string", returns: "Promise", }, }, store: { get: { host: true, permission: "store:read", doc: "Read a per-app value.", args: "key: string", returns: "Promise", }, set: { host: true, permission: "store:write", doc: "Write a per-app value (JSON).", args: "key: string, value: unknown", returns: "Promise<{ ok: boolean }>", }, delete: { host: true, permission: "store:write", doc: "Delete a key.", args: "key: string", returns: "Promise<{ ok: boolean }>", }, list: { host: true, permission: "store:read", doc: "All keys for this app.", args: "", returns: "Promise", }, clear: { host: true, permission: "store:write", doc: "Delete every key.", args: "", returns: "Promise<{ ok: boolean }>", }, }, http: { fetch: { host: true, permission: "http:external", doc: "Fetch an external URL through the SSRF-guarded proxy.", args: "url: string, opts?: { method?: string; headers?: Record; body?: unknown }", returns: "Promise<{ status: number; headers: Record; body: unknown }>", }, }, events: { on: { host: true, permission: null, doc: 'Subscribe to a channel ("object.entry.created" | "object.entry.updated" | "object.entry.deleted" | "theme.changed").', args: "channel: string, callback: (data: unknown) => void", returns: "void", }, off: { host: true, permission: null, doc: "Unsubscribe.", args: "channel: string, callback?: (data: unknown) => void", returns: "void", }, }, bus: { send: { host: true, permission: "apps:message", doc: "Send a message to another open app.", args: "targetApp: string, message: unknown", returns: "Promise<{ ok: boolean }>", }, on: { host: true, permission: "apps:message", doc: "Receive messages from other apps.", args: "callback: (message: { from: string; message: unknown }) => void", returns: "void", }, list: { host: true, permission: "apps:message", doc: "Other apps currently open.", args: "", returns: "Promise>", }, }, apps: { list: { op: "apps.list", doc: "Installed apps in this workspace.", args: "", returns: R("apps.list"), }, gallery: { op: "apps.gallery", doc: "Discover listings (Official + Community).", args: "", returns: R("apps.gallery"), }, create: { op: "apps.create", doc: "Scaffold and register a new app.", args: `opts: ${A("apps.create")}`, returns: R("apps.create"), }, install: { op: "apps.install", doc: "Install a listing.", args: "listingId: string", returns: R("apps.install"), }, publish: { op: "apps.publish", doc: "Publish an app folder as a listing version.", args: `opts: ${A("apps.publish")}`, returns: R("apps.publish"), }, uninstall: { op: "apps.uninstall", doc: "Uninstall an app.", args: "slug: string", returns: R("apps.uninstall"), }, enable: { op: "apps.setEnabled", doc: "Enable an installed app.", args: "slug: string", returns: R("apps.setEnabled"), }, disable: { op: "apps.setEnabled", doc: "Disable an installed app.", args: "slug: string", returns: R("apps.setEnabled"), }, pin: { op: "apps.setPinned", doc: "Pin an app in the rail.", args: "slug: string", returns: R("apps.setPinned"), }, unpin: { op: "apps.setPinned", doc: "Unpin an app.", args: "slug: string", returns: R("apps.setPinned"), }, send: { host: true, permission: "apps:message", doc: "Deprecated alias of bus.send.", args: "targetApp: string, message: unknown", returns: "Promise<{ ok: boolean }>", deprecated: "Use dench.bus.send.", }, on: { host: true, permission: "apps:message", doc: "Deprecated alias of bus.on.", args: 'eventType: "message", callback: (message: { from: string; message: unknown }) => void', returns: "void", deprecated: "Use dench.bus.on.", }, }, cron: { schedule: { host: true, permission: "cron:schedule", doc: "Create a scheduled agent (automation).", args: "opts: CronScheduleOptions", returns: "Promise", }, list: { host: true, permission: "cron:schedule", doc: "Automations in the workspace.", args: "", returns: "Promise", }, update: { host: true, permission: "cron:schedule", doc: "Update an automation.", args: "jobId: string, patch: Partial", returns: "Promise", }, run: { host: true, permission: "cron:schedule", doc: "Run an automation now.", args: "jobId: string", returns: "Promise", }, cancel: { host: true, permission: "cron:schedule", doc: "Delete an automation.", args: "jobId: string", returns: "Promise", }, }, webhooks: { register: { host: true, permission: "webhooks:receive", doc: "Register an inbound webhook for this app. Returns the URL and signing secret ONCE — store the secret with the sender.", args: "hookName: string", returns: R("apps.webhooks.register"), }, on: { host: true, permission: "webhooks:receive", doc: "Receive events for a hook while the app is open (polls every 5s).", args: "hookName: string, callback: (event: AppWebhookEvent) => void", returns: "void", }, poll: { host: true, permission: "webhooks:receive", doc: "Events received since a timestamp.", args: "hookName: string, opts?: { since?: number; limit?: number }", returns: R("apps.webhooks.poll"), }, list: { host: true, permission: "webhooks:receive", doc: "This app's registered hooks (URLs, never secrets).", args: "", returns: R("apps.webhooks.list"), }, delete: { host: true, permission: "webhooks:receive", doc: "Remove a hook and its stored events.", args: "hookName: string", returns: R("apps.webhooks.delete"), }, }, clipboard: { read: { host: true, permission: "clipboard:read", doc: "Read clipboard text.", args: "", returns: "Promise", }, write: { host: true, permission: "clipboard:write", doc: "Write clipboard text.", args: "text: string", returns: "Promise<{ ok: boolean }>", }, }, integrations: { list: { host: true, permission: "integrations:read", doc: "Connected accounts (toolkit, status, connectedAccountId).", args: "", returns: "Promise", }, search: { host: true, permission: "integrations:read", doc: "Find tools by natural-language query and/or toolkit.", args: "opts: { query?: string; toolkit?: string; limit?: number }", returns: "Promise", }, execute: { host: true, permission: "integrations:read", doc: "Run a tool. Write-classified tools additionally need integrations:write.", args: "opts: { tool: string; arguments?: Record; connectedAccountId?: string }", returns: "Promise", }, }, geo: { resolveLocation: { host: true, permission: null, doc: "Free text → country (ISO2 + ISO numeric). Same rules as the analytics map.", args: "text: string", returns: "Promise<{ id: string; iso2: string; name: string } | null>", }, resolveRegion: { host: true, permission: null, doc: "Free text → admin-1 region (state/province) within a country. Loads that country's file on first use.", args: "text: string, iso2: string", returns: "Promise", }, loadAdmin1: { host: true, permission: null, doc: "First-level divisions of a country as GeoJSON features (what the map draws).", args: "iso2: string", returns: "Promise", }, }, db: { query: { host: true, permission: "crm:read", doc: "Removed. DuckDB SQL is gone — use objects.query / analytics.compute.", args: "sql: string", returns: "Promise", deprecated: "Removed: use objects.query or analytics.compute.", }, execute: { host: true, permission: "crm:write", doc: "Removed. Use objects.create / update / delete.", args: "sql: string", returns: "Promise", deprecated: "Removed: use objects.create / update / delete.", }, }, }; /** * Registry groups → app permission scope group. Anything not listed maps * to its own group name (`billing` → `billing:read` / `billing:write`). * The `gateway` group is split by operation id prefix (search / web / * image / tool / integrations) so scopes read naturally. */ export const GROUP_SCOPE_OVERRIDES: Record = { crm: "crm", files: "files", chat: "ai", routines: "cron", meta: "", }; /** Operations that are reads despite a non-GET method. */ export const READ_OPERATION_OVERRIDES = new Set([ "crm.query", "crm.queryPage", "crm.tasks.pageAssigned", "crm.tasks.pageForEntry", "crm.sql", "crm.aggregate", "crm.analytics.compute", "crm.people.search", "crm.companies.search", "enrichment.jobs.search", "enrichment.search.preview", "enrichment.company.related", "search.web", "search.contents", "search.answer", "web.scrape", "web.brand", "web.sitemap", "web.crawl", "web.screenshot", "web.extract", "web.product", "web.competitors", "web.styleguide", "web.fonts", "web.classify", "web.brandSearch", "tool.search", ]); /** * Scope for a registry operation. Pure so the generator, the host and the * CLI validator agree without importing each other. */ export function permissionForOperation(operation: { id: string; group: string; method: string; }): string | null { if (operation.group === "meta") return null; if (operation.id.startsWith("apps.webhooks.")) return "webhooks:receive"; if (operation.group === "chat") return "ai:chat"; if (operation.group === "routines") return "cron:schedule"; const isRead = operation.method === "GET" || READ_OPERATION_OVERRIDES.has(operation.id); let scopeGroup: string; if (operation.group === "gateway") { const prefix = operation.id.split(".")[0]; scopeGroup = prefix === "tool" ? "integrations" : prefix; } else { scopeGroup = GROUP_SCOPE_OVERRIDES[operation.group] ?? operation.group; } return `${scopeGroup}:${isRead ? "read" : "write"}`; } export function listSurfaceMethods( surface: AppSdkSurface = APP_SDK_SURFACE, ): string[] { const out: string[] = []; for (const [ns, methods] of Object.entries(surface)) { for (const name of Object.keys(methods)) out.push(`${ns}.${name}`); } return out; }