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

> Run your own agent inside of Plain, alongside Sidekick.

You can add custom internal agents in Plain alongside [Sidekick](/product/agents/sidekick). This page covers that surface: the events your agent receives, the mutations it calls back, and the tools it has available.

This is a different surface from a [support agent](/agents/support-agent), which answers a customer's [thread](/graphql/threads). Here your agent answers your own team, in a [discussion](/graphql/discussions), and nothing it writes reaches the customer.

## How a turn works

A user asks a question, Plain sends you a webhook, and every visible state after that is one your agent reports:

1. A user opens a Sidekick discussion, picks your agent, and types a question.
2. Plain sends you [`discussion.message_created`](/webhooks/discussion-message-created).
3. Your agent reports `IN_PROGRESS`, does its work, posts an answer, and reports `IDLE`.
4. For an action a person should decide first, your agent reports the call, asks for approval, and waits for [`discussion.tool_call_approval_resolved`](/webhooks/discussion-tool-call-approval-resolved).

## What you need in Plain

Your agent needs a machine user Plain recognizes as a custom agent, a webhook target on the right version, and the two SDKs.

<Steps>
  <Step title="Turn a machine user into a custom agent">
    Your agent needs a [machine user](/agents/machine-users) with the **Custom agent** toggle on. The toggle marks the machine user as an agent your workspace built, so a user can pick it when they start a Sidekick discussion.

    Give its API key these four permissions at minimum:

    * `threadDiscussion:read`: read the discussion and ask for approvals
    * `threadDiscussion:edit`: report agent status and resolve the discussion
    * `threadDiscussionMessage:create`: post answers and tool calls
    * `threadDiscussionMessage:edit`: resolve a tool call you started

    Add `thread:read` and `customer:read` if your agent reads the thread a discussion hangs off.
  </Step>

  <Step title="Subscribe to relevant webhooks">
    Subscribe a [webhook target](/webhooks) to the `discussion.*` events your agent needs, and set its version to `2026-09-06`.

    | Event                                     | Needed                         | What it is for                                                                                     |
    | ----------------------------------------- | ------------------------------ | -------------------------------------------------------------------------------------------------- |
    | `discussion.message_created`              | Required                       | Drives every turn: a message arrived in a discussion your agent owns.                              |
    | `discussion.tool_call_approval_resolved`  | Required for the approval gate | A user approved or denied a tool call your agent parked. Skip if your agent isn't using approvals. |
    | `discussion.tool_call_approval_requested` | Optional                       | Mirrors the approval request your agent made itself.                                               |
    | `discussion.discussion_created`           | Optional                       | Announces a new discussion. The first `discussion.message_created` is enough to start a turn.      |
  </Step>

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

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

## Decide whether to answer

Your own replies return to you as webhooks. Answer only when all four conditions hold:

| Condition                                  | Why                                                                     |
| ------------------------------------------ | ----------------------------------------------------------------------- |
| `discussion.type` is `AGENT_SESSION`       | Other discussion types are between people, not agents.                  |
| `discussion.agent.id` is your machine user | Otherwise the discussion belongs to Sidekick or to another agent.       |
| `message.type` is `OUTBOUND`               | A person's turn is `OUTBOUND` and your own replies return as `INBOUND`. |
| `discussion.status` is not `RESOLVED`      | The discussion is over.                                                 |

Call `myMachineUser` once at startup to learn your own id, and deduplicate on `message.id` so a retried delivery does not produce a second answer.

```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 your agent's reply into the discussion. It takes Markdown, and the message appears under your machine user's public name.

Posting your answer is what marks the discussion unread for the user.

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

Read `error` on every response before you treat the call as done.

## Report what your agent is doing

`updateDiscussionAgentStatus` reports your agent's progress. Report `IN_PROGRESS` when the turn starts and `IDLE` when it ends, so the discussion stops showing work that has finished.

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

Settle on `IDLE` when the turn fails too. Post the failure as a message first, so the user reads what went wrong, then report `IDLE`.

<Note>
  The mutation rejects `TOOL_CALL_APPROVAL_PENDING` and `UNKNOWN`. Plain sets the pending state itself when you ask for an approval, and it refuses `IDLE` and `IN_PROGRESS` while an approval is open.
</Note>

## Report tool calls

`upsertDiscussionToolCall` puts a line on the discussion for each call your agent makes, keyed by an id you choose. Report it as `PENDING` before the call and again with the same id afterwards, with `SUCCESS` or `ERROR`.

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

The call enforces four rules:

* **`toolCallId` is yours**, unique within the discussion, 1 to 256 characters of letters, digits, hyphens and underscores.
* **`text` is required on every write**, up to 2000 characters, and it is the line a user reads on the timeline.
* **`error` is required when `status` is `ERROR`**, up to 4000 characters.
* **`SUCCESS` and `ERROR` are final.** A later write to a settled call returns `result: NOOP` and changes nothing.

## Gate an action on a user

Some actions should not run without human approval. Report the call, ask for an approval, and wait: Plain shows the user a card with the call's `text` as the heading and your `justification` underneath, with **Approve** and **Deny** controls.

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

The call named by `toolCallId` must already be reported and still `PENDING`. Asking twice for the same call returns the same approval unchanged. Requesting one also moves the discussion to `TOOL_CALL_APPROVAL_PENDING`.

Plain then sends you [`discussion.tool_call_approval_requested`](/webhooks/discussion-tool-call-approval-requested), and [`discussion.tool_call_approval_resolved`](/webhooks/discussion-tool-call-approval-resolved) once a user decides:

* **On `APPROVED`**, run the call, then report the outcome with `upsertDiscussionToolCall`.
* **On `DENIED`**, do not run it, and do not report an error either. Plain has already failed the call with `reviewerNote` as its error.
* **If you stop waiting**, report the call as `ERROR` with a message saying so.

## Resolve the discussion

`agentStatus` says what your agent is doing inside a turn. Whether the discussion is over is its own status, and `changeThreadDiscussionStatus` moves it:

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

Resolving is reversible: pass `OPEN` to reopen. Resolve only when the user needs nothing further, because a resolved discussion drops out of their view.

## Reference implementation

[`team-plain/example-internal-agents`](https://github.com/team-plain/example-internal-agents) is a working agent on this API. Read it for a complete example of the surface above.

## Caveats

* **Keep one model session per discussion**, not per message. Store the discussion id against your model's session handle and resume it, or every turn starts from nothing and the discussion has no memory.
* **Nothing your agent posts in a discussion reaches the customer.** A discussion is internal to your team, whatever thread it hangs off.
