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:- A user opens a Sidekick discussion, picks your agent, and types a question.
- Plain sends you
discussion.message_created. - Your agent reports
IN_PROGRESS, does its work, posts an answer, and reportsIDLE. - 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 approvalsthreadDiscussion:edit: report agent status and resolve the discussionthreadDiscussionMessage:create: post answers and tool callsthreadDiscussionMessage:edit: resolve a tool call you started
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.
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.
- SDK
- GraphQL
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.
- SDK
- GraphQL
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.
- SDK
- GraphQL
toolCallIdis yours, unique within the discussion, 1 to 256 characters of letters, digits, hyphens and underscores.textis required on every write, up to 2000 characters, and it is the line a user reads on the timeline.erroris required whenstatusisERROR, up to 4000 characters.SUCCESSandERRORare final. A later write to a settled call returnsresult: NOOPand 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’stext as the heading and your justification underneath, with Approve and Deny controls.
- SDK
- GraphQL
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 withupsertDiscussionToolCall. - On
DENIED, do not run it, and do not report an error either. Plain has already failed the call withreviewerNoteas its error. - If you stop waiting, report the call as
ERRORwith 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:
- SDK
- GraphQL
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.

