Skip to main content
Understanding when each handler is called helps you reason about state, idempotency, and failure recovery. The lifecycle differs significantly between trigger and step blocks.

Minimal example

  • Step block: execute.ts calls an external API and returns an outcome:
    execute.ts
    import {Workflows} from "attio/server"
    import block from "./block"
    
    export default Workflows.defineWorkflowBlockExecute(block, async ({config, metadata}) => {
      const result = await fetch("https://api.example.com/tasks", {
        method: "POST",
        body: JSON.stringify({title: config.task_title}),
      }).then((r) => r.json())
    
      return {type: "outcome", id: "created", data: {task_id: result.id}}
    })
    
  • Trigger block: activate.ts registers the webhook, trigger.ts fires the run:
    activate.ts
    import {Workflows} from "attio/server"
    import block from "./block"
    
    export default Workflows.defineWorkflowBlockActivate(block, async ({config, metadata}) => {
      await fetch("https://api.example.com/webhooks", {
        method: "POST",
        body: JSON.stringify({url: metadata.triggerCallbackUrl, id: metadata.uniqueActivationId}),
      })
      return {type: "complete"}
    })
    
    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()
      return {type: "outcome", id: "received", data: {event_id: payload.id}}
    })
    

Trigger lifecycle

A trigger manages an external subscription. Attio drives three distinct phases: Key points:
  • activate and deactivate each run once per workflow version. When a workspace member edits and re-enables the workflow, Attio deactivates the old version and activates the new one. You may receive events for the old triggerCallbackUrl during this window.
  • trigger runs once per incoming request to triggerCallbackUrl. It should be fast and side-effect-free beyond deciding whether to start a run. Any heavy work belongs in a step block.
  • uniqueActivationId is stable across retries of the same activate call. Store it alongside your webhook registration so you can identify and clean it up in deactivate.
  • If activate returns {type: "error"}, the workflow is not enabled and deactivate is never called.

Step lifecycle

A step runs inline during a workflow run. The basic path is a single call to execute: Key points:
  • uniqueExecutionId is stable across retries of the same execute call. Use it as an idempotency key when calling external APIs to avoid duplicate side-effects.
  • Returning {type: "defer"} suspends the block; the run pauses until an external service POSTs to finishCallbackUrl. No server resources are held while waiting.
  • finish is only required when execute may return {type: "defer"}. If execute never defers, the file is optional.
  • A deferred block can receive multiple callbacks before resolving; return {type: "no-op"} to stay deferred and wait for another POST.
  • retryable: true on an {type: "error"} return tells Attio it may safely retry the execution. Only set this when the operation is genuinely idempotent.

Defer

Use defer when your block must wait for something that happens outside the current HTTP request: a human approval, a slow background job, or a webhook from an external system. Unlike returning an outcome immediately, defer tells Attio to suspend the run entirely until an external signal arrives. The flow in detail:
  1. execute() runs. Before returning, register metadata.finishCallbackUrl with the external service; this is the URL it will POST to when the work is done.
  2. Return {type: "defer"}. Attio immediately suspends the block. The workflow run is checkpointed and paused; no server resources are held while waiting. There is no timeout; the block can stay deferred indefinitely.
  3. External event fires. The external service POSTs to finishCallbackUrl with any payload it wants.
  4. Attio calls finish() with the incoming request. The handler reads the payload and decides what to do.
  5. Resolve or stay deferred. Return an outcome / exit / error to resume the run, or return {type: "no-op"} to stay deferred and wait for the next POST. The external service can POST multiple times before the block resolves, which is useful for polling-style callbacks or multi-step approvals.
finishCallbackUrl is unique per execution. Register it with the external service inside execute(), not before. Do not share it across runs or blocks.
execute.ts
import {Workflows} from "attio/server"
import block from "./block"

export default Workflows.defineWorkflowBlockExecute(block, async ({config, metadata}) => {
  // Register the callback URL — the external service will POST here when done
  await fetch("https://api.example.com/jobs", {
    method: "POST",
    body: JSON.stringify({
      callback_url: metadata.finishCallbackUrl,
      task: config.task_title,
    }),
  })

  // Suspend the run — Attio calls finish() when the job POSTs back
  return {type: "defer"}
})
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") {
    // Job not finished — stay deferred and wait for the next POST
    return {type: "no-op"}
  }

  return {type: "outcome", id: "completed", data: {result: payload.result}}
})

Configurator

configurator.tsx is not part of the runtime lifecycle. It runs in the browser, inside the workflow editor, when a workspace member is configuring the block. It has no access to server-side APIs and should never perform side-effects. See Configurator.

See also