> ## Documentation Index
> Fetch the complete documentation index at: https://www.plain.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a support agent

> Build a support agent that replies when it is assigned to a thread.

A support agent replies when it is assigned to a thread. It reads the thread, replies to the customer, and reports agent status.

For an agent that answers your team in Ask Sidekick, see [internal agents](/docs/agents/internal-agent). That uses different events and mutations, and nothing it writes reaches the customer.

<Snippet file="agents/beta.mdx" />

<Steps>
  <Step title="Give the agent an identity">
    Create a [machine user](/docs/agents/machine-users) under **Settings → Machine users & API Keys**.

    This example reads a thread, replies, reports status, and can hand off. Click **Add API key** and grant:

    * `thread:read`
    * `thread:reply` for `replyToThread`
    * `thread:edit` for agent status, done, and todo
    * `thread:assign` and `thread:unassign`
    * `customer:read`

    Add more if the agent does more. A mutation without the right permission returns an error that names it.

    Copy the machine user ID from the URL (`/settings/machine-users/mu_…`).
  </Step>

  <Step title="Receive and verify events">
    Add an HTTPS `POST` endpoint. Create a target under **Settings → Webhooks** (**Add webhook target**). Subscribe to [`thread.thread_assignment_transitioned`](/docs/webhooks/thread-assignment-transitioned) and customer-message events such as [`thread.email_received`](/docs/webhooks/thread-email-received). Copy the signing secret from **Settings → Request signing** (**Workspace HMAC Secret**). The webhook target has no secret.

    ```bash theme={null}
    npm install @team-plain/webhooks
    ```

    ```ts theme={null}
    import { verifyPlainWebhook } from "@team-plain/webhooks";

    app.post("/webhooks/plain", async (req, res) => {
      const result = verifyPlainWebhook(
        req.body, // raw body string, see the warning below
        req.header("plain-request-signature")!,
        process.env.PLAIN_WEBHOOK_SECRET!,
      );

      if (result.error) {
        return res.status(400).send(result.error.message);
      }

      const event = result.data;

      switch (event.payload.eventType) {
        case "thread.thread_assignment_transitioned":
          await onAssigned(event.payload);
          break;
        case "thread.email_received":
          await onEmailReceived(event.payload);
          break;
      }

      res.sendStatus(200);
    });
    ```

    <Warning>
      `verifyPlainWebhook` needs the **raw request body**, not parsed JSON. With Express, use `express.text({ type: "*/*" })`.
    </Warning>

    `parsePlainWebhook` skips the signature and checks shape only. See [webhooks](/docs/webhooks), [request signing](/docs/request-signing), and [mTLS](/docs/mtls).
  </Step>

  <Step title="Decide which threads it acts on">
    In the handler, run only when the thread is assigned to your machine user:

    ```ts theme={null}
    function isAssignedToMe(thread: { assignee?: { id: string } | null }): boolean {
      return thread.assignee?.id === process.env.AGENT_MACHINE_USER_ID;
    }

    if (event.payload.eventType === "thread.thread_assignment_transitioned") {
      if (!isAssignedToMe(event.payload.thread)) return;
      await runAgent(event.payload.thread);
    }

    if (event.payload.eventType === "thread.email_received") {
      if (!isAssignedToMe(event.payload.thread)) return;
      await runAgent(event.payload.thread);
    }
    ```

    Assign in the UI, or with a [workflow](/docs/product/workflows) under **Settings → Workflows** (channel, labels, customer tier, support hours). Workflows are UI-only. Assignment still arrives as `thread.thread_assignment_transitioned`. The payload includes `previousThread`.

    Ignore events your agent caused. For message events, skip when the author is your machine user. After a handoff, the assignee is no longer you, so the filter drops it.
  </Step>

  <Step title="Read the thread">
    ```ts theme={null}
    const thread = await plain.query.thread({
      threadId: "th_01H8H46YPB2S4MAJM382FG9423",
    });
    ```

    Needs `thread:read`. `customer`, `assignee`, and `labels` are lazy-loaded. See the [GraphQL SDK](/docs/graphql/sdk).

    Concatenate `llmText` on timeline entries for a prompt-ready thread.

    ```ts theme={null}
    async function getThreadAsLlmText(threadId: string): Promise<string> {
      const thread = await plain.query.thread({ threadId });
      const parts: string[] = [];

      let page = await thread.timelineEntries({ first: 50 });
      while (true) {
        for (const entry of page.nodes) {
          if (entry.llmText) parts.push(entry.llmText);
        }

        const next = await page.fetchNext();
        if (!next) break;
        page = next;
      }

      return parts.join("\n\n");
    }
    ```

    <Note>
      `llmText` is `null` for entry types with nothing meaningful to render. Skip those entries.
    </Note>

    You can also read `thread.customer`, [thread fields](/docs/graphql/threads/thread-fields), or the message already on payloads such as [`thread.email_received`](/docs/webhooks/thread-email-received). To search Help Center articles and indexed documents, see [searching knowledge](/docs/agents/searching-knowledge).
  </Step>

  <Step title="Act on the thread">
    Reply with `replyToThread` (`thread:reply`). Works on `API`, `CHAT`, `EMAIL`, `SLACK`, and `MS_TEAMS`. Plain delivers on that channel as the machine user.

    ```bash theme={null}
    npm install @team-plain/graphql
    ```

    ```ts theme={null}
    import { PlainClient } from "@team-plain/graphql";

    const plain = new PlainClient({ apiKey: process.env.PLAIN_API_KEY! });

    const result = await plain.mutation.replyToThread({
      input: {
        threadId: thread.id,
        textContent: "Thanks for reaching out, let me look into this.",
        markdownContent: "Thanks for reaching out, let me look into this.",
      },
    });

    if (result.error) {
      console.error(result.error.message);
    }
    ```

    Always send both fields. `textContent` is the fallback; `markdownContent` is rendered in Plain, chat, and modern email. See [reply to thread](/docs/graphql/messaging/reply-to-thread).
  </Step>

  <Step title="Report agent status">
    | Status        | When                                                                                                                                                                    |
    | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
    | `IN_PROGRESS` | The agent is working the thread.                                                                                                                                        |
    | `HANDLED`     | The agent resolved it. Also [mark it as done](/docs/graphql/threads/status-changes#mark-thread-as-done).                                                                     |
    | `HANDED_OFF`  | A user is needed. Also [unassign](/docs/graphql/threads/assignment#unassigning-threads), and optionally [mark as todo](/docs/graphql/threads/status-changes#mark-thread-as-todo). |

    ```ts theme={null}
    await plain.mutation.updateThreadAgentStatus({
      input: {
        threadId: thread.id,
        agentStatus: "IN_PROGRESS",
      },
    });
    ```

    First Response, Next Response, and Investigating only show `HANDED_OFF` threads.
  </Step>
</Steps>

## Caveats

If a user replies on a thread your agent marked `HANDLED` or `IN_PROGRESS`, Plain sets `HANDED_OFF` itself.

On failure: `createNote`, `updateThreadAgentStatus(HANDED_OFF)`, `unassignThread`, then `markThreadAsTodo`.

## Also useful

**Suggest a reply** with `addGeneratedReply` (`generatedReply:create`). The customer sees nothing until a user sends it. Once you've suggested a reply on a thread, Ari stops drafting its own suggestions there. `markdown` max 5,000 characters. `timelineEntryId` must be a message from the customer or from a machine user (for example `payload.email.timelineEntryId`). See [suggested replies](/docs/graphql/messaging/suggested-replies).

```ts theme={null}
const result = await plain.mutation.addGeneratedReply({
  input: {
    threadId: thread.id,
    timelineEntryId: customerMessage.id,
    markdown: "Hi! You can reset your password under **Settings → Security**.",
  },
});
```

**Note** (never sent to the customer):

```ts theme={null}
await plain.mutation.createNote({
  input: {
    customerId: thread.customer.id,
    threadId: thread.id,
    text: "Customer asked for a refund. Confidence: low. Escalating.",
    markdown: "Customer asked for a refund. **Confidence: low.** Escalating.",
  },
});
```

**Labels** from **Settings → Labels**. `removeLabels` takes label IDs, not label type IDs. See [labels](/docs/graphql/labels/add).

```ts theme={null}
await plain.mutation.addLabels({
  input: {
    threadId: thread.id,
    labelTypeIds: ["lt_01HB8BTNTZ58730MX8H5VMKFD5"],
  },
});
```

**Hand off** with [assignment](/docs/graphql/threads/assignment):

```ts theme={null}
await plain.mutation.assignThread({
  input: {
    threadId: thread.id,
    userId: "u_01FSVKMHFDHJ3H5XFM20EMCBQN",
  },
});

await plain.mutation.unassignThread({
  input: { threadId: thread.id },
});
```

**Assign to a machine user** from a classifier:

```ts theme={null}
await plain.mutation.assignThread({
  input: {
    threadId: thread.id,
    machineUserId: process.env.AGENT_MACHINE_USER_ID,
  },
});
```

**Filter in the handler** (no assignment) for classifiers, notes, or a one-shot acknowledgement. Do not use this for an agent that handles support on its own.

```ts theme={null}
if (event.payload.eventType === "thread.thread_created") {
  const thread = event.payload.thread;
  if (thread.tier?.name !== "Premium") return;
  await runAgent(thread);
}
```

Other events: [`thread.thread_created`](/docs/webhooks/thread-created), [`thread.chat_received`](/docs/webhooks/thread-chat-received), [`thread.slack_message_received`](/docs/webhooks/thread-slack-message-received), [`thread.thread_status_transitioned`](/docs/webhooks/thread-status-transitioned). `thread.thread_created` plus `thread.email_received` fires twice for the first email; use `isStartOfThread` if you want one of them.

| Action                    | Mutation                                                                       |
| ------------------------- | ------------------------------------------------------------------------------ |
| Done or todo              | [`markThreadAsDone`](/docs/graphql/threads), [`markThreadAsTodo`](/docs/graphql/threads) |
| New outbound email        | [`sendNewEmail`](/docs/graphql/messaging/send-email)                                |
| Reply to a specific email | [`replyToEmail`](/docs/graphql/messaging/reply-email)                               |
| Thread field              | [`upsertThreadField`](/docs/graphql/threads/thread-fields)                          |
| Customer event            | [`createCustomerEvent`](/docs/graphql/events/create-customer-event)                 |

[API explorer](https://app.plain.com/developer/api-explorer/)

## Resources

* [Machine users](/docs/agents/machine-users)
* [Searching knowledge](/docs/agents/searching-knowledge)
* [Internal agents](/docs/agents/internal-agent)
* [GraphQL SDK](/docs/graphql/sdk)
* [Webhooks](/docs/webhooks)
