<!-- @generated by packages/sdk/scripts/copy-reference-docs.mjs -- DO NOT EDIT -->
<!-- Regenerate: pnpm scaffold:sync -->

﻿---
title: Customize CRM Actions
description: Add, hide, or replace CRM deal action buttons in a template-derived project, and override default platform action workflows with project-owned implementations.
---

# Customize CRM Actions

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`.

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.

**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`.

Use this recipe when a user asks for work like:

- "Hide Close Lost from the deal drawer."
- "Add a Send Quote action to deals in Proposal."
- "Override Move to Proposal to also create a task in our project tool."
- "Build a custom deal page that runs one of our workflows."

## How Platform Action Dispatch Works

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:

1. Validates `isAvailableFor(deal)` server-side (security gate -- cannot be skipped).
2. Resolves `actionDef.workflowId` to a deployed resource.
3. Dispatches through `SingleExecutionCoordinator` -- same execution engine as every other workflow.
4. Returns the refetched deal.

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.

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.

## ActionDef Shape

Read the generated contract reference before editing:

`node_modules/@elevasis/sdk/reference/scaffold/reference/contracts.md`

The current flat shape (no `handler` nesting, no `kind` discriminator):

```ts
export interface ActionDef {
  key: string
  label: string
  isAvailableFor: (deal: AcqDealRow) => boolean
  workflowId: string
  payloadSchema?: z.ZodTypeAny
}
```

`deriveActions(deal, actions)` returns only render-time actions: `{ key, label, payloadSchema? }`. It does not expose `workflowId` or `isAvailableFor` to the browser.

A minimal entry looks like:

```ts
{
  key: 'move_to_proposal',
  label: 'Move to Proposal',
  isAvailableFor: (deal) => deal.stage_key === 'interested',
  workflowId: 'move_to_proposal-workflow',
  payloadSchema: undefined
}
```

## acqDb and crm Helpers

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:

- `acqDb.transitionDeal({ dealId, toStage, toState? })` -- moves a deal to a new stage, optionally updating state_key.
- `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.
- `acqDb.loadDeal({ dealId })` -- returns the deal row joined with contact and company.

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.

## Override a Default Action

Deploy a workflow with the same `workflowId` as the default action you want to replace. The resource registry resolves your project-owned workflow first.

The canonical transition workflow (see `packages/elevasis-operations/src/sales/crm/actions/move-to-proposal.ts` for the deployed reference):

```ts
// operations/src/sales/crm/actions/move-to-proposal.ts
import type { WorkflowDefinition } from '@elevasis/sdk'
import { acqDb } from '@elevasis/sdk/worker'
import { resourceDescriptors } from '@core/config/organization-model'
import { ActionWorkflowInputSchema, ActionWorkflowOutputSchema } from '../shared/action-workflow-schemas.js'

export const moveToProposalWorkflow: WorkflowDefinition = {
  config: {
    resource: resourceDescriptors.moveToProposal,
    resourceId: resourceDescriptors.moveToProposal.id,
    name: 'Move to Proposal',
    type: resourceDescriptors.moveToProposal.kind,
    version: '1.0.0',
    status: 'prod',
    category: 'internal',
  },
  contract: {
    inputSchema: ActionWorkflowInputSchema,
    outputSchema: ActionWorkflowOutputSchema
  },
  steps: {
    transition: {
      id: 'transition',
      name: 'Transition to Proposal',
      inputSchema: ActionWorkflowInputSchema,
      outputSchema: ActionWorkflowOutputSchema,
      handler: async (rawInput, context) => {
        const { dealId } = rawInput as { dealId: string; organizationId: string }
        context.logger.info(`[transition] Moving deal ${dealId} to proposal`)
        await acqDb.transitionDeal({ dealId, toStage: 'proposal' })
        return { dealId, sent: false }
      },
      next: null
    }
  },
  entryPoint: 'transition'
}
```

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.

Register the workflow in the operations manifest:

```ts
// operations/src/index.ts
import { moveToProposalWorkflow } from './sales/crm/actions/move-to-proposal.js'

export const deploymentSpec = {
  workflows: [moveToProposalWorkflow],
  agents: []
}
```

Then deploy:

```bash
pnpm exec elevasis-sdk deploy
```

## 1. Override the Shared CRM Action Set (UI-Side)

To hide, reorder, or relabel buttons in the shared deal UI, create a local action config module:

```ts
// ui/src/config/crm-actions.ts
import type { ActionDef } from '@elevasis/sdk'
import { platformCrmActions } from './platform-crm-actions'

export const crmActions: ActionDef[] = platformCrmActions.filter((action) => action.key !== 'move_to_closed_lost')
```

To add a new action entry alongside the defaults (note: brand-new keys are not server-dispatched until the platform knows their `workflowId`):

```ts
// ui/src/config/crm-actions.ts
import type { ActionDef } from '@elevasis/sdk'
import { platformCrmActions } from './platform-crm-actions'

export const crmActions: ActionDef[] = [
  ...platformCrmActions,
  {
    key: 'send_quote',
    label: 'Send Quote',
    isAvailableFor: (deal) => deal.stage_key === 'proposal',
    workflowId: 'send-quote-workflow'
  }
]
```

Wire it into the app provider. If the template uses `createElevasisApp`, pass it in the app config:

```tsx
// ui/src/main.tsx
import { crmActions } from './config/crm-actions'

const App = createElevasisApp({
  router,
  apiUrl: API_URL,
  auth: {
    clientId: import.meta.env.VITE_WORKOS_CLIENT_ID,
    redirectUri: import.meta.env.VITE_WORKOS_REDIRECT_URI,
    devMode: true
  },
  theme: { presets: themePresets, background, loader },
  queryClient,
  crmActions
})
```

If the project hand-wires providers, pass the same array to `ElevasisUIProvider` or `ElevasisCoreProvider`:

```tsx
<ElevasisUIProvider auth={auth} apiUrl={API_URL} crmActions={crmActions}>
  <AppRoutes />
</ElevasisUIProvider>
```

This controls the shared `DealDetailPage` and `DealDrawer` action row.

## 2. Add a Custom Workflow-Backed Action

For a brand-new action key that calls a project-owned workflow, define the workflow first.

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/`.

### Define the Workflow Contract

```ts
// core/types/index.ts
import { z } from 'zod'

export const sendQuoteInputSchema = z.object({
  dealId: z.string().uuid(),
  organizationId: z.string().uuid()
})

export const sendQuoteOutputSchema = z.object({
  dealId: z.string().uuid(),
  sent: z.boolean(),
  messageId: z.string().optional()
})

export type SendQuoteInput = z.infer<typeof sendQuoteInputSchema>
export type SendQuoteOutput = z.infer<typeof sendQuoteOutputSchema>
```

### Define the Workflow

```ts
// operations/src/sales/send-quote.ts
import type { WorkflowDefinition } from '@elevasis/sdk'
import { acqDb, createResendAdapter } from '@elevasis/sdk/worker'
import { resourceDescriptors } from '@core/config/organization-model'
import {
  sendQuoteInputSchema,
  sendQuoteOutputSchema
} from '@core/types'

export const sendQuoteWorkflow: WorkflowDefinition = {
  config: {
    resource: resourceDescriptors.sendQuote,
    resourceId: resourceDescriptors.sendQuote.id,
    name: 'Send Quote',
    type: resourceDescriptors.sendQuote.kind,
    version: '1.0.0',
    status: 'dev',
    category: 'internal',
  },
  contract: {
    inputSchema: sendQuoteInputSchema,
    outputSchema: sendQuoteOutputSchema
  },
  steps: {
    send: {
      id: 'send',
      name: 'Send Quote Email',
      inputSchema: sendQuoteInputSchema,
      outputSchema: sendQuoteOutputSchema,
      handler: async (rawInput, context) => {
        const { dealId } = rawInput as { dealId: string; organizationId: string }
        const deal = await acqDb.loadDeal({ dealId })

        const resend = createResendAdapter('my-resend-credential')
        context.logger.info(`[send-quote] Sending quote for deal ${dealId}`)

        const sent = await resend.sendEmail({
          to: deal.contact.email,
          subject: 'Your quote is ready',
          html: `<p>Your quote is ready. Reply to this email with questions.</p>`
        })

        await acqDb.recordDealActivity({
          dealId,
          type: 'quote_sent',
          title: 'Sent quote',
          payload: { triggered_by_action: 'send_quote', channel: 'email' }
        })

        return {
          dealId,
          sent: true,
          messageId: String(sent.id ?? '')
        }
      },
      next: null
    }
  },
  entryPoint: 'send'
}
```

Register the workflow in the operations manifest and deploy:

```ts
// operations/src/index.ts
import { sendQuoteWorkflow } from './sales/send-quote.js'

export const deploymentSpec = {
  workflows: [sendQuoteWorkflow],
  agents: []
}
```

```bash
pnpm exec elevasis-sdk deploy
```

### Choose the UI Path

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.

```tsx
// ui/src/features/crm/components/SendQuoteButton.tsx
import { Button } from '@mantine/core'
import { useMutation } from '@tanstack/react-query'
import { useElevasisServices } from '@elevasis/ui/provider'
import { resourceDescriptors } from '@core/config/organization-model'

export function SendQuoteButton({ dealId }: { dealId: string }) {
  const { apiRequest, organizationId } = useElevasisServices()
  const resourceId = resourceDescriptors.sendQuote.id

  const sendQuote = useMutation({
    mutationFn: async () => {
      if (!organizationId) throw new Error('Organization context is not ready')

      return apiRequest('/execute-async', {
        method: 'POST',
        body: JSON.stringify({
          resourceType: 'workflow',
          resourceId,
          input: { dealId, organizationId }
        })
      })
    }
  })

  return (
    <Button loading={sendQuote.isPending} onClick={() => sendQuote.mutate()}>
      Send Quote
    </Button>
  )
}
```

Use this button through a custom deal route or a `renderActions` slot where available.

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.

## 3. Build a Fully Custom Deal Page

When you own the full page, use the primitives directly:

- `useDealDetail(dealId)` loads the deal.
- `deriveActions(deal, crmActions)` filters the action set.
- `useExecuteAction({ dealId })` dispatches platform-known action keys.
- Project-owned workflow buttons call `/execute` or `/execute-async` directly when they are outside the server-dispatched action set.

```tsx
import { deriveActions } from '@elevasis/sdk'
import { Group, Stack } from '@mantine/core'
import { useMemo } from 'react'
import { useDealDetail, useExecuteAction } from '@elevasis/ui/hooks'
import { crmActions } from '../config/crm-actions'
import { SendQuoteButton } from './SendQuoteButton'

export function CustomDealPage({ dealId }: { dealId: string }) {
  const { data: deal } = useDealDetail(dealId)
  const executeAction = useExecuteAction({ dealId })

  const platformActions = useMemo(() => {
    return deal ? deriveActions(deal, crmActions) : []
  }, [deal])

  if (!deal) return null

  return (
    <Stack>
      <Group>
        {platformActions.map((action) => (
          <button
            key={action.key}
            type="button"
            onClick={() => executeAction.mutate({ key: action.key })}
          >
            {action.label}
          </button>
        ))}
        <SendQuoteButton dealId={deal.id} />
      </Group>
    </Stack>
  )
}
```

## CRM State-Key Source of Truth

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/organization-model` 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.

Within the Elevasis monorepo, import runtime CRM definitions from `@repo/elevasis-core/organization-model`:

```ts
import {
  CRM_PIPELINE_DEFINITION,
  CRM_DISCOVERY_REPLIED_STATE,
  getValidStatesForStage
} from '@repo/elevasis-core/organization-model'

const validStates = getValidStatesForStage(CRM_PIPELINE_DEFINITION, 'interested')
// [{ stateKey: 'discovery_replied', label: '...' }, ...]
```

Template-derived projects should define their own CRM catalog/action config in project code rather than importing Elevasis runtime constants from published core.

## Activity Log Conventions

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:

- `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.
- `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.

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.

## Verify

Run the relevant checks from the project root:

```bash
pnpm -C operations run check
pnpm -C ui run check
```

For a workflow-backed action, deploy or run the workflow smoke before wiring it into the UI:

```bash
pnpm -C operations exec elevasis-sdk check
pnpm -C operations exec elevasis-sdk deploy
```


