@elevasis/sdk 1.15.1 → 1.17.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 (60) hide show
  1. package/dist/cli.cjs +2325 -124
  2. package/dist/index.d.ts +410 -473
  3. package/dist/index.js +96 -44
  4. package/dist/node/index.d.ts +69 -0
  5. package/dist/node/index.js +273 -0
  6. package/dist/test-utils/index.d.ts +473 -466
  7. package/dist/types/worker/platform.d.ts +2 -9
  8. package/package.json +12 -3
  9. package/reference/_navigation.md +23 -1
  10. package/reference/_reference-manifest.json +98 -0
  11. package/reference/claude-config/rules/agent-start-here.md +13 -0
  12. package/reference/claude-config/rules/organization-model.md +40 -40
  13. package/reference/claude-config/rules/organization-os.md +16 -16
  14. package/reference/claude-config/rules/vibe.md +13 -13
  15. package/reference/claude-config/skills/knowledge/SKILL.md +253 -0
  16. package/reference/claude-config/skills/{configure → knowledge}/operations/codify-level-a.md +100 -100
  17. package/reference/claude-config/skills/{configure → knowledge}/operations/codify-level-b.md +158 -158
  18. package/reference/claude-config/skills/knowledge/operations/customers.md +109 -0
  19. package/reference/claude-config/skills/knowledge/operations/features.md +113 -0
  20. package/reference/claude-config/skills/knowledge/operations/goals.md +118 -0
  21. package/reference/claude-config/skills/knowledge/operations/identity.md +93 -0
  22. package/reference/claude-config/skills/knowledge/operations/labels.md +89 -0
  23. package/reference/claude-config/skills/knowledge/operations/offerings.md +109 -0
  24. package/reference/claude-config/skills/knowledge/operations/roles.md +99 -0
  25. package/reference/claude-config/skills/knowledge/operations/techStack.md +102 -0
  26. package/reference/claude-config/skills/run-ui/SKILL.md +73 -0
  27. package/reference/claude-config/skills/setup/SKILL.md +270 -270
  28. package/reference/claude-config/skills/tutorial/SKILL.md +249 -0
  29. package/reference/claude-config/skills/tutorial/progress-template.md +74 -0
  30. package/reference/claude-config/skills/tutorial/technical.md +1309 -0
  31. package/reference/claude-config/skills/tutorial/vibe-coder.md +890 -0
  32. package/reference/claude-config/sync-notes/2026-05-04-elevasis-workspace.md +71 -0
  33. package/reference/claude-config/sync-notes/2026-05-04-knowledge-bundle.md +83 -0
  34. package/reference/claude-config/sync-notes/2026-05-04-template-skills-run-ui-and-tutorial.md +59 -0
  35. package/reference/deployment/index.mdx +5 -5
  36. package/reference/examples/organization-model.ts +40 -0
  37. package/reference/framework/index.mdx +1 -1
  38. package/reference/framework/tutorial-system.mdx +86 -173
  39. package/reference/packages/core/src/knowledge/README.md +32 -0
  40. package/reference/packages/ui/src/knowledge/README.md +31 -0
  41. package/reference/packages/ui/src/theme/presets/README.md +19 -0
  42. package/reference/scaffold/core/organization-model.mdx +1 -1
  43. package/reference/scaffold/recipes/add-a-feature.md +1 -1
  44. package/reference/scaffold/recipes/customize-crm-actions.md +433 -433
  45. package/reference/scaffold/recipes/customize-organization-model.md +3 -3
  46. package/reference/scaffold/recipes/extend-lead-gen.md +90 -55
  47. package/reference/scaffold/recipes/gate-by-feature-or-admin.md +1 -1
  48. package/reference/scaffold/recipes/index.md +6 -0
  49. package/reference/scaffold/reference/contracts.md +1265 -1154
  50. package/reference/scaffold/reference/feature-registry.md +2 -1
  51. package/reference/scaffold/ui/composition-extensibility.mdx +17 -0
  52. package/reference/claude-config/skills/configure/SKILL.md +0 -98
  53. package/reference/claude-config/skills/configure/operations/customers.md +0 -150
  54. package/reference/claude-config/skills/configure/operations/features.md +0 -162
  55. package/reference/claude-config/skills/configure/operations/goals.md +0 -147
  56. package/reference/claude-config/skills/configure/operations/identity.md +0 -133
  57. package/reference/claude-config/skills/configure/operations/labels.md +0 -128
  58. package/reference/claude-config/skills/configure/operations/offerings.md +0 -159
  59. package/reference/claude-config/skills/configure/operations/roles.md +0 -153
  60. package/reference/claude-config/skills/configure/operations/techStack.md +0 -139
@@ -1,436 +1,436 @@
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
- ---
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
5
  <!-- @generated by packages/sdk/scripts/copy-reference-docs.mjs -- DO NOT EDIT -->
6
6
  <!-- Regenerate: pnpm scaffold:sync -->
7
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 `external/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 { ActionWorkflowInputSchema, ActionWorkflowOutputSchema } from '../shared/action-workflow-schemas.js'
88
-
89
- export const moveToProposalWorkflow: WorkflowDefinition = {
90
- config: {
91
- resourceId: 'move_to_proposal-workflow',
92
- name: 'Move to Proposal',
93
- type: 'workflow',
94
- version: '1.0.0',
95
- status: 'prod',
96
- category: 'internal',
97
- links: [{ nodeId: 'feature:sales.crm', kind: 'operates-on' }]
98
- },
99
- contract: {
100
- inputSchema: ActionWorkflowInputSchema,
101
- outputSchema: ActionWorkflowOutputSchema
102
- },
103
- steps: {
104
- transition: {
105
- id: 'transition',
106
- name: 'Transition to Proposal',
107
- inputSchema: ActionWorkflowInputSchema,
108
- outputSchema: ActionWorkflowOutputSchema,
109
- handler: async (rawInput, context) => {
110
- const { dealId } = rawInput as { dealId: string; organizationId: string }
111
- context.logger.info(`[transition] Moving deal ${dealId} to proposal`)
112
- await acqDb.transitionDeal({ dealId, toStage: 'proposal' })
113
- return { dealId, sent: false }
114
- },
115
- next: null
116
- }
117
- },
118
- entryPoint: 'transition'
119
- }
120
- ```
121
-
122
- To add side effects (create a task, send a Slack message, log a deal activity), extend the handler before the `return`. The `resourceId` must match the action's `workflowId` in `DEFAULT_CRM_ACTIONS` from `@elevasis/sdk` exactly.
123
-
124
- Register the workflow in the operations manifest:
125
-
126
- ```ts
127
- // operations/src/index.ts
128
- import { moveToProposalWorkflow } from './sales/crm/actions/move-to-proposal.js'
129
-
130
- export const deploymentSpec = {
131
- workflows: [moveToProposalWorkflow],
132
- agents: []
133
- }
134
- ```
135
-
136
- Then deploy:
137
-
138
- ```bash
139
- pnpm exec elevasis-sdk deploy
140
- ```
141
-
142
- ## 1. Override the Shared CRM Action Set (UI-Side)
143
-
144
- To hide, reorder, or relabel buttons in the shared deal UI, create a local action config module:
145
-
146
- ```ts
147
- // ui/src/config/crm-actions.ts
148
- import { DEFAULT_CRM_ACTIONS, type ActionDef } from '@elevasis/sdk'
149
-
150
- export const crmActions: ActionDef[] = [
151
- ...DEFAULT_CRM_ACTIONS.filter((action) => action.key !== 'move_to_closed_lost')
152
- ]
153
- ```
154
-
155
- To add a new action entry alongside the defaults (note: brand-new keys are not server-dispatched until the platform knows their `workflowId`):
156
-
157
- ```ts
158
- // ui/src/config/crm-actions.ts
159
- import { DEFAULT_CRM_ACTIONS, type ActionDef } from '@elevasis/sdk'
160
-
161
- export const crmActions: ActionDef[] = [
162
- ...DEFAULT_CRM_ACTIONS,
163
- {
164
- key: 'send_quote',
165
- label: 'Send Quote',
166
- isAvailableFor: (deal) => deal.stage_key === 'proposal',
167
- workflowId: 'send-quote-workflow'
168
- }
169
- ]
170
- ```
171
-
172
- Wire it into the app provider. If the template uses `createElevasisApp`, pass it in the app config:
173
-
174
- ```tsx
175
- // ui/src/main.tsx
176
- import { crmActions } from './config/crm-actions'
177
-
178
- const App = createElevasisApp({
179
- router,
180
- apiUrl: API_URL,
181
- auth: {
182
- clientId: import.meta.env.VITE_WORKOS_CLIENT_ID,
183
- redirectUri: import.meta.env.VITE_WORKOS_REDIRECT_URI,
184
- devMode: true
185
- },
186
- theme: { presets: themePresets, background, loader },
187
- queryClient,
188
- crmActions
189
- })
190
- ```
191
-
192
- If the project hand-wires providers, pass the same array to `ElevasisUIProvider` or `ElevasisCoreProvider`:
193
-
194
- ```tsx
195
- <ElevasisUIProvider auth={auth} apiUrl={API_URL} crmActions={crmActions}>
196
- <AppRoutes />
197
- </ElevasisUIProvider>
198
- ```
199
-
200
- This controls the shared `DealDetailPage` and `DealDrawer` action row.
201
-
202
- ## 2. Add a Custom Workflow-Backed Action
203
-
204
- For a brand-new action key that calls a project-owned workflow, define the workflow first.
205
-
206
- 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 `external/elevasis/operations/src/sales/crm/actions/`.
207
-
208
- ### Define the Workflow Contract
209
-
210
- ```ts
211
- // core/types/index.ts
212
- import { z } from 'zod'
213
-
214
- export const sendQuoteInputSchema = z.object({
215
- dealId: z.string().uuid(),
216
- organizationId: z.string().uuid()
217
- })
218
-
219
- export const sendQuoteOutputSchema = z.object({
220
- dealId: z.string().uuid(),
221
- sent: z.boolean(),
222
- messageId: z.string().optional()
223
- })
224
-
225
- export type SendQuoteInput = z.infer<typeof sendQuoteInputSchema>
226
- export type SendQuoteOutput = z.infer<typeof sendQuoteOutputSchema>
227
- ```
228
-
229
- ### Define the Workflow
230
-
231
- ```ts
232
- // operations/src/sales/send-quote.ts
233
- import type { WorkflowDefinition } from '@elevasis/sdk'
234
- import { acqDb, createResendAdapter } from '@elevasis/sdk/worker'
235
- import {
236
- sendQuoteInputSchema,
237
- sendQuoteOutputSchema
238
- } from '@core/types'
239
-
240
- export const sendQuoteWorkflow: WorkflowDefinition = {
241
- config: {
242
- resourceId: 'send-quote-workflow',
243
- name: 'Send Quote',
244
- type: 'workflow',
245
- version: '1.0.0',
246
- status: 'dev',
247
- category: 'internal',
248
- links: [{ nodeId: 'feature:sales.crm', kind: 'operates-on' }]
249
- },
250
- contract: {
251
- inputSchema: sendQuoteInputSchema,
252
- outputSchema: sendQuoteOutputSchema
253
- },
254
- steps: {
255
- send: {
256
- id: 'send',
257
- name: 'Send Quote Email',
258
- inputSchema: sendQuoteInputSchema,
259
- outputSchema: sendQuoteOutputSchema,
260
- handler: async (rawInput, context) => {
261
- const { dealId } = rawInput as { dealId: string; organizationId: string }
262
- const deal = await acqDb.loadDeal({ dealId })
263
-
264
- const resend = createResendAdapter('my-resend-credential')
265
- context.logger.info(`[send-quote] Sending quote for deal ${dealId}`)
266
-
267
- const sent = await resend.sendEmail({
268
- to: deal.contact.email,
269
- subject: 'Your quote is ready',
270
- html: `<p>Your quote is ready. Reply to this email with questions.</p>`
271
- })
272
-
273
- await acqDb.recordDealActivity({
274
- dealId,
275
- type: 'quote_sent',
276
- title: 'Sent quote',
277
- payload: { triggered_by_action: 'send_quote', channel: 'email' }
278
- })
279
-
280
- return {
281
- dealId,
282
- sent: true,
283
- messageId: String(sent.id ?? '')
284
- }
285
- },
286
- next: null
287
- }
288
- },
289
- entryPoint: 'send'
290
- }
291
- ```
292
-
293
- Register the workflow in the operations manifest and deploy:
294
-
295
- ```ts
296
- // operations/src/index.ts
297
- import { sendQuoteWorkflow } from './sales/send-quote.js'
298
-
299
- export const deploymentSpec = {
300
- workflows: [sendQuoteWorkflow],
301
- agents: []
302
- }
303
- ```
304
-
305
- ```bash
306
- pnpm exec elevasis-sdk deploy
307
- ```
308
-
309
- ### Choose the UI Path
310
-
311
- 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.
312
-
313
- ```tsx
314
- // ui/src/features/crm/components/SendQuoteButton.tsx
315
- import { Button } from '@mantine/core'
316
- import { useMutation } from '@tanstack/react-query'
317
- import { useElevasisServices } from '@elevasis/ui/provider'
318
-
319
- export function SendQuoteButton({ dealId }: { dealId: string }) {
320
- const { apiRequest, organizationId } = useElevasisServices()
321
-
322
- const sendQuote = useMutation({
323
- mutationFn: async () => {
324
- if (!organizationId) throw new Error('Organization context is not ready')
325
-
326
- return apiRequest('/execute-async', {
327
- method: 'POST',
328
- body: JSON.stringify({
329
- resourceType: 'workflow',
330
- resourceId: 'send-quote-workflow',
331
- input: { dealId, organizationId }
332
- })
333
- })
334
- }
335
- })
336
-
337
- return (
338
- <Button loading={sendQuote.isPending} onClick={() => sendQuote.mutate()}>
339
- Send Quote
340
- </Button>
341
- )
342
- }
343
- ```
344
-
345
- Use this button through a custom deal route or a `renderActions` slot where available.
346
-
347
- ## 3. Build a Fully Custom Deal Page
348
-
349
- When you own the full page, use the primitives directly:
350
-
351
- - `useDealDetail(dealId)` loads the deal.
352
- - `deriveActions(deal, crmActions)` filters the action set.
353
- - `useExecuteAction({ dealId })` dispatches platform-known action keys.
354
- - Project-owned workflow buttons call `/execute` or `/execute-async` directly when they are outside the server-dispatched action set.
355
-
356
- ```tsx
357
- import { DEFAULT_CRM_ACTIONS, deriveActions } from '@elevasis/sdk'
358
- import { Group, Stack } from '@mantine/core'
359
- import { useMemo } from 'react'
360
- import { useDealDetail, useExecuteAction } from '@elevasis/ui/hooks'
361
- import { SendQuoteButton } from './SendQuoteButton'
362
-
363
- export function CustomDealPage({ dealId }: { dealId: string }) {
364
- const { data: deal } = useDealDetail(dealId)
365
- const executeAction = useExecuteAction({ dealId })
366
-
367
- const platformActions = useMemo(() => {
368
- return deal ? deriveActions(deal, DEFAULT_CRM_ACTIONS) : []
369
- }, [deal])
370
-
371
- if (!deal) return null
372
-
373
- return (
374
- <Stack>
375
- <Group>
376
- {platformActions.map((action) => (
377
- <button
378
- key={action.key}
379
- type="button"
380
- onClick={() => executeAction.mutate({ key: action.key })}
381
- >
382
- {action.label}
383
- </button>
384
- ))}
385
- <SendQuoteButton dealId={deal.id} />
386
- </Group>
387
- </Stack>
388
- )
389
- }
390
- ```
391
-
392
- ## CRM State-Key Source of Truth
393
-
394
- `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.
395
-
396
- Import via `@repo/core/organization-model`:
397
-
398
- ```ts
399
- import {
400
- CRM_PIPELINE_DEFINITION,
401
- CRM_DISCOVERY_REPLIED_STATE,
402
- getValidStatesForStage
403
- } from '@repo/core/organization-model'
404
-
405
- const validStates = getValidStatesForStage(CRM_PIPELINE_DEFINITION, 'interested')
406
- // [{ stateKey: 'discovery_replied', label: '...' }, ...]
407
- ```
408
-
409
- 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.
410
-
411
- **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 `external/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.
412
-
413
- ## Activity Log Conventions
414
-
415
- 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:
416
-
417
- - `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.
418
- - `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.
419
-
420
- 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.
421
-
422
- ## Verify
423
-
424
- Run the relevant checks from the project root:
425
-
426
- ```bash
427
- pnpm -C operations run check
428
- pnpm -C ui run check
429
- ```
430
-
431
- For a workflow-backed action, deploy or run the workflow smoke before wiring it into the UI:
432
-
433
- ```bash
434
- pnpm -C operations exec elevasis-sdk check
435
- pnpm -C operations exec elevasis-sdk deploy
436
- ```
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 { ActionWorkflowInputSchema, ActionWorkflowOutputSchema } from '../shared/action-workflow-schemas.js'
88
+
89
+ export const moveToProposalWorkflow: WorkflowDefinition = {
90
+ config: {
91
+ resourceId: 'move_to_proposal-workflow',
92
+ name: 'Move to Proposal',
93
+ type: 'workflow',
94
+ version: '1.0.0',
95
+ status: 'prod',
96
+ category: 'internal',
97
+ links: [{ nodeId: 'feature:sales.crm', kind: 'operates-on' }]
98
+ },
99
+ contract: {
100
+ inputSchema: ActionWorkflowInputSchema,
101
+ outputSchema: ActionWorkflowOutputSchema
102
+ },
103
+ steps: {
104
+ transition: {
105
+ id: 'transition',
106
+ name: 'Transition to Proposal',
107
+ inputSchema: ActionWorkflowInputSchema,
108
+ outputSchema: ActionWorkflowOutputSchema,
109
+ handler: async (rawInput, context) => {
110
+ const { dealId } = rawInput as { dealId: string; organizationId: string }
111
+ context.logger.info(`[transition] Moving deal ${dealId} to proposal`)
112
+ await acqDb.transitionDeal({ dealId, toStage: 'proposal' })
113
+ return { dealId, sent: false }
114
+ },
115
+ next: null
116
+ }
117
+ },
118
+ entryPoint: 'transition'
119
+ }
120
+ ```
121
+
122
+ To add side effects (create a task, send a Slack message, log a deal activity), extend the handler before the `return`. The `resourceId` must match the action's `workflowId` in `DEFAULT_CRM_ACTIONS` from `@elevasis/sdk` exactly.
123
+
124
+ Register the workflow in the operations manifest:
125
+
126
+ ```ts
127
+ // operations/src/index.ts
128
+ import { moveToProposalWorkflow } from './sales/crm/actions/move-to-proposal.js'
129
+
130
+ export const deploymentSpec = {
131
+ workflows: [moveToProposalWorkflow],
132
+ agents: []
133
+ }
134
+ ```
135
+
136
+ Then deploy:
137
+
138
+ ```bash
139
+ pnpm exec elevasis-sdk deploy
140
+ ```
141
+
142
+ ## 1. Override the Shared CRM Action Set (UI-Side)
143
+
144
+ To hide, reorder, or relabel buttons in the shared deal UI, create a local action config module:
145
+
146
+ ```ts
147
+ // ui/src/config/crm-actions.ts
148
+ import { DEFAULT_CRM_ACTIONS, type ActionDef } from '@elevasis/sdk'
149
+
150
+ export const crmActions: ActionDef[] = [
151
+ ...DEFAULT_CRM_ACTIONS.filter((action) => action.key !== 'move_to_closed_lost')
152
+ ]
153
+ ```
154
+
155
+ To add a new action entry alongside the defaults (note: brand-new keys are not server-dispatched until the platform knows their `workflowId`):
156
+
157
+ ```ts
158
+ // ui/src/config/crm-actions.ts
159
+ import { DEFAULT_CRM_ACTIONS, type ActionDef } from '@elevasis/sdk'
160
+
161
+ export const crmActions: ActionDef[] = [
162
+ ...DEFAULT_CRM_ACTIONS,
163
+ {
164
+ key: 'send_quote',
165
+ label: 'Send Quote',
166
+ isAvailableFor: (deal) => deal.stage_key === 'proposal',
167
+ workflowId: 'send-quote-workflow'
168
+ }
169
+ ]
170
+ ```
171
+
172
+ Wire it into the app provider. If the template uses `createElevasisApp`, pass it in the app config:
173
+
174
+ ```tsx
175
+ // ui/src/main.tsx
176
+ import { crmActions } from './config/crm-actions'
177
+
178
+ const App = createElevasisApp({
179
+ router,
180
+ apiUrl: API_URL,
181
+ auth: {
182
+ clientId: import.meta.env.VITE_WORKOS_CLIENT_ID,
183
+ redirectUri: import.meta.env.VITE_WORKOS_REDIRECT_URI,
184
+ devMode: true
185
+ },
186
+ theme: { presets: themePresets, background, loader },
187
+ queryClient,
188
+ crmActions
189
+ })
190
+ ```
191
+
192
+ If the project hand-wires providers, pass the same array to `ElevasisUIProvider` or `ElevasisCoreProvider`:
193
+
194
+ ```tsx
195
+ <ElevasisUIProvider auth={auth} apiUrl={API_URL} crmActions={crmActions}>
196
+ <AppRoutes />
197
+ </ElevasisUIProvider>
198
+ ```
199
+
200
+ This controls the shared `DealDetailPage` and `DealDrawer` action row.
201
+
202
+ ## 2. Add a Custom Workflow-Backed Action
203
+
204
+ For a brand-new action key that calls a project-owned workflow, define the workflow first.
205
+
206
+ 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/`.
207
+
208
+ ### Define the Workflow Contract
209
+
210
+ ```ts
211
+ // core/types/index.ts
212
+ import { z } from 'zod'
213
+
214
+ export const sendQuoteInputSchema = z.object({
215
+ dealId: z.string().uuid(),
216
+ organizationId: z.string().uuid()
217
+ })
218
+
219
+ export const sendQuoteOutputSchema = z.object({
220
+ dealId: z.string().uuid(),
221
+ sent: z.boolean(),
222
+ messageId: z.string().optional()
223
+ })
224
+
225
+ export type SendQuoteInput = z.infer<typeof sendQuoteInputSchema>
226
+ export type SendQuoteOutput = z.infer<typeof sendQuoteOutputSchema>
227
+ ```
228
+
229
+ ### Define the Workflow
230
+
231
+ ```ts
232
+ // operations/src/sales/send-quote.ts
233
+ import type { WorkflowDefinition } from '@elevasis/sdk'
234
+ import { acqDb, createResendAdapter } from '@elevasis/sdk/worker'
235
+ import {
236
+ sendQuoteInputSchema,
237
+ sendQuoteOutputSchema
238
+ } from '@core/types'
239
+
240
+ export const sendQuoteWorkflow: WorkflowDefinition = {
241
+ config: {
242
+ resourceId: 'send-quote-workflow',
243
+ name: 'Send Quote',
244
+ type: 'workflow',
245
+ version: '1.0.0',
246
+ status: 'dev',
247
+ category: 'internal',
248
+ links: [{ nodeId: 'feature:sales.crm', kind: 'operates-on' }]
249
+ },
250
+ contract: {
251
+ inputSchema: sendQuoteInputSchema,
252
+ outputSchema: sendQuoteOutputSchema
253
+ },
254
+ steps: {
255
+ send: {
256
+ id: 'send',
257
+ name: 'Send Quote Email',
258
+ inputSchema: sendQuoteInputSchema,
259
+ outputSchema: sendQuoteOutputSchema,
260
+ handler: async (rawInput, context) => {
261
+ const { dealId } = rawInput as { dealId: string; organizationId: string }
262
+ const deal = await acqDb.loadDeal({ dealId })
263
+
264
+ const resend = createResendAdapter('my-resend-credential')
265
+ context.logger.info(`[send-quote] Sending quote for deal ${dealId}`)
266
+
267
+ const sent = await resend.sendEmail({
268
+ to: deal.contact.email,
269
+ subject: 'Your quote is ready',
270
+ html: `<p>Your quote is ready. Reply to this email with questions.</p>`
271
+ })
272
+
273
+ await acqDb.recordDealActivity({
274
+ dealId,
275
+ type: 'quote_sent',
276
+ title: 'Sent quote',
277
+ payload: { triggered_by_action: 'send_quote', channel: 'email' }
278
+ })
279
+
280
+ return {
281
+ dealId,
282
+ sent: true,
283
+ messageId: String(sent.id ?? '')
284
+ }
285
+ },
286
+ next: null
287
+ }
288
+ },
289
+ entryPoint: 'send'
290
+ }
291
+ ```
292
+
293
+ Register the workflow in the operations manifest and deploy:
294
+
295
+ ```ts
296
+ // operations/src/index.ts
297
+ import { sendQuoteWorkflow } from './sales/send-quote.js'
298
+
299
+ export const deploymentSpec = {
300
+ workflows: [sendQuoteWorkflow],
301
+ agents: []
302
+ }
303
+ ```
304
+
305
+ ```bash
306
+ pnpm exec elevasis-sdk deploy
307
+ ```
308
+
309
+ ### Choose the UI Path
310
+
311
+ 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.
312
+
313
+ ```tsx
314
+ // ui/src/features/crm/components/SendQuoteButton.tsx
315
+ import { Button } from '@mantine/core'
316
+ import { useMutation } from '@tanstack/react-query'
317
+ import { useElevasisServices } from '@elevasis/ui/provider'
318
+
319
+ export function SendQuoteButton({ dealId }: { dealId: string }) {
320
+ const { apiRequest, organizationId } = useElevasisServices()
321
+
322
+ const sendQuote = useMutation({
323
+ mutationFn: async () => {
324
+ if (!organizationId) throw new Error('Organization context is not ready')
325
+
326
+ return apiRequest('/execute-async', {
327
+ method: 'POST',
328
+ body: JSON.stringify({
329
+ resourceType: 'workflow',
330
+ resourceId: 'send-quote-workflow',
331
+ input: { dealId, organizationId }
332
+ })
333
+ })
334
+ }
335
+ })
336
+
337
+ return (
338
+ <Button loading={sendQuote.isPending} onClick={() => sendQuote.mutate()}>
339
+ Send Quote
340
+ </Button>
341
+ )
342
+ }
343
+ ```
344
+
345
+ Use this button through a custom deal route or a `renderActions` slot where available.
346
+
347
+ ## 3. Build a Fully Custom Deal Page
348
+
349
+ When you own the full page, use the primitives directly:
350
+
351
+ - `useDealDetail(dealId)` loads the deal.
352
+ - `deriveActions(deal, crmActions)` filters the action set.
353
+ - `useExecuteAction({ dealId })` dispatches platform-known action keys.
354
+ - Project-owned workflow buttons call `/execute` or `/execute-async` directly when they are outside the server-dispatched action set.
355
+
356
+ ```tsx
357
+ import { DEFAULT_CRM_ACTIONS, deriveActions } from '@elevasis/sdk'
358
+ import { Group, Stack } from '@mantine/core'
359
+ import { useMemo } from 'react'
360
+ import { useDealDetail, useExecuteAction } from '@elevasis/ui/hooks'
361
+ import { SendQuoteButton } from './SendQuoteButton'
362
+
363
+ export function CustomDealPage({ dealId }: { dealId: string }) {
364
+ const { data: deal } = useDealDetail(dealId)
365
+ const executeAction = useExecuteAction({ dealId })
366
+
367
+ const platformActions = useMemo(() => {
368
+ return deal ? deriveActions(deal, DEFAULT_CRM_ACTIONS) : []
369
+ }, [deal])
370
+
371
+ if (!deal) return null
372
+
373
+ return (
374
+ <Stack>
375
+ <Group>
376
+ {platformActions.map((action) => (
377
+ <button
378
+ key={action.key}
379
+ type="button"
380
+ onClick={() => executeAction.mutate({ key: action.key })}
381
+ >
382
+ {action.label}
383
+ </button>
384
+ ))}
385
+ <SendQuoteButton dealId={deal.id} />
386
+ </Group>
387
+ </Stack>
388
+ )
389
+ }
390
+ ```
391
+
392
+ ## CRM State-Key Source of Truth
393
+
394
+ `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.
395
+
396
+ Import via `@repo/core/organization-model`:
397
+
398
+ ```ts
399
+ import {
400
+ CRM_PIPELINE_DEFINITION,
401
+ CRM_DISCOVERY_REPLIED_STATE,
402
+ getValidStatesForStage
403
+ } from '@repo/core/organization-model'
404
+
405
+ const validStates = getValidStatesForStage(CRM_PIPELINE_DEFINITION, 'interested')
406
+ // [{ stateKey: 'discovery_replied', label: '...' }, ...]
407
+ ```
408
+
409
+ 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.
410
+
411
+ **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.
412
+
413
+ ## Activity Log Conventions
414
+
415
+ 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:
416
+
417
+ - `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.
418
+ - `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.
419
+
420
+ 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.
421
+
422
+ ## Verify
423
+
424
+ Run the relevant checks from the project root:
425
+
426
+ ```bash
427
+ pnpm -C operations run check
428
+ pnpm -C ui run check
429
+ ```
430
+
431
+ For a workflow-backed action, deploy or run the workflow smoke before wiring it into the UI:
432
+
433
+ ```bash
434
+ pnpm -C operations exec elevasis-sdk check
435
+ pnpm -C operations exec elevasis-sdk deploy
436
+ ```