> ## Documentation Index
> Fetch the complete documentation index at: https://plain-docs-orca-916-agent-docs-restructure.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a support agent

> Run your own agent on customer threads, from its identity through to the actions it takes.

A support agent works customer threads: it receives events from Plain, reads the thread, and replies, labels, notes, or hands off to your team. This page is the whole journey in order, from the agent's identity to the actions it takes. Plain runs the infrastructure around it, and the AI part, including model choice, prompts, and tool use, is yours.

To have your agent answer your own team inside a Sidekick discussion instead, see [internal agents](/agents/internal-agent). That surface uses different events and different mutations, and nothing it writes reaches the customer.

<Steps>
  <Step title="Give the agent an identity">
    Your agent acts as a [machine user](/agents/machine-users), created under **Settings** → **Machine Users**. It gets a public name and an avatar that customers see, and an audit trail separate from any person's.

    Create an API key on the machine user's page with the permissions your agent needs:

    * `thread:read`: read threads and their timelines
    * `thread:reply`: send replies with `replyToThread`
    * `generatedReply:create`: add suggested replies for a user to review
    * `thread:assign` and `thread:unassign`: hand threads to and from people
    * `customer:read`: look up customer context before replying

    If your agent does more, such as labeling threads or marking them as done, add the matching permissions. A mutation attempted without one returns an error naming the permission it needs.

    Copy the machine user's ID from that page too. Your agent compares it against a thread's assignee in the routing step below.
  </Step>

  <Step title="Receive and verify events">
    Your agent finds out about new threads and customer messages through [webhooks](/webhooks). Stand up a public HTTPS endpoint that accepts `POST`, then add a target under **Settings** → **Webhooks** and copy its signing secret.

    The [`@team-plain/webhooks`](/webhooks/sdk) package handles signature verification, replay protection, and schema validation, and gives you typed payloads:

    ```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;

      // event.payload is a discriminated union, narrow on eventType
      switch (event.payload.eventType) {
        case "thread.thread_created":
          await onThreadCreated(event.payload);
          break;
        case "thread.email_received":
          await onEmailReceived(event.payload);
          break;
      }

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

    <Warning>
      `verifyPlainWebhook` needs the **raw request body**, not the parsed JSON. With Express, use `express.text({ type: "*/*" })`; with other frameworks, disable JSON parsing for the webhook route and read the body as a string.
    </Warning>

    These are the events agents subscribe to most often:

    | Event                                                                               | When it fires                                                                      |
    | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
    | [`thread.thread_created`](/webhooks/thread-created)                                 | A new thread is created, whatever channel it came from.                            |
    | [`thread.email_received`](/webhooks/thread-email-received)                          | A customer email arrives. Fires for the first email and every reply.               |
    | [`thread.chat_received`](/webhooks/thread-chat-received)                            | A customer sends a chat message via the [chat widget](/ui-components).             |
    | [`thread.slack_message_received`](/webhooks/thread-slack-message-received)          | A customer posts in a connected Slack channel.                                     |
    | [`thread.thread_assignment_transitioned`](/webhooks/thread-assignment-transitioned) | A thread's assignee changes. Use this if your agent should only run when assigned. |
    | [`thread.thread_status_transitioned`](/webhooks/thread-status-transitioned)         | A thread moves between `TODO`, `SNOOZED`, and `DONE`.                              |

    <Note>
      Subscribing to both `thread.thread_created` and `thread.email_received` gives you two events for the first email in a thread. Check `isStartOfThread` on the email payload if you only want to react once.
    </Note>

    For development, or when verification happens upstream in an API gateway, `parsePlainWebhook` skips the signature check and validates only the payload shape. See the [webhooks overview](/webhooks) for delivery semantics, retries, [request signing](/request-signing), and [mTLS](/mtls).
  </Step>

  <Step title="Decide which threads it acts on">
    An event tells you something happened, and most agents act on a subset of threads. There are two patterns.

    **Filter in code.** Subscribe to an event type and decide in the handler:

    ```ts theme={null}
    if (event.payload.eventType === "thread.thread_created") {
      const thread = event.payload.thread;

      // Example filters, adapt to whatever your agent cares about
      if (thread.priority !== 0) return; // only urgent threads
      if (thread.labels.some(l => l.labelType.name === "no-bot")) return;

      await runAgent(thread);
    }
    ```

    This suits a small code-driven decision, or an agent that runs on every new thread. It fits agents that observe and supplement what your team does: classifiers, summarizers, agents that post internal notes, or an autoresponder that sends one acknowledgement. It does not suit an agent handling support autonomously, because the thread is never assigned to the agent and your reporting will not reflect its involvement.

    **Assign threads to the machine user, which we recommend.** The "should the agent handle this?" decision lives in Plain rather than your code, and your existing reporting attributes the agent's work to it the way it would for a person, covering volumes, resolution times, and response times.

    ```ts theme={null}
    function isAssignedToMe(thread: { assignee?: { id: string } | null }): boolean {
      // Your machine user's ID is on its settings page in Plain.
      return thread.assignee?.id === process.env.AGENT_MACHINE_USER_ID;
    }

    // Fires when a thread is assigned
    if (event.payload.eventType === "thread.thread_assignment_transitioned") {
      if (!isAssignedToMe(event.payload.thread)) return;
      await runAgent(event.payload.thread);
    }

    // Fires when an email arrives on a thread
    if (event.payload.eventType === "thread.email_received") {
      if (!isAssignedToMe(event.payload.thread)) return;
      await runAgent(event.payload.thread);
    }
    ```

    That pattern keeps filter logic out of your code. A user hands a thread over by reassigning it, the agent hands back the same way, and you change routing rules without redeploying. The `thread.thread_assignment_transitioned` payload also includes `previousThread`, the state before the change, so you can see who the thread came from.

    A [workflow](/product/workflows) is the usual way to do the assigning: trigger on thread creation, optionally check channel, labels, customer tier, or support hours, then assign the thread to your machine user. You configure workflows under **Settings** → **Workflows**, and the assignment action lets you pick a machine user directly.

    <Note>
      Workflows are configured in the Plain UI, not through the API. Once a workflow assigns a thread to a machine user, your agent receives a `thread.thread_assignment_transitioned` event, the same as any other assignment change.
    </Note>

    You can also assign from code, for example from a classifier that picks which downstream agent gets a thread. `AssignThreadInput` accepts a `machineUserId` in place of a `userId`:

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

    **Whichever pattern you use, reject events your own agent caused.** The assignee filter above handles assignment changes; for message events, check that the message author is not your own machine user before reacting. If the agent reassigns a thread to a person, the resulting event carries a different assignee, so the assignee filter drops it.
  </Step>

  <Step title="Read the thread">
    Most agents read the thread before deciding what to do. The `thread` query returns a thread by ID with its metadata and needs `thread:read`:

    ```ts theme={null}
    const thread = await plain.query.thread({
      threadId: "th_01H8H46YPB2S4MAJM382FG9423",
    });
    ```

    Most thread fields are scalars on the model. Related objects such as `customer`, `assignee`, and `labels` are lazy-loaded, so reading one triggers a separate API call. See the [GraphQL SDK](/graphql/sdk) for how that works.

    Every timeline entry exposes an `llmText` field, a plain-text rendering shaped for a language model. Paginate `timelineEntries` and concatenate it to get the whole thread as prompt-ready text:

    ```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>

    If you do not need the whole thread, read what you need directly:

    * **The customer**: `thread.customer`, or `plain.query.customer({ customerId })`, for subscription tier, external IDs, and anything else on the customer.
    * **Custom thread fields**: structured data attached to the thread. See [thread fields](/graphql/threads/thread-fields).
    * **The triggering message**: on events like [`thread.email_received`](/webhooks/thread-email-received) the message is already on the payload, so no extra call is needed.

    If your agent answers questions, ground its replies in your own content with [knowledge search](/agents/searching-knowledge).
  </Step>

  <Step title="Act on the thread">
    Everything happens through the [GraphQL SDK](/graphql/sdk):

    ```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! });
    ```

    **Reply directly** with `replyToThread`, which works on threads whose channel is `API`, `CHAT`, `EMAIL`, `SLACK`, or `MS_TEAMS`. Plain delivers the message through the right channel, and it appears as a reply from the agent's machine user. Needs `thread:reply`.

    ```ts theme={null}
    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 provide both fields. `textContent` is shown in clients that do not render markdown, and `markdownContent` is rendered in the Plain UI, the chat widget, and modern email clients. See [reply to thread](/graphql/messaging/reply-to-thread) for the full reference.

    **Suggest a reply** with `addGeneratedReply` instead of sending. The suggestion appears in Plain attached to a specific customer message, and a user reviews, edits, and sends or discards it. The customer sees nothing until a person sends. This is a good default while you tune an agent: you get the drafting without committing to autonomous send.

    ```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**.",
      },
    });
    ```

    The `timelineEntryId` must point at a customer message, which you get from the webhook payload (for example `payload.email.timelineEntryId`) or by paginating timeline entries. `markdown` is capped at 5,000 characters, and the call needs `generatedReply:create`. See [suggested replies](/graphql/messaging/suggested-replies).

    **Post a note** that lives on the timeline and is never delivered to the customer. Notes are for leaving context for the next person on the thread, or recording why the agent did or did not act.

    ```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.",
      },
    });
    ```

    **Add labels** to classify threads, flag them for review, or drive workflows, reporting, and routing rules. A classifier that only labels each new thread is among the smallest agents you can build. Labels reference label types you create under **Settings** → **Labels**.

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

    Remove them with `removeLabels`, passing the IDs of the labels rather than the label types. See [labels](/graphql/labels/add).

    **Change the assignee** to hand off to a person, or unassign and let your workflows route from there. A common handoff is a note explaining the context followed by a reassignment.

    ```ts theme={null}
    await plain.mutation.assignThread({
      input: {
        threadId: thread.id,
        userId: "u_01FSVKMHFDHJ3H5XFM20EMCBQN", // for example whoever is on call
      },
    });

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

    See [assignment](/graphql/threads/assignment) for the full reference.
  </Step>

  <Step title="Report agent status">
    Agent status is what puts threads in the right views in Plain, so set it as part of your integration. It has three states:

    | Status        | When to use                                                                                                                                                                                         |
    | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
    | `IN_PROGRESS` | Your agent is actively working on the thread.                                                                                                                                                       |
    | `HANDLED`     | Your agent resolved the thread. Also [mark it as done](/graphql/threads/status-changes#mark-thread-as-done).                                                                                        |
    | `HANDED_OFF`  | Your agent needs a person. Also [unassign the thread](/graphql/threads/assignment#unassigning-threads), and optionally [move it back to TODO](/graphql/threads/status-changes#mark-thread-as-todo). |

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

    **Only threads with an agent status of `HANDED_OFF` appear in your First Response, Next Response, and Investigating queues.** That is deliberate: work your agent is handling stays out of view, and only threads needing a person are visible. To see everything your agent touched, go to **Plain** → **AI** → **Agent Activity**.
  </Step>
</Steps>

## Caveats

**A human reply moves the thread to `HANDED_OFF` automatically.** When a person replies to a thread your agent marked `HANDLED` or `IN_PROGRESS`, Plain moves it to `HANDED_OFF` itself, which signals the handoff. This matters because a thread your agent answered, which the customer then replied to, which a person then picked up, appears in your Todo queues from that point on.

**Watch for loops in error paths.** A typical failure path is `createNote` with what happened, then `updateThreadAgentStatus(HANDED_OFF)`, then `unassignThread`, then `markThreadAsTodo` so the thread returns to the Todo queues for a person to pick up.

## Other useful operations

These mutations cover most agents, and the same `PlainClient` exposes the rest of Plain's API:

| Action                       | Mutation                                                                       |
| ---------------------------- | ------------------------------------------------------------------------------ |
| Mark as done or move to todo | [`markThreadAsDone`](/graphql/threads), [`markThreadAsTodo`](/graphql/threads) |
| Send a new outbound email    | [`sendNewEmail`](/graphql/messaging/send-email)                                |
| Reply to a specific email    | [`replyToEmail`](/graphql/messaging/reply-email)                               |
| Set a custom thread field    | [`upsertThreadField`](/graphql/threads/thread-fields)                          |
| Create a customer event      | [`createCustomerEvent`](/graphql/events/create-customer-event)                 |

The [GraphQL API explorer](https://app.plain.com/developer/api-explorer/) is the fastest way to see what is available and try it.

## Resources

* [Machine users](/agents/machine-users): the agent's identity and API keys
* [Searching knowledge](/agents/searching-knowledge): ground replies in your Help Center and indexed documents
* [Internal agents](/agents/internal-agent): the same idea for a Sidekick discussion with your team
* [GraphQL SDK](/graphql/sdk): the typed client your agent calls
* [Webhooks](/webhooks): delivery semantics, retries, and security options
* [API explorer](https://app.plain.com/developer/api-explorer/): browse and test queries interactively
