This only covers apps from the perspective of the App SDK, apps can also use the REST API

An app is a way to extend the existing functionality of Attio, typically by pulling data into Attio from a third party source, or of extracting data out of Attio to use with some tool. Apps can provide custom UI directly inside of Attio’s user interface using custom React components.

Let’s look at an example.

Let’s imagine a hypothetical service called Acme Lead Checker (ALC) that has an API to receive potential leads, an AI agent initiates an SMS chat with the lead, and then needs to update the lead’s record in Attio about how interested the person is in whatever product we are selling.

Our app needs:

  • A button inside Attio that will call a server function
  • A server function to actually send the data to ALC
  • A webhook to receive the lead status back from ALC sometime in the future

App UI components cannot directly communicate with the outside world. They can only call custom app server functions, which can communicate with the outside world via fetch(), and communicate with Attio’s REST API via attioFetch().

Sequence

The general sequence of how the app will work is:

Installation

  1. User clicks to install the app.
  2. User is prompted to add a connection to Acme Lead Checker
  3. User logs into Acme Lead Checker to complete the OAuth flow.
  4. User is redirected back to Attio. The app is now installed.
  5. The connection-added event handler the app registered is fired.
  6. Event Handler calls createWebhookHandler() to register a webhook handler.
  7. Event Handler registers the new webhook with Acme Lead Checker.

Usage

  1. App provides a record action which will manifest itself in Attio’s UI as
    • a button on the People record page
    • in the CMD-K quick action palette.
  2. User views the record page
  3. User clicks button
  4. Attio’s UI fires the onTrigger() function provided by the record action
  5. Record Action notifies the user that async things are happening with showToast().
  6. Record Action loads the phone number of the person whose record page we are on asynchronously via GraphQL using runQuery().
    • If no phone numbers are found, the user is notified via an alert(). Otherwise…
  7. Record Action calls a server function called sendToALC().
  8. Server function uses fetch() to send a POST request to api.acmeleadchecker.ai.
  9. Server function uses attioFetch() to mark the record as “Pending”.
  10. Server function returns success.
  11. Record Action hides first toast with hideToast().
  12. Record Action notifies the user that the process was successful with showToast().

…some time later…

  1. Acme Lead Checker’s server calls a webhook provided by the app.
  2. Webhook Handler uses attioFetch() to mark the record as “Complete”.

Implementation

Record action

Record action files can have any name, but they MUST have a named export called recordAction.

send-to-alc-record-action.ts
import type {RecordAction} from "attio/client"
import {runQuery, showToast, alert} from "attio/client"
import getPersonPhoneNumbersQuery from "./get-person-phone-numbers.graphql"
import sendToAlc from "./send-to-alc.server"

// This must be a named export called "recordAction"
export const recordAction: RecordAction = {
  id: "send-to-alc", // internal unique identifier
  label: "Send to ALC", // user-facing label
  onTrigger: async ({recordId}) => {
    const {hideToast} = await showToast({
      title: "Preparing to send to ALC...",
      variant: "neutral",
    })

    const {person} = await runQuery(getPersonPhoneNumbersQuery, {recordId})
    // `person` is strongly typed here as:
    // {
    //   name: {
    //     full_name: string | null
    //   } | null
    //   phone_numbers: string[]
    // } | null
    // ...so TypeScript will help us know the checks we need to perform.

    if (!person) {
      await hideToast()
      await alert({
        title: "Failed to load person data",
        text: "Please try again.",
      })
      return
    }

    const firstPhoneNumber = person.phone_numbers[0] ?? null
    if (!firstPhoneNumber) {
      await hideToast()
      await alert({
        title: "No phone number found",
        text: "Please add a phone number to the person and try again.",
      })
      return
    }

    try {
      await sendToAlc(recordId, person.name?.full_name ?? "Unknown", firstPhoneNumber)
    } catch {
      await hideToast()
      await alert({
        title: "Failed to send to ALC",
        text: "Please try again.",
      })
      return
    }

    await hideToast()
    await showToast({
      title: "Successfully sent to ALC!",
      variant: "success",
    })
  },
  objects: "person", // only show this action on person records
}

GraphQL query

Now let’s write that GraphQL query we’re importing.

get-person-phone-numbers.graphql
query getPersonPhoneNumbers($recordId: String!) {
  person(id: $recordId) {
    name {
      full_name
    }
    phone_numbers
  }
}

Server function

Server function file names MUST:

  • have a .server.ts (or .server.js) suffix
  • contain an export default async function

The suffix is how Attio knows to execute them on the server. However, they are imported as if they were in the same bundle as the client side code, even though they are not.

Because they live in different bundles and runtimes, everything passed to, returned from, or thrown by server functions MUST be serializable.

send-to-alc.server.ts
import {attioFetch, getWorkspaceConnection} from "attio/server"

export default async function sendToAlc(recordId: string, name: string, phoneNumber: string) {
  // Get the authorization token from the workspace connection
  // that the user has set up in their Attio account.
  const connection = getWorkspaceConnection()
  const authorizationToken = connection.value

  const response = await fetch("https://api.acmeleadchecker.ai/api/v1/leads", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Authorization": `Bearer ${authorizationToken}`,
    },
    body: JSON.stringify({recordId, name, phoneNumber}),
  })

  if (!response.ok) {
    throw new Error("Failed to send to ALC")
  }

  const lead = await response.json()

  // Update the person record with the ALC ID
  await attioFetch({
    // call to Attio's REST API
  })

  return lead
}

Webhook handler

Our webhook handler is going to be called by Acme Lead Checker when they have processed our lead that we sent them.

Webhook handler files MUST:

  • Live under the src/webhooks directory
  • Have a .webhook.ts (or .webhook.js) suffix.
  • Contain an export default async function that:
webhooks/lead-processed.webhook.ts
import { attioFetch } from "attio/server"

export default async function leadProcessedWebhook(req: Request): Promise<Response> {
  const body = await req.json()

  const recordId = body.record_id
  const status = body.status

  await attioFetch({
    // call to Attio's REST API
  })

  return new Response(null, {status: 200})
}

Connection event handlers

In order to let Acme Lead Checker know how to call our app’s webhook, we need to tell them as soon as our user creates an authorized connection; we accomplish this with event handlers.

Connection Event Handler files MUST:

  • Live in src/events
  • Have a .event.ts (or .event.js) suffix.
  • Contain an export default async function that:
    • Takes a { connection: Connection } argument
    • Returns void

Connection added event handler

When a connection is added, we need to:

  1. Create a webhook
  2. Register our webhook with Acme Lead Checker
  3. Update our webhook with the unique identifier of our webhook on ALC’s side
events/connection-added.event.ts
import type {Connection} from "attio/server"
import {createWebhookHandler, updateWebhookHandler} from "attio/server"

export default async function connectionAdded({connection}: {connection: Connection}) {
  // The filename must match the file in src/webhooks, but without the suffix
  const handler = await createWebhookHandler({fileName: "lead-processed"})

  const authorizationToken = connection.value

  const response = await fetch("https://api.acmeleadchecker.ai/api/v1/webhooks", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Authorization": `Bearer ${authorizationToken}`,
    },
    body: JSON.stringify({
      name: handler.id,
      url: handler.url,
      event: "lead.processed",
    }),
  })

  if (!response.ok) {
    throw new Error(`Failed to register webhook: ${response.statusText}`)
  }

  const webhook = await response.json()

  // Save the external webhook ID so we can delete it when the connection is removed
  await updateWebhookHandler(handler.id, {
    externalWebhookId: webhook.webhook_id,
  })
}

Connection removed event handler

When a connection is removed, we need to:

  1. Load all our app’s webhook handlers (there should only be one)
  2. For each handler, tell ALC to stop calling it
  3. Delete the webhook from Attio
events/connection-removed.event.ts
import type {Connection} from "attio/server"
import {deleteWebhookHandler, listWebhookHandlers} from "attio/server"

export default async function connectionRemoved({connection}: {connection: Connection}) {
  try {
    const handlers = await listWebhookHandlers()
    const authorizationToken = connection.value

    // Delete webhooks on ALC
    // There should be only one webhook handler active as we have single workspace connection
    await Promise.all(
      handlers.map(async (handler) => {
        const response = await fetch(
          `https://api.acmeleadchecker.ai/api/v1/webhooks/${handler.externalWebhookId}`,
          {
            method: "DELETE",
            headers: {
              Authorization: `Bearer ${authorizationToken}`,
            },
          }
        )
        if (!response.ok) {
          throw new Error(`Failed to delete webhook: ${response.statusText}`)
        }
      })
    )

    // Delete webhooks on Attio
    await Promise.all(
      handlers.map(async (handler) => {
        await deleteWebhookHandler(handler.id)
      })
    )
  } catch (error) {
    console.error(error)
    // don't rethrow the error so the connection is still removed
  }
}