API version 1.16.0

MindThread API for Agents

Run a brand's Threads presence end to end: generate posts, publish, manage the library and schedules, run cloud patrol with review, read effectiveness. Everything the web app can do, an agent can do with one key.

Base URLhttps://mindthread.tw/api/v1
OpenAPI 3.1 (read this first)/api/v1/openapi.json
Agent guide (llms.txt)/llms.txt
MCP serverPOST https://mindthread.tw/api/mcp
GET lists the tools

Auth and keys

Create keys in the web app (Settings → API keys). One key = one member = all of their accounts. Up to 5 active keys.

Authorization: Bearer mt_live_…   # acts for real
Authorization: Bearer mt_test_…   # sandbox
KeyContract
mt_live_Real writes. Daily call quota per plan and per key (Free 50 / Connect 500 / Pro 1,000 / Business 5,000), resets 00:00 Asia/Taipei, plus 120 calls per minute per key. GET /me shows what is left.
mt_test_Returns mock data. Every write responds with test_mode:true and persisted:false and never mutates live account state. An endpoint that is not sandboxed yet answers test_mode_unsupported instead of writing. Sandbox quota: all mt_test_ keys of one member share 500 units per day (not charged to the plan) plus 30 calls per minute. Test keys never call the model: material build returns a mock digest, library writes skip judge_claims, and POST /accounts/connect-link returns a mock link that connects nothing. Sandbox writes that publish now or approve a pending cloud reply record a real sandbox event that only this key reads on GET /events (1.8.2).

GET /me also returns the key itself: key { id, label, prefix, last4, kind, status, scopes, legacy, scopes_enforced, agent_name, agent_url, created_at, last_used_at }, actor { type, key_id, label } and actor_id: api:<key_id>. prefix is mt_live or mt_test; status is always active there; last_used_at is the last recorded use before this request (recorded at most once a minute); agent_name and agent_url are what the owner entered in Settings (null when empty; no API key can set them); an empty label reads as legacy-<last4>. Keys created before scopes existed report legacy: true, scopes_enforced: false and allow everything; every other key is enforced (see Permissions and approvals). Write responses carry a top-level actor { type: "key", key_id }; the key label is only returned by GET /me.

/keys, PATCH /keys/{id} and /usage are the web app's own key management endpoints. Called with an API key they answer 401 { "error": { "code": "session_required", … } }; the calling key's metadata is on GET /me under data.key, and its own usage and call records are on GET /keys/current/usage (1.8.2, below).

Permissions and approvals (1.8.0)

Every key except legacy keys has allow, ask or deny per permission (GET /me → data.key.scopes, scopes_enforced: true). Each operation in the spec names its permission in x-scope (and x-scope-alt when the body decides); the full table and presets are under x-scopes. New live keys start on standard, new test keys on open; keys made by team members are standard or stricter. read is never ask. GET /me, GET /approvals/{id} and POST /keys/current/disable need no permission.

PermissionCoversStandardGrok vocabulary
readEvery GETallowread
content:writeGenerate, draft library items and edits of drafts, material, settings that do not loosen a guardrail, cloud patrol settings that leave it offallowgenerate, library (not approve), patrol (settings that do not enable)
content:schedulepublish_at 30 minutes or more away, /content/schedule, POST /library unless status is draft (queued items publish automatically), library approve / queue, new text on a queued item, cancel a scheduleallowschedule, library approve
content:publish_nowpublish_now, /content/publish-thread, POST /experiments (variants publish at once), any publish_at less than 30 minutes away or in the pastaskpublish
patrol:reply_sendApprove a pending cloud replyaskpatrol (send)
patrol:cloud_enableCloud patrol ends up enabledaskpatrol (enable)
patrol:browser_commandBrowser patrol commandsaskpatrol (browser)
accounts:connectPOST /accounts/connect-linkaskadmin (connect)
accounts:guardrailsSettings PATCH that loosens a guardrail: review_before_publish off, auto_reply_enabled / video_autopilot / enabled on, content_rules removed or loosened (block → warn, fewer banned phrases, facts cleared, a fact the account did not have or a change that lets a new number through, forbid_cta / forbid_hashtags / forbid_causal_claims / one_fact_per_post / judge_claims off, max_chars raised or removed, min_chars lowered or removed, a mention_limits entry dropped or raised). content_rules is replaced as a whole. From 1.8.3 the approval shows every field the body touches as current and proposed value and fails with approval_base_changed if one of them changes before it runs.askadmin (settings, content_rules)
webhooks:writeCreate and delete webhooksallowwebhooks

Standard allows content:schedule, so a post scheduled 30 minutes or more ahead is queued without asking and publishes at its time. Conservative = standard plus ask for content:schedule. Open = allow everything. There is no approve permission and no approve_path: only the owner, signed in to the web app, can approve.

Changing an existing key (1.8.1)

The owner changes a key in place in Settings; the web app calls PATCH /keys/{id} with the signed-in session (x-audience: human in the spec). An API key gets 401 session_required there, so an agent can never change its own permissions, and there is no MCP tool for it. The body takes preset (conservative, standard or open), scopes (only the permissions to change; a legacy key starts from standard and stops being legacy), label, agent_name (1 to 60 characters) and agent_url (https, up to 300 characters; null clears). Widening any permission needs a sign-in within the last 24 hours that was not revoked (401 reauth_required with reason); tightening never does. Team members change only keys they created (403 owner_only) and only to standard or stricter (403 team_scope_limit). A revoked key answers 409 key_disabled; another member's key 404 key_not_found. Setting a permission to deny cancels that key's pending approvals for it (cancel_reason: permission_set_to_deny), and the response counts them; an approval that slipped through can no longer be approved (the owner's decide answers 409 approval_permission_denied). Creating a live key wider than standard (POST /keys) needs the same recent sign-in. GET /keys items carry scopes_updated_at; send it back as if_scopes_updated_at with any permission change, and if the permissions changed meanwhile nothing is saved: 409 key_changed with error.key, the current state. 400 codes: nothing_to_update, invalid_preset, invalid_scopes, invalid_label, invalid_agent_name, invalid_agent_url, invalid_request (label and agent_name refuse line breaks and invisible control characters); the full list is in the OpenAPI operation PATCH /keys/{id}. GET /me with the key shows the change on its next call. /keys errors use the standard envelope { "error": { "code", "message", "request_id" } }.

PATCH /api/v1/keys/K9f2…        # Firebase ID token of the owner
{ "scopes": { "content:publish_now": "deny" }, "if_scopes_updated_at": "2026-09-24T04:30:00.000Z", "agent_name": "OpenClaw", "agent_url": "https://openclaw.example/agent" }

{ "ok": true, "request_id": "req_…", "data": {
    "key": { "id": "K9f2…", "label": "OpenClaw Agent", "kind": "live", "legacy": false, "scopes_enforced": true, "scopes": { …, "content:publish_now": "deny" },
             "agent_name": "OpenClaw", "agent_url": "https://openclaw.example/agent", … },
    "changes": [ { "scope": "content:publish_now", "from": "ask", "to": "deny" } ],
    "cancelled_approvals": 1, "cancelled_approval_ids": ["apr_7Qx…"] } }

Grok acceptance items vs. this API (1.10.0)

The same rows are in the spec under x-scopes.grok_acceptance. The r4 rows (P2-OBS, P2-4-E2E-LIVE) quote Grok's r4 tickets; the r5 rows (P2-OBS-FOLLOW, P2-GOV-DOCS) quote Grok's r5 tickets, and the P2-5 row says where shared policies stand. The P2-3b-A row is the 1.10.0 in-channel approval item (see In-channel approval below).

ItemGrok expectsThis APIWhy
P2-1 (passes)Two keys show different idsGET /me data.key.id and the actor.key_id on write responses differ per key
P2-4 (passes)A key can disable itselfPOST /keys/current/disable, then 401 key_disabled on the next call
P2-3 (passes)Test keys do not trigger approvalNew test keys are open: nothing asks until the owner sets a permission to ask
P2-2 (passes)A denied permission is a hard refusaldeny answers 403 insufficient_scope with X-Required-Scope
approval status code (differs)409 or 202 approval_required403 approval_requiredCommon HTTP clients and MCP pass 403 through without retrying, and every permission refusal shares one status
publish without permission (differs)insufficient_scopeA standard live key gets 403 approval_required; only publish_now=deny gives insufficient_scopeDangerous actions ask the owner instead of failing
approver (differs)An approve scope and a governor APINo approve scope, no governor API: only the signed-in owner approves at /approve/{id}; from 1.10.0 also from a one-time link sent to the owner's own channelsin-channel approval keeps a human credential in the loop: owner channel link, session, passkey or an owner-registered attested client; never the API key
key admin (differs)/keys/{id}/disable and /enableNot provided: a key disables only itself; other keys are managed in Settings; there is no re-enableA leaked key must not be able to switch other keys on or off
401 headers (differs)X-RateLimit-* on every responseA 401 carries no X-RateLimit-* headersNo quota is checked before authentication succeeds
scopes shape (differs)An array of scope namesAn object: permission name to allow | ask | denyThree states need a value per permission
/keys with an API key (differs)404 or a key list401 session_requiredKey metadata for API callers is only on GET /me
P2-2b (addressed in 1.8.1)An existing key can change its permissions (the fix asked for a new key)1.8.1: the owner changes an existing key in place with PATCH /keys/{id} (web app session); insufficient_scope.fix points there
P2-4-E2E (addressed in 1.8.1)Prove end to end that disabling a key cancels its pending approvals1.8.1: POST /keys/current/disable answers cancelled_approvals and cancelled_approval_ids; each shows as cancelled (cancel_reason key_disabled_self) in approval.decided
P2-OBS (addressed in 1.8.2)r4 ticket P2-OBS: make events real instead of mock and add per-key usage (observability_audit was held at 4)1.8.2: GET /keys/current/usage (own key only: summary and recent calls with request_id, cursor paging); sandbox writes record real sandbox events that only that test key reads on GET /events; live keys and webhooks never see them
P2-4-E2E-LIVE (addressed: run on production 2026-09-24)r4 ticket P2-4-E2E-LIVE: with a disposable key, actually run disable, then cancelled_approvals and cancelled_approval_ids, then key_disabled (the contract shipped in 1.8.1)Run on production 2026-09-24 with a disposable test key: it created an ask approval, POST /keys/current/disable answered cancelled_approvals 1 with that approval id, and the next call got 401 key_disabled (agent-conformance-p2 --expect 1.8.1 --require-e2e). A second disposable test key was issued for the Grok suite to repeat it. Reading the cancellation from another key's event stream needs a live key, because from 1.8.2 a test key's approvals are sandbox events
P2-3b-A (passes, 1.10.0)Owner approves in-channel; key never sees the tokenOne-time link to the owner's own channels (email, web push; LINE follows); decision_via: "link" on GET /approvals/{id} and approval.decided; the key view never carries the link
P2-5 (addressed in 1.9.0, minimal version)Shared policies per account: a desired and an active version, with policy.apply going through approvals1.9.0, minimal version: the guardrails on the account are the active policy (GET /accounts/{id}/settings data.guardrails with etag, last_change and pending), a pending settings approval is the desired change, and the owner confirming it applies it; every change is a revision (GET /accounts/{id}/guardrails/history) that the owner can undo (POST /accounts/{id}/guardrails/rollback, or the undo button on the web app page /accounts/{id}/guardrails)A separate policy object and proposal endpoints wait until more than one key really edits the same account; today the approval is the proposal
P2-OBS-FOLLOW (addressed in 1.9.0)P2-OBS-FOLLOW(可選)Events 跨 live/sandbox 操作手冊與 webhook 去重(post_id) (r5 ticket, in English: optional, an operating guide for events across live and sandbox, and webhook dedupe by post_id)1.9.0: live keys read live events and test keys only their own sandbox events; start the live cursor fresh after switching keys; webhooks are live only; every webhook body now carries id, the same id GET /events gives the event, so dedupe by id (post_id alone is not unique: one post can fail more than once); count equal to limit, or a types filter, can mean older events were skipped
P2-GOV-DOCS (addressed in 1.9.0)P2-GOV-DOCS 拋棄式 key 與 suite key 分帳/成員邊界說明(避免評測帳號錯位) (r5 ticket, in English: keep the disposable key and the suite key on separate books, and explain the member boundary, so evaluation accounts do not end up in the wrong place)1.9.0 docs: a key sees only its own member's accounts; create the suite key and the disposable key under the member being evaluated; each key has its own approvals and usage, so check a disposable key's results with that key; the suite key is never disabled, the disposable key is only for destructive checks
# deny: nobody is asked
HTTP/1.1 403
X-Required-Scope: content:publish_now
{ "error": { "code": "insufficient_scope", "message": "This key is not allowed to do this (content:publish_now).",
             "required": ["content:publish_now"], "mode": "deny", "have": ["content:schedule", "content:write", "read", "webhooks:write"],
             "fix": "The key owner can change this key in place, no new key needed: sign in at https://mindthread.tw/settings (Settings → API keys), find the key mt_live_...8f2a, press “Edit permissions and name”, open “Advanced: set each permission” and set “Publish now” (content:publish_now) to “Allow” (or “Ask me”, so each such request waits for the owner to confirm). Widening a permission needs a sign-in within the last 24 hours. Team members can change only keys they created, and can set this permission to Ask me at most; Allow needs the owner. 中文:…",
             "request_id": "req_…" } }

# ask: validated, approval created, owner notified (bell, push, email)
HTTP/1.1 403
{ "error": { "code": "approval_required", "message": "This action needs the key owner's confirmation. …", "scope": "content:publish_now", "approval_id": "apr_7Qx…",
             "approval": { "id": "apr_7Qx…", "status": "pending", "scope": "content:publish_now", "key_id": "K9f2…", "test_mode": false,
                           "action": { "method": "POST", "route": "/content/publish", "account_id": "utOW…", "account_username": "ultraprobe.tw", "summary": "立刻發文到 @ultraprobe.tw:「…」" },
                           "created_at": "…", "expires_at": "…", "decided_at": null, "decision": null, "result": null, "human_url": "https://mindthread.tw/approve/apr_7Qx…",
                           "human_message": "請打開 https://mindthread.tw/approve/apr_7Qx… 確認:…(也已送到你的站內通知、電郵)。", "poll": "GET /approvals/apr_7Qx…", "event": "approval.decided",
                           "execution": "server-side after the owner confirms; …" },
             "request_id": "req_…" } }

On approval_required: hand human_url or human_message to the human as is, never open it yourself, never resend (the same body returns the same approval). The owner confirms once, confirms and allows this permission from now on, or denies. The server then runs the original request itself and emits approval.decided with actor { type: "human", key_id: null, via_key_id, label: "owner" }. Poll GET /approvals/{id} (only approvals your key created; no daily quota; Retry-After: 15 while pending): the full result is only there. GET /events?types=approval.decided carries a summary (ok, http_status, post_id, threads_post_id, error.code), and other live keys of the same member see only approval_id, status, decision and cancel_reason of a live approval (a test key's approvals are sandbox events that no other key reads). For test keys GET /events returns only that key's own sandbox events (its approval.decided included, 1.8.2); poll GET /approvals/{id} for the full result. human_message names only the channels that actually reached the owner. A lost approval_id is recovered by resending the same body while the approval is pending (you get the same approval). Statuses: executed (the normal response is in result.data, plus result.permalink when Threads returned one), failed (result.error { code, message }; approval_payload_changed = the library items changed after the owner was asked and nothing ran, approval_base_changed (1.8.3) = a settings field the request touches changed after the owner was asked and nothing ran (read the settings again and resend, which asks the owner again), execution_unknown = the outcome was not recorded, check before retrying), denied, expired (24 hours), cancelled (cancel_reason: key_revoked or key_disabled_self when the key was disabled, permission_set_to_deny when the owner set that permission to deny; other values are support cancellations). Anything but executed: stop and ask the human. Other codes: 409 approval_already_executed (this exact request was already confirmed and run within 24h; read error.approval.result, do not resend) · 409 approval_in_progress (confirmed, still running) · 409 approval_denied (the owner denied this exact request within 24h) · 400 approval_unsupported_field (approvals never store secrets) · 413 approval_payload_too_large · 400 invalid_idempotency_key (keys starting with approval: are reserved) · 403 approval_not_supported (POST /content/generate-and-schedule never waits for approval: generate with POST /content/generate, then publish the text with POST /content/publish). Limits: 10 pending per key, 50 per member per day (test keys 20 more), then 429 approval_flood with window: "approvals". A request that would fail validation returns its normal error and creates nothing. 403 scope_unmapped is a server configuration error. Accounts with review_before_publish: confirming at /approve counts as the review for that post; API publishing never went through review_before_publish, the permission is the gate. Test keys default to open; set one permission to ask on a test key to rehearse the flow (the approved request runs as a mock).

Kill switch

POST /keys/current/disable (no body) permanently disables the key that calls it. It consumes no quota, ignores the per-minute limit, sends no X-RateLimit-* headers and works for live, test and legacy keys. The next request with that key is rejected, which we promise within 5 seconds. Requests already in progress are not interrupted. Disabling stops API requests only; webhooks, queued posts and cloud patrol settings created with this key keep running. Review them in Settings. There is no re-enable: create a new key in Settings. A key cannot disable other keys. Treat both 200 and 401 key_disabled as "stopped". Since 1.8.1 the 200 lists the approvals this key was still waiting on, now cancelled: cancelled_approvals (a count; null only if that step failed, and such an approval can still never be approved) and cancelled_approval_ids (up to 50). The disabled key cannot read them any more; for a live key, another live key of the same member sees each as cancelled with cancel_reason: key_disabled_self in GET /events?types=approval.decided. A test key's approvals are sandbox events (1.8.2) that no other key reads, so rely on cancelled_approval_ids there.

POST /api/v1/keys/current/disable
{ "ok": true, "request_id": "req_…", "data": { "key_id": "K9f2…", "status": "disabled", "disabled_at": "2026-09-24T05:00:00.000Z",
  "cancelled_approvals": 1, "cancelled_approval_ids": ["apr_7Qx…"] }, "actor": { "type": "key", "key_id": "K9f2…" } }

# every later call with that key, including /me:
HTTP/1.1 401
{ "error": { "code": "key_disabled", "message": "This API key has been disabled", "key_id": "K9f2…",
             "disabled_at": "2026-09-24T05:00:00.000Z", "request_id": "req_…" } }

A key revoked in Settings answers with the same key_disabled. A missing or wrong key stays 401 unauthorized. A 401 carries no X-RateLimit-* headers (no quota is checked before authentication succeeds). On a mt_test_ key the 200 also says "persisted": true.

Usage records and sandbox events (1.8.2)

GET /keys/current/usage returns the calling key's own usage: summary { calls, units, errors, by_endpoint[] } (days=1 is today since 00:00 Asia/Taipei, the same cut as the quota reset) and recent[] { request_id, endpoint, status, units, at } newest first with next_cursor / has_more; days 1..30, limit 1..100, cursor = next_cursor of the previous page. There is no key id parameter: a key never sees another key's records, label or the member id. It needs no permission (like GET /me) and consumes no daily quota; at most 10 requests per minute per key on this endpoint, on top of the usual per-minute limit. MCP: get_my_usage.

What is recorded (the same text is in data.note): every request after the server identifies the key and answers, success or error, including 400, 403 and 429 refusals. Not recorded: 401 unauthorized, 401 key_disabled, answers given before the key is checked (405 method_not_allowed, 401 session_required), and the validation run before an approval is created. 429 answers and refusals made before the quota check (400 invalid_idempotency_key, 403 scope_unmapped) are recorded about once per key per minute on each server, so those counts are a lower bound. Records are written after the answer is sent, best effort: one can occasionally be missing, so summary and recent may differ slightly. A request the server ran after the owner confirmed an approval shows request_id: apr_exec_<approval id> and units: 0; a caller's own X-Request-Id starting with apr_exec_ is replaced by a server-generated id. summary counts start with 1.8.2; recent covers at most the last 30 days. Errors: 400 invalid_days, invalid_limit, invalid_cursor; 429 rate_limit_exceeded; 503 usage_index_building while the database index is being built.

GET /api/v1/keys/current/usage?days=1&limit=2
{ "ok": true, "request_id": "req_…", "data": { "key_id": "K9f2…", "kind": "test",
  "window": { "days": 1, "since": "2026-09-23T16:00:00.000Z", "until": "…", "timezone": "Asia/Taipei" },
  "summary": { "calls": 3, "units": 2, "errors": 1, "by_endpoint": [ { "endpoint": "POST /content/publish", "calls": 2, "units": 2, "errors": 1 }, … ] },
  "recent": [ { "request_id": "req_…", "endpoint": "POST /content/publish", "status": 200, "units": 1, "at": "2026-09-24T03:12:40.000Z" }, … ],
  "next_cursor": null, "has_more": false, "note": "Only this key's records. Coverage: …" }, "test_mode": true, "data_source": "live_readonly" }

Sandbox events: with a mt_test_ key, publishing now (POST /content/publish or POST /content/generate-and-schedule with publish_now, POST /content/publish-thread, POST /experiments) records post.published, approving a pending cloud reply records patrol.reply, and an approval this key created records approval.decided when it is decided, cancelled or expires. Their data carries test_mode: true, key_id, persisted: false and request_id (approval.decided carries test_mode: true and key_id). GET /events with that test key returns only these, marked data_source: sandbox; with none it returns an empty list and data.note says which writes record events. Other sandbox writes (for example scheduling, library, settings, patrol settings, browser commands, webhooks, material, connect-link, generating text, rejecting a pending reply, cancelling an experiment) record no event. Sandbox events are never delivered to webhooks and never shown to live keys. GET /events covers the last 7 days and answers 503 events_index_building while its index is being built; latest_at moves past events you did not ask for (types), so polling never sticks.

Live publishing: From 1.8.2, publishing now with a live key through POST /content/publish or POST /content/generate-and-schedule (publish_now) also emits post.published or post.failed (source api_publish or api_generate_and_schedule); webhook deliveries can repeat, so dedupe by post_id.

Content rules and settings approvals (1.8.3)

From 1.8.3, content_rules are checked on every API path that writes or schedules text: POST /content/generate, /content/publish, /content/schedule, /content/generate-and-schedule, /content/publish-thread, /content/generate-thread, /experiments, POST /library and PATCH /library/{id}. With enforce:block a violation answers 422 content_rules_violated and nothing is written, scheduled or published; with enforce:warn (the default) the request succeeds and carries rule_violations (/experiments does not report them). Every path checks the fixed rules (max_chars, min_chars, forbid_cta, forbid_hashtags, banned_phrases, facts, forbid_causal_claims, one_fact_per_post, mention_limits). The model review (judge_claims) and the recently used numbers check (repeated_fact) run only on POST /content/generate, POST /library and PATCH /library/{id}; every other path, including the check right before a scheduled post publishes, runs the fixed rules only. error.violations is RuleViolation[] ({ rule, detail }) for one text (/content/publish, /content/schedule, /content/generate-and-schedule, PATCH /library/{id}); one RuleViolation[] per post for /content/generate; [{ index, rule_violations }] for /library and /content/publish-thread (index counts from 0 in the request array) and for /content/generate-thread (index is the thread_parts[].index of the answer, from 1); [{ formula, rule_violations }] for /experiments.

Matching ignores invisible format characters such as zero-width spaces, compares banned_phrases and mention_limits after Unicode NFKC and case folding, and skips a number written next to - : / only when it is part of a date, time or range such as 2026-09-25, 12:30 or 5-10 (so 100000/month now counts). A topic_tag is checked against banned_phrases too; forbid_hashtags covers #hashtags in the text, and on an account with it on, posts from the API and the library go out without a Threads topic tag (topic_tag comes back null). Stored banned phrases are normalized the same way and capped at 100 characters, and invisible characters are removed from facts.

Test keys get the same 422: /content/schedule and /content/publish-thread check the submitted text, and the two generating paths check their mock text, which contains the topic, so a banned phrase in the topic rehearses the refusal. The mock text is checked only against banned_phrases, forbid_cta and forbid_hashtags, because length and number rules need real text: a test key can still rehearse a successful call, and an ask permission on /content/generate-thread still creates its approval. /content/generate-and-schedule and /content/generate-thread now put the rules in the prompt and regenerate a violating draft (up to 3 model calls with enforce:block, 2 with warn), like /content/generate; model_calls says how many were made.

Posts scheduled through the API are checked again right before they publish, against the rules in force at that moment: with enforce:block a violating post is not published but moved to draft with held_reason and rule_violations (GET /schedules?status=draft), the owner is notified in the web app, and post.failed with error content_rules_violated is emitted; with warn it publishes and the violations are recorded. Not checked: posts written in the web app's compose page, cloud patrol replies, auto-replies to comments on the account's posts, and browser patrol (extension) replies and posts.

Behavior change: scheduling or threading text that violates an enforce:block account through these paths used to succeed and now answers 422, and such a post already in the queue is held as a draft instead of published. Also a behavior change: PATCH /accounts/{id}/settings with a live key (legacy keys included) used to merge content_rules key by key and now replaces the object, so rules left out of the body are removed, and the stricter matching above can refuse text that used to pass.

PATCH /accounts/{id}/settings now really replaces content_rules as a whole on live keys, as documented: before 1.8.3 a live write merged the object key by key, so rules left out of the body silently stayed.

A settings approval (PATCH /accounts/{id}/settings waiting for the owner) now shows the owner every field the body touches as current and proposed value, using the value the server would actually write, including fields that are not guardrails such as persona and schedule_times; content_rules is itemized down to the added and removed facts and banned phrases and the enforce change. A fact that differs only in spaces is listed too, with the numbers the change newly allows. The comparison is capped at 60,000 characters: long current values are shortened (truncated:true), and a request whose comparison still does not fit answers 413 approval_payload_too_large. A facts change counts as loosening (accounts:guardrails) when a fact is new ignoring spaces, when the set of numbers the facts allow grows (a space inside 101563 allows 10 and 1563), or when a new Chinese quantity phrase appears. Such an approval also records the current value of every field the body touches. If any of those fields changes before the request runs, it is not run: the approval turns failed with result.error.code approval_base_changed as soon as it is read, listed or decided (deciding answers 409 approval_base_changed, checked in the same transaction, and always is not applied), or right before it runs. Changes to fields the body does not touch do not matter. Read the settings again and resend: the same body then gets a new approval. Settings approvals created before 1.8.3 have no recorded values and end the same way when read, listed or decided.

{ "error": { "code": "content_rules_violated", "message": "…", "violations": [ { "index": 0, "rule_violations": [ { "rule": "banned_phrase", "detail": "…" } ] }, { "index": 1, "rule_violations": [] } ], "request_id": "req_…" } }

Guardrail versions, history and rollback (1.9.0)

Shared policy, minimal version: the guardrails on the account are the active policy for every key and automation on it; a pending settings approval is the desired (proposed) change; the owner confirming it applies it. There is no separate policy object or proposal endpoint yet. From 1.9.0, GET /accounts/{id}/settings returns data.guardrails: etag (an opaque version of the five guardrails: content_rules, review_before_publish, auto_reply_enabled, video_autopilot, enabled), covers, last_change, pending, enforced_on and not_enforced_on.

PATCH /accounts/{id}/settings accepts an optional if_match with that etag. It is compared only on the guardrails the body sends, content_rules as one part and each of the four switches on its own, so a change to another part does not refuse the request. If a sent part changed since you read it, the answer is 412 guardrails_changed with current_etag, changed, current (the five current values) and last_change, and nothing is written: read again, apply your change to the current content_rules (it is replaced as a whole) and resend. Without if_match the last writer wins, as before, and every change is recorded.

dry_run:true only previews: it needs the read permission, creates no approval and writes nothing, and each key can dry-run at most 10 times a minute (429 rate_limit_exceeded). It answers changed, loosened, tightened, required_scope, your_mode, outcome (apply, approval_required or denied), etag_now and impact: how many of the first 200 queued library items, in publishing order, the new content_rules would newly flag, checked with the fixed rules only (no judge_claims, no scheduled posts).

Every change to a guardrail made through the API, by an approval or by the system writes a revision that is never edited or deleted: GET /accounts/{id}/guardrails/history (read permission, newest first, limit up to 50, before=next_before for the next page) lists id, at, source (api, approval, rollback or system), actor, mine, changed_fields, loosened, tightened, gap_before, before, after and restore_body; approval_id, request_id and reason only on revisions your own key made. PATCH accepts reason (up to 200 characters, plain text, links removed) to explain a change. gap_before:true means the guardrails changed outside the record since the previous revision (in the web app, or before 1.9.0); those changes are not recorded by name yet. A system revision (actor type system) records the server pausing an account (its authorization failed, it is blocked or publishing keeps failing) or turning it back on after a reconnect or token refresh, with system_reason; it cannot be undone. Test keys read live revisions and write none.

The owner can undo one revision: POST /accounts/{id}/guardrails/rollback { revision_id, if_match } (web app session only, x-audience human; an API key gets 401 session_required, a team member 403 owner_only; in the web app the owner presses the undo button in the change history on the page /accounts/{id}/guardrails). It restores the fields that revision changed to their previous values, only while they still hold the values that revision wrote; otherwise it answers 409 rollback_conflict with later_revision_id (the later revision that changed them) or changed_outside_record:true, and nothing changes. If those fields already hold their previous values, it answers 200 with already_rolled_back:true and writes nothing. An undo is recorded as a revision with source rollback. An undo that loosens a guardrail needs a sign-in within 24 hours (401 reauth_required). To undo through the API instead, send restore_body with if_match as a normal PATCH: loosening still needs the owner.

Loosening is now decided field by field from one comparison table that every content_rules field must be declared in: a difference that is not proven stricter counts as loosening, and a change that loosens one rule and tightens another counts as loosening. The first facts list on an account that had none is not loosening, because numbers were not checked before; the revision records it as facts_created. The same comparison decides the permission, the approval preview, and loosened and tightened in revisions and dry runs. Writes are transactional: a settings approval that runs after the owner confirmed checks its recorded values again in the same transaction as the write, and a request that became loosening after the permission check (the guardrails changed in between) answers 412 guardrails_changed instead of writing.

422 content_rules_violated now carries guardrails_etag, the version of the rules that refused the text; a scheduled API post held at publish time carries held_guardrails_etag next to held_reason (GET /schedules) and guardrails_etag in post.failed, and a queued library item held at publish time carries held_guardrails_etag next to held_reason (GET /library). When a guardrail is loosened without the owner pressing allow (the key has accounts:guardrails on allow, or it is a legacy key), the owner gets an in-app notification (no email); more loosening by the same key on the same account within the same 30 minutes updates that notification instead of adding another. content_rules can only be changed through PATCH /accounts/{id}/settings: the web app's direct database writes can no longer change it (the web app never wrote it), and an account that has had content_rules cannot be deleted and created again under the same id from the web app, so every change is recorded.

enforced_on today: POST /content/generate; POST /content/publish; POST /content/schedule; POST /content/generate-and-schedule; POST /content/publish-thread; POST /content/generate-thread; POST /experiments; POST /library; PATCH /library/{id}; publish time: posts scheduled through the API; publish time: the library queue, including items written by the nightly refill and other tools. not_enforced_on: the web app's compose page (posts written or scheduled there); cloud patrol replies; auto-replies to comments on this account's posts; browser patrol (extension) replies and posts.

New error codes: 412 guardrails_changed, 409 rollback_conflict, 404 revision_not_found, 400 invalid_if_match, invalid_reason and invalid_dry_run. MCP: update_account_settings takes if_match, reason and dry_run; list_guardrail_changes reads the history; there is no rollback tool, because only the owner undoes, in the web app.

# GET /accounts/{id}/settings
"guardrails": { "etag": "g1.3f2a9c0144be7d10.8b7d1e22c9a0f311.aa01bc34d2e5f607.0f9e8d7c6b5a4938.11223344556677aa", "covers": ["content_rules", "review_before_publish", "auto_reply_enabled", "video_autopilot", "enabled"], "last_change": { "revision_id": "grv_…", "source": "api", "actor": { "type": "key", "key_id": "K9f2abc" }, "mine": true, "loosened": [], "tightened": ["banned_phrases_added"], "gap_before": false }, "pending": [], "enforced_on": [...], "not_enforced_on": [...] }

# PATCH with a stale if_match
HTTP/1.1 412
{ "error": { "code": "guardrails_changed", "message": "...", "current_etag": "g1.…", "changed": ["content_rules"], "current": { "content_rules": {…}, "review_before_publish": true, … }, "last_change": { "revision_id": "grv_…", "source": "api", … }, "request_id": "req_…" } }

Events across live and sandbox (Grok r5 P2-OBS-FOLLOW)

Event stream across live and sandbox: a mt_live_ key reads the member's live events on GET /events and never sandbox ones; a mt_test_ key reads only its own sandbox events. Build against a test key, then switch keys and start the live cursor fresh (omit since, or use the time you switched): a latest_at from one stream means nothing in the other, and the two are never merged. Webhooks carry live events only. From 1.9.0 every webhook body is { id, event, timestamp, data }, and id is the id GET /events gives the same event. A webhook delivery can repeat, so dedupe by id, on webhooks and on GET /events alike. post_id says which post an event is about, but one post can fail more than once, so do not dedupe by post_id alone. GET /events returns the newest events after since, up to limit (at most 200): when count equals limit, or when you filter by types, older events after since may have been skipped, so poll more often or with a larger limit.

Keys, members and evaluation keys (Grok r5 P2-GOV-DOCS)

Key boundaries: a key only sees and acts on the Threads accounts of the member it belongs to (another member's account answers 403 forbidden). A key a team member creates in the owner's workspace belongs to the owner's member. For a third-party evaluation, create both the suite key and the disposable key under the same member as the accounts being evaluated, otherwise GET /accounts is empty and every account id is refused. The suite key is the long-lived key the evaluation runs everything with and is never disabled; a disposable key exists only for destructive checks such as POST /keys/current/disable and is thrown away afterwards. Each key reads only its own approvals and usage, so check a disposable key's results with that key before disabling it.

Publishing in progress and unknown results (1.9.1)

From 1.9.1 the publisher marks a scheduled post or a queued library item as publishing right before it sends it to Threads, and records published, partial or failed when it is done. While an item is publishing it can no longer be changed: PATCH /library/{id} and POST /library/{id}/approve answer 409 publishing_in_progress, POST /library/bulk reports publishing_in_progress for that item on archive, approve and queue, and DELETE /schedules/{id} answers 409 publishing_in_progress. Read it again in a minute.

PATCH /library/{id} writes in a transaction: when the item's status changes between the read and the write, nothing is written and it answers 409 status_changed (read the item again and retry), or 409 publishing_in_progress when the item became publishing.

If a publishing run is cut off before it records the result, the item is moved to draft, usually 15 to 20 minutes later, and is never resent automatically, because it may already be on Threads. It then carries held_reason publish_result_unknown, ahead of any other held reason, and reclaimed: true (GET /library?status=draft, GET /schedules?status=draft), and the owner is notified in the web app. An API key cannot put such an item back in the queue: POST /library/{id}/approve and PATCH /library/{id} with status queued answer 409 publish_result_unknown, and POST /library/bulk reports publish_result_unknown for it on approve and queue. Archiving it first does not make it queueable. Ask the owner to check Threads: the owner re-queues it in the web app only if it was not posted, or deletes it.

Behavior change: DELETE /schedules/{id} on a post that is being published used to answer 400 cannot_cancel and now answers 409 publishing_in_progress, and GET /schedules now reports a post whose publish result is unknown with held_reason publish_result_unknown and reclaimed: true instead of held_reason null. PATCH /library/{id}, POST /library/{id}/approve and POST /library/bulk can answer the new codes above.

HTTP/1.1 409
{ "error": { "code": "publish_result_unknown", "message": "This draft was being published when the run was cut off, so it may already be on Threads. Ask the account owner to check Threads, then re-queue it in the web app (Library → Drafts) only if it was not posted, or delete it.", "request_id": "req_…" } }

Account availability (1.9.1)

Account availability (1.9.1): POST /content/publish (publish_now), POST /content/publish-thread and POST /content/generate-and-schedule (publish_now) answer 409 account_unavailable when the account's healthStatus is blocked or token_error: the platform does not publish through that account right now. The message says which. token_error: the owner reconnects the account in the web app and the code clears immediately. blocked: wait for the platform block to clear (re-checked every 6 hours). Do not retry in a loop. Scheduling with publish_at is still accepted, but if the account is still unavailable at publish time the scheduled post fails (post.failed) instead of publishing. The MCP tool publish returns the same code as a tool result with isError:true.

In-channel approval (1.10.0)

From 1.10.0 the owner can decide from the notification itself: a one-time link (https://mindthread.tw/a/{lid}, with the secret in the URL fragment, 10 minutes by default, single use, bound to the approval, the owner and the allowed decisions) is sent to the owner's verified channels (email and web push in 1.10.0; LINE follows) once the owner has turned approval links on in Settings. API keys never receive, see or need that link: keep handing human_url and human_message to the human as is, never ask the human to paste the link or any code to you, and never open it yourself.

New read-only fields on Approval: tier (t1, t2 or t3), channels_notified (type and status per channel, never an address), decision_via (web, link, push, client_attested or null), decision_channel (email, line, web_push or null), decided_by ({ type: "human", role: "owner" } or null) and step_up ({ method: none | session | passkey | code } or null); settings approvals also carry guardrails_etag_at_request. approval.decided carries tier, decision_via, decision_channel, decided_by and step_up_method (the same method as a plain string). Approvals created before 1.10.0 carry null in these fields, except decided_by, which is derived from the decision and reads { type: "human", role: "owner" } whenever the owner decided; every decision made before 1.10.0 was made on the web.

always (allow this permission from now on) is still decided only at /approve/{id}, signed in; a link can only allow once or deny. No behavior change for keys: approval_required stays HTTP 403 with the same shape, human_url still points to /approve/{id}, polling GET /approvals/{id} and approval.decided work as before; only the wording of human_message changes to name the channels the link reached.

The link endpoints (POST /a/{lid}/view, /a/{lid}/decide, /a/{lid}/step-up/code, /a/{lid}/reissue and /a/{lid}/deny-all) and the owner settings endpoints (/notification-channels, /approval-settings and POST /approvals/{id}/links/revoke) are for humans: an API key gets 401 session_required on the settings endpoints, and the link endpoints ignore an API key entirely (a missing or wrong secret answers 404 approval_link_not_found, the same answer as an unknown link).

Error codes on the link endpoints (every one carries fix, English then Chinese, and request_id): 404 approval_link_not_found; 410 approval_link_expired (reissue_available) and approval_link_revoked (reason, for example superseded when the request was decided through another link); 409 approval_link_used (replay of a used link), approval_already_decided (the link is still active but the request was decided on the web or elsewhere) and approval_stale (stale_reason, changed, current_etag; the key still sees result.error.code approval_base_changed); 401 step_up_required (step_up.methods, code_channels, reason) and step_up_failed (attempts_left); 429 step_up_locked (5 wrong codes; the link is revoked); 403 approval_link_wrong_user (the browser is signed in to another account), decision_not_allowed_via_link (always, or once on a channel added less than 24 hours ago) and csrf_failed; 503 approval_links_disabled (web_fallback). The existing 409 approval_expired, approval_key_revoked and approval_permission_denied and 400 invalid_decision keep their codes, and the link endpoints' own rate limit answers 429 rate_limit_exceeded with window: "approval_link" (20 requests a minute per link, 60 per IP, 200 an hour per owner; no X-RateLimit-* headers, because this is not the key quota). Owner settings endpoints: 400 invalid_channel (also for type: "line" until LINE ships), channel_verification_failed (attempts_left), tier_below_floor, invalid_ttl, invalid_phrase; 404 channel_not_found; 409 channel_limit (3 per type, 6 in total), channel_not_verified, no_verified_channel; 410 channel_verification_expired; plus 401 session_required / reauth_required (adding a channel or loosening a setting needs a sign-in within 24 hours) and 403 owner_only / workspace_revoked. The examples in the spec are the messages the server sends.

# replaying a link that was already used
HTTP/1.1 409
{ "error": { "code": "approval_link_used", "message": "This approval link was already used.",
             "fix": "Nothing to do: the request was already handled. See https://mindthread.tw/approve/apr_7Qx… for the result. 中文:這條連結已使用過,請求已處理,結果請見 /approve 頁。",
             "approval": { "id": "apr_7Qx…", "status": "executed", "decision": "once", "decision_via": "link", "decided_at": "…", "result_summary": { "ok": true, "http_status": 200 } },
             "request_id": "req_…" } }

For owners: approving from a notification

Turn it on in Settings → API keys → 在通知裡直接批准 (GET and PATCH /approval-settings, links_enabled; off by default) and switch on approval_links for the channels that should receive links (/notification-channels). 1.10.0 channels: your sign-in email (nch_email_primary), up to two more email addresses each verified with a 6-digit code (three email channels in all, six channels in total), and the web push subscription of the web app (nch_push_primary, registered automatically when you turn notifications on); LINE follows. An email channel verified less than 24 hours ago only receives links that can deny. Each link is valid 10 minutes by default (link_ttl_seconds 300 to 900; test approvals use test_link_ttl_seconds, 60 to 900), single use, and bound to one request. Denying is always allowed; allowing depends on the risk tier of the request:

TierWhat it covers and what a link may do
T0Denying is always allowed from a link (T0), including "deny every pending request of this key".
T1T1: patrol:reply_send, content:publish_now (live) and every test_mode approval. Pure link when the channel is marked link-only; otherwise the same second proof as T2.
T2T2: patrol:cloud_enable, patrol:browser_command, content:schedule. Needs a second proof: a signed-in browser session, or a 6-digit code sent to any verified channel (the same one is fine).
T3T3: accounts:guardrails, accounts:connect, and any permission not listed here. Needs a signed-in session from the last 24 hours, or a 6-digit code sent to a different channel than the one that carried the link (an owner with a single channel can only use the session).

You can only make a tier stricter (tier_overrides), never below its floor. Email is not link-only by default (mailboxes are often read by assistants and scanners), so a T1 link in an email needs the same second proof as T2; a signed-in browser satisfies it silently, and on a phone the notification usually opens a browser that is already signed in, so approving stays one tap. Choose an anti-phishing phrase (anti_phishing_phrase, 2 to 16 characters): every approval notification and the card show it, and a message without your phrase is not from MindThread. Links are always https://mindthread.tw/a/…, never a short URL. The card page never asks for your password, an API key or anything else to type in except a 6-digit code you requested yourself. Never paste the link or a code to an AI assistant: the assistant only needs human_url, which approves nothing on its own. Forwarded a notification by mistake? POST /approvals/{id}/links/revoke (signed in) withdraws every active link of that request, and deleting a channel revokes its links too; the request itself stays open on /approve.

Test mode: approvals from mt_test_ keys send links too (titles start with [測試] and the card shows a test badge), approving runs the request as a mock (persisted: false, nothing reaches Threads, no guardrail revision), the tier is always T1 unless you set test_mode_step_up: "mirror_live", and test_link_ttl_seconds can go down to 60 seconds so an expiry can be rehearsed without waiting 10 minutes. Handing a link to an audit agent: only a link that is already used, expired, superseded or revoked (the card offers a copy button in that state and never on an active link); the agent can then replay POST /a/{lid}/view and /decide and should see state: "used", 409 approval_link_used, 410 approval_link_expired or approval_link_revoked, never a decision. An active link is never shared with anything that is not you.

Rate limit headers

X-RateLimit-Limit: 500          # daily units of this key's pool (also on 429)
X-RateLimit-Remaining: 487      # left in the daily pool
X-RateLimit-Reset: 1758758400   # epoch seconds, next 00:00 Asia/Taipei
X-RateLimit-Policy: 500;w=86400, 30;w=60
X-RateLimit-Scope: user         # who shares the counter: key = live (per key), user = sandbox (per member); not a permission
X-RateLimit-Sandbox: true       # mt_test_ only
Retry-After: 39                 # 429 only

A 429 keeps the daily-pool headers above; the body says which limit was hit (window: daily, minute, global_sandbox or concurrency). A per-minute 429 on a sandbox key, as sent:

HTTP/1.1 429
Retry-After: 39
X-RateLimit-Limit: 500
X-RateLimit-Remaining: 470
X-RateLimit-Reset: 1758758400
X-RateLimit-Policy: 500;w=86400, 30;w=60
X-RateLimit-Scope: user
X-RateLimit-Sandbox: true

{ "error": { "code": "rate_limit_exceeded", "message": "Too many requests (30/minute per member across all mt_test_ keys). Retry after 39 seconds.",
             "window": "minute", "pool": "sandbox", "limit": 30, "used": 30, "reset_at": "2026-09-24T05:06:00.000Z",
             "retry_after_seconds": 39, "key": { "id": "K9f2…" }, "request_id": "req_…" } }

Over MCP the same numbers arrive in the _meta["mindthread.tw/rate_limit"] of each successful tool result that consumed quota (disable_this_key and 401 results have none), and an error result keeps this error object in structuredContent.

Scheduling contract

The time field is publish_at (ISO 8601 with offset) on every scheduling endpoint. schedule_at and scheduled_at are accepted as deprecated aliases. Responses echo publish_at. Missing time intent returns:

{ "error": { "code": "missing_schedule",
             "message": "Provide publish_at (ISO 8601), or publish_now: true, or auto_optimize_time: true …",
             "expected_one_of": ["publish_at", "publish_now", "auto_optimize_time"],
             "aliases": ["schedule_at", "scheduled_at"] } }

Success is { ok: true, request_id, data: … }; errors are always { error: { code, message, request_id, …structured fields } }. Every response sets X-Request-Id (send your own and it is echoed). mt_test_ reads are marked data_source: live_readonly (GET /events with a test key: data_source: sandbox); mt_test_ calls draw from the member's sandbox pool, not from the plan quota (see Auth and keys).

GET /analytics/insights requires account_id; the literal all gives the organization-wide view (scope: organization). Webhook events are validated against one list (see components.schemas.WebhookEvent in the spec); unknown names return invalid_events with valid_events[].

Shortest agent loop

  1. GET /me, GET /accounts → pick account_id
  2. PATCH /accounts/{id}/settings → persona, schedule_times, content_rules (facts are the only numbers the AI may use; the server checks every number; the object is replaced as a whole, so send all of it)
  3. POST /accounts/{id}/material → the platform digests your own pages into who / sells / claims / FAQ / sourced numbers
  4. POST /content/generate → POST /library (queued items publish at schedule_times), or POST /content/generate-and-schedule with publish_at
  5. PUT /patrol/cloud → 1–10 keywords (3–5 works best), review_before_reply:true for the first week → approve from GET /patrol/cloud/pending
  6. GET /patrol/cloud/stats, GET /analytics/insights, GET /events?since=… or a webhook
  7. Patrol finds little? GET /patrol/cloud/keyword-suggestions?account_id=… → diagnosis plus test-searched keyword suggestions (once per account per 24 h) → apply the ones you want with PUT /patrol/cloud

The full playbook is embedded in the spec under x-agent-playbook.

Account settings in bulk

The same server code as the web app's bulk settings sheet (/accounts/bulk, its CSV upload and its one-sentence edit): the same column rules, plan limits, ownership checks, change records and 24-hour revert. The column reference is the rules page mindthread.tw/accounts/bulk/rules; GET /accounts/settings also returns data.columns (type, allowed values, which columns are sensitive). Up to 100 accounts per request. Nothing is written without a preview: preview returns apply_changes (with base, the values you saw) and a preview_id valid for 15 minutes; apply checks the preview_id when you send it, and connections from the Claude and ChatGPT directories must send it.

# 1. read every account (status chips: expired authorization, repeated publish failures, skipped refill)
curl -s https://mindthread.tw/api/v1/accounts/settings \
  -H "Authorization: Bearer $MT_KEY"

# 2. preview (writes nothing): change two accounts' posting times and one reply limit
curl -s https://mindthread.tw/api/v1/accounts/settings/preview \
  -H "Authorization: Bearer $MT_KEY" -H "Content-Type: application/json" \
  -d '{"changes":[{"account_id":"a1","set":{"scheduleTimes":"09:30,21:00","maxRepliesPerPost":5}},
                 {"account_id":"a2","set":{"scheduleTimes":"09:30,21:00"}}]}'
# → data.rows[].changes (from → to), data.rows[].error, data.apply_changes, data.preview_id

# 3. apply exactly what you previewed; the Idempotency-Key makes a retry safe
curl -s https://mindthread.tw/api/v1/accounts/settings/apply \
  -H "Authorization: Bearer $MT_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: schedule-2026-10-11-01" \
  -d '{"changes": <data.apply_changes>, "preview_id": "<data.preview_id>", "reason": "morning and night"}'
# → data.batch_id, data.results[] (ok, changed, error per account), data.revert_deadline
# the same key and body again → the same body, "idempotent_replay": true, header Idempotent-Replayed: true

# 4. run mode, auto-posting and review before publish are sensitive
curl -s https://mindthread.tw/api/v1/accounts/settings/apply \
  -H "Authorization: Bearer $MT_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: pause-2026-10-11-01" \
  -d '{"changes":[{"account_id":"a1","set":{"mode":"paused"}}],"confirm_sensitive":true}'
# without confirm_sensitive → 400 confirm_sensitive_required; with it the permission is accounts:guardrails

# 5. CSV in and out (the same file as the web download and upload; UTF-8)
curl -s https://mindthread.tw/api/v1/accounts/settings/export.csv -H "Authorization: Bearer $MT_KEY" -o settings.csv
curl -s https://mindthread.tw/api/v1/accounts/settings/import \
  -H "Authorization: Bearer $MT_KEY" -F "file=@settings.csv"
# → same answer as preview, plus row_errors (line, account_id, reasons) and rows_read

# 6. results and undo (24 hours; accounts changed again since are skipped and listed)
curl -s https://mindthread.tw/api/v1/accounts/settings/batches/<batch_id> -H "Authorization: Bearer $MT_KEY"
curl -s -X POST https://mindthread.tw/api/v1/accounts/settings/batches/<batch_id>/revert -H "Authorization: Bearer $MT_KEY"

Permissions: reading, export, preview and import need read; apply and revert need content:write, and apply with confirm_sensitive:true needs accounts:guardrails (set to ask, the owner confirms first). Per key per minute: listing and batches 30, preview 20, import, export, apply and revert 10, on top of the usual 120 per minute and the daily quota; CSV uploads share the web app's 30 a day. Apply errors: idempotency_key_required, confirm_sensitive_required, preview_required / preview_mismatch / preview_expired, 422 idempotency_key_reused, 409 idempotency_in_progress, 409 idempotency_result_unknown (never rerun automatically: check GET /accounts/settings/batches first). Accounts that are not yours come back as row errors, never as a write. mt_test_ keys validate only (persisted:false). MCP tools: list_account_settings, preview_account_settings, apply_account_settings, revert_account_settings_batch, get_account_settings_batch.

MCP

JSON-RPC 2.0 over POST, stateless, no SSE. Same Bearer key (on /api/mcp the key may also be sent as X-API-Key or X-Auth-Token). initialize returns the daily-loop instructions; with a key tools/list returns 44 tools that mirror the REST surface (each tool calls its own v1 endpoint, so quotas and ownership rules are identical), each with title, annotations and securitySchemes. Without a key you see the directory set of 40 tools; initialize, tools/list, get_playbook and list_formulas work, and any other tool answers HTTP 401 with WWW-Authenticate pointing to https://mindthread.tw/.well-known/oauth-protected-resource. Tool results carry the REST JSON as text in content[].

curl -s https://mindthread.tw/api/mcp -H "Authorization: Bearer mt_test_…" \
  -H "content-type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Connect with OAuth: Claude, ChatGPT, Perplexity

From 1.14.0 an AI assistant can connect by signing in instead of pasting a key. It reads https://mindthread.tw/.well-known/oauth-protected-resource, finds the authorization server at https://mindthread.tw/.well-known/oauth-authorization-server, registers itself (dynamic client registration or a client ID metadata document) and sends you to the MindThread consent page. You sign in, pick a permission set (Careful, Standard or Full) and select Allow. Actions marked “ask me” still wait for your confirmation in MindThread; turning on cloud patrol and remote-controlling browser patrol are never granted over OAuth.

The assistant gets an access token that expires after 1 hour and renews itself (the renewal credential lasts at most 30 days); it never sees your MindThread or Threads password. Revoke a connection at any time in Settings → Connected AI assistants. If a connection lacks a permission a tool needs and signing in again can grant it, the server answers 403 with WWW-Authenticate: Bearer error="insufficient_scope", scope="…" (ChatGPT: the same challenge in the tool result _meta["mcp/www_authenticate"]), so the assistant can ask you to approve it (1.15.0). Server URL everywhere: https://mindthread.tw/api/mcp, no trailing slash.

Claude

  1. In Claude, open Settings → Connectors → Add custom connector.
  2. Name: MindThread. Remote MCP server URL: https://mindthread.tw/api/mcp. Leave the OAuth client ID and secret empty.
  3. Select Connect. Claude opens the MindThread consent page: sign in, choose a permission set and select Allow.
  4. In a chat, turn MindThread on from the tools menu and ask “list my MindThread accounts”.

ChatGPT

Once MindThread is listed in the ChatGPT plugin directory, install it from there. Until then, add it as a custom MCP server (OpenAI's current steps: Connect and test your plugin; account and workspace policies decide whether custom MCP servers are available to you):

  1. Go to chatgpt.com/plugins, select the plus button, then Add custom MCP server.
  2. Enter a name (MindThread) and a description. Under Connection choose the public endpoint and enter https://mindthread.tw/api/mcp.
  3. For authentication choose OAuth (ChatGPT finds the MindThread sign-in from the server URL; no client ID or secret is needed). Review the risk warning and select I understand and want to continue.
  4. Select Create as a plugin, review the tools ChatGPT discovered, and when it asks you to sign in, sign in to MindThread, choose a permission set and select Allow.

OpenAI verifies the domain with https://mindthread.tw/.well-known/openai-apps-challenge. It answers 404 until a verification token is configured for a directory submission; that is expected.

Perplexity

Follow Connect from Perplexity below and choose OAuth instead of API Key under Authentication; Perplexity discovers the endpoints from the server URL and opens the MindThread consent page. If Perplexity asks for a client ID and secret, use the API Key option instead.

用 OAuth 連接:Claude、ChatGPT、Perplexity

1.14.0 起,AI 助理可以用登入的方式連接,不用貼金鑰。它會讀 https://mindthread.tw/.well-known/oauth-protected-resource,找到授權伺服器,自行註冊,再把你帶到 MindThread 的同意頁。你登入、選權限組合(保守、標準、全開),按「允許」就連上了。標示「先問我」的動作仍要你在 MindThread 確認;開啟雲端海巡與遙控瀏覽器海巡永遠不經由 OAuth 開放。

AI 助理拿到的是 1 小時到期、會自動換新的通行碼(換新用的憑證最長 30 天),看不到你的 MindThread 或 Threads 密碼。到「設定 → 已連接的 AI 助理」可以隨時撤銷。連線缺某個權限、而重新授權拿得到時,伺服器回 403 並在 WWW-Authenticate 標明缺的權限(ChatGPT 則放在工具結果的 _meta["mcp/www_authenticate"]),AI 助理會請你再同意一次(1.15.0)。伺服器網址一律是 https://mindthread.tw/api/mcp,結尾不要加斜線。

  1. Claude:「Settings → Connectors → Add custom connector」,名稱填 MindThread,伺服器網址填 https://mindthread.tw/api/mcp,OAuth 的 client ID 與 secret 留空。按 Connect,在 MindThread 同意頁登入、選權限組合、按「允許」。之後在對話的工具選單打開 MindThread,問「列出我的 MindThread 帳號」。
  2. ChatGPT:MindThread 上架 ChatGPT 外掛目錄後,直接從目錄安裝。上架前可以自己加成自訂 MCP 伺服器(OpenAI 現行步驟見 Connect and test your plugin;能不能加看你的帳號與工作區設定):到 chatgpt.com/plugins 按加號,選「Add custom MCP server」,填名稱與說明,Connection 選公開端點並填 https://mindthread.tw/api/mcp,驗證方式選 OAuth(不用填 client ID 與 secret),看完風險提醒按「I understand and want to continue」,再按「Create as a plugin」,確認找到的工具後依提示登入 MindThread、選權限組合並按「允許」。網域驗證檔 /.well-known/openai-apps-challenge 在送審設定好驗證碼之前會回 404,這是正常的。
  3. Perplexity:照下面「在 Perplexity 連接 MindThread」的步驟,鑑權改選 OAuth;Perplexity 會從伺服器網址找到授權端點並開啟 MindThread 同意頁。如果它要你填 client ID 與 secret,就改用 API Key 的方式。

Connect from Perplexity

Perplexity Pro, Max and Enterprise can add MindThread as a custom remote connector. With an API key (for OAuth, choose OAuth in step 4 and skip step 1):

  1. In MindThread, open Settings → API keys and create a key (it starts with mt_live_ and is shown once). The standard permission set asks you before anything is published now, a drafted reply is sent, patrol is turned on, an account is connected or a guardrail is loosened.
  2. In Perplexity, open Settings → Connectors → Custom connector and choose Remote.
  3. Name: MindThread. Server URL: https://mindthread.tw/api/mcp (no trailing slash).
  4. Authentication: API Key, and paste the key.
  5. Ask Perplexity something like “list my MindThread accounts” to check the connection.

To stop the connection, disable the key in Settings → API keys (the next call is rejected) or ask the assistant to run disable_this_key.

在 Perplexity 連接 MindThread

  1. 在 MindThread 的「設定 → API 金鑰」建立一把金鑰(以 mt_live_ 開頭,只顯示一次)。預設的標準權限組合,在立刻發文、送出回覆草稿、啟用雲端海巡、連接帳號、放寬內容護欄之前都會先問你。
  2. 在 Perplexity 開啟 Settings → Connectors → Custom connector,選 Remote。
  3. 名稱填 MindThread,伺服器網址填 https://mindthread.tw/api/mcp(結尾不要加斜線)。
  4. 鑑權選 API Key,貼上金鑰。
  5. 問 Perplexity「列出我的 MindThread 帳號」確認連上。

要停用這個連線,到「設定 → API 金鑰」停用那把金鑰(下一次呼叫就會被拒),或請 AI 助理執行 disable_this_key。

Interactive reference

Changelog

VersionChange
1.16.0 · 2026-10-11Account settings in bulk: the web app's bulk settings sheet as an API (examples). GET /accounts/settings (every connected Threads account, its settings and status chips; limit and cursor), GET /accounts/settings/export.csv, POST /accounts/settings/preview (changes or csv; writes nothing; apply_changes and a 15-minute preview_id), POST /accounts/settings/import (multipart CSV, the web upload's parser), POST /accounts/settings/apply (Idempotency-Key required: a retry with the same key and body returns the first result with idempotent_replay:true; confirm_sensitive:true for run mode, auto-posting and review before publish, which also makes the permission accounts:guardrails), GET /accounts/settings/batches, GET /accounts/settings/batches/{batch_id} and POST /accounts/settings/batches/{batch_id}/revert (24 hours). Directory connections (Claude, ChatGPT) must send the preview_id and cannot change videoAutopilot. Batches record whether they came from the website, the API or MCP. Per-route minute limits now apply to routes that count toward the daily quota as well. MCP tools list_account_settings, preview_account_settings, apply_account_settings, revert_account_settings_batch, get_account_settings_batch (44 tools).
1.15.0 · 2026-10-04MCP directory stage 3: when an OAuth connection lacks a permission that signing in again can grant, the answer is 403 with WWW-Authenticate: Bearer error="insufficient_scope", scope="…" (REST and /api/mcp), so Claude can ask for that permission; ChatGPT gets the same challenge in the tool result _meta["mcp/www_authenticate"]. API keys behave as before. New /.well-known/openai-apps-challenge for OpenAI domain verification (404 until set).
1.14.0 · 2026-10-04OAuth 2.1 for MCP (stage 2): connect Claude, ChatGPT and other assistants with sign-in and consent instead of pasting a key. Discovery at /.well-known/oauth-authorization-server; PKCE S256 required; access tokens last 1 hour, refresh tokens rotate. Cloud patrol activation and browser patrol commands are never granted to OAuth connections. Manage connections in Settings, Connected AI assistants.
1.13.0 · 2026-10-04MCP for AI assistant directories, stage 1 (OAuth follows). Without a valid key, initialize, tools/list, get_playbook and list_formulas still work and any other tool now answers HTTP 401 with WWW-Authenticate: Bearer … resource_metadata="https://mindthread.tw/.well-known/oauth-protected-resource" and a JSON-RPC error (-32001, data.error unauthorized or key_disabled) instead of a 200 tool result. On /api/mcp the key may also be sent as X-API-Key or X-Auth-Token. Every tool has title, annotations and securitySchemes. New tools bulk_archive_library, bulk_delete_library and bulk_queue_library split bulk_library by effect. Without a key you see the directory set (35 tools, no browser patrol tools, no bulk_library, short instructions); ?toolset=full or ?toolset=directory picks one. A browser Origin outside the allowed list gets 403; unsupported MCP-Protocol-Version values get 400. New GET /.well-known/oauth-protected-resource (RFC 9728). New section: Connect from Perplexity (39 tools).
1.12.0 · 2026-10-03Approval reminders and the daily digest. POST /approvals/{id}/remind (API key, only approvals this key created; no daily quota) asks MindThread to remind the owner again by bell, web push and email, all pointing to the web page /approve/{id}; no new one-time link. At least 30 minutes after the last notification, at least 10 minutes before expiry, at most 3 reminders per approval (remind_too_soon with Retry-After, remind_limit_reached, approval_not_pending, approval_expiring). MindThread also reminds once automatically after 4 hours. Owners whose confirmation emails were skipped by the 10-minute cooldown get one digest at 8 pm Taipei time; email_digest in approval settings (default on) turns it off. 36 tools.
1.11.0 · 2026-10-03Broadcasts (Business plan). POST /broadcasts publishes the same plain text (1-500 characters) to 2-20 of your own Threads accounts (account_ids, or preset_id from GET /broadcast-presets). With keep_best (default true) MindThread reads views about 45-50 minutes after the last post and keeps only the most-viewed post; the others are deleted from Threads (cannot be undone) and the owner is notified. GET /broadcasts, GET /broadcasts/{id} (members, snapshots, winner, deletions, anything to delete by hand) and POST /broadcasts/{id}/cancel (keep every post). GET /me adds data.features.broadcast { available, reason, min_accounts, max_accounts, window_min, max_concurrent }. Permission: POST /broadcasts is content:publish_now, cancel is content:write, reads are read. Only a key created by the account owner can broadcast (403 owner_key_required). Every account must be yours, connected and pass its own content rules before anything is posted. idempotency_key makes a retry return the first result (409 broadcast_in_progress while running, 409 broadcast_result_unknown: do not resend). MCP tool count unchanged (36 tools).
1.10.0 · 2026-09-28In-channel approval, Phase A. From 1.10.0 the owner can decide from the notification itself: a one-time link (https://mindthread.tw/a/{lid}, with the secret in the URL fragment, 10 minutes by default, single use, bound to the approval, the owner and the allowed decisions) is sent to the owner's verified channels (email and web push in 1.10.0; LINE follows) once the owner has turned approval links on in Settings. API keys never receive, see or need that link: keep handing human_url and human_message to the human as is, never ask the human to paste the link or any code to you, and never open it yourself. New read-only fields on Approval: tier (t1, t2 or t3), channels_notified (type and status per channel, never an address), decision_via (web, link, push, client_attested or null), decision_channel (email, line, web_push or null), decided_by ({ type: "human", role: "owner" } or null) and step_up ({ method: none | session | passkey | code } or null); settings approvals also carry guardrails_etag_at_request. approval.decided carries tier, decision_via, decision_channel, decided_by and step_up_method (the same method as a plain string). Approvals created before 1.10.0 carry null in these fields, except decided_by, which is derived from the decision and reads { type: "human", role: "owner" } whenever the owner decided; every decision made before 1.10.0 was made on the web. always (allow this permission from now on) is still decided only at /approve/{id}, signed in; a link can only allow once or deny. No behavior change for keys: approval_required stays HTTP 403 with the same shape, human_url still points to /approve/{id}, polling GET /approvals/{id} and approval.decided work as before; only the wording of human_message changes to name the channels the link reached. The link endpoints (POST /a/{lid}/view, /a/{lid}/decide, /a/{lid}/step-up/code, /a/{lid}/reissue and /a/{lid}/deny-all) and the owner settings endpoints (/notification-channels, /approval-settings and POST /approvals/{id}/links/revoke) are for humans: an API key gets 401 session_required on the settings endpoints, and the link endpoints ignore an API key entirely (a missing or wrong secret answers 404 approval_link_not_found, the same answer as an unknown link). MCP tool count unchanged (36 tools). Fixed 2026-09-29: approval ids are always apr_ plus 20 letters and digits (some earlier ids had 19); the link endpoints answer Cache-Control: no-store; a view of a link that is no longer active carries csrf null and sets no cookie, since nothing can be decided with it; an MCP tool called without a required argument that belongs in the path (for example get_approval without id) answers invalid_params instead of requesting a path with undefined in it; GuardrailRevision documents decision_via and step_up_method, and ChannelsNotifiedItem explains what sent means.
1.9.1 · 2026-09-28Publishing claims and unknown results: a scheduled post or queued library item is publishing while it is being sent, and changing it then answers 409 publishing_in_progress (PATCH /library/{id}, POST /library/{id}/approve, per item in POST /library/bulk, and DELETE /schedules/{id}, which used to answer 400 cannot_cancel). PATCH /library/{id} writes in a transaction and answers 409 status_changed when the status changed meanwhile. An item whose send was cut off is moved to draft with held_reason: publish_result_unknown and reclaimed: true (GET /library, GET /schedules) and is never resent automatically; approving or queueing it through the API answers 409 publish_result_unknown, also after archiving it, and only the owner re-queues it in the web app after checking Threads. Account availability: new error code 409 account_unavailable on POST /content/publish (publish_now), POST /content/publish-thread and POST /content/generate-and-schedule (publish_now): the account is blocked by the platform or its token is invalid or revoked, so nothing is published through it. token_error clears the moment the owner reconnects the account in the web app; blocked clears when the platform block clears (re-checked every 6 hours). Scheduling with publish_at is still accepted, but the scheduled post fails at publish time if the account is still unavailable. The MCP tool publish returns the same code as a tool result with isError:true. MCP tool count unchanged (36 tools).
1.9.0 · 2026-09-25Shared policy, minimal version (P2-5): GET /accounts/{id}/settings returns data.guardrails (etag, last_change, pending, enforced_on, not_enforced_on); PATCH takes if_match (412 guardrails_changed), reason and dry_run (needs only read); every guardrail change is a revision in the new GET /accounts/{id}/guardrails/history, and the owner can undo one with POST /accounts/{id}/guardrails/rollback (web app session only; 409 rollback_conflict, 404 revision_not_found). Loosening is decided by one comparison table; writes are transactional; 422 content_rules_violated carries guardrails_etag; loosening without the owner pressing allow sends an in-app notification; content_rules can no longer be changed by the web app writing to the database directly. New MCP tool list_guardrail_changes (36 tools). Webhook bodies carry id, the same id GET /events gives the event, for dedupe. Docs for Grok r5 P2-OBS-FOLLOW (events across live and sandbox, webhook dedupe) and P2-GOV-DOCS (key and member boundaries, suite and disposable keys).
1.8.3 · 2026-09-25Content rules are checked on POST /content/schedule, /content/generate-and-schedule, /content/publish-thread and /content/generate-thread (422 content_rules_violated with enforce:block, rule_violations with warn; the generating paths regenerate like /content/generate and report model_calls; test keys get the same 422, and their mock text is checked only against banned phrases, calls to action and hashtags). Posts scheduled through the API are checked again right before they publish: with enforce:block a violating post is held as a draft (held_reason, rule_violations on GET /schedules?status=draft), the owner is notified and post.failed is emitted. Matching ignores invisible characters, compares banned phrases after NFKC, counts numbers such as 100000/month, and checks topic_tag against banned phrases; POST /content/publish now returns rule_violations on live keys too. Behavior change: scheduling or threading text that violates an enforce:block account used to succeed and now answers 422. Behavior change: PATCH /accounts/{id}/settings now really replaces content_rules as a whole on live keys (it used to merge key by key, so rules left out of the body stayed). Settings approvals show every touched field as current and proposed value (a space inside a number in a fact now counts as loosening and is shown), and fail with approval_base_changed (decide answers 409) when one of those fields changed before the request ran. A scheduled video from POST /content/publish now publishes as a video.
1.8.2 · 2026-09-24New GET /keys/current/usage: the calling key's own usage (summary { calls, units, errors, by_endpoint[] } for 1 to 30 days, recent[] { request_id, endpoint, status, units, at } with cursor paging); no permission, no daily quota, 10 requests per minute per key on this endpoint; MCP tool get_my_usage (35 tools). Every request after the key is identified is now recorded, including 400, 403 and 429 refusals (429 and refusals before the quota check about once per key per minute on each server). Test keys: sandbox writes that publish now or approve a pending cloud reply record a real sandbox event (post.published or patrol.reply with test_mode: true, key_id, persisted: false, request_id); GET /events with a test key returns only that key's sandbox events (its approval.decided included) instead of mock data, marked data_source: sandbox. Live keys and webhooks never see sandbox events. Live keys: publishing now through POST /content/publish or POST /content/generate-and-schedule now also emits post.published / post.failed (source api_publish / api_generate_and_schedule; dedupe by post_id). GET /events covers the last 7 days, its latest_at moves past events you did not ask for, and it answers 503 events_index_building while its index is being built. Unhandled server errors answer 500 server_error in the standard envelope; an X-Request-Id starting with apr_exec_ is replaced. Grok r4 tickets P2-OBS and P2-4-E2E-LIVE addressed.
1.8.1 · 2026-09-24New PATCH /keys/{id} for the owner in the web app (not for API keys, no MCP tool): change an existing key's permissions (preset or single permissions), label, agent_name and agent_url in place. Widening needs a sign-in within 24 hours; team members change only keys they created, to standard or stricter; setting a permission to deny cancels that key's pending approvals for it. insufficient_scope.fix now names the permission and points the owner to Settings instead of asking for a new key. POST /keys/current/disable answers cancelled_approvals and cancelled_approval_ids. Cancelled approvals carry cancel_reason (GET /approvals/{id}, approval.decided; other keys of the member see it too). GET /me data.key.agent_name / agent_url show what the owner entered. /keys errors from the web app session use the standard error envelope. GET /keys items carry scopes_updated_at; PATCH takes it back as if_scopes_updated_at and answers 409 key_changed when the permissions changed meanwhile. Creating a live key wider than standard needs a sign-in within 24 hours (401 reauth_required). Labels and assistant names refuse invisible control characters (400 invalid_label, invalid_agent_name; POST /keys no longer cuts a label short). An approval whose permission is deny by now can no longer be approved (409 approval_permission_denied). Grok r3 items P2-2b and P2-4-E2E addressed.
1.8.0 · 2026-09-24Permissions are enforced for every key except legacy keys (scopes_enforced: true). deny answers 403 insufficient_scope (required, mode, have, fix, header X-Required-Scope); ask validates the request, creates an approval, notifies the owner and answers 403 approval_required (approval_id, approval { … human_url, human_message }); a route missing from the permission table answers 403 scope_unmapped. New permission accounts:guardrails for settings changes that loosen a guardrail (1.7.0 keys inherit the stricter of their content:publish_now and patrol:reply_send). New keys: live = standard, test = open (keys created in 1.7.0 keep what they had; keys from before 1.7.0 stay legacy and allow everything). New GET /approvals/{id} for keys, GET /approvals and POST /approvals/{id}/decide for the web app; the owner's decision runs the request server-side and emits the new event approval.decided (actor type human or system). Disabling or revoking a key cancels its pending approvals. GET /me needs no permission. 429 approval_flood with window: "approvals". Each operation in the spec carries x-scope; the table is x-scopes. MCP tool get_approval (34 tools). POST /experiments and any publish_at less than 30 minutes away count as content:publish_now; POST /library without status: "draft" and new text on a queued item count as content:schedule; adding a fact counts as loosening. Experiments now check content rules before publishing (enforce block: none of the variants is published). The approval page shows the full text and the images; library approvals run only if the items are unchanged. New codes: approval_already_executed, approval_in_progress, approval_denied, approval_not_supported, approval_unsupported_field, approval_payload_too_large, invalid_idempotency_key; result codes approval_payload_changed and execution_unknown. approval.decided carries a result summary only; /content/schedule refuses past times for test keys too.
1.7.0 · 2026-09-24Every authenticated response sets X-RateLimit-Limit / Remaining / Reset / Policy / Scope (sandbox adds X-RateLimit-Sandbox); 429 is structured (window, pool, limit, used, reset_at, retry_after_seconds, Retry-After). New per-minute limits: 120 per live key, 30 per member for sandbox keys (there was no per-minute limit before). mt_test_ keys move to a separate sandbox pool (500 units/day per member, not charged to the plan; before, each test key drew from the plan quota separately). Test keys no longer call the model (material build, judge_claims) and POST /accounts/connect-link returns a mock link on test keys. GET /me returns key { id, label, prefix, last4, kind, status, scopes, legacy, scopes_enforced, agent_name, agent_url, created_at, last_used_at }, actor { type, key_id, label }, actor_id, api.pool, api.per_minute, api.reset_at; write responses carry a top-level actor { type: "key", key_id }. Kill switch POST /keys/current/disable (MCP tool disable_this_key, 33 tools). A disabled or revoked key now answers 401 key_disabled with key_id and disabled_at (the code was unauthorized before; the HTTP status is unchanged). An API key calling /keys or /usage gets 401 session_required. The 429 field is named window. MCP serverInfo.version now equals the API version. MCP tool results carry the quota in _meta and keep the error object in structuredContent. GET /experiments, GET /experiments/{id}, POST /experiments/{id}/cancel, DELETE /schedules/{id} and DELETE /webhooks/{id} now count toward the quota like every other call. Scopes are reported, not enforced yet.
1.6.0 · 2026-09-23Every response carries request_id (body) and X-Request-Id (header). Webhook events patrol.reply and patrol.pending can now be registered (the list is generated from the server constant); invalid_events is structured. GET /analytics/insights requires account_id and supports the literal all with scope: organization. mt_test_ reads are marked data_source: live_readonly. GET /formulas uses the { ok, data } envelope. MCP analytics_insights gained period; schedule tools document the aliases; read structuredContent first. Mock char_count now equals the text length. DELETE /webhooks/{id} documented.
1.5.0 · 2026-09-23mt_test_ keys no longer touch live state on material and webhook endpoints; every sandboxed write returns persisted:false. publish_at is the canonical time field on both scheduling endpoints, aliases accepted, missing_schedule lists expected_one_of. This page replaces the app shell at /developers. Keyword limit documented as 1–10 everywhere.
1.4.0Content rules enforced in code (facts, causal-claim judge), material digest, review-before-reply for cloud patrol, library bulk operations, GET /events.
1.2.0MCP server, connect links, browser patrol commands, patrol.reply webhook.