---
title: Workflow Recipes
description: Anatomy of a workflow, adapter usage, and trigger patterns -- runnable email-notification example replaces the trivial echo workflow. Resolves eval score 2/5 on workflow authoring.
---
<!-- @generated by packages/sdk/scripts/copy-reference-docs.mjs -- DO NOT EDIT -->
<!-- Regenerate: pnpm scaffold:sync -->


# Workflow Recipes

`🟢 Stable` -- Use these patterns as-is against `@elevasis/sdk ^1.4.0`.

---

## 1. Anatomy of a Workflow

Every workflow is a `WorkflowDefinition` object with four top-level keys: `config`, `contract`, `steps`, and `entryPoint`. The `email-notification` workflow at `operations/src/email-notification/index.ts` is used as the running example throughout this section.

### Config

```typescript
import { resourceDescriptors } from '@core/config/organization-model'

config: {
  resource: resourceDescriptors.emailNotification,
  resourceId: resourceDescriptors.emailNotification.id,
  name: 'Email Notification',
  type: resourceDescriptors.emailNotification.kind,
  description: 'Sends a notification email to a user...',
  version: '1.0.0',
  status: 'dev'
}
```

- The OM Resources descriptor owns the stable ID and kind. Runtime execution still uses that deployed `resourceId`, but workflow authoring should derive it from the descriptor.
- Bump `version` whenever you change `contract.inputSchema` or `contract.outputSchema`.

### Contract

```typescript
// core/types/index.ts -- shared with frontend
export const emailNotificationInputSchema = z.object({
  recipientEmail: z.string().email(),
  recipientName: z.string().min(1),
  subject: z.string().min(1).max(200),
  body: z.string().min(1).max(10000),
  category: z.string().min(1).default('operations'),
  actionUrl: z.string().url().optional()
})

export const emailNotificationOutputSchema = z.object({
  delivered: z.boolean(),
  notificationId: z.string().optional(),
  summary: z.string()
})

export type EmailNotificationInput = z.infer<typeof emailNotificationInputSchema>
export type EmailNotificationOutput = z.infer<typeof emailNotificationOutputSchema>
```

```typescript
// operations/src/email-notification/index.ts
import { emailNotificationInputSchema, emailNotificationOutputSchema } from '@core/types'

contract: {
  inputSchema: emailNotificationInputSchema,
  outputSchema: emailNotificationOutputSchema
}
```

**Rules:**

- Define schemas in `core/types/index.ts` -- never inline Zod schemas in workflow files.
- Both runtimes (frontend and platform) import from `@core/types`, keeping validation consistent.
- `@core/types` resolves to `core/types/index.ts` via the tsconfig path alias.

**Entity-backed workflows:** for workflows that operate on a domain entity, reference the entity contract rather than redeclaring it.

```typescript
// core/types/index.ts
import { BaseDealSchema } from '@elevasis/core/entities'
import { DealSchema } from '@core/types/entities' // project-local extended version

export const closeDealInputSchema = z.object({
  deal: DealSchema,
  closedReason: z.string()
})

export type CloseDealInput = z.infer<typeof closeDealInputSchema>
```

`DealSchema` is defined in `core/types/entities.ts` by extending `BaseDealSchema` with project-specific metadata. See [../recipes/extend-a-base-entity.md](../recipes/extend-a-base-entity.md) for the pattern.

### Steps

Each step is a `WorkflowStep` with: `id`, `name`, `description`, `handler`, `inputSchema`, `outputSchema`, and `next`.

```typescript
import { StepType } from '@elevasis/sdk'

steps: {
  validate: {
    id: 'validate',
    name: 'Validate Input',
    description: 'Validates recipient and content parameters before sending',
    handler: async (rawInput, context) => {
      const input = rawInput as EmailNotificationInput
      context.logger.info(`[validate] Checking recipient: ${input.recipientEmail}`)
      // ... validation logic ...
      return input          // Pass validated data to next step
    },
    inputSchema: emailNotificationInputSchema,
    outputSchema: emailNotificationInputSchema,  // Passes full input through to next step
    next: { type: StepType.LINEAR, target: 'notify' }
  },
  notify: {
    id: 'notify',
    name: 'Send Notification',
    handler: async (rawInput, context) => {
      // ...
      return { delivered: true, summary: '...' }
    },
    inputSchema: emailNotificationInputSchema,
    outputSchema: emailNotificationOutputSchema,
    next: null  // Terminal step -- ends the workflow
  }
}
```

**Key rules:**

- `next: null` marks a terminal step.
- `next: { type: StepType.LINEAR, target: 'stepId' }` chains to another step.
- Use `context.logger` -- never `console.log`. Platform captures `context.logger.*` only.
- Cast `rawInput` to your input type: `const input = rawInput as EmailNotificationInput`.

### Entrypoint

```typescript
entryPoint: 'validate'   // Must match a step id in the steps map
```

The platform starts execution here. For single-step workflows, `entryPoint` points to the only step (e.g., `echo` points to `'echo'`).

### Optional: Interface Form

Declare `interface.form` to auto-generate an execution form in AI Studio and Command Center:

```typescript
interface: {
  form: {
    title: 'Send Email Notification',
    description: 'Sends a notification email to a user.',
    fields: [
      { name: 'recipientEmail', label: 'Recipient Email', type: 'text', required: true },
      { name: 'body', label: 'Body', type: 'text', required: true }
    ],
    submitButton: { label: 'Send notification', loadingLabel: 'Sending...' }
  }
}
```

---

## 2. Adapter Usage

Adapters are typed wrappers over `platform.call()`. Import singletons from `@elevasis/sdk/worker`.

### notifications

Send a platform notification to the current user. `userId` and `organizationId` are injected server-side -- you never supply them.

```typescript
import { notifications } from '@elevasis/sdk/worker'

await notifications.create({
  category: 'operations',      // Any string: 'operations', 'delivery', 'acquisition', etc.
  title: 'Workflow complete',
  message: 'Your email notification was sent successfully.',
  actionUrl: '/operations'     // Optional -- link the user can follow
})
```

Available methods: `create`.

### llm

Generate text or structured output using a language model.

```typescript
import { llm } from '@elevasis/sdk/worker'

const response = await llm.generate({
  provider: 'anthropic',          // 'anthropic' | 'openai' | 'openrouter' | 'google'
  model: 'claude-sonnet-4-5',
  messages: [
    { role: 'user', content: 'Summarize this email body: ' + input.body }
  ]
})

const summary = response.output as string
```

For structured output, pass a JSON Schema as `responseSchema`:

```typescript
const response = await llm.generate({
  provider: 'anthropic',
  model: 'claude-sonnet-4-5',
  messages: [{ role: 'user', content: 'Extract the key action from: ' + input.body }],
  responseSchema: {
    type: 'object',
    properties: { action: { type: 'string' }, urgency: { type: 'string' } }
  }
})
const { action, urgency } = response.output as { action: string; urgency: string }
```

Available methods: `generate`.

### storage

Upload and retrieve files scoped to the organization. All paths are automatically prefixed with the organization's storage prefix server-side.

```typescript
import { storage } from '@elevasis/sdk/worker'

// Upload
await storage.upload({
  bucket: 'operations',
  path: 'email-logs/2026-04-16/batch-01.json',
  content: Buffer.from(JSON.stringify(logData)).toString('base64'),
  contentType: 'application/json'
})

// Download
const file = await storage.download({
  bucket: 'operations',
  path: 'email-logs/2026-04-16/batch-01.json'
})

// Signed URL for frontend access
const { signedUrl } = await storage.createSignedUrl({
  bucket: 'operations',
  path: 'email-logs/2026-04-16/batch-01.json',
  expiresIn: 3600
})
```

Available methods: `upload`, `download`, `createSignedUrl`, `delete`, `list`.

### scheduler

Schedule future or recurring workflow executions.

```typescript
import { scheduler } from '@elevasis/sdk/worker'

const schedule = await scheduler.createSchedule({
  name: 'Daily email digest',
  target: { resourceType: 'workflow', resourceId: 'email-notification' },
  scheduleConfig: {
    type: 'cron',
    expression: '0 9 * * 1-5'  // 9am weekdays
  }
})

context.logger.info(`[schedule] Created schedule: ${schedule.id}`)
```

Available methods: `createSchedule`, `updateAnchor`, `deleteSchedule`, `getSchedule`, `listSchedules`, `cancelSchedule`, `cancelSchedulesByMetadata`, `cancelScheduleByIdempotencyKey`, `findByIdempotencyKey`, `deleteScheduleByIdempotencyKey`.

**Note on other adapters:** Integration adapters (`createResendAdapter`, `createAttioAdapter`, etc.) follow a factory pattern -- bind a credential once, use the instance for all calls:

```typescript
import { createResendAdapter } from '@elevasis/sdk/worker'

const resend = createResendAdapter('my-resend-credential')
await resend.sendEmail({
  to: input.recipientEmail,
  subject: input.subject,
  html: `<p>${input.body}</p>`
})
```

See `operations/node_modules/@elevasis/sdk/reference/` for the full adapter reference.

---

## 3. Trigger Patterns from Frontend

### (a) API call via `useApiClient`

Use this pattern in React components and hooks. `apiRequest` automatically attaches the auth token and org context.

```typescript
// ui/src/features/notifications/hooks/useSendEmailNotification.ts
import { useMutation } from '@tanstack/react-query'
import { useApiClient } from '@/lib/hooks/useApiClient'
import type { EmailNotificationInput, EmailNotificationOutput } from '@core/types'

export function useSendEmailNotification() {
  const { apiRequest } = useApiClient()

  return useMutation({
    mutationFn: async (input: EmailNotificationInput) => {
      return apiRequest<EmailNotificationOutput>('/execute', {
        method: 'POST',
        body: JSON.stringify({
          resourceType: 'workflow',
          resourceId: 'email-notification',
          input
        })
      })
    }
  })
}
```

Usage in a component:

```tsx
// ui/src/features/notifications/components/SendNotificationButton.tsx
import { useSendEmailNotification } from '../hooks/useSendEmailNotification'

function SendNotificationButton() {
  const { mutate, isPending, isSuccess } = useSendEmailNotification()

  return (
    <Button
      loading={isPending}
      onClick={() =>
        mutate({
          recipientEmail: 'user@example.com',
          recipientName: 'Jane Smith',
          subject: 'Your request is ready',
          body: 'Hi Jane, your workflow has completed.',
          category: 'operations'
        })
      }
    >
      Send Notification
    </Button>
  )
}
```

For async execution (long-running workflows), use `/execute-async` instead:

```typescript
return apiRequest<{ executionId: string }>('/execute-async', {
  method: 'POST',
  body: JSON.stringify({
    resourceType: 'workflow',
    resourceId: 'email-notification',
    input
  })
})
// Poll /executions/email-notification/:executionId for status
```

### (b) Direct SDK dispatch (CLI or scripts)

From the project root, use the platform CLI for manual invocations, testing, and scripting:

```bash
# Describe the schema before executing
pnpm exec elevasis describe Elevasis/email-notification

# Execute synchronously
pnpm exec elevasis exec Elevasis/email-notification --input '{
  "recipientEmail": "user@example.com",
  "recipientName": "Jane Smith",
  "subject": "Hello",
  "body": "Hi Jane, this is a test notification."
}'

# Execute asynchronously (for long-running workflows)
pnpm exec elevasis exec Elevasis/email-notification --async --input '{...}'

# View a specific execution
pnpm exec elevasis execution Elevasis/email-notification <executionId>
```

The `--prod` flag targets `https://api.elevasis.io` and goes **before** the command:

```bash
pnpm exec elevasis --prod exec Elevasis/email-notification --input '{...}'
```

---

## 4. Registry Pattern

Workflows are discovered through `operations/src/index.ts`, which exports a `DeploymentSpec` as its default export. The spec assembles deployable runtime resources; it is not a second resource identity catalog.

**Pattern:** each feature group in `operations/src/` has its own exports barrel. The top-level spec spreads all groups:

```
operations/src/
  index.ts                          # Top-level DeploymentSpec -- never add workflows here directly
  example/
    index.ts                        # export const workflows = [echo]; export const agents = []
    echo.ts                         # WorkflowDefinition for 'echo'
  email-notification/
    exports.ts                      # export const workflows = [emailNotification]; export const agents = []
    index.ts                        # WorkflowDefinition for 'email-notification'
```

Top-level registry (`operations/src/index.ts`):

```typescript
import type { DeploymentSpec } from '@elevasis/sdk'
import { organizationModel } from '@core/config/organization-model'
import * as example from './example/index.js'
import * as emailNotification from './email-notification/exports.js'

const org: DeploymentSpec = {
  version: '0.1.0',
  organizationModel,
  workflows: [...example.workflows, ...emailNotification.workflows],
  agents: [...example.agents, ...emailNotification.agents]
}
export default org
```

Feature group barrel (e.g., `email-notification/exports.ts`):

```typescript
import { emailNotification } from './index.js'
import type { WorkflowDefinition } from '@elevasis/sdk'

export const workflows: WorkflowDefinition[] = [emailNotification]
export const agents: never[] = []
```

**Adding a new workflow:**

1. Add the resource descriptor to `core/config/organization-model.ts`.
2. Create `operations/src/<feature>/index.ts` with the `WorkflowDefinition`, deriving `config.resourceId` and `config.type` from the descriptor.
3. Create `operations/src/<feature>/exports.ts` with `workflows` and `agents` arrays.
4. Import the group barrel in `operations/src/index.ts` and spread into `workflows`/`agents`.
5. Run `pnpm -C operations check` to validate descriptor/code alignment, then `pnpm -C operations deploy` to publish.

**Note:** Use `.js` extensions in imports even though the source is TypeScript. The TypeScript compiler and esbuild bundler both require this for ESM interoperability.

---

## 5. Testing Custom Workflows And UI

Package-owned test helpers are the stable way to test custom downstream code. Do not copy template-only tests into a project unless the project owns the matching workflow, route, or component.

After the bundled package release lands, use these public subpaths:

```typescript
import { makeProject } from '@elevasis/core/test-utils'
import { renderWithProviders, mockAuthenticatedUser } from '@elevasis/ui/test-utils'
import { assertResourceRegistry, mockNotifications, runWorkflow } from '@elevasis/sdk/test-utils'
```

### Workflow Smoke Test

Use `runWorkflow` for project-owned workflows. This tests the workflow contract, step execution, and parsed output without deploying.

```typescript
import { describe, expect, it } from 'vitest'
import { runWorkflow, mockNotifications } from '@elevasis/sdk/test-utils'
import { emailNotification } from './index'
import type { EmailNotificationOutput } from '@core/types'

describe('emailNotification workflow', () => {
  it('runs the notify step with a mocked notification adapter', async () => {
    const notifications = mockNotifications({
      create: { id: 'notification-1' }
    })

    const result = await runWorkflow<EmailNotificationOutput>(
      emailNotification,
      {
        recipientEmail: 'jane@example.com',
        recipientName: 'Jane',
        subject: 'Action ready',
        body: 'Your request has been processed.'
      },
      { adapters: { notification: notifications } }
    )

    expect(result.output.delivered).toBe(true)
    expect(result.stepEvents.map((event) => event.stepId)).toContain('notify')
  })
})
```

### Registry Smoke Test

Use `assertResourceRegistry` for a project-owned `operations/src/index.ts` manifest. Keep assertions generic unless the project intentionally owns a fixed workflow list.

```typescript
import { describe, expect, it } from 'vitest'
import { assertResourceRegistry } from '@elevasis/sdk/test-utils'
import org from './index'

describe('operations registry', () => {
  it('registers a valid operations manifest', () => {
    const spec = assertResourceRegistry(org, { organizationName: 'Example Project' })
    const workflows = spec.workflows ?? []

    expect(workflows.length + (spec.agents?.length ?? 0)).toBeGreaterThan(0)
    expect(new Set(workflows.map((workflow) => workflow.config.resourceId)).size).toBe(workflows.length)
  })
})
```

### UI And Core Fixtures

Use `@elevasis/ui/test-utils` for route/component tests and `@elevasis/core/test-utils` for schema-compatible fixtures.

```tsx
import { describe, expect, it } from 'vitest'
import { screen } from '@testing-library/react'
import { makeProject } from '@elevasis/core/test-utils'
import { mockAuthenticatedUser, renderWithProviders } from '@elevasis/ui/test-utils'
import { ProjectCard } from './ProjectCard'

describe('ProjectCard', () => {
  it('renders a project fixture for an authenticated user', () => {
    const project = makeProject({ name: 'Website Refresh' })

    renderWithProviders(<ProjectCard project={project} />, {
      auth: mockAuthenticatedUser()
    })

    expect(screen.getByText('Website Refresh')).toBeInTheDocument()
  })
})
```

Template tests are examples and smoke coverage. Downstream projects should keep custom tests close to the feature they validate, then consume these package helpers instead of copying scaffold internals.
