> ## 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.

# SDK concepts

> A walkthrough of building a complete Attio app using the App SDK

**An app extends Attio** by pulling data from a third-party service into Attio, or extracting Attio data to use with an external tool. Apps can surface custom UI *directly inside Attio's interface* using React components, run server-side logic in Attio's sandbox, and call the [REST API](/rest-api/overview) to read and write Attio data.

This page walks through a complete example to show how the pieces fit together. For a higher-level map of what you can build, see the [App SDK overview](./overview).

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

<Note>
  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()`](../server/available-globals#server-only), and communicate with Attio's REST API via
  [`ATTIO_API_TOKEN`](../server/attio-api-token).
</Note>

## 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](../server/connections) 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`](../server/events/connection-added) event handler the app
   registered is fired.
6. Event Handler calls [`createWebhookHandler()`](../server/webhooks/create-webhook-handler)
   to register a [webhook handler](../server/webhooks/update-webhook-handler).
7. Event Handler registers the new webhook with Acme Lead Checker.

```mermaid theme={"system"}
sequenceDiagram
    actor User
    participant Attio UI
    participant ALC as Acme Lead Checker
    participant Event Handler
    participant Attio Server SDK

    User->>Attio UI: Clicks to install app
    Attio UI->>User: Prompts to add connection to ALC
    User->>ALC: Logs in (OAuth flow)
    ALC-->>Attio UI: Redirects back with auth token
    Attio UI-->>User: Shows app is now installed
    Attio UI->>Event Handler: Fires 'connection-added' event
    Event Handler->>Attio Server SDK: Calls createWebhookHandler()
    Attio Server SDK-->>Event Handler: Returns webhook handler details
    Event Handler->>ALC: Registers webhook
```

### Usage

1. App provides a [record action](../entry-points/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()`](../notifications/show-toast).
6. Record Action loads the phone number of the person whose record page we are on
   asynchronously via [GraphQL](../graphql) using [`runQuery()`](../graphql/run-query). - If no phone numbers are found, the user is notified via an [`alert()`](../notifications/alert).
   Otherwise...
7. Record Action calls a
   [server function](../server/server-functions) called `sendToALC()`.
8. Server function uses [`fetch()`](../server/available-globals#server-only)
   to send a `POST` request to `api.acmeleadchecker.ai`.
9. Server function calls the [Attio REST API](/rest-api/overview) with [`ATTIO_API_TOKEN`](../server/attio-api-token) to mark the record
   as "Pending".
10. Server function returns success.
11. Record Action hides first toast
    with [`hideToast()`](../notifications/show-toast#hidetoast-promise-void).
12. Record Action notifies the user that the process was successful
    with [`showToast()`](../notifications/show-toast).

...some time later...

10. Acme Lead Checker's server calls a webhook provided by the app.
11. Webhook Handler calls the [Attio REST API](/rest-api/overview) with [`ATTIO_API_TOKEN`](../server/attio-api-token) to mark the record
    as "Complete".

```mermaid theme={"system"}
sequenceDiagram
    actor User
    participant Attio UI
    participant Record Action
    participant GraphQL
    participant Server Function
    participant ALC as api.acmeleadchecker.ai
    participant Webhook
    participant Attio API

    User->>Attio UI: Clicks button
    Attio UI->>Record Action: Fires onTrigger()
    Record Action-->>Attio UI: showToast()
    Attio UI-->>User: Shows "Loading" toast
    Record Action->>GraphQL: Load phone numbers
    GraphQL-->>Record Action: Query result
    Record Action->>Server Function: Calls sendToALC()
    Server Function->>ALC: POST request
    Server Function->>Attio API: with ATTIO_API_TOKEN to mark record "Pending"
    Server Function-->>Record Action: Returns success
    Record Action-->>Attio UI: hideToast()
    Attio UI-->>User: Hides "Loading" toast
    Record Action-->>Attio UI: showToast()
    Attio UI-->>User: Shows "Success" toast

    Note over User,Attio API: Some time later...

    ALC->>Webhook: Calls the app's webhook
    Webhook->>Attio API: with ATTIO_API_TOKEN to mark record "Complete"
```

## Implementation

### `app.ts`

An app always contains an `app.ts` file in the `src` folder, which describes its different entry points.
Here's an example of an empty app which doesn't have any features yet.

```ts theme={"system"}
import type {App} from "attio"

export const app: App = {
  record: {
    actions: [],
    bulkActions: [],
    widgets: [],
  },
  callRecording: {
    insight: {
      textActions: [],
    },
    summary: {
      textActions: [],
    },
    transcript: {
      textActions: [],
    },
  },
}
```

### Record action

Our [Record action](../entry-points/record-action) can be defined in any file, and must have the type `App.Record.Action`.

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

export const sendToAlcAction: App.Record.Action = {
  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
}
```

We must then include our record action in `app.ts`.

```ts theme={"system"}
import type {App} from "attio"

import {sendToAlcAction} from "./send-to-alc-record-action"

export const app: App = {
  record: {
    actions: [sendToAlcAction],
    // ...
  },
  // ...
}
```

### GraphQL query

Now let's write that [GraphQL](../graphql) query we're importing.

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

### Server function

[Server function](../server) file names *MUST*:

* have a `.server.ts` 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.

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

```typescript send-to-alc.server.ts theme={"system"}
import {ATTIO_API_TOKEN, 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()

  await fetch(`https://api.attio.com/v2/objects/people/records/${recordId}`, {
    method: "PATCH",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${ATTIO_API_TOKEN}`,
    },
    body: JSON.stringify({
      data: {
        values: {
          alc_lead_id: [{value: lead.id}],
        },
      },
    }),
  })

  return lead
}
```

### Webhook handler

Our [webhook handler](../server/webhooks) 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` suffix.
* Contain an `export default async function` that:
  * takes an HTTP [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) argument
  * returns an HTTP [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response)

```typescript webhooks/lead-processed.webhook.ts theme={"system"}
import {ATTIO_API_TOKEN} 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

  if (!recordId || !status) {
    return new Response("Bad Request", {status: 400})
  }

  await fetch(`https://api.attio.com/v2/objects/people/records/${recordId}`, {
    method: "PATCH",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${ATTIO_API_TOKEN}`,
    },
    body: JSON.stringify({
      data: {
        values: {
          alc_status: [{value: status}],
        },
      },
    }),
  })

  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](../server/connections);
we accomplish this with [event handlers](../server/events).

Connection Event Handler files *MUST*:

* Live in `src/events`
* Have a `.event.ts` 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

```typescript events/connection-added.event.ts theme={"system"}
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

```typescript events/connection-removed.event.ts theme={"system"}
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
  }
}
```

## Workflow blocks

The example above shows how apps surface UI and call external APIs. Apps can also extend **Attio Workflows** with custom triggers and steps.

* A **trigger block** starts a workflow run when an external event arrives — for example, a webhook from a third-party service.
* A **step block** runs as part of an in-progress workflow run — for example, calling an external API, transforming data, or waiting on an async callback.

Workflow blocks use the same server sandbox as server functions and have full access to `fetch()`, `ATTIO_API_TOKEN`, and connections.

See the [Workflow blocks overview](../workflow-blocks/overview) and the [reference pages](../workflows/define-workflow-block) for full details.
