Skip to main content
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. That surface uses different events and different mutations, and nothing it writes reaches the customer.
1

Give the agent an identity

Your agent acts as a machine user, created under SettingsMachine 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.
2

Receive and verify events

Your agent finds out about new threads and customer messages through webhooks. Stand up a public HTTPS endpoint that accepts POST, then add a target under SettingsWebhooks and copy its signing secret.The @team-plain/webhooks package handles signature verification, replay protection, and schema validation, and gives you typed payloads:
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.
These are the events agents subscribe to most often:
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.
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 for delivery semantics, retries, request signing, and mTLS.
3

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:
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.
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 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 SettingsWorkflows, and the assignment action lets you pick a machine user directly.
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.
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:
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.
4

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:
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 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:
llmText is null for entry types with nothing meaningful to render. Skip those entries.
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.
  • The triggering message: on events like 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.
5

Act on the thread

Everything happens through the GraphQL SDK:
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.
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 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.
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.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.
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 SettingsLabels.
Remove them with removeLabels, passing the IDs of the labels rather than the label types. See labels.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.
See assignment for the full reference.
6

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:
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 PlainAIAgent Activity.

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: The GraphQL API explorer is the fastest way to see what is available and try it.

Resources