@elevasis/sdk 1.20.2 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/cli.cjs +3386 -1529
  2. package/dist/index.d.ts +412 -149
  3. package/dist/index.js +955 -721
  4. package/dist/node/index.d.ts +0 -3
  5. package/dist/node/index.js +21 -48
  6. package/dist/test-utils/index.d.ts +395 -128
  7. package/dist/test-utils/index.js +599 -368
  8. package/dist/worker/index.js +536 -323
  9. package/package.json +2 -2
  10. package/reference/_navigation.md +9 -7
  11. package/reference/_reference-manifest.json +1 -1
  12. package/reference/claude-config/rules/agent-start-here.md +4 -0
  13. package/reference/claude-config/rules/frontend.md +2 -2
  14. package/reference/claude-config/rules/organization-model.md +44 -2
  15. package/reference/claude-config/rules/organization-os.md +12 -12
  16. package/reference/claude-config/rules/ui.md +14 -14
  17. package/reference/claude-config/rules/vibe.md +37 -33
  18. package/reference/claude-config/skills/explore/SKILL.md +6 -6
  19. package/reference/claude-config/skills/knowledge/SKILL.md +73 -29
  20. package/reference/claude-config/skills/knowledge/operations/codify-level-a.md +1 -1
  21. package/reference/claude-config/skills/knowledge/operations/codify-level-b.md +25 -24
  22. package/reference/claude-config/skills/knowledge/operations/features.md +56 -93
  23. package/reference/claude-config/skills/knowledge/operations/labels.md +19 -14
  24. package/reference/claude-config/skills/knowledge/operations/offerings.md +6 -6
  25. package/reference/claude-config/skills/save/SKILL.md +2 -2
  26. package/reference/claude-config/skills/setup/SKILL.md +1 -1
  27. package/reference/claude-config/skills/tutorial/technical.md +23 -26
  28. package/reference/claude-config/skills/tutorial/vibe-coder.md +9 -9
  29. package/reference/claude-config/sync-notes/2026-05-12-sdk-ready-release-train.md +30 -0
  30. package/reference/cli.mdx +140 -0
  31. package/reference/deployment/provided-features.mdx +29 -15
  32. package/reference/examples/organization-model.ts +1 -1
  33. package/reference/packages/core/src/knowledge/README.md +8 -7
  34. package/reference/packages/core/src/organization-model/README.md +66 -26
  35. package/reference/packages/ui/src/provider/README.md +5 -5
  36. package/reference/scaffold/core/organization-graph.mdx +16 -15
  37. package/reference/scaffold/core/organization-model.mdx +89 -41
  38. package/reference/scaffold/index.mdx +9 -9
  39. package/reference/scaffold/operations/propagation-pipeline.md +3 -3
  40. package/reference/scaffold/operations/scaffold-maintenance.md +11 -11
  41. package/reference/scaffold/recipes/add-a-feature.md +26 -24
  42. package/reference/scaffold/recipes/add-a-resource.md +10 -14
  43. package/reference/scaffold/recipes/customize-crm-actions.md +439 -439
  44. package/reference/scaffold/recipes/customize-knowledge-browser.md +384 -0
  45. package/reference/scaffold/recipes/customize-organization-model.md +72 -44
  46. package/reference/scaffold/recipes/extend-crm.md +40 -39
  47. package/reference/scaffold/recipes/extend-lead-gen.md +15 -16
  48. package/reference/scaffold/recipes/gate-by-feature-or-admin.md +34 -30
  49. package/reference/scaffold/recipes/index.md +13 -12
  50. package/reference/scaffold/recipes/query-the-knowledge-graph.md +200 -0
  51. package/reference/scaffold/reference/contracts.md +362 -99
  52. package/reference/scaffold/reference/feature-registry.md +9 -20
  53. package/reference/scaffold/reference/glossary.md +18 -18
  54. package/reference/scaffold/ui/composition-extensibility.mdx +23 -23
  55. package/reference/scaffold/ui/customization.md +11 -11
  56. package/reference/scaffold/ui/feature-flags-and-gating.md +8 -8
  57. package/reference/scaffold/ui/feature-shell.mdx +19 -19
  58. package/reference/scaffold/ui/recipes.md +29 -28
@@ -1,442 +1,442 @@
1
- ---
2
- title: Customize CRM Actions
3
- description: Add, hide, or replace CRM deal action buttons in a template-derived project, and override default platform action workflows with project-owned implementations.
4
- ---
5
1
  <!-- @generated by packages/sdk/scripts/copy-reference-docs.mjs -- DO NOT EDIT -->
6
2
  <!-- Regenerate: pnpm scaffold:sync -->
7
3
 
8
-
9
- # Customize CRM Actions
10
-
11
- CRM deal pages derive their action buttons from an `ActionDef[]` array. The shared UI reads that array from the provider, filters it with `deriveActions(deal, actions)`, and renders one-click buttons for actions without a `payloadSchema`.
12
-
13
- For the broader CRM extension map -- pages, sidebars, hooks, workflow adapters, contracts, and org-model boundaries -- start with [Build and Extend CRM](extend-crm.md). This recipe is only the deal-action path.
14
-
15
- **Shape reference:** The `ActionDef` flat shape and the consolidation that replaced the old `handler`/`kind` union are documented in `apps/docs/content/docs/in-progress/active-development/sdk-changes/crm/crm-current-state-assessment.mdx`.
16
-
17
- Use this recipe when a user asks for work like:
18
-
19
- - "Hide Close Lost from the deal drawer."
20
- - "Add a Send Quote action to deals in Proposal."
21
- - "Override Move to Proposal to also create a task in our project tool."
22
- - "Build a custom deal page that runs one of our workflows."
23
-
24
- ## How Platform Action Dispatch Works
25
-
26
- Every default action in `DEFAULT_CRM_ACTIONS` maps to a deployed workflow via its `workflowId` field. When the shared UI calls `POST /deals/:dealId/actions/:actionKey`, the platform:
27
-
28
- 1. Validates `isAvailableFor(deal)` server-side (security gate -- cannot be skipped).
29
- 2. Resolves `actionDef.workflowId` to a deployed resource.
30
- 3. Dispatches through `SingleExecutionCoordinator` -- same execution engine as every other workflow.
31
- 4. Returns the refetched deal.
32
-
33
- External projects override an action's behavior by deploying a workflow with the same `workflowId` as the default. The resource registry picks the project-owned workflow over the platform default. The `isAvailableFor` predicate is not overridable in V1 -- it stays in `@core` `DEFAULT_CRM_ACTIONS`. Override what the action does, not when the button shows.
34
-
35
- ## ActionDef Shape
36
-
37
- Read the generated contract reference before editing:
38
-
39
- `node_modules/@elevasis/sdk/reference/scaffold/reference/contracts.md`
40
-
41
- The current flat shape (no `handler` nesting, no `kind` discriminator):
42
-
43
- ```ts
44
- export interface ActionDef {
45
- key: string
46
- label: string
47
- isAvailableFor: (deal: AcqDealRow) => boolean
48
- workflowId: string
49
- payloadSchema?: z.ZodTypeAny
50
- }
51
- ```
52
-
53
- `deriveActions(deal, actions)` returns only render-time actions: `{ key, label, payloadSchema? }`. It does not expose `workflowId` or `isAvailableFor` to the browser.
54
-
55
- A minimal entry looks like:
56
-
57
- ```ts
58
- {
59
- key: 'move_to_proposal',
60
- label: 'Move to Proposal',
61
- isAvailableFor: (deal) => deal.stage_key === 'interested',
62
- workflowId: 'move_to_proposal-workflow',
63
- payloadSchema: undefined
64
- }
65
- ```
66
-
67
- ## acqDb and crm Helpers
68
-
69
- Workflows backing CRM actions use worker adapters instead of raw Supabase calls. The acquisition adapter is imported as `acqDb` from `@elevasis/sdk/worker` and exposes the action-workflow helpers:
70
-
71
- - `acqDb.transitionDeal({ dealId, toStage, toState? })` -- moves a deal to a new stage, optionally updating state_key.
72
- - `acqDb.recordDealActivity({ dealId, type, title, description?, payload? })` -- appends an entry to the deal's `activity_log` JSONB column. Use this for outbound/inbound audit and lifecycle events.
73
- - `acqDb.loadDeal({ dealId })` -- returns the deal row joined with contact and company.
74
-
75
- These wrap existing `LeadService` logic -- they are extraction, not new business logic. The separate `crm` adapter also exposes focused CRM methods such as `crm.recordActivity(...)`; use `acqDb` for action workflows that need deal transitions and the broader acquisition substrate.
76
-
77
- ## Override a Default Action
78
-
79
- Deploy a workflow with the same `workflowId` as the default action you want to replace. The resource registry resolves your project-owned workflow first.
80
-
81
- The canonical transition workflow (see `packages/elevasis-operations/src/sales/crm/actions/move-to-proposal.ts` for the deployed reference):
82
-
83
- ```ts
84
- // operations/src/sales/crm/actions/move-to-proposal.ts
85
- import type { WorkflowDefinition } from '@elevasis/sdk'
86
- import { acqDb } from '@elevasis/sdk/worker'
87
- import { resourceDescriptors } from '@core/config/organization-model'
88
- import { ActionWorkflowInputSchema, ActionWorkflowOutputSchema } from '../shared/action-workflow-schemas.js'
89
-
90
- export const moveToProposalWorkflow: WorkflowDefinition = {
91
- config: {
92
- resource: resourceDescriptors.moveToProposal,
93
- resourceId: resourceDescriptors.moveToProposal.id,
94
- name: 'Move to Proposal',
95
- type: resourceDescriptors.moveToProposal.kind,
96
- version: '1.0.0',
97
- status: 'prod',
98
- category: 'internal',
99
- links: [{ nodeId: 'feature:sales.crm', kind: 'operates-on' }]
100
- },
101
- contract: {
102
- inputSchema: ActionWorkflowInputSchema,
103
- outputSchema: ActionWorkflowOutputSchema
104
- },
105
- steps: {
106
- transition: {
107
- id: 'transition',
108
- name: 'Transition to Proposal',
109
- inputSchema: ActionWorkflowInputSchema,
110
- outputSchema: ActionWorkflowOutputSchema,
111
- handler: async (rawInput, context) => {
112
- const { dealId } = rawInput as { dealId: string; organizationId: string }
113
- context.logger.info(`[transition] Moving deal ${dealId} to proposal`)
114
- await acqDb.transitionDeal({ dealId, toStage: 'proposal' })
115
- return { dealId, sent: false }
116
- },
117
- next: null
118
- }
119
- },
120
- entryPoint: 'transition'
121
- }
122
- ```
123
-
124
- To add side effects (create a task, send a Slack message, log a deal activity), extend the handler before the `return`. The OM descriptor ID must match the action's `workflowId` in `DEFAULT_CRM_ACTIONS` from `@elevasis/sdk` exactly.
125
-
126
- Register the workflow in the operations manifest:
127
-
128
- ```ts
129
- // operations/src/index.ts
130
- import { moveToProposalWorkflow } from './sales/crm/actions/move-to-proposal.js'
131
-
132
- export const deploymentSpec = {
133
- workflows: [moveToProposalWorkflow],
134
- agents: []
135
- }
136
- ```
137
-
138
- Then deploy:
139
-
140
- ```bash
141
- pnpm exec elevasis-sdk deploy
142
- ```
143
-
144
- ## 1. Override the Shared CRM Action Set (UI-Side)
145
-
146
- To hide, reorder, or relabel buttons in the shared deal UI, create a local action config module:
147
-
148
- ```ts
149
- // ui/src/config/crm-actions.ts
150
- import { DEFAULT_CRM_ACTIONS, type ActionDef } from '@elevasis/sdk'
151
-
152
- export const crmActions: ActionDef[] = [
153
- ...DEFAULT_CRM_ACTIONS.filter((action) => action.key !== 'move_to_closed_lost')
154
- ]
155
- ```
156
-
157
- To add a new action entry alongside the defaults (note: brand-new keys are not server-dispatched until the platform knows their `workflowId`):
158
-
159
- ```ts
160
- // ui/src/config/crm-actions.ts
161
- import { DEFAULT_CRM_ACTIONS, type ActionDef } from '@elevasis/sdk'
162
-
163
- export const crmActions: ActionDef[] = [
164
- ...DEFAULT_CRM_ACTIONS,
165
- {
166
- key: 'send_quote',
167
- label: 'Send Quote',
168
- isAvailableFor: (deal) => deal.stage_key === 'proposal',
169
- workflowId: 'send-quote-workflow'
170
- }
171
- ]
172
- ```
173
-
174
- Wire it into the app provider. If the template uses `createElevasisApp`, pass it in the app config:
175
-
176
- ```tsx
177
- // ui/src/main.tsx
178
- import { crmActions } from './config/crm-actions'
179
-
180
- const App = createElevasisApp({
181
- router,
182
- apiUrl: API_URL,
183
- auth: {
184
- clientId: import.meta.env.VITE_WORKOS_CLIENT_ID,
185
- redirectUri: import.meta.env.VITE_WORKOS_REDIRECT_URI,
186
- devMode: true
187
- },
188
- theme: { presets: themePresets, background, loader },
189
- queryClient,
190
- crmActions
191
- })
192
- ```
193
-
194
- If the project hand-wires providers, pass the same array to `ElevasisUIProvider` or `ElevasisCoreProvider`:
195
-
196
- ```tsx
197
- <ElevasisUIProvider auth={auth} apiUrl={API_URL} crmActions={crmActions}>
198
- <AppRoutes />
199
- </ElevasisUIProvider>
200
- ```
201
-
202
- This controls the shared `DealDetailPage` and `DealDrawer` action row.
203
-
204
- ## 2. Add a Custom Workflow-Backed Action
205
-
206
- For a brand-new action key that calls a project-owned workflow, define the workflow first.
207
-
208
- If the action sends email, writes to another channel, or otherwise touches a customer, use `acqDb.recordDealActivity` inside the workflow handler to append an audit entry to the deal's `activity_log`. For an advanced Instantly-thread-aware variant that prefers in-thread replies and falls back to fresh outbound, see the canonical CRM action examples in `packages/elevasis-operations/src/sales/crm/actions/`.
209
-
210
- ### Define the Workflow Contract
211
-
212
- ```ts
213
- // core/types/index.ts
214
- import { z } from 'zod'
215
-
216
- export const sendQuoteInputSchema = z.object({
217
- dealId: z.string().uuid(),
218
- organizationId: z.string().uuid()
219
- })
220
-
221
- export const sendQuoteOutputSchema = z.object({
222
- dealId: z.string().uuid(),
223
- sent: z.boolean(),
224
- messageId: z.string().optional()
225
- })
226
-
227
- export type SendQuoteInput = z.infer<typeof sendQuoteInputSchema>
228
- export type SendQuoteOutput = z.infer<typeof sendQuoteOutputSchema>
229
- ```
230
-
231
- ### Define the Workflow
232
-
233
- ```ts
234
- // operations/src/sales/send-quote.ts
235
- import type { WorkflowDefinition } from '@elevasis/sdk'
236
- import { acqDb, createResendAdapter } from '@elevasis/sdk/worker'
237
- import { resourceDescriptors } from '@core/config/organization-model'
238
- import {
239
- sendQuoteInputSchema,
240
- sendQuoteOutputSchema
241
- } from '@core/types'
242
-
243
- export const sendQuoteWorkflow: WorkflowDefinition = {
244
- config: {
245
- resource: resourceDescriptors.sendQuote,
246
- resourceId: resourceDescriptors.sendQuote.id,
247
- name: 'Send Quote',
248
- type: resourceDescriptors.sendQuote.kind,
249
- version: '1.0.0',
250
- status: 'dev',
251
- category: 'internal',
252
- links: [{ nodeId: 'feature:sales.crm', kind: 'operates-on' }]
253
- },
254
- contract: {
255
- inputSchema: sendQuoteInputSchema,
256
- outputSchema: sendQuoteOutputSchema
257
- },
258
- steps: {
259
- send: {
260
- id: 'send',
261
- name: 'Send Quote Email',
262
- inputSchema: sendQuoteInputSchema,
263
- outputSchema: sendQuoteOutputSchema,
264
- handler: async (rawInput, context) => {
265
- const { dealId } = rawInput as { dealId: string; organizationId: string }
266
- const deal = await acqDb.loadDeal({ dealId })
267
-
268
- const resend = createResendAdapter('my-resend-credential')
269
- context.logger.info(`[send-quote] Sending quote for deal ${dealId}`)
270
-
271
- const sent = await resend.sendEmail({
272
- to: deal.contact.email,
273
- subject: 'Your quote is ready',
274
- html: `<p>Your quote is ready. Reply to this email with questions.</p>`
275
- })
276
-
277
- await acqDb.recordDealActivity({
278
- dealId,
279
- type: 'quote_sent',
280
- title: 'Sent quote',
281
- payload: { triggered_by_action: 'send_quote', channel: 'email' }
282
- })
283
-
284
- return {
285
- dealId,
286
- sent: true,
287
- messageId: String(sent.id ?? '')
288
- }
289
- },
290
- next: null
291
- }
292
- },
293
- entryPoint: 'send'
294
- }
295
- ```
296
-
297
- Register the workflow in the operations manifest and deploy:
298
-
299
- ```ts
300
- // operations/src/index.ts
301
- import { sendQuoteWorkflow } from './sales/send-quote.js'
302
-
303
- export const deploymentSpec = {
304
- workflows: [sendQuoteWorkflow],
305
- agents: []
306
- }
307
- ```
308
-
309
- ```bash
310
- pnpm exec elevasis-sdk deploy
311
- ```
312
-
313
- ### Choose the UI Path
314
-
315
- A custom `ActionDef` entry with `workflowId: 'send-quote-workflow'` can be rendered through the shared `crmActions` provider path. Server dispatch through `POST /deals/:dealId/actions/:actionKey` is constrained by the platform-known/default action set in v1, so use a custom deal page or render slot that calls the workflow directly through `/execute` or `/execute-async` when the action key is outside that server-side set.
316
-
317
- ```tsx
318
- // ui/src/features/crm/components/SendQuoteButton.tsx
319
- import { Button } from '@mantine/core'
320
- import { useMutation } from '@tanstack/react-query'
321
- import { useElevasisServices } from '@elevasis/ui/provider'
322
- import { resourceDescriptors } from '@core/config/organization-model'
323
-
324
- export function SendQuoteButton({ dealId }: { dealId: string }) {
325
- const { apiRequest, organizationId } = useElevasisServices()
326
- const resourceId = resourceDescriptors.sendQuote.id
327
-
328
- const sendQuote = useMutation({
329
- mutationFn: async () => {
330
- if (!organizationId) throw new Error('Organization context is not ready')
331
-
332
- return apiRequest('/execute-async', {
333
- method: 'POST',
334
- body: JSON.stringify({
335
- resourceType: 'workflow',
336
- resourceId,
337
- input: { dealId, organizationId }
338
- })
339
- })
340
- }
341
- })
342
-
343
- return (
344
- <Button loading={sendQuote.isPending} onClick={() => sendQuote.mutate()}>
345
- Send Quote
346
- </Button>
347
- )
348
- }
349
- ```
350
-
351
- Use this button through a custom deal route or a `renderActions` slot where available.
352
-
353
- ## 3. Build a Fully Custom Deal Page
354
-
355
- When you own the full page, use the primitives directly:
356
-
357
- - `useDealDetail(dealId)` loads the deal.
358
- - `deriveActions(deal, crmActions)` filters the action set.
359
- - `useExecuteAction({ dealId })` dispatches platform-known action keys.
360
- - Project-owned workflow buttons call `/execute` or `/execute-async` directly when they are outside the server-dispatched action set.
361
-
362
- ```tsx
363
- import { DEFAULT_CRM_ACTIONS, deriveActions } from '@elevasis/sdk'
364
- import { Group, Stack } from '@mantine/core'
365
- import { useMemo } from 'react'
366
- import { useDealDetail, useExecuteAction } from '@elevasis/ui/hooks'
367
- import { SendQuoteButton } from './SendQuoteButton'
368
-
369
- export function CustomDealPage({ dealId }: { dealId: string }) {
370
- const { data: deal } = useDealDetail(dealId)
371
- const executeAction = useExecuteAction({ dealId })
372
-
373
- const platformActions = useMemo(() => {
374
- return deal ? deriveActions(deal, DEFAULT_CRM_ACTIONS) : []
375
- }, [deal])
376
-
377
- if (!deal) return null
378
-
379
- return (
380
- <Stack>
381
- <Group>
382
- {platformActions.map((action) => (
383
- <button
384
- key={action.key}
385
- type="button"
386
- onClick={() => executeAction.mutate({ key: action.key })}
387
- >
388
- {action.label}
389
- </button>
390
- ))}
391
- <SendQuoteButton dealId={deal.id} />
392
- </Group>
393
- </Stack>
394
- )
395
- }
396
- ```
397
-
398
- ## CRM State-Key Source of Truth
399
-
400
- `CRM_PIPELINE_DEFINITION` in `packages/core/src/organization-model/domains/sales.ts` is the single canonical source for CRM `state_key` values. It exports a `StatefulPipelineDefinition` that enumerates every valid state per stage (e.g. `interested` → `discovery_replied`, `discovery_link_sent`, `discovery_nudging`, etc.). Named per-state constants (`CRM_DISCOVERY_REPLIED_STATE`, `CRM_DISCOVERY_LINK_SENT_STATE`, and siblings) are also exported for use in conditional logic.
401
-
402
- Import via `@repo/core/organization-model`:
403
-
404
- ```ts
405
- import {
406
- CRM_PIPELINE_DEFINITION,
407
- CRM_DISCOVERY_REPLIED_STATE,
408
- getValidStatesForStage
409
- } from '@repo/core/organization-model'
410
-
411
- const validStates = getValidStatesForStage(CRM_PIPELINE_DEFINITION, 'interested')
412
- // [{ stateKey: 'discovery_replied', label: '...' }, ...]
413
- ```
414
-
415
- Workflows that write `state_key` should import from this definition rather than hardcoding string literals. This keeps the vocabulary in one place and lets the API validate against the allowed set for a deal's current stage.
416
-
417
- **Current gap:** `@elevasis/core` v0.13.0 does not yet expose the `organization-model` subpath. Until a new release ships that export, hardcoded strings in `packages/elevasis-operations/...` are tracked as follow-up. Document usages with a `// TODO: replace with CRM_PIPELINE_DEFINITION import once @elevasis/core exposes organization-model` comment so they are easy to find.
418
-
419
- ## Activity Log Conventions
420
-
421
- The `acq_deal_activity_log` table uses a `kind` field to distinguish how a state transition was initiated. Two values are relevant for CRM state changes:
422
-
423
- - `state_change` -- written by workflow steps (e.g. `crm-send-booking-link.ts` transitioning to `discovery_link_sent`). Carries an `action_taken` payload identifying the workflow.
424
- - `state_changed_manually` -- written by the `PATCH /api/deals/:dealId/state` route when an operator edits `state_key` directly from the UI. Carries the user id and the before/after state. This is a pure column update with no workflow side-effects.
425
-
426
- When reading the audit trail, use `kind` to distinguish automated pipeline progression from manual repair operations. Do not conflate the two when building reports or alerting rules.
427
-
428
- ## Verify
429
-
430
- Run the relevant checks from the project root:
431
-
432
- ```bash
433
- pnpm -C operations run check
434
- pnpm -C ui run check
435
- ```
436
-
437
- For a workflow-backed action, deploy or run the workflow smoke before wiring it into the UI:
438
-
439
- ```bash
440
- pnpm -C operations exec elevasis-sdk check
441
- pnpm -C operations exec elevasis-sdk deploy
442
- ```
4
+ ---
5
+ title: Customize CRM Actions
6
+ description: Add, hide, or replace CRM deal action buttons in a template-derived project, and override default platform action workflows with project-owned implementations.
7
+ ---
8
+
9
+ # Customize CRM Actions
10
+
11
+ CRM deal pages derive their action buttons from an `ActionDef[]` array. The shared UI reads that array from the provider, filters it with `deriveActions(deal, actions)`, and renders one-click buttons for actions without a `payloadSchema`.
12
+
13
+ For the broader CRM extension map -- pages, sidebars, hooks, workflow adapters, contracts, and org-model boundaries -- start with [Build and Extend CRM](extend-crm.md). This recipe is only the deal-action path.
14
+
15
+ **Shape reference:** The `ActionDef` flat shape and the consolidation that replaced the old `handler`/`kind` union are documented in `apps/docs/content/docs/in-progress/active-development/sdk-changes/crm/crm-current-state-assessment.mdx`.
16
+
17
+ Use this recipe when a user asks for work like:
18
+
19
+ - "Hide Close Lost from the deal drawer."
20
+ - "Add a Send Quote action to deals in Proposal."
21
+ - "Override Move to Proposal to also create a task in our project tool."
22
+ - "Build a custom deal page that runs one of our workflows."
23
+
24
+ ## How Platform Action Dispatch Works
25
+
26
+ Every default action in `DEFAULT_CRM_ACTIONS` maps to a deployed workflow via its `workflowId` field. When the shared UI calls `POST /deals/:dealId/actions/:actionKey`, the platform:
27
+
28
+ 1. Validates `isAvailableFor(deal)` server-side (security gate -- cannot be skipped).
29
+ 2. Resolves `actionDef.workflowId` to a deployed resource.
30
+ 3. Dispatches through `SingleExecutionCoordinator` -- same execution engine as every other workflow.
31
+ 4. Returns the refetched deal.
32
+
33
+ External projects override an action's behavior by deploying a workflow with the same `workflowId` as the default. The resource registry picks the project-owned workflow over the platform default. The `isAvailableFor` predicate is not overridable in V1 -- it stays in `@core` `DEFAULT_CRM_ACTIONS`. Override what the action does, not when the button shows.
34
+
35
+ ## ActionDef Shape
36
+
37
+ Read the generated contract reference before editing:
38
+
39
+ `node_modules/@elevasis/sdk/reference/scaffold/reference/contracts.md`
40
+
41
+ The current flat shape (no `handler` nesting, no `kind` discriminator):
42
+
43
+ ```ts
44
+ export interface ActionDef {
45
+ key: string
46
+ label: string
47
+ isAvailableFor: (deal: AcqDealRow) => boolean
48
+ workflowId: string
49
+ payloadSchema?: z.ZodTypeAny
50
+ }
51
+ ```
52
+
53
+ `deriveActions(deal, actions)` returns only render-time actions: `{ key, label, payloadSchema? }`. It does not expose `workflowId` or `isAvailableFor` to the browser.
54
+
55
+ A minimal entry looks like:
56
+
57
+ ```ts
58
+ {
59
+ key: 'move_to_proposal',
60
+ label: 'Move to Proposal',
61
+ isAvailableFor: (deal) => deal.stage_key === 'interested',
62
+ workflowId: 'move_to_proposal-workflow',
63
+ payloadSchema: undefined
64
+ }
65
+ ```
66
+
67
+ ## acqDb and crm Helpers
68
+
69
+ Workflows backing CRM actions use worker adapters instead of raw Supabase calls. The acquisition adapter is imported as `acqDb` from `@elevasis/sdk/worker` and exposes the action-workflow helpers:
70
+
71
+ - `acqDb.transitionDeal({ dealId, toStage, toState? })` -- moves a deal to a new stage, optionally updating state_key.
72
+ - `acqDb.recordDealActivity({ dealId, type, title, description?, payload? })` -- appends an entry to the deal's `activity_log` JSONB column. Use this for outbound/inbound audit and lifecycle events.
73
+ - `acqDb.loadDeal({ dealId })` -- returns the deal row joined with contact and company.
74
+
75
+ These wrap existing `LeadService` logic -- they are extraction, not new business logic. The separate `crm` adapter also exposes focused CRM methods such as `crm.recordActivity(...)`; use `acqDb` for action workflows that need deal transitions and the broader acquisition substrate.
76
+
77
+ ## Override a Default Action
78
+
79
+ Deploy a workflow with the same `workflowId` as the default action you want to replace. The resource registry resolves your project-owned workflow first.
80
+
81
+ The canonical transition workflow (see `packages/elevasis-operations/src/sales/crm/actions/move-to-proposal.ts` for the deployed reference):
82
+
83
+ ```ts
84
+ // operations/src/sales/crm/actions/move-to-proposal.ts
85
+ import type { WorkflowDefinition } from '@elevasis/sdk'
86
+ import { acqDb } from '@elevasis/sdk/worker'
87
+ import { resourceDescriptors } from '@core/config/organization-model'
88
+ import { ActionWorkflowInputSchema, ActionWorkflowOutputSchema } from '../shared/action-workflow-schemas.js'
89
+
90
+ export const moveToProposalWorkflow: WorkflowDefinition = {
91
+ config: {
92
+ resource: resourceDescriptors.moveToProposal,
93
+ resourceId: resourceDescriptors.moveToProposal.id,
94
+ name: 'Move to Proposal',
95
+ type: resourceDescriptors.moveToProposal.kind,
96
+ version: '1.0.0',
97
+ status: 'prod',
98
+ category: 'internal',
99
+ },
100
+ contract: {
101
+ inputSchema: ActionWorkflowInputSchema,
102
+ outputSchema: ActionWorkflowOutputSchema
103
+ },
104
+ steps: {
105
+ transition: {
106
+ id: 'transition',
107
+ name: 'Transition to Proposal',
108
+ inputSchema: ActionWorkflowInputSchema,
109
+ outputSchema: ActionWorkflowOutputSchema,
110
+ handler: async (rawInput, context) => {
111
+ const { dealId } = rawInput as { dealId: string; organizationId: string }
112
+ context.logger.info(`[transition] Moving deal ${dealId} to proposal`)
113
+ await acqDb.transitionDeal({ dealId, toStage: 'proposal' })
114
+ return { dealId, sent: false }
115
+ },
116
+ next: null
117
+ }
118
+ },
119
+ entryPoint: 'transition'
120
+ }
121
+ ```
122
+
123
+ To add side effects (create a task, send a Slack message, log a deal activity), extend the handler before the `return`. The OM descriptor ID must match the action's `workflowId` in `DEFAULT_CRM_ACTIONS` from `@elevasis/sdk` exactly.
124
+
125
+ Register the workflow in the operations manifest:
126
+
127
+ ```ts
128
+ // operations/src/index.ts
129
+ import { moveToProposalWorkflow } from './sales/crm/actions/move-to-proposal.js'
130
+
131
+ export const deploymentSpec = {
132
+ workflows: [moveToProposalWorkflow],
133
+ agents: []
134
+ }
135
+ ```
136
+
137
+ Then deploy:
138
+
139
+ ```bash
140
+ pnpm exec elevasis-sdk deploy
141
+ ```
142
+
143
+ ## 1. Override the Shared CRM Action Set (UI-Side)
144
+
145
+ To hide, reorder, or relabel buttons in the shared deal UI, create a local action config module:
146
+
147
+ ```ts
148
+ // ui/src/config/crm-actions.ts
149
+ import { DEFAULT_CRM_ACTIONS, type ActionDef } from '@elevasis/sdk'
150
+
151
+ export const crmActions: ActionDef[] = [
152
+ ...DEFAULT_CRM_ACTIONS.filter((action) => action.key !== 'move_to_closed_lost')
153
+ ]
154
+ ```
155
+
156
+ To add a new action entry alongside the defaults (note: brand-new keys are not server-dispatched until the platform knows their `workflowId`):
157
+
158
+ ```ts
159
+ // ui/src/config/crm-actions.ts
160
+ import { DEFAULT_CRM_ACTIONS, type ActionDef } from '@elevasis/sdk'
161
+
162
+ export const crmActions: ActionDef[] = [
163
+ ...DEFAULT_CRM_ACTIONS,
164
+ {
165
+ key: 'send_quote',
166
+ label: 'Send Quote',
167
+ isAvailableFor: (deal) => deal.stage_key === 'proposal',
168
+ workflowId: 'send-quote-workflow'
169
+ }
170
+ ]
171
+ ```
172
+
173
+ Wire it into the app provider. If the template uses `createElevasisApp`, pass it in the app config:
174
+
175
+ ```tsx
176
+ // ui/src/main.tsx
177
+ import { crmActions } from './config/crm-actions'
178
+
179
+ const App = createElevasisApp({
180
+ router,
181
+ apiUrl: API_URL,
182
+ auth: {
183
+ clientId: import.meta.env.VITE_WORKOS_CLIENT_ID,
184
+ redirectUri: import.meta.env.VITE_WORKOS_REDIRECT_URI,
185
+ devMode: true
186
+ },
187
+ theme: { presets: themePresets, background, loader },
188
+ queryClient,
189
+ crmActions
190
+ })
191
+ ```
192
+
193
+ If the project hand-wires providers, pass the same array to `ElevasisUIProvider` or `ElevasisCoreProvider`:
194
+
195
+ ```tsx
196
+ <ElevasisUIProvider auth={auth} apiUrl={API_URL} crmActions={crmActions}>
197
+ <AppRoutes />
198
+ </ElevasisUIProvider>
199
+ ```
200
+
201
+ This controls the shared `DealDetailPage` and `DealDrawer` action row.
202
+
203
+ ## 2. Add a Custom Workflow-Backed Action
204
+
205
+ For a brand-new action key that calls a project-owned workflow, define the workflow first.
206
+
207
+ If the action sends email, writes to another channel, or otherwise touches a customer, use `acqDb.recordDealActivity` inside the workflow handler to append an audit entry to the deal's `activity_log`. For an advanced Instantly-thread-aware variant that prefers in-thread replies and falls back to fresh outbound, see the canonical CRM action examples in `packages/elevasis-operations/src/sales/crm/actions/`.
208
+
209
+ ### Define the Workflow Contract
210
+
211
+ ```ts
212
+ // core/types/index.ts
213
+ import { z } from 'zod'
214
+
215
+ export const sendQuoteInputSchema = z.object({
216
+ dealId: z.string().uuid(),
217
+ organizationId: z.string().uuid()
218
+ })
219
+
220
+ export const sendQuoteOutputSchema = z.object({
221
+ dealId: z.string().uuid(),
222
+ sent: z.boolean(),
223
+ messageId: z.string().optional()
224
+ })
225
+
226
+ export type SendQuoteInput = z.infer<typeof sendQuoteInputSchema>
227
+ export type SendQuoteOutput = z.infer<typeof sendQuoteOutputSchema>
228
+ ```
229
+
230
+ ### Define the Workflow
231
+
232
+ ```ts
233
+ // operations/src/sales/send-quote.ts
234
+ import type { WorkflowDefinition } from '@elevasis/sdk'
235
+ import { acqDb, createResendAdapter } from '@elevasis/sdk/worker'
236
+ import { resourceDescriptors } from '@core/config/organization-model'
237
+ import {
238
+ sendQuoteInputSchema,
239
+ sendQuoteOutputSchema
240
+ } from '@core/types'
241
+
242
+ export const sendQuoteWorkflow: WorkflowDefinition = {
243
+ config: {
244
+ resource: resourceDescriptors.sendQuote,
245
+ resourceId: resourceDescriptors.sendQuote.id,
246
+ name: 'Send Quote',
247
+ type: resourceDescriptors.sendQuote.kind,
248
+ version: '1.0.0',
249
+ status: 'dev',
250
+ category: 'internal',
251
+ },
252
+ contract: {
253
+ inputSchema: sendQuoteInputSchema,
254
+ outputSchema: sendQuoteOutputSchema
255
+ },
256
+ steps: {
257
+ send: {
258
+ id: 'send',
259
+ name: 'Send Quote Email',
260
+ inputSchema: sendQuoteInputSchema,
261
+ outputSchema: sendQuoteOutputSchema,
262
+ handler: async (rawInput, context) => {
263
+ const { dealId } = rawInput as { dealId: string; organizationId: string }
264
+ const deal = await acqDb.loadDeal({ dealId })
265
+
266
+ const resend = createResendAdapter('my-resend-credential')
267
+ context.logger.info(`[send-quote] Sending quote for deal ${dealId}`)
268
+
269
+ const sent = await resend.sendEmail({
270
+ to: deal.contact.email,
271
+ subject: 'Your quote is ready',
272
+ html: `<p>Your quote is ready. Reply to this email with questions.</p>`
273
+ })
274
+
275
+ await acqDb.recordDealActivity({
276
+ dealId,
277
+ type: 'quote_sent',
278
+ title: 'Sent quote',
279
+ payload: { triggered_by_action: 'send_quote', channel: 'email' }
280
+ })
281
+
282
+ return {
283
+ dealId,
284
+ sent: true,
285
+ messageId: String(sent.id ?? '')
286
+ }
287
+ },
288
+ next: null
289
+ }
290
+ },
291
+ entryPoint: 'send'
292
+ }
293
+ ```
294
+
295
+ Register the workflow in the operations manifest and deploy:
296
+
297
+ ```ts
298
+ // operations/src/index.ts
299
+ import { sendQuoteWorkflow } from './sales/send-quote.js'
300
+
301
+ export const deploymentSpec = {
302
+ workflows: [sendQuoteWorkflow],
303
+ agents: []
304
+ }
305
+ ```
306
+
307
+ ```bash
308
+ pnpm exec elevasis-sdk deploy
309
+ ```
310
+
311
+ ### Choose the UI Path
312
+
313
+ A custom `ActionDef` entry with `workflowId: 'send-quote-workflow'` can be rendered through the shared `crmActions` provider path. Server dispatch through `POST /deals/:dealId/actions/:actionKey` is constrained by the platform-known/default action set in v1, so use a custom deal page or render slot that calls the workflow directly through `/execute` or `/execute-async` when the action key is outside that server-side set.
314
+
315
+ ```tsx
316
+ // ui/src/features/crm/components/SendQuoteButton.tsx
317
+ import { Button } from '@mantine/core'
318
+ import { useMutation } from '@tanstack/react-query'
319
+ import { useElevasisServices } from '@elevasis/ui/provider'
320
+ import { resourceDescriptors } from '@core/config/organization-model'
321
+
322
+ export function SendQuoteButton({ dealId }: { dealId: string }) {
323
+ const { apiRequest, organizationId } = useElevasisServices()
324
+ const resourceId = resourceDescriptors.sendQuote.id
325
+
326
+ const sendQuote = useMutation({
327
+ mutationFn: async () => {
328
+ if (!organizationId) throw new Error('Organization context is not ready')
329
+
330
+ return apiRequest('/execute-async', {
331
+ method: 'POST',
332
+ body: JSON.stringify({
333
+ resourceType: 'workflow',
334
+ resourceId,
335
+ input: { dealId, organizationId }
336
+ })
337
+ })
338
+ }
339
+ })
340
+
341
+ return (
342
+ <Button loading={sendQuote.isPending} onClick={() => sendQuote.mutate()}>
343
+ Send Quote
344
+ </Button>
345
+ )
346
+ }
347
+ ```
348
+
349
+ Use this button through a custom deal route or a `renderActions` slot where available.
350
+
351
+ ## 3. Build a Fully Custom Deal Page
352
+
353
+ When you own the full page, use the primitives directly:
354
+
355
+ - `useDealDetail(dealId)` loads the deal.
356
+ - `deriveActions(deal, crmActions)` filters the action set.
357
+ - `useExecuteAction({ dealId })` dispatches platform-known action keys.
358
+ - Project-owned workflow buttons call `/execute` or `/execute-async` directly when they are outside the server-dispatched action set.
359
+
360
+ ```tsx
361
+ import { DEFAULT_CRM_ACTIONS, deriveActions } from '@elevasis/sdk'
362
+ import { Group, Stack } from '@mantine/core'
363
+ import { useMemo } from 'react'
364
+ import { useDealDetail, useExecuteAction } from '@elevasis/ui/hooks'
365
+ import { SendQuoteButton } from './SendQuoteButton'
366
+
367
+ export function CustomDealPage({ dealId }: { dealId: string }) {
368
+ const { data: deal } = useDealDetail(dealId)
369
+ const executeAction = useExecuteAction({ dealId })
370
+
371
+ const platformActions = useMemo(() => {
372
+ return deal ? deriveActions(deal, DEFAULT_CRM_ACTIONS) : []
373
+ }, [deal])
374
+
375
+ if (!deal) return null
376
+
377
+ return (
378
+ <Stack>
379
+ <Group>
380
+ {platformActions.map((action) => (
381
+ <button
382
+ key={action.key}
383
+ type="button"
384
+ onClick={() => executeAction.mutate({ key: action.key })}
385
+ >
386
+ {action.label}
387
+ </button>
388
+ ))}
389
+ <SendQuoteButton dealId={deal.id} />
390
+ </Group>
391
+ </Stack>
392
+ )
393
+ }
394
+ ```
395
+
396
+ ## CRM State-Key Source of Truth
397
+
398
+ `CRM_PIPELINE_DEFINITION` in `packages/core/src/organization-model/domains/sales.ts` is the single canonical source for CRM `state_key` values. It exports a `StatefulPipelineDefinition` that enumerates every valid state per stage (e.g. `interested` → `discovery_replied`, `discovery_link_sent`, `discovery_nudging`, etc.). Named per-state constants (`CRM_DISCOVERY_REPLIED_STATE`, `CRM_DISCOVERY_LINK_SENT_STATE`, and siblings) are also exported for use in conditional logic.
399
+
400
+ Import via `@repo/core/organization-model`:
401
+
402
+ ```ts
403
+ import {
404
+ CRM_PIPELINE_DEFINITION,
405
+ CRM_DISCOVERY_REPLIED_STATE,
406
+ getValidStatesForStage
407
+ } from '@repo/core/organization-model'
408
+
409
+ const validStates = getValidStatesForStage(CRM_PIPELINE_DEFINITION, 'interested')
410
+ // [{ stateKey: 'discovery_replied', label: '...' }, ...]
411
+ ```
412
+
413
+ Workflows that write `state_key` should import from this definition rather than hardcoding string literals. This keeps the vocabulary in one place and lets the API validate against the allowed set for a deal's current stage.
414
+
415
+ **Current gap:** `@elevasis/core` v0.13.0 does not yet expose the `organization-model` subpath. Until a new release ships that export, hardcoded strings in `packages/elevasis-operations/...` are tracked as follow-up. Document usages with a `// TODO: replace with CRM_PIPELINE_DEFINITION import once @elevasis/core exposes organization-model` comment so they are easy to find.
416
+
417
+ ## Activity Log Conventions
418
+
419
+ The `acq_deal_activity_log` table uses a `kind` field to distinguish how a state transition was initiated. Two values are relevant for CRM state changes:
420
+
421
+ - `state_change` -- written by workflow steps (e.g. `crm-send-booking-link.ts` transitioning to `discovery_link_sent`). Carries an `action_taken` payload identifying the workflow.
422
+ - `state_changed_manually` -- written by the `PATCH /api/deals/:dealId/state` route when an operator edits `state_key` directly from the UI. Carries the user id and the before/after state. This is a pure column update with no workflow side-effects.
423
+
424
+ When reading the audit trail, use `kind` to distinguish automated pipeline progression from manual repair operations. Do not conflate the two when building reports or alerting rules.
425
+
426
+ ## Verify
427
+
428
+ Run the relevant checks from the project root:
429
+
430
+ ```bash
431
+ pnpm -C operations run check
432
+ pnpm -C ui run check
433
+ ```
434
+
435
+ For a workflow-backed action, deploy or run the workflow smoke before wiring it into the UI:
436
+
437
+ ```bash
438
+ pnpm -C operations exec elevasis-sdk check
439
+ pnpm -C operations exec elevasis-sdk deploy
440
+ ```
441
+
442
+