@elevasis/sdk 1.56.0 → 1.57.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 (40) hide show
  1. package/dist/{chunk-72ZGICTR.js → chunk-3XEHBMWM.js} +1 -1
  2. package/dist/{chunk-R3J6BEPO.js → chunk-HDMMMDIR.js} +225 -67
  3. package/dist/cli.cjs +209 -109
  4. package/dist/index.d.ts +82 -20
  5. package/dist/index.js +1 -1
  6. package/dist/test-utils/index.js +2 -2
  7. package/dist/worker/index.js +2 -2
  8. package/package.json +2 -2
  9. package/reference/_navigation.md +1 -1
  10. package/reference/index.mdx +1 -1
  11. package/reference/rules/agent-start-here.md +1 -1
  12. package/reference/rules/organization-model.md +3 -2
  13. package/reference/scaffold/recipes/customize-crm-actions.md +231 -223
  14. package/reference/scaffold/recipes/extend-crm.md +4 -2
  15. package/reference/scaffold/recipes/extend-lead-gen.md +2 -2
  16. package/reference/scaffold/recipes/index.md +2 -2
  17. package/reference/scaffold/reference/contracts.md +40 -12
  18. package/reference/scaffold/reference/system-interface-capabilities.md +4 -4
  19. package/reference/sdk/cli-management.mdx +0 -4
  20. package/reference/sdk/cli.mdx +0 -4
  21. package/reference/sdk/concepts.mdx +0 -4
  22. package/reference/sdk/deployment/command-center.mdx +0 -4
  23. package/reference/sdk/framework/agent.mdx +0 -4
  24. package/reference/sdk/framework/index.mdx +0 -4
  25. package/reference/sdk/framework/project-structure.mdx +0 -4
  26. package/reference/sdk/getting-started.mdx +0 -4
  27. package/reference/sdk/index.mdx +4 -8
  28. package/reference/sdk/platform-tools/index.mdx +0 -4
  29. package/reference/sdk/resources/index.mdx +0 -4
  30. package/reference/sdk/resources/patterns.mdx +0 -4
  31. package/reference/sdk/runtime.mdx +0 -4
  32. package/reference/sdk/templates/data-enrichment.mdx +0 -4
  33. package/reference/sdk/templates/email-sender.mdx +0 -4
  34. package/reference/sdk/templates/index.mdx +0 -4
  35. package/reference/sdk/templates/lead-scorer.mdx +0 -4
  36. package/reference/sdk/templates/pdf-generator.mdx +0 -4
  37. package/reference/sdk/templates/recurring-job.mdx +0 -4
  38. package/reference/sdk/templates/text-classifier.mdx +0 -4
  39. package/reference/sdk/templates/web-scraper.mdx +0 -4
  40. package/reference/sdk/troubleshooting.mdx +0 -4
@@ -1,192 +1,121 @@
1
+ ---
2
+ title: Customize CRM Actions
3
+ description: Add, hide, reorder, or re-implement CRM deal action buttons by authoring them in your project's Organization Model and deploying the workflows they dispatch to.
4
+ ---
1
5
  <!-- @generated by packages/sdk/scripts/copy-reference-docs.mjs -- DO NOT EDIT -->
2
6
  <!-- Regenerate: pnpm scaffold:sync -->
3
7
 
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
8
 
9
9
  # Customize CRM Actions
10
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`.
11
+ CRM deal action buttons are authored in the Organization Model, not in code. Each button is one `actionTypes` record under the `sales.crm` System's ontology scope. The record carries the button's label, its order in the action bar, the conditions under which it appears, and the id of the deployed resource it dispatches to.
12
12
 
13
- For the broader CRM extension map -- pages, sidebars, hooks, workflow adapters, System Interfaces, and org-model boundaries -- start with [Build and Extend CRM](extend-crm.md). This recipe is only the deal-action path.
13
+ `compileCrmActionCatalog` turns those records into a `CompiledCrmAction[]`. The shared UI reads that array from the provider and filters it per deal with `deriveCompiledCrmActions`; the API re-checks the same conditions server-side before dispatching.
14
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/technical/features/operations/crm/index.mdx`.
15
+ For the broader CRM extension map -- pages, sidebars, hooks, workflow adapters, System Interfaces, and org-model boundaries -- start with [Build and Extend CRM](extend-crm.md). This recipe is only the deal-action path.
16
16
 
17
17
  Use this recipe when a user asks for work like:
18
18
 
19
19
  - "Hide Close Lost from the deal drawer."
20
20
  - "Add a Send Quote action to deals in Proposal."
21
- - "Override Move to Proposal to also create a task in our project tool."
21
+ - "Change what Move to Proposal does."
22
+ - "Reorder the deal action buttons."
22
23
  - "Build a custom deal page that runs one of our workflows."
23
24
 
24
- ## How Platform Action Dispatch Works
25
-
26
- Every action in the CRM action catalog 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 action entry. The resource registry picks the project-owned workflow over the platform default. The `isAvailableFor` predicate comes from the caller-supplied action catalog; override what the action does and keep button availability aligned with the server-side gate.
34
-
35
- CRM action dispatch is an API-backed surface. The tenant Organization Model must expose an active and ready `sales.crm.apiInterface` marker before shared CRM action buttons are considered ready. If a lead-gen workflow hands off into CRM, model that crossing through lead-gen resources that bind the CRM pipeline plus a scoped topology grant to `sales.crm.apiInterface` instead of making base lead-gen readiness depend on CRM catalogs.
36
-
37
- ## ActionDef Shape
38
-
39
- Read the generated contract reference before editing:
25
+ ## One Action, One Record
40
26
 
41
- `operations/node_modules/@elevasis/sdk/reference/scaffold/reference/contracts.md`
27
+ An action exists exactly once, in the OM. There is no second array to keep in sync and no override-by-shadowing mechanism -- the record's `resourceId` is the single binding between the button and the code that runs.
42
28
 
43
- The current flat shape (no `handler` nesting, no `kind` discriminator):
29
+ The ontology id's local id **is** the action key used in `POST /deals/:dealId/actions/:actionKey`. `sales.crm:action/move_to_proposal` is the `move_to_proposal` button.
44
30
 
45
- <!-- doc-snippet:skip: shape excerpt from derive-actions.ts -- AcqDealRow and z are shown unimported here for readability -->
31
+ <!-- doc-snippet:skip: illustrative OM excerpt -- one entry from the sales.crm System's ontology.actionTypes map -->
46
32
 
47
33
  ```ts
48
- export interface ActionDef {
49
- key: string
50
- label: string
51
- isAvailableFor: (deal: AcqDealRow) => boolean
52
- workflowId: string
53
- payloadSchema?: z.ZodTypeAny
54
- }
55
- ```
56
-
57
- `deriveActions(deal, actions)` returns only render-time actions: `{ key, label, payloadSchema? }`. It does not expose `workflowId` or `isAvailableFor` to the browser.
58
-
59
- A minimal entry looks like:
60
-
61
- <!-- doc-snippet:skip: illustrative excerpt, not a standalone compilable file -->
62
-
63
- ```ts
64
- {
65
- key: 'move_to_proposal',
34
+ // core/config/organization-model.ts, under the sales.crm System
35
+ 'sales.crm:action/move_to_proposal': {
36
+ id: 'sales.crm:action/move_to_proposal',
66
37
  label: 'Move to Proposal',
67
- isAvailableFor: (deal) => deal.stage_key === 'interested',
68
- workflowId: 'move_to_proposal-workflow',
69
- payloadSchema: undefined
38
+ description: 'Advance a qualified CRM deal into the proposal stage.',
39
+ ownerSystemId: 'sales.crm',
40
+ order: 10,
41
+ actsOn: ['sales.crm:object/deal'],
42
+ resourceId: 'move_to_proposal-workflow',
43
+ availableFrom: { stageKeys: ['interested'] },
44
+ effects: [
45
+ { kind: 'setProperty', objectType: 'sales.crm:object/deal', property: 'stage', to: 'proposal' },
46
+ { kind: 'emitEvent', eventType: 'sales.crm:event/deal.stage-changed' }
47
+ ]
70
48
  }
71
49
  ```
72
50
 
73
- ## acqDb and crm Helpers
74
-
75
- 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:
76
-
77
- - `acqDb.transitionDeal({ dealId, toStage, toState? })` -- moves a deal to a new stage, optionally updating state_key.
78
- - `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.
79
- - `acqDb.loadDeal({ dealId })` -- returns the deal row joined with contact and company.
51
+ | Field | Meaning |
52
+ | --------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
53
+ | `label` | Button text. Falls back to the action key when omitted. |
54
+ | `order` | Position in the deal action bar. Records without one sort after every ordered record, in id order. |
55
+ | `resourceId` | The deployed workflow this action dispatches to. Required for any record with `availableFrom`. |
56
+ | `availableFrom` | When the button appears. Omit the whole field to keep a record in the ontology without making it a deal action. |
57
+ | `actsOn` | The ontology object the action operates on. |
58
+ | `effects` | Declarative record of what the action changes. Read by the graph and by knowledge nodes; it does not execute -- the workflow does that. |
80
59
 
81
- 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.
60
+ ### `availableFrom`
82
61
 
83
- ## Override a Default Action
62
+ Three optional dimensions, all evaluated against the deal row:
84
63
 
85
- Deploy a workflow with the same `workflowId` as the default action you want to replace. The resource registry resolves your project-owned workflow first.
64
+ ```
65
+ stageKeys deal.stage_key (required, at least one)
66
+ stateKeys deal.state_key (optional)
67
+ nextActionKeys deal.nextAction (optional)
68
+ ```
86
69
 
87
- The canonical transition workflow (see `packages/elevasis/operations/src/sales/crm/actions/move-to-proposal.ts` for the deployed reference):
70
+ Dimensions **AND** together; membership within one dimension is **OR**; an omitted dimension is unconstrained. So this button shows only for a deal that is in the `interested` stage, sitting in one of three states, and whose derived next action is `send_reply`:
88
71
 
89
- <!-- doc-snippet:skip: illustrative excerpt -- `resourceDescriptors` names the project's own resource-descriptors export, and `../shared/action-workflow-schemas.js` is a project-local sibling file -->
72
+ <!-- doc-snippet:skip: illustrative excerpt -- the availableFrom fragment of an actionTypes record -->
90
73
 
91
74
  ```ts
92
- // operations/src/sales/crm/actions/move-to-proposal.ts
93
- import type { WorkflowDefinition } from '@elevasis/sdk'
94
- import { acqDb } from '@elevasis/sdk/worker'
95
- import { resourceDescriptors } from '@core/config/organization-model'
96
- import { ActionWorkflowInputSchema, ActionWorkflowOutputSchema } from '../shared/action-workflow-schemas.js'
97
-
98
- export const moveToProposalWorkflow: WorkflowDefinition = {
99
- config: {
100
- resource: resourceDescriptors.moveToProposal,
101
- resourceId: resourceDescriptors.moveToProposal.id,
102
- name: 'Move to Proposal',
103
- description: 'Transition a deal to the proposal stage.',
104
- type: resourceDescriptors.moveToProposal.kind,
105
- version: '1.0.0',
106
- status: 'prod',
107
- category: 'internal',
108
- },
109
- contract: {
110
- inputSchema: ActionWorkflowInputSchema,
111
- outputSchema: ActionWorkflowOutputSchema
112
- },
113
- steps: {
114
- transition: {
115
- id: 'transition',
116
- name: 'Transition to Proposal',
117
- description: 'Call transitionDeal to move the deal to the proposal stage',
118
- inputSchema: ActionWorkflowInputSchema,
119
- outputSchema: ActionWorkflowOutputSchema,
120
- handler: async (rawInput, context) => {
121
- const { dealId } = rawInput as { dealId: string; organizationId: string }
122
- context.logger.info(`[transition] Moving deal ${dealId} to proposal`)
123
- await acqDb.transitionDeal({ dealId, toStage: 'proposal' })
124
- return { dealId, sent: false }
125
- },
126
- next: null
127
- }
128
- },
129
- entryPoint: 'transition'
75
+ availableFrom: {
76
+ stageKeys: ['interested'],
77
+ stateKeys: ['discovery_replied', 'discovery_link_sent', 'discovery_nudging'],
78
+ nextActionKeys: ['send_reply']
130
79
  }
131
80
  ```
132
81
 
133
- 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 entry's `workflowId` exactly.
82
+ Every `stageKeys` entry must be a stage in the CRM pipeline catalog, and every `stateKeys` entry must be a valid state for one of the declared stages. `compileCrmActionCatalog` checks both and reports every violation at once as a `SystemInterfaceReadinessError` carrying `SYSTEM_INTERFACE_INVALID` issues -- which reaches an API caller as a 503 with `readinessCode`, not a silent missing button.
134
83
 
135
- Register the workflow in the operations manifest:
84
+ ## Compile the Catalog
136
85
 
137
- <!-- doc-snippet:skip: illustrative excerpt with a relative import to the project's own workflow file, not a standalone compilable file -->
86
+ <!-- doc-snippet:skip: illustrative excerpt -- `@core/config/organization-model` is the project's own OM alias -->
138
87
 
139
88
  ```ts
140
- // operations/src/index.ts
141
- import { moveToProposalWorkflow } from './sales/crm/actions/move-to-proposal.js'
89
+ // ui/src/config/crm-actions.ts
90
+ import { compileCrmActionCatalog, compileCrmApiOntologyValidationIndex, type CompiledCrmAction } from '@elevasis/sdk'
91
+ import { organizationModel } from '@core/config/organization-model'
142
92
 
143
- export const deploymentSpec = {
144
- workflows: [moveToProposalWorkflow],
145
- agents: []
146
- }
93
+ export const crmActions: CompiledCrmAction[] = compileCrmActionCatalog(
94
+ compileCrmApiOntologyValidationIndex(organizationModel)
95
+ )
147
96
  ```
148
97
 
149
- Then deploy:
98
+ `compileCrmApiOntologyValidationIndex` proves the `sales.crm` System Interface is ready -- active `apiInterface` marker, resolvable scoped resources, a non-empty pipeline catalog -- before the action catalog compiles against it. CRM action dispatch is an API-backed surface: if that marker is missing or disabled, compilation throws rather than producing a catalog of buttons that would 503 on click.
150
99
 
151
- ```bash
152
- pnpm exec elevasis-sdk deploy
153
- ```
100
+ If a lead-gen workflow hands off into CRM, model that crossing through lead-gen resources that bind the CRM pipeline plus a scoped topology grant to `sales.crm.apiInterface` -- not by making base lead-gen readiness depend on CRM catalogs.
154
101
 
155
- ## 1. Override the Shared CRM Action Set (UI-Side)
102
+ The compiled shape:
156
103
 
157
- To hide, reorder, or relabel buttons in the shared deal UI, create a local action config module:
158
-
159
- <!-- doc-snippet:skip: illustrative excerpt with a relative import to the project's own platform-crm-actions module, not a standalone compilable file -->
104
+ <!-- doc-snippet:skip: shape excerpt from compile-action-catalog.ts -->
160
105
 
161
106
  ```ts
162
- // ui/src/config/crm-actions.ts
163
- import type { ActionDef } from '@elevasis/sdk'
164
- import { platformCrmActions } from './platform-crm-actions'
165
-
166
- export const crmActions: ActionDef[] = platformCrmActions.filter((action) => action.key !== 'move_to_closed_lost')
107
+ export interface CompiledCrmAction {
108
+ key: string
109
+ label: string
110
+ availableFrom: CompiledCrmActionAvailability
111
+ resourceId: string
112
+ payloadSchema?: z.ZodTypeAny
113
+ }
167
114
  ```
168
115
 
169
- To add a new action entry alongside the defaults (note: brand-new keys are not server-dispatched until the platform knows their `workflowId`):
116
+ ### Wire It Into the Provider
170
117
 
171
- <!-- doc-snippet:skip: illustrative excerpt with a relative import to the project's own platform-crm-actions module, not a standalone compilable file -->
172
-
173
- ```ts
174
- // ui/src/config/crm-actions.ts
175
- import type { ActionDef } from '@elevasis/sdk'
176
- import { platformCrmActions } from './platform-crm-actions'
177
-
178
- export const crmActions: ActionDef[] = [
179
- ...platformCrmActions,
180
- {
181
- key: 'send_quote',
182
- label: 'Send Quote',
183
- isAvailableFor: (deal) => deal.stage_key === 'proposal',
184
- workflowId: 'send-quote-workflow'
185
- }
186
- ]
187
- ```
188
-
189
- Wire it into the app provider. If the template uses `createElevasisApp`, pass it in the app config:
118
+ If the template uses `createElevasisApp`, pass it in the app config:
190
119
 
191
120
  ```tsx
192
121
  // ui/src/main.tsx
@@ -214,48 +143,47 @@ If the project hand-wires providers, pass the same array to `ElevasisUIProvider`
214
143
  </ElevasisUIProvider>
215
144
  ```
216
145
 
217
- This controls the shared `DealDetailPage` and `DealDrawer` action row.
146
+ This drives the shared `DealDetailPage` and `DealDrawer` action rows.
218
147
 
219
- ## 2. Add a Custom Workflow-Backed Action
148
+ ## How Dispatch Works
220
149
 
221
- For a brand-new action key that calls a project-owned workflow, define the workflow first.
150
+ When the shared UI calls `POST /deals/:dealId/actions/:actionKey`, the platform:
222
151
 
223
- 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/`.
152
+ 1. Loads the deal and finds the compiled action by key.
153
+ 2. Re-evaluates `availableFrom` against the deal server-side. This is the security gate and cannot be skipped -- a client that fabricates an action key or calls one out of stage gets a 400.
154
+ 3. Validates the payload against the action's `payloadSchema`, and rejects any payload sent to an action that declares none.
155
+ 4. Appends an `action_taken` entry to the deal's activity log, before the handler runs -- operator intent is recorded separately from effect.
156
+ 5. Resolves `resourceId` to the deployed resource and dispatches through `SingleExecutionCoordinator`, the same execution engine as every other workflow.
157
+ 6. Returns the refetched deal.
224
158
 
225
- ### Define the Workflow Contract
159
+ ### Payload Schemas Are Platform-Registered
226
160
 
227
- ```ts
228
- // core/types/index.ts
229
- import { z } from 'zod'
161
+ A Zod schema is not serializable model data, so `payloadSchema` cannot be authored in the OM. `compileCrmActionCatalog` resolves it from a small registry in published core, keyed by action key. Today only `send_reply` has one (`replyBody`). An action with no registered schema is a one-click button and rejects any payload.
230
162
 
231
- export const sendQuoteInputSchema = z.object({
232
- dealId: z.string().uuid(),
233
- organizationId: z.string().uuid()
234
- })
163
+ Adding a payload schema for a new action key is platform work, not scaffold work. Until then, take input through a custom UI path that calls the workflow directly (see [Custom Deal Page](#build-a-fully-custom-deal-page) below).
235
164
 
236
- export const sendQuoteOutputSchema = z.object({
237
- dealId: z.string().uuid(),
238
- sent: z.boolean(),
239
- messageId: z.string().optional()
240
- })
165
+ ## Add an Action
241
166
 
242
- export type SendQuoteInput = z.infer<typeof sendQuoteInputSchema>
243
- export type SendQuoteOutput = z.infer<typeof sendQuoteOutputSchema>
244
- ```
167
+ Three steps, in order.
245
168
 
246
- ### Define the Workflow
169
+ ### 1. Define and Deploy the Workflow
247
170
 
248
- <!-- doc-snippet:skip: illustrative excerpt -- `resourceDescriptors` names the project's own resource-descriptors export, and `sendQuoteInputSchema`/`sendQuoteOutputSchema` are the schemas defined in the previous step, not yet added to this project's real core/types/index.ts -->
171
+ Workflows backing CRM actions use worker adapters instead of raw Supabase calls. The acquisition adapter is imported as `acqDb` from `@elevasis/sdk/worker`:
172
+
173
+ - `acqDb.transitionDeal({ dealId, toStage, toState? })` -- moves a deal to a new stage, optionally updating `state_key`.
174
+ - `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.
175
+ - `acqDb.loadDeal({ dealId })` -- returns the deal row joined with contact and company.
176
+
177
+ The separate `crm` adapter exposes focused CRM methods such as `crm.recordActivity(...)`; use `acqDb` for action workflows that need deal transitions and the broader acquisition substrate.
178
+
179
+ <!-- doc-snippet:skip: illustrative excerpt -- `resourceDescriptors` names the project's own resource-descriptors export, and the schemas are project-local -->
249
180
 
250
181
  ```ts
251
182
  // operations/src/sales/send-quote.ts
252
183
  import type { WorkflowDefinition } from '@elevasis/sdk'
253
184
  import { acqDb, createResendAdapter } from '@elevasis/sdk/worker'
254
185
  import { resourceDescriptors } from '@core/config/organization-model'
255
- import {
256
- sendQuoteInputSchema,
257
- sendQuoteOutputSchema
258
- } from '@core/types'
186
+ import { sendQuoteInputSchema, sendQuoteOutputSchema } from '@core/types'
259
187
 
260
188
  export const sendQuoteWorkflow: WorkflowDefinition = {
261
189
  config: {
@@ -265,8 +193,8 @@ export const sendQuoteWorkflow: WorkflowDefinition = {
265
193
  description: 'Sends a quote email to the deal contact.',
266
194
  type: resourceDescriptors.sendQuote.kind,
267
195
  version: '1.0.0',
268
- status: 'dev',
269
- category: 'internal',
196
+ status: 'prod',
197
+ category: 'internal'
270
198
  },
271
199
  contract: {
272
200
  inputSchema: sendQuoteInputSchema,
@@ -299,11 +227,7 @@ export const sendQuoteWorkflow: WorkflowDefinition = {
299
227
  payload: { triggered_by_action: 'send_quote', channel: 'email' }
300
228
  })
301
229
 
302
- return {
303
- dealId,
304
- sent: true,
305
- messageId: String(sent.id ?? '')
306
- }
230
+ return { dealId, sent: true, messageId: String(sent.id ?? '') }
307
231
  },
308
232
  next: null
309
233
  }
@@ -312,9 +236,11 @@ export const sendQuoteWorkflow: WorkflowDefinition = {
312
236
  }
313
237
  ```
314
238
 
315
- Register the workflow in the operations manifest and deploy:
239
+ If the action sends email, writes to another channel, or otherwise touches a customer, use `acqDb.recordDealActivity` inside the handler so the audit trail reflects it. For an 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/`.
240
+
241
+ Register and deploy:
316
242
 
317
- <!-- doc-snippet:skip: illustrative excerpt with a relative import to the project's own workflow file, not a standalone compilable file -->
243
+ <!-- doc-snippet:skip: illustrative excerpt with a relative import to the project's own workflow file -->
318
244
 
319
245
  ```ts
320
246
  // operations/src/index.ts
@@ -330,71 +256,118 @@ export const deploymentSpec = {
330
256
  pnpm exec elevasis-sdk deploy
331
257
  ```
332
258
 
333
- ### Choose the UI Path
259
+ ### 2. Author the Action Record
334
260
 
335
- 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.
261
+ Add one entry to the `sales.crm` System's `ontology.actionTypes`, with `resourceId` matching the deployed resource id exactly:
336
262
 
337
- ```tsx
338
- // ui/src/features/crm/components/SendQuoteButton.tsx
339
- import { Button } from '@mantine/core'
340
- import { useMutation } from '@tanstack/react-query'
341
- import { useElevasisServices } from '@elevasis/ui/provider'
342
- import { resourceDescriptors } from '@core/config/organization-model'
263
+ <!-- doc-snippet:skip: illustrative OM excerpt -->
343
264
 
344
- export function SendQuoteButton({ dealId }: { dealId: string }) {
345
- const { apiRequest, organizationId } = useElevasisServices()
346
- const resourceId = resourceDescriptors.sendQuote.id
265
+ ```ts
266
+ 'sales.crm:action/send_quote': {
267
+ id: 'sales.crm:action/send_quote',
268
+ label: 'Send Quote',
269
+ description: 'Email the current quote to the deal contact.',
270
+ ownerSystemId: 'sales.crm',
271
+ order: 25,
272
+ actsOn: ['sales.crm:object/deal'],
273
+ resourceId: 'send-quote-workflow',
274
+ availableFrom: { stageKeys: ['proposal'] }
275
+ }
276
+ ```
347
277
 
348
- const sendQuote = useMutation({
349
- mutationFn: async () => {
350
- if (!organizationId) throw new Error('Organization context is not ready')
278
+ The local id (`send_quote`) becomes the action key. Underscores are legal in a local id; the format is `system.path:kind/local-id`, parsed and formatted through the core ontology helpers rather than by splitting strings.
351
279
 
352
- return apiRequest('/execute-async', {
353
- method: 'POST',
354
- body: JSON.stringify({
355
- resourceType: 'workflow',
356
- resourceId,
357
- input: { dealId, organizationId }
358
- })
359
- })
360
- }
361
- })
280
+ ### 3. Deploy the Organization Model
362
281
 
363
- return (
364
- <Button loading={sendQuote.isPending} onClick={() => sendQuote.mutate()}>
365
- Send Quote
366
- </Button>
367
- )
282
+ The compiled catalog is derived from the OM, so the button appears once the model carrying it is deployed. Run the model deployment the same way you deploy any OM change in your project; `om:doctor` validates the ontology before it lands.
283
+
284
+ ## Hide, Relabel, or Reorder an Action
285
+
286
+ All three are OM edits on the existing record -- no code change, no filtering array.
287
+
288
+ | Want | Do |
289
+ | ------------------------ | ------------------------------------------------------------------------------------------------------------- |
290
+ | Hide the button entirely | Remove the record, or remove its `availableFrom` to keep it in the ontology without surfacing it as a button. |
291
+ | Change when it appears | Edit `availableFrom.stageKeys` / `stateKeys` / `nextActionKeys`. |
292
+ | Change the button text | Edit `label`. |
293
+ | Change its position | Edit `order`. |
294
+
295
+ Removing a record removes it from both the UI and the server-side dispatch set at once, because both read the same catalog. Note that removing an ontology id from a model whose snapshot is already deployed leaves that snapshot stale until the model is deployed again.
296
+
297
+ ## Change What an Action Does
298
+
299
+ Edit the workflow the record's `resourceId` points at, or point `resourceId` at a different deployed resource. There is no same-name shadowing mechanism and no platform default to fall back to -- whatever id the record names is what runs.
300
+
301
+ The canonical transition workflow, for reference (see `packages/elevasis/operations/src/sales/crm/actions/move-to-proposal.ts`):
302
+
303
+ <!-- doc-snippet:skip: illustrative excerpt -- `resourceDescriptors` names the project's own resource-descriptors export, and `../shared/action-workflow-schemas.js` is a project-local sibling file -->
304
+
305
+ ```ts
306
+ // operations/src/sales/crm/actions/move-to-proposal.ts
307
+ import type { WorkflowDefinition } from '@elevasis/sdk'
308
+ import { acqDb } from '@elevasis/sdk/worker'
309
+ import { resourceDescriptors } from '@core/config/organization-model'
310
+ import { ActionWorkflowInputSchema, ActionWorkflowOutputSchema } from '../shared/action-workflow-schemas.js'
311
+
312
+ export const moveToProposalWorkflow: WorkflowDefinition = {
313
+ config: {
314
+ resource: resourceDescriptors.moveToProposal,
315
+ resourceId: resourceDescriptors.moveToProposal.id,
316
+ name: 'Move to Proposal',
317
+ description: 'Transition a deal to the proposal stage.',
318
+ type: resourceDescriptors.moveToProposal.kind,
319
+ version: '1.0.0',
320
+ status: 'prod',
321
+ category: 'internal'
322
+ },
323
+ contract: {
324
+ inputSchema: ActionWorkflowInputSchema,
325
+ outputSchema: ActionWorkflowOutputSchema
326
+ },
327
+ steps: {
328
+ transition: {
329
+ id: 'transition',
330
+ name: 'Transition to Proposal',
331
+ description: 'Call transitionDeal to move the deal to the proposal stage',
332
+ inputSchema: ActionWorkflowInputSchema,
333
+ outputSchema: ActionWorkflowOutputSchema,
334
+ handler: async (rawInput, context) => {
335
+ const { dealId } = rawInput as { dealId: string; organizationId: string }
336
+ context.logger.info(`[transition] Moving deal ${dealId} to proposal`)
337
+ await acqDb.transitionDeal({ dealId, toStage: 'proposal' })
338
+ return { dealId, sent: false }
339
+ },
340
+ next: null
341
+ }
342
+ },
343
+ entryPoint: 'transition'
368
344
  }
369
345
  ```
370
346
 
371
- Use this button through a custom deal route or a `renderActions` slot where available.
347
+ To add side effects (create a task, send a Slack message, log a deal activity), extend the handler before the `return`. Keep the record's `effects` in step with what the handler actually changes -- `effects` is what the graph and knowledge nodes read.
372
348
 
373
- Keep the same interface boundary for direct workflow buttons: custom UI may render read-only deal context without API readiness, but buttons that call `/execute`, `/execute-async`, or CRM action routes should be hidden or disabled until the relevant System Interface and any scoped topology grant are ready.
374
-
375
- ## 3. Build a Fully Custom Deal Page
349
+ ## Build a Fully Custom Deal Page
376
350
 
377
351
  When you own the full page, use the primitives directly:
378
352
 
379
353
  - `useDealDetail(dealId)` loads the deal.
380
- - `deriveActions(deal, crmActions)` filters the action set.
381
- - `useExecuteAction({ dealId })` dispatches platform-known action keys.
382
- - Project-owned workflow buttons call `/execute` or `/execute-async` directly when they are outside the server-dispatched action set.
354
+ - `deriveCompiledCrmActions(deal, crmActions)` returns the subset the deal can currently invoke, in authored order.
355
+ - `useExecuteAction({ dealId })` dispatches by action key.
356
+ - A button for something that is not a CRM deal action calls `/execute` or `/execute-async` directly.
383
357
 
384
358
  ```tsx
385
- import { deriveActions } from '@elevasis/sdk'
359
+ import { deriveCompiledCrmActions } from '@elevasis/sdk'
386
360
  import { Group, Stack } from '@mantine/core'
387
361
  import { useMemo } from 'react'
388
362
  import { useDealDetail, useExecuteAction } from '@elevasis/ui/hooks'
389
363
  import { crmActions } from '../config/crm-actions'
390
- import { SendQuoteButton } from './SendQuoteButton'
391
364
 
392
365
  export function CustomDealPage({ dealId }: { dealId: string }) {
393
366
  const { data: deal } = useDealDetail(dealId)
394
367
  const executeAction = useExecuteAction({ dealId })
395
368
 
396
- const platformActions = useMemo(() => {
397
- return deal ? deriveActions(deal, crmActions) : []
369
+ const actions = useMemo(() => {
370
+ return deal ? deriveCompiledCrmActions(deal, crmActions) : []
398
371
  }, [deal])
399
372
 
400
373
  if (!deal) return null
@@ -402,22 +375,55 @@ export function CustomDealPage({ dealId }: { dealId: string }) {
402
375
  return (
403
376
  <Stack>
404
377
  <Group>
405
- {platformActions.map((action) => (
406
- <button
407
- key={action.key}
408
- type="button"
409
- onClick={() => executeAction.mutate({ key: action.key })}
410
- >
378
+ {actions.map((action) => (
379
+ <button key={action.key} type="button" onClick={() => executeAction.mutate({ key: action.key })}>
411
380
  {action.label}
412
381
  </button>
413
382
  ))}
414
- <SendQuoteButton dealId={deal.id} />
415
383
  </Group>
416
384
  </Stack>
417
385
  )
418
386
  }
419
387
  ```
420
388
 
389
+ For a direct-workflow button, call `/execute-async` with the resource id:
390
+
391
+ ```tsx
392
+ // ui/src/features/crm/components/RunAuditButton.tsx
393
+ import { Button } from '@mantine/core'
394
+ import { useMutation } from '@tanstack/react-query'
395
+ import { useElevasisServices } from '@elevasis/ui/provider'
396
+ import { resourceDescriptors } from '@core/config/organization-model'
397
+
398
+ export function RunAuditButton({ dealId }: { dealId: string }) {
399
+ const { apiRequest, organizationId } = useElevasisServices()
400
+ const resourceId = resourceDescriptors.runAudit.id
401
+
402
+ const runAudit = useMutation({
403
+ mutationFn: async () => {
404
+ if (!organizationId) throw new Error('Organization context is not ready')
405
+
406
+ return apiRequest('/execute-async', {
407
+ method: 'POST',
408
+ body: JSON.stringify({
409
+ resourceType: 'workflow',
410
+ resourceId,
411
+ input: { dealId, organizationId }
412
+ })
413
+ })
414
+ }
415
+ })
416
+
417
+ return (
418
+ <Button loading={runAudit.isPending} onClick={() => runAudit.mutate()}>
419
+ Run Audit
420
+ </Button>
421
+ )
422
+ }
423
+ ```
424
+
425
+ Keep the same interface boundary for direct workflow buttons: custom UI may render read-only deal context without API readiness, but buttons that call `/execute`, `/execute-async`, or CRM action routes should be hidden or disabled until the relevant System Interface and any scoped topology grant are ready.
426
+
421
427
  ## CRM State-Key Source of Truth
422
428
 
423
429
  CRM `stage_key` and `state_key` values are tenant/runtime data. In the Elevasis workspace, the canonical CRM pipeline definition lives in `@repo/elevasis-core/sales` and is authored into the `sales.crm:catalog/crm.pipeline` ontology catalog on the canonical organization model. Published `@elevasis/core` keeps only generic `StatefulPipelineDefinition` types/helpers and transport schemas.
@@ -432,7 +438,7 @@ const validStates = getValidStatesForStage(CRM_PIPELINE_DEFINITION, 'interested'
432
438
  // [{ stateKey: 'discovery_replied', label: '...' }, ...]
433
439
  ```
434
440
 
435
- Template-derived projects should define their own CRM catalog/action config in project code rather than importing Elevasis runtime constants from published core.
441
+ Template-derived projects author their own CRM pipeline catalog in their own OM rather than importing Elevasis runtime constants from published core. The stage and state keys an action's `availableFrom` may name are exactly the ones that catalog defines.
436
442
 
437
443
  ## Activity Log Conventions
438
444
 
@@ -458,3 +464,5 @@ For a workflow-backed action, deploy or run the workflow smoke before wiring it
458
464
  pnpm -C operations exec elevasis-sdk check
459
465
  pnpm -C operations exec elevasis-sdk deploy
460
466
  ```
467
+
468
+ A compiled catalog that silently comes back shorter than expected means a record was skipped for want of `availableFrom`; a `SYSTEM_INTERFACE_INVALID` throw means a `stageKeys`/`stateKeys` entry names something the pipeline catalog does not define, or a record declares `availableFrom` with no `resourceId`. Both are reported with the offending ontology id.