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 |
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
Createsrc/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
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
Createsrc/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
| 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 |
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
Createsrc/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
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 differentid 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
src/blocks/create-todoist-task/execute.ts
src/blocks/create-todoist-task/configurator.tsx
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
src/blocks/my-step/finish.ts
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 singleexecute handler, a trigger block manages an ongoing subscription to an external service across the full lifecycle of the workflow:
- Activate: called once when the workspace member enables the workflow. Register your webhook or subscription with the external service here.
- Trigger: called for every incoming event at
triggerCallbackUrl. Decide whether the event should start a run. - Deactivate: called once when the workflow is disabled or deleted. Remove the subscription here.
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
2. Write the activate handler
Createsrc/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
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
Createsrc/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
{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
Createsrc/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
5. Wire up a configurator
Same pattern as step blocks:src/blocks/my-trigger/configurator.tsx
Accessing third-party services
Workflow block handlers run in Attio’s server sandbox, the same environment as server functions. You have access tofetch() 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.
Calling the Attio REST API
UseATTIO_API_TOKEN to call the Attio REST API from any handler:
Reference
- File structure: complete folder layout for trigger and step blocks
- Block definition: block identity and config schema
- Config schema: all available schema field types
- Outcome schema: typing the
datafield in return values - Executing a step: step execute handler
- Registering a trigger: trigger activate handler
- Receiving a trigger event: trigger event handler
- Deactivating a trigger: trigger deactivate handler
- Finishing a deferred step: deferred step handler