Plain GraphQL setup reference

Every mutation below has been executed against a live Plain workspace and corrected against what the API actually accepts β€” not just what the schema implies. Where the schema and the runtime validator disagree, the runtime wins and is noted.

Endpoint: https://core-api.uk.plain.com/graphql/v1 (POST only) Auth: Authorization: Bearer plainApiKey_xxx plus Content-Type: application/json Body: { "query": "...", "variables": {...}, "operationName": "..." }

Verifying anything here

Official per-operation docs, including the exact permission each needs:

https://www.plain.com/docs/graphql-reference/mutations/<mutationName>.md
https://www.plain.com/docs/graphql-reference/queries/<queryName>.md

Full docs index: https://www.plain.com/docs/llms.txt. Raw schema (for input-type and enum shapes): https://core-api.uk.plain.com/graphql/v1/schema.graphql.


Gotchas that will bite you (all verified the hard way)

  1. DateTime is an object, not a scalar. Every timestamp needs subfields: publishedAt { iso8601 } or { unixTimestamp }. Selecting it bare is a validation error.
  2. Icons are slugs, not emoji. icon must match ^[a-z0-9_-]+$ β€” e.g. credit-card, fire. Passing πŸ’³ fails with "icon must be lowercase alphanumeric, _, or -".
  3. The schema marks some fields optional that the validator requires. Known cases: upsertHelpCenterArticle.description, and threadsFilter.displayOptions.hasLinearIssues / hasJiraIssues (which are also @deprecated β€” still required).
  4. inviteUserToWorkspace is forbidden to machine users. A setup API key cannot invite teammates (ForbiddenError: Machine user not allowed to perform this operation). Team invites are UI-only. assignRolesToUser on an existing user does work with a machine key.
  5. SLA response times are mutually exclusive. One SLA record holds either firstResponseTimeMinutes or nextResponseTimeMinutes, never both. Want both? Create two records against the same tier.
  6. breachActions cannot be empty. Every entry must carry beforeBreachAction: { beforeBreachMinutes: Int! }.
  7. KnowledgeSource is a union (KnowledgeSourceSitemap | KnowledgeSourceUrl) β€” select through inline fragments.
  8. Workflow event names β‰  webhook event names. Two different namespaces; see Β§8 and Β§13.
  9. Permission scope suffixes are inconsistent: tier:edit (not :update), tenantFieldSchema:update but threadFieldSchema:edit. Don't extrapolate β€” check the operation's doc.

0. Order of operations

  1. Tiers β†’ 2. SLAs (need tierId) β†’ 3. Business hours β†’ 4. Label types β†’ 5. Tenant field schemas β†’
  2. Thread field schemas β†’ 7. Escalation paths β†’ 8. Workflows (need label/user/tier IDs) β†’ 9. Saved views β†’
  3. Help center + groups + articles β†’ 11. Knowledge sources β†’ 12. Sidekick β†’ 13. Webhooks β†’
  4. Roles (invites are UI-only) β†’ 15. Tenants + field values

1. Tiers βœ… verified

mutation { createTier(input: { name: "Enterprise", externalId: "enterprise", color: "#5B5FEF" defaultThreadPriority: 1 # 0=urgent 1=high 2=normal 3=low memberIdentifiers: [] # [{ companyId } | { tenantId }] isDefault: false # only one tier may be true }) { tier { id name } error { message code fields { field message } } } }

Scopes: tier:create, tier:read, tier:edit, tier:delete, tierMembership:create/delete/read. Add members later with addMembersToTier.

2. SLAs (per tier) βœ… verified

One record per response type β€” not both in one call.

# First-response SLA mutation { createServiceLevelAgreement(input: { tierId: "tier_..." serviceLevelAgreement: { firstResponseTimeMinutes: 60 threadPriorityFilter: [0, 1] # optional; defaults to all priorities useBusinessHoursOnly: true breachActions: [{ beforeBreachAction: { beforeBreachMinutes: 15 } }] } }) { serviceLevelAgreement { id } error { message code fields { field message } } } } # Next-response SLA β€” a SECOND call against the same tier mutation { createServiceLevelAgreement(input: { tierId: "tier_..." serviceLevelAgreement: { nextResponseTimeMinutes: 240 useBusinessHoursOnly: true breachActions: [{ beforeBreachAction: { beforeBreachMinutes: 30 } }] } }) { serviceLevelAgreement { id } error { message code } } }

Scopes: serviceLevelAgreement:create/edit/delete.

3. Business hours βœ… verified

Output field is slots. BusinessHoursSlot has no id β€” only timezone/weekday/opensAt/closesAt.

mutation { syncBusinessHoursSlots(input: { slots: [ { timezone: "Europe/London", weekday: MONDAY, opensAt: "09:00", closesAt: "17:30" } { timezone: "Europe/London", weekday: FRIDAY, opensAt: "09:00", closesAt: "17:00" } ] }) { slots { weekday opensAt closesAt } error { message code fields { field message } } } }

This replaces the full set of slots each call. Scopes: businessHours:edit, businessHours:read. (upsertBusinessHours exists but is deprecated.)

4. Label types βœ… verified

mutation { createLabelType(input: { name: "Billing" icon: "credit-card" # slug only β€” NOT emoji color: "#22C55E" type: DEFAULT # or TEAM description: "Billing and invoicing questions" externalId: "billing" isExcludedFromAi: true # DEFAULT TO TRUE β€” see below }) { labelType { id name } error { message code fields { field message } } } }

Always create labels with isExcludedFromAi: true unless the customer explicitly asks otherwise. Plain's built-in AI triage applies labels independently of your workflows, so leaving this false means two systems label the same threads and your carefully-built triage tree stops being authoritative β€” a thread routed to Security also picked up an unrelated Bug label in testing. Excluding labels from the built-in AI makes your workflow the single source of truth, which is what a customer designing explicit triage rules actually wants. Existing labels can be flipped with updateLabelType(input: { labelTypeId: "lt_...", isExcludedFromAi: { value: true } }). Scopes: labelType:create/edit/read, label:create, suggestedLabelType:read. Also archiveLabelType/unarchiveLabelType (prefer archiving), moveLabelType, suggestedLabelTypes + acceptSuggestedLabelTypes (useful when migrating).

5. Tenant field schemas βœ… verified

mutation { upsertTenantFieldSchema(input: { tenantFieldSchemas: [{ source: "api" # new schemas must be "api" externalFieldId: "arr", label: "ARR" type: NUMBER_TYPE # STRING_TYPE | NUMBER_TYPE | BOOLEAN_TYPE | STRING_ARRAY | DATETIME_TYPE | USER_REFERENCE_TYPE options: [], isVisible: true, order: 0 }] }) { tenantFieldSchemas { id label } error { message code fields { field message } } } }

Scopes: tenantFieldSchema:create/update/read/delete (note :update, not :edit).

6. Thread field schemas βœ… verified

mutation { createThreadFieldSchema(input: { label: "Resolution reason" key: "resolution_reason" # ^[a-z0-9_]+$, immutable description: "Why this ticket was closed" order: 0 type: ENUM # STRING | BOOL | ENUM | NUMBER | CURRENCY | DATE enumValues: ["fixed", "wontfix", "duplicate", "user_error"] isRequired: false isAiAutoFillEnabled: true # let Ari fill it from the conversation isAvailableToAgents: true isClientReadonly: false dependsOnLabelTypeIds: [] }) { threadFieldSchema { id key } error { message code fields { field message } } } }

Scopes: threadFieldSchema:create/edit/read/delete (:edit, not :update), threadField:create/update/read/delete.

7. Escalation paths βœ… verified

EscalationPathStepType is USER (with userId β€” machine users allowed) or LABEL_TYPE (with labelTypeId). There is no ASSIGN_USER/ADD_LABEL.

mutation { createEscalationPath(input: { name: "Billing escalation" description: "Escalate unresolved billing issues" steps: [ { type: USER, userId: "mu_..." } { type: LABEL_TYPE, labelTypeId: "lt_..." } ] }) { escalationPath { id name } error { message code fields { field message } } } }

Scopes: escalationPath:create/edit/read/delete/execute.

8. Workflows βœ… verified end-to-end

Four steps, or it silently never runs: create (draft) β†’ add steps β†’ set startStepId β†’ publish.

8a. Create with trigger

trigger is a JSON-encoded string:

typeextra fields
manualnone β€” runs via triggerWorkflow or a UI button
eventsevents: string[] (β‰₯1)
schedulecron: string (UTC) β€” this is what makes recurring digests possible

Workflow event names: thread.thread_created, thread.message_added, thread.thread_field_updated, thread.thread_labels_changed, thread.thread_status_transitioned.

mutation { createWorkflow(input: { name: "Route billing tickets" trigger: "{\"type\":\"events\",\"events\":[\"thread.thread_created\"]}" }) { workflow { id trigger publishedAt { iso8601 } } error { message code } } }

publishedAt: null β‡’ inactive draft.

8b. Add steps

mutation { createWorkflowStep(input: { workflowId: "wf_..." type: ACTION # CONDITION | ACTION | WAIT name: "Label as billing" payload: "{\"version\":1,\"type\":\"apply_labels\",\"labelTypeIds\":[\"lt_...\"]}" transitions: [null] # ACTION: 1 element; CONDITION: 2 (true,false); null = terminal positionX: 0, positionY: 0 }) { workflowStep { id type } error { message code fields { field message } } } }

Payloads are { version: 1, type: <discriminator>, ... }:

  • CONDITION β€” contains_label, thread_field_equals, thread_field_is_set, priority_equals, assigned_to, customer_equals, ai_workflow_rule_condition, or combinators and/or/not/else_if.
  • ACTION β€” apply_labels, remove_labels, set_priority, set_status, set_tier, assign_to_user, assign_to_team, add_note, send_message, send_slack_notification, send_http_request, set_thread_field, snooze_thread, escalate_thread.
  • WAIT β€” { "duration": <seconds>, "cancelCondition"?: <condition> }.

Payloads embed workspace-specific IDs β€” never copy them between workspaces; resolve real IDs first. bulkUpsertWorkflowSteps can set the whole step list plus startStepId and trigger in one call.

8c. Publish

mutation { updateWorkflow(input: { workflowId: "wf_..." startStepId: { value: "wfs_..." } isPublished: { value: true } }) { workflow { id startStepId publishedAt { iso8601 } } error { message code } } }

Note: there is no workflow:* permission scope β€” only workflowRule:create/edit/read/trigger. Workflow mutations succeeded with an Admin-preset key. startStepId can't be cleared while published.

8d. AI prompt conditions and branching βœ… verified live, end-to-end

The AI condition takes a plain-English prompt inline β€” you do not need to create a WorkflowRule first:

{"version":1,"type":"ai_workflow_rule_condition","prompt":"This is a security vulnerability report or responsible disclosure"}

Build branching chains leaf-first. transitions needs real step IDs, so create the terminal actions first, then the conditions that point at them, then set startStepId to the top condition. An if / else-if / else router looks like this:

steptypepayloadtransitions
DACTIONapply_labels β†’ Bug[null]
EACTIONapply_labels β†’ Feature Request[null]
BACTIONapply_labels β†’ Security[null]
CCONDITIONAI prompt: "customer is reporting something broken"[D, E]
ACONDITIONAI prompt: "security vulnerability report"[B, C]

…then updateWorkflow { startStepId: A, isPublished: true }. Condition transitions are strictly [trueStepId, falseStepId].

Verified behaviour on three real threads (~3.2s per run, executionStatus: SUCCESS):

  • "Possible XSS in comment field…" β†’ A matched β†’ Security. Only two steps ran.
  • "Export button throws 500…" β†’ A false, C matched β†’ Bug.
  • "Please add dark mode" β†’ A false, C false β†’ Feature Request.

Branching is genuinely exclusive β€” the untaken branch never executes.

Debugging executions. workflowExecutions(workflowId:, first:) returns per-run traces, and each stepExecutions[].output is JSON containing conditionMatched β€” the fastest way to see which branch the AI chose:

query { workflowExecutions(workflowId: "wf_...", first: 5) { edges { node { triggeredBy executionStatus executionDurationMs errorMessage stepExecutions { workflowStepId status output } } } } }

Note triggeredBy comes back prefixed β€” domain.thread.thread_created β€” while the trigger config uses thread.thread_created.

8e. Architecture: how to actually structure triage + routing ⚠️ read before designing

Three constraints, all verified live, that dictate the design:

  1. Workflow actions do not cascade into other workflows. A label applied by a workflow (systemId: workflows_handler) does not fire a workflow triggered on thread.thread_labels_changed. Verified: triage applied Security, the label-triggered routing workflow recorded zero executions.
  2. API- or human-applied label changes do fire those workflows. The same routing workflow fired immediately (conditionMatched: true, priority set to urgent) when the label came from addLabels.
  3. Triggers are an OR-list of events. No AND, no predicates at the trigger level β€” all narrowing happens in condition steps.

So the intuitive two-layer design β€” "triage labels on thread_created, routing reacts to label added" β€” does not work for automated triage. The second layer never fires.

The efficient, controllable shape

One workflow per entry trigger, doing classify β†’ label β†’ route inline. On thread.thread_created:

  1. Cheap deterministic conditions first β€” support_email_equals, tier/customer_equals, contains_label. They're instant and free, and they shrink the population before you spend AI latency.
  2. One else_if switch for the classification, not a chain of binary AI conditions. else_if is a genuine N-way switch: conditions: [c1, c2, c3] with transitions: [s1, s2, s3, fallback]. It returns matchedConditionIndex and stops at the first match:
    {"version":1,"type":"else_if","conditions":[ {"version":1,"type":"ai_workflow_rule_condition","prompt":"security vulnerability report"}, {"version":1,"type":"ai_workflow_rule_condition","prompt":"something is broken or erroring"}, {"version":1,"type":"ai_workflow_rule_condition","prompt":"requesting a new feature"}]}
    Verified: a "charts not loading, console shows 502" thread returned matchedConditionIndex: 1 and applied Bug β€” one step, one execution, and the third prompt was never evaluated.
  3. Chain the per-branch actions β€” ACTION steps take transitions: [nextStepId], so a branch can run apply_labels β†’ set_priority β†’ assign_to_team β†’ escalate_thread in sequence.

Why this beats a chain: each ai_workflow_rule_condition is an LLM call (~1–3s). Three chained binary AI conditions is three sequential calls on the worst-case path; one else_if is a single step that short-circuits. Order the branches most-likely-first.

What label-triggered workflows are still for: reacting to a human agent manually re-labelling a thread. Keep them for that β€” just never rely on them to catch automated output.

If you genuinely need decoupled layers, the escape hatches are a send_http_request action calling back into the API (re-enters as an API actor, so it would cascade β€” at the cost of latency and loop risk) or a webhook out to your own service. Both are infrastructure, not no-code; avoid unless the separation is worth it.

Don't stack multiple workflows on the same trigger. During testing a leftover workflow that applied Billing unconditionally on thread.thread_created silently labelled every thread alongside the real triage. Multiple published workflows on one event all fire, with no ordering guarantee β€” audit workflows(first: N) { edges { node { name publishedAt { iso8601 } } } } before adding another, and delete the experiments.

8f. How to write AI prompt conditions

Official guidance (worth reading, and mirrored in the in-product UI): https://www.plain.com/docs/product/workflows/workflows-conditions#ai-prompts

Each prompt returns a single match / no match boolean, evaluated against a context snapshot Plain assembles: the thread's message history (up to 600 messages, 3,000 chars each), thread metadata (title, description, status, priority, labels), customer name and email, channel, assignees, thread fields and their schemas, and attachment count. Nothing else β€” no CRM, no product usage, no external data.

  1. Use Plain's Match if… / Don't match if… convention. State what takes the Yes branch, then clarify what shouldn't:

    "Match if the customer explicitly requests a refund. Don't match for general billing questions or payment issues without refund mentions."

  2. Give specific criteria and concrete examples, in the customer's own vocabulary:

    "Match if the customer reports that existing functionality is broken, erroring, or timing out β€” for example a 500 error, a failed export, or a page that will not load." Never "use your best judgment to decide if this is important" β€” the model can't guess what "important" means to this workspace.

  3. One decision per prompt. Don't stuff several outcomes into one condition, and don't ask the prompt to take actions β€” it only returns match/no-match; actions are separate steps. If you need real boolean logic, use the and / or / not combinators. Prefer positive statements over stacked negations.
  4. Order is your tiebreaker, and it does real work. else_if stops at the first match, so put the highest-stakes and most specific case first. A security bug should hit security before bug purely because security is listed first. Later prompts may assume the earlier ones were false β€” don't over-qualify them ("a bug but not a security issue" is unnecessary and hurts).
  5. Only ask about what's actually in the thread. The model sees the conversation, not your CRM, plan tier, or the sending domain. Route on those with deterministic conditions (customer_equals, tier, support_email_equals) β€” they're free and instant. Save prompts for genuine language judgement.
  6. Always wire the fallback branch. transitions has N+1 entries; the last one is "nothing matched". Point it at a Needs triage label rather than null, so unclassified threads are visible instead of silently untouched. Verified: "Thanks for the help" matched nothing and landed in Needs triage.
  7. Tune from traces, not vibes. stepExecutions[0].output.matchedConditionIndex tells you exactly which prompt won. Push your real historical threads through it and reword wherever the index is wrong.
  8. One or two sentences. Long prompts drift.

Ordering by frequency also cuts latency, since a match short-circuits the remaining prompts.

⚠️ assign_to_user needs a human user id β€” machine ids fail silently

assign_to_user with a machine user id (mu_...) returns status: SUCCESS with entities: [] and assigns nobody. No error, no warning. With a human id (u_...) it assigns correctly.

{"version":1,"type":"assign_to_user","userId":"u_..."} // βœ… works {"version":1,"type":"assign_to_user","userId":"mu_..."} // ⚠️ SUCCESS, but no assignment

Fetch real human IDs with users(first: N) { edges { node { id publicName } } } before building assignment steps, and verify thread.assignedTo is non-null after a test run rather than trusting the step status.

⚠️ Plain's built-in AI triage also labels threads and sets priority

Plain runs its own AI triage independently of your workflows. In testing, a thread got the workflow's Security label and an unrelated Bug label β€” the latter applied by a different system actor:

labels[].createdBy.systemId == "workflows_handler"      ← your workflow
labels[].createdBy.systemId == "thread_triage_handler"  ← Plain's built-in AI triage

If a label should be controlled only by your workflow, create it with isExcludedFromAi: true. Otherwise expect built-in triage to apply it too, and don't mistake that for a workflow bug. Checking createdBy.systemId on a thread's labels tells you instantly which system applied what.

It sets priority too. A thread whose workflow only applied a label (no priority action existed) still came back at priority 0 β€” built-in triage set it. And a thread where the workflow set priority 1 (high) ended up at 0, i.e. the two systems compete and last-writer-wins. There is no setting in the GraphQL schema to disable built-in triage β€” it appears to be UI/plan-level, so if deterministic priority matters to a customer, check Settings β†’ Agents in the app rather than assuming the workflow is authoritative. Always verify the end state on a test thread instead of trusting the step trace.

8g. Metrics β€” for auditing an existing workspace βœ… verified

Useful when tuning rather than setting up: find where response times are worst, which labels drag CSAT down, how AI-handled threads compare to human-handled.

query { threadSingleValueMetric(input: { metricName: threads_first_response_time from: "2026-08-01T00:00:00Z" to: "2026-09-02T12:00:00Z" groupBy: [{ dimension: LABEL_TYPE }] # field is `dimension`, not `groupBy` percentile: 90 # P90; defaults to median mode: METRIC # or THREAD_IDS }) { values { value group { dimension value } } } }
  • Queries: threadSingleValueMetric, threadTimeSeriesMetric, threadHeatmapMetric (the un-prefixed singleValueMetric/timeSeriesMetric/heatmapMetric are deprecated β€” they don't support filtering or group-by).
  • groupBy dimensions: ASSIGNEE, LABEL_TYPE, TIER, PRIORITY, COMPANY, TENANT, CUSTOMER_GROUP, MESSAGE_SOURCE, THREAD_FIELD and TENANT_FIELD (both need subKey). ASSIGNEE requires metricsAgent:read.
  • Metric names: threads_first_response_time, threads_resolution_time, threads_time_customer_waiting, threads_time_between_follow_up_responses, threads_csat__percentage, threads_csat__count, service_level_agreement_compliance_frt / _nrt, threads_created_count (time-series only), threads_status_count__todo|done|snoozed, threads_all_time_count_done. Most have an agent_ variant for AI-handled threads.
  • mode: THREAD_IDS returns the actual thread IDs behind a number β€” use it to cite evidence for a recommendation instead of asserting it.
  • Scopes: metrics:read, plus metricsAgent:read for assignee/agent breakdowns.

Gotchas:

  • to cannot be in the future β€” a date even a day ahead fails validation.
  • Output is values { value group { … } } β€” group singular, not groups.
  • CSAT metrics require filters.surveyResponse.rating β€” omitting it fails with "rating is required for CSAT metrics", which the schema doesn't tell you.
  • All-time counts (e.g. threads_all_time_count_done) reject a date range; everything else requires one.
  • percentile is only accepted on the configurable-percentile duration metrics; it's rejected elsewhere.

9. Saved (custom) views βœ… verified

Nearly every threadsFilter field is non-null β€” an empty {} is rejected. Pass empty arrays. And the two @deprecated display flags are still required.

mutation { createSavedThreadsView(input: { name: "Urgent billing", icon: "fire", color: "#EF4444", isHidden: false threadsFilter: { statuses: [TODO] # TODO | SNOOZED | DONE statusDetails: [], priorities: [0, 1] assignedToUser: [], participants: [], customerGroups: [], companies: [] tenants: [], tiers: [], labelTypeIds: ["lt_..."] messageSource: [], supportEmailAddresses: [], slaTypes: [], slaStatuses: [] threadFields: [], tenantFields: [], threadLinkGroupIds: [], threadLinkSources: [] groupBy: NONE # NONE|PRIORITY|STATUS|COMPANY|LABEL|TIER|CHANNEL|ASSIGNEE|CUSTOMER_GROUP|TENANT layout: TABLE # TABLE | BOARD sort: { field: CREATED_AT, direction: DESC } # ThreadsSortField: STATUS_CHANGED_AT | CREATED_AT | CLOSEST_TO_BREACH_SLA # | LAST_INBOUND_MESSAGE_AT | PRIORITY | THREAD_FIELD displayOptions: { hasStatus: true, hasCustomer: true, hasCompany: false, hasPreviewText: true hasTier: true, hasCustomerGroups: false, hasLabels: true hasLinearIssues: false, hasJiraIssues: false # deprecated BUT REQUIRED hasLinkedThreads: false, hasServiceLevelAgreements: true hasChannels: true, hasLastUpdated: true, hasAssignees: true, hasRef: true } } }) { savedThreadsView { id name } error { message code fields { field message } } } }

and/or/not accept nested filters. Scopes: savedThreadsView:create/edit/read/delete.

10. Help center + migration βœ… verified

mutation { createHelpCenter(input: { publicName: "Acme Help Center", internalName: "acme-help-center" type: PUBLIC # PUBLIC | PRIVATE | INTERNAL description: "Support docs for Acme" subdomain: "acme" # must be globally unique isChatEnabled: true isCustomerFacingAiEnabled: true # Ari answers directly on the help center }) { helpCenter { id publicName } error { message code fields { field message } } } } mutation { createHelpCenterArticleGroup(input: { helpCenterId: "hc_...", name: "Getting started" # parentHelpCenterArticleGroupId: "..." to nest }) { helpCenterArticleGroup { id name } error { message code } } } mutation { upsertHelpCenterArticle(input: { helpCenterId: "hc_...", helpCenterArticleGroupId: "hcag_..." title: "How to reset your password" description: "Steps to reset a forgotten password" # REQUIRED despite schema saying optional contentHtml: "<p>Click forgot password.</p>" status: PUBLISHED slug: "reset-password" }) { helpCenterArticle { id title } error { message code fields { field message } } } }

Scopes: helpCenter:create/edit/read/delete plus separate helpCenterArticle:* and helpCenterArticleGroup:* scopes. generateHelpCenterArticle AI-drafts from a thread. For bulk migration: fetch each source page, convert to clean HTML, create groups mirroring their nav, then loop upsertHelpCenterArticle.

11. Ari knowledge sources βœ… verified

KnowledgeSource is a union β€” use inline fragments.

mutation { createKnowledgeSource(input: { url: "https://acme.com/sitemap.xml" type: SITEMAP # SITEMAP (crawls the sitemap) | URL (single page) labelTypeIds: [] }) { knowledgeSource { __typename ... on KnowledgeSourceSitemap { id url } ... on KnowledgeSourceUrl { id url } } error { message code fields { field message } } } }

A help center you create is auto-indexed β€” no separate source needed. Scopes: knowledgeSource:create/read/delete. Also reindexKnowledgeSource, deleteKnowledgeSource.

12. Sidekick βœ… verified (settings + custom skill)

mutation { updateSidekickSettings(input: { customPrompt: "Always mention our 30-day refund policy." }) { error { message code } } } mutation { createSidekickCustomSkill(input: { displayName: "Check subscription status" description: "Looks up a customer's plan and billing status" instructions: "Use the billing MCP for lookups only." }) { customSkill { id displayName } error { message code } } } # output is `customSkill`

Scopes: sidekickSettings:read/update, sidekickCustomSkill:create/edit/read/delete, sidekickMcpServer:read/update β€” note there is no sidekickMcpServer:create scope, so createSidekickMcpServer may be restricted; verify before promising it. Per-integration scopes exist and are individually gated: sidekickPosthogIntegration:update, sidekickSentryIntegration:update, sidekickLinearIntegration:update, sidekickJiraIntegration:update, sidekickGithubIntegration:update, sidekickNotionIntegration:update, sidekickHubspotIntegration:update, sidekickDatadogIntegration:update, sidekickGrafanaIntegration:update, sidekickIncidentioIntegration:update, sidekickAttioIntegration:update, sidekickLaunchdarklyIntegration:update, plus workspaceSlackSidekickIntegration:create/update/delete. OAuth-based connectors still need a human to complete consent in the browser.

13. Webhook targets βœ… verified

mutation { createWebhookTarget(input: { url: "https://acme.com/plain-webhook" eventSubscriptions: [ { eventType: "thread.service_level_agreement_status_transitioned" } { eventType: "thread.thread_created" } ] isEnabled: true description: "Ops alerts" }) { webhookTarget { id } error { message code fields { field message } } } }

thread.sla_status_transitioned does not exist. Valid webhook event types (distinct from workflow events): thread.thread_created, thread.thread_status_transitioned, thread.thread_assignment_transitioned, thread.email_received, thread.email_sent, thread.slack_message_received, thread.slack_message_sent, thread.slack_message_updated, thread.discord_message_received, thread.discord_message_sent, thread.discord_message_updated, thread.ms_teams_message_sent, thread.ms_teams_message_received, thread.chat_sent, thread.chat_received, thread.note_created, thread.note_mention_created, discussion.discussion_created, discussion.message_created, thread.thread_labels_changed, thread.thread_priority_changed, thread.thread_field_created, thread.thread_field_updated, thread.thread_field_deleted, thread.service_level_agreement_status_transitioned, thread.thread_tenant_updated, thread.thread_locked, customer.customer_created, customer.customer_updated, customer.customer_deleted, customer.customer_changed, customer.customer_group_changed, customer.customer_group_memberships_changed, timeline.timeline_entry_changed. Scopes: webhookTarget:create/edit/read/delete.

14. Team members and roles ⚠️ invites are UI-only

# ❌ FAILS with a machine-user key: # inviteUserToWorkspace(input: { email: "...", roleKey: SUPPORT }) # β†’ ForbiddenError: Machine user not allowed to perform this operation

Inviting teammates cannot be automated with a setup API key β€” it requires a human actor context. Put invites in the handoff report as a UI task (Settings β†’ Members), don't try to script them.

assignRolesToUser does work with a machine key for users who already exist:

mutation { assignRolesToUser(input: { userId: "u_...", roleKey: SUPPORT }) { error { message code } } }

RoleKey: OWNER | ADMIN | SUPPORT | VIEWER | NONE, or customRoleId. Scopes: roles:read/assign/create/edit.

15. Tenants and field values βœ… verified

mutation { upsertTenant(input: { identifier: { externalId: "acme-inc" } name: "Acme Inc", externalId: "acme-inc" # primaryDomain / alternateDomains / isDomainAutoJoinEnabled are beta β€” may not be enabled }) { tenant { id name } error { message code fields { field message } } } } mutation { upsertTenantField(input: { tenantFieldIdentifier: { tenantIdentifier: { externalId: "acme-inc" } # or tenantId externalFieldId: "arr" } type: NUMBER_TYPE numberValue: 48000 # set exactly the one value field matching `type` }) { tenantField { id } error { message code fields { field message } } } }

Scopes: tenant:create/read/delete, tenantField:create/update/read/delete. Tier a tenant with updateTenantTier.


Permission scopes β€” corrected against a live key

An Admin-preset key reported 390 scopes. Corrections to earlier guesses:

AreaReal scopes
Tierstier:create, tier:read, tier:edit, tier:delete, tierMembership:create/delete/read
SLAsserviceLevelAgreement:create/edit/delete
Business hoursbusinessHours:edit, businessHours:read
LabelslabelType:create/edit/read, label:create, suggestedLabelType:read
Tenant fieldstenantFieldSchema:create/update/read/delete, tenantField:create/update/read/delete
Tenantstenant:create/read/delete
Thread fieldsthreadFieldSchema:create/edit/read/delete, threadField:create/update/read/delete
Escalation pathsescalationPath:create/edit/read/delete/execute
Workflowsno workflow:* scope exists β€” only workflowRule:create/edit/read/trigger
Saved viewssavedThreadsView:create/edit/read/delete
Help centrehelpCenter:* plus separate helpCenterArticle:*, helpCenterArticleGroup:*
Knowledge sourcesknowledgeSource:create/read/delete, knowledgeGap:*, knowledgeGapSignal:*
SidekicksidekickSettings:read/update, sidekickCustomSkill:create/edit/read/delete, sidekickMcpServer:read/update (no :create), per-integration sidekick<Service>Integration:read/update
WebhookswebhookTarget:create/edit/read/delete
Rolesroles:read/assign/create/edit
Sanity checkpermission:read β†’ call myPermissions first to see exactly what you hold

Easiest path for a one-time setup key remains the Admin preset. Delete the machine user (deleteMachineUser) or narrow the key afterwards.

Confirmed UI-only (cannot be done with a setup key)

  • Inviting teammates β€” inviteUserToWorkspace rejects machine users outright. Settings β†’ Members.
  • Personal notification preferences β€” no mutation exists (updateInternalNotifications only marks notifications read/archived). Avatar β†’ Preferences, per user.
  • OAuth completion for channels and OAuth MCP servers β€” Slack / MS Teams / Discord / email forwarding have create*Integration mutations that provision the shell, but consent and DNS verification need a human browser session. Support is not live until a channel is connected.
  • Chat widget snippet placement β€” happens on the customer's own site.