> ## Documentation Index
> Fetch the complete documentation index at: https://docs.attio.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Building workflow blocks

> How to add custom triggers and steps to Attio Workflows using the App SDK

This guide walks through building a step block and a trigger block from scratch. For an overview of how blocks work, see [Workflow blocks](/sdk/workflow-blocks/overview). For individual API details, see the [reference pages](/sdk/workflows/define-workflow-block).

## How workflow blocks work

A workflow block is a reusable action that workspace members can drag into the Attio Workflows builder. Once your app is installed, your blocks appear in the same block picker as Attio's built-in blocks. A workspace member configures the block once (e.g. enters an API key or a list ID), then connects it to other blocks to form an automation.

Every block is made of three files that serve distinct roles:

| File                              | Runs in                   | Purpose                                                                                   |
| --------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------- |
| `block.ts`                        | Shared (browser + server) | Declares the block's identity, display name, and the fields the workspace member fills in |
| `execute.ts` / `activate.ts` etc. | Server only               | The logic that runs when Attio calls your block                                           |
| `configurator.tsx`                | Browser only              | The form the workspace member sees in the workflow editor                                 |

These files are separate by design. `block.ts` is loaded in the browser to render the editor UI *and* on the server to run your logic, so it cannot import anything browser-specific or server-specific. The handler files (`.ts`) are server-only. `configurator.tsx` is browser-only and has no access to server APIs.

When in doubt, start with a step block — triggers involve managing external subscriptions across activation and deactivation, which adds complexity. See [Workflow blocks overview](/sdk/workflow-blocks/overview) for a comparison of the two types.

***

## Building a step block

A step block runs inline when a workflow reaches it. The workspace member configures it once (for example, entering an email address or an API project ID) and then the block executes those values each time a run passes through.

### 1. Define the block

Create `src/blocks/my-step/block.ts`. This file declares the block's stable identity and the configuration fields workspace members will fill in. It is imported by both the browser (to render the editor UI) and the server (to type-check your handlers), so it must not import anything from `attio/client` or `attio/server`.

```ts src/blocks/my-step/block.ts theme={"system"}
import {Workflows} from "attio"

export default Workflows.defineWorkflowBlock({
  type: "step",
  id: "send-notification",
  title: "Send notification",
  description: "Send a notification to an external service.",
  configSchema: Workflows.ConfigSchema.struct({
    recipient_email: Workflows.ConfigSchema.emailAddress(),
    message: Workflows.ConfigSchema.string(),
  }),
})
```

The `id` is the stable internal identifier for this block; it must be unique across all blocks in your app and must not change once the block is in use, because workflow definitions reference it by ID. The `title` and `description` appear in the block picker.

The `configSchema` describes the form the workspace member fills in. Each field in `configSchema` maps to a typed property on the `config` argument in your handlers. If you add a `recipient_email` field, `config.recipient_email` is typed as an email address value in `execute.ts`; TypeScript will catch mismatches. See [Config schema](/sdk/workflows/config-schema) for all available field types.

### 2. Write the execute handler

Create `src/blocks/my-step/execute.ts`. Attio calls this function each time a running workflow reaches your block. It receives the values the workspace member configured (`config`) and contextual metadata about the current run (`metadata`).

```ts src/blocks/my-step/execute.ts theme={"system"}
import {Workflows} from "attio/server"
import block from "./block"

export default Workflows.defineWorkflowBlockExecute(block, async ({config, metadata}) => {
  const response = await fetch("https://api.example.com/notify", {
    method: "POST",
    headers: {"Content-Type": "application/json"},
    body: JSON.stringify({
      to: config.recipient_email,
      body: config.message,
    }),
  })

  if (!response.ok) {
    return {
      type: "error",
      errorMessage: `Notification failed: ${response.statusText}`,
      retryable: true,
    }
  }

  return {type: "outcome", id: "sent", data: null}
})
```

The return value tells Attio what to do next:

| Return                                     | Effect                                                                                                                                      |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `{type: "outcome", id, data}`              | Continue the workflow run down the named branch                                                                                             |
| `{type: "error", errorMessage, retryable}` | Surface an error in the Attio UI. Set `retryable: true` for transient failures (e.g. rate limits, temporary outages) that are safe to retry |
| `{type: "exit"}`                           | Stop the current run silently, without raising an error                                                                                     |
| `{type: "defer"}`                          | Pause the run until an external system POSTs to `metadata.finishCallbackUrl`                                                                |

See [Executing a step](/sdk/workflows/define-workflow-block-execute) for the full API.

<Note>
  `metadata.uniqueExecutionId` is stable across retries of the same execution. Use it as an
  idempotency key when calling external APIs so that a retried execution does not create duplicate
  side-effects.
</Note>

### 3. Wire up a configurator

Create `src/blocks/my-step/configurator.tsx`. This is the form workspace members see in the workflow editor when they add or edit your block. It runs only in the browser, so it has no access to server-side APIs.

```tsx src/blocks/my-step/configurator.tsx theme={"system"}
import {Workflows} from "attio/client"
import block from "./block"

export default Workflows.defineConfigurator(block, (workflowBlock) => {
  const {EmailAddressInput, TextInput, Outcome} = Workflows.useConfigurator(workflowBlock)
  return (
    <>
      <EmailAddressInput name="recipient_email" label="Recipient" />
      <TextInput name="message" label="Message" />
      <Outcome id="sent" schema={null} />
    </>
  )
})
```

`Workflows.useConfigurator(workflowBlock)` returns typed input components bound to the block's config schema. The `name` prop on each input is type-checked against the schema; rename a field in `block.ts` and TypeScript will show a compile error in `configurator.tsx` until you update it here too. The input component names (`EmailAddressInput`, `TextInput`, etc.) match the field types declared in the schema.

The `Outcome` component tells the editor that a branch with that `id` exists. Its `schema` prop declares what data that branch carries, which becomes the typed variables downstream steps can reference. For example, `schema={{task_id: Workflows.OutcomeSchema.string()}}` makes `task_id` available as a variable to any step wired to that branch. The `data` object returned from `execute` must have the same shape as the declared schema. When there is only one outcome, `label` is optional; the editor uses the block's title as the branch label.

### Loading data into the configurator

When your configurator loads data from a third party — say, to populate a dropdown — prefer a combobox [options provider](/sdk/workflows/configurator#combobox-options) passed to the `options` prop:

```tsx theme={"system"}
<ComboboxInput name="project_id" label="Project" options={projectOptions} />
```

For anything else, use [`useAsyncCache`](/sdk/data-fetching/use-async-cache), which suspends until the data is ready. See [Loading data](/sdk/workflows/configurator#loading-data) for the full guidance.

### Multiple outcomes

Return different `id` values from `execute` for different code paths. Each `id` becomes a named branch in the workflow editor that workspace members wire to downstream steps. Declare each `id` with the `Outcome` component so the editor knows the branch exists and what data it exposes.

Here is a step block that creates a Todoist task and branches based on the result:

```ts src/blocks/create-todoist-task/block.ts theme={"system"}
import {Workflows} from "attio"

export default Workflows.defineWorkflowBlock({
  type: "step",
  id: "create-todoist-task",
  title: "Create Todoist task",
  description: "Add a task to a Todoist project.",
  configSchema: Workflows.ConfigSchema.struct({
    project_id: Workflows.ConfigSchema.string(),
    content: Workflows.ConfigSchema.string(),
  }),
})
```

```ts src/blocks/create-todoist-task/execute.ts theme={"system"}
import {Workflows} from "attio/server"
import block from "./block"

export default Workflows.defineWorkflowBlockExecute(block, async ({config, metadata}) => {
  const response = await fetch("https://api.todoist.com/rest/v2/tasks", {
    method: "POST",
    headers: {"Content-Type": "application/json"},
    body: JSON.stringify({
      project_id: config.project_id,
      content: config.content,
    }),
  })

  if (response.status === 404) {
    return {type: "outcome", id: "project-not-found", data: null}
  }

  if (!response.ok) {
    return {
      type: "error",
      errorMessage: `Failed to create task: ${response.statusText}`,
      retryable: response.status === 429,
    }
  }

  const task = (await response.json()) as {id: string; url: string}

  return {
    type: "outcome",
    id: "created",
    data: {task_id: task.id, task_url: task.url},
  }
})
```

```tsx src/blocks/create-todoist-task/configurator.tsx theme={"system"}
import {Workflows} from "attio/client"
import block from "./block"

export default Workflows.defineConfigurator(block, (workflowBlock) => {
  const {TextInput, Outcome} = Workflows.useConfigurator(workflowBlock)
  return (
    <>
      <TextInput name="project_id" label="Project ID" />
      <TextInput name="content" label="Task name" />
      <Outcome
        id="created"
        label="Task created"
        schema={{
          task_id: Workflows.OutcomeSchema.string(),
          task_url: Workflows.OutcomeSchema.string(),
        }}
      />
      <Outcome id="project-not-found" label="Project not found" schema={null} />
    </>
  )
})
```

The editor shows `created` and `project-not-found` as separate connectable branches. Workspace members wire each branch to a different downstream step, for example logging a record update on `created` and sending an alert on `project-not-found`. The `task_id` and `task_url` values from the `created` outcome become typed variables any step downstream on that branch can use.

### Deferred execution

If your step needs to pause and wait for an async callback (for example, a background job that posts back when it finishes, or a human approval), return `{type: "defer"}` from `execute` and add a `finish.ts` handler.

Before returning `{type: "defer"}`, pass `metadata.finishCallbackUrl` to the external service as the URL to POST to when the work is complete. Attio immediately checkpoints and suspends the run; no server resources are held while waiting. When the external service POSTs to that URL, Attio calls `finish` with the incoming request. Return an outcome to resume the run, or `{type: "no-op"}` to stay deferred and wait for the next POST.

```ts src/blocks/my-step/execute.ts theme={"system"}
import {Workflows} from "attio/server"
import block from "./block"

export default Workflows.defineWorkflowBlockExecute(block, async ({config, metadata}) => {
  await fetch("https://api.example.com/jobs", {
    method: "POST",
    body: JSON.stringify({
      task: config.task_name,
      callback_url: metadata.finishCallbackUrl,
    }),
  })

  return {type: "defer"}
})
```

```ts src/blocks/my-step/finish.ts theme={"system"}
import {Workflows} from "attio/server"
import block from "./block"

export default Workflows.defineWorkflowBlockFinish(block, async (req, {config, metadata}) => {
  const payload = await req.json()

  if (payload.status === "pending") {
    return {type: "no-op"} // stay deferred, wait for next POST
  }

  return {type: "outcome", id: "completed", data: null}
})
```

See [Finishing a deferred step](/sdk/workflows/define-workflow-block-resume-callback) for the full API reference.

***

## Building a trigger block

A trigger block starts a new workflow run when something happens in an external system. Unlike a step block, which has a single `execute` handler, a trigger block manages an ongoing subscription to an external service across the full lifecycle of the workflow:

1. **Activate**: called once when the workspace member enables the workflow. Register your webhook or subscription with the external service here.
2. **Trigger**: called for every incoming event at `triggerCallbackUrl`. Decide whether the event should start a run.
3. **Deactivate**: called once when the workflow is disabled or deleted. Remove the subscription here.

Attio provides a stable `triggerCallbackUrl` per workflow version. Any HTTP POST to that URL is forwarded to your `trigger` handler. You never expose that URL directly to workspace members; you register it with the external service inside `activate`.

### 1. Define the block

```ts src/blocks/my-trigger/block.ts theme={"system"}
import {Workflows} from "attio"

export default Workflows.defineWorkflowBlock({
  type: "trigger",
  id: "new-form-submission",
  title: "New form submission",
  description: "Start a workflow when a form is submitted.",
  configSchema: Workflows.ConfigSchema.struct({
    form_id: Workflows.ConfigSchema.string(),
  }),
})
```

### 2. Write the activate handler

Create `src/blocks/my-trigger/activate.ts`. Attio calls this once when the workspace member enables the workflow. Use it to register your webhook with the upstream service, passing `metadata.triggerCallbackUrl` as the destination.

Store `metadata.uniqueActivationId` alongside your webhook registration so you can look it up during deactivation. This ID is stable across retries of the same `activate` call, so it is safe to use as an idempotency key.

```ts src/blocks/my-trigger/activate.ts theme={"system"}
import {Workflows} from "attio/server"
import block from "./block"

export default Workflows.defineWorkflowBlockActivate(block, async ({config, metadata}) => {
  const response = await fetch(`https://api.example.com/forms/${config.form_id}/webhooks`, {
    method: "POST",
    headers: {"Content-Type": "application/json"},
    body: JSON.stringify({
      url: metadata.triggerCallbackUrl,
      ref: metadata.uniqueActivationId,
    }),
  })

  if (!response.ok) {
    return {type: "error", errorMessage: "Failed to register webhook"}
  }

  return {type: "complete"}
})
```

If `activate` returns `{type: "error"}`, the workflow is not enabled and `deactivate` is never called for this version. See [Registering a trigger](/sdk/workflows/define-workflow-block-activate).

### 3. Handle incoming events

Create `src/blocks/my-trigger/trigger.ts`. Attio calls this once for every HTTP POST that arrives at `triggerCallbackUrl`. Your handler reads the payload and decides whether the event should start a workflow run.

This handler should be fast and free of heavy side-effects; its only job is to inspect the incoming event and signal whether to start a run.

```ts src/blocks/my-trigger/trigger.ts theme={"system"}
import {Workflows} from "attio/server"
import block from "./block"

export default Workflows.defineWorkflowBlockTrigger(block, async (req, {config, metadata}) => {
  const payload = await req.json()

  // Ignore events that don't match the configured form
  if (payload.form_id !== config.form_id) {
    return {type: "no-op"}
  }

  return {
    type: "outcome",
    id: "submitted",
    data: null,
  }
})
```

Return `{type: "outcome"}` to start a new workflow run, or `{type: "no-op"}` to silently ignore the event. See [Receiving a trigger event](/sdk/workflows/define-workflow-block-on-trigger-callback).

### 4. Write the deactivate handler

Create `src/blocks/my-trigger/deactivate.ts`. Attio calls this once when the workflow is disabled or the workspace member edits and re-enables the workflow (which deactivates the old version before activating the new one). Use `metadata.uniqueActivationId` to look up and remove the webhook registration you created in `activate`.

```ts src/blocks/my-trigger/deactivate.ts theme={"system"}
import {Workflows} from "attio/server"
import block from "./block"

export default Workflows.defineWorkflowBlockDeactivate(block, async ({config, metadata}) => {
  await fetch(
    `https://api.example.com/forms/${config.form_id}/webhooks/${metadata.uniqueActivationId}`,
    {
      method: "DELETE",
    },
  )

  return {type: "complete"}
})
```

See [Deactivating a trigger](/sdk/workflows/define-workflow-block-deactivate).

### 5. Wire up a configurator

Same pattern as step blocks:

```tsx src/blocks/my-trigger/configurator.tsx theme={"system"}
import {Workflows} from "attio/client"
import block from "./block"

export default Workflows.defineConfigurator(block, (workflowBlock) => {
  const {TextInput, Outcome} = Workflows.useConfigurator(workflowBlock)
  return (
    <>
      <TextInput name="form_id" label="Form ID" />
      <Outcome id="submitted" schema={null} />
    </>
  )
})
```

***

## Accessing third-party services

Workflow block handlers run in Attio's server sandbox, the same environment as [server functions](/sdk/server/server-functions). You have access to `fetch()` and can use [connections](/sdk/server/connections/connections) to authenticate on behalf of the user.

To require a user connection, set `requireUserConnection: true` in `Workflows.defineWorkflowBlock`, then call `getUserConnection()` in your handlers.

```ts theme={"system"}
import {getUserConnection} from "attio/server"

const token = getUserConnection().value
```

See [Authenticating to external services](/sdk/guides/authenticating-to-external-services) for the full setup.

## Calling the Attio REST API

Use `ATTIO_API_TOKEN` to call the Attio REST API from any handler:

```ts theme={"system"}
import {ATTIO_API_TOKEN} from "attio/server"

await fetch("https://api.attio.com/v2/records", {
  headers: {Authorization: `Bearer ${ATTIO_API_TOKEN}`},
})
```

See the [REST API overview](/rest-api/overview) for available endpoints.

## Reference

* [File structure](/sdk/workflows/file-structure): complete folder layout for trigger and step blocks
* [Block definition](/sdk/workflows/define-workflow-block): block identity and config schema
* [Config schema](/sdk/workflows/config-schema): all available schema field types
* [Outcome schema](/sdk/workflows/outcome-schema): typing the `data` field in return values
* [Executing a step](/sdk/workflows/define-workflow-block-execute): step execute handler
* [Registering a trigger](/sdk/workflows/define-workflow-block-activate): trigger activate handler
* [Receiving a trigger event](/sdk/workflows/define-workflow-block-on-trigger-callback): trigger event handler
* [Deactivating a trigger](/sdk/workflows/define-workflow-block-deactivate): trigger deactivate handler
* [Finishing a deferred step](/sdk/workflows/define-workflow-block-resume-callback): deferred step handler
