Skip to main content
This guide walks through building a step block and a trigger block from scratch. For an overview of how blocks work, see Workflow blocks. For individual API details, see the reference pages.

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:
FileRuns inPurpose
block.tsShared (browser + server)Declares the block’s identity, display name, and the fields the workspace member fills in
execute.ts / activate.ts etc.Server onlyThe logic that runs when Attio calls your block
configurator.tsxBrowser onlyThe 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 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.
src/blocks/my-step/block.ts
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 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).
src/blocks/my-step/execute.ts
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:
ReturnEffect
{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 for the full API.
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.

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.
src/blocks/my-step/configurator.tsx
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.

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:
src/blocks/create-todoist-task/block.ts
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(),
  }),
})
src/blocks/create-todoist-task/execute.ts
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},
  }
})
src/blocks/create-todoist-task/configurator.tsx
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.
src/blocks/my-step/execute.ts
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"}
})
src/blocks/my-step/finish.ts
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 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

src/blocks/my-trigger/block.ts
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.
src/blocks/my-trigger/activate.ts
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.

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.
src/blocks/my-trigger/trigger.ts
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.

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.
src/blocks/my-trigger/deactivate.ts
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.

5. Wire up a configurator

Same pattern as step blocks:
src/blocks/my-trigger/configurator.tsx
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. You have access to fetch() and can use 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.
import {getUserConnection} from "attio/server"

const token = getUserConnection().value
See 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:
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 for available endpoints.

Reference