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¶
All paths in this document are relative to that root.
Authentication¶
Attach a bearer token in the Authorization header on every request:
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 callGET /emulator/sessionto learn which org and agent it is scoped to. Every other route, and every other agent, answers403auth.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:readis 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 addwebhooks:manageandorg: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
Long lists are paginated with an opaquedataarray: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
statusCodeand a human-readablemessage: -
Newer endpoints (noted in their sections below) return problem details instead: an
application/problem+jsonbody with the HTTPstatus, a stable machine-readablecodesuch asrequest.invalidorresource.not_found, a human-readabletitle, an optionaldetail, and, for validation failures, anerrorsarray whose entries name the offendingfieldpath:Authorization failures on every endpoint use{ "status": 422, "code": "request.invalid", "title": "Validation failed", "errors": [{ "field": "fields[0].key", "message": "must match ^[a-zA-Z][a-zA-Z0-9_]*$" }] }auth.forbidden(role floor not met) andauth.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
409agent.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
usesintegrations andmessageTemplateRefs - a
[[template: …]]in a template, Playbook or Fact - a Fact's
scopePlaybooks, 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 mediaDELETE) - 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
DELETEreturns 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:
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:
The imported personality recordsderivedFrom 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 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:
Response: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:
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:
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
}
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
queuedsends becomecancelled, and theirdetailnames the deleted Outreach. A send that has already left the queue (sendingor 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.
Test links¶
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):
| 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, 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.
| 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
statusandeventstell 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.
authis 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 |
GETreturns{ "data": Persona[] }and seeds the starter set on an agent that has none (idempotent).POST …/personaswith{ "name", "description"?, "preset"? }returns201Persona. The starting point is validated against the agent (anassignedRepresentativeIdor appointmentrepresentativeIdmust exist;customFieldskeys are stored as given) and applied at once, appointments included; aphoneis stored but never indexed, so a persona may reuse a real customer's number.PATCHreplacesname,descriptionand/orpreset; editing a starter's preset makes itcustom. The running Person state is untouched until the next reset — reset after replacing the preset to start from the new starting point.POST …/resetreturns 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 toformerIdentities, so match a conversation'suserIdagainstchannelIdentitiesandformerIdentitiestogether to name its persona.GET …/transcriptreturns aPersonaTranscript: 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'srenderedis 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).textstays plain text.POST …/from-conversationwith{ "sessionId", "throughMessageId"?, "name"?, "dryRun"? }— a session id fromGET …/sessions, amessageIdfromGET …/messages— returns201PersonawithpresetOfset. 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 includingthroughMessageId(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: truereturns200{ name, description, preset, replacements }and creates nothing.conversationIdis accepted as a deprecated alias ofsessionId.DELETEreturns204.
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:
| 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", ... }], ... }
Emulator links¶
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 | — |
GETreturns{ "data": EmulatorLink[] }: the agent's active links (not expired, not revoked), newest first, without their urls.POST …/emulator-linkswith{ "ttlDays"?: number, "label"?: string }creates a link and returns201EmulatorLink$Created.ttlDaysis 1–90 (default 14);labelis at most 200 characters. Theurlis 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.DELETErevokes the link and returns204. The url stops working immediately for everyone who has it; there is no un-revoke.-
POST …/emulator-grantswith{ "ttlSec"?: number }(60–3600, default 1800) returns200EmulatorGrant. 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/sessionwith a grant as the bearer returns200EmulatorSession; with any other bearer it is403auth.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:
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
}
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" }
]
}
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.
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…" }
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.