Skip to main content
You can add custom internal agents in Plain alongside 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, which answers a customer’s thread. Here your agent answers your own team, in a discussion, 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.
  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.

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

Turn a machine user into a custom agent

Your agent needs a machine user 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.
2

Subscribe to relevant webhooks

Subscribe a webhook target to the discussion.* events your agent needs, and set its version to 2026-09-06.
3

Install the SDKs

Use @team-plain/graphql 3.0.0 or newer and @team-plain/webhooks 1.9.0.

Decide whether to answer

Your own replies return to you as webhooks. Answer only when all four conditions hold: 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.
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.
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.
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.
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.

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.
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.
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, and 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:
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 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.