Skip to content

Agent API

Build, configure, and inspect your Anychat agents programmatically.


The Agent API is the HTTP interface for managing agents on the Anychat platform — the Definition sections (identity, personality, guardrails, goals, playbooks, Message Templates) and capability configuration exposed as REST resources you can drive from your own code or from the anychat-cli.

The Agent API is the management plane: creating agents, editing their behavior, and browsing their conversation history. For the runtime API that delivers messages to and from your agents, see the Messaging API.

Base URL

https://api.anychat.ai/v1

All paths in this document are relative to that root.

Authentication

Attach a bearer token in the Authorization header on every request:

Authorization: Bearer <token>

Two kinds of bearer token work:

  • API keys — strings starting sk-any-…, issued from the console's Workspace · API keys page or via the API keys endpoints below. A key belongs to one org and carries the scopes its issuer chose when minting it.
  • User session tokens — the ID token of a signed-in console user. Scopes are derived from the user's role in the org.
  • Emulator grants — the credential inside a standalone emulator link (v2.…) or minted for a sitting (v1.…). A grant is a viewer of one agent's personas and nothing else: it may list, read, create and reset that agent's personas, run sandbox turns as them, and call GET /emulator/session to learn which org and agent it is scoped to. Every other route, and every other agent, answers 403 auth.emulator_grant.forbidden. This is how the standalone emulator page talks to the API without an account.

Treat any of them as a secret.

An optional X-Anychat-Agent header names an automated agent acting on the caller's behalf, for example mcp (the @anychat-ai/mcp server) or copilot (the console's Anychat Copilot). The value is a short token matching [a-z0-9._-]{1,32}; anything else is ignored. It is recorded on the API's access log for attribution and has no effect on authentication or authorization.

Authorization model

Every management endpoint is scoped to one org. A token can only manage the org it was issued for; requests that name a different org in the URL receive a 404 (the existence of foreign orgs is not revealed).

Within your org, every endpoint enforces two orthogonal checks — a role floor and a scope. Both are listed per route in the tables below.

Role floors gate who the calling user is within the org:

  • viewer — read endpoints (GET).
  • editor — write endpoints (POST, PUT, PATCH, DELETE, import, apply).
  • admin — org administration (API keys, webhooks, org settings).

Scopes gate what a token can do:

Scope Covers
agents:read / agents:write Agents and their configuration sub-resources, including lint and flow generation
people:read / people:write / people:export The per-agent People CRM, including sending an Outreach to a Person; people:export gates bulk export and is implied by people:write
knowledge:read / knowledge:write Knowledge sources: reading them, and adding, refreshing and removing them
messages:read Messages, sessions, users, logs, turn records, and the activity feed
catalogs:read The system catalogs
templates:apply Importing catalog templates onto an agent
webhooks:manage Org webhooks
org:admin Org settings and API-key management
integrations:ajo Only the Adobe Journey Optimizer send and status routes (/orgs/{org}/agents/{agent}/integrations/ajo/…), which also accept people:write. A key holding only this scope gets 403 (auth.scope_missing) everywhere else, and the scope never counts toward any other route

Scope rules:

  • A write scope implies its read scope (agents:write ⊃ agents:read, people:write ⊃ people:read) — grant only the broadest scope you intend to use.
  • catalogs:read is granted to every token by default.
  • User session tokens hold the scope set implied by the user's role: viewer holds the read scopes; editor adds the write scopes and templates:apply; admin and owner add webhooks:manage and org:admin.
  • A request whose token lacks the route's scope receives 403 (auth.scope_missing).

Token introspection

GET /me describes the token making the call: who it belongs to, the org it is bound to and the caller's role there, and the effective scope set. It is the first call an SDK, the @anychat-ai/mcp server, or anychat whoami makes to resolve the org for every later request. There is no role floor and no scope requirement; any authenticated token can introspect itself.

Method Path Role Scope
GET /me — —
interface Me {
  caller: { kind: 'user'; id: string };   // user id, or the API key's id for sk-any-… tokens
  org: {
    id: string;
    name?: string;          // omitted if the org lookup fails
    role: 'viewer' | 'editor' | 'admin' | 'owner';
  };
  isSysop: boolean;
  scopes: string[];         // effective scope set for this token
  rateLimit: null;          // reserved; always null today
}

For an API key, org.role is the role implied by the key's scopes and scopes is the key's expanded scope set (write scopes expanded to include their read scopes). For a user session token, scopes is the set the user's role implies.

{
  "caller": { "kind": "user", "id": "68a1f0c2e4b0a1d2c3e4f567" },
  "org": { "id": "acme", "name": "Acme Health", "role": "editor" },
  "isSysop": false,
  "scopes": ["agents:write", "agents:read", "people:write", "people:read", "people:export", "knowledge:read", "messages:read", "catalogs:read", "templates:apply"],
  "rateLimit": null
}

Identifiers

Every resource carries a stable string id. Several flavors show up in this API:

Kind Format Example
Org id URL-safe slug acme
Agent id URL-safe slug, minted at create time, immutable agent-acme-bot-pj7r2qx4
Catalog template / tool id Qualified @anychat/... id @anychat/personality-template.professional, @anychat/tool.custom-http-webhook
Per-agent playbook / goal / template id Short slug sales-discovery, book-call

Qualified ids contain a / and must be URL-encoded when they appear as a path parameter. For example, @anychat/tool.custom-http-webhook becomes %40anychat%2Ftool.custom-http-webhook.

Responses and errors

  • List endpoints return an envelope with a data array:

    { "data": [ ... ] }
    
    Long lists are paginated with an opaque nextPageToken; pass it back as ?pageToken=... to fetch the next page.

  • Single-item endpoints return the object directly, with no envelope.

  • Errors are JSON with a numeric statusCode and a human-readable message:

    { "statusCode": 400, "message": "`text` (string) is required" }
    

  • Newer endpoints (noted in their sections below) return problem details instead: an application/problem+json body with the HTTP status, a stable machine-readable code such as request.invalid or resource.not_found, a human-readable title, an optional detail, and, for validation failures, an errors array whose entries name the offending field path:

    {
      "status": 422,
      "code": "request.invalid",
      "title": "Validation failed",
      "errors": [{ "field": "fields[0].key", "message": "must match ^[a-zA-Z][a-zA-Z0-9_]*$" }]
    }
    
    Authorization failures on every endpoint use auth.forbidden (role floor not met) and auth.scope_missing (token lacks the scope).

A 422 relayed from an internal call to the agent service (a schema rejection this API's own sanitizers didn't already catch) carries the same errors[] shape, mapped from whichever form the agent emitted (errors: [{ field, message }] or a legacy propertyErrors: [{ property, message }]); title folds the per-field detail in so it stays informative even when the upstream body carried no message.

  • Removing something that is still in use is refused. A write to the agent document that would leave one part of it naming something that no longer exists returns 409 agent.integrity_conflict. That covers:
  • an Outreach's opener or follow-up Message Template, its owned Playbook, or its Goal
  • a Playbook flow step's template (say), Playbook (call, handoff), integration (action) or People field (bind)
  • a Playbook's uses integrations and messageTemplateRefs
  • a [[template: …]] in a template, Playbook or Fact
  • a Fact's scope Playbooks, a Goal assignment, and a Thing's collection
  • a template's [[things: …]] / [[collection: …]] markers and the People fields its slots read
  • a media item named as image:<id> in a template, Playbook or Fact, or used as a Thing's media (refused on the media DELETE)
  • a people segment a draft Campaign uses as its audience (refused on the segment DELETE; staged and later Campaigns already hold their recipients and don't block)

The refusal applies whichever route removes the item: deleting the template, Playbook or integration, a whole-list PUT (Goals, Facts, People fields) that drops it, or an …/import or template apply. Only a reference the write would newly break counts, so an agent that already carries a dangling reference is not blocked from unrelated edits. detail names what uses the item; errors[] lists each reference with its lint code and document path:

{
  "status": 409,
  "code": "agent.integrity_conflict",
  "title": "Something else on this agent still uses what this change removes",
  "detail": "This change would break a reference; change or remove what uses it first. Playbook \"Wrap up\" flow: say references Message \"booked\", which does not exist on this agent.",
  "errors": [
    { "field": "playbooks/0/flow/steps/0/say", "code": "flow.template.unknown", "message": "…" }
  ]
}

  • A successful DELETE returns 204 No Content unless its section says otherwise. All other writes return the updated resource.

Rate limits

Routes that send to a person are rate limited per API key: each sk-any-… key may call them 600 times a minute sustained, in bursts of up to 100. Today that is the named send, POST …/outreach/{outreachId}/sends. The Adobe Journey Optimizer send action has a separate, larger budget per key: 12,000 calls a minute (200 a second) in bursts of up to 1,000, so a journey releasing a whole audience fits. Console sign-ins (Auth0 tokens) and emulator grants are not limited, and no other route is.

The limit is a token bucket: a full bucket holds 100 sends and refills at 10 a second. Responses on a limited route carry:

Header Meaning
RateLimit-Limit The burst: how many calls a full bucket admits (100)
RateLimit-Remaining Whole calls left in the bucket after this one
RateLimit-Reset Seconds until the bucket is full again

Over the limit, the call is refused before anything happens, with 429 request.rate_limited and a Retry-After header in whole seconds (at least 1). Wait that long and send the same request again; with an idempotencyKey a retry is always safe.

HTTP/1.1 429 Too Many Requests
Retry-After: 1
RateLimit-Limit: 100
RateLimit-Remaining: 0
Content-Type: application/problem+json

{
  "status": 429,
  "code": "request.rate_limited",
  "title": "Too many requests for this API key",
  "detail": "This API key is limited to 600 requests a minute on this route, in bursts of up to 100. Nothing was done; retry after 1s."
}

Resources

The Agent API mirrors the vocabulary of The Agent Definition and Capabilities. The following sections cover each resource, the shape of its data, and the endpoints that read and write it. (Wire field names such as toolBindings and customTools predate the capability vocabulary and are stable — they're the API contract, not the operator model.)

Most resources follow the same pattern: a GET/PUT pair on the collection, plus an import endpoint that copies a system template onto the agent (stamping derivedFrom so the console can later show whether your copy has diverged). Playbooks add per-item endpoints and a _setDefault action on top, to uphold the one-default invariant on every write.

Agent

The top-level resource. Every other resource in the API is nested beneath an agent. The Agent joins a name + an identity superset (used to seed channel bindings) with the agent's operational configuration; the operational pieces hang off their own sub-resource URLs.

interface Agent {
  id: string;            // immutable, minted at create time
  orgId: string;
  name: string;
  description?: string;
  identity: AgentIdentity;   // see Identity below
  framework: 'v2';       // the runtime; always 'v2'
  createdAt: string;     // ISO-8601
  updatedAt: string;     // ISO-8601
}
Method Path Role Scope
GET /orgs/{org}/agents viewer agents:read
POST /orgs/{org}/agents editor agents:write
GET /orgs/{org}/agents/{agent} viewer agents:read
PATCH /orgs/{org}/agents/{agent} editor agents:write
DELETE /orgs/{org}/agents/{agent} editor agents:write

Create body:

{ "name": "Acme Bot", "id": "acme-bot", "identity": { "websiteUrl": "https://acme.com" } }
id and identity are optional; if id is omitted, Anychat mints a slug from name. Once set, id is immutable.

PATCH accepts any of { "name", "description", "identity" }.

framework is accepted on create and PATCH for compatibility: "v2" is a no-op, and any other value is rejected with 422 (agent.framework.invalid, errors[].field = framework).

Example:

curl https://api.anychat.ai/v1/orgs/acme/agents \
  -H "Authorization: Bearer $ANYCHAT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "Acme Sales Bot"}'

Identity

The agent's name, branding, and brand contact details — the single source of truth for contact info across the platform (there is no separate "escalation contacts" block). These values also seed new channel bindings: when you bind a channel, Anychat pre-fills the binding's identity from here and prompts only for whatever that channel still needs. After a binding exists, its identity is its own — edits here don't auto-propagate to existing bindings.

interface AgentIdentity {
  smallLogoUrl?: string;
  largeBannerUrl?: string;
  defaultCardImageUrl?: string;   // stand-in art for cards sent without a picture
  brandColor?: string;            // hex, e.g. #1a73e8
  contactEmail?: string;
  contactPhone?: string;
  websiteUrl?: string;
  privacyPolicyUrl?: string;
  termsOfServiceUrl?: string;
  contactAvailability?: string;  // shown verbatim on escalation, e.g. "Mon–Fri 9–5 PT"
  identityPreamble?: string;     // short agent-level prompt prelude (see below)
}
Method Path Role Scope
GET /orgs/{org}/agents/{agent}/identity viewer agents:read
PUT /orgs/{org}/agents/{agent}/identity editor agents:write
PATCH /orgs/{org}/agents/{agent}/identity editor agents:write

PUT replaces the identity; PATCH updates only the fields you send.

identityPreamble is the short, agent-level "who's talking" line composed into every system prompt regardless of which Playbook is active (e.g. "You are Acme Corp's sales development agent."). The operational "how the agent behaves" content lives in the agent's Default Playbook (see Playbooks).

Personality

The voice and tone the agent uses, stored as plain text. See Personality in the framework overview.

interface AgentPersonality {
  description?: string;  // human-readable summary
  text: string;          // the prompt fragment the agent uses
  derivedFrom?: { templateId: string; version: string };
}
Method Path Role Scope
GET /orgs/{org}/agents/{agent}/personality viewer agents:read
GET /orgs/{org}/agents/{agent}/personality/diff viewer agents:read
PUT /orgs/{org}/agents/{agent}/personality editor agents:write
POST /orgs/{org}/agents/{agent}/personality/import editor templates:apply

PUT replaces the personality wholesale (text is required).

Import copies a system Personality Template onto the agent:

{ "templateId": "@anychat/personality-template.professional" }
The imported personality records derivedFrom pointing at the template; edit freely afterward.

diff reports whether the agent's personality has diverged from the template it was imported from, per field:

{ "current": { ... }, "baseline": { ... }, "divergence": { "description": false, "text": true } }
current / baseline / divergence are all null when no personality is set; baseline is null (and every field counts as diverged) when the personality wasn't imported from a template.

Goals

A Goal is one measurable outcome, defined once in a small library and assigned where it applies: an agent's defaultGoal, a Playbook's goal, or an Outreach's goal. A conversation's active Goal is the most specific assignment that applies (Playbook, then Outreach, then default); when a Goal is active, the Behavior document renders it. See the console guide's Goals page for how Goals are defined and assigned.

type GoalEvent =
  | 'appointment.booked'   // an appointment reached scheduled in this conversation
  | 'contact.captured'     // the Person gained email or phone during the conversation
  | 'person.replied'       // the person sent any message after an agent-initiated opening
  | 'link.tapped';         // an outreach link was redeemed by this person (recorded only; no producer yet)

type GoalDefinition =
  | { kind: 'event'; event: GoalEvent }
  | { kind: 'judged'; criterion: string };  // ≤ 500 chars; read by Conversation Review at close

interface AgentGoal {
  id: string;              // slug, unique per agent
  name: string;             // ≤ 80
  description?: string;     // ≤ 300
  definition: GoalDefinition;
}

/** One-level condition on the Person that swaps an assignment's Goal. */
type GoalCondition =
  | { field: 'person.lastCallAt'; op: 'exists' }
  | { field: 'person.consent.status'; op: 'eq'; value: 'granted' | 'denied' | 'unknown' }
  | { field: 'person.status'; op: 'eq'; value: string }
  | { field: `person.customFields.${string}`; op: 'eq' | 'exists'; value?: string };

interface GoalAssignment {
  goalId: string;
  unless?: GoalCondition;  // when this holds for the person, `then` is the active Goal instead
  then?: string;            // a goalId; missing/deleted falls through to the next source
}

interface AgentGoals$Put { goals: AgentGoal[]; defaultGoal?: GoalAssignment | null }
interface AgentGoals$Response { data: AgentGoal[]; defaultGoal?: GoalAssignment }
Method Path Role Scope
GET /orgs/{org}/agents/{agent}/goals viewer agents:read
PUT /orgs/{org}/agents/{agent}/goals editor agents:write

PUT replaces the whole library and the default assignment in one call (AgentGoals$Put); there is no per-item CRUD. Up to 20 Goals per agent. A Playbook's own Goal rides the existing Playbook create/update routes as its goal field; a An Outreach's Goal rides the existing Outreach create/update routes as its goal field — neither has a route of its own.

Goal writes that fail validation are problem details:

Status code When
422 goal.unknown An assignment's goalId or then (in defaultGoal, a Playbook's goal, or an Outreach's goal) names a Goal that doesn't exist on the agent (errors[].field = defaultGoal.goalId, playbooks[i].goal.goalId, playbooks[i].goal.then, or the equivalent under outreach)
422 request.invalid A Goal id is missing, not a unique slug, or the library exceeds 20 entries; definition isn't one of the two discriminated shapes; event is outside the GoalEvent enum; or a condition's field/op/value doesn't match GoalCondition
409 agent.integrity_conflict The PUT drops a Goal an Outreach still names; detail names the Outreach (see Responses and errors)

A Goal defined on link.tapped is accepted and stored, but nothing marks it reached yet — see the note under Lint.

Skills (retired resource)

Retired 2026-08-09 by the skills→playbooks collapse: instruction blocks moved onto their Playbook as procedures[], and on 2026-09-15 those were merged into the Playbook's body (see Playbooks). The endpoints were removed in the phase-5 cleanup; requests to /agents/{agent}/skills* now return 404.

interface AgentSkill {
  id: string;                  // short slug
  name: string;
  description?: string;
  body: string;
  enabled: boolean;
  derivedFrom?: { templateId: string; version: string };
}
Method Path Role Scope
GET /orgs/{org}/agents/{agent}/skills viewer agents:read
POST /orgs/{org}/agents/{agent}/skills editor agents:write
GET /orgs/{org}/agents/{agent}/skills/{skillId} viewer agents:read
PATCH /orgs/{org}/agents/{agent}/skills/{skillId} editor agents:write
PUT /orgs/{org}/agents/{agent}/skills/{skillId} editor agents:write
DELETE /orgs/{org}/agents/{agent}/skills/{skillId} editor agents:write
POST /orgs/{org}/agents/{agent}/skills/{skillId}/_enable editor agents:write
POST /orgs/{org}/agents/{agent}/skills/{skillId}/_disable editor agents:write
POST /orgs/{org}/agents/{agent}/skills/import editor templates:apply

Each skill carries a source of author or system in responses. System skills are immutable — write attempts return 403 (skill.system_immutable).

A skill's runtime config (used by skills like the scheduling skill) is not writable through this CRUD surface — it's populated only via import / apply-template.

Import body:

{ "templateId": "@anychat/skill-template.meeting-scheduling" }
Response:
{
  "skill":  { ... },
  "addedToolBindings": ["@anychat/tool.google-calendar"]
}

addedToolBindings lists the empty bindings the import created — you typically loop over them and PUT each one's configuration before the skill is usable. An imported skill remains in the agent's configuration even if its required tools aren't configured yet; the runtime withholds it for that turn. Re-importing the same template replaces the existing skill in place.

Playbooks

A Playbook is a named mode of operation — the situated plan the agent follows in a given kind of moment (greeting, scheduling, product questions, …). Every agent has exactly one Default Playbook (isDefault: true), and a Playbook may belong to an Outreach (outreachId), in which case the agent knows it only in a conversation that Outreach started; the runtime switches between Playbooks mid-conversation by a router decision recorded in the turn record. The agent decides when to leave a Playbook from the when-to-use of the others — there is no authored exit field. See Playbooks in the framework overview. A Playbook also declares the capabilities it acts through (uses) and may carry a deterministic path (flow); both are documented below.

interface AgentPlaybook {
  id: string;                  // short slug
  name: string;
  description?: string;        // operator-facing
  whenToUse?: string;          // LLM-facing activation context (routing)
  body: string;                // LLM-facing instructional body
  examples?: { role: 'user' | 'agent'; content: string }[][];
  toolRefs?: string[];           // catalog tool ids this Playbook may invoke
  messageTemplateRefs?: string[];// optional override: templates to inline even when the body never names them
  uses?: string[];               // capability ids this Playbook acts through, its Needs (see Uses below)
  flow?: AgentFlow;              // the Playbook's deterministic path (see Flow below)
  goal?: GoalAssignment;         // the Goal a conversation works toward while this Playbook is active (see Goals)
  isDefault: boolean;            // exactly one true per agent
  outreachId?: string;           // belongs to that Outreach; known to the agent only in conversations it started
  derivedFrom?: { templateId: string; version: string };
}
Method Path Role Scope
GET /orgs/{org}/agents/{agent}/playbooks viewer agents:read
POST /orgs/{org}/agents/{agent}/playbooks editor agents:write
POST /orgs/{org}/agents/{agent}/playbooks/import editor templates:apply
GET /orgs/{org}/agents/{agent}/playbooks/{playbookId} viewer agents:read
PATCH /orgs/{org}/agents/{agent}/playbooks/{playbookId} editor agents:write
PUT /orgs/{org}/agents/{agent}/playbooks/{playbookId} editor agents:write
DELETE /orgs/{org}/agents/{agent}/playbooks/{playbookId} editor agents:write
POST /orgs/{org}/agents/{agent}/playbooks/{playbookId}/_setDefault editor agents:write

GET (list) returns { "data": AgentPlaybook[] }. POST creates one (slugifies an id from the name when omitted) and returns the created Playbook. PUT replaces a Playbook while preserving its derivedFrom provenance; PATCH updates only the supplied fields. POST .../import copies a catalog playbook template ({ "templateId": "…" }) onto the agent — stamping derivedFrom — and returns the full { "data": [...] } list. DELETE of a Playbook that is still in use (one an Outreach owns, a flow's call or handoff target, or a Fact's scope) is refused with 409 agent.integrity_conflict. An Outreach's own Playbook goes when you delete the Outreach.

procedures (retired). Playbooks no longer carry a procedures array: on 2026-09-15 every stored procedure was merged into its Playbook's body (appended as ## Procedures followed by ### {name} and the procedure's text), and reads no longer return the field. POST, PUT, and PATCH reject a body carrying procedures with 422; write the text into body. Importing an older document still folds them (see Export / import).

The runtime invariants are upheld on every write: the agent always has exactly one Playbook with isDefault: true when any exist (deleting the default promotes the next). POST .../{playbookId}/_setDefault moves the default atomically. Template updates arrive at the agent level, not per Playbook: see Template updates.

A Playbook an Outreach owns (outreachId) is authored inside that Outreach and is the Playbook its conversations start in. In any other conversation it is left out of the Playbook index and the reply form entirely, so the agent never routes into it from an inbound message; it cannot also be the default. Everything else about it — When, What, Examples, Steps, Needs — is an ordinary Playbook. The opener, the class and the Goal belong to the Outreach, not to the Playbook.

A Playbook's goal assigns it a Goal: while that Playbook is active, its Goal takes priority over the conversation's Outreach Goal or the agent's default. goal.goalId must reference a Goal on the agent (see the goal.unknown error under Goals).

Playbook writes that fail these checks are problem details:

Status code When
422 agent.playbook.uses_unknown uses names an id outside the agent's capability vocabulary (title lists the unknown ids and the vocabulary; errors[].field = uses)
422 agent.playbook.flow_invalid flow fails a structural flow.* lint rule (errors[] carries each finding's code, path, message)
422 request.invalid uses is not a string array, or flow is not an object with a steps array

Uses (capabilities per Playbook)

uses lists the capability ids the Playbook acts through (the console calls this list Needs):

Id Capability
scheduling Scheduling — offer times, book, reschedule, cancel
people People — save to the Person record and its People fields
notify Notifications — Slack / email alerts to your team
integration:<id> One of the agent's custom integrations, by id

Knowledge (Sources, Facts, Things, collections, and business info) is not a uses id. Every agent has it, and it is offered on every turn whatever uses says: the agent can always search it and always sees business info and Things. For compatibility, knowledge is accepted in uses and has no effect.

POST, PUT, and PATCH validate uses against the agent's vocabulary (the ids above, integration:<id> for each of its custom integrations, and the accepted knowledge); an unknown id is rejected with 422 (agent.playbook.uses_unknown — the structural lint rule playbook.uses.unknown, listing the unknown ids and the agent's vocabulary). Duplicates are removed. Omitting uses on PUT or PATCH keeps the current list; send uses: [] to clear it. POST (create) and POST .../import fill an omitted uses with scheduling, people, notify, and integration:<id> for each of the agent's custom integrations, written onto the Playbook as an explicit list — so a client that doesn't send uses still gets a working Playbook, and what it can do is visible and editable from then on rather than recomputed on every turn.

uses is explicit: the runtime offers a Playbook only the tools of the capabilities named in its uses (plus Knowledge), and an empty uses means none. A Playbook with no uses at all is offered only Knowledge; the structural lint rule playbook.uses.missing (error) flags it, and since every write fills a missing uses it is unreachable through the API. A Playbook whose declared capabilities are not ready is still enterable; while it is active, the Behavior document tells the agent which of them are unavailable. Whether the body's prose matches the declaration is not a lint rule; it is the job of Definition Review.

Flow (deterministic path)

flow is the Playbook's deterministic path: a fixed sequence of steps the runtime walks in order, with the model filling in answers and phrasing questions. When a Playbook carries a flow, the flow runs and the body is its brief. Semantics — steps, slots, digressions, corrections, sub-flows — are described in Playbooks and Flows.

interface AgentFlow {
  id: string;
  name: string;
  description: string;
  slots: FlowSlot[];
  steps: FlowStep[];
  onComplete?: 'return' | 'end';   // pop back to the caller, or terminate (default 'return')
  allowDigressions?: boolean;      // may hand to another Playbook mid-flow, then resume
  generatedFrom?: {                // provenance of a generated flow
    bodyHash: string;              //   sha256 of the Playbook body it was compiled from
    by?: string; at?: string; model?: string;
  };
}

interface FlowSlot {
  name: string;                    // referenced from steps and {{name}} substitutions
  type: 'string' | 'email' | 'phone' | 'number' | 'date' | 'timeslot' | 'enum' | 'boolean';
  required?: boolean;
  bind?: string;                   // Person field (e.g. 'person.email'); a bound slot already on file is never asked
  options?: unknown[];             // static choices (enum)
  source?: string;                 // dynamic choices, e.g. 'tool:scheduling.getAvailability' (timeslot)
  prompt?: string;                 // pinned elicitation copy; generated when omitted
  validate?: string;               // '/regex/' or a predicate over the slot; a failing value is re-asked
  confirm?: boolean;               // read the value back once before it is used
}

// Exactly one key names the step kind. Every step may carry an `id`
// (the target of branch goto/else).
type FlowStep =
  | { collect: string | string[] }                       // fill one or more slots
  | { say: string | { text: string; suggestions?: string[] } }  // text, 'template:<messageTemplateId>', or text + 1–4 reply chips
  | { action: string; with?: Record<string, unknown>;    // call a capability tool, e.g. 'scheduling.createAppointment'
      confirm?: string; on_error?: 'retry' | 'handoff' | 'branch' }
  | { branch: { if: string; goto: string }[]; else?: string }  // predicates over slot state
  | { call: string }                                     // run another Playbook's flow, then return
  | { handoff: string }                                  // 'human', 'playbook:<id>', or a bare Playbook id
  | { end: { outcome: string; handoff?: string } };      // record the outcome; optionally hand off

POST, PUT, and PATCH accept flow. On every write the candidate document is run through Definition lint; any flow.* error finding under the written Playbook rejects the write with 422 (agent.playbook.flow_invalid) whose errors[] list each finding's code, path, and message. Warnings never block. flow: null on PATCH or PUT clears the path; omitting it on PUT keeps the current one. Editing the body without regenerating leaves the path in place — lint reports flow.body.stale until you regenerate or accept the difference.

Only the flow's own actions must be covered by the Playbook's uses (flow.action.capability.missing). A flow may call the knowledge search whatever uses says.

Generating a flow

Draft a flow from the Playbook's text instead of writing one by hand. The runtime reads the brief and the agent's capability catalog (People fields, Message Templates, Things, scheduling tools, Playbook ids), proposes an AgentFlow, validates it against the flow schema, lints every reference, and dry-runs it through the executor with a cooperative person. Nothing is persisted — inspect the result and, if you accept it, save it with PUT/PATCH carrying flow. This is the call behind the console's Deterministic path panel (Generate path / Regenerate) and the Copilot.

Method Path Role Scope
POST /orgs/{org}/agents/{agent}/playbooks/{playbookId}/flow:generate editor agents:write

Request body (all optional):

interface FlowGenerationRequest {
  brief?: string;     // what the flow should accomplish; default: the Playbook's body (+ description, whenToUse)
  uses?: string[];    // capability ids the flow may act through; default: the Playbook's `uses`
  model?: string;     // drafting model; default: the platform's
}

Response:

interface FlowGenerationResult {
  ok: boolean;              // a flow was produced, passes the schema, and has no lint errors
  flow?: AgentFlow;         // present on success — and, as the best attempt, on some failures
  notes?: string;           // the generator's assumptions / open questions
  brief: string;            // the brief that was used
  attempts: number;         // draft + repair rounds
  model: string;
  schema: { ok: boolean; errors: string[] };
  lint:   { errors: string[]; warnings: string[]; advice: string[] };
  dryRun?: {
    reachedEnd: boolean;    // the scripted walk reached an `end` step
    outcome?: string;
    stuckAt?: string;       // step the walk stalled at, when it did not
    turns: { person: string; agent: string[]; trace: string[] }[];
    toolCalls: string[];    // capability.tool names the walk would have called
  };
  usage: { input: number; output: number };   // tokens
  error?: string;           // why generation failed outright, when it did
}

Generation is slow — 10–40 seconds — and returns synchronously. Errors: 404 for an unknown agent or Playbook; 400 for a malformed body; 502 when the runtime is unreachable.

# 1. Draft a path from the booking Playbook's text.
curl -X POST "https://api.anychat.ai/v1/orgs/acme/agents/agent-acme-sdr-pj7r2qx4/playbooks/schedule-call/flow:generate" \
  -H "Authorization: Bearer $ANYCHAT_TOKEN" -H "Content-Type: application/json" -d '{}'
# → { "ok": true, "flow": { ... }, "lint": { "errors": [], ... }, "dryRun": { "reachedEnd": true, "outcome": "call_booked", ... } }

# 2. Accept it.
curl -X PATCH "https://api.anychat.ai/v1/orgs/acme/agents/agent-acme-sdr-pj7r2qx4/playbooks/schedule-call" \
  -H "Authorization: Bearer $ANYCHAT_TOKEN" -H "Content-Type: application/json" \
  -d '{"flow": { ...the returned flow... }}'

Connections

A connection is an account the business has linked — a Google account, a Slack workspace. It is keyed on the provider and owned by the org, so connecting Google once serves every capability that needs Google. Tokens never appear in these shapes; they stay encrypted inside the platform.

interface Connection {
  id: string;
  provider: string;                 // e.g. "google"
  label: string;                    // names it; several accounts per provider are legal
  accountLabel?: string;            // what the provider calls the account, e.g. an email
  status: 'connected' | 'expired' | 'revoked' | 'error';
  lastError?: string;
  expiresAt?: string;
  lastUsedAt?: string;
  usedBy: ConnectionUser[];         // agents that use it; [] when none
}

interface ConnectionUser {
  agentId: string;
  name: string;                     // the agent's name
}

interface ConnectionDisconnect$Response {  // DELETE …/connections/{id}
  disconnected: boolean;
  usedBy: ConnectionUser[];         // the agents that were using it
}
Method Path Role Scope
GET /orgs/{org}/connections viewer agents:read
GET /orgs/{org}/connections/{id} viewer agents:read
POST /orgs/{org}/connections/{provider}/_start admin agents:write
DELETE /orgs/{org}/connections/{id} admin agents:write

GET (list) returns { "data": Connection[] }. Creating or deleting a grant takes admin: it is an org-wide act, and a connection one agent's editor removed would break every other agent using it.

Start returns an authorizationUrl the operator's browser should navigate to. The body takes an optional label (which names the connection when an org links a second account of the same provider; it defaults to the provider's name) and an optional returnTo URL — Anychat redirects the browser back there once the grant completes, with ?oauthStatus=success&connectionId=… or ?oauthStatus=error&reason=….

The provider redirects to a single deployment-wide callback, GET /v1/oauth/callback, which completes the handshake. That endpoint is unauthenticated by design: the unguessable single-use state token is what binds the redirect to the org that started it.

Used by. usedBy lists the org's agents whose Scheduling capability mirrors booked calls into the connection, sorted by name. It is empty when no agent uses it.

Delete revokes the grant at the provider and deletes the stored credential. It is never refused, even while agents use the connection, because it is also how a credential is revoked. The response is a ConnectionDisconnect$Response, whose usedBy names the agents that were using it. Those agents keep booking calls, but their bookings stop reaching the calendar until an operator connects the account again and picks it in each agent's Scheduling settings. Read usedBy first to know what a delete affects.

Statuses come from the platform's own use of the connection. expired means a refresh failed and the operator should reconnect; revoked means the provider rejected the grant even after a successful refresh; error means the last call failed for some other reason, with lastError saying what.

Notifications

The Notifications capability's configuration: where a "tell the team" message lands, and which sender the agent's outbound email uses. The capability owns its own config — it is not a tool binding — and offers its tools only once this is set, so an unconfigured agent is never handed a notification tool that fails when it calls it.

interface NotificationsConfig {
  operator?: {                     // where a notify-operator call lands
    provider: 'slack' | 'email';
    toAddress?: string;            // required for the email provider
  };
  email?: {                        // sender shared by both of the capability's tools
    provider: 'sendgrid' | 'mailgun';
    fromAddress: string;
    fromName?: string;
    mailgunDomain?: string;        // required for mailgun
  };
}
Method Path Role Scope
GET /orgs/{org}/agents/{agent}/notifications viewer agents:read
PUT /orgs/{org}/agents/{agent}/notifications editor agents:write

PUT is a whole-object replace: omitted keys clear. An email destination needs a toAddress and a configured sender; a mailgun sender needs its mailgunDomain.

The two secrets are write-only and separate from that replace. Send slackWebhookUrl and emailApiKey alongside the config: a string stores the value, null clears it, and omitting the field leaves the stored secret untouched — so editing the from-address never means re-entering the API key. A read returns redacted metadata instead of the value:

{
  "operator": { "provider": "slack" },
  "email": { "provider": "sendgrid", "fromAddress": "agent@acme.com" },
  "secrets": {
    "slackWebhookUrl": { "set": true, "last4": "EFGH" },
    "emailApiKey": { "set": false }
  }
}

Sending a secret inside the config shape (for example email.apiKey) is rejected rather than dropped. The config half is portable and appears in an export as capabilities.notifications; the secrets never do.

Integrations

A custom integration gives the agent an ability of your own, backed by your HTTP endpoint (see Capabilities). One record carries both halves: the agent-facing contract — what the agent calls it, what it does, the arguments it supplies — and the endpoint the platform calls. GET .../integrations returns the same list as integrations.custom in an export, plus each integration's credential state.

interface IntegrationDef {
  id: string;                          // ^[a-zA-Z0-9_-]{1,64}$ — the name the agent calls it by
  name?: string;                       // operator-facing label; defaults to id
  description: string;                 // what it does and when the agent should use it
  parameters?: Record<string, unknown>;// JSON Schema for the call arguments; defaults to {}
  endpoint: {
    url: string;                       // where the platform sends the call
    method?: 'POST' | 'PUT' | 'PATCH'; // defaults to POST
  };
}
Method Path Role Scope
GET /orgs/{org}/agents/{agent}/integrations viewer agents:read
GET /orgs/{org}/agents/{agent}/integrations/{id} viewer agents:read
PUT /orgs/{org}/agents/{agent}/integrations/{id} editor agents:write
DELETE /orgs/{org}/agents/{agent}/integrations/{id} editor agents:write

GET (list) returns { "data": IntegrationDef[] }; each entry also carries auth, the redacted state of its credential. PUT upserts under the id in the path (a body id, if present, must equal it) and returns 201 when it created the integration or 200 when it replaced one. DELETE returns 204 and removes the stored credential with the record — one record, one delete.

The auth header is write-only. Send authHeader alongside the record to set the value the platform sends as the Authorization request header: a string stores it, null clears it, and omitting the field leaves the stored credential untouched. A read returns redacted metadata instead:

{
  "id": "lookupOrder",
  "description": "Look up an order by number.",
  "parameters": { "type": "object", "properties": { "orderNumber": { "type": "string" } } },
  "endpoint": { "url": "https://acme.example/orders", "method": "POST" },
  "auth": { "set": true, "last4": "9af3" }
}

Sending an auth value inside the record itself (at the top level or on endpoint) is rejected rather than dropped, so a credential can never reach the stored definition or an export.

Errors are problem details: 404 tool.not_found for an unknown {id} on GET or DELETE, and 422 request.invalid for a malformed body (errors[].field names the path, for example integration.endpoint.url).

Guardrails

Three parallel lists of plain-text constraints, plus a back-link list of the templates the agent has imported from.

interface AgentGuardrails {
  topicsToAvoid?: string[];
  claimsNotToMake?: string[];
  requiredDisclaimers?: string[];
  derivedFromTemplates?: string[];
}
Method Path Role Scope
GET /orgs/{org}/agents/{agent}/guardrails viewer agents:read
PUT /orgs/{org}/agents/{agent}/guardrails editor agents:write
PATCH /orgs/{org}/agents/{agent}/guardrails editor agents:write
POST /orgs/{org}/agents/{agent}/guardrails/import editor templates:apply

PUT replaces the lists wholesale; PATCH updates only the lists you send. The list categories (topicsToAvoid, claimsNotToMake, requiredDisclaimers) take { add, remove } operations under PATCH. The free-form additionalRules string is retired: a write carrying it is rejected with 422, and its text now lives in the agent's standing instructions. (Escalation is no longer a guardrail — it's the Escalate to Human Playbook.)

Import merges the template's strings into the agent's existing lists (deduplicated, original order preserved). The template id is appended to derivedFromTemplates:

{ "templateId": "@anychat/guardrail-template.healthcare-disclaimers" }
Guardrails are the only block where import merges into the existing lists instead of replacing them, so importing several guardrail templates accumulates their entries.

Standing instructions

Short sentences the agent follows on every turn, in every Playbook, the same way Personality and Guardrails do. See the console guide.

interface AgentStandingInstructionsPut {
  standingInstructions: string[];   // ≤ 20 entries, each ≤ 300 chars
}
interface AgentStandingInstructionsResponse {
  data: string[];
}
Method Path Role Scope
GET /orgs/{org}/agents/{agent}/standing-instructions viewer agents:read
PUT /orgs/{org}/agents/{agent}/standing-instructions editor agents:write

PUT replaces the list wholesale. An agent with none set has no standing-instructions block in its Behavior document; the document for every other agent is unchanged.

Status Code When
422 request.invalid More than 20 entries, or an entry over 300 characters (errors[].field = standingInstructions[i])

Rules

Deterministic checks against the agent's reply, applied by the platform rather than followed by the agent. See Rules and safety for Guardrails versus Rules and the console guide for the authoring surface.

interface AgentRuleCheck {
  kind: 'pattern';
  patterns: string[];   // plain phrase (case-insensitive substring) or '/regex/flags'; ≤ 20, each ≤ 200 chars
}

interface AgentRule {
  id: string;                     // slug, unique per agent
  text: string;                   // the sentence the operator wrote, e.g. "Never mention Brand X"; ≤ 300 chars
  mode: 'checked' | 'judged';
  check?: AgentRuleCheck;          // required for 'checked' (≥ 1 pattern); ignored for 'judged' — a fast model reads `text` instead
  action: 'rewrite' | 'block';    // rewrite: ask the model once with the violation named, then fall back; block: skip the rewrite
  fallbackText?: string;          // sent instead of a reply that still trips the rule; ≤ 1000 chars. Omitted → the reply is dropped
  enabled?: boolean;               // default true
}

interface PlatformRule {
  id: string;
  text: string;
  mode: 'checked' | 'instructed'; // 'instructed' marks a baseline guardrail: document text, not a runtime check
  kind: 'toolGate' | 'send' | 'baseline';
  source: 'platform';
}
Method Path Role Scope
GET /orgs/{org}/agents/{agent}/rules viewer agents:read
PUT /orgs/{org}/agents/{agent}/rules editor agents:write

GET returns the agent's own Rules alongside the read-only platform catalog:

interface AgentRules$Response {
  data: AgentRule[];
  platform: PlatformRule[];
}

PUT replaces the whole list (body { rules: AgentRule[] }) through the same sanitizer import uses, so writing through this route and importing an export document can't drift apart. A pattern that isn't a plain phrase and doesn't parse as /regex/flags fails with 422 and path rules[i].check.patterns[j]. A checked Rule with no pattern fails with 422 (rules[i].check.patterns, "at least one pattern is required"); a judged Rule needs no check at all, and one supplied anyway is stripped rather than validated. Lint flags a judged Rule that still carries a check (rule.check.unused, info) so a mode switch doesn't leave stale patterns behind.

Each time a Rule fires it is named in the turn record's trace, under rulesFired, as '<id>:rewrite' or '<id>:block', whether the Rule that fired was checked or judged.

Message Templates

Operator-authored exact copy with {slot} placeholders, rendered deterministically at send time. An Outreach uses one as its opener and another as its follow-up; Playbooks name them for moments inside a conversation. The collection is general-purpose. The routes read message-templates.

interface AgentMessageTemplate {
  id: string;
  name: string;
  description?: string;
  body: string;            // slot syntax, e.g. "Hi {person.firstName | "there"}"
  defaultOnMissingSlot?: 'omit-sentence' | 'omit-message' | 'block-send';
  use?: 'whenFits' | 'onlyWhenNamed'; // default 'whenFits'
}
Method Path Role Scope
GET /orgs/{org}/agents/{agent}/message-templates viewer agents:read
POST /orgs/{org}/agents/{agent}/message-templates editor agents:write
GET /orgs/{org}/agents/{agent}/message-templates/{templateId} viewer agents:read
GET /orgs/{org}/agents/{agent}/message-templates/{templateId}/preview viewer agents:read
PATCH /orgs/{org}/agents/{agent}/message-templates/{templateId} editor agents:write
PUT /orgs/{org}/agents/{agent}/message-templates/{templateId} editor agents:write
DELETE /orgs/{org}/agents/{agent}/message-templates/{templateId} editor agents:write

POST creates a template with a server-generated id. PUT is an upsert: it replaces the template stored under {templateId}, or, when none exists, creates one under that client-chosen id and returns 201 (a replace returns 200). PATCH merges into an existing template. name and body are required on POST and PUT. DELETE of a template that is still in use (an Outreach's opener or follow-up, a flow say step, a Playbook's messageTemplateRefs, or a [[template: …]] in other copy) is refused with 409 agent.integrity_conflict.

A template body is AML: plain text plus optional markers, so reviewed copy can carry chips, a rich card, or a carousel — [[chips: Book a demo | Tell me more]], [[card | Title ~ description | media: image:<id>]], one card per line inside [[carousel … ]], and [[next-message]] for a second bubble. Pictures come from the agent's media library and are named by id (media: image:<id>), never by URL. Playbooks send a template by templateRef; the platform expands it in place, markers and all.

Slot syntax reads from the Person record and the agent identity: {person.firstName}, {person.customFields.plan}, {agent.companyName}. Per-slot fallbacks use an inline pipe — {person.firstName | "there"} (literal default), or one of omit-sentence / omit-message / block-send. defaultOnMissingSlot sets the policy for slots without an inline one.

use gates whether the agent can choose the template on its own. whenFits (the default when the field is absent) is today's behavior: the template is indexed on every turn and the agent sends it whenever it judges the moment right. onlyWhenNamed indexes the template only on turns whose active Playbook names it — a template:<id> reference in the instructions, [[template: <id>]], the id or name in quotes or backticks, or the Playbook's messageTemplateRefs override — so the model can never choose it unprompted. An Outreach's opener, its follow-up, and an Outreach's opener reference all resolve by id directly and are unaffected by use. POST and PUT validate use against the enum (422 when it isn't whenFits or onlyWhenNamed); PATCH follows the same absent-means-unchanged rule as defaultOnMissingSlot.

preview renders the template against a real Person (?personId=… required) and returns what would be sent:

interface MessageTemplatePreview {
  renderedText: string | null;  // null when block-send fired
  missingSlots: Array<{
    path: string;               // e.g. person.firstName
    action: 'default' | 'omit-sentence' | 'omit-message' | 'block-send';
  }>;
  blocked: boolean;             // true iff a block-send slot was hit
}
{ "renderedText": "Hi Dana — thanks for stopping by Acme.", "missingSlots": [], "blocked": false }

Media

The agent's media library: the images (and, as rich channels allow, videos) it may show in rich cards, carousels and media messages. A menu as a carousel, a Thing card, a "here's what we do" showcase early in a conversation. The platform hosts each item (the object is created and deleted with the entry). Content refers to an item by id, never by URL: media: image:<id> in any AML marker that takes media: ([[card]], [[carousel]] lines, [[showcase]] items), [[media: image:<id>]] on its own, or card.media / media in an agent response. A reference is <kind>:<id>; the kind is a readability cue and the id is the key. An id that no longer exists degrades to "no picture" at send time, never a broken link, and lint reports it (media.ref.unknown).

name and usageHint are the human-facing fields: what the item is, and when the agent should reach for it. description is what the agent reads when it decides to show something on its own ("Media you can show: id — description (Use when: hint)" sits next to the Things catalog in its brief). It is generated by a vision model when the item is added or its file replaced (what it depicts, visible text quoted) and is editable.

interface AgentMediaItem {
  id: string;                     // stable per-agent slug — the reference key
  kind: 'image' | 'video';        // image today; video as channels allow
  name: string;
  description: string;
  descriptionSource: 'generated' | 'edited' | 'none';
  usageHint?: string;
  url: string;                    // public hosted URL (read-only)
  contentType: string;            // image/png | image/jpeg | image/gif | image/webp
  bytes: number;
  width?: number;
  height?: number;
  sourceUrl?: string;             // when added by URL
  createdAt: string;
  updatedAt: string;
}
Method Path Role Scope
GET /orgs/{org}/agents/{agent}/media viewer agents:read
POST /orgs/{org}/agents/{agent}/media editor agents:write
GET /orgs/{org}/agents/{agent}/media/{mediaId} viewer agents:read
PATCH /orgs/{org}/agents/{agent}/media/{mediaId} editor agents:write
PUT /orgs/{org}/agents/{agent}/media/{mediaId}/content editor agents:write
POST /orgs/{org}/agents/{agent}/media/{mediaId}:describe editor agents:write
DELETE /orgs/{org}/agents/{agent}/media/{mediaId} editor agents:write

GET …/media returns { data: AgentMediaItem[] }. POST …/media adds an item either as multipart (file field, plus optional name, description, usageHint, id fields) or as JSON:

interface AgentMediaCreateRequest {
  sourceUrl?: string;    // public http(s) URL to fetch — or
  dataBase64?: string;   // raw bytes, base64
  id?: string;           // preferred id (slugified; suffixed when taken); defaults from name
  name?: string;
  description?: string;  // skips generation when supplied
  usageHint?: string;
}

It returns 201 with the entry, or 200 with the existing entry when the same bytes are already in the library. PNG, JPEG, GIF and WebP up to 5 MB; at most 200 items per agent. PATCH takes any of name, description, usageHint (null clears the hint). The id never changes.

PUT …/media/{mediaId}/content replaces the file behind an id, as multipart (file) or JSON ({ sourceUrl } or { dataBase64 }): a new hosted URL, a regenerated description, and the same id, name and usage hint, so every template that references the id shows the new picture. POST …/media/{mediaId}:describe regenerates the description from the stored file. DELETE removes the entry and the hosted object.

Errors: agent.media.not_found (404), agent.media.source_required (422), agent.media.unsupported_type (415), agent.media.too_large (413), agent.media.limit_reached (409), agent.media.fetch_failed (422).

/orgs/{org}/agents/{agent}/images… is a deprecated alias of the same routes.

Export / Import carries the library as capabilities.media[] (id, kind, name, description, usageHint, url); an import adds, by URL, the ids the target agent lacks.

Outreach

Outreach is how an agent starts a conversation. The model is one sentence — send a predefined message, with its playbook and goal, to one or more people, who may be known now or specified later — and three resources:

  • an Outreach: the reusable reason to reach out. An opener (Message Template), a Playbook, a Goal, a class, optionally one follow-up and the inputs the opener needs. It has no audience.
  • an audience: one person named on the spot. Two things are built on top: Automated Outreach sends an Outreach to each new qualifying row on a linked sheet, and a Campaign sends one Outreach to a list you pick.
  • a send: one message to one person, whatever created it. A queued send can be cancelled; a Campaign is paused or cancelled as a unit.

See the console guide for the operator view.

interface Outreach {
  id: string;
  name: string;                  // operator-facing, e.g. "New lead welcome"
  description?: string;
  openerTemplateId: string;      // a Message Template id — sent verbatim, slots filled
  playbookId: string;            // the Playbook this Outreach owns (carries outreachId)
  goal?: GoalAssignment;   // see Goals — {goalId, unless?, then?}
  class: 'marketing' | 'service';
  /** Custom People-field keys the opener needs; a send missing one is refused. */
  inputs?: string[];
  followUp?: {
    templateId: string;
    afterDays: number;           // sent only while the person has not replied
  };
  openerFollowOn?: 'none' | 'compose';  // after the opener: 'none' sends only the Message Template (default); 'compose' lets the agent add a composed follow-on bubble
  enabled?: boolean;             // default true
  createdAt?: string;
  updatedAt?: string;
}
Method Path Role Scope
GET /orgs/{org}/agents/{agent}/outreach viewer agents:read
POST /orgs/{org}/agents/{agent}/outreach editor agents:write
GET /orgs/{org}/agents/{agent}/outreach/{outreachId} viewer agents:read
PATCH /orgs/{org}/agents/{agent}/outreach/{outreachId} editor agents:write
DELETE /orgs/{org}/agents/{agent}/outreach/{outreachId} editor agents:write
GET /orgs/{org}/agents/{agent}/outreach/{outreachId}/preview viewer agents:read

GET …/preview?personId=&inputs=<json> renders the opener for one person with the send's inputs filled in, without sending. openerTemplateId must name a Message Template this agent has. goal is a Goal assignment like a Playbook's. The Playbook is created with the Outreach: POST takes playbook: { … } (a Playbook document, or { copyOf: "<playbookId>" }) and returns its id as playbookId; the Playbook then carries outreachId and is edited through the Playbooks routes like any other. An Outreach keeps that Playbook: a PATCH whose playbookId differs from the current one is refused with 422 request.invalid (field playbookId), and sending the unchanged value is accepted. To use a different Playbook, create a new Outreach. Otherwise 422 request.invalid names the field. An Outreach whose opener cannot be resolved cannot be sent — there is no fallback copy and the agent never composes an opening freehand.

Deleting

DELETE …/outreach/{outreachId} deletes the Outreach and the Playbook it owns, and returns 204. The delete is refused while something still uses the Outreach:

Status code When
404 agent.outreach.not_found No such Outreach on this agent
409 agent.outreach.in_use A Campaign that is draft, staged, running or paused, or an Automated Outreach rule (enabled or not), names this Outreach

detail names every referrer, and referrers lists them. Cancel those Campaigns or point those rules at another Outreach, then delete again. A Campaign that is completed, cancelled or failed does not block the delete.

{
  "status": 409,
  "code": "agent.outreach.in_use",
  "title": "This Outreach is still in use",
  "detail": "Campaign \"Spring promo\" (running) and Automated Outreach \"New leads\" use this Outreach. Cancel or change them first.",
  "referrers": [
    { "kind": "campaign", "id": "66f0…", "name": "Spring promo", "status": "running" },
    { "kind": "automatedOutreach", "id": "new-leads", "name": "New leads" }
  ]
}

When the delete goes ahead, it also cancels the work the Outreach would otherwise fail on later:

  • Its queued sends become cancelled, and their detail names the deleted Outreach. A send that has already left the queue (sending or later) is not touched.
  • People whose pending automatic follow-up came from this Outreach no longer get it. Each person's journal records why.

Sends already made keep their outreachId.

Sending

POST …/sends sends an Outreach to one person, now. It returns 202 with a create result that says whether the send was accepted. The send itself is a send record you read afterwards.

Method Path Role Scope
POST /orgs/{org}/agents/{agent}/outreach/{outreachId}/sends editor people:write
POST same, with recipient: { personaId } viewer agents:read

A send to a persona is a sandbox rehearsal: nothing reaches a phone, so it needs only what a persona turn needs. Every other recipient form needs editor and people:write.

// one person, now: an operator's Send, your booking system, a journey
// platform at a step in its flow
{
  "recipient": { "channel": "rcs", "address": "+15551234567" },
  "profile": { "firstName": "Jane", "email": "jane@example.com" },
  "profileUpdate": "fill",
  "source": "booking:opera",
  "inputs": { "checkInDate": "2026-10-14" },
  "consent": { "optIn": true, "evidence": "Agreed to updates at booking" },
  "idempotencyKey": "booking-7:arrival:88421",
  "dryRun": false
}

The recipient. A send goes to one channel identity: a channel and the address that channel uses. recipient names it in exactly one of four forms:

Form Resolves to
{ "channel", "address" } The Person who holds that identity. When nobody holds it, the send creates a Person to hold it
{ "asid" } The Person who holds the identity with that ASID. When nobody holds it, the send creates a Person to hold it
{ "personId", "channel"? } That Person's identity on channel, or, without channel, the identity Anychat picks for outbound messages. A Person with no such identity is refused no_channel_identity
{ "personaId" } A persona. The whole pipeline runs and the opening is captured into the persona's sandbox conversation. Nothing reaches a phone. The send is dispatched within the call, so it can't be cancelled

A send always goes to the resolved identity's address, never to a profile phone. Profile fields never choose a recipient, so a recipient is never ambiguous. Outreach sends on rcs: a channel it can't send on is refused channel_unavailable. An RCS address is a phone number in international format; spaces, dashes, dots and parentheses are ignored. A sandbox identity or Person is not a recipient: address a persona with personaId.

Request fields. Every field except recipient is optional. A key the table does not name, at any level, is 422 request.invalid, so a misspelt consent.opt_in is refused and never read as "no assertion".

Field Type Meaning
recipient object Who receives the send, in exactly one of the forms above. No form, or more than one, is 422 request.invalid
recipient.channel string The identity's channel: rcs. With personId, picks that Person's identity on this channel
recipient.address string The address the channel uses. For RCS, a phone number in international format
recipient.asid string An identity's ASID
recipient.personId string A live Person on this agent
recipient.personaId string A persona on this agent
profile.firstName, profile.lastName, profile.fullName, profile.email, profile.phone string Profile information for the recipient's Person, written as profileUpdate says when the send is accepted. It never finds or addresses anyone
profileUpdate string How profile updates a field that already has a value. fill (the default) writes only empty fields. overwrite replaces values that differ, for a caller whose own system is the source of truth. Either way, a locked field is never written
source string Where this recipient came from, as a short tag (ajo, booking:opera): 1–64 characters of A-Z a-z 0-9 _ . : -. It is stamped on a Person the send creates, and defaults to api. It never finds a person
inputs object of strings Values for the Outreach's inputs. The send renders its opener with these, and they are merged into the Person's fields key by key, except a locked field. At most 100 keys. Each key is 1–128 characters, contains no . and does not start with $. Each value is at most 4000 characters
consent.optIn boolean false refuses the send. true is recorded on the send as the caller's claim
consent.evidence, consent.capturedAt, consent.source string The caller's evidence, kept verbatim on the send. capturedAt is ISO-8601
idempotencyKey string The dedup handle, at most 200 characters. See Idempotency below
dryRun boolean Decide everything and send nothing. See Dry run below

A profile sent with personaId applies to the persona like any Person.

Nulls are absences. A null profile field, input or consent field is read as "not supplied" and never clears stored data. Journey platforms commonly send unmapped optional parameters as explicit nulls. An empty idempotencyKey is read as absent.

The create result. It returns 202 with this body. It is not an OutreachSend:

{ "status": "queued",
  "sendId": "osnd_0123456789abcdef01234567",
  "personId": "665f2a…", "outreachId": "arrival-info",
  "asid": "v2-…", "channel": "rcs", "unknownInputs": [] }
Field Meaning
status queued: accepted, and the send is under way. duplicate: this idempotencyKey already produced a send on this agent. skipped: a dry run
sendId The send, to follow with the sends endpoints. On duplicate, the original send. Absent on a dry run
personId The recipient. On duplicate, the original send's person. Absent on a dry run whose recipient the send would create
outreachId The Outreach sent
asid, channel The channel identity the send goes to. asid is absent on a dry run whose identity the send would create, and both are absent on a send to a personaId, which is captured in the persona's sandbox conversation instead
unknownInputs Supplied inputs keys that the agent has not declared as People fields. They are stored anyway
warning Present when the send is a marketing send to a person inside the frequency cap. The send still goes

Every status is a success. A caller that retries its own timeout must not send twice, and must not see a failure.

Idempotency. An idempotencyKey is unique per agent, across all of the agent's Outreaches. The first request with a key makes the send. A later request with the same key answers duplicate with that send's sendId and personId. That answer changes nothing: no Person or channel identity is created or updated, even when the request carries different inputs. The key is kept with the send for as long as the send record is kept (180 days), and a key used again after that makes a new send.

With no key, one is derived from the agent, the Outreach, the person and the UTC day, so an Outreach reaches a person at most once per UTC day unless the caller supplies distinct keys. Always send a key from an integration, stable per step of your flow per recipient (booking-7:arrival:<reservation>). The key is what makes a retry after a timeout safe.

Dry run. dryRun: true resolves the recipient and checks the Outreach, consent, STOP and inputs exactly as a real send would, and answers skipped or the refusal the real send would get. It writes nothing: no send, no Person, no channel identity, no merge of the supplied data. A dry run does not look up the idempotencyKey, so it never answers duplicate.

Refusals are problem details. A refused request writes nothing.

Status code When
422 request.invalid The body is malformed: an unknown key, a wrong type, a recipient with no form or more than one, a profileUpdate other than fill or overwrite, a source or inputs outside the limits above, or an idempotencyKey longer than 200 characters. errors[] names each field
422 agent.outreach.consent_declined The body carries consent.optIn: false. No Person is created or changed
409 agent.outreach.opted_out The recipient's channel identity replied STOP to this agent
409 agent.outreach.consent_denied The person's consent on record is denied
404 agent.outreach.recipient_not_found No such asid, personId or personaId on this agent, or one in the sandbox addressed as asid or personId
422 agent.outreach.no_channel_identity The personId names a Person with no channel identity on channel, or, without channel, none Outreach can send on
422 agent.outreach.invalid_address The address is not a valid address on its channel
422 agent.outreach.channel_unavailable Outreach can't send on the recipient's channel
422 agent.outreach.not_found No such Outreach, or it is disabled
422 agent.outreach.missing_inputs The opener needs inputs that neither this call nor the Person supplies. errors[] names them as inputs.<key>
404 agent.not_found No such agent in this org
429 request.rate_limited The API key is over its rate limit. Retry after Retry-After seconds
502 agent.outreach_send.upstream The API could not reach the agent
504 agent.outreach_send.timeout The agent did not answer within 30 seconds

After a 502 or 504 the send may or may not have been accepted. Retrying with the same idempotencyKey is safe: a send that went through comes back as duplicate and is not sent twice.

Consent. The caller owns consent for a send it names. The send goes to a person whose consent on record is granted or unknown, and to a person this call creates, with or without a consent object. consent is recorded on the send as its consent basis: optIn: true with evidence, capturedAt and source is kept verbatim as the caller's evidence, and optIn: false refuses the send. A named send never writes the Person's consent. To change it, update the Person. Two records always win over the caller: a STOP on the recipient's channel identity, and a person whose consent is recorded as denied. STOP is held per channel identity per agent: it covers every provider and binding of that channel, and never another channel.

What you ask for by name, you get. The caller also owns the send time. A named send goes out when it is named: safe hours do not apply to it, and the frequency cap warns instead of blocking. The only refusals are the ones in the table above.

Channel. Outreach sends over RCS, on the agent's RCS channel binding. A recipient whose number can't receive RCS is accepted, and the send ends with reason not_rcs_capable: failed when the capability check finds it, and suppressed once the number is known not to be RCS-capable. Nothing falls back to SMS. A number found RCS-capable on a channel is remembered for 7 days, so later sends to it within that time go out without asking the provider again; if one of those sends fails to dispatch, the number is checked afresh on the next send.

# Your booking system, at check-in
curl -s https://api.anychat.ai/v1/orgs/acme/agents/agent-acme-resort-pj7r2qx4/outreach/arrival-info/sends \
  -H "Authorization: Bearer $ANYCHAT_TOKEN" -H "Content-Type: application/json" \
  -d '{"recipient":{"channel":"rcs","address":"+15551234567"},
       "profile":{"firstName":"Jane"},"source":"booking:opera",
       "inputs":{"checkInDate":"2026-10-14"},
       "consent":{"optIn":true,"evidence":"Agreed to updates at booking"},
       "idempotencyKey":"booking-7:arrival:88421"}'

Sends

One row per person, and the record everything is reported from.

interface OutreachSend {
  sendId: string;
  outreachId: string;
  origin: 'named' | 'automated' | 'campaign';
  campaignId?: string;               // set when origin is 'campaign'
  automatedOutreachId?: string;      // set when origin is 'automated'
  personId: string;
  state: 'queued' | 'sending' | 'sent' | 'failed' | 'suppressed' | 'cancelled';
  reason?: OutreachFailureReason;   // set on failed | suppressed
  detail?: string;
  channel: string;                  // the recipient's channel identity: rcs
  asid: string;                     // its ASID, which the conversation is opened with
  address?: string;                 // its address when the send was accepted; see below
  messageId?: string;
  queuedAt: string;
  sentAt?: string;
  failedAt?: string;
  cancelledAt?: string;
  attempts?: number;                // named sends: dispatch attempts so far (at most 4)
  nextAttemptAt?: string;           // set while queued for an automatic retry
  respondedAt?: string;
  delivery: OutreachSendDelivery;
  consent?: OutreachSendConsent;    // set on named sends only
}

interface OutreachSendConsent {
  claimed: 'yes' | 'none';          // the caller sent optIn: true, or no consent object
  recorded: 'granted' | 'unknown' | 'new'; // the Person's consent at send time; 'new' = created by this send
  evidence?: string;                // the caller's evidence, verbatim
  capturedAt?: string;              // ISO-8601, as the caller supplied it
  source?: string;
}

type OutreachFailureReason =
  | 'no_channel_identity' | 'no_binding' | 'not_rcs_capable' | 'capability_unknown'
  | 'dispatch_failed' | 'delivery_failed' | 'opted_out' | 'no_consent'
  | 'frequency_capped' | 'template_blocked' | 'outside_safe_hours'
  | 'missing_inputs' | 'outreach_unavailable' | 'binding_error' | 'no_route';

interface OutreachSendDelivery {
  known: boolean;          // false = no delivery record — see below
  sent: boolean;
  delivered: boolean;
  read: boolean;
  failed: boolean;
  sentAt?: string;
  deliveredAt?: string;
  readAt?: string;
  failedAt?: string;
  failReason?: string;
}

state describes the attempt and stops at sent; what happened to the message afterwards is delivery.

state Final Meaning
queued No Accepted and waiting for the send worker, which picks a named send up in about a minute. A send that is waiting for an automatic retry is also queued, with nextAttemptAt set to when the next attempt is due
sending No Being dispatched
sent Yes Handed to the channel. What happened next is delivery
failed Yes Not sent. reason and detail say why
suppressed Yes Not sent, and not attempted on the channel. For example: outreach_unavailable (the Outreach was disabled or deleted before dispatch), missing_inputs, or not_rcs_capable for a number already found not RCS-capable
cancelled Yes Cancelled while queued

A named send that fails transiently goes back to queued and is retried automatically, up to 4 attempts, the last about 30 minutes after the first. The transient failures are capability_unknown (the RCS capability check did not answer) and dispatch_failed when the channel binding could not be looked up or the gateway timed out or answered 5xx. After the last attempt the send is failed, and failed is final. Retrying the request yourself with the same idempotencyKey answers duplicate: the automatic retry is the one that applies.

binding_error is not transient: the agent's RCS channel is misconfigured — its provider refused the capability check because of the channel's own credentials, sender or RCS agent. The send is failed at once and is not retried, and the person is not marked unreachable, since the fault is the channel's, not theirs. Fix the channel binding, then send again with a new idempotencyKey.

no_route is final too: the agent has no binding on the identity's channel, or several with nothing to choose between them. Connect the channel, or keep one binding per channel, then send again with a new idempotencyKey.

asid and channel name the channel identity the send goes to, fixed when the send is accepted. address is that identity's address at the time, such as the phone number, and is returned only to a caller allowed to read channel addresses (see Channel identities). A send whose Person no longer holds its identity when it comes to dispatch, because the identity was moved, removed or merged away, is failed with no_channel_identity.

consent is the basis a named send went out on. It is set on named sends only and absent on Automated Outreach and Campaign sends. A refused send writes no row, so an explicit decline or a denied record never appears here.

Named sends never carry the reasons no_consent, frequency_capped or outside_safe_hours. The values stay in the set for compatibility.

delivery.known: false is not a failure. It means no delivery record backs this send — it never dispatched, the record has not appeared yet, or the send is older than the 180-day retention window. The other booleans are all false in that case, so never read them as a negative without checking known first. For the same reason: RCS delivery and read receipts are best-effort and vary by handset and carrier, so delivered and read are positive evidence and their absence is not evidence of failure.

Method Path Role Scope
GET /orgs/{org}/agents/{agent}/outreach/sends viewer people:read
GET /orgs/{org}/agents/{agent}/outreach/sends/{sendId} viewer people:read
POST /orgs/{org}/agents/{agent}/outreach/sends/{sendId}/cancel editor people:write

cancel applies to a send that is still queued; it moves the send to cancelled and returns it. A named send is queued until the send worker picks it up, which takes about a minute, and again while it waits for an automatic retry. Those are the windows in which cancel works. A send to a persona is dispatched within the request, so it is never queued. A send that has left the queue is 409 agent.outreach.send_not_cancellable.

The list returns { "data": OutreachSend[] }, newest first, from the live partition only: a send to a persona is stamped sandbox and appears as the opening of that persona's sandbox conversation, never here. Filters, ANDed: outreachId, origin, campaignId, automatedOutreachId, personId, state, reason, since, until (ISO-8601) and limit (1–200, default 50). An unknown value for a closed vocabulary is 422 request.invalid naming it.

Guardrails

Whoever names the recipient owns consent and send time. A named send is one person named on the spot: POST …/sends, a Send in the console, and the follow-up nudge. The caller is responsible for it, and Anychat refuses it only on an explicit no. Automated Outreach, Campaigns, scheduled follow-ups and appointment reminders are sends Anychat times itself, so Anychat enforces consent and send time for them. STOP is the exception to everything: on every RCS and SMS channel, a person who replies STOP gets no further message of any kind until they reply START. The account-level values live in Sending settings; the Outreach's class decides only whether the frequency cap applies.

Guardrail Named send Automated Outreach Campaign Scheduled follow-up Appointment reminder
Consent Sent unless the body says optIn: false (422 consent_declined) or the person's consent is denied (409 consent_denied) Only people whose consent is granted; others are skipped Only people whose consent is granted; others are excluded Stops if the person's consent is now denied Not required: the person booked the call
Opt-out (STOP) Refused (409 opted_out) Never sent Never sent Never sent Never sent
Safe hours Do not apply; the send goes when named Waits for safe hours and its own send window, unless the rule sets ignoreSafeHours Waits for safe hours and its own send window Waits for safe hours Ignored by default; waits for them when reminders.ignoreSafeHours is false
Frequency (marketing only) Warns in the response; never blocks. The follow-up nudge is exempt The row is skipped The recipient is excluded (frequency-gated) Exempt Exempt
Conversation in progress Not held; the send goes when named Waits until 30 minutes after the person last wrote to the agent (row reason in-conversation) Waits until 30 minutes after the person last wrote to the agent Never arises: it only goes to someone who has not replied Not held

Automated Outreach never sends the same Outreach to a person twice, and no Outreach locks a person out of another.

Sending settings

Account-level settings for how sends behave, apart from any one Outreach.

interface SendingSettings {
  minTouchIntervalHours?: number;   // marketing frequency cap, default 48; 0 disables
  safeHours?: {                     // the window in which sends are allowed
    startHour: number;              // 0–23, inclusive
    endHour: number;                // 0–23, exclusive; may wrap midnight
    timeZone: string;               // IANA zone the hours are read in
  };
}

Safe hours are the hours in which sends are allowed: from startHour (inclusive) to endHour (exclusive), read in the configured timeZone, never the person's own zone. The window can wrap midnight: 20 to 8 allows 20:00 through 07:59. startHour equal to endHour, or no safeHours at all, allows any time. Safe hours govern Automated Outreach, Campaigns and scheduled follow-ups; outside the window they wait, and the first pass inside the window sends them. An Automated Outreach or Campaign sendWindow narrows this further: a send goes only when both windows are open. An Automated Outreach rule with ignoreSafeHours: true sends outside safe hours; consent and its own sendWindow still apply. Appointment reminders ignore safe hours unless the scheduling config sets reminders.ignoreSafeHours: false. Named sends ignore safe hours, and a reply in a conversation is never held: safe hours govern starting one.

Frequency cap. minTouchIntervalHours is the minimum gap between marketing messages to one person. Automated Outreach skips a person inside it, a Campaign excludes them, a named send warns, and service Outreaches and scheduled follow-ups are exempt.

Method Path Role Scope
GET /orgs/{org}/agents/{agent}/settings/sending viewer agents:read
PUT /orgs/{org}/agents/{agent}/settings/sending editor agents:write

PUT replaces the whole document; an omitted field clears back to its default.

Adobe Journey Optimizer

Two routes built to be called as custom actions from an Adobe Journey Optimizer (AJO) journey: a send action and a status action. The send action makes the same named send as POST …/outreach/{outreachId}/sends, with the same consent, STOP and idempotency rules. It takes Adobe's own fields and answers in a shape a journey can branch on. How to set the actions up in AJO is in the guide Send from Adobe Journey Optimizer.

Method Path Role Scope
POST /orgs/{org}/agents/{agent}/integrations/ajo/outreach/{outreachId}/sends editor integrations:ajo or people:write
GET /orgs/{org}/agents/{agent}/integrations/ajo/sends/{sendId} viewer integrations:ajo or people:read
GET /orgs/{org}/agents/{agent}/integrations/ajo/outreach/{outreachId}/action-definition viewer integrations:ajo or agents:read

The status action also answers GET …/integrations/ajo/sends?sendId=…, for an action that carries the id as a query parameter rather than a path segment.

This surface never changes incompatibly. AJO freezes an action's request once a live journey uses it, so within integrations/ajo response fields may be added, request fields are added only as optional, and nothing is removed, renamed, retyped or made required. No outcome or reason value is removed or given a new meaning. A change that can't keep this promise gets a new path, and this one keeps working.

One action per Outreach. The Outreach is in the path, and the request's inputs are that Outreach's inputs.

The send action
{
  "profile": {
    "phone": "+19285551234",      // required, E.164: the address the send goes to
    "channel": "rcs",             // default rcs
    "id": "a1b2c3…",              // AJO profileId: feeds only the fallback idempotency key
    "firstName": "Jane",
    "lastName": "Rivera",
    "email": "jane@example.com"   // profile information
  },
  "profileUpdate": "fill",        // default fill
  "consent": {
    "sms": "y",                   // AEP consents.marketing.sms.val
    "evidence": "Booking form opt-in"
  },
  "journey": {                    // the journey position: the idempotency key
    "versionId": "…",             // journeyVersionUID
    "nodeId": "…",                // currentNodeId
    "instanceId": "…"             // instanceUID
  },
  "inputs": { "checkInDate": "2026-10-14", "marina": "Wahweap" },
  "emulator": { "personaId": null },
  "dryRun": false
}

Request fields. Every field is optional except profile.phone, which is required unless emulator.personaId is set. null, an empty string and a missing field all mean "not supplied", and never clear stored data: AJO sends an unmapped optional field as null or leaves it out. AJO does not enforce the types it declares, so a number or boolean where a string is expected is read as a string, and dryRun also accepts "true" and "false". The body is read as JSON whatever its Content-Type.

Field Meaning
profile.phone The phone number to send to, in E.164 (+ and 7 to 15 digits). With profile.channel it names the recipient's channel identity: the send goes to the Person who holds it, or creates a Person to hold it, with source: ajo. It is also written to the Person's profile phone. A missing or non-E.164 number is 400
profile.channel The channel to send on. Default rcs; a channel Outreach can't send on is 400
profile.id The AJO profileId. It is used only to build the fallback idempotency key (below), and is not stored. It never finds a person
profile.firstName, profile.lastName, profile.email Profile information, written to the Person as profileUpdate says when the send is accepted. They never find a person
profileUpdate fill (the default) writes only empty profile fields; overwrite replaces values that differ. See the named send
consent.sms AEP's consents.marketing.sms.val. y is recorded on the send as the caller's consent. n refuses the send (declined). Any other value, including empty, asserts nothing, and the send goes unless the person replied STOP or their consent is recorded as denied, as AJO itself treats an empty value as consent to send. See Consent
consent.evidence Kept on the send. Defaults to AJO consents.marketing.sms.val = <value> for y and n
journey.versionId, journey.nodeId, journey.instanceId The Journey Properties journeyVersionUID, currentNodeId and instanceUID. Together they make the idempotency key (below)
inputs Values for the Outreach's inputs, with the named send's limits. A key with a . or a leading $ is 400. A key the Outreach does not declare is stored anyway and listed in unknownInputs
emulator.personaId A rehearsal: the send goes to this persona's emulator session and nothing reaches a phone. profile must then be empty, or the call is 400. An unknown persona is 400. Leave it empty for a real send
dryRun Decide everything, send nothing and write nothing: answers skipped, or the refusal the real send would get

Idempotency. The send's key is ajo:<versionId>:<nodeId>:<instanceId>:<outreachId>: one send per journey step, per profile, per entry into the journey. A retry of the same call answers duplicate and sends nothing; a guest who enters the journey again gets a new send. Without all three journey fields the key falls back to one send of this Outreach per UTC day to this profile.id, or, without one, to this channel and phone number, and the response says "idempotency": "derived". The key holds a hash of the id, never the id itself. Keys are scoped to the agent.

The response. Every business outcome, refusals included, is 200, and every field is present on every answer, so the action's response is declared once from this example:

{
  "outcome": "queued",
  "reason": "",
  "detail": "",
  "sendId": "osnd_0123456789abcdef01234567",
  "personId": "665f2a…",
  "unknownInputs": [],
  "idempotency": "journey"
}
outcome reason When
queued Accepted. Follow it with the status action
duplicate This journey step already produced a send. sendId is that send, and nothing changed
skipped A dry run. Nothing was written
refused opted_out The number replied STOP to this agent on this channel. Permanent
refused declined consent.sms was n
refused consent_denied The person's consent is recorded as denied
refused missing_inputs The opener needs an input this call did not supply. detail names it

A refusal writes nothing and is final for that call: retrying it changes nothing. sendId is "" unless the outcome is queued or duplicate, and personId is "" on a refusal and on a dry run for a person the send would create. detail is the operator-readable reason for a refusal. A journey that meets a reason it does not know must treat it as "do not reach this person another way".

Errors are problem details (code, title, detail), reserved for the cases where AJO's own error handling is right:

Status code When
400 request.invalid The body is malformed: an unknown or misspelt field, a wrong type, a dotted input key, a missing or non-E.164 profile.phone, a profile.channel Outreach can't send on, a rehearsal that also names a profile, or an unknown persona. errors[] names each field
401, 403 auth.* A missing or wrong key, or one without the scope
404 agent.not_found, agent.outreach.not_found No such agent, or no such Outreach, or it is disabled
429 request.rate_limited The API key is over this action's rate limit: 12,000 calls a minute, in bursts of up to 1,000. The named send's budget is separate
502, 504 agent.outreach_send.upstream, agent.outreach_send.timeout The send may or may not have been accepted. The same call again lands on the same key and answers duplicate if it was

AJO retries every HTTP error except 401, 403 and 404. That retry is safe: nothing is written before a send is accepted, and an accepted send answers duplicate. The API answers within 10 seconds, so a slow moment still leaves AJO time to retry inside its own timeout.

The status action

GET …/integrations/ajo/sends/{sendId} returns the send, flattened. Every field is always present:

{
  "sendId": "osnd_0123456789abcdef01234567",
  "state": "sent",
  "reason": "",
  "reachable": true,
  "delivered": false,
  "read": false,
  "replied": false,
  "final": true,
  "nextAttemptAt": ""
}
Field Meaning
state queued, sending, sent, failed, suppressed or cancelled
reason The send's reason (not_rcs_capable, binding_error, outreach_unavailable, …), or ""
reachable false only when the phone can't receive RCS (not_rcs_capable): the answer to branch on for another channel
delivered, read Positive evidence from the channel. false is not a failure: receipts are best-effort
replied The person has replied
final The state will not change again (delivery receipts aside): sent, failed, suppressed or cancelled
nextAttemptAt While an automatic retry is pending, when it is due; otherwise ""

A transient failure (a capability check or the channel timing out) is retried automatically, four attempts over about 30 minutes, and the send reads queued, final: false until then. failed is always final. failed with binding_error means the agent's RCS channel is misconfigured: it is not retried, reachable stays true, and it is ours to fix, so route it to the journey's error branch rather than to another channel. suppressed with outreach_unavailable means the Outreach was disabled after the send was accepted, and nothing was sent. An unknown sendId is 404 resource.not_found.

The action definition

GET …/integrations/ajo/outreach/{outreachId}/action-definition returns everything to enter in AJO for this Outreach's send action and for the status action, generated from the Outreach, so what you paste always matches what the actions accept:

interface AjoActionDefinition {
  contract: 'ajo/1';
  agentId: string;
  outreachId: string;
  inputs: string[];                  // the Outreach's inputs, each an inputs.<key> field
  send: AjoActionSpec;
  status: AjoActionSpec;             // one status action serves every Outreach on the agent
  runbook: string[];                 // setup and branching steps, in order
}

interface AjoActionSpec {
  name: string;                      // the action name: at most 30 of a-z 0-9 _
  method: 'POST' | 'GET';
  url: string;
  headers: Record<string, string>;   // Authorization: "Bearer <your API key>"
  request?: object;                  // send: the request payload example to paste
  fields?: AjoActionField[];         // send: how to mark each request field
  path?: string;                     // status: the journey's Path expression, @action{<send>.sendId}
  queryParameter?: string;           // status: "sendId", to carry the id as a query parameter instead
  response: object;                  // the response payload example to paste
  errorResponse: object;             // the failure response payload example
}

interface AjoActionField {
  field: string;                     // e.g. "journey.nodeId"
  type: 'string' | 'boolean';
  mode: 'constant' | 'variable';
  value?: string | boolean;          // a constant's value
  optional: boolean;                 // tick "Is optional" in AJO
  mapFrom?: string;                  // where a variable is usually mapped from
}

profile.phone and every Outreach input are required fields; the others are optional. profile.channel is a constant, rcs, and profileUpdate a constant, fill. The runbook notes that the profile's mobilePhone.number must be in E.164, or the send answers 400. The runbook starts with a warning when the Outreach is turned off, because the send action answers 404 until it is turned on again. A definition can't be generated for an Outreach whose inputs AJO can't carry as field names:

Status code When
422 agent.outreach.ajo_incompatible_inputs An Outreach input contains a ., starts with $, or is longer than 128 characters. errors[] names each; rename the input to generate the definition
404 agent.outreach.not_found No such Outreach on this agent

Campaigns

A campaign sends one Outreach to a snapshotted slice of People: choose the Outreach and the audience; stage it to validate everything and see exactly what would go out; then start it and watch progress. The campaign carries nothing authored of its own: the opener, Playbook, Goal, class and follow-up come from the Outreach, and every recipient row is an ordinary send with origin: 'campaign' and campaignId set. Staging never sends anything; starting a staged campaign hands it to the send engine, which works through the snapshot at the channel's governed rate (pause and resume pick up exactly where they left off; cancel stops remaining sends and keeps the report).

The opening is the Outreach's opener Message Template, rendered per recipient. The agent never composes campaign openings freehand: a campaign whose Outreach is disabled or whose opener cannot be resolved cannot be staged, and what you preview is what a run will use.

The campaign's class is the Outreach's class and drives two behaviors: the marketing frequency cap applies to marketing campaigns only, and includePreviouslyContacted defaults false for marketing, true for service (always overridable; the previously-contacted count is reported either way).

Method Path Role Scope
POST /orgs/{org}/agents/{agent}/campaigns editor agents:write
GET /orgs/{org}/agents/{agent}/campaigns viewer agents:read
GET /orgs/{org}/agents/{agent}/campaigns/{campaignId} viewer agents:read
PATCH /orgs/{org}/agents/{agent}/campaigns/{campaignId} editor agents:write
DELETE /orgs/{org}/agents/{agent}/campaigns/{campaignId} editor agents:write
POST /orgs/{org}/agents/{agent}/campaigns/{campaignId}/stage editor agents:write
GET /orgs/{org}/agents/{agent}/campaigns/{campaignId}/recipients viewer agents:read
GET /orgs/{org}/agents/{agent}/campaigns/{campaignId}/preview viewer agents:read
POST /orgs/{org}/agents/{agent}/campaigns/{campaignId}/start editor agents:write
POST /orgs/{org}/agents/{agent}/campaigns/{campaignId}/pause editor agents:write
POST /orgs/{org}/agents/{agent}/campaigns/{campaignId}/resume editor agents:write
POST /orgs/{org}/agents/{agent}/campaigns/{campaignId}/cancel editor agents:write
GET /orgs/{org}/agents/{agent}/campaigns/{campaignId}/report viewer agents:read

Create body:

{ "name": "September check-ins", "outreachId": "checkin-help",
  "audience": { "segmentId": "seg-vip" },
  "sendWindow": { "startHour": 11, "endHour": 20,
                  "timeZone": "America/New_York" } }

audience carries exactly one of segmentId, filter (the People list filter vocabulary), importBatchId, personIds (up to 50,000), or personaIds (1–200 personas: a rehearsal — every send is captured into the persona's sandbox conversation, no outreach channel is needed, and the campaign runs regardless of the real-sends gate). outreachId must name an enabled Outreach on the agent (422 agent.outreach.not_found). Optional fields: includePreviouslyContacted and sendWindow (same shape and validation as Automated Outreach's). A running campaign sends only while both its sendWindow and the account's safe hours are open, and waits otherwise. A PATCH returns the campaign to draft and drops any snapshot; re-stage after editing.

POST …/stage snapshots the audience into per-recipient rows and runs every check that can run before sending: recorded consent must be granted; previously-contacted handling per the class default; the marketing frequency cap; per-recipient resolution of the channel identity the send goes to (a person with none Outreach can send on is excluded no-channel-identity, and one whose identity's channel has no usable binding channel-unresolved); a per-person render of the opening (a template whose block-send slot is missing surfaces here, not mid-send); and opt-out. It returns the campaign with counts (total, sendable, and per-reason exclusions such as no-consent, previously-contacted, frequency-gated, no-channel-identity, channel-unresolved, template-blocked, opted-out), a per-provider duration estimate, and up to five rendered previews. Re-staging replaces the snapshot. A campaign whose opening does not resolve returns 422 with code agent.campaign.invalid; editing or staging a campaign past staged returns 409 agent.campaign.immutable.

GET …/recipients pages the snapshot (state filter, startingAfter cursor, limit ≤ 200). Every staged recipient carries the asid of the identity it is sent to. If the person no longer holds that identity when the row is dispatched, nothing is sent and the send fails with no_channel_identity. GET …/preview?personId= renders the opening for one recipient.

POST …/start requires a staged campaign with sendable recipients. Each recipient is sent at most once per campaign (stable idempotency keys end-to-end), openings are the Outreach's opener exactly (the agent never composes campaign openers), and campaign messages ride the channel's rate-governed batch queue. Each recipient row is also visible as a send carrying origin: 'campaign' and campaignId. pause / resume / cancel act on the sending side only — conversations already opened continue normally.

GET …/report returns the live outcome aggregation: { states: { sent, delivered, read, failed, excluded, … }, reasons: { "no-consent": n, … }, test } — delivered/read promote lazily from delivery receipts as reads of the report reconcile them.

RCS doesn't let users start a conversation with a business. A test link is a shareable URL to a public phone-entry page: a tester opens it, enters their phone number, and the real agent initiates the RCS conversation with them, in its Default Playbook. It is a testing feature, the counterpart of early access for getting the agent onto a phone without an operator sending. Links are minted against one of the agent's RCS channel bindings and are use-limited and time-limited.

interface OutreachLink {
  id: string;
  code: string;             // URL-safe code; the link is {url}
  url: string;              // shareable page, e.g. https://embed.anychat.ai/o/{code}
  bindingId: string;        // the RCS channel binding it starts conversations on
  label?: string;           // operator-facing note
  maxUses: number;
  usesRemaining: number;
  redemptionCount: number;
  expiresAt: string;        // ISO-8601
  disabled: boolean;
  disabledReason?: 'binding_deleted'; // set only when Anychat disabled it
  createdAt?: string;
}
Method Path Role Scope
GET /orgs/{org}/agents/{agent}/outreach-links viewer agents:read
POST /orgs/{org}/agents/{agent}/outreach-links editor agents:write
PATCH /orgs/{org}/agents/{agent}/outreach-links/{id} editor agents:write
DELETE /orgs/{org}/agents/{agent}/outreach-links/{id} editor agents:write
POST /orgs/{org}/agents/{agent}/outreach-links/{id}/redeem editor agents:write

Create body:

{ "bindingId": "<rcs channel binding id>", "label": "Trade-show QR",
  "maxUses": 100, "expiresAt": "2026-07-01T00:00:00Z" }
bindingId is required and must be an RCS binding on this agent. maxUses defaults to 25 (1–500). expiresAt defaults to 14 days out and may be at most 30 days out. Validation failures return 400 with a human-readable error.

PATCH takes exactly { "disabled": true | false } — pause or resume a link without losing its remaining uses. DELETE removes the link (204). The redemption audit log is not exposed; redemptionCount summarizes it.

Deleting the link's channel binding (DELETE …/channels/{bindingId}) disables the link rather than deleting it: disabled becomes true and disabledReason is "binding_deleted", so the list still shows the link, why it stopped, and its counts. The public page then tells visitors the link is disabled. A link an operator paused is left paused and gets the same reason. Resuming a link clears disabledReason, but a link whose channel is gone cannot start a conversation, so mint a new one on a live channel instead.

POST .../outreach-links/{id}/redeem redeems the link as a persona — body { "personaId": "<id>" }. The link is validated exactly as the public page validates it (expiry, disabled, uses remaining, and that it belongs to this agent), but no use is consumed: this is a sandbox rehearsal, not a real redemption. The answer is 202 { "status": "queued", "personaId": "<id>", "userId": "<asid>" } — the agent composes the opening behind the call and it arrives in the persona's sandbox inbox (GET .../personas/{personaId}/turns), or in the emulator watching that persona.

Errors: 404 agent.persona.not_found (no such persona on this agent), 409 agent.persona.unavailable (the persona has no emulator identity — reset it first), 404 resource.not_found (no such link on this agent), 409 agent.outreach_link.unavailable (expired, disabled, used up, or the link's preflight failed — the reason is in detail), 404 agent.sandbox.unavailable (the agent has no sandbox transport).

Tests

Tests are test suites for an agent's behavior. A suite is an agent-scoped, named collection of cases; a case describes one situation the agent must handle well (who the person is and what the agent already knows about them, how the conversation opens, what the person says, and what must be true of the agent's replies). A run executes a selection of cases against the agent in the background, and each case ends in a result: a transcript, the outcome of every deterministic check, an LLM judge's verdict on the operator's own criteria with quoted evidence, and the usage it cost. Cases run through the agent's production conversation pipeline with the model the agent is configured to use, against a fresh copy of a persona, in the sandbox — every effect is captured. See How a Test runs for the execution model and the console guide for authoring patterns.

Suites and cases are Definition data (they travel with the agent); runs and results are operational data with their own retention (results 90 days, run summaries one year). Ids are opaque strings. There is no org feature gate; the per-org quota is the limiter.

Renamed routes

These routes were served at …/evaluations… until September 18, 2026. The old paths were removed with the rename; call …/tests….

Method Path Role Scope
GET /orgs/{org}/agents/{agent}/tests/suites viewer agents:read
POST /orgs/{org}/agents/{agent}/tests/suites editor agents:write
GET /orgs/{org}/agents/{agent}/tests/suites/{suite} viewer agents:read
PATCH /orgs/{org}/agents/{agent}/tests/suites/{suite} editor agents:write
DELETE /orgs/{org}/agents/{agent}/tests/suites/{suite} editor agents:write
GET /orgs/{org}/agents/{agent}/tests/suites/{suite}/cases viewer agents:read
POST /orgs/{org}/agents/{agent}/tests/suites/{suite}/cases editor agents:write
POST /orgs/{org}/agents/{agent}/tests/suites/{suite}/cases/preview viewer agents:read
POST /orgs/{org}/agents/{agent}/tests/suites/{suite}/cases/generate editor agents:write
POST /orgs/{org}/agents/{agent}/tests/suites/{suite}/cases/compile editor agents:write
GET /orgs/{org}/agents/{agent}/tests/suites/{suite}/cases/{case} viewer agents:read
PUT /orgs/{org}/agents/{agent}/tests/suites/{suite}/cases/{case} editor agents:write
DELETE /orgs/{org}/agents/{agent}/tests/suites/{suite}/cases/{case} editor agents:write
POST /orgs/{org}/agents/{agent}/tests/suites/{suite}/cases/{case}/preview viewer agents:read
GET /orgs/{org}/agents/{agent}/tests/runs viewer agents:read
POST /orgs/{org}/agents/{agent}/tests/runs editor agents:write
GET /orgs/{org}/agents/{agent}/tests/runs/{run} viewer agents:read
POST /orgs/{org}/agents/{agent}/tests/runs/{run}/pause editor agents:write
POST /orgs/{org}/agents/{agent}/tests/runs/{run}/resume editor agents:write
POST /orgs/{org}/agents/{agent}/tests/runs/{run}/cancel editor agents:write
POST /orgs/{org}/agents/{agent}/tests/runs/{run}/pin editor agents:write
POST /orgs/{org}/agents/{agent}/tests/runs/{run}/unpin editor agents:write
GET /orgs/{org}/agents/{agent}/tests/runs/{run}/results viewer agents:read
GET /orgs/{org}/agents/{agent}/tests/runs/{run}/results/{result} viewer agents:read
GET /orgs/{org}/agents/{agent}/tests/quota viewer agents:read
GET /orgs/{org}/agents/{agent}/tests/cases/{case}/history viewer agents:read
POST /orgs/{org}/agents/{agent}/tests/suites/{suite}/cases/{case}/copy editor agents:write
POST /orgs/{org}/agents/{agent}/tests/cases/from-conversation editor agents:write
GET /orgs/{org}/agents/{agent}/tests/pass-rates viewer agents:read

Errors use problem details with these codes:

Status Code When Extra fields
404 agent.test.not_found Unknown suite, case, run, result, conversation, or turn
422 agent.test.invalid A case fails lint on write or at run start; a selection matches no cases; a run would exceed maxCasesPerRun; a generate request has an empty description or a count outside 1–15; a compile request has a brief missing scenario or expected; a schedule is malformed; a from-conversation request names nothing or a conversation whose history is gone lint (findings, when lint failed); totalResults, maxCasesPerRun (when the case count is the problem)
409 agent.test.run_active Starting a run, or deleting a suite, while a run is queued, running, or paused on the agent activeRunId
409 agent.test.run_immutable A lifecycle verb the run's status does not allow
409 agent.test.system_suite A write to a system suite or one of its cases playbookId
429 agent.test.quota_exceeded The run's estimate exceeds the units available today or the per-run ceiling estimate, remaining, maxUnitsPerRun, resetsAt, suggestion
503 agent.test.authoring_unavailable generate or compile on a deployment without an authoring model configured

Suites

interface TestSuite {
  id: string;
  agentApplicationId: string;
  name: string;
  description?: string;
  defaults?: {
    scenario?: { channel?: TestChannel; provider?: string };
    samples?: number;                       // 1–5
  };
  tags?: string[];
  system?: boolean;                         // a platform-owned suite (see Examples as Tests)
  playbookId?: string;                      // system suites: the Playbook whose Examples it holds
  schedule?: TestSuiteSchedule;             // see Schedules
  nextRunAt?: string;                       // with schedule: when the next scheduled run starts
  lastScheduledRunId?: string;              // with schedule: the last run the schedule started
  caseCount?: number;                       // list only
  lastRun?: { id, status, counts, completedAt?, createdAt };   // list only: the newest run that selected this suite
  createdAt: string;
  updatedAt: string;
}

defaults are merged under every case in the suite; a case's own values win. The judge model is a platform setting and is not part of the suite or the case. POST …/suites takes { name, description?, defaults?, tags?, schedule?, cases? } where cases is an array of case documents; it returns 201 with the suite plus cases[], each case carrying its lint findings. PATCH accepts any of name, description, defaults, tags, schedule (null clears the schedule). DELETE removes the suite and its cases and is refused with 409 agent.test.run_active while a run is active on the agent; results of past runs are kept. PATCH and DELETE on a system suite are refused with 409 agent.test.system_suite.

{ "name": "Booking behavior",
  "description": "Everything about getting to and through a booked call.",
  "defaults": { "scenario": { "channel": "rcs" }, "samples": 1 },
  "tags": ["scheduling"],
  "cases": [ { "title": "Books the first offered slot", "turns": ["Do you have anything Tuesday?", { "slot": 0, "fallback": "The first one works." }],
               "expect": { "checks": { "pickerShown": true, "appointmentCreated": true } } } ] }

Cases

interface TestCase {
  id: string;
  suiteId: string;
  agentApplicationId: string;
  key?: string;                 // mirrored from the document
  title: string;                // mirrored from the document
  tags?: string[];              // mirrored from the document
  document: TestCaseDocument;
  lint?: LintFinding[];         // on create, get, put, and list (see Lint on writes)
  lastVerdict?: { state: 'passed' | 'failed' | 'errored'; runId: string; finishedAt?: string };   // list only
  createdAt: string;
  updatedAt: string;
}

POST …/cases takes the case document as the body and returns 201; PUT …/cases/{case} replaces the document. Both lint the document against the agent as it is now: an error rejects the write with 422 agent.test.invalid and the findings in lint; warnings and infos come back with the saved case. Editing a case never rewrites the results of past runs, which keep a snapshot of the case as run.

A case can be written two ways. Write the brief (the situation and the expected behavior, in English) and have the platform compile it into the structured scenario, turns, and expect; or write the structure yourself. A document with a brief and no turns is a draft: the write succeeds with the warning case.brief.not-compiled, and a run that selects the case errors it at the scenario stage instead of executing it.

The case document

The document is the operator's authored case: JSON on the wire, YAML in the console's editor. Its JSON Schema (draft-07) is published at /schemas/test-case.schema.json; title and turns are required and unknown keys anywhere are errors.

Field Type Meaning
key string Operator-facing slug, ^[a-z0-9][a-z0-9-]{0,118}$, unique within the suite
title string Required; up to 300 characters
brief object The use case in English: { scenario, expected }, each 1–2,000 characters. scenario says who the person is, what the agent already knows about them, how the conversation starts, and what they say; expected says what must be true of the agent's behavior. The structured fields below are compiled from it and stay editable.
notes string Why the case exists (the problem seen, when, where); up to 4,000 characters
tags string[] Up to 20; a run can select by tag
scenario object The world the case runs in (below)
turns array The person's side, one directive per turn (below); up to 40 entries, 20 is the guideline. May be empty only when brief is set (a draft, see Lint on writes).
expect object checks and judge (below)
samples integer Repetitions per run, 1–5; the case passes only if every sample passes
origin object Set by the platform, with kind saying which: { kind: "conversation", conversationId?, userId, turnId?, recordedAt } for a case made from a conversation; { kind: "playbookExample", playbookId, exampleIndex } for a case compiled from a Playbook Example; { generatedFrom, generatedAt } for a generated case, where generatedFrom is the description the generate request was given (up to 4,000 characters)

scenario decides what the agent knows before the first turn. Every field maps onto something the runtime reads on a live turn, and the preview shows the rendering.

Field Meaning
channel rcs, sms, whatsapp, or appleMessages. Default from the suite, else rcs.
provider Provider name for the channel; default is the agent's binding for that channel, else the channel's default profile.
persona Who the case runs against — a fresh copy per case of a persona: { id } names a saved one, { preset: { fields?, consent?, historySeed? } } writes one inline, and null (or absent) is a stranger with no record. preset.fields are the authorable Person fields: fullName, firstName, lastName, email, phone (drives the likely-timezone line), company, title, productInterest (reconciled against the Things catalog), status (new / contacted / qualified / converted / lost), source, customFields (map of the agent's People fields; values are strings, and a number or boolean such as isCheckedIn: true is accepted and stored as its string; a value that doesn't fit the field's declared type is a lint warning), assignedRepresentativeId, tags, notes; preset.consent is { status: granted / denied / unknown }; preset.historySeed (up to 40 { role, content }) is prior turns the conversation enters with. notes and tags are stored on the persona but never rendered to the agent; lint says so.
appointments Up to 5 existing appointments for the persona: { startsIn, durationMinutes?, representativeId?, status? }. startsIn is an ISO-8601 duration from now (PT15M, P2D, -P1D); status is scheduled (default), cancelled, or completed.
start How the conversation opens; exactly one of inbound: true (the person writes first; the default), outreach: { outreachId?, inputs?, formData?, campaign? } (the agent started it: outreachId names the Outreach, which supplies the opener, the Playbook and the class; inputs fills its opener's slots; formData is what an intake form would have carried; campaign records campaign attribution), reminder: true (the entering context was written by the appointment-reminder sweep), or resume: { activePlaybook } (mid-conversation, with history).
history Prior turns entering the case, up to 40: { role: user / assistant, content }.
clock A fixed clock: { now } (ISO-8601 instant) or { weekday, time, timezone } (the next such moment). Accepted and validated, but not applied yet: the case runs at real time and lint reports case.clock.not-applied. Use relative dates in turns ("next Tuesday") until it ships.
stubs Responses for custom integrations during the case, keyed by integration id. A stubbed integration returns its stub; an unstubbed one is captured and answers the agent that the call was captured, not made, and that it should proceed as if it had succeeded. Either way the call is recorded as a captured effect.

turns is the person's script. Each entry is a plain string (the person types it) or one directive object. Tap directives take a fallback string the person types when the affordance is missing; with no fallback, a missing affordance errors the case.

Directive Meaning
"text" The person sends this text.
{ chip, fallback? } Tap the reply chip whose label matches chip: a case-insensitive substring, or a /regex/i literal.
{ slot, fallback? } Pick the time slot at index slot (0-based) from the last time picker.
{ confirm, fallback? } Answer the last confirmation prompt yes (true) or no (false).
{ system } Deliver a channel system event: optOut, optIn, subscribed, or unsubscribed, as the gateway would.
{ action, label? } Tap a suggested action: calendar, dial, url, or viewLocation; label picks one when several are offered.
{ media, url, text? } Send an image or video at url, with optional caption.
{ simulate: { goal, persona?, knows?, until?, maxTurns? } } Hand the rest of the conversation to an LLM-played person with a goal. Must be the last turn. Not available yet: lint reports case.turns.simulate-not-available and the case errors at run time.

expect.checks are deterministic and evaluated over what reached the wire on every turn and what was persisted when the case ended. The vocabulary is closed; an unknown key is a lint error. Every key is optional.

Check Type Passes when
finalReplyNonEmpty boolean The last agent turn carried text (or did not, for false)
pickerShown boolean A time picker reached the wire on some turn
appointmentCreated boolean An appointment was created during the case and not cancelled
activeAppointments integer Exactly this many non-cancelled appointments exist for the person at the end
outcomes string[] The Flow outcomes recorded during the case equal this list, in order
activePlaybookAtEnd string This Playbook is active when the case ends
personFields map Each named Person field (profile column or People field) equals the value, case-insensitively (a number or boolean is compared as its string)
chipsOnAnyTurn string[] Each label appeared as a chip or slot on some turn
chipsOnLastMessage boolean The final message offered tappable options (chips, slots, choices, or a confirm)
textIncludes string[] All agent text contains each substring (case-insensitive)
textExcludes string[] All agent text contains none of the substrings
textMatches string[] All agent text matches each regular expression (case-insensitive)
toolsCalled map Each named action was invoked exactly this many times (API-wire names such as scheduling.createAppointment; refused calls do not count)
toolsNotCalled string[] None of the named actions was invoked
noWarnings boolean No turn record carried a runtime warning
maxModelCallsPerTurn integer No turn needed more model calls than this
maxTurnsToOutcome integer A Flow outcome was reached within this many turns
replyCount { min?, max? } Every non-system turn produced between min and max agent messages
noReplyTo string[] Each listed system event (optOut, optIn, subscribed, unsubscribed) was sent and got no reply
partsOnLastTurn string[] The final agent turn carried each listed part kind: text, card, carousel, showcase, ask.time, ask.choice, confirm, media, or chips (message-level suggestion chips). The check a Playbook Example's reply shape compiles to

expect.judge asks an LLM judge to grade the conversation. criteria is a list of up to 20 { id, text } where id matches ^[a-z0-9][a-z0-9-]{0,58}$ and text is a plain-language statement the judge marks pass or fail with a one-sentence reason and the agent line the verdict rests on. Every criterion must pass; the judge grades nothing else. Write criteria the way you would write a contract: name the observable fact and the turn it applies to ("the first reply names Jordan"), and prefer a check whenever one can express the same thing.

{
  "key": "reminder-cant-make-it",
  "title": "A can't-make-it reply to the reminder cancels cleanly and offers to rebook",
  "brief": {
    "scenario": "Dr. Mira Chen, a qualified lead assigned to Jordan with a demo 15 minutes from now, replies to the appointment reminder on RCS: sorry, something came up, she can't make it today.",
    "expected": "The agent cancels the appointment without asking which one, offers to rebook before she has to ask, and shows a time picker."
  },
  "tags": ["scheduling", "reminders", "regression"],
  "notes": "Observed 2026-07-16: the reply landed on an expired context and the agent asked which appointment.",
  "scenario": {
    "channel": "rcs",
    "persona": { "preset": { "fields": {
                   "fullName": "Dr. Mira Chen", "firstName": "Mira", "phone": "+15125557432",
                   "company": "Chen Aesthetic Partners", "productInterest": "MediSpa platform",
                   "assignedRepresentativeId": "rep_jordan_alvarez" } } },
    "appointments": [ { "startsIn": "PT15M", "durationMinutes": 30, "representativeId": "rep_jordan_alvarez" } ],
    "start": { "reminder": true }
  },
  "turns": [
    "Sorry, something came up. I can't make it today.",
    { "chip": "/rebook|new time/i", "fallback": "Can we find another time next week?" },
    { "slot": 0, "fallback": "The first one works." }
  ],
  "expect": {
    "checks": { "toolsCalled": { "scheduling.cancelAppointment": 1, "scheduling.createAppointment": 1 },
                "activeAppointments": 1, "pickerShown": true, "textExcludes": ["which appointment"], "noWarnings": true },
    "judge": { "criteria": [
      { "id": "acknowledges", "text": "The first reply acknowledges the cancellation without asking which appointment." },
      { "id": "rebook-offer", "text": "The agent offers to rebook before the person has to ask." } ] }
  },
  "samples": 2
}

Documents saved before September 10, 2026 may still carry expect.judge.dimensions and expect.judge.model; both are dropped on the next write and are not part of the schema.

Lint on writes

Every create, replace, and run start lints the case against the agent and returns findings in the definition lint shape (severity, code, path as a JSON pointer into the document, message; no fix). Errors reject the write with 422 agent.test.invalid and the findings in lint; at run start the body carries lint: [{ caseId, findings }] for every failing case and the run is not created.

Family Examples Severity
Schema case.schema.<keyword>: an unknown key (case.schema.additionalProperties, listing the supported checks when the key is under checks) or a value of the wrong type (when a value can take several shapes, such as persona, only the errors of the shape it is closest to are reported), case.turns.empty (no turns and no brief), samples above 5, a directive matching no shape error
Draft case.brief.not-compiled: the case has a brief but no turns. The write succeeds; compile the brief before running warning
References case.start.outreach.missing (an outreach start must name its outreachId), case.start.outreach.unknown, case.start.resume.playbook-unknown, case.persona.representative-unknown, case.appointments.representative-unknown, case.checks.playbook-unknown, case.checks.tool-unknown error
Structure case.start.multiple (two start modes), case.turns.too-many (over 40), case.turns.simulate-not-last, case.clock.mixed, case.clock.invalid, case.checks.text-matches-invalid, case.checks.reply-count-range, case.judge.duplicate-criterion error
Consistency case.persona.custom-field-undeclared, case.persona.custom-field-type (the value doesn't fit the field's declared type), case.persona.product-interest-unmatched, case.appointments.no-scheduling, case.start.reminder.no-appointment, case.stubs.unknown-integration, case.turns.long (over 20), case.turns.slot-without-scheduling, case.checks.scheduling-not-enabled, case.checks.person-field-undeclared, case.checks.outcome-unknown, case.checks.no-reply-without-system-turn, case.expect.empty (the case asserts nothing) warning
Runtime support case.clock.not-applied, case.turns.simulate-not-available, case.persona.not-prompt-visible, case.turns.no-fallback info

Preview

POST …/cases/{case}/preview composes, without calling a model, what the first turn would see; POST …/cases/preview does the same for an unsaved document in the body (a schema error returns 422). Suite defaults are applied first.

interface TestCasePreview {
  brief: {
    person?: string;        // the person block as the agent reads it (from the persona preset)
    formData?: string;      // the intake block for an outreach start
    currentDate: string;    // the current-date line
    channel: string;        // the channel line
  };
  resolved: {
    likelyTimezone?: string;
    productInterest?: { raw: string; mapped: boolean; display: string };
    representative?: { id: string; name: string; title?: string };
    startingPlaybook?: string;   // the Playbook the case opens in
    greeting?: string;           // the rendered opener, for outreach starts
    greetingOnly?: boolean;      // true when the opener goes out without a model turn
  };
  lint: LintFinding[];
}

Generate and compile

Both routes have the platform's authoring model write case structure from English, using the agent's own Definition (Playbooks and when to use them, Message Templates, representatives, People fields, Things, scheduling, outreach settings) so that the ids and names it references exist on the agent. Each draft is linted against the agent; drafts with errors get one repair round. The tokens spent are metered as operator-initiated assist usage on the agent, not as case units. Both are long calls: allow up to three minutes for a response. On a deployment with no authoring model configured, both return 503 agent.test.authoring_unavailable.

POST …/suites/{suite}/cases/generate writes new cases for a described situation and creates them in the suite.

{ "description": "A prospect with a booked demo messages to say they can't make it.",
  "count": 5,
  "tags": ["scheduling"] }

With source: "faqs" the same route builds a Fact coverage suite from the agent's own Facts instead (the wire value keeps its historical name):

{ "source": "faqs",
  "faqIds": ["parking", "cancellation"],
  "wordingsPerFaq": 3 }
Field Meaning
source Optional, description (default) or faqs. Which authoring path the fields below feed.
description Required unless source is faqs. The situation to cover, in English. Stored on each case as origin.generatedFrom.
count Required unless source is faqs, 1–15. How many cases to write; fewer come back when a draft cannot be repaired.
tags Optional, description source only. Added to every generated case.
faqIds faqs source only. Optional subset of Fact ids; default every enabled Fact.
wordingsPerFaq faqs source only. 1–6, default 2: the canonical question plus that many alternates less one.

With the description source, the cases differ from one another and from the suite's existing cases (their briefs are given to the model). Each carries a brief, the structure, and origin.generatedFrom / origin.generatedAt.

With the faqs source no authoring model is used: usage comes back zeroed with model: "none". The route builds one case per enabled Fact and wording: key faq-<id> for the canonical question and faq-<id>-2, faq-<id>-3, and so on for alternates; title FAQ: <question>; tags faq and faq:<id>; one turn, the wording; and an expect of finalReplyNonEmpty (plus a textIncludes of the exact answer for the canonical wording of an Exact Fact) with one judge criterion: the reply answers from the confirmed answer, every detail, number, price, and commitment as written, nothing added or contradicted, and a deferral fails. A case whose key already exists in the suite is replaced, so re-running after editing an answer refreshes it. When the agent has no enabled Facts, or none of the given faqIds is enabled, the response is 422 agent.test.invalid.

For both sources the response is 201:

interface TestCase$GenerateResponse {
  cases: TestCase[];                                   // created in the suite, each with lint
  dropped: Array<{ title?: string; findings: LintFinding[] }>;   // drafts that still had errors after repair
  usage: { model: string; calls: number; inputTokens: number; outputTokens: number; cacheReadTokens: number };
}

POST …/suites/{suite}/cases/compile turns one brief into a structured document. Nothing is saved; write the returned document with POST …/cases or PUT …/cases/{case}.

{ "brief": { "scenario": "A prospect who booked a demo 15 minutes from now, on RCS, messages: sorry, I can't make it today.",
             "expected": "The agent cancels the appointment politely and offers to rebook with a time picker." },
  "current": { "title": "…", "turns": ["…"], "expect": { "checks": { "noWarnings": true } } },
  "caseId": "66f1…" }
Field Meaning
brief Required. scenario and expected, both non-empty.
current Optional. The document as it is now. The compiler makes the smallest change that satisfies the brief and keeps everything the brief does not contradict, so hand edits to the structure survive.
caseId Optional. The case being edited, so its own title and key are not treated as siblings when keeping the title distinct.

The response is 200:

interface TestCase$CompileResponse {
  document: TestCaseDocument;   // brief set to the request's brief; suite defaults not applied
  lint: LintFinding[];                // against the agent as it is now; may still contain errors
  usage: { model: string; calls: number; inputTokens: number; outputTokens: number; cacheReadTokens: number };
  caveat?: string;                    // one line: what the brief asked for that a case cannot express
}

Runs

POST …/runs starts a run. The body selects cases; an empty body selects every case on the agent.

{ "suiteIds": ["66f0…"], "caseIds": ["66f1…"], "tags": ["scheduling"], "samplesOverride": 1 }

suiteIds, caseIds, and tags narrow the selection together; samplesOverride (1–5) replaces every case's samples for this run. Starting a run lints every selected case, computes an estimate in case units, checks the quota, snapshots each case into cases × samples result rows, records what is under test, and leaves the run queued for the driver, which picks it up within a minute. The response is 202 with the run. A second run while one is queued, running, or paused returns 409 agent.test.run_active with activeRunId; a run whose estimate exceeds the units available today or maxUnitsPerRun returns 429 agent.test.quota_exceeded with estimate, remaining, maxUnitsPerRun, resetsAt (next UTC midnight), and a suggestion naming a selection that fits. The API records who asked in requestedBy.via from the X-Anychat-Agent header: mcp for the MCP server, copilot for the Copilot, otherwise api (the console records console).

interface TestRun {
  id: string;
  agentApplicationId: string;
  selection: { suiteIds?: string[]; caseIds?: string[]; tags?: string[] };
  status: 'queued' | 'running' | 'paused' | 'completed' | 'cancelled' | 'failed';
  pauseReason?: 'operator' | 'quota';
  requestedBy: { userId?: string; via: 'console' | 'api' | 'mcp' | 'copilot' | 'schedule' };
  attribution: {                       // what was under test
    definitionUpdatedAt?: string;
    definitionHash: string;            // content hash of the agent's Definition
    templateId?: string; templateVersion?: number;
    runtimeBuild: string;              // the agent service build
    llm: { provider: string; model: string };
    judge: { model: string; promptVersion: number };
    simulator?: { model: string; promptVersion: number };
  };
  counts: { total: number; passed: number; failed: number; errored: number; skipped: number };
  estimate: { units: number; tokens: number };
  usage: { units: number; inputTokens: number; outputTokens: number; cacheReadTokens: number; judgeTokens: number; simulatorTokens: number };
  diff?: { against: string; newlyFailing: string[]; newlyPassing: string[]; stillFailing: string[] };   // case ids
  pinned?: boolean;
  pending?: number;                    // result rows not yet started (run views only)
  inFlight?: Array<{ resultId: string; caseId: string; title: string; sample: number }>;
  startedAt?: string; pausedAt?: string; completedAt?: string; cancelledAt?: string;
  failReason?: string;
  createdAt: string; updatedAt: string;
}

GET …/runs lists runs newest first (?suiteId=, ?status=, ?limit= up to 200). GET …/runs/{run} reconciles counts from the result rows on every read and adds pending and inFlight.

The lifecycle verbs are POST with no body and return the run:

Verb From Effect
pause queued, running Stops claiming cases. A case in flight finishes its current turn and returns to pending; it restarts from its scenario on resume. pauseReason is operator. A run the quota pauses (pauseReason: quota) resumes on its own when the daily budget resets.
resume paused Continues from the pending rows.
cancel queued, running, paused Pending cases become skipped; in-flight cases finish their turn and become cancelled.
pin / unpin any Marks the run as the baseline: later runs on the agent compute diff against the pinned run instead of the previous completed run with the same selection. Pinning does not extend retention.

A verb the status does not allow returns 409 agent.test.run_immutable. When no rows are pending or in flight, the run becomes completed, counts are finalized, and diff is computed over cases decided in both runs (a case passes only if every sample passed). failed means the run could not continue (for example the agent was deleted); failReason says why.

Results

interface TestResult {
  id: string;
  runId: string; suiteId: string; caseId: string;
  caseKey?: string; caseTitle: string;
  sample: number;                      // 1-based
  caseSnapshot: TestCaseDocument;   // the case as run
  state: 'pending' | 'claimed' | 'running' | 'passed' | 'failed' | 'errored' | 'skipped' | 'cancelled';
  userId: string;                      // the sandbox identity the turns ran under; the key for the agent's turn records
  transcript?: Array<{ role: 'user' | 'agent' | 'system'; text: string; rendered?: string; turnIndex?: number; directive?: string; via?: 'tap' | 'text' | 'simulated' | 'system' }>;
  checks?: Array<{ key: string; pass: boolean; detail: string }>;
  judge?: {
    criteria: Array<{ id: string; pass: boolean; reason: string; evidence: string }>;
    summary: string; worstTurn: string; model: string; promptVersion: number;
  };
  effects?: Array<{ kind: string; summary: string; payload?: unknown; at: string; turnIndex?: number }>;   // side effects captured instead of performed (see Turns → CapturedEffect)
  error?: { stage: 'scenario' | 'turn' | 'simulate' | 'checks' | 'judge' | 'budget'; message: string };
  usage?: { units: number; agent: TokenUsage; simulator?: TokenUsage; judge?: TokenUsage };
  turnIds?: string[];
  claimedAt?: string; startedAt?: string; finishedAt?: string;
  createdAt: string; updatedAt: string;
}

interface TokenUsage { inputTokens: number; outputTokens: number; cacheReadTokens: number; weighted: number }

passed means every check held and every criterion passed; failed lists what did not, with the check's detail or the judge's reason and evidence; errored means the case could not reach a verdict (error.stage says where: the scenario could not be built or the case was an uncompiled draft, a directive found no affordance and had no fallback, the judge failed twice, or the case exceeded the per-case unit ceiling) and is never counted as failed; skipped and cancelled come from cancel. Check keys are the check name, suffixed with the item for list and map checks (textIncludes.you're booked, personFields.practice_type). Captured effects also appear in the transcript as system lines, for example [notify: New call booked for Tuesday 2 pm].

GET …/runs/{run}/results pages the rows: ?state= and ?caseId= filter, ?limit= up to 200 (default 50), and ?startingAfter=<result id> continues; the envelope is { data, hasMore }. GET …/runs/{run}/results/{result} returns one result in full.

GET …/cases/{case}/history returns { data } of the case's decided results across runs, newest first (?limit=, default 30, up to 200), each { id, runId, sample, state, finishedAt, usage, error? }.

Examples as Tests

Every Playbook Example doubles as a Test. For each Playbook that has Examples, the platform keeps a system suite (system: true, playbookId) named after the Playbook, with one case per Example, keyed example-1, example-2, and so on. The case is compiled without a model: the Example's person turns become the script; the case starts in the Example's Playbook (start.resume, or start.inbound for the Default Playbook); the checks describe the shape of the Example's reply as rendered by the same compiler that renders a Message (finalReplyNonEmpty, activePlaybookAtEnd, and, when the reply has them, pickerShown, chipsOnLastMessage, partsOnLastTurn); and one judge criterion, matches-example, asks whether the final reply says in substance what the Example's reply says. The case carries origin: { kind: "playbookExample", playbookId, exampleIndex }.

The suite follows the Playbook: it is created when a Playbook with Examples is saved (or on the first read of the suite list), its cases are regenerated in place when an Example changes (a case keeps its id and its verdict history), and it is deleted with the Examples. It is listed and read like any suite and can be run like any suite; PATCH and DELETE on it, and every case write inside it, are refused with 409 agent.test.system_suite. To edit a compiled case, copy it:

POST …/suites/{suite}/cases/{case}/copy with { suiteId } creates a copy of the case in the named suite of the same agent and returns 201 with the copy (with lint). The copy has no origin, notes where it came from, and takes the next free key when the source key is taken. The target must not be a system suite.

GET …/pass-rates returns one row per Playbook with Examples:

interface TestPassRates$Response {
  data: Array<{
    playbookId: string;
    suiteId: string;        // the Playbook's system suite
    passed: number;         // cases whose every sample passed in the newest run that decided any of them
    total: number;          // cases decided in that run; 0 when the suite has never been run
    runId?: string;
    lastRunAt?: string;
  }>;
}

Make a Test from a conversation

POST …/cases/from-conversation makes a case from a conversation, live or sandbox, and returns 201 with the case (with lint) plus the suite it landed in.

{ "turnId": "66f1…", "expectation": "Shows real times instead of promising to look." }
Field Meaning
conversationId The conversation, as /v1 …/turns[].conversationId.
userId The ASID of the person's channel identity: the whole thread with them.
turnId A turn (/v1 …/turns[].id) to stop at, inclusive; its conversation is implied.
suiteId The suite to create the case in. Default: the agent's "Recorded from conversations" suite, created on first use.
title Optional; default names the conversation's date and the person's first line.
expectation Optional; kept as the case's brief.expected, with the replayed lines as brief.scenario.

At least one of conversationId, userId, turnId is required. The person's lines up to that point become turns (a tapped chip or slot becomes the matching directive with the tapped label as fallback); the scenario carries the channel and a stand-in for the person (first name, company, product interest, status; never phone or email); and the checks describe what the agent's last reply in that window actually did (finalReplyNonEmpty, pickerShown, chipsOnLastMessage, partsOnLastTurn, toolsCalled, activePlaybookAtEnd), for the operator to edit into what it should do. The case carries origin: { kind: "conversation", conversationId?, userId, turnId?, recordedAt }. 422 agent.test.invalid when nothing identifies the conversation or the person's messages are no longer available (history is kept 30 days); 404 agent.test.not_found for an unknown turn or conversation.

Schedules

A suite with a schedule is run by the platform without anyone starting it:

interface TestSuiteSchedule {
  every: 'daily' | 'weekly';
  at?: string;        // HH:mm in timezone; default 06:00
  weekday?: 'monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday' | 'sunday';   // weekly only; default monday
  timezone?: string;  // IANA zone; default UTC
}

Set it on POST …/suites or PATCH …/suites/{suite} (schedule: null clears it; a malformed schedule is 422 agent.test.invalid). The suite then carries nextRunAt; within a minute of it the platform starts a run of the whole suite with requestedBy.via: "schedule" and advances nextRunAt, recording the run as lastScheduledRunId. Quota applies as to any run. While another run is active on the agent the scheduled run waits and is tried again the next minute. A scheduled run that cannot start for any other reason (the daily budget, a case that fails lint), or that completes with failed or errored cases, notifies the team through the agent's Notify capability destination (Slack or email), when one is configured.

Quota

Every result is weighted in case units after it runs: max(1, ceil(weightedTokens / UNIT_TOKENS)), where weighted tokens sum the agent's turns, the judge, and any simulator calls with output counted at 5× and cache reads at 0.1×, and UNIT_TOKENS is 40,000 in the current calibration. A short scripted case with a judge call is one unit. Units are metered as billable usage (evaluation.cases and evaluation.case_units) for decided results only; an errored result is not billed. GET …/quota returns today's position:

interface TestQuota {
  limits: { dailyUnits: number; maxUnitsPerRun: number; maxCasesPerRun: number; perCaseUnitCeiling: number };
  org:   { used: number; limit: number; remaining: number };     // per UTC day
  agent: { used: number; limit: number; remaining: number };     // agent limit ≤ org limit
  resetsAt: string;                                             // next UTC midnight
  agentDailyUnits?: number;                                     // set when the agent carries a lower daily limit than the org
}

Platform defaults are 250 units per org per day, 150 units per run, 200 case samples per run, and 8 units per case (a case that exceeds the ceiling is errored with stage: budget). The run's estimate uses each case's last measured units, or a figure derived from its shape when it has never run. While a run executes, its measured units count against the day; when the day's budget is exhausted mid-run the run pauses with pauseReason: quota and resumes when the budget resets, so no case is silently skipped.

Per-org limits are set by system operators through GET and PUT /system/tests/orgs/{org}/settings (outside the /v1 contract; sysop only). PUT takes any of dailyUnits, maxUnitsPerRun, maxCasesPerRun as a non-negative number, or null to return that limit to the platform default, and both verbs return the effective limits.

Human escalation

Anychat does not route conversations to a live human in real time. When the user asks for a human, the runtime escalate-to-human skill renders the agent's identity contact fields (contactEmail, contactPhone, websiteUrl) as tappable contact options and shows contactAvailability verbatim if set — the user takes the action. Configure these once on the Identity resource; there is no separate escalation-contacts payload.

Scheduling

The Scheduling capability: the hours the agent may book, the default call length, who is notified, the pre-call reminder, and the representatives the booked calls are with. This resource is the capabilities.scheduling slice of an export. Which calendar the agent mirrors booked calls into is account-specific state and is not part of it: it names a connection and a calendar inside that account, so it never appears on GET, is ignored on PUT, and is preserved across writes. Link a calendar through the console.

interface SchedulingConfig {
  timezone?: string;                       // IANA; default zone for proposing and holding slots
  availableHours?: SchedulingWindow[];     // weekly bookable windows; empty = nothing restricted
  blackoutDates?: string[];                // YYYY-MM-DD dates with no availability
  disallowSameDayBooking?: boolean;        // never offer slots on the current day (in timezone); default false
  defaultDurationMinutes?: number;         // 1–1440
  notificationRecipients?: string[];       // emails for appointment-lifecycle mail, alongside the representative's
  reminders?: {
    enabled?: boolean;                     // unset behaves as on
    leadMinutes?: number;                  // 1–10080, minutes before the call
    includeContactCard?: boolean;          // attach the representative's contact card (.vcf)
    ignoreSafeHours?: boolean;             // unset behaves as true: send when due, whatever the safe hours
    playbookId?: string;                   // Playbook a reply opens in when no conversation is in progress; unset = the default Playbook
  };
  representatives?: SchedulingRepresentative[];
  defaultRepresentativeIdentity?: string;  // fallback identity, e.g. "a sales representative"
}

interface SchedulingRepresentative {
  id: string;                              // stable per-agent id (Person.assignedRepresentativeId)
  name: string;
  title?: string;
  phone?: string;
  email?: string;                          // lifecycle emails go here
  type?: string;                           // free string: sales, advisor, clinician, support; default sales
  availability?: {                         // empty = use the agent-level availableHours
    timezone?: string;
    workingHours?: SchedulingWindow[];
  };
}

interface SchedulingWindow {
  dayOfWeek: number;                       // 0–6, Sunday–Saturday
  startTime: string;                       // "HH:MM", 24-hour, in the governing timezone
  endTime: string;                         // "HH:MM"
}
Method Path Role Scope
GET /orgs/{org}/agents/{agent}/scheduling viewer agents:read
PUT /orgs/{org}/agents/{agent}/scheduling editor agents:write

GET returns the SchedulingConfig (representatives is always present, [] when there are none). PUT takes a full SchedulingConfig and replaces the whole configuration: keys the body omits are cleared (representatives becomes [], defaultRepresentativeIdentity is unset), so read, modify, and write back the whole object. Both return the stored SchedulingConfig. Validation failures are 422 request.invalid problem details whose errors[].field names the offending path, for example scheduling.availableHours[0].startTime. Representative ids must be unique within the agent.

Things

The agent's catalog: the products, restaurants, rentals, or other items the agent can present as cards and reconcile a Person's productInterest against, part of the Knowledge capability. This resource is the capabilities.knowledge.things slice of an export; older exports and hand-written documents may still use the historical products key, which the importer reads as an alias (see Export / Import). AgentProduct remains a type alias of AgentThing in @anychat-ai/agent-api for source compatibility, but the old /products route and its AgentProducts$Put body type are removed: agent-api 10.0.0 is a breaking release.

interface AgentThing {
  id: string;                          // stable per-agent id, unique within the agent
  name: string;
  description?: string;
  imageUrl?: string;                   // raw URL fallback when no media item is set
  media?: string;                      // media library item id
  productPageUrl?: string;             // one "View details" link when `actions` is absent
  actions?: ThingAction[];             // 1–4 card buttons; overrides productPageUrl
  location?: ThingLocation;            // where it is; a viewLocation button opens it
  collections?: string[];              // every Collection id it belongs to (a set); empty/absent = the default group
  collection?: string;                 // deprecated: one of its collections, for clients that predate `collections`
  metadata?: Record<string, string>;   // free-form attributes (price, category, …)
}

interface ThingAction {
  label: string;                   // 1–25 chars
  kind?: 'url' | 'dial' | 'reply' | 'viewLocation'; // absent = 'url'
  url?: string;                    // kind url: http(s), required
  phone?: string;                  // kind dial: required; stored and served as E.164
  value?: string;                  // kind reply: ≤ 200 chars, what the agent receives
}

interface ThingLocation {
  address?: string;    // ≤ 500 chars; on write also a "lat, long" or a Google Maps link
  latitude?: number;   // -90..90, with longitude
  longitude?: number;  // -180..180
  label?: string;      // map pin label, ≤ 200 chars; default the Thing's name
}

A url action opens its link. A dial action calls phone, which is accepted in any common format (a number without a country code is read as US) and stored as E.164. A reply action sends its label back to the agent as a quick reply; the agent receives value, or "<label>: <Thing name>" when value is absent, so it knows which card was tapped. On a channel without the button, a url or dial action degrades to text (Label: <link or number>). An action without kind is a url action, which is every action stored before kinds existed; kind is stored only when sent. Fields that don't belong to the action's kind are dropped.

A viewLocation action opens the Thing's location in the phone's maps app and carries no target of its own. The Thing must have a location.

On write, a location's address may be a postal address, a pasted lat, long, or a Google Maps link. Coordinates are read from the last two, and a link's place name becomes the address. A plain address is geocoded: the coordinates are stored, the address is replaced by the formatted one, and an address that matches no place is a 422 at things[i].location.address. A location that already has coordinates is never geocoded again, and an address sent unchanged without them reuses the stored coordinates. If the address can't be geocoded at the time, it's stored without coordinates and the button opens a Maps search for it.

A Thing sent without a location key keeps its stored location, so a client written before locations can't wipe them by saving the list back. Send "location": null to clear it.

Method Path Role Scope
GET /orgs/{org}/agents/{agent}/knowledge/things viewer agents:read
PUT /orgs/{org}/agents/{agent}/knowledge/things editor agents:write

GET returns { "data": AgentThing[] }. PUT takes { "things": AgentThing[] } and replaces the catalog; both return { "data": AgentThing[] }. A body without a things array, a Thing missing id or name, a duplicate id, a non-string metadata value, more than 4 actions, an action label over 25 characters, an unknown action kind, a missing or non-http(s) url on a url action, a missing or invalid phone on a dial action, a reply value over 200 characters, a viewLocation action on a Thing without a location, a location coordinate out of range or sent without its pair, or a collections entry that names no existing collection is a 422 request.invalid problem detail (errors[].field, for example things[1].collections[0]). Field caps are enforced the same way: id/name ≤ 200 chars, description ≤ 4,000, imageUrl/productPageUrl ≤ 500, media ≤ 80, each collection id ≤ 200, at most 20 collections — each an over-length value is 422 request.invalid naming the field (e.g. things[2].actions[1].label). media accepts a bare media item id or an image:<id> reference; either form is stored as the bare id, and an id naming no media item reports thing.media.unknown on lint rather than failing the write.

Several collections. A Thing can belong to more than one collection — a jet-ski rental in both watercraft-rentals and wahweap-marina. collections is a set; its order carries no meaning. A collection's presentation applies only when the Thing is sent as part of that collection ([[collection: X]], or showcase.collection in a reply). A Thing shown on its own uses the default card: picture, name, description, and all its actions (up to 4). The agent's Brief lists the Thing under each of its collections.

collection (deprecated). Reads also return collection, one of the Thing's collections, kept for clients that predate collections. On PUT, collection is ignored whenever collections is sent. A Thing sent with only collection is treated as an older client's write: if it names the value GET returned, the Thing keeps its other collections; if it names a different collection, the Thing moves to that collection alone. A Thing sent with neither belongs to no collection.

Collections

Named groups of Things, with how the agent presents each group's cards. Collections are optional: a Thing in no collection still renders, grouped under the agent's default. Once a collection holds a Thing, the agent names cards by Thing id and can send a whole collection by id as one carousel (in collection order, up to the channel's carousel maximum); an agent without one names Things by their exact name. This resource is the capabilities.knowledge.collections slice of an export.

interface AgentCollection {
  id: string;                          // stable per-agent id, unique within the agent
  name: string;
  purpose?: string;                    // one line, shown to the agent: "restaurants and bars on the lake"
  presentation?: AgentCollectionPresentation;
}

interface AgentCollectionPresentation {
  fields?: Array<'picture' | 'name' | 'description'>;  // default ['picture', 'name', 'description']
  actions?: string[];                  // action labels to show, in order; absent shows all (max 4)
}
Method Path Role Scope
GET /orgs/{org}/agents/{agent}/knowledge/collections viewer agents:read
PUT /orgs/{org}/agents/{agent}/knowledge/collections editor agents:write

GET returns { "data": AgentCollection[] }. PUT takes { "collections": AgentCollection[] } and replaces the whole list; both return { "data": AgentCollection[] }. A body without a collections array, an entry missing id or name, or a duplicate id is a 422 request.invalid problem detail (errors[].field, for example collections[0].id). Field caps are enforced the same way: id/name ≤ 200 chars, purpose ≤ 500, presentation.actions ≤ 4 entries each ≤ 25 chars — each over-length or over-count value is 422 request.invalid naming the field. Deleting a collection that Things still reference is also 422 (request.invalid, with one errors[] entry per Thing still in it) unless the request carries ?force=true, in which case the dropped collection is removed from each such Thing's collections; the Thing keeps its other collections and all its other fields, and a Thing left with none belongs to the default group.

Facts

The agent's Facts, part of the Knowledge capability. A Fact is a question and the answer you have confirmed. On every turn the agent reads every enabled Fact as authoritative: the same question in any wording counts, and every detail, number, and price is kept as written. Every Fact is also indexed alongside the agent's Sources, where the search ranks it ahead of Source passages. This resource is the capabilities.knowledge.facts slice of an export; older exports and hand-written documents may still use the historical faqs key, which the importer reads as an alias (see Export / Import).

interface AgentFact {
  id: string;                 // stable per-agent id, unique within the agent
  question: string;           // canonical wording, ≤ 300 chars
  alternates?: string[];      // other wordings, matching only (≤ 10 × 200 chars)
  answer: string;             // the confirmed answer, plain text, ≤ 1,000 chars
  exact?: boolean;            // Exact: send the answer word for word (default false); `verbatim` is accepted on write as a legacy alias
  enabled?: boolean;          // default true; disabled entries are kept but never used
  source?: 'operator' | 'import' | 'suggested';
  updatedAt?: string;         // read-only ISO-8601, stamped when the entry's content changes
  confirmed?: boolean;        // default true; false only from the Knowledge inbox (unshipped) — the agent never reads it and it is never mirrored to the Knowledge index
  alwaysOn?: boolean;         // default false; in every turn's Facts block regardless of ranking or budget
  scope?: string[];           // Playbook ids; when non-empty the Fact is offered only while one of them is active. Default empty: every Playbook. At most 50 entries, each ≤ 200 chars
}
Method Path Role Scope
GET /orgs/{org}/agents/{agent}/knowledge/facts viewer agents:read
PUT /orgs/{org}/agents/{agent}/knowledge/facts editor agents:write
POST /orgs/{org}/agents/{agent}/knowledge/facts/_suggest-paraphrases editor agents:write
GET /orgs/{org}/agents/{agent}/knowledge/facts/usage viewer agents:read

GET returns { "data": AgentFact[] }. PUT takes { "facts": AgentFact[] } and replaces the whole list; both return { "data": AgentFact[] }. updatedAt is server-owned: an entry whose content is unchanged keeps its stamp, and anything new or changed is stamped. A value sent on PUT is ignored unless the entry is new to the agent, so an export re-imported onto a fresh agent keeps its history. A body without a facts array, an entry missing id, question, or answer, a duplicate id, more than 10 alternates, an unknown source, or more than 200 entries is a 422 request.invalid problem detail (errors[].field, for example facts[1].id). A scope naming a Playbook id that does not exist on the agent is the same 422 (errors[].field, for example facts[0].scope[1]); the request loads the agent to check it. A scope with more than 50 entries, or an entry over 200 characters, is also 422 request.invalid. Field caps are enforced the same way, naming the field: question ≤ 300 chars, answer ≤ 1,000, each alternates entry ≤ 200.

POST …/_suggest-paraphrases asks the model for other wordings of one question, for the entry's alternates. It takes { "question": string, "answer"?: string, "existing"?: string[], "count"?: number } (count 1–10, default 5) and returns { "alternates": string[], "model": string }. It is one model call and persists nothing: the operator edits the suggestions and saves them through PUT. A missing or blank question, an existing that is not an array of strings, or a count out of range is a 422 request.invalid; an unknown agent is 404; a suggester outage is 502.

GET …/knowledge/facts/usage?days= reports how each Fact has been used, read from the agent's turn traces over the last days days (1–90, default 30; out of range is a 422 request.invalid). Every current Fact is in data, so an entry nobody asks is as visible as a popular one, along with ids seen in the window that no longer exist. Rows are ordered most-asked first. To convert an imported question-and-answer Source into entries, see Knowledge sources; to generate a test suite that checks every Fact against the live agent, see Generate and compile.

interface AgentFactUsage$Response {
  windowDays: number;          // 1–90
  turnsWithFacts: number;      // turns in the window whose prompt carried the Facts block
  data: AgentFactUsageRow[];   // every current Fact plus ids seen in the window that no longer exist; most-asked first
}

interface AgentFactUsageRow {
  id: string;
  asked: number;               // turns where the person plainly asked this Fact
  exactSent: number;           // turns where an Exact answer went out word for word
  exactMissed: number;         // turns where a plainly-asked Exact Fact was answered in the agent's own words instead
  lastAskedAt?: string;        // ISO-8601; absent when never asked in the window
}

Business info

The agent's structured local-business facts, part of the Knowledge capability: a closed, typed subset of schema.org LocalBusiness — hours, address, amenities, price range — that the agent answers everyday customer questions from ("are you open right now?", "do you allow pets?"). Website import fills it automatically when the site publishes the data; the console edits it under the agent's Knowledge area. This resource is the businessInfo slice of an export.

interface BusinessInfo {
  name?: string;
  businessType?: string;                 // schema.org type, e.g. "Restaurant", "HairSalon"
  description?: string;
  url?: string;
  telephone?: string;
  email?: string;
  address?: {                            // schema.org PostalAddress subset
    streetAddress?: string;
    addressLocality?: string;
    addressRegion?: string;
    postalCode?: string;
    addressCountry?: string;
  };
  timezone?: string;                     // IANA; frames every wall-clock time below
  hours?: BusinessHoursPeriod[];         // weekly opening windows
  specialHours?: BusinessSpecialHours[]; // date-specific overrides (holiday hours)
  amenities?: { name: string; value: string }[]; // e.g. { name: "Pets allowed", value: "yes" }
  priceRange?: string;                   // e.g. "$$"
  cuisine?: string[];                    // Restaurant servesCuisine
  menuUrl?: string;
  sameAs?: string[];                     // social / profile URLs
  importedFrom?: string;                 // read-only: source URL of the last import
  lastImportedAt?: string;               // read-only: ISO-8601
}

interface BusinessHoursPeriod {
  dayOfWeek: number;                     // 0–6, Sunday–Saturday
  opens: string;                         // "HH:MM", 24-hour, in timezone
  closes: string;                        // "HH:MM"; closes <= opens crosses midnight
}

interface BusinessSpecialHours {
  date: string;                          // YYYY-MM-DD, in timezone
  closed?: boolean;                      // closed all day; opens/closes ignored
  opens?: string;                        // "HH:MM"
  closes?: string;                       // "HH:MM"
  label?: string;                        // operator-facing, e.g. "Thanksgiving"
}
Method Path Role Scope
GET /orgs/{org}/agents/{agent}/business-info viewer agents:read
PUT /orgs/{org}/agents/{agent}/business-info editor agents:write

GET returns the stored BusinessInfo (an empty object when nothing has been set). PUT takes a full BusinessInfo and replaces the whole object: keys the body omits are cleared, so read, modify, and write back the whole object. Unknown keys are dropped. importedFrom and lastImportedAt are read-only provenance stamped by website import — values sent for them on PUT are ignored. Both return the stored BusinessInfo. Validation failures are 422 request.invalid problem details whose errors[].field names the offending path, for example businessInfo.hours[0].opens.

Two request shapes are also 422 request.invalid rather than silently clearing the record: a body wrapped in a top-level businessInfo key (send the fields at the top level of the body instead), and a body containing only unknown keys (nothing recognized to write). An explicitly empty {} body is the one way to clear the record — that still succeeds.

Conventions: dayOfWeek is Sunday-first (0 = Sunday … 6 = Saturday), matching SchedulingWindow. A window whose closes is at or before its opens crosses midnight into the following day (a bar open 17:00–02:00 is one window). When specialHours has entries for a date, they replace that date's weekly windows entirely; closed: true closes the whole day. timezone (IANA) frames every stored wall-clock time and is required for the agent's computed open-right-now status — without it the agent will not claim the business is currently open or closed.

Knowledge sources

The Sources in the agent's knowledge base: the uploaded files and imported pages the agent retrieves passages from, the other half of the Knowledge capability. The reads return Source metadata: the file name, type, title, and processing status. Neither the extracted text nor the stored file is returned. Sources are added, refreshed, and removed either in the console, under the agent's Knowledge area (see Knowledge), or through this API. One additional write converts an imported question-and-answer Source into Facts.

interface KnowledgeSource {
  id: string;
  agentId: string;
  fileName: string;
  mimeType: string;
  kind: 'upload' | 'url' | 'text';              // how the Source was added
  title?: string;                              // extracted or operator-set title
  status: 'processing' | 'ready' | 'failed';   // ready = indexed and available to the agent
  sourceUrl?: string;                          // kind: 'url' — the page it was imported from
  uploadedAt: string;                          // ISO-8601
  refreshedAt?: string;                        // ISO-8601 — kind: 'url' only, set on each refresh
  createdAt?: string;                          // ISO-8601
  updatedAt?: string;                          // ISO-8601
}
Method Path Role Scope
GET /orgs/{org}/agents/{agent}/knowledge/sources viewer knowledge:read
GET /orgs/{org}/agents/{agent}/knowledge/sources/{sourceId} viewer knowledge:read
GET /orgs/{org}/agents/{agent}/knowledge/sources/{sourceId}/content viewer knowledge:read
POST /orgs/{org}/agents/{agent}/knowledge/sources editor knowledge:write
DELETE /orgs/{org}/agents/{agent}/knowledge/sources/{sourceId} editor knowledge:write
POST /orgs/{org}/agents/{agent}/knowledge/sources/{sourceId}:refresh editor knowledge:write
POST /orgs/{org}/agents/{agent}/knowledge/sources/{sourceId}/_convert-to-facts editor knowledge:write

The list returns { "data": KnowledgeSource[] } and is not paginated. An unknown {sourceId} on this agent is a 404 resource.not_found problem detail.

GET …/{sourceId}/content returns the Source's indexed text — what the agent actually chunked and embedded, not the original file:

interface KnowledgeSourceContent {
  id: string;
  title?: string;
  mimeType: string;
  text: string;         // the extracted/indexed text
  characters: number;   // text.length
  chunks?: number;      // chunk count, when available
}

An unknown {sourceId} on this agent is a 404 resource.not_found problem detail.

POST …/knowledge/sources adds a Source one of three ways: as multipart (file field — console only, not available over MCP), or as JSON:

type KnowledgeSourceCreateRequest =
  | { sourceUrl: string }              // kind: 'url' — fetched and indexed immediately
  | { title: string; text: string };   // kind: 'text' — pasted text, indexed as given

It returns 201 with the new KnowledgeSource. A sourceUrl that cannot be fetched returns 422 request.invalid.

DELETE …/{sourceId} removes the Source and its indexed content. Returns 204; an unknown {sourceId} is a 404 resource.not_found.

POST …/{sourceId}:refresh re-fetches a kind: 'url' Source from its sourceUrl and re-indexes it, updating refreshedAt. Returns 200 with the updated KnowledgeSource. A Source with no sourceUrl (kind: 'upload' or 'text') returns 409 agent.knowledge_source.no_url; an unknown {sourceId} is a 404 resource.not_found.

POST …/{sourceId}/_convert-to-facts turns an imported question-and-answer Source into Facts. Website import wrote such Sources (website-faq.html, one "Q: … A: …" paragraph per pair) before Facts existed. Each pair is added to the agent's Facts with source: "import"; a question the agent already has is never overwritten, the 200-entry cap applies, and the Source itself is left in place. The response is { "added": number, "data": AgentFact[] } (KnowledgeSourceConvertToFacts$Response), where data is the full list after the change. An unknown {sourceId} is a 404 resource.not_found; a Source in which no question/answer pairs are found is a 422 request.invalid.

The sysop /system/knowledgeBaseDocument route is unchanged.

Channels

Wires an agent to the messaging channels its users live on — RCS, Apple Messages, WhatsApp, SMS, and the long tail. Operators think in channels; the provider serving a channel is an implementation detail. One live binding per channel per agent.

Two ways to connect a channel:

  • Managed (recommended) — name the channel, supply the business inputs its setup form asks for (brand name, banner, 10DLC registration info, …), and Anychat provisions it: provider choice, credentials, and the carrier/Apple/Meta workflow are all handled for you. Provisioning for rich channels takes days to weeks; the binding's status and events tell the story.
  • Unmanaged — bring your own provider account (your Webex Connect tenancy, Twilio account, Google RBM agent, or WhatsApp Business Account) and supply its credentials. auth is write-only: it is accepted on create and rotate, and never returned by any read.
type ChannelName =
  | 'rcs' | 'appleMessages' | 'whatsapp' | 'sms'
  | 'viber' | 'slack' | 'poe';

type ChannelBindingStatus =
  | 'Requested'        // managed request filed; Anychat is on it
  | 'Provisioning'     // carrier/Apple/Meta workflow in flight
  | 'ActionRequired'   // blocked on YOU — statusDetail says what
  | 'Active'
  | 'Failed'           // statusDetail explains
  | 'Disabled';

interface ChannelBinding {
  id: string;
  agentId: string;
  channel: ChannelName;
  provider?: string;        // unmanaged only — you chose it
  mode: 'managed' | 'unmanaged';
  status: ChannelBindingStatus;
  statusDetail?: string;    // operator-safe status explanation
  events?: Array<{          // lifecycle history, oldest first
    at: string;
    from?: ChannelBindingStatus;
    to: ChannelBindingStatus;
    note?: string;
    actor: 'operator' | 'sysop' | 'system';
  }>;
  routingId?: string;       // provider-side id; display only
  serviceId?: string;       // provider-assigned service id (some RCS providers); display only
  inputs?: Record<string, unknown>; // your submitted business inputs
  delivery?: {              // delivery tuning (any mode)
    minMessageDelayMs?: number;     // 250–3000; unset = no pacing
  };
  compliance?: {            // compliance messages (rcs/sms only; always present there)
    enabled: boolean;               // on by default
    keywordReplies?: 'anychat' | 'provider'; // who replies to STOP/START/HELP; default anychat
    notice?: boolean;               // first-message notice; default true
    disclaimerText?: string;        // the notice's wording; max 1000 chars
    replies?: {                     // wording for the replies Anychat sends
      optOut?: string;              //   each max 1000 chars; placeholders
      optIn?: string;               //   {brand} {phone} {email} {website} {contacts}
      help?: string;                //   must mention STOP
      paused?: string;
    };
  };
  providerKeywordReplies?: 'never' | 'optional' | 'always'; // rcs/sms: does the provider reply itself
  launched: boolean;        // false = only the early-access list gets through
  earlyAccess?: string[];   // E.164 numbers admitted before launch
  inbound?: {              // webhook health; absent until a webhook arrives
    lastAcceptedAt?: string;        // last provider webhook accepted (±1 min)
    rejecting?: {                   // present while webhooks are rejected:
      since: string;                //   messages are NOT reaching the agent
      at: string;                   //   most recent rejection
      reason: 'signature-mismatch' | 'account-mismatch' | 'invalid-signature';
      detail: string;               //   names the credential to fix
    };                              // bring-your-own channels only; cleared by
                                    // the next accepted webhook or a rotation
    lastEarlyAccessDrop?: {         // unlaunched only: last message dropped
      at: string;                   //   because the sender isn't on earlyAccess
      from: string;                 //   last four digits, e.g. '•••2294'
    };
  };
  auth?: Record<string, string>;  // bring-your-own only: the NON-SECRET
                                  // credential fields, as configured
  secretsSet?: string[];          // which secret fields hold a value
                                  // (values are never returned)
}

interface ChannelConnectionTest {  // POST …/channels/{bindingId}/test-connection
  supported: boolean;            // false = this provider has no test yet
  ok: boolean;                   // every check that ran passed
  checks: Array<{
    name: string;                // 'credentials' | 'provider-auth' | 'reachable' | …
    label: string;               // short label for display
    status: 'pass' | 'fail' | 'skipped';
    detail?: string;             // provider's own error text, or why it was skipped
  }>;
  testedAt: string;              // ISO 8601
}

interface ChannelEarlyAccess {   // GET / PUT …/channels/{bindingId}/early-access
  launched: boolean;
  earlyAccess: string[];         // normalized E.164
  invites?: Array<{              // when providerList.sync is 'automatic': provider invite status
    phoneNumber: string;
    inviteStatus: 'INVITE_STATUS_UNSPECIFIED' | 'PENDING' | 'ACCEPTED' | 'DECLINED';
  }>;
  providerList?: {               // RCS only: how the provider's own tester list is kept
    providerName: string;        // e.g. "Vonage"
    sync: 'automatic' | 'manual';
    maintainedBy: 'anychat' | 'operator';
  };
}
Method Path Role Scope
GET /orgs/{org}/agents/{agent}/channels viewer agents:read
GET /orgs/{org}/agents/{agent}/channels/{bindingId} viewer agents:read
POST /orgs/{org}/agents/{agent}/channels editor agents:write
PATCH /orgs/{org}/agents/{agent}/channels/{bindingId} editor agents:write
DELETE /orgs/{org}/agents/{agent}/channels/{bindingId} editor agents:write
POST /orgs/{org}/agents/{agent}/channels/{bindingId}/_disable editor agents:write
POST /orgs/{org}/agents/{agent}/channels/{bindingId}/_enable editor agents:write
GET /orgs/{org}/agents/{agent}/channels/{bindingId}/early-access viewer agents:read
PUT /orgs/{org}/agents/{agent}/channels/{bindingId}/early-access editor agents:write
POST /orgs/{org}/agents/{agent}/channels/{bindingId}/_launch editor agents:write
POST /orgs/{org}/agents/{agent}/channels/{bindingId}/test-connection editor agents:write

POST bodies:

{ "channel": "rcs", "mode": "managed",
  "inputs": { "brandName": "Acme Health", "bannerUrl": "https://…", "...": "…" } }
{ "channel": "sms", "mode": "unmanaged", "provider": "twilio",
  "auth": { "accountSid": "AC…", "authToken": "…",
            "messagingServiceSid": "MG…", "phoneNumber": "+1555…" } }

A managed request returns 201 with status: "Requested"; poll the binding or list to follow progress. A second create for an already-bound channel returns 409. PATCH { "auth": { … } } rotates unmanaged credentials. DELETE removes the binding — cancelling a pending request, or releasing a live one (managed RCS is archived rather than deleted, so re-enabling restores the same carrier-verified agent) — and returns the removed ChannelBinding with 200 rather than 204. The same delete disables every test link on the binding (disabledReason: "binding_deleted"), and the response adds disabledTestLinks, how many links it switched off (links that were already paused are not counted).

PATCH { "delivery": { "minMessageDelayMs": 1000 } } sets message pacing: a minimum gap (250–3000 ms) between consecutive agent messages to one user on this channel. Use it to make multi-message replies land one after another, and to keep messages in order on providers that don't guarantee delivery ordering. Works on managed and unmanaged bindings alike; "delivery": null clears it. Out-of-range values return 400.

Opt-out on every RCS and SMS binding. STOP, STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT, REVOKE and OPT OUT (or OPT-OUT, OPTOUT) opt the person out of that agent, and START, UNSTOP or JOIN opts them back in, whatever the binding's settings. The keyword must be the whole message; case and surrounding punctuation are ignored. Anychat records the opt-out, sends that person nothing more until they opt back in, and stops passing their other messages to the agent.

Compliance messages are on by default on every RCS and SMS binding; a binding that never set compliance reads as { "enabled": true }. They are:

  • the first-message notice, sent once at the start of each messaging relationship: the stock text, or disclaimerText. Turn it off alone with "notice": false;
  • when Anychat replies to keywords, the STOP and START confirmations, the HELP answer (a brand-contact reply built from the agent's identity, sent even to an opted-out person) and a one-time "messaging is paused" reminder when an opted-out person writes again.

keywordReplies says who replies to STOP, START and HELP. With "provider", the provider answers them (for example Vonage Opt-Out Assist): Anychat sends no confirmations or reminder, and keeps HELP from the agent so the person gets one answer. Opt-outs are recorded and enforced either way. providerKeywordReplies on the read says what the provider can do: never (Google RBM; "provider" returns 400), optional (Vonage, Webex Connect), or always (Twilio; it always replies, and "anychat" returns 400).

replies sets the wording of each reply Anychat sends; an omitted field keeps the default. {brand}, {phone}, {email} and {website} are filled from the agent's identity, and {contacts} with a sentence built from whichever of those are set. help must mention STOP. An unknown placeholder returns 400.

PATCH { "compliance": { "enabled": false } } turns all compliance messages off. "compliance": null clears the block, which restores the defaults. Non-carrier channels return 400.

Credentials on read. A bring-your-own-account channel echoes its non-secret credential fields in auth, and names the secret fields that hold a value in secretsSet. Which fields count as secret comes from the provider's authFields in the channels catalog — anything marked secret is never returned, in any form. So you can always see what a channel is pointed at (sender id, application id, account key, region) and confirm that a key or token is present, without the value ever leaving the gateway.

Rotating credentials. PATCH …/channels/{id} with an auth object merges over what is stored: a field you omit keeps its current value, and an explicit null clears it. Send only what changed — in particular, omit the secrets you are not rotating, since you cannot read them back to resend them. The merged result is validated as a whole, so a rotation cannot leave a channel in a state that a fresh connection would have been rejected for.

Early access and launch. A channel is connected before it is launched. While launched is false, only the phone numbers on the binding's early-access list can reach the agent on that channel — inbound from anyone else is dropped without a reply, on every provider — which is how staff and a customer's acceptance testers use the real agent on their own phones before the public can. GET …/early-access returns the launch state and the list; PUT …/early-access with { "earlyAccess": ["+15551234567", …] } replaces the whole list (numbers are normalized to E.164 and de-duplicated; an unparseable entry is a 422 naming it). On RCS, the provider also keeps its own pre-launch tester list, and providerList says how that list is kept. With sync: "automatic" (a managed Google RBM channel, or a Vonage agent that Vonage's Channel Manager lists) the PUT invites added numbers and removes dropped ones there before saving, and the response carries each number's inviteStatus; a person must accept the invitation on their phone before messages reach them. On Vonage only numbers entering or leaving the list are touched, never test devices added in Vonage's dashboard. If the provider refuses, the list is not saved: 429 over quota (Google allows 20 invites a day and 200 in total), 422 with Vonage's reason (Vonage allows 50 test devices per agent), 503 when Vonage is unavailable. With sync: "manual" each number has to be added at the provider too, by whoever maintainedBy names: you for your own provider account, Anychat support for a managed channel. POST …/_launch opens the channel to everyone and returns the binding; it is one-way (pause a launched channel with _disable), and editing the list afterwards is a 409. Every new binding starts unlaunched.

Testing the connection. POST …/test-connection asks the provider to prove the channel's credentials work. Nothing is sent to anyone: the provider makes the cheapest authenticated call its API offers. The response is a ChannelConnectionTest — ok, plus a checks array to read in order: credential format, provider authentication, and (optionally) whether a given handset can receive messages from this agent. Each check is pass, fail or skipped. A check's label names what was checked and never asserts the outcome, so it is safe to render in any state; status carries the verdict and detail explains it, including the provider's own error text on a failure.

Pass { "phoneNumber": "+15551234567" } to run the live checks. Some providers — Vonage RCS among them — can only prove their credentials by asking about a specific handset, so without a number those checks are skipped rather than failed. A failed check is still a 200 with ok: false: the request was fine, the credentials are not. Only a malformed phoneNumber is a 422. supported: false means that provider has no test implemented yet and checks is empty.

This is worth running whenever an agent is not answering on a channel, and right after connecting one: the emulator uses a simulated transport, so it can never tell you whether the real provider accepts your credentials.

The per-channel setup forms (which fields, which are seeded from identity, expected provisioning times) come from the channels catalog.

People

The built-in CRM. Anychat keeps one Person record for each person, per agent, and updates it as conversations reveal new facts. A Person is found only through its channel identities; profile fields such as phone and email are information about the person and never find, address or match anyone. See the People guide for the console side.

interface Person {
  id: string;
  fullName?: string;
  firstName?: string;
  lastName?: string;
  email?: string;
  phone?: string;                    // informational; not unique, never matched. E.164 when it parses, else as given
  company?: string;
  title?: string;
  status: 'new' | 'contacted' | 'qualified' | 'converted' | 'lost';
  notes?: string;
  customFields?: Record<string, string>;
  tags?: string[];
  lockedFields?: string[];           // field paths the agent may never write
  // Read-only: who set each field's current value, keyed by field path
  fieldMeta?: Record<string, { by: 'operator'|'agent'|'import'|'intake'|'system'; at: string }>;
  assignedRepresentativeId?: string; // a representative on the agent
  journal?: PersonJournalEntry[];   // read-only memory log
  channelIdentities: PersonChannelIdentity[]; // read-only; managed through …/identities
  // Messaging consent (absent = unknown; see "Consent" below) —
  consent?: {
    status: 'granted' | 'denied' | 'unknown';
    evidence?: string;               // the raw grant, verbatim (disclosure text or a note)
    source?: string;                 // read-only: gsheet:<id>/<sheet>, operator:<userId>
    at?: string;                     // read-only: ISO-8601; when consent was captured
  };
  // Outreach state (read-only; runtime-owned) —
  outreachInitiatedAt?: string;      // ISO-8601; set once outreach is delivered
  outreachReachability?: 'rcs' | 'unreachable'; // result of the last attempt's capability check
  lastOutreachAttemptAt?: string;    // ISO-8601; last attempt, success or failure
  // Follow-up state (read-only; runtime-owned) —
  lastInboundAt?: string;            // ISO-8601; most recent inbound reply
  nextFollowUpAt?: string;           // ISO-8601; when the next nudge is due, if enrolled
  followUpsSentCount?: number;       // nudges sent in the current sequence
  followUpsPaused?: boolean;         // operator paused automatic nudges
  // Scheduling mirrors —
  scheduledCallAt?: string;          // ISO-8601; next active booking (cleared once it elapses)
  scheduledCallTimezone?: string;    // IANA
  lastCallAt?: string;               // ISO-8601; most recent completed call (read-only; persists)
  lastCallTimezone?: string;         // IANA (read-only)
  // Partitions —
  internal?: boolean;                // staff on a real device: kept out of Review and statistics, nothing else (operator-set)
  sandbox?: boolean;                 // read-only: the sandbox partition (a test fixture or persona), never a real person
  // …plus channel metadata
}

type IdentityChannel =
  | 'rcs' | 'sms' | 'whatsapp' | 'amb' | 'viber' | 'slack' | 'poe'
  | 'webchat' | '_simulator' | '_diagnostic';

interface PersonChannelIdentity {
  asid: string;                      // the identity's opaque handle (v2-…)
  channel: IdentityChannel;
  addedAt: string;                   // ISO-8601; when this Person took the identity
  lastInboundAt?: string;            // ISO-8601; last message from it
  lastOutboundAt?: string;           // ISO-8601; last message to it
}

interface PersonJournalEntry {
  at: string;                        // ISO-8601
  author: 'agent' | 'operator';
  text: string;
}

interface Appointment {
  id: string;
  startUtc: string;                  // ISO-8601 (UTC)
  durationMinutes: number;
  prospectTimezone: string;          // IANA, e.g. America/Los_Angeles
  status: 'scheduled' | 'rescheduled' | 'cancelled' | 'completed';
  representativeName?: string;
  representativeTitle?: string;
  notes?: string;
  history: Array<{
    at: string;                      // ISO-8601
    action: string;                  // created / rescheduled / cancelled / completed
    from?: string;
    to?: string;
    reason?: string;
  }>;
  createdAt?: string;
  // Present on the agent-level listing only —
  personId?: string;                 // linked person, when resolvable
  personName?: string;
}
Method Path Role Scope
GET /orgs/{org}/agents/{agent}/people viewer people:read
POST /orgs/{org}/agents/{agent}/people editor people:write
GET /orgs/{org}/agents/{agent}/people/{personId} viewer people:read
PATCH /orgs/{org}/agents/{agent}/people/{personId} editor people:write
DELETE /orgs/{org}/agents/{agent}/people/{personId} editor people:write
GET /orgs/{org}/agents/{agent}/people/{personId}/appointments viewer people:read
GET /orgs/{org}/agents/{agent}/appointments viewer people:read
GET /orgs/{org}/agents/{agent}/people/stats viewer people:read
GET /orgs/{org}/agents/{agent}/people/export editor people:export
POST /orgs/{org}/agents/{agent}/people/import editor people:write
POST /orgs/{org}/agents/{agent}/people/{personId}/outreach/follow-up editor people:write
POST /orgs/{org}/agents/{agent}/people/{personId}/merge editor people:write
POST /orgs/{org}/agents/{agent}/people/{personId}/restore editor people:write
GET /orgs/{org}/agents/{agent}/people/{personId}/identities viewer people:read
POST /orgs/{org}/agents/{agent}/people/{personId}/identities editor people:write
DELETE /orgs/{org}/agents/{agent}/people/{personId}/identities/{asid} editor people:write
GET /orgs/{org}/agents/{agent}/people/{personId}/similar viewer people:read

GET /people accepts limit (max 200), startingAfter (the last person id of the previous page), and the filter parameters below. With any of these parameters the response is { "data": Person[], "hasMore": boolean }, newest first. A bare GET /people still returns the full { "data": Person[] } array; treat that form as deprecated and pass limit in new integrations. Single-resource reads and writes return the bare Person object.

Filtering people

The list, stats, and export endpoints share one filter vocabulary. Filters combine with AND: a person matches only when it satisfies every parameter supplied.

Parameter Matches
status Exact funnel status
q Case-insensitive substring of name, email, phone, or company
tags People carrying any of the listed tags (comma-separated)
source Exact source value
lastImportBatchId People created or updated by one import call (the importBatchId in an import result)
hasPhone true: a profile phone number is on file; false: missing or empty. A profile phone does not make a person reachable; see hasIdentity
channel People who hold a channel identity on this channel, such as rcs
hasIdentity true: people who hold at least one channel identity; false: people who hold none, and so can't be messaged
address The person who holds the channel identity with this address, on channel when it is given, else on any channel that has addresses (rcs, sms, whatsapp, amb)
outreachReachability rcs or unreachable, the result of the most recent outreach attempt's capability check
consent granted, denied, or unknown. unknown matches people with no consent record at all as well as an explicit unknown grant: the people a rule audience skips
createdAfter / createdBefore Record created on or after / on or before the ISO-8601 instant
lastInboundAfter / lastInboundBefore Most recent inbound message on or after / on or before the ISO-8601 instant
internal true: internal people only (staff on real devices, kept out of Review and statistics); false: everyone else. Omit for both
sandbox true: the sandbox partition only (test fixtures and personas, never real people). Omitted or false: live people, the default of every list
cf.<key> Exact value of the custom field <key>, e.g. cf.targetMarket=medspa
segmentId The filter saved in a segment

A cf. key must be a declared People field; an undeclared key is rejected with 400 naming the key. segmentId stands alone: combining it with any individual filter parameter is a 400, and an unknown segment id is a 404.

GET .../people/export takes the same parameters alongside its format, so a filtered export contains exactly the people the equivalent filtered list returns.

Counting people

GET .../people/stats returns counts over the people that the filter parameters or segmentId select, computed in one pass on the server however many people the agent has. Without parameters it counts every live person of the agent; sandbox=true counts the sandbox partition instead, and deleted people never count. The response carries counts only, with no names, phone numbers, or other personal data, so it needs only viewer and people:read. Use it for reporting and for any "how many" question, and keep export (editor, people:export) for when you need the records themselves.

{
  "total": 243,
  "outreach": {            // mutually exclusive; sums to total
    "reached": 122,
    "notReachable": 33,
    "declined": 2,
    "noConsent": 4,
    "notYetSent": 82
  },
  "repliedAfterOutreach": 40,
  "calls": { "any": 17, "upcoming": 3 },
  "consent": { "granted": 200, "denied": 5, "unknown": 38 },
  "byStatus": { "new": 200, "contacted": 30, "qualified": 10, "converted": 3, "lost": 0 },
  "asOf": "2026-09-28T17:00:00.000Z"
}
Field Counts people who
total Match the filter (live and not deleted, unless sandbox=true)
outreach.reached Have outreachInitiatedAt set
outreach.notReachable Are not reached, and whose outreachReachability is unreachable
outreach.declined Are not reached, not unreachable, and whose consent status is denied
outreach.noConsent Are not reached, not unreachable, and whose consent status is neither granted nor denied (including no consent record)
outreach.notYetSent Are not reached, not unreachable, and whose consent status is granted
repliedAfterOutreach Have outreachInitiatedAt set and lastInboundAt at or after it. Paused follow-ups do not change this
calls.any Have scheduledCallAt or lastCallAt set: an upcoming, past, or cancelled call
calls.upcoming Have scheduledCallAt at or after asOf
consent Are in each consent state; unknown is anything other than granted or denied, including no consent record
byStatus Have each funnel status. Every status appears, with 0 when nobody has it; a person with no status counts under none
asOf The ISO-8601 time the counts were taken

The five outreach buckets are mutually exclusive and sum to total. When more than one applies, a sent date wins over notReachable, and notReachable wins over the consent state.

Writable fields

POST and PATCH accept exactly these fields; on create, the person is bound to the agent in the path. A PATCH with none of them is a 400.

Field Type Limit
fullName string 200
firstName / lastName string 100 each
email string 300
phone string 50; normalized to E.164 when it parses as a phone number, else stored as given. Informational: any number of people may share it, and it never finds or reaches anyone. To make a number reachable, add it as a channel identity
company string 200
title string 200
productInterest string 200; reconciled against the Things catalog at use time
source string 200
status enum new, contacted, qualified, converted, lost
notes string 5,000
tags string[] Free-form labels
lockedFields string[] Field paths the agent may never write (e.g. email, customFields.budget)
customFields object String values, 1,000 each; keys must be declared People fields, and values are validated against each field's declared type
assignedRepresentativeId string Must reference one of the agent's representatives
scheduledCallAt / scheduledCallTimezone ISO-8601 / IANA Normally maintained by the scheduling feature; writable as an operator override
consent object { status, evidence? }. status is granted, denied, or unknown; evidence is free text, 2,000. The server stamps source (operator:<userId>) and at; supplying them is ignored

productInterest, status, assignedRepresentativeId, and the scheduled-call fields serve the Things catalog, sales-funnel, and call-scheduling features. Applications without a catalog, funnel, or representatives can leave them untouched; status stays new.

Consent. consent records whether the person agreed to receive messages. Automated Outreach writes it from the sheet's consent column and only sends to people whose status is granted; a person with no record, or an explicit unknown, is created but never messaged automatically, and a Campaign excludes them the same way. A send you ask for by name (POST …/outreach/{id}/sends) makes you responsible for consent: it goes to a person whose status is granted or unknown, is refused for denied, and records any consent you assert on the send without changing this field. Write consent here when you hold consent obtained elsewhere (a signed form, a phone call) and want Automated Outreach and Campaigns to reach the person: send { "status": "granted", "evidence": "Signed intake form 9/2" } and the server stamps source and at. evidence is kept verbatim; it is the only thing a later reader has to judge the grant by.

Everything else on Person is runtime-owned and read-only: channel identities (managed through their own routes), outreach state, follow-up state, and the journal, which the agent appends to as it learns (including a one-line summary each time a conversation ends; entries are capped at 2,000 characters).

DELETE removes the person (204). Deletion is soft for 30 days: the record disappears from every list, lookup, and export, and POST .../{personId}/restore undoes it. Deleting releases the person's channel identities, so a later message from one of them starts a new record. Restoring takes back each identity that nobody else holds now; the others stay with their holders. Records consumed by a merge cannot be restored (409). After 30 days the record is purged.

GET .../people/export streams the CRM, narrowed by any filter parameters present: format=csv (default) produces one row per person with columns matching the import template, and format=ndjson produces one full JSON record per line including the journal and fieldMeta. The CSV's channel and address columns name the person's default identity, the one Anychat picks for outbound messages, so an edited export imports back onto the same people. Its channelIdentities column lists every identity as channel:asid, separated by ;. address is filled only for a caller allowed to read channel addresses.

POST .../people/import takes { "rows": [...], "profileUpdate": "fill" } where each row is a header-to-value object with the core profile columns, your People fields, and optionally a channel identity in channel and address columns. A row that names an identity updates the Person who holds it, or creates a Person to hold it. Rows that name the same identity apply in order to one Person. A row without channel and address always creates a new Person, which can't be messaged until an identity is added. Profile columns never find an existing person. profileUpdate: "fill" (default) writes only empty fields, and profileUpdate: "overwrite" replaces values that differ. Locked fields are never overwritten. Import takes no opt-in attestation: consent comes from the row's consent columns. The result reports imported, updated, skipped, unmatchedColumns, per-row rowErrors, an importBatchId that the lastImportBatchId filter accepts to list exactly the people this call touched, and rowOutcomes: one entry per submitted row with index, action (created / updated / unchanged / skipped), personId, the asid of the row's identity, a reason when the row could not land, and warnings for non-fatal problems. Identity reasons are invalid_address (the address is not valid on its channel), channel_not_addressable (the channel has no addresses you can name: use rcs, sms, whatsapp or amb), and incomplete_identity (one of channel and address without the other).

Channel identities

A channel identity is one person's address on one channel: the channel and the address that channel uses for them, an E.164 phone number on RCS, SMS and WhatsApp, or the opaque per-business id Apple Messages for Business assigns. Each identity has one ASID (v2-…), an opaque handle that is the same on every provider and binding of that channel, and that the rest of the API uses in place of the address: a send's asid, a turn's and a message's userId. RCS and SMS are separate channels, so one phone number is two identities.

A Person holds any number of identities, on any channels, and an identity belongs to at most one Person of the agent. Inbound messages bind to the Person who holds the sender's identity, and nothing else: no profile field is consulted. A named send goes to an identity. STOP is held per identity per agent: it covers every provider and binding of that channel, and never another channel or agent.

interface PersonChannelIdentityDetail extends PersonChannelIdentity {
  address?: string;        // present only when the caller may read it
  optedOut: boolean;       // the identity replied STOP to this agent
  optedOutAt?: string;     // ISO-8601
  unreachableAt?: string;  // ISO-8601; a definite "not capable" verdict on this channel
}

GET …/people/{personId}/identities returns { "identities": PersonChannelIdentityDetail[] }. It answers 502 agent.people.upstream when an identity's STOP state or address could not be looked up; nothing is returned without it, so retry.

POST …/people/{personId}/identities adds an identity to the person. Name it as { "channel", "address" } or as { "asid" }, and attest that the person opted in to messages on that channel:

{ "channel": "rcs", "address": "+15551234567",
  "optIn": true, "evidence": "Asked for text updates at the front desk" }
Field Meaning
channel, address The identity by its channel and address. channel is rcs, sms, whatsapp or amb
asid The identity by its ASID, instead of channel and address
optIn Required, and must be true: your attestation that the person opted in to messages on this channel
evidence Optional, at most 2,000 characters: how the opt-in was obtained
moveFrom The personId of the person who holds the identity now, to move it from them to this person

The attestation is recorded in the person's journal. When the person's consent is unknown, it also records consent as granted, with your evidence; consent recorded as denied refuses the add, so change the consent first.

Status code When
201 Added. The body is { "identity": PersonChannelIdentityDetail }
200 This person already holds the identity. Nothing changed
409 agent.people.identity_held Another person holds the identity. The problem detail carries asid and heldBy, that person's id. To move it, send the request again with moveFrom set to heldBy
409 agent.people.consent_denied The person's consent is recorded as denied
422 agent.people.invalid_address The address is not valid on its channel
422 agent.people.opt_in_required optIn is missing or not true
404 agent.people.identity_not_found No identity with that asid on this agent
502 agent.people.upstream The identity could not be looked up or written. Nothing changed; retry

A move takes the identity, with its STOP state and message history, from one person to the other in a single step, and records it in both journals. It is the same decision as a merge with nothing else to merge.

DELETE …/people/{personId}/identities/{asid} detaches the identity (204; 404 agent.people.identity_not_found when the person doesn't hold it; 502 agent.people.upstream when it could not be done, and nothing changed). The identity itself is kept with its STOP state and message history, and a later message from it starts a new record when inbound records are on. The agent's working memory of that conversation is not carried over to the new record.

Who sees an address. An address is returned only where a response names it in a field called address: on GET …/people/{personId}/identities, on a send, and in the export's address column. A Person never carries one. Today every caller allowed to read the person may read its addresses.

Similar people

GET …/people/{personId}/similar lists up to 20 live people of the agent whose profile email or phone matches this person's, as { "people": [{ "personId", "fullName"?, "matchedOn": "email" | "phone" }] }. It is a suggestion for review, computed when you ask, and never changes anything. Two people sharing a phone number or an email are allowed; merge them when they are the same human. A 502 agent.people.upstream means the agent couldn't answer; retry.

Merging people

POST .../people/{personId}/merge with { "sourceId": "...", "fieldChoices": { "email": "source" } } merges the source record into the target and retires the source. Defaults are target-wins with source filling empty fields; fieldChoices overrides per field. For status, new counts as empty, so a new target adopts a contacted/qualified source's stage. Outreach and follow-up state (outreachInitiatedAt, lastOutreachTouchAt, nextFollowUpAt, …) moves to the target when the target has never been reached out to; lastInboundAt and lastCallAt take the later value and createdAt the earlier. The target takes the union of both records' channel identities, so every conversation either one had continues on the merged person.

Appointments

.../people/{personId}/appointments lists the person's scheduled calls (newest first, including cancelled ones) as { "data": Appointment[] }.

.../agents/{agent}/appointments lists every appointment across the agent's people, newest start first, each annotated with personId and personName when the linked person can be resolved. Optional query parameters: status (scheduled, completed, cancelled) and limit (default 200, max 500). A completed status means the call's scheduled time has elapsed; the person's lastCallAt mirrors the most recent such call.

Sending outreach to one person

Sending is an Outreach concern, not a People one: name the Outreach and the person, and the platform does the rest.

POST /orgs/{org}/agents/{agent}/outreach/{outreachId}/sends
{ "recipient": { "personId": "665f2a…" } }

See Sending for the recipient forms, the consent rules and the refusal codes, and Sends for what came of it. A send named this way goes out when you name it, outside safe hours included, and warns rather than blocks on the frequency cap.

A follow-up goes out on the Outreach's own schedule and stops the moment the person replies. To send that follow-up now rather than wait for its day, nudge the person yourself:

POST /orgs/{org}/agents/{agent}/people/{personId}/outreach/follow-up
{ "messageTemplateId": "arrival-nudge" }

messageTemplateId is optional — omitted, the Outreach's own follow-up template is used. The nudge is a named send: it goes out now, ignores safe hours and the frequency cap, and is refused with 409 agent.outreach.consent_denied when the person's consent is denied (and 409 agent.outreach.opted_out after STOP). It needs a conversation the agent already opened with this person; 400 agent.outreach.follow_up_failed when there is none, when the template is unknown or unconfigured, or when delivery fails (the title says which). 422 agent.outreach.no_channel_identity means the person no longer holds the channel identity the Outreach was sent to; the follow-up goes only there.

Examples

# List an agent's people
curl -s https://api.anychat.ai/v1/orgs/acme/agents/agent-acme-sdr-pj7r2qx4/people \
  -H "Authorization: Bearer $ANYCHAT_TOKEN"

# List tagged people with a phone number on file
curl -s "https://api.anychat.ai/v1/orgs/acme/agents/agent-acme-sdr-pj7r2qx4/people?tags=webinar-2026-06&hasPhone=true&limit=50" \
  -H "Authorization: Bearer $ANYCHAT_TOKEN"

# Create a person
curl -s https://api.anychat.ai/v1/orgs/acme/agents/agent-acme-sdr-pj7r2qx4/people \
  -H "Authorization: Bearer $ANYCHAT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "fullName": "Jane Smith",
    "email": "jane@example.com",
    "phone": "+15551234567",
    "productInterest": "CoolSculpting",
    "source": "webinar-2026-06",
    "customFields": { "targetMarket": "medspa" }
  }'

# Move a person along the funnel
curl -s -X PATCH \
  https://api.anychat.ai/v1/orgs/acme/agents/agent-acme-sdr-pj7r2qx4/people/665f2a... \
  -H "Authorization: Bearer $ANYCHAT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status": "qualified"}'

# Count people reached, replied, and booked, for one tag
curl -s "https://api.anychat.ai/v1/orgs/acme/agents/agent-acme-sdr-pj7r2qx4/people/stats?tags=webinar-2026-06" \
  -H "Authorization: Bearer $ANYCHAT_TOKEN"

# Export the whole CRM as CSV
curl -s -OJ https://api.anychat.ai/v1/orgs/acme/agents/agent-acme-sdr-pj7r2qx4/people/export \
  -H "Authorization: Bearer $ANYCHAT_TOKEN"

# Import rows, overwriting the profiles of people who hold their identities
curl -s https://api.anychat.ai/v1/orgs/acme/agents/agent-acme-sdr-pj7r2qx4/people/import \
  -H "Authorization: Bearer $ANYCHAT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "profileUpdate": "overwrite",
    "rows": [
      { "channel": "rcs", "address": "+15551234567", "email": "jane@example.com", "company": "Example Health" }
    ]
  }'

Automated Outreach

Automated Outreach watches a customer-owned Google Sheet for new qualifying rows, imports each as a Person, and sends an Outreach to the ones that are ready, with no console interaction per row. It is built on Outreach: the opener, Playbook, Goal, class and follow-up all come from the Outreach it names.

An agent holds a named collection of these, one rule per sheet tab it watches, so a business with two lead sources keeps two rules with their own filters, consent columns and Outreaches. Each rule has an id, unique within the agent, and a name the operator chooses. The sheet is monitored for changes: new and edited rows are typically picked up within about ten minutes, once the edits have settled. Run now (POST …/runs) triggers a run immediately. See the console guide for the operator walkthrough.

The spreadsheet must be shared with the platform's sheets service account (Viewer, or Editor when writeBack is enabled).

interface AutomatedOutreachConfig {
  id?: string;                          // stable, unique within the agent; assigned on create
  name?: string;                        // what the operator calls this lead source
  enabled?: boolean;                    // master switch for automatic runs
  dryRun?: boolean;                     // dry-run mode: every run simulates (see below)
  outreachId?: string;                  // the Outreach each qualifying row is sent
  shadowOutreachId?: string;            // shadow run: decide every row under this Outreach too, dispatch nothing, report agreement on the run
  source?: {
    spreadsheetId?: string;             // from the Google Sheets URL
    sheetName?: string;                 // tab title, e.g. "Qualified Leads"
  };
  mapping?: Record<string, string>;     // sheet header → person field, or "rowId" for your own row id; "" ignores the column
  rowFilter?: {                         // only matching rows are processed
    column?: string;                    // e.g. "Qualified?"
    acceptedValues?: string[];          // default ["yes"] (case-insensitive)
  };
  consent?: {                           // consent bucketing for one sheet column
    column?: string;
    grantedValues?: string[];           // default ["yes"]
    deniedValues?: string[];            // default ["no"]
    grantedIfContains?: string[];       // substring rescue; default ["i consent"]
  };
  timestampColumn?: string;             // lead-form submission time (consent evidence)
  startAt?: string;                     // ISO 8601; only rows on or after are processed
  requiredFields?: string[];            // must be non-empty before auto-send
  maxSendsPerRun?: number;              // automated send rate; default 25, max 500, spent per hour
  sendWindow?: {                        // optional; all three fields together
    startHour?: number;                 // 0–23, inclusive
    endHour?: number;                   // 0–24, exclusive; may wrap midnight
    timeZone?: string;                  // valid IANA zone, e.g. "America/New_York"
  };
  ignoreSafeHours?: boolean;            // send outside the account's safe hours; default false
  writeBack?: {                         // per-row result notes on the sheet
    enabled?: boolean;
    column?: string;                    // e.g. "Anychat Outreach"
  };
}
Method Path Role Scope
GET /orgs/{org}/agents/{agent}/automated-outreaches viewer agents:read
POST /orgs/{org}/agents/{agent}/automated-outreaches editor agents:write
GET /orgs/{org}/agents/{agent}/automated-outreaches/{id} viewer agents:read
PATCH /orgs/{org}/agents/{agent}/automated-outreaches/{id} editor agents:write
DELETE /orgs/{org}/agents/{agent}/automated-outreaches/{id} editor agents:write
GET /orgs/{org}/agents/{agent}/automated-outreaches/{id}/runs viewer agents:read
POST /orgs/{org}/agents/{agent}/automated-outreaches/{id}/runs editor agents:write

GET on the collection returns { "automatedOutreaches": [...] }. POST requires name and assigns id by slugifying it unless the body supplies one; a duplicate id is 422. PATCH changes only the keys the body carries, except outreachId and shadowOutreachId, which are preserved when absent and cleared only by sending null. ignoreSafeHours is also kept when a write omits it, including a PUT on the single-rule route, and is switched off only by sending false. It is for a lead source you answer the moment someone leaves their details: rows go out as they arrive, whatever the account's safe hours. Consent, STOP, the frequency cap and the rule's own sendWindow still apply. Both ids must name an enabled Outreach on the agent (else 422 agent.outreach.not_found); malformed fields are 422. An id the agent does not have is 404. DELETE removes the rule and keeps its run history, which names the rule each run belongs to.

The singular routes are deprecated

GET and PUT /orgs/{org}/agents/{agent}/automated-outreach predate the collection. They still work and address the agent's first rule, so a client written before this change is unaffected. They retire in the next major version of @anychat-ai/agent-api.

Send window. Sends are allowed from startHour (inclusive) to endHour (exclusive) in timeZone: 11 to 21 means 11:00:00 through 20:59:59. The send window narrows the account's safe hours: a row is sent only when both are open, and a row that arrives outside them waits for the first run inside. timeZone must be a valid IANA name with underscores, e.g. America/New_York; a space typed in their place is normalized on PUT, and any zone that still isn't valid is a 422.

Start date. startAt excludes sheet entries that predate the agent: rows whose timestampColumn value is before it are left completely untouched, not imported and not messaged, and counted as beforeStart in the run record. It requires timestampColumn (the PUT is 422 without it). While a cutoff is set, a row with a blank or unreadable timestamp is skipped and flagged (unparseable-timestamp) rather than silently included. Set it to the go-live date so an existing sheet's backlog never enters the pipeline.

How a run processes the sheet. Every run reads the whole tab and classifies every row; there is no stored cursor, and re-scans are safe by construction. Each rule keeps a record of the rows it has processed, keyed on the row's own id when a column is mapped to rowId and on a fingerprint of the row otherwise, so a row it has seen is never imported twice, and anyone the Outreach has already been sent to is excluded. A new qualifying row's phone number becomes the Person's channel identity on RCS, the identity the Outreach is sent to; a row without a valid phone number is imported as a Person with no identity and reported invalid-phone. Each row's profile is written in fill mode (existing operator edits win), then the row must pass the skip ladder before sending: consent granted, valid phone, required fields present, inside the send window and the account's safe hours, outside the frequency cap for a marketing Outreach, and under the per-run cap. Everything else is flagged, never auto-sent, including every row whose consent cell is blank or unrecognized. Each send is an ordinary send of the named Outreach, carrying origin: 'automated', and is reported with the others.

Shadow run. With shadowOutreachId set and outreachId unset, each run also decides every row under that Outreach, running the same skip ladder, but dispatches nothing and writes nothing: no People created or updated, no consent stamps, no sheet write-back, no sends. The run records the comparison as shadow: { outreachId, rows, agree, disagree }, where rows is the number of rows decided, agree the number where the two decisions match, and disagree lists the rest as { rowNumber, personId?, legacy: { action, reason? }, outreach: { action, reason? } }. Cut over by setting outreachId to the shadow Outreach and clearing shadowOutreachId.

Once per person. Automated Outreach never sends the same Outreach to a person it has already reached with it, whatever the sheet says on a later pass; that is the "already contacted" exclusion, keyed on the Outreach. Rows above the hourly cap wait for a later run, as today.

Consent. The consent cell is bucketed per the config and stored on the Person as consent: { status, evidence, source, at } with the raw cell text kept verbatim as evidence. Automated sends require granted; a per-person send is unchanged. Opt-out (STOP) handling is separate, on the gateway, and always wins.

Runs. GET …/runs returns run records newest first (?limit=, default 20, max 100), retained 90 days:

{
  "data": [{
    "_id": "…", "trigger": "sweep", "status": "completed",
    "startedAt": "2026-08-27T17:07:00.000Z", "finishedAt": "…",
    "counts": { "rows": 171, "notQualified": 12, "alreadyContacted": 140,
                "imported": 6, "updated": 0, "sent": 4, "skipped": 2, "failed": 0 },
    "rows": [
      { "rowNumber": 172, "name": "Tiffany Petersen", "personId": "…", "sendId": "osnd_…", "action": "sent" },
      { "rowNumber": 173, "name": "…", "action": "skipped", "reason": "no-consent",
        "detail": "no messaging consent recorded — send manually if consent exists offline" }
    ]
  }]
}

Row reason values: no-consent, consent-denied, invalid-phone, missing-required-fields, missing-inputs, outside-send-window, in-conversation, frequency-gated, max-sends-reached, not-rcs-capable, send-failed, row-error, claimed-by-concurrent-run. missing-inputs means the Outreach's declared inputs could not be filled from the row; in-conversation means the person wrote to the agent in the last 30 minutes, and the row goes on a later run. Rows that are already handled (already sent, not qualified) are counted, not itemized. A run that couldn't start at all (sheet not shared, incomplete config, no outreachId) is status: "failed" with an operator-readable error. A run also carries unmatchedColumns when sheet columns matched neither a built-in alias, the configured mapping, nor a declared custom field.

POST …/runs responds 202 { "started": true } immediately; the run record appears in the list when it finishes.

Dry run. POST …/runs with body { "dryRun": true } classifies every row exactly like a real run but writes nothing: no People created or updated, no consent stamps, no sheet write-back, and no sends. It executes synchronously and responds 200 with the completed run record, flagged "dryRun": true; candidate rows carry action: "would-send". Setting dryRun: true on the config latches every run, automatic or requested, into simulation until the flag is cleared.

Write-back. With writeBack enabled, each handled row gets a note in the configured column (outreach sent 2026-08-27, skipped: no consent recorded, not RCS-capable), creating the column header if needed. Only empty cells are ever written.

People segments

A segment is a named, saved People filter stored on the agent. Pass its id as segmentId on the People list or export to apply it. A segment stores the filter, not its results: it matches whichever people satisfy the filter at the moment it is used.

interface PeopleSegment {
  id: string;            // per-agent slug, derived from name at create time
  name: string;          // ≤ 100 characters
  description?: string;  // ≤ 500 characters
  filter: PeopleSearchFilter;
}

filter is the query-parameter vocabulary of the People list as a JSON object: scalar parameters by name, tags as a string array, and custom-field conditions nested under cf:

{
  "name": "Webinar leads, reachable",
  "filter": {
    "tags": ["webinar-2026-06"],
    "hasPhone": true,
    "outreachReachability": "rcs",
    "cf": { "targetMarket": "medspa" }
  }
}
Method Path Role Scope
GET /orgs/{org}/agents/{agent}/people/segments viewer people:read
POST /orgs/{org}/agents/{agent}/people/segments editor people:write
GET /orgs/{org}/agents/{agent}/people/segments/{segmentId} viewer people:read
PUT /orgs/{org}/agents/{agent}/people/segments/{segmentId} editor people:write
DELETE /orgs/{org}/agents/{agent}/people/segments/{segmentId} editor people:write

id is slugified from name on create and is stable through renames. POST also accepts an explicit id in the body; a value already in use on the agent is rejected with 422 (agent.people_segment.id_conflict). PUT is an upsert: it replaces the segment under the id in the path, or, when none exists, creates one under that id and returns 201 (a replace returns 200). An agent holds at most 50 segments. A body that fails validation (a missing name, a filter key outside the vocabulary, an undeclared cf key) is rejected with a 422 problem detail.

People fields

The declared People fields: the structured facts the agent tracks about each Person under customFields[key], and the keys the People list's cf. filters, POST / PATCH customFields, and import matching accept. See Capabilities · People fields in the People guide. This resource is the capabilities.people.fields slice of an export.

interface PersonFieldDef {
  key: string;                   // ^[a-zA-Z][a-zA-Z0-9_]*$, unique within the agent
  label?: string;                // operator- and LLM-facing label
  description?: string;          // what the field means and when to fill it
  type?: 'string' | 'number' | 'date' | 'boolean' | 'enum'; // default string
  enum?: string[];               // allowed values when type is enum
  examples?: string[];           // sample values that help the agent recognise the field
  required?: boolean;            // advisory: expected on an intake form; never blocks the agent
  intake?: { aliases?: string[] }; // source columns matching by key, label, or alias (case-insensitive)
}
Method Path Role Scope
GET /orgs/{org}/agents/{agent}/people-fields viewer agents:read
PUT /orgs/{org}/agents/{agent}/people-fields editor agents:write

GET returns { "data": PersonFieldDef[] }. PUT takes { "fields": PersonFieldDef[] } and replaces the whole list; both return { "data": PersonFieldDef[] }. A body without a fields array, a field missing key, a key that fails the pattern or repeats, or a type outside the enumeration is a 422 request.invalid problem detail (errors[].field, for example fields[0].key).

Personas

A persona is a Person record that lives in the sandbox partition, with a starting point (preset: fields, messaging consent, earlier messages, appointments) and its own conversations. Its id is its Person id, so GET …/people/{personaId} reads its full record (the People list stays live-only). The runtime treats it exactly as a Person — the consent gate, intake, identity matching, CRM fields and scheduling all run for real against it — and every turn against it is captured: reads are real, writes are returned with the reply instead of performed. A persona is made at its starting point; reset returns it there. Tests take a fresh copy per case; the emulator keeps one across sittings, so Returning guest stays returning.

Personas are reusable. The first list on an agent seeds a starter set (First-time visitor; Returning guest, with an appointment in two days; Opted-out lead), you add your own, and you can make one from a real conversation, up to a message you choose, to reproduce a problem and continue it from that point.

An emulator grant may list, read, create and reset personas, read their transcripts and run their turns — the standalone emulator page's whole surface — regardless of the role column below; it may not patch, delete or create from a conversation.

A persona can be the recipient of anything outbound. An Outreach sent to a persona — by name, or by Automated Outreach — and its follow-up run the whole pipeline: opener resolution, the guardrails for that kind of send, the send record (stamped sandbox). The send is captured: it appears as the opening in the persona's conversation, where you can reply. A test link counts too — redeem it with { "personaId": "<id>" } and it consumes no use.

interface Persona {
  id: string;                        // also its Person id
  agentApplicationId: string;
  name: string;
  description?: string;
  kind: 'starter' | 'custom';        // an edited starter becomes custom
  preset: PersonaPreset;             // the starting point
  presetOf?: string;                 // the session it was made from
  emulatorUserId?: string;           // the persona's stable emulator identity (renewed by reset)
  channelIdentities: PersonChannelIdentity[]; // its current emulator identity
  formerIdentities: Array<{          // identities it held before a reset, oldest first; empty if never reset
    asid: string;                    //   its earlier sandbox conversations carry this as their userId
    channel: string;                 //   'webchat'
    releasedAt: string;              //   ISO-8601
  }>;
  person: {                          // the Person state the runtime holds right now
    fullName?, firstName?, lastName?, email?, phone?, company?, title?, productInterest?,
    status: string, customFields?, consent?: { status }, scheduledCallAt?, lastInboundAt?
  };
  changedSinceStart: {               // what changed since the starting point
    fields: string[];                //   written by the agent, intake or system (not an operator); custom fields as `customFields.<key>`
    consent: boolean;                //   consent differs from the starting point's
    appointments: number;            //   sandbox appointments made or changed beyond the starting point's own
    journal: number;                 //   memory-journal entries
    sends: number;                   //   sandbox sends to the persona
  };
  resetAt?: string; createdAt?: string; updatedAt?: string;
}

interface PersonaPreset {
  fields?: {                         // the authorable Person fields
    fullName?, firstName?, lastName?, email?, phone?, company?, title?, productInterest?,
    status?, source?, customFields?, assignedRepresentativeId?, tags?, notes?
  };
  consent?: { status: 'granted' | 'denied' | 'unknown' };
  historySeed?: Array<{ role: 'user' | 'assistant'; content: string }>;   // earlier messages, up to 40
  appointments?: Array<{             // up to 10, relative to the moment of reset
    startsIn: string;                // ISO-8601 duration: "PT15M", "P2D", "P2DT10H"; in the past via "-P1D"
    durationMinutes?: number;        // 5–480; default the agent's scheduling default
    representativeId?: string;       // default the agent's first representative
    status?: 'scheduled' | 'cancelled' | 'completed';
  }>;
}

interface PersonaTranscript {
  userId: string;                    // the current identity
  messages: Array<{                  // oldest first
    origin: 'user' | 'agent';
    text: string;                    // plain text, best effort
    rendered?: string;               // agent messages: transcript text, each card's chips nested under it
    seeded?: true;                   // an earlier message from the starting point, never sent
    messageId?: string; timestamp?: string; sessionId?: string;
  }>;
}
Method Path Role Scope
GET /orgs/{org}/agents/{agent}/personas viewer agents:read
POST /orgs/{org}/agents/{agent}/personas editor agents:write
POST /orgs/{org}/agents/{agent}/personas/from-conversation editor agents:write
GET /orgs/{org}/agents/{agent}/personas/{personaId} viewer agents:read
PATCH /orgs/{org}/agents/{agent}/personas/{personaId} editor agents:write
DELETE /orgs/{org}/agents/{agent}/personas/{personaId} editor agents:write
POST /orgs/{org}/agents/{agent}/personas/{personaId}/reset viewer agents:read
GET /orgs/{org}/agents/{agent}/personas/{personaId}/transcript viewer agents:read
POST /orgs/{org}/agents/{agent}/personas/{personaId}/turns viewer agents:read
GET /orgs/{org}/agents/{agent}/personas/{personaId}/turns viewer agents:read
  • GET returns { "data": Persona[] } and seeds the starter set on an agent that has none (idempotent).
  • POST …/personas with { "name", "description"?, "preset"? } returns 201 Persona. The starting point is validated against the agent (an assignedRepresentativeId or appointment representativeId must exist; customFields keys are stored as given) and applied at once, appointments included; a phone is stored but never indexed, so a persona may reuse a real customer's number.
  • PATCH replaces name, description and/or preset; editing a starter's preset makes it custom. The running Person state is untouched until the next reset — reset after replacing the preset to start from the new starting point.
  • POST …/reset returns the persona to its starting point: fresh Person state, the starting point's appointments made again (other sandbox appointments dropped), a new emulator identity (the next turn opens a new conversation) and the open conversation closed. Earlier conversations stay in the partition, still attributed to the persona: the identity they carry moves to formerIdentities, so match a conversation's userId against channelIdentities and formerIdentities together to name its persona.
  • GET …/transcript returns a PersonaTranscript: the starting point's earlier messages, then the content messages since it (up to 200), read from the message journal. The journal lags a turn by a few seconds. An agent message's rendered is the message in the format the Test judge reads: each rich element on its own bracketed line, a carousel's cards listed with each card's [chips: …] indented beneath it, and the message's own chips as [suggested replies: …] (see How a test runs). text stays plain text.
  • POST …/from-conversation with { "sessionId", "throughMessageId"?, "name"?, "dryRun"? } — a session id from GET …/sessions, a messageId from GET …/messages — returns 201 Persona with presetOf set. The starting point copies the person's record (the name replaced by the persona's name; phone, email, last name and notes removed; company, title, product interest, custom fields, status, tags, representative and consent kept) and the contact's messages up to and including throughMessageId (default: to the end of that session), the last 40, as earlier messages. The person's known name, phone and email are also replaced inside the copied text. That is best-effort: what the person typed that was never on their record is not caught. dryRun: true returns 200 { name, description, preset, replacements } and creates nothing. conversationId is accepted as a deprecated alias of sessionId.
  • DELETE returns 204.

Errors are problem details:

Status code When
404 agent.persona.not_found no such persona on this agent; for from-conversation, no such session or message
422 agent.persona.invalid the name or preset is malformed, or names a representative the agent lacks; more than 200 personas
409 agent.persona.unavailable the persona's emulator identity could not be minted or has been lost — reset it
502 agent.persona.upstream the agent or gateway did not answer

Sandbox turns

The sandbox turn API is the one door for everything headless: the emulator, Tests, the standalone emulator link and your own scripts all send a persona a message and receive the reply and the captured effects. Every turn runs the real pipeline — identity, session ids, the agent's conversation runtime, the channel's own rendering rules — so a script can be pointed at any agent in any environment, production included, with no side effects and full visibility in the console.

POST …/personas/{personaId}/turns sends one turn as the persona:

{ "channel": "rcs", "message": "Can I move my appointment to Friday?" }
Field Meaning
channel The channel look the reply is rendered for: rcs (default), amb, whatsapp or sms. Rendering goes through the same degrade resolver a real channel uses, against that channel's capability document.
message Text the persona sends.
action A tap: { payload, label? }, the chip's postback payload from a previous reply.
place A shared location: { latitude, longitude, label? }.
start Open the conversation without text (an outreach-style start): { context? }.

Exactly one of message, action, place or start. The reply:

interface SandboxTurn {
  reply: Array<{ messageId: string; signal: string }             // typing / read signals
              | { messageId: string; bubbles: unknown[]; warnings?: string[] }>;  // rendered bubbles (text, cards, chips, pickers)
  effects: CapturedEffect[];       // what the agent captured instead of performing, in order
  conversationId?: string;         // the session — GET …/sessions lists it with includeSandbox=true
  turnId?: string;                 // the turn record — GET …/turns shows its trace and effects
  userId: string;                  // the persona's gateway identity in this conversation
}

With Accept: text/event-stream the same call streams: one message event per reply message, one effects event ({ effects, turnId?, conversationId?, userId }), then done.

GET …/personas/{personaId}/turns returns what the agent sent the persona on its own — an Outreach opener, a follow-up, a list send — rendered for ?channel= (default rcs), each message once: { messages, userId }. With ?stream=1 (or Accept: text/event-stream) it long-polls up to 25 s for the first message and streams message events, then done. The emulator uses this to show a captured opening the moment it lands.

Turns are rate-limited per agent (120 per minute across every caller); over the limit is 409 agent.sandbox.rate_limited. An agent whose emulator transport is missing answers 404 agent.sandbox.unavailable; a malformed body is 422 request.invalid.

# Ask the agent, as the returning guest, to move Friday's booking — nothing is written to the calendar
curl -X POST "https://api.anychat.ai/v1/orgs/acme/agents/agent-acme-sdr-pj7r2qx4/personas/66f1…/turns" \
  -H "Authorization: Bearer $ANYCHAT_TOKEN" -H "Content-Type: application/json" \
  -d '{ "channel": "sms", "message": "Can I move my appointment to next Friday?" }'
# → { "reply": [...], "effects": [{ "kind": "booking.rescheduled", "summary": "Rescheduled to Fri 10:00 AM", ... }], ... }

The emulator is the browser page that renders a conversation the way a handset would — RCS, Apple Messages, WhatsApp or SMS — and sends it through the real rendering pipeline. Inside the console it is the Emulator page and its docked panel. Outside the console, a standalone emulator link lets a colleague who works with the team but not in the console (a client, a reviewer, a copywriter) talk to one agent without an account. Every conversation through a link is in the sandbox partition: it is kept beside the real ones and never counted, and nothing done in it reaches a real customer. This is not a channel's early-access list, which is who can reach the agent on a real channel, on their own phone, before launch.

A link carries a signed, agent-scoped grant. Revoking the link invalidates it at once; it also stops working at expiresAt. A short-lived (minutes) grant of the same kind can be minted for a session that is already authenticated — a script or MCP host mints one to open the emulator page for an agent right now. A grant is a bearer token in its own right (see Authentication): the standalone page sends it as Authorization: Bearer <grant> to GET /emulator/session, then to the agent's persona routes, which is the only surface a grant can reach.

interface EmulatorLink {
  id: string;
  label?: string;          // operator-facing note
  expiresAt: string;       // ISO 8601
  createdAt?: string;
  createdBy?: string;      // member id
}

interface EmulatorLink$Created extends EmulatorLink {
  url: string;             // the standalone emulator page — returned once, never on a list
}

interface EmulatorGrant {
  grant: string;           // a bearer token scoped to this agent's personas
  expiresAt: string;       // ISO 8601
  url: string;             // the standalone emulator page carrying the grant
}

interface EmulatorSession {
  orgId: string;
  agentId: string;         // the agent the grant is scoped to
}
Method Path Role Scope
GET /orgs/{org}/agents/{agent}/emulator-links viewer agents:read
POST /orgs/{org}/agents/{agent}/emulator-links editor agents:write
DELETE /orgs/{org}/agents/{agent}/emulator-links/{id} editor agents:write
POST /orgs/{org}/agents/{agent}/emulator-grants viewer agents:read
GET /emulator/session grant bearer only —
  • GET returns { "data": EmulatorLink[] }: the agent's active links (not expired, not revoked), newest first, without their urls.
  • POST …/emulator-links with { "ttlDays"?: number, "label"?: string } creates a link and returns 201 EmulatorLink$Created. ttlDays is 1–90 (default 14); label is at most 200 characters. The url is returned exactly once — copy it into your own message to the person you are sharing it with. Anyone holding the url can talk to the agent in the sandbox until the link expires or is revoked.
  • DELETE revokes the link and returns 204. The url stops working immediately for everyone who has it; there is no un-revoke.
  • POST …/emulator-grants with { "ttlSec"?: number } (60–3600, default 1800) returns 200 EmulatorGrant. Grants are not listed and cannot be revoked; they expire on their own. Any role on the agent may mint one, because anyone with a role may use the emulator.

  • GET /emulator/session with a grant as the bearer returns 200 EmulatorSession; with any other bearer it is 403 auth.forbidden.

The url opens the emulator's standalone page with the agent id and the grant in the URL fragment (#agent=…&grant=…), so neither reaches the hosting server's logs. The page asks the API which agent the grant is for, lets the person pick one of the agent's personas or make a new one by name, lets them pick the channel look, remembers their persona in that browser, and says plainly that it is a sandbox. Two people holding the same link share a conversation only if they pick the same persona.

Errors are problem details:

Status code When
422 agent.emulator_link.invalid ttlDays outside 1–90 or not an integer; label over 200 characters (errors[].field)
422 request.invalid ttlSec is not a number
404 agent.not_found the agent is not in the org
404 agent.emulator_link.not_found no such link on this agent (a link of another agent is a 404, not a 403)
401 — the bearer is a grant that is malformed, expired, revoked, or signed for an agent that no longer exists (an empty body, as for any bad bearer)
403 auth.emulator_grant.forbidden a grant bearer called anything beyond its agent's personas, their turns, and GET /emulator/session

Apply Agent Template

Stand an agent up from an Agent Template. A template is an agent document with identity omitted — the same two-layer shape as an export (a definition layer with personality, guardrails, goals, playbooks, Message Templates; a capabilities layer with people and outreach configuration) — so applying one imports that document through the same path as Import.

Method Path Role Scope
POST /orgs/{org}/agents/{agent}/apply-template editor templates:apply

Body:

{ "templateId": "@anychat/agent-template.sales-development" }
Response:
{
  "agent": { ... },
  "appliedFrom": { "templateId": "...", "templateName": "Sales Development Agent" },
  "applied": [
    "definition.personality",
    "definition.goals",
    "definition.playbooks",
    "capabilities.outreach"
  ],
  "skipped": []
}

Semantics are wholesale section-replace (import semantics): every section the definition carries replaces that section on the agent; sections it omits are left untouched. The agent is stamped with templateRef (the template id and version it was applied from), which later drives Template updates. The result is validated to have exactly one default playbook. Account-specific configuration (connected accounts, secrets) is not part of a definition, so it's untouched — you connect accounts afterward.

Only a template your workspace is offered can be applied, and GET /catalogs/agent-templates lists exactly those.

Status Code Meaning
403 agent.template.not_available the template exists, but your workspace is not offered it. Ask your Anychat operator to add it.
404 agent.template.not_found no template has that id

Template updates

Applying a template copies its content onto your agent, so a later catalog edit never silently changes a live agent. When the catalog publishes a newer version of the template you started from, these endpoints surface it and let you apply it as a reviewed merge. The server compares three documents — the template version your agent came from (the base), the latest template version, and your agent's current document — and reports every difference as an item you resolve: take the template's change or keep yours.

Method Path Role Scope
GET /orgs/{org}/agents/{agent}/template-update viewer agents:read
GET /orgs/{org}/agents/{agent}/template-update/preview viewer agents:read
POST /orgs/{org}/agents/{agent}/template-update/apply editor agents:write

GET .../template-update reports whether a newer version exists:

{
  "templateRef": { "templateId": "@anychat/agent-template.sales-development", "version": "24" },
  "latestVersion": "25",
  "latestName": "Sales Development Agent",
  "updateAvailable": true,
  "baseAvailable": true
}
These three endpoints keep working even if your workspace stops being offered the template the agent came from. The content is already in your workspace, so an agent built from a template never loses its upgrade path.

templateRef is null for agents not created from a template. baseAvailable: false means the original version is no longer on record; the preview then reports every difference as a conflict, since edits you made can't be told apart from template changes.

GET .../template-update/preview returns the merge items. Each has a path (the document section, e.g. definition.playbooks), a key for items inside array sections, a label, the three compared values (base, theirs, mine), and a status:

Status Meaning Default resolution
incoming the template changed this; you didn't take template
added new in the template take template
removed the template dropped this; you didn't change it take template
local you changed this; the template didn't keep yours
conflict both changed you must choose

Items identical on both sides are omitted. Content you added that never came from the template is yours alone and never appears.

POST .../template-update/apply takes the target version and your choices:

{
  "toVersion": "25",
  "resolutions": [
    { "path": "definition.playbooks", "key": "sales-discovery", "choice": "template" },
    { "path": "definition.personality", "choice": "mine" }
  ]
}
Unlisted non-conflict items get their default resolution; an unresolved conflict is a 422. A 409 means the catalog moved past toVersion while you were reviewing (fetch the preview again). On success the agent's templateRef is re-stamped to the new version and the updated agent is returned.

The retired per-piece endpoints GET .../updates and POST .../updates/apply remain routable for compatibility: the list is always empty, and apply responds 410 Gone.

Export / Import

A round-trippable, operator-meaningful snapshot of an agent. Export projects the agent into a two-layer document: a header (name, description, template lineage, llm, genericConfig), a definition layer — the authored document (identity, personality, guardrails, standing instructions (definition.standingInstructions), goals (definition.goals, definition.defaultGoal; a Playbook's own goal rides inside definition.playbooks[]), playbooks, Message Templates including each one's use) — and a capabilities layer — capability configuration (scheduling including representatives and defaultRepresentativeIdentity, people with fields and the update permission, knowledge with Things and collections, outreach, and integrations.custom — custom integration contracts). Import applies the same document back as a patch. This is the supported way to read and write playbooks in bulk, and the mechanism behind moving an agent between accounts.

Method Path Role Scope
GET /orgs/{org}/agents/{agent}/export viewer agents:read
POST /orgs/{org}/agents/{agent}/import editor agents:write

definition.playbooks[].uses, definition.playbooks[].flow (see Playbooks), and definition.rules[] round-trip in the same document; rules are the agent's own Rules (see Rules and safety) — the same AgentRule[] shape documented there. Documents written before this shipped, or exported against the retired policy model, use policies in place of rules; import still accepts policies as a legacy alias, converted with the same mapping the one-time migration used (patterns become the Rule's check.patterns, action: 'drop' becomes action: 'block').

Export no longer writes definition.playbooks[].procedures or definition.guardrails.additionalRules. Import still accepts both on an older document: each procedure with text is appended to its Playbook's body under ## Procedures / ### {name}, and additionalRules becomes standingInstructions, one instruction per line (bullet markers stripped, duplicates skipped).

Export no longer writes definition.objectives either (Objectives were retired on 2026-09-15). Import still accepts them on an older document: when the resulting agent has no defaultGoal, the objectives are appended to the default Playbook's body in priority order, as the document used to render them; when it has one, they are dropped. The import response notes either way.

The header carries the agent's framework (always v2); import ignores it.

export returns YAML by default (the document reads top-to-bottom in the console's information-architecture order); pass ?format=json for JSON. The document never contains secrets or account-specific state: credentials (an integration's auth header, a notification API key), channel bindings, connections (including the scheduling.calendar reference that names one), knowledge Sources, and runtime state are all excluded.

import accepts the same shape (as a JSON body) and applies only the sections present — unset sections are left untouched; a section present with an empty value clears it. Server-owned keys (id, framework, secrets, timestamps) are ignored. The export document writes capabilities.knowledge.facts and capabilities.knowledge.things; within capabilities.knowledge, the historical faqs and products keys are still accepted on import as aliases of facts and things (either is written to the same slice) — send whichever your document has, not both. On a Fact, verbatim is likewise accepted on import as a legacy alias of exact. Any other key under capabilities.knowledge the importer doesn't recognize is not applied and is reported under skipped (e.g. capabilities.knowledge.sources) rather than failing the whole import. Documents in the retired flat format (pre-August 2026, section keys at the top level) are rejected with a 422 asking for a re-export. Validation mirrors the individual resources (e.g. exactly one default playbook, unique ids, known uses ids), and imported flows are linted the same way a Playbook write is — a flow.* error in the document is a 422 (agent.playbook.flow_invalid) listing the findings. The target agent's own Google Calendar connection, if any, is preserved across an import. Responds with the updated agent plus what happened to each document path:

{
  "agent": { ... },
  "applied": ["name", "definition.playbooks", "capabilities.scheduling"],
  "skipped": ["id"],
  "notes": []
}

Lint

Definition lint is a pure check over the agent's document (plus its capability configuration) that returns findings: is the document internally consistent? It is the static counterpart of readiness ("is the capability set up?") and shares its finding shape, so the console's Readiness card shows both in one list. The structural rules also run inline on every document write (an error there rejects the write with 422), on import, and when a template is applied.

Method Path Role Scope
GET /orgs/{org}/agents/{agent}/lint viewer agents:read
interface AgentLintResponse { findings: LintFinding[] }

interface LintFinding {
  severity: 'error' | 'warning' | 'info';
  code: string;        // stable rule id, e.g. 'playbook.uses.unknown'
  path: string;        // JSON pointer into the agent document, e.g. '/playbooks/0/uses/1'
  message: string;     // operator-facing
  fix?: {
    label: string;                 // e.g. 'Create the template or drop the reference'
    route?: string;                // console path, relative to the agent, that fixes it
  };
}

Rule families and codes:

Family Codes Severity
Structural (rejects the write) playbook.uses.unknown, playbook.uses.missing (a Playbook has no uses at all — unreachable through the API, since every write fills a missing uses), playbook.messageTemplateRef.unknown, outreach.openerTemplate.unknown (an Outreach's openerTemplateId names no Message Template on the agent), outreach.playbook.unknown (an Outreach's playbookId names no Playbook, or that Playbook does not carry this Outreach's id), outreach.followUp.template.unknown (an Outreach's follow-up templateId names no Message Template), playbooks.default.missing, playbooks.default.multiple, playbook.id.duplicate, messageTemplate.id.duplicate, messageTemplate.marker.unknownThing (a message template body's [[things: ...]] or [[products: ...]] marker names an id or name not in the Things catalog; an entry that is a {…} fill-in is resolved at send time and not checked), messageTemplate.marker.unknownCollection (a message template body's [[collection: ...]] marker names a collection id that does not exist; a {…} fill-in is not checked), fact.scope.unknown (a Fact's scope names a Playbook id that does not exist), thing.collection.unknown (an entry of a Thing's collections names no existing collection), rule.pattern.invalid (path /rules/i/check/patterns/j; a Rule's pattern is neither a plain phrase nor a valid /regex/flags), rule.text.empty (a Rule's text is blank), standingInstruction.empty (path /standingInstructions/i; a standing instruction is blank), goal.unknown (an assignment — defaultGoal, a Playbook's goal, or an Outreach's goal — names a goalId or then that doesn't exist on the agent; see Goals) error
Content messageTemplate.slot.unknown, playbook.whenToUse.empty, media.ref.unknown (an image:<id> reference to an item not in the media library — the message still sends, without the picture), thing.media.unknown (a Thing's media names no existing media item — the card falls back to imageUrl; only checked when media ids are given), fact.answer.placeholder (path /facts/i/answer; a Fact's answer still has placeholder text, e.g. "[fill in]" or "TODO" — the agent will say it to people until it's replaced), fact.question.duplicate (path /facts/i/question; two Facts ask the same question in different wording; the agent may answer from either), fact.contact.drift (path /facts/i/answer; a Fact's answer gives a phone number or email that is not the agent's listed contact detail in Identity), facts.budget (path /facts; the Facts list has outgrown the always-in-view budget; alwaysOn entries count as always inline, not against the budget), rule.fallback.missing (a Rule's action is block with no fallbackText, so a reply that trips it is dropped silently), messageTemplate.onlyWhenNamed.unreachable (path /messageTemplates/i/use; a Message Template set to onlyWhenNamed that no Playbook names and no Outreach references by id — the agent can never send it) warning
Capability setup scheduling.hours.unset (Scheduling is set up and a Playbook uses it, but no available hours are set, so the agent can offer a call at any hour — set hours, or leave them empty on purpose) warning
Retired constructs v2.ignored.skills — a retired skills-library array the runtime ignores (a playbook's toolRefs / derivedFrom are ignored and not reported) warning
Advisory playbook.template.unreferenced (a Playbook's messageTemplateRefs names a template its text never mentions — harmless, the override is what makes it available), rule.check.unused (a judged Rule still carries a check block, which the judge ignores), goal.unused (a Goal in the library that no defaultGoal, Playbook, or Outreach assigns), goal.event.noProducer (a Goal's definition.event is link.tapped, which nothing marks reached yet — see Goals), outreach.input.unreferenced (an Outreach's declared input is never used by the opener's slots) info
Flow (structural) flow.action.capability.missing, flow.action.tool.unknown, flow.template.unknown, flow.bind.unknown, flow.slot.unknown, flow.branch.target.unknown, flow.handoff.unknown, flow.call.unknown, flow.call.noflow, flow.enum.options.missing, flow.timeslot.source.missing, flow.end.missing, flow.predicate.invalid, flow.predicate.option.unknown, flow.say.suggestions.invalid, flow.step.unknown error
Flow (advisory) flow.bind.notes (a slot bound to person.notes — a destructive sink; declare a People field instead), flow.slot.prompt.missing, flow.step.unreachable, flow.outcome.style, flow.body.stale (the Playbook body changed since the path was generated) warning

Lint is deterministic: every rule checks a reference or a structure, never the meaning of prose. Cross-layer consistency, such as "this Playbook talks about booking but Scheduling is not declared or not configured", is the job of Definition Review. Findings are computed on each read; the response has no page token.

{
  "findings": [
    {
      "severity": "error",
      "code": "playbook.messageTemplateRef.unknown",
      "path": "/playbooks/2/messageTemplateRefs/0",
      "message": "Playbook \"Schedule a call\" refers to message template \"confirm-slot\", which does not exist on this agent.",
      "fix": { "label": "Create the template or drop the reference", "route": "/agents/{id}/settings/message-templates" }
    }
  ]
}

Definition Review

Definition Review is a model's read of the whole agent document, run when you ask for it. Where lint answers "is the document valid" with deterministic rules on every write, a review answers "is the document sensible": does it promise what its capabilities can deliver, does it contradict itself, does it assume a channel it is not bound to. One review is one call to a frontier model over the Definition, the capability configuration (including which capabilities are actually configured), the bound channels, the media library's descriptions, and the current lint findings.

A review costs money and takes 20 to 120 seconds, so it never runs on a write and never runs on a schedule. It never blocks anything: findings are advice, and a document with review findings still saves, imports, and publishes. Requires the definition-review feature on the org; without it every route below returns 404 with code agent.definition_review.not_found.

Method Path Role Scope
POST /orgs/{org}/agents/{agent}/review editor agents:write
GET /orgs/{org}/agents/{agent}/review viewer agents:read
GET /orgs/{org}/agents/{agent}/review/quota viewer agents:read
GET /orgs/{org}/agents/{agent}/review/{reviewId} viewer agents:read
POST /orgs/{org}/agents/{agent}/review/findings/{fingerprint}:dismiss editor agents:write
DELETE /orgs/{org}/agents/{agent}/review/findings/{fingerprint}:dismiss editor agents:write

POST …/review runs the review and blocks until it finishes — typically 20 to 120 seconds — then returns 202 with the terminal status: completed (read GET …/review for the findings) or failed (error says why). If the API stops waiting before the agent finishes (after 200 seconds), the response is 202 with status: 'running' and no id; poll GET …/review until it settles. The status code is 202 in every case so clients written to poll keep working. The body is optional and may carry via (console, api, mcp, copilot, cli) to record who asked.

interface DefinitionReviewStart {
  id?: string;              // absent only when the API stopped waiting
  status: 'running' | 'completed' | 'failed';
  createdAt?: string;
  completedAt?: string;
  error?: string;           // present when `failed`
}

interface DefinitionReview {
  id: string;
  agentId: string;
  definitionHash: string;   // the document version the review ran against
  stale: boolean;           // the document has changed since
  requestedBy: { userId?: string; via: 'console' | 'api' | 'mcp' | 'copilot' | 'cli' };
  model: string;
  status: 'running' | 'completed' | 'failed';
  findings: DefinitionReviewFinding[];   // empty while running
  rejected: number;         // findings dropped by citation verification
  usage?: { inputTokens: number; outputTokens: number; cacheReadTokens?: number; costUsd?: number };
  error?: string;           // present when `failed`
  createdAt: string;
  completedAt?: string;
}

interface DefinitionReviewFinding {
  severity: 'error' | 'warning' | 'info';
  code: string;             // from the closed set below
  path: string;             // JSON pointer: '/definition/…', '/capabilities/…', '/channels/…'
  quote: string;            // verbatim from the document at `path`, 200 chars or fewer
  message: string;          // what is wrong and why it matters
  fix?: {
    label: string;
    route?: string;         // console path, relative to the agent
    suggestion?: string;    // proposed replacement for the quoted text
  };
  testCase?: string;  // a brief for a test case worth adding
  fingerprint: string;      // stable identity; the key for dismissals
  dismissed?: { at: string; by: string };
}

Every finding cites the document: the server checks that code is in the closed set, that path resolves, and that quote appears verbatim at path, and drops the finding otherwise. rejected counts what was dropped. stale is computed on each read by rehashing the current document; a stale review is shown as it is, never re-run automatically.

Finding codes:

Code Severity Reports
promise.capability.unconfigured error A Playbook or template promises an action whose capability is absent or not configured
promise.capability.undeclared warning A Playbook relies on a capability its uses excludes, so the runtime will not offer it
uses.broad info A Playbook's uses names capabilities its body never draws on
playbook.overlap warning Two Playbooks' when-to-use describe the same situation
playbook.unreachable warning A Playbook's when-to-use is too vague to be chosen, or the Default subsumes it
playbook.contradicts.guardrail error A Playbook instruction violates a guardrail
playbook.contradicts.playbook warning Two Playbooks give incompatible instructions for one situation
playbook.steps.noflow info A step-shaped body with no generated path
outreach.opening.mismatch warning The opening template's purpose differs from the Playbook's
outreach.consent.missing warning An outreach-eligible Playbook with no opt-out or consent language where the channel requires it
channel.shape.unsupported warning An instruction assumes rich cards or pickers and every bound channel is text-only
channel.none info The agent has no channel binding
identity.escalation.missing warning Hand-off language with no contact phone or email in Identity
knowledge.empty.referenced warning Instructions point at Sources, Things, or Facts that are empty
people.field.unreferenced info A declared People field nothing captures or uses
template.slot.unfillable warning A placeholder that resolves but whose source nothing populates
personality.tone.mismatch info Copy in a register the personality forbids
guardrail.unenforceable info A guardrail the model cannot act on
test.suggested info A case worth adding to a test suite; the brief is in testCase

Errors use problem details with these codes:

Status Code When Extra fields
404 agent.definition_review.not_found The agent has never been reviewed, an unknown reviewId, or the org lacks the definition-review feature
409 agent.definition_review.running A review is already running for this agent id
429 agent.definition_review.quota_exceeded The org has spent its reviews for the day dailyLimit, usedToday, resetsAt
422 agent.definition_review.invalid The agent service rejected the request
502 agent.definition_review.upstream The agent service is unavailable

GET …/review/quota returns the org's allowance for the current UTC day.

interface DefinitionReviewQuota {
  dailyLimit: number;
  usedToday: number;
  resetsAt: string;
}

Dismissals are per finding and outlive the review that produced them: POST …/review/findings/{fingerprint}:dismiss hides a finding in later reviews, DELETE on the same path restores it.

{
  "id": "rev-8f21",
  "agentId": "agent-glow-medspa-a1b2c3d4",
  "definitionHash": "9c1e…",
  "stale": false,
  "requestedBy": { "userId": "user-42", "via": "console" },
  "model": "claude-opus-5",
  "status": "completed",
  "rejected": 1,
  "findings": [
    {
      "severity": "error",
      "code": "promise.capability.unconfigured",
      "path": "/definition/playbooks/1/body",
      "quote": "Offer to book a consultation and give them the next three openings.",
      "message": "This Playbook offers to book, but Scheduling is not configured on this agent, so the agent has no times to offer and will promise something it cannot do.",
      "fix": {
        "label": "Configure Scheduling, or rewrite the Playbook to hand off",
        "route": "/agents/{id}/settings/scheduling"
      },
      "testCase": "A person asks for an appointment on Tuesday; the agent must not claim it will pull up times.",
      "fingerprint": "b2f0c7d1e9a4…"
    }
  ],
  "usage": { "inputTokens": 9420, "outputTokens": 1880 },
  "createdAt": "2026-09-11T17:02:11.000Z",
  "completedAt": "2026-09-11T17:02:39.000Z"
}

Catalogs

Read-only system catalogs of starter content. Available to any authenticated caller. Every kind supports a list and a single-item read. There is no role floor; the only requirement is the catalogs:read scope, which every token holds by default.

Every kind returns the same content for every workspace except agent-templates, which returns the templates your workspace is offered. An Anychat operator sets that per workspace: by default you see the general-release templates, and the list is the authority on what Apply Agent Template will accept. A template your workspace is not offered is absent from the list and its single-item read is a 404, the same as an id that does not exist.

Method Path Role Scope
GET /catalogs/{kind} — catalogs:read
GET /catalogs/{kind}/{id} — catalogs:read

{kind} is one of the values below; any other path is a 404. {id} is the item's qualified id (URL-encoded, see Identifiers) or, for the static manifests, its short key such as rcs; an unknown id is a 404 resource.not_found problem detail.

Kind Returns
agent-templates AgentTemplate[]
playbook-templates PlaybookTemplate[]
flows FlowTemplate[]: catalog Flows, deterministic conversation paths with typed slots and steps
personality-templates PersonalityTemplate[]
guardrail-templates GuardrailTemplate[]
tools Tool[]
channels channel manifest: per-channel setup forms for bindings — managed business-input fields, unmanaged provider credential-form schemas, provisioning copy
log-events log-event manifest (static)
webhook-events webhook-event manifest (static)

List responses are enveloped as { "kind": "...", "data": [ ... ] } and are not paginated. Single-item reads return the item directly.

interface AgentTemplate {
  id: string;                                  // e.g. @anychat/agent-template.sales-development
  name: string;
  description: string;
  category?: string;
  framework?: string;
  version?: string;                            // integer string; the catalog serves the latest version
  access?: 'public' | 'restricted';            // 'restricted' reaches a workspace only when it is offered it explicitly
  // The template's whole document, inlined in the two-layer export
  // shape: a `definition` layer (personality, guardrails, goals,
  // playbooks, Message Templates) and a
  // `capabilities` layer (people, outreach, …), with identity omitted.
  // apply-template is a plain import of this document onto the agent.
  definition: Record<string, unknown>;
}

interface PlaybookTemplate {
  id: string; name: string; description: string;
  whenToUse?: string;                          // activation context
  body: string;                                // prompt fragment
  examples?: { role: 'user' | 'agent'; content: string }[][];
  isDefault?: boolean;
  outreachId?: string;                         // owned by that Outreach
}

interface FlowTemplate {
  id: string; name: string; description: string;
  version?: string;
  whenToUse?: string;                          // activation context
  guard?: string;
  slots: unknown[];                            // typed inputs the flow collects
  steps: unknown[];                            // ordered steps; shape varies by step kind
  onComplete?: 'return' | 'end';
  allowDigressions?: boolean;
  isDefault?: boolean;
}

interface PersonalityTemplate {
  id: string; name: string; description: string;
  promptFragment: string;
}

interface GuardrailTemplate {
  id: string; name: string; description: string;
  topicsToAvoid?: string[];
  claimsNotToMake?: string[];
  requiredDisclaimers?: string[];
}

interface Tool {
  id: string; name: string; description: string;
  category: string;
  parameters: Record<string, unknown>;         // describes how the agent calls the tool
  configSchema?: Record<string, unknown>;      // shape PUT into AgentToolBinding.config
}

Every template carries a human-readable description distinct from its body / promptFragment / list contents. UIs typically show the description by default and reveal the prompt body behind an advanced toggle — the underlying text is rarely something an operator needs to edit.

Messages, sessions, users, and logs

A read-only window into a single agent's traffic and runtime events.

Two terms organize this data. A conversation is the complete message thread between one contact and one agent, over all time. A session is one sitting within it: a run of messages with less than 30 minutes of idle time between them. sessionId on a message names its session; userId names the contact and therefore the conversation.

Method Path Role Scope
GET /orgs/{org}/agents/{agent}/messages viewer messages:read
GET /orgs/{org}/agents/{agent}/sessions viewer messages:read
GET /orgs/{org}/agents/{agent}/users viewer messages:read
GET /orgs/{org}/agents/{agent}/logs viewer messages:read

Renamed route

Sessions were briefly served at GET …/conversations; that alias was removed on August 25, 2026 (it predated the session vocabulary and had no external callers). Call …/sessions.

Shared query parameters (all optional):

Param Default Applies to
start, end last 24 hours all
limit 200 (max 1000) all
userId — messages, sessions, logs
sessionId — messages, logs
origin — messages (user / agent)
type — messages (see Message types)
level — logs (debug / info / warn / error)
event — logs (e.g. framework.dispatched, llm.call)
pageToken — messages, logs
includeSandbox false messages, sessions, users

start and end accept ISO-8601 timestamps or epoch-millis strings.

Message types

A message's type is the label the gateway recorded for it:

type Origin Meaning
message user, agent A conversational message: text, media, cards or prompts.
result user The user's answer to a prompt, such as a tapped suggestion or chip.
action user A channel-side intent the user triggered, such as dial or open URL.
status user A delivery or read receipt.
system user A channel system event.
signal agent A non-message signal the agent sent, such as typing.
content user, agent Legacy. Appears only on entries recorded before the current message format.

As a query parameter, type=message, result, system or signal matches that label exactly. content, status and action are categories that match more than one label:

type= Matches
content message and legacy content
status status and signal
action action and result

As filters, status and action mean the category, so type=action includes taps. Use type=result for taps alone. Any other value returns 400 with code request.invalid.

Sandbox partition

Every conversation is either live (a real person on a real device) or sandbox (a persona — through the emulator, a Test run or the sandbox turn API). In the sandbox, reads are real and writes are captured: returned with the reply and kept on the turn record, never performed. Sandbox rows are stamped sandbox: true on sessions, appointments, outreach sends, the recipients of a send, and the people a Test creates, and they are kept out of every scheduled action, sweep, review, billing meter and default listing. Messages, sessions, users, activity and people results exclude the partition by default; pass includeSandbox=true (or sandbox=true on the People list) to see it. Included entries carry sandbox: true and, for emulator conversations, emulatedChannel — the channel the emulator was rendering (rcs, amb, whatsapp, sms) — which is the channel you should display (the transport channel is the emulator's internal transport). A live person can additionally be marked internal (staff on a real device); that is a People filter, not a partition.

Returned shapes:

interface AgentMessageEntry {
  timestamp: string;       // ISO-8601
  agentId: string;
  userId: string;          // channel-side user id
  sessionId: string;       // the session (30-min-idle sitting) this message belongs to
  origin: 'user' | 'agent';
  type:   'message' | 'result' | 'action' | 'status' | 'system' | 'signal'
        | 'content';     // legacy; see Message types
  subtype?: string;        // text / image / card / receipt / dial / ...
  channel: string;         // rcs / sms / slack / ...
  text?: string;           // best-effort plain-text extraction
  messageId: string;       // Anychat-assigned id
  channelMessageId?: string;
  channelConversationId?: string;
  error?: string;          // delivery failure (outbound only)
  raw: unknown;            // full underlying payload
  sandbox?: boolean;       // sandbox partition (emulator / Test)
  emulatedChannel?: string; // channel the emulator rendered (rcs / amb / ...)
}

interface AgentSessionSummary {
  sessionId: string;
  userId: string;
  channel: string;
  startedAt: string;       // ISO-8601
  endedAt: string;         // ISO-8601
  messageCount: number;
  firstMessagePreview?: string;
  sandbox?: boolean;       // sandbox partition (emulator / Test / persona)
  personaId?: string;      // the persona the sandbox session belongs to
  emulatedChannel?: string;
}

interface AgentUserSummary {
  userId: string;
  channel: string;
  lastSeenAt: string;      // ISO-8601
  messageCount: number;
  sandbox?: boolean;       // sandbox partition (emulator / Test)
  emulatedChannel?: string;
}

interface AgentLogEntry {
  timestamp: string;
  agentId: string;
  level: 'debug' | 'info' | 'warn' | 'error';
  event: string;           // dot-namespaced, e.g. framework.dispatched
  framework?: string;
  userId?: string;
  sessionId?: string;
  channel?: string;
  data?: unknown;
  error?: { message: string; stack?: string };
  raw: unknown;
}

The raw field on each entry exposes the full underlying payload — useful for power UIs and debugging, optional for typical clients.

Turns

The turn record of an agent: one row per runtime turn (inbound event → reply) with a trace of what the runtime did — which Playbook was chosen and why, whether the model was called, which tools were offered and called, which Rules fired, the flow steps that ran, latency and usage. It is the read behind the console's Inspect turns view on a conversation and answers "why did it say that?" (see How a turn works). Rows are kept for 90 days.

Method Path Role Scope
GET /orgs/{org}/agents/{agent}/turns viewer messages:read
Param Default Notes
userId — One channel identity, by its ASID (v2-…)
since, until — ISO-8601 timestamps or epoch-millis strings; inclusive
limit 100 1–500; the newest N matching turns, still returned ascending
includeHistory false Include each turn's historyEntries (the conversation-history delta it appended)

Returns { "data": AgentTurnRecord[] }, ascending by createdAt; there is no page token. Malformed since, until, or limit values are a 400 (request.invalid).

interface AgentTurnRecord {
  id: string;
  agentApplicationId: string;
  userId: string;                  // the channel identity's ASID (v2-…); never its address
  conversationId?: string;         // absent on an outreach opening turn (traced before the conversation opens)
  turnIndex: number;               // history length at turn start; a restart re-uses low indexes — order by createdAt
  framework: 'v2';
  kind: 'text' | 'action' | 'conversationStarted' | 'restart' | 'result';
  channel?: string;                // rcs / sms / …
  provider?: string;               // rbm / twilio / webexConnect / …
  createdAt: string;               // ISO-8601
  trace: AgentTurnTrace;
  effects?: CapturedEffect[];      // sandbox turns: what was captured instead of performed
  historyEntries?: unknown[];      // only with includeHistory=true; opaque runtime history format
}

// A side effect a sandbox turn captured instead of performing (reads are
// real, writes are captured): a booking, a notification, an email, an
// integration call, an outreach send.
interface CapturedEffect {
  kind: string;                    // booking | booking.rescheduled | booking.cancelled | notify | email | integration | send
  summary: string;                 // one operator-readable line
  payload?: unknown;               // the structured detail (the booking, the request body, …)
  at: string;                      // ISO-8601
}

// Every member is optional — a turn a rule short-circuited never
// reached the model. Treat unknown members additively.
interface AgentTurnTrace {
  router?: {
    activePlaybook: string;
    decidedBy: 'default' | 'model' | 'sticky' | 'unavailable';  // how the Playbook was chosen
    reason?: string;
  };
  goal?: { id: string; source: 'playbook' | 'outreach' | 'default'; conditionApplied?: boolean };  // the active Goal, if one resolved (see Goals)
  model?: { provider: string; model: string; iterations: number; terminatedBy?: string };
  toolsOffered?: string[];
  toolsCalled?: string[];          // a '!denied' suffix marks a call a rule refused
  capabilitiesEnabled?: string[];  // capabilities the agent loaded mid-turn (enable_capability) because the person needed one its Playbook's uses left out
  things?: {                       // which Things the prompt listed; absent when the agent has none
    mode: 'inline-all' | 'retrieved' | 'retrieved-lexical' | 'degraded';  // inline-all = the whole catalog
    total: number;                 // Things the agent has
    shown: number;                 // Things listed this turn
    shownIds?: string[];           // only when a subset was listed
    pinned?: string[];             // listed because the conversation named them
    scores?: Record<string, number>;  // top relevance scores (subset modes)
  };
  knowledgeSearches?: {            // knowledge searches the agent ran this turn; absent when it did not search
    seq: number;                   // order among this turn's searches
    callIndex: number;             // position among all tool calls this turn (adjacent values = back-to-back searches)
    query: string;                 // what the agent searched for, in its own words (≤ 300 chars)
    count: number;                 // results returned; 0 = nothing on file matched
    faqCount: number;              // how many of those were verified Facts
    topScore?: number;             // best relevance score returned (0–1+)
    ms: number;
    error?: true;                  // the search failed
  }[];
  rulesFired?: string[];           // e.g. 'scheduling.cancel.explicit-intent:deny', 'no-diagnosis:rewrite'
  policiesFired?: string[];        // deprecated alias of `rulesFired`; only on turn records written before rulesFired existed — no longer written
  rulesJudge?: { model: string; ms: number; findings: number } | string;  // present only when the agent has an enabled judged Rule; a string ('skipped:<reason>' or 'disabled') means the judge didn't run
  resources?: { id: string; tokens?: number }[];   // context assembled into the prompt
  response?: unknown;              // the raw structured reply before channel rendering
  warnings?: string[];
  flow?: string[];                 // flow-executor decisions (flow-backed Playbooks only)
  latencyMs?: number;
  usage?: { inputTokens?: number; outputTokens?: number; costUsd?: number };
}

kind is the inbound event that started the turn: text (a message), action (a tap or channel-side intent), conversationStarted (the channel opened a conversation with no user text — an emulator first open or an outreach opening), restart, or result (a typed result round-trip such as a picker answer). Turn records reference gateway message ids rather than duplicating the transcript; join to messages by userId and time.

# The last 20 turns of one person's conversation, with the history each turn appended
curl "https://api.anychat.ai/v1/orgs/acme/agents/agent-acme-sdr-pj7r2qx4/turns?userId=v2-8f3a…&limit=20&includeHistory=true" \
  -H "Authorization: Bearer $ANYCHAT_TOKEN"

Activity

The agent's activity feed: operator-facing events with a pre-rendered one-line summary, newest first. Today the feed carries the scheduling lifecycle (appointment.created, appointment.rescheduled, appointment.cancelled); further event types will join the same shape.

Method Path Role Scope
GET /orgs/{org}/agents/{agent}/activity viewer messages:read
Param Default Notes
limit 50 1–200; out-of-range values are clamped, non-numeric values fall back to the default
includeSandbox false Include events from the sandbox partition

Returns { "data": AgentActivityEvent[] }; there is no page token.

interface AgentActivityEvent {
  id: string;                     // stable per-agent event id
  type: 'appointment.created' | 'appointment.rescheduled' | 'appointment.cancelled';
  at: string;                     // ISO-8601, when the event occurred
  summary: string;                // operator-facing one-liner
  appointmentId: string;
  personId?: string;
  personName?: string;
  when?: string;                  // ISO-8601 start of the call
  prospectTimezone?: string;      // IANA
  representativeName?: string;
  fromTime?: string;              // rescheduled: the prior start time, ISO-8601
  reason?: string;                // cancelled: optional reason
}

Org

A curated read of the org's own metadata, plus a small preferences patch. Operational and billing configuration is not exposed here.

interface Org {
  id: string;
  name: string;
  description?: string;
  timeZone?: string;       // IANA
  features: string[];
  products: string[];
  createdAt?: string;      // ISO-8601
  updatedAt?: string;      // ISO-8601
}
Method Path Role Scope
GET /orgs/{org} viewer —
PATCH /orgs/{org} admin org:admin

GET is deliberately scope-ungated so any token can read the metadata of its own org. PATCH accepts any of { "name", "description", "timeZone" } and rejects everything else; a body with no editable fields returns 400.

API keys

Mint and manage the org's API keys. All five endpoints require the admin role and the org:admin scope.

interface ApiKey {
  id: string;
  name?: string;
  description?: string;
  prefix?: string;         // display prefix, e.g. sk-any-api01-Ab12Cd
  scopes: string[];        // issuer-chosen scope set
  expiresAt?: string;      // ISO-8601, optional automatic expiry
  lastUsedAt?: string;     // ISO-8601, best-effort
  createdAt?: string;      // ISO-8601
  updatedAt?: string;      // ISO-8601
}
Method Path Role Scope
GET /orgs/{org}/api-keys admin org:admin
POST /orgs/{org}/api-keys admin org:admin
GET /orgs/{org}/api-keys/{keyId} admin org:admin
PATCH /orgs/{org}/api-keys/{keyId} admin org:admin
DELETE /orgs/{org}/api-keys/{keyId} admin org:admin

Create body (name is required):

{ "name": "CI deploys", "description": "Used by the deploy pipeline",
  "scopes": ["agents:write", "people:read"], "expiresAt": "2027-01-01T00:00:00Z" }

The 201 create response is the only place the raw key value ever appears:

{ "id": "…", "name": "CI deploys", "prefix": "sk-any-api01-Ab12Cd",
  "scopes": ["agents:write", "people:read"],
  "key": "sk-any-api01-…full secret, shown once…" }
Store it immediately — every subsequent read (list / get / patch) returns only prefix + metadata. Anychat stores a hash, not the key, so a lost key cannot be recovered; mint a new one.

PATCH accepts any of { "name", "description", "scopes", "expiresAt" }. DELETE removes the key outright (204) and immediately revokes it.

Webhooks

Register org-level webhook endpoints for control-plane events. All endpoints require the admin role and the webhooks:manage scope. The available event names are listed by the webhook-events catalog.

interface Webhook {
  id: string;
  url: string;
  events: string[];        // subscribed event names; [] = none
  active: boolean;
  description?: string;
  signingSecretPrefix?: string;
  lastDeliveryAt?: string;             // ISO-8601
  lastDeliveryStatus?: 'success' | 'failure';
  createdAt?: string;      // ISO-8601
  updatedAt?: string;      // ISO-8601
}
Method Path Role Scope
GET /orgs/{org}/webhooks admin webhooks:manage
POST /orgs/{org}/webhooks admin webhooks:manage
GET /orgs/{org}/webhooks/{webhookId} admin webhooks:manage
PATCH /orgs/{org}/webhooks/{webhookId} admin webhooks:manage
DELETE /orgs/{org}/webhooks/{webhookId} admin webhooks:manage
POST /orgs/{org}/webhooks/{webhookId}/_rotate admin webhooks:manage

Create body (url is required; active defaults to true):

{ "url": "https://example.com/anychat-events",
  "events": ["agent.updated"], "description": "Ops bus" }

The create and _rotate responses carry the raw signingSecret — the only places it ever appears; reads show signingSecretPrefix only. Use the secret to verify that deliveries to your URL came from Anychat; _rotate invalidates the old secret and issues a fresh one.

PATCH accepts any of { "url", "events", "description", "active" } (set active: false to pause deliveries without losing the registration). DELETE removes the registration (204).


A worked example

Spin up an agent from scratch using the Sales Development Agent template:

# 1. Create the agent.
curl -s https://api.anychat.ai/v1/orgs/acme/agents \
  -H "Authorization: Bearer $ANYCHAT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "Acme SDR"}'
# → { "id": "agent-acme-sdr-pj7r2qx4", ... }

# 2. Apply the Sales Development template (brings in playbooks, goals, …).
curl -s https://api.anychat.ai/v1/orgs/acme/agents/agent-acme-sdr-pj7r2qx4/apply-template \
  -H "Authorization: Bearer $ANYCHAT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"templateId": "@anychat/agent-template.sales-development"}'
# → { "agent": {...} }

# 3. Inspect what got applied — including playbooks — via export.
curl -s "https://api.anychat.ai/v1/orgs/acme/agents/agent-acme-sdr-pj7r2qx4/export" \
  -H "Authorization: Bearer $ANYCHAT_TOKEN"
# → two-layer YAML snapshot: definition (identity, personality, playbooks[], …) + capabilities

# 4. Connect the org's Google account, once.
curl -s "https://api.anychat.ai/v1/orgs/acme/connections/google/_start" \
  -H "Authorization: Bearer $ANYCHAT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"label": "Sales calendar", "returnTo": "https://yourapp.example.com/settings/connections"}'
# → { "authorizationUrl": "https://accounts.google.com/o/oauth2/..." }
# Open that URL in the operator's browser to complete the grant.

# 5. Point the agent's scheduling at that connection through the console.

# 6. Add a custom guardrail.
curl -s -X PUT https://api.anychat.ai/v1/orgs/acme/agents/agent-acme-sdr-pj7r2qx4/guardrails \
  -H "Authorization: Bearer $ANYCHAT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"topicsToAvoid": ["competitor pricing"], "claimsNotToMake": ["any guarantees about renewal terms"]}'

Once the OAuth grant completes, the agent is ready to accept traffic on whichever messaging channels are bound to it.