> ## 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 an internal agent

> Build an agent that answers your team in Ask Sidekick, next to Sidekick.

Build an agent that answers users in a [discussion](/docs/graphql/discussions), next to [Sidekick](/docs/product/agents/sidekick). Nothing it writes reaches the customer. For customer threads, see [support agents](/docs/agents/support-agent).

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

## How a turn works

1. A user opens Ask Sidekick, picks your agent, and sends a prompt.
2. Plain sends [`discussion.message_created`](/docs/webhooks/discussion-message-created).
3. Your agent reports `IN_PROGRESS`, works, posts an answer, reports `IDLE`.
4. For an action a user must decide, the agent asks for approval and waits on [`discussion.tool_call_approval_resolved`](/docs/webhooks/discussion-tool-call-approval-resolved).

## What you need

<Steps>
  <Step title="Create a custom agent">
    Create a [machine user](/docs/agents/machine-users) with **Type** set to **Custom agent** (on an existing one, **Change type**). That is what makes it appear in Ask Sidekick.

    On **Add API key**, expand the non-recommended groups and grant `threadDiscussion` and `threadDiscussionMessage`:

    * `threadDiscussion:read` and `threadDiscussion:edit`: read, report status, ask for approvals, resolve or reopen. Status reporting needs both.
    * `threadDiscussionMessage:create` and `threadDiscussionMessage:edit`: post answers. `upsertDiscussionToolCall` also needs `threadDiscussion:read`.

    Add `thread:read` and `customer:read` if the agent reads the thread the discussion is on.
  </Step>

  <Step title="Subscribe to webhooks">
    Go to **Settings → Webhooks**, click **Add webhook target**, pin version `2026-09-06`, and subscribe to `discussion.*`. Copy the signing secret from **Settings → Request signing**.

    | Event                                     | Needed               | Why                                                                       |
    | ----------------------------------------- | -------------------- | ------------------------------------------------------------------------- |
    | `discussion.message_created`              | Required             | Every turn. Fires for every discussion in the workspace, so filter below. |
    | `discussion.tool_call_approval_resolved`  | If you use approvals | A user approved or denied a call you parked.                              |
    | `discussion.tool_call_approval_requested` | Optional             | Echo of the request you made.                                             |
    | `discussion.discussion_created`           | Optional             | The first `discussion.message_created` is enough to start.                |
  </Step>

  <Step title="Install the SDKs">
    [`@team-plain/graphql`](/docs/graphql/sdk) 3.0.0 or newer and [`@team-plain/webhooks`](/docs/webhooks/sdk) 1.9.0 or newer.

    ```bash theme={null}
    npm install @team-plain/graphql@3 @team-plain/webhooks@^1.9.0
    ```
  </Step>
</Steps>

## Decide whether to answer

Your own replies come back as webhooks. Answer only when all of these hold:

| Condition                                  | Why                                                      |
| ------------------------------------------ | -------------------------------------------------------- |
| `discussion.type` is `AGENT_SESSION`       | Other discussion types are between users.                |
| `discussion.agent.id` is your machine user | Otherwise it is Sidekick or another agent.               |
| `message.type` is `OUTBOUND`               | A user's turn is `OUTBOUND`; your replies are `INBOUND`. |
| `discussion.status` is not `RESOLVED`      | The discussion is over.                                  |

Call `myMachineUser` once at startup. Deduplicate on `message.id`.

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

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

function shouldAnswer(payload: DiscussionMessageCreatedPublicEventPayload) {
  return (
    payload.discussion.type === "AGENT_SESSION" &&
    payload.discussion.agent?.id === me.id &&
    payload.message.type === "OUTBOUND" &&
    payload.discussion.status !== "RESOLVED"
  );
}
```

Answer the HTTP request with `200` before you start working to avoid webhook retries.

## Post an answer

`sendDiscussionMessage` posts Markdown as the machine user's public name. That call is what marks the discussion unread.

<Snippet file="graphql/discussion-agent-reply.mdx" />

Check `error` on the response.

## Report what your agent is doing

`updateDiscussionAgentStatus`: `IN_PROGRESS` when the turn starts, `IDLE` when it ends (including after a failure: post the error as a message first).

<Snippet file="graphql/discussion-agent-status.mdx" />

<Note>
  You cannot set `TOOL_CALL_APPROVAL_PENDING` or `UNKNOWN`. Asking for an approval sets pending. You cannot set `IDLE` or `IN_PROGRESS` while an approval is open.
</Note>

## Report tool calls

`upsertDiscussionToolCall` is keyed by an id you choose. Report `PENDING`, then the same id as `SUCCESS` or `ERROR`.

<Snippet file="graphql/discussion-tool-call-upsert.mdx" />

* `toolCallId`: yours, unique in the discussion, 1–256 characters `[A-Za-z0-9_-]`
* `text`: required every write, max 2000 characters
* `error`: required on `ERROR`, max 4000 characters
* `SUCCESS` and `ERROR` are final; a later write returns `result: NOOP`

## Gate an action on a user

Report the call, then ask for approval. Plain shows a card with `text` as the heading, `justification` underneath, and **Approve** / **Deny**.

<Snippet file="graphql/discussion-tool-call-approval-request.mdx" />

The `toolCallId` must already be `PENDING`. Asking again returns the same approval. The discussion moves to `TOOL_CALL_APPROVAL_PENDING`.

Then [`discussion.tool_call_approval_requested`](/docs/webhooks/discussion-tool-call-approval-requested), and [`discussion.tool_call_approval_resolved`](/docs/webhooks/discussion-tool-call-approval-resolved) when a user decides:

* **`APPROVED`**: run the call, then `upsertDiscussionToolCall` with the outcome.
* **`DENIED`**: do not run it and do not report `ERROR`. Plain already failed the call with `reviewerNote`.
* If you stop waiting: report `ERROR`.

Most decisions happen on the card. `resolveDiscussionApproval` is for your own review tooling. An agent's API key is refused: it cannot approve itself.

<Snippet file="graphql/discussion-approval-resolve.mdx" />

## Resolve the discussion

`agentStatus` is the turn. `changeThreadDiscussionStatus` opens or resolves the discussion.

<Snippet file="graphql/discussion-status-change.mdx" />

Pass `OPEN` to reopen. Resolve when the user needs nothing further.

## Example and caveats

Two working agents on this API, one per repository:
[`example-aisdk-assistant-agent`](https://github.com/team-plain/example-aisdk-assistant-agent) on the Vercel AI SDK, and [`example-eve-assistant-agent`](https://github.com/team-plain/example-eve-assistant-agent) on eve, Vercel's agent framework.

* Keep one model session per discussion, not per message.
* Nothing posted in a discussion reaches the customer.
