# Iris agent kit Connect an AI agent to [Iris](https://iris.dotfun.co), the AI receptionist by dotfun. This repo holds everything an agent client needs: - the `iris-api` skill, which teaches an agent the Iris API and the daily operator routine, - the Iris MCP server connection, - the public OpenAPI document (`openapi/openapi.json`), - a plugin marketplace for Claude Code and Claude Cowork, and one for Codex. Site: https://dot-fun.github.io/iris-agent-kit/ serves `llms.txt`, `llms-full.txt`, `openapi.json` and the `.well-known` skill indexes. ## Before you start 1. **Get a key.** In the Iris dashboard, open Organization > API keys. Create a key with the scopes the agent needs. Iris shows the key once. 2. **Set the key.** Export it as `IRIS_API_KEY` in the environment the agent runs in: ```sh export IRIS_API_KEY=iris_sk_... ``` 3. **Know the MCP status.** The MCP server is `https://api.iris.dotfun.co/api/v1/mcp`. It is rolling out behind a feature flag. If your organization does not have it yet, the server answers 404, and the skill still works over plain REST with the same key. Never commit the key or paste it into a chat. ## Claude Code Install: ```text /plugin marketplace add Dot-Fun/iris-agent-kit /plugin install iris@iris ``` Or from a shell: `claude plugin marketplace add Dot-Fun/iris-agent-kit`, then `claude plugin install iris@iris`. Auth: the plugin's `.mcp.json` sends `Authorization: Bearer ${IRIS_API_KEY}`. Start Claude Code from a shell where `IRIS_API_KEY` is set. Update: the plugin has no version, so every commit to `main` is an update. Claude Code checks in the background. To update now, run `/plugin marketplace update iris`, then `/plugin update iris@iris`. ## Claude Cowork and Claude Desktop Install: in Cowork, or the Code tab of Claude Desktop, add the marketplace `Dot-Fun/iris-agent-kit` and install the `iris` plugin. The skill arrives with it. Auth: the MCP connection in Cowork, Claude Desktop chat and claude.ai is a custom connector (Customize > Connectors, URL `https://api.iris.dotfun.co/api/v1/mcp`). These connectors cannot send an API key header. They need OAuth on the Iris server, which is not live yet. Organizations in Anthropic's request-headers beta can add `Authorization: Bearer iris_sk_...` under Request headers today. Update: Cowork checks the marketplace for updates. Press Update on the marketplace page to pull now. ## Codex CLI and Codex desktop Install the plugin: ```sh codex plugin marketplace add Dot-Fun/iris-agent-kit ``` Then open `/plugins` in Codex and install `iris`. To install only the skill, run `$skill-installer install https://github.com/Dot-Fun/iris-agent-kit/tree/main/skills/iris-api` inside Codex. Auth: add the MCP server to `~/.codex/config.toml`. The CLI, the desktop app and the IDE extension share this file. ```toml [mcp_servers.iris] url = "https://api.iris.dotfun.co/api/v1/mcp" bearer_token_env_var = "IRIS_API_KEY" ``` Update: run `codex plugin marketplace upgrade iris`, then restart the desktop app. Codex caches the plugin by version, and every skill change here bumps it. ## ChatGPT Iris on ChatGPT waits for OAuth. ChatGPT connectors do not accept an API key, and the Iris server does not offer OAuth yet. This section changes when it does. When OAuth ships: turn on Settings > Security and login > Developer mode, open chatgpt.com/plugins, press the plus button, and enter `https://api.iris.dotfun.co/api/v1/mcp`. ChatGPT reads the tool list live, so no update step exists. ## Hermes Agent Install the skill: ```sh hermes skills install Dot-Fun/iris-agent-kit/skills/iris-api ``` Hermes asks for `IRIS_API_KEY` because the skill declares it. Auth: add the MCP server to `~/.hermes/config.yaml`: ```yaml mcp_servers: iris: url: "https://api.iris.dotfun.co/api/v1/mcp" headers: Authorization: "Bearer ${IRIS_API_KEY}" ``` Update: `hermes skills check`, then `hermes skills update iris-api`. ## OpenClaw Install the skill: ```sh openclaw skills install skills-sh:Dot-Fun/iris-agent-kit/iris-api ``` The skill stays inactive until `IRIS_API_KEY` is set. Auth: add the MCP server, then set the `Authorization: Bearer ...` header in the Settings config editor. Do not write the key into the config as a literal. ```sh openclaw mcp add iris --url https://api.iris.dotfun.co/api/v1/mcp --transport streamable-http ``` Update: OpenClaw refreshes only ClawHub installs. Run the install command again to update. ## npx skills Install for one or more agents: ```sh npx skills add Dot-Fun/iris-agent-kit npx skills add Dot-Fun/iris-agent-kit --skill iris-api -g -a claude-code -a codex -a hermes -a openclaw -y ``` Auth: set `IRIS_API_KEY`, and add the MCP server in each client as its section above shows. Update: `npx skills update`. ## Gemini CLI Install the skill: ```sh gemini skills install https://github.com/Dot-Fun/iris-agent-kit.git --path skills/iris-api --consent ``` Auth: set `IRIS_API_KEY`. The skill calls the REST API with it. Update: Gemini CLI has no update command. Run the install command again. ## What is in this repo | Path | Used by | |---|---| | `.claude-plugin/marketplace.json`, `.claude-plugin/plugin.json` | Claude Code, Claude Cowork | | `.agents/plugins/marketplace.json`, `.codex-plugin/plugin.json` | Codex | | `.mcp.json` | Claude Code, Claude Cowork | | `skills/iris-api/` | every client | | `openapi/openapi.json` | code generators, the site | | `docs/llms.txt`, `docs/index.md` | the site | | `scripts/build-site.mjs`, `.github/workflows/pages.yml` | the site build | ## Maintaining The Iris monorepo is the source of `skills/iris-api/` and `openapi/openapi.json`. Its `agent-kit-publish` workflow copies them here on every push to its `develop` branch that changes the public API contract or the skill. Do not edit those two paths in this repo: the next publish overwrites them. The rule: every change to an Iris `/api/v1` route updates the skill and this kit in the same pull request. The monorepo enforces it. Its freshness check fails when the generated `references/endpoints.md` drifts from the OpenAPI document, or when the document has an operation the skill never names. The Codex version rule: bump `version` in `.codex-plugin/plugin.json` in the same commit as any change under `skills/`. Codex caches plugins by version, so without a bump Codex users keep the old skill. The publish workflow does this for you (a patch bump). `.claude-plugin/plugin.json` has no version on purpose: Claude Code and Cowork treat every commit as an update. The site rebuilds on every push to `main`. To build it locally, run `node scripts/build-site.mjs` and open `_site/index.html`. It needs Node 20 or newer and no packages. ## License MIT. See `LICENSE`. --- # Iris agent kit This kit connects an AI agent to Iris, the AI receptionist by dotfun. - **Skill.** `skills/iris-api/SKILL.md` teaches the API: whoami first, the never-write rule, the error envelope, paging, scopes, and the operator recipes (find a contact, check enrollments, enroll, cancel, pause and resume, read the send log and totals, build a draft sequence). - **MCP.** `https://api.iris.dotfun.co/api/v1/mcp`, Streamable HTTP, bearer API key. Tool names are the API operationIds. The server is rolling out behind a feature flag. - **OpenAPI.** `openapi.json` is the public v1 contract. The skill and the MCP tools come from it. - **Discovery.** `/.well-known/skills/index.json` (v0.1.0) and `/.well-known/agent-skills/index.json` (v0.2.0) list the skill for clients that install from a URL. Install steps for Claude Code, Claude Cowork and Desktop, Codex, ChatGPT, Hermes Agent, OpenClaw, `npx skills` and Gemini CLI are in the README: https://github.com/Dot-Fun/iris-agent-kit#readme --- --- name: iris-api description: Work an Iris organization through the Iris public API or the Iris MCP server. Use when asked to find or update a contact, check which sequences a contact is in, enroll or cancel a contact in a sequence, pause or resume a sequence, read a send log or sequence totals, build a draft sequence, schedule, start or pause a broadcast send, change an appointment’s title, notes or attendee details, or read appointments, conversations and calls in Iris. required_environment_variables: - name: IRIS_API_KEY prompt: Iris API key (starts with iris_sk_) help: In the Iris dashboard, open Settings > My agent access and create a personal key with the scopes you need. An owner or admin can create an organization key under Organization > API keys instead. The key is shown once. metadata: { "openclaw": { "requires": { "env": ["IRIS_API_KEY"] }, "primaryEnv": "IRIS_API_KEY" } } --- # Iris API Iris is an AI receptionist for small businesses. It answers calls and texts, books appointments, keeps a CRM of contacts, and follows up with email and SMS sequences. This skill teaches you to act on one Iris organization with an API key. Every operation has an operationId. The same name is the REST operation and the MCP tool, so a recipe step such as `contacts_search` means "call the `contacts_search` tool" over MCP or "send the request that `references/endpoints.md` lists under `contacts_search`" over REST. - REST base URL: `https://api.iris.dotfun.co` - MCP server: `https://api.iris.dotfun.co/api/v1/mcp` (Streamable HTTP) - Every endpoint, with its parameters and scope: `references/endpoints.md` - Keys, scopes and the connection details for each client: `references/auth.md` The API is growing. If `references/endpoints.md` does not list an operation this skill names, that operation is not live yet. Tell the user and stop. Do not guess a path. ## Authenticate Send the key as a bearer token on every request: ``` Authorization: Bearer $IRIS_API_KEY ``` Read the key from the `IRIS_API_KEY` environment variable. Never print it, never write it to a file, and never put it in a URL. A key belongs to one organization. The organization always comes from the key, never from a request body, so you cannot reach another organization. ## Connect over MCP The MCP server is one operation, `mcp_request` (`POST /api/v1/mcp`). Add it to your client as a Streamable HTTP server with the header `Authorization: Bearer $IRIS_API_KEY`. The config for each client is in `references/auth.md`. Every other operation is a tool with the same name. Read tools carry `readOnlyHint: true`. A 404 from the MCP URL means MCP is not turned on for this organization yet. Use REST instead. Claude Desktop, claude.ai, Cowork and ChatGPT cannot send that header. Add the MCP URL as a custom connector with no header and no client id: the client finds the Iris authorization server from the 401, registers itself, and opens the Iris consent page, where the user signs in and approves the scopes. Any member can approve for themselves. The steps for each client are in `references/auth.md`. With OAuth, the client holds the token and you do not need `IRIS_API_KEY`. ## Start with whoami Call `me_get` (`GET /api/v1/me`) before anything else. It needs no scope beyond a live key. It returns: - `organization`: the `id` and `name` you act on. Tell the user which organization it is. - `api_key.scopes`: what this key may do. Do not call an operation whose scope is missing. Over OAuth, `api_key` is null and `oauth_client.scopes` holds what the owner granted. - `api_key.role`, `api_key.location_ids` and `api_key.powers`: for a key with a role, the role it acts at (`MANAGER` or `MEMBER`), the locations it is limited to and the manager powers it acts with. `role` and `powers` are null for a key with no role, and `location_ids` for a key that reaches the whole organization. - `principal`: the person behind the credential, their `role` now and the `location_ids` they reach. Over OAuth it is the person who granted the token, and for a personal key its owner. An organization key has no `principal`. - `scopes_available`: every scope, with a label, so you can tell the user what to add. - `rate_limit`: the request budget. If `me_get` returns 401, the key is missing, wrong or revoked, or an owner or admin turned the person's agent access off. Stop and ask the user: My agent access says when access is off, and then the same key works again once it is back on. Do not retry. ## The never-write rule Some things are never writable with a key, whatever its scopes: - hard delete of a contact or a sequence (no such operation exists), - billing and the plan, - organization membership, - API keys, - the voice agent configuration. If the user asks for one of these, say that the API does not allow it and point them to the Iris dashboard. Do not look for a workaround. For the writes that do exist: 1. Read the current state first. 2. Tell the user what you will change. 3. Make the smallest write that does it. 4. Read the result back and report it. Ask the user before any write that sends a message to a person (`sequences_activate`, `sequences_resume`, `sequences_enroll`, `sequences_start_schedule`, `conversations_send_sms`, `conversations_reply`, `conversations_resume`) unless they asked for that exact action. Each operation in `references/endpoints.md` states these facts, and the OpenAPI document carries them as extensions. `Confirm: required` (`x-iris-confirm`) marks a call that sends to a customer, deletes or cancels, or moves money. Ask the user before you call it, unless they asked for that exact action. `Changes` (`x-iris-changes`) names the resources a write changes, or `none`. Over MCP, each tool's `_meta` carries both as `iris/confirm` and `iris/changes`. `Idempotent` (`x-iris-idempotent`) marks a call that takes an `idempotency_key` of your own. A retry on the same credential with the same key and details within 24 hours returns the first answer and does not run the call again. The same key with other details answers 409 `idempotency_key_reused`. A 409 `idempotency_in_progress` means the first call is still running. A 409 `idempotency_outcome_unknown` means the first call failed and may have taken effect. A 503 `idempotency_unavailable` means the call did not run. ## Errors Every failure returns the same JSON envelope: ```json { "statusCode": 403, "error": "insufficient_scope", "required": "contacts:read", "timestamp": "2026-09-22T12:00:00.000Z", "path": "/api/v1/contacts" } ``` Branch on `statusCode` and `error`. Never branch on the text of `message`. | statusCode | error | What to do | |---|---|---| | 400 | (validation) | Read `message`, fix the request, try once more. | | 400 | `sequence_activation_blocked` | Show the user `blockers`. Fix them or stop. | | 401 | | The key is missing, wrong or revoked, or an owner turned the person's agent access off. Stop and ask the user. | | 403 | `insufficient_scope` | The key lacks the scope in `required`. Tell the user to create a key with it under My agent access, or under Organization > API keys for an organization key. | | 403 | `insufficient_role` | The person behind the credential is a MEMBER, and members cannot call this operation or act for another team member. A key with a role gets it where a person in that role, with its powers, cannot act. Do not retry. Ask an owner or admin. | | 404 | `not_found` | The id does not exist in this organization. Search again. Do not guess ids. | | 404 | `not_found` from `sequences_get_schedule`, `sequences_start_schedule` or `sequences_pause_schedule` | The broadcast may have no schedule yet. Set one with `sequences_set_schedule`. | | 409 | `sequence_inactive` | The sequence is off. Do not enroll. Ask the user. | | 409 | `sequence_paused` | The sequence is paused. Resume it first, or wait. | | 409 | `sequence_not_paused` | `sequences_resume` on a sequence that is not paused. Nothing changed. Read its `status`. | | 409 | `contact_not_at_location` | The sequence runs for one location and the contact is not there. Do not retry. Tell the user. | | 409 | `sms_not_permitted` | The contact has not consented or is suppressed. Do not send. | | 429 | | Too many requests. Wait 60 seconds, then retry. | | 5xx | | Retry once after a few seconds. Then stop and report. | Over MCP, a failing tool call returns `isError: true` with the same envelope as its content. ## Paging Most list operations take `page` (starts at 1) and `limit` (default 25, at most 100). Every page echoes `total`, `page` and `limit`. Fetch the next page while `page * limit < total`. `conversations_list` and `calls_list` page with a `cursor` instead: pass back `next_cursor` from the last page until it is null. `sequences_get_schedule` pages its `runs` and names the count `runs_total`. Stop when you have what you need: do not read every page of a large list to answer one question. ## Scopes A key carries scopes, one read and one write per noun: | Scope | Lets you | |---|---| | `contacts:read` | search and read contacts | | `contacts:write` | create and update contacts, notes, tags, suppression and SMS consent, and enroll contacts in sequences or cancel them through events | | `sequences:read` | read sequences, enrollments, send logs and totals, and a broadcast's schedule and runs | | `sequences:write` | create, change, activate, pause, resume and duplicate sequences, enroll and cancel, and set, start and pause a broadcast's schedule and cancel one run | | `appointments:read` | read appointments, calendars, locations, booking options, open slots and territories | | `appointments:write` | book, change, reschedule and cancel appointments | | `conversations:read` | read conversations and their messages | | `conversations:write` | send an SMS, reply in a conversation, and hold or resume its AI (a staff member: only for their own clients, while their SMS setting is on) | | `calls:read` | read calls and transcripts | | `audiences:read` | read and count audiences, and list their contacts | | `audiences:write` | create, change, archive and restore audiences, and add and remove members | | `forms:read` | read forms, their fields and their submissions | Each operation in `references/endpoints.md` names its scope. The OpenAPI document at `/api/v1/docs-json` carries it as `x-iris-scope` on each operation, null when any live key may call it. Over MCP, the tool list shows only the tools your key's scopes allow. A credential acts with the role of the person behind it. An organization key and an owner's or admin's credential reach the whole organization. For a MEMBER, each operation in `references/endpoints.md` names its member access: `open` (allowed), `scoped` (allowed, limited to the member's locations), `sms_setting` (allowed while the member's SMS setting is on, limited to their own clients at their locations) or `deny` (403 `insufficient_role`). Over MCP, a MEMBER does not see the `deny` tools. A MANAGER gets a MEMBER's access, so every MEMBER rule here applies to a MANAGER too. An owner or admin may also give a MANAGER powers. A `deny` tool marked `x-manager-power: contacts` is open to a MANAGER with the contacts power, at their locations only: a contact elsewhere answers 404. A key with a role acts at `api_key.role`, at its `api_key.location_ids` when it has them, with the powers in `api_key.powers`. Every rule here for that role applies to it. A MANAGER key gets 403 `insufficient_role` on a plain `deny` tool, with no `x-manager-power`, and the MCP tool list leaves it out. A MEMBER key gets it on every `deny` tool. Without the appointments power, the key cannot book or change an appointment for a team member, because no person stands behind it. A sequence may run for one location (`location_id`), or for the whole organization (null). A `deny` tool marked `x-manager-power: sequences` is open to a MANAGER with the sequences power for the sequences of their locations. Such a manager must send one of their `location_id`s to `sequences_create`. A manager's write to an organization-wide sequence answers 403, and another location's sequence answers 404. On a `scoped` operation a MEMBER's lists hold only its own locations' rows, and an id at another location answers 404, as an id that does not exist does. Do not retry it. A MEMBER can book, reschedule and cancel appointments at its own locations. At a staffed location it books itself only and changes only its own appointments: another team member answers 403 `insufficient_role`. Leave out `team_member_id`. `locations_booking_options` shows a MEMBER only itself and the services it handles, with every buffer as 0. A MANAGER with the appointments power books any team member at its own locations, and reschedules and cancels anyone's appointment there. `locations_booking_options` shows it every team member, so book the one the user names. A location-wide block still answers 403, and another location 404. A MEMBER (staff) reads only the calls of its own clients, the contacts on its appointments, at either access level. `calls_get` on any other call answers 404, so it is out of reach, not missing. Without a linked staff profile, `calls_list` is empty. A MEMBER at the own-records level reads only its own records: its appointments, the contacts on them, and those contacts' conversations and calls. Any other contact, conversation, call or appointment answers 404, so it is out of reach, not missing. Do not retry it. It books only itself. It may book a new client: any contact at its locations whose id it has. ## Operator recipes ### 1. Find a contact 1. If you have an email, call `contacts_search` with `email`. It returns the exact match only. 2. Otherwise call `contacts_search` with `q` (part of a name, email or phone). 3. If more than one contact matches, show the user the candidates (name, email, phone) and ask. 4. Call `contacts_get` with the `contactId` for the full record: stage, tags, custom fields. A MEMBER, and a MANAGER without the contacts power, get only `id`, `name`, `email` and `phone`, from both `contacts_search` and `contacts_get`. 5. To see what happened with the contact, call `contacts_list_interactions` with the `contactId`. It lists calls, chats and SMS threads, newest first. Email is in `conversations_list`. Filter with `channel` or `status`. A `page` past the last interaction answers 400. To read the contact's notes, call `contacts_list_notes` with the `contactId`. It pages newest first, with `limit` at most 100. A `page` past the last note answers 400. A MEMBER, and a MANAGER without the contacts power, get `insufficient_role` there. Notes that belong to one location (dashboard notes and GoHighLevel syncs) are not listed. Never create a contact to "find" one. `contacts_upsert` creates or updates by email, so use it only when the user wants a contact created or changed. ### 2. Check which sequences a contact is in 1. Find the contact (recipe 1). 2. Call `contacts_list_enrollments` with the `contactId`. 3. Report each enrollment: sequence, `status`, current step, and the next send time. The next send time is `next_run_at`. On `sequences_list_enrollments` it is `next_step_due_at`, or `next_run_at` when that is empty. A sequence `status` is `active`, `paused` (every enrollment held, shown as `paused`) or `inactive` (off). To go the other way (who is in one sequence), call `sequences_list` to find the sequence, then `sequences_list_enrollments` with its `sequenceId`. Filter with `status`, `contact_id` or `email`. ### 3. Enroll a contact without double-enrolling 1. Call `sequences_list` and pick the sequence. Its `status` must be `active`. 2. Find the contact (recipe 1). 3. Call `sequences_list_enrollments` with the `sequenceId` and `contact_id`. If an enrollment is `active` or `paused`, the contact is already in. Tell the user and stop. 4. Call `sequences_enroll` with `contact_id` (or `email`). 5. Read `status` in the answer: - `enrolled`: done. Report the enrollment. - `skipped` with `reason: already_enrolled`: the contact was already in. Nothing changed. 6. On 409 `sequence_inactive`, `sequence_paused` or `contact_not_at_location`, stop and tell the user. `sequences_enroll` is safe to retry: a second call answers `skipped`, not a second enrollment. ### 4. Cancel an enrollment 1. Find the enrollment with recipe 2. Note its `sequenceId` and `enrollmentId`. 2. Call `sequences_cancel_enrollment`. 3. The answer is the cancelled enrollment. A repeat call returns the same row, so a retry is safe. ### 5. Pause and resume a sequence Pause holds every contact where they are. Nothing sends while the sequence is paused. Resume continues each contact from its current step, with the same spacing. Use pause to fix the copy of a live sequence. 1. Call `sequences_get` and check `status` is `active`. 2. Call `sequences_pause`. The answer shows `status: paused`. 3. Make the fix (recipe 8, step 4). 4. Call `sequences_resume`. The answer shows `status: active`. A sequence that is not paused answers 409 `sequence_not_paused`, and nothing changes. Do not use `sequences_update` with `is_active: false` to pause. Deactivating cancels every enrollment, and resume cannot bring them back. ### 6. Read the send log for one contact in one sequence 1. Find the enrollment (recipe 2). 2. Call `sequences_get_enrollment` with `sequenceId` and `enrollmentId`. 3. It returns the enrollment, its step runs and its events in order: sent, delivered, bounced, replied. Answer "what happened to this person" from these events only. ### 7. Read sequence totals 1. Call `sequences_analytics` with the `sequenceId`. 2. Report the per-step counts and the rollup. 3. Read `email_delivery_reporting` before you report email numbers: - `supported`: delivery and bounce counts are real. - `unsupported`: the sending provider does not report delivery. A zero means "unknown", not "none". Say so. - `not_applicable`: the sequence sends no email. ### 8. Build a draft sequence and activate it 1. Call `sequences_capabilities`, `sequences_list_triggers`, `sequences_list_variables` and `sequences_get_condition_vocabulary`. Use only the step kinds, triggers, variables and conditions they return. 2. Call `sequences_create` with the name, trigger, steps and delays. It always creates an inactive draft. Do not send `is_active: true`: it returns 400. 3. To start from a sequence that works, call `sequences_duplicate` instead and change the copy. 4. Call `sequences_update` to change steps, delays or copy. `steps`, `edges` and `exit_conditions` each replace the whole list, so start from `sequences_get`, edit, and send the full list back with every `step_key` kept. Read it back with `sequences_get`. 5. Show the user the draft and ask before you activate it. 6. Call `sequences_activate`. On 400 `sequence_activation_blocked`, show the `blockers` (for example a missing sending domain) and stop. ### 9. Book an appointment 1. Find the contact (recipe 1). 2. Call `locations_list` and pick the location. Note its `timezone` and `staffed`. 3. Call `locations_booking_options` with the location id. Pick a service from `offerings`, and a team member from its `team_member_ids` if the user names one. 4. For a home visit, call `territories_list` to check that the customer's postal code is covered. 5. Call `appointments_availability` with `location_id`, `date`, and `offering_id`, `team_member_id` and `postal_code` as needed. Offer the user the slots it returns. 6. Call `appointments_create` with `location_id`, `contact_id`, the slot's `start` and `end` as `start_time` and `end_time`, `offering_id`, `team_member_id` if chosen, `service_address` for a home visit, and an `idempotency_key` of your own. Leave out `team_member_id` to book the first team member who is free. 7. On 409, read `error`. `offering_required`: choose from the `booking_options` in the body. `slot_taken`, `no_team_member_available` or `outside_hours`: check availability again and offer another time. `team_member_not_eligible`: offer another team member. `address_out_of_area`: tell the user the address is not covered. `duration_mismatch`: make the end time match the service's `duration_minutes`. Another 409 with no code means the key was used with other details: use a new key. 8. Tell the user the appointment's time, service and team member. A location that is not staffed can also be booked with `calendar_id` from `calendars_list` instead of `location_id`. Send one of the two, never both. ### 10. Keep the CRM current after a call 1. Find the contact (recipe 1). 2. Call `contacts_update` to change the name, phone, email, stage or custom fields. An unknown custom field key returns 400 naming it. To move the contact to a stage, call `contacts_list_stages` first and send one stage `id` as `stage_id`. 3. Call `contacts_add_note` with what you learned. 4. Call `contacts_add_tag` or `contacts_remove_tag` to change tags. Both are safe to repeat. 5. When a person asks to stop email or SMS, call `contacts_suppress` with the channels at once. ### 11. Report assessment abandonment 1. Call `contacts_upsert` with the contact, `fields.user_message_count`, and an `assessment_abandoned` event with a stable session key. 2. Count nonempty user answers in the current session. Send numeric zero for a session with no answers. 3. Read `assessmentAbandonment`. `accepted` confirms a durable receipt. Keep retry eligibility for `no_subscriber` or a request failure. 4. Record `legacy_unverified` for operator reconciliation. Do not invent a new key to bypass an existing receipt. Counts must be nonnegative safe integers. Missing counts match only triggers without an `assessmentAudience` filter. The `started` filter requires a positive count. `never_started` requires zero. These filters apply only to `assessment_abandoned`. Do not activate the never-started campaign before its copy is approved. ### 12. Send a broadcast on a schedule A broadcast sends to its audience on its schedule. Each send is a run. `sequences_activate` does not start a broadcast: only `sequences_start_schedule` does. 1. Call `sequences_get` and check `kind` is `broadcast`. An automation has no schedule. 2. Call `sequences_set_schedule`. For one send, pass `schedule_kind: once` and `send_at`, a date-time that ends in `Z` or an offset such as `-05:00`. To repeat, pass `schedule_kind: recurring`, an `rrule` such as `FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0`, and the `timezone` it is read in. The schedule is saved as `draft` and sends nothing yet. A rule takes only `FREQ` (`DAILY`, `WEEKLY` or `MONTHLY`), `INTERVAL` (1 to 100), `BYDAY`, `BYMONTHDAY`, `BYSETPOS`, one `BYHOUR` and one `BYMINUTE`. There is no `COUNT` or `UNTIL`: end a series with `ends_on`. On 400, read which part `message` names and fix it. 3. Call `sequences_get_schedule` to read it back. 4. Show the user when it sends and to which audience, and ask before you start it. 5. Call `sequences_start_schedule`. The answer shows `status: scheduled` and the first send in `next_fire_at`. On 400 `sequence_activation_blocked`, show the `blockers` and stop. 6. To watch the sends, call `sequences_get_schedule`. Each item in `runs` is one send, with `status` and recipient counts. 7. To stop later sends, call `sequences_pause_schedule`. A run that is already sending keeps sending. Start again with `sequences_start_schedule`. 8. To stop one run, call `sequences_cancel_schedule_run` with the run's `id` as `runId`. `cancelled` counts the enrollments it stopped. Setting the schedule again puts it back to `draft` and cancels the runs that have not ended: start it again after a change. Do not use `sequences_pause` on a broadcast: it answers 400. ### 13. Build an audience 1. Call `audiences_list` to check that a fitting audience does not already exist. 2. Write a `definition`: `match` (`all` or `any`) and `rules` of `field`, `op` and `value`. The `audiences_create` schema lists the fields and which ops each takes. `field_values.` reads an enabled CRM custom field. To filter by pipeline stage, call `contacts_list_stages`: use its `audience_field` as the `field`, with stage ids as values. 3. Call `audiences_preview_count` with the definition. A 400 names the field, op or value to fix. Show the user the count. 4. Call `audiences_create` with the name and definition. For a hand-picked list, send `kind: static` and no definition, then add contacts with `audiences_add_member`. 5. To change it, read it with `audiences_get`, edit the definition, and send it whole to `audiences_update`. `kind` cannot change. 6. Call `audiences_count` for its size now, and `audiences_list_members` to see who is in it. Both count opted-out contacts: they are left out only when a message is sent. 7. To retire it, call `audiences_archive`. `audiences_update` with `is_archived: false` restores it. ### 14. Test a step and pick a send window 1. To pick when a sequence may send, call `sequences_list_send_schedules`. It pages with `page` and `limit`, and the windows are in `items`. Pass the `id` you choose as `send_schedule_id` on `sequences_create` or `sequences_update`. A sequence with none uses the window where `is_default` is true. 2. To test a step, save it first: the test sends what is saved. Call `sequences_test_step` with the `sequenceId` and the `step_key` of the step. 3. Send `to_email` (email step) or `to_phone` (SMS step) only to an address or number the user owns, never to a customer. Or send `contact_id` to fill the variables from a contact: the message then goes to that contact, so ask the user first. 4. Read `success`. When false, `error` and `reason_code` say why, for example `opted_out`. ## Other operations These follow the same rules. Their parameters are in `references/endpoints.md`. - Appointments: `appointments_list`, `appointments_get`, `appointments_create`, `appointments_update`, `appointments_reschedule`, `appointments_cancel`, `appointments_availability`, `locations_list`, `locations_booking_options`, `territories_list`, `calendars_list`. To book, follow recipe 9. To answer "when is Jane booked", find the contact (recipe 1) and call `appointments_list` with `contact_id`. To fix a title, notes or attendee detail without moving the appointment, call `appointments_update`. It refuses time and status fields with a 400 that names the operation to use. Cancel, update and reschedule answer 409 `recurring_series_not_supported` for a row with `recurring: true`. Nothing changes: tell the user to change it in the Iris dashboard. Reschedule answers 409 `appointment_cancelled` for a cancelled appointment. Nothing changes: book a new appointment instead. A 422 from `appointments_create` can mean the user who created the API key left the organization: tell the user to create a new key. - Conversations: `conversations_list`, `conversations_get` (with messages), `conversations_reply`, `conversations_hold`, `conversations_resume`, `conversations_send_sms`. The channels are `email`, `sms` and `chat` (web chat). A search (`q`) skips chat. `conversations_reply` answers on the conversation's own channel: read the conversation first. `conversations_hold` stops the AI answering an SMS or chat conversation until `conversations_resume`, and `response_mode` in `conversations_get` shows who answers. An email conversation has no AI: hold and resume answer 422 `channel_not_supported`. Do not retry it. `conversations_reply` is not safe to retry: after a 5xx the message may already have gone to the contact. Read the conversation with `conversations_get` before you send it again. A 422 `no_acting_user` on an email reply means the key has no person to send as: tell the user. Read the conversation before you text the contact. The text goes from the number of the contact's location, else from the default number. A 422 can mean no number can text the contact, or (for a manager) that the contact's thread on that number belongs to another location: tell the user, and do not retry. A staff member (MEMBER) texts only their own clients, and only while an owner, admin or manager has turned their SMS setting on. A 403 `insufficient_role` means the setting is off: tell the user to ask for it, and do not retry. A 404 means the contact is not one of their clients there, or their thread on that number is at another location. Record SMS consent with `contacts_record_sms_consent` only when the contact gave it to the user. On 409 `sms_not_permitted`, do not send, and do not record consent to get past it. - Calls: `calls_list`, `calls_get` (with transcript). Filter `calls_list` with `contact_id` to see one contact's calls. - Audiences: `audiences_list`, `audiences_add_member`, `audiences_remove_member`. Both member writes are safe to repeat. Only a `static` audience takes members. To build or change an audience, follow recipe 13. - Forms: `forms_list`, `forms_get` (with fields), `forms_list_submissions`. To review the leads a form brought in, call `forms_list` for the form's `id`, then `forms_list_submissions` with it. Each submission's `payload` is keyed by the `key` of the form's fields, and `contact` is the contact it created or updated: use `contacts_get` for more. Submissions page with `page` and `limit`. Filter by `status` (`accepted` or `spam`), `from`, `to` (ISO 8601 with a time) and `search` (part of an email). Submissions are personal data: quote only what the user asked for. A staff member (MEMBER) reads forms but gets 403 `insufficient_role` on submissions: tell the user, and do not retry. --- # Authentication and connection ## Get a key 1. Sign in to the Iris dashboard at https://iris.dotfun.co. 2. Open Settings > My agent access. 3. Create a key. Pick only the scopes the agent needs. 4. Copy the key. Iris shows it once and stores only a hash. 5. Put it in the `IRIS_API_KEY` environment variable of the agent. A key looks like `iris_sk_` followed by 32 characters and belongs to one organization. There are two kinds: - A **personal key** acts for the member who created it, with that member's role and locations now. Any active member creates one under My agent access, and there is no limit on how many. An owner or admin sees every personal key under Organization > API keys and can revoke it there. - An **organization key** acts on the whole organization. Only an owner or admin creates one, under Organization > API keys. - An organization key may have a **role**, Manager or Member. It then acts as a person in that role with no person behind it. A Manager key holds manager powers: every power from an owner or admin, the creator's own from a manager. A Member key holds none, so it reads a contact's name, email and phone only, sends no SMS and cannot book or change an appointment for a team member. - A **location-limited key** is an organization key limited to some locations. It always has a role, Manager unless its creator picked Member. An owner or admin creates one for any locations, and a manager with the staff power for their own, under Organization > API keys. Its role, locations and powers never change. It is revoked when its creator loses the powers it carries. `me_get` shows them as `api_key.role`, `api_key.location_ids` and `api_key.powers`. Revoke a key in the screen where it was made. A revoked key gets 401 at once. A personal key follows its owner. After a demotion from admin to member it keeps working with a member's access. When the owner is deactivated or removed, it is revoked. An organization key is revoked when the person who created it loses the powers it carries. That person is removed or deactivated, an admin becomes a manager or member, or a manager becomes a member. Nobody can keep it. If the agent gets 401 after a team change, ask the user for a new key. An owner or admin can also turn agent access off, for one person or for everyone in a role. An owner's access is always on. While it is off, that person's personal keys and OAuth tokens get 401, and My agent access says so. Nothing is revoked: the same key works again once an owner or admin turns access back on. Do not create a new key in that case. Organization keys are not affected. ## Send the key ``` Authorization: Bearer iris_sk_... ``` The scheme name `Bearer` is case-insensitive. Nothing else authenticates a public call: no query parameter, no cookie, no body field. ## What the answers mean - **401** `Invalid API key`: the header is missing, uses another scheme, or the key is malformed, unknown or revoked. All five cases give the same answer on purpose. A personal key or OAuth token also gets it while the person's agent access is off. Ask the user to check My agent access, and for a new key only if the page does not say access is off. - **403** `insufficient_scope`: the key is live but lacks the scope named in `required`. The user creates a new key with the scope under My agent access, or under Organization > API keys. - **403** `insufficient_role`: the person behind the credential is a MEMBER, and members cannot call this operation or act for another team member. A key with a role gets it on the operations a person in that role, with the key's powers, cannot call. A new scope does not help. An owner or admin must do it. Call `me_get` to see the organization, the key's scopes and the full scope catalog. ## Scopes | Scope | Label | |---|---| | `contacts:read` | Search and read contacts | | `contacts:write` | Create and update contacts, notes, tags, suppression, SMS consent, and enroll contacts in sequences or cancel them through events | | `sequences:read` | Read sequences, enrollments, send logs and totals, and a broadcast's schedule and runs | | `sequences:write` | Author, activate, pause, resume and duplicate sequences, enroll and cancel, and set, start and pause a broadcast's schedule and cancel one run | | `appointments:read` | Read appointments, open slots, calendars, locations, booking options and territories | | `appointments:write` | Book, change, reschedule and cancel appointments, including attendee details | | `conversations:read` | Read conversations and messages | | `conversations:write` | Send an SMS, reply in a conversation, and hold or resume its AI. A staff member acts only for their own clients, while their SMS setting is on | | `calls:read` | Read calls and transcripts | | `audiences:read` | Read audiences | | `audiences:write` | Add and remove audience members | | `forms:read` | Read forms, their fields and their submissions | `me_get` returns the live catalog. When this table and `me_get` differ, trust `me_get`. ## Rate limit 600 requests a minute per client address on each public route. A 429 means wait 60 seconds. ## MCP connection The MCP server is `https://api.iris.dotfun.co/api/v1/mcp` (Streamable HTTP, stateless). It is rolling out behind a feature flag. An organization without the flag gets 404 after the key is checked. Tool names are the operationIds, and read tools carry `readOnlyHint: true`. Clients that can send a header use the key today: - Claude Code: the `iris` plugin's `.mcp.json` sends `Authorization: Bearer ${IRIS_API_KEY}`. - Codex: in `~/.codex/config.toml`: ```toml [mcp_servers.iris] url = "https://api.iris.dotfun.co/api/v1/mcp" bearer_token_env_var = "IRIS_API_KEY" ``` - Hermes Agent: in `~/.hermes/config.yaml`: ```yaml mcp_servers: iris: url: "https://api.iris.dotfun.co/api/v1/mcp" headers: Authorization: "Bearer ${IRIS_API_KEY}" ``` - OpenClaw: `openclaw mcp add iris --url https://api.iris.dotfun.co/api/v1/mcp --transport streamable-http`, then set the `Authorization` header in the Settings config editor. claude.ai, Claude Desktop, Claude Cowork and ChatGPT cannot send a custom header. They connect with OAuth instead (IRIS-3783): 1. Add `https://api.iris.dotfun.co/api/v1/mcp` as a custom connector. In Claude, open Customize > Connectors. In ChatGPT, turn on Developer mode, then add a connector. Leave the client id and secret empty: the client registers itself. 2. The client opens the Iris consent page. The user signs in and approves the scopes it lists. Any active member can approve. A member's client reaches only what that member can do: their locations, and no operation marked `deny`. 3. The client holds the tokens and refreshes them itself. Nothing goes in an environment variable. Each person sees the clients they connected under My agent access, and an owner or admin sees every connected client under Organization > API keys, with who granted it. Revoke in either place ends its access at once. Each person who approves a client gets their own connection. It keeps working after a demotion, with the new role's access, and stops when that person is deactivated or leaves the organization (IRIS-4138). Claude Code and Codex can use OAuth too: add the server with no header and they start the same flow. An OAuth token answers `me_get` with `api_key: null` and an `oauth_client` that names the client and its scopes. An OAuth token acts for the person who approved it, and a personal key for its owner, with that person's current role. `me_get` shows them as `principal`: `user_id`, `role` and `location_ids`. Iris reads the role on every request. A MEMBER cannot call an operation whose member access is `deny` in `references/endpoints.md`, and the MCP tool list hides those tools from a MEMBER. A MANAGER gets a MEMBER's access, plus the `deny` operations marked `x-manager-power` for a power the manager holds, at their locations. With the `sequences` power that means the sequences bound to their locations only, never an organization-wide one. An operation whose member access is `sms_setting` (`conversations_send_sms`, `conversations_reply`, `conversations_hold`, `conversations_resume`) admits a MEMBER only while their SMS setting is on, and only for their own clients, and a MANAGER only with the `contacts` power. While a person may not text, the consent page leaves `conversations:write` out and the grant drops it, and a personal key cannot take it. --- # Iris API endpoints Generated from the Iris public OpenAPI document (Iris Public API 1.0.0) by `scripts/contracts/agent-kit/generate.mts` in the Iris monorepo. Do not edit by hand. Base URL: `https://api.iris.dotfun.co`. Send `Authorization: Bearer $IRIS_API_KEY` on every call. ## Appointments ### appointments_availability `GET /api/v1/appointments/availability`. Scope: `appointments:read`. Member: `scoped`. Find open slots at a location on one day - `location_id` (query, string uuid, required): A location from `locations_list` - `date` (query, string, required): The day, in the location’s time zone - `offering_id` (query, string uuid): Only slots for this service, at its length - `team_member_id` (query, string uuid): Only slots this team member is free for - `postal_code` (query, string): The customer’s postal code for a home visit. Only people who cover it. - `country_code` (query, string): ISO 3166-1 alpha-2 country of `postal_code` - `duration_minutes` (query, number): Ignored when `offering_id` is sent ### appointments_cancel `POST /api/v1/appointments/{appointmentId}/cancel`. Scope: `appointments:write`. Member: `scoped`. Confirm: required. Changes: `appointments`. Cancel an appointment - `appointmentId` (path, string uuid, required) ### appointments_create `POST /api/v1/appointments`. Scope: `appointments:write`. Member: `scoped`. Changes: `appointments`. Idempotent: send `idempotency_key`. Book an appointment for a contact - `calendar_id` (body, string uuid): A calendar from `calendars_list`. It sets the location. Send this or `location_id`, not both. Use it at a location that is not staffed. - `location_id` (body, string uuid): A location from `locations_list`. Send this or `calendar_id`, not both. A staffed location needs this shape. - `offering_id` (body, string uuid): With `location_id`: a service from `locations_booking_options`. The end time must match its `duration_minutes`. - `team_member_id` (body, string uuid): With `location_id`: the team member to book. Leave it out to book the first team member who is free. - `service_address` (body, ServiceAddressDto): With `location_id`: where a home visit happens. Its postal code must be in a territory. - `line1` (body, string) - `line2` (body, string) - `city` (body, string) - `region` (body, string): State, province or region - `postal_code` (body, string): Decides the territory, and so who can come - `country_code` (body, string): ISO 3166-1 alpha-2 - `contact_id` (body, string uuid, required): The contact the appointment is for - `start_time` (body, string date-time, required) - `end_time` (body, string date-time, required) - `title` (body, string) - `idempotency_key` (body, string, required): Your own key for this booking. A retry with the same key and the same details returns the first booking. The same key with other details is a 409. ### appointments_get `GET /api/v1/appointments/{appointmentId}`. Scope: `appointments:read`. Member: `scoped`. Get one appointment - `appointmentId` (path, string uuid, required) ### appointments_list `GET /api/v1/appointments`. Scope: `appointments:read`. Member: `scoped`. List appointments in a time window - `page` (query, number): 1-based page number - `limit` (query, number): Page size (capped at 100) - `contact_id` (query, string uuid): Only this contact’s appointments: bookings made for the contact, and bookings whose attendee phone or email is the contact’s. - `from` (query, string date-time): Start of the window. Defaults to now. - `to` (query, string date-time): End of the window. Defaults to 30 days after `from`. At most 92 days after it. ### appointments_reschedule `POST /api/v1/appointments/{appointmentId}/reschedule`. Scope: `appointments:write`. Member: `scoped`. Changes: `appointments`. Move an appointment to a new time - `appointmentId` (path, string uuid, required) - `start_time` (body, string date-time, required) - `end_time` (body, string date-time, required) ### appointments_update `PATCH /api/v1/appointments/{appointmentId}`. Scope: `appointments:write`. Member: `scoped`. Changes: `appointments`. Change an appointment’s title, notes or attendee details - `appointmentId` (path, string uuid, required) - `title` (body, string) - `description` (body, string): Notes on the appointment. `null` clears them. - `attendee_name` (body, string) - `attendee_phone` (body, string): E.164, e.g. +14155550100 - `attendee_email` (body, string) - `attendee_notes` (body, string): Notes about the attendee ## Audiences ### audiences_add_member `POST /api/v1/audiences/{audienceId}/members`. Scope: `audiences:write`. Member: `deny`. Changes: `audiences`. Add a contact to a static audience - `audienceId` (path, string uuid, required) - `contact_id` (body, string uuid, required) ### audiences_archive `POST /api/v1/audiences/{audienceId}/archive`. Scope: `audiences:write`. Member: `deny`. Changes: `audiences`. Archive an audience - `audienceId` (path, string uuid, required) ### audiences_count `GET /api/v1/audiences/{audienceId}/count`. Scope: `audiences:read`. Member: `open`. Count the contacts in a saved audience - `audienceId` (path, string uuid, required) ### audiences_create `POST /api/v1/audiences`. Scope: `audiences:write`. Member: `deny`. Changes: `audiences`. Idempotent: send `idempotency_key`. Create an audience - `idempotency_key` (body, string): Your own key for this call. A retry with the same key and the same details within 24 hours returns the first answer and does not run the call again. The same key with other details is a 409 `idempotency_key_reused`. - `name` (body, string, required) - `description` (body, string) - `kind` (body, string, one of `static`, `dynamic`): `dynamic` holds the contacts that match `definition`. `static` holds the contacts added to it. Fixed after create. - `channel` (body, string, one of `any`, `email`, `sms`): The channel the audience is meant for - `definition` (body, AudienceFilterDto): Which contacts a dynamic audience holds. A static audience keeps its members instead, and ignores it. - `match` (body, string, one of `all`, `any`): `all`: a contact must meet every rule (AND). `any`: one rule is enough (OR). - `rules` (body, array of AudienceFilterRuleDto): At most 50 rules, in one flat list: a rule holds no other rules. No rules, or an empty list, means every contact of the organization. - `field` (body, string, required): The contact field the rule tests. Either a contact column: `display_name`, `email`, `phone_number` (text); `total_calls` (number); `first_seen_at`, `last_seen_at`, `created_at`, `updated_at` (dates, compared as ISO 8601 strings). Or `field_values.`, which reads a CRM custom field of this organization: `` is the name of an enabled custom field (letters, digits and underscore only). A custom field of type number or currency compares as a number, date or datetime as an ISO 8601 string, boolean as true or false, and any other type as text. To filter by pipeline stage, use the `audience_field` that `contacts_list_stages` answers, with stage ids as values. Any other field makes `audiences_preview_count`, and `audiences_count` and `audiences_list_members` on a dynamic audience, answer 400. Create and update check only its format, at most 200 characters. - `op` (body, string, required, one of `eq`, `neq`, `in`, `not_in`, `gt`, `gte`, `lt`, `lte`, `contains`, `contains_any`, `exists`, `not_exists`): `eq`, `neq`: equal or not equal. `in`, `not_in`: equal to one of a list, or to none of it. `gt`, `gte`, `lt`, `lte`: greater or less than; only on number and date fields (`total_calls`, the four date columns, and custom fields of type number, currency, date or datetime). `contains`: the text holds the value, ignoring case. `contains_any`: the text holds any value of the list. Both work on the text columns and on custom fields, not on `total_calls` or dates. `exists`, `not_exists`: the field has a value, or has none. `eq`, `neq`, `in`, `not_in`, `exists` and `not_exists` work on every field. - `value` (body, object): What the field is compared with. Leave it out for `exists` and `not_exists`. `in`, `not_in` and `contains_any` take a non-empty array of at most 500 strings, numbers or booleans. Every other op takes one string, number or boolean. A number field needs a number (or a numeric string). A string is at most 1000 characters. An object, or an array inside the array, answers 400. - `exclude_opted_out` (body, boolean): Stored with the audience. Counts and contact lists do not apply it: opted-out contacts are removed when a message is sent. ### audiences_get `GET /api/v1/audiences/{audienceId}`. Scope: `audiences:read`. Member: `open`. Read one audience with its definition - `audienceId` (path, string uuid, required) ### audiences_list `GET /api/v1/audiences`. Scope: `audiences:read`. Member: `open`. List the organization’s audiences ### audiences_list_members `GET /api/v1/audiences/{audienceId}/contacts`. Scope: `audiences:read`. Member: `deny`. List the contacts in an audience - `audienceId` (path, string uuid, required) - `cursor` (query, string): The `next_cursor` of the page before. Leave out for the first page. - `limit` (query, number) ### audiences_preview_count `POST /api/v1/audiences/preview-count`. Scope: `audiences:read`. Member: `open`. Changes: none. Count the contacts a draft definition matches - `match` (body, string, one of `all`, `any`): `all`: a contact must meet every rule (AND). `any`: one rule is enough (OR). - `rules` (body, array of AudienceFilterRuleDto): At most 50 rules, in one flat list: a rule holds no other rules. No rules, or an empty list, means every contact of the organization. - `field` (body, string, required): The contact field the rule tests. Either a contact column: `display_name`, `email`, `phone_number` (text); `total_calls` (number); `first_seen_at`, `last_seen_at`, `created_at`, `updated_at` (dates, compared as ISO 8601 strings). Or `field_values.`, which reads a CRM custom field of this organization: `` is the name of an enabled custom field (letters, digits and underscore only). A custom field of type number or currency compares as a number, date or datetime as an ISO 8601 string, boolean as true or false, and any other type as text. To filter by pipeline stage, use the `audience_field` that `contacts_list_stages` answers, with stage ids as values. Any other field makes `audiences_preview_count`, and `audiences_count` and `audiences_list_members` on a dynamic audience, answer 400. Create and update check only its format, at most 200 characters. - `op` (body, string, required, one of `eq`, `neq`, `in`, `not_in`, `gt`, `gte`, `lt`, `lte`, `contains`, `contains_any`, `exists`, `not_exists`): `eq`, `neq`: equal or not equal. `in`, `not_in`: equal to one of a list, or to none of it. `gt`, `gte`, `lt`, `lte`: greater or less than; only on number and date fields (`total_calls`, the four date columns, and custom fields of type number, currency, date or datetime). `contains`: the text holds the value, ignoring case. `contains_any`: the text holds any value of the list. Both work on the text columns and on custom fields, not on `total_calls` or dates. `exists`, `not_exists`: the field has a value, or has none. `eq`, `neq`, `in`, `not_in`, `exists` and `not_exists` work on every field. - `value` (body, object): What the field is compared with. Leave it out for `exists` and `not_exists`. `in`, `not_in` and `contains_any` take a non-empty array of at most 500 strings, numbers or booleans. Every other op takes one string, number or boolean. A number field needs a number (or a numeric string). A string is at most 1000 characters. An object, or an array inside the array, answers 400. - `exclude_opted_out` (body, boolean): Stored with the audience. Counts and contact lists do not apply it: opted-out contacts are removed when a message is sent. ### audiences_remove_member `DELETE /api/v1/audiences/{audienceId}/members/{contactId}`. Scope: `audiences:write`. Member: `deny`. Confirm: required. Changes: `audiences`. Remove a contact from a static audience - `audienceId` (path, string uuid, required) - `contactId` (path, string uuid, required) ### audiences_update `PATCH /api/v1/audiences/{audienceId}`. Scope: `audiences:write`. Member: `deny`. Changes: `audiences`. Change an audience - `audienceId` (path, string uuid, required) - `name` (body, string) - `description` (body, string): Null clears it - `channel` (body, string, one of `any`, `email`, `sms`) - `definition` (body, AudienceFilterDto): Replaces the whole definition. Which contacts a dynamic audience holds. A static audience keeps its members instead, and ignores it. - `match` (body, string, one of `all`, `any`): `all`: a contact must meet every rule (AND). `any`: one rule is enough (OR). - `rules` (body, array of AudienceFilterRuleDto): At most 50 rules, in one flat list: a rule holds no other rules. No rules, or an empty list, means every contact of the organization. - `field` (body, string, required): The contact field the rule tests. Either a contact column: `display_name`, `email`, `phone_number` (text); `total_calls` (number); `first_seen_at`, `last_seen_at`, `created_at`, `updated_at` (dates, compared as ISO 8601 strings). Or `field_values.`, which reads a CRM custom field of this organization: `` is the name of an enabled custom field (letters, digits and underscore only). A custom field of type number or currency compares as a number, date or datetime as an ISO 8601 string, boolean as true or false, and any other type as text. To filter by pipeline stage, use the `audience_field` that `contacts_list_stages` answers, with stage ids as values. Any other field makes `audiences_preview_count`, and `audiences_count` and `audiences_list_members` on a dynamic audience, answer 400. Create and update check only its format, at most 200 characters. - `op` (body, string, required, one of `eq`, `neq`, `in`, `not_in`, `gt`, `gte`, `lt`, `lte`, `contains`, `contains_any`, `exists`, `not_exists`): `eq`, `neq`: equal or not equal. `in`, `not_in`: equal to one of a list, or to none of it. `gt`, `gte`, `lt`, `lte`: greater or less than; only on number and date fields (`total_calls`, the four date columns, and custom fields of type number, currency, date or datetime). `contains`: the text holds the value, ignoring case. `contains_any`: the text holds any value of the list. Both work on the text columns and on custom fields, not on `total_calls` or dates. `exists`, `not_exists`: the field has a value, or has none. `eq`, `neq`, `in`, `not_in`, `exists` and `not_exists` work on every field. - `value` (body, object): What the field is compared with. Leave it out for `exists` and `not_exists`. `in`, `not_in` and `contains_any` take a non-empty array of at most 500 strings, numbers or booleans. Every other op takes one string, number or boolean. A number field needs a number (or a numeric string). A string is at most 1000 characters. An object, or an array inside the array, answers 400. - `exclude_opted_out` (body, boolean): Stored with the audience. Counts and contact lists do not apply it: opted-out contacts are removed when a message is sent. - `is_archived` (body, boolean): `false` restores an archived audience. Archive with `audiences_archive`. ## Calendars ### calendars_list `GET /api/v1/calendars`. Scope: `appointments:read`. Member: `scoped`. List the calendars appointments can be booked on ## Calls ### calls_get `GET /api/v1/calls/{callId}`. Scope: `calls:read`. Member: `scoped`. Get one call with its transcript - `callId` (path, string, required): The `id` from `calls_list` ### calls_list `GET /api/v1/calls`. Scope: `calls:read`. Member: `scoped`. List calls - `cursor` (query, string): The `next_cursor` of the page before. Leave out for the first page. - `limit` (query, number) - `contact_id` (query, string uuid): Only calls to or from this contact’s phone number - `from` (query, string date-time): Only calls that started at or after this time - `to` (query, string date-time): Only calls that started at or before this time ## Contacts ### contacts_add_note `POST /api/v1/contacts/{contactId}/notes`. Scope: `contacts:write`. Member: `deny`. Manager power: `contacts`. Changes: `contacts`. Idempotent: send `idempotency_key`. Add a note to a contact - `contactId` (path, string uuid, required) - `idempotency_key` (body, string): Your own key for this call. A retry with the same key and the same details within 24 hours returns the first answer and does not run the call again. The same key with other details is a 409 `idempotency_key_reused`. - `body` (body, string, required) ### contacts_add_tag `POST /api/v1/contacts/{contactId}/tags`. Scope: `contacts:write`. Member: `deny`. Manager power: `contacts`. Changes: `contacts`. Add a tag to a contact - `contactId` (path, string uuid, required) - `tag` (body, string, required): Tag name. Matched without regard to case or surrounding space; created on first use. ### contacts_get `GET /api/v1/contacts/{contactId}`. Scope: `contacts:read`. Member: `scoped`. Get one contact - `contactId` (path, string uuid, required) ### contacts_list_interactions `GET /api/v1/contacts/{contactId}/interactions`. Scope: `contacts:read`. Member: `scoped`. List the interactions of a contact - `contactId` (path, string uuid, required) - `page` (query, number): 1-based page number - `limit` (query, number): Page size (capped at 100) - `channel` (query, string, one of `voice`, `chat`, `sms`): Only interactions on this channel - `status` (query, string, one of `in_progress`, `completed`, `abandoned`): Only interactions in this status ### contacts_list_notes `GET /api/v1/contacts/{contactId}/notes`. Scope: `contacts:read`. Member: `deny`. Manager power: `contacts`. List the notes of a contact - `contactId` (path, string uuid, required) - `page` (query, number): 1-based page number - `limit` (query, number): Page size (capped at 100) ### contacts_list_stages `GET /api/v1/contacts/stages`. Scope: `contacts:read`. Member: `open`. List the organization’s pipeline stages ### contacts_remove_tag `DELETE /api/v1/contacts/{contactId}/tags/{tag}`. Scope: `contacts:write`. Member: `deny`. Manager power: `contacts`. Confirm: required. Changes: `contacts`. Remove a tag from a contact - `contactId` (path, string uuid, required) - `tag` (path, string, required): Tag name, URL-encoded ### contacts_search `GET /api/v1/contacts`. Scope: `contacts:read`. Member: `scoped`. Search contacts - `page` (query, number): 1-based page number - `limit` (query, number): Page size (capped at 100) - `q` (query, string): Free text. Matches contacts whose name, email or phone contains it, without regard to case. - `email` (query, string email): Exact email, without regard to case - `phone` (query, string): Exact phone number with its country code. Spaces, dashes and brackets are ignored, so `+1 (415) 555-0100` works. Matches the contact's phone number, not a phone held in `custom_fields`. ### contacts_suppress `POST /api/v1/contacts/{contactId}/suppress`. Scope: `contacts:write`. Member: `deny`. Manager power: `contacts`. Confirm: required. Changes: `contacts`, `sequences`. Suppress a contact on email or SMS - `contactId` (path, string uuid, required) - `channels` (body, array of string, required, one of `email`, `sms`) - `reason` (body, string): Why, for example "asked to stop on a call". Written to the server log; not stored on the contact. ### contacts_update `PATCH /api/v1/contacts/{contactId}`. Scope: `contacts:write`. Member: `deny`. Manager power: `contacts`. Changes: `contacts`. Update a contact - `contactId` (path, string uuid, required) - `name` (body, string) - `phone` (body, string): E.164, e.g. +14155550100 - `email` (body, string email) - `stage_id` (body, string): A stage `id` from the organization's pipeline - `custom_fields` (body, object): Custom field values to merge, keyed by field name. An unknown name is a 400 that names it. At most 200 keys. ### contacts_upsert `POST /api/v1/contacts`. Scope: `contacts:write`. Member: `deny`. Confirm: required. Changes: `contacts`, `sequences`. Create or update a contact by email - `name` (body, string, required): Contact full name - `email` (body, string email, required): Contact email address — the upsert key, matched case-insensitively - `phone` (body, string): Contact phone number. Stored in field_values; the contact itself stays email-keyed with phone_number NULL. - `source` (body, string, required): Attribution source - `fields` (body, object): Arbitrary key/value data merged into the contact's CRM field values. At most 200 keys. With assessment_abandoned, user_message_count must be a numeric nonnegative safe integer when present. Cohort routing uses this request's count: started requires a positive count, never_started requires zero, and missing counts match only unfiltered triggers. - `cancel_events` (body, array of ContactCancelEventDto): Sequence events to CANCEL for this contact, unconditionally. Processed before `events`, and idempotent because a second call cancels nothing. Use it for a goal exit, such as ending a win-back nurture once the contact converts. - `name` (body, string, required): Event name whose enrollments should be cancelled for this contact. - `reason` (body, string, one of `replied`, `booked`, `opted_out`, `manual`, `sequence_deactivated`, `converted`, `stage_reached`, `goal_met`, `schedule_cancelled`, `assessment_retake`): Why the journey ended. Defaults to `goal_met`. - `events` (body, array of ContactEventDto): Sequence events to FIRE for this contact, after the cancellations. Each event in turn claims its `key`, cancels the enrollments named in its `supersedes`, then enrolls. An event with no subscribing sequence leaves its key unspent, so a later retry still enrolls. - `name` (body, string, required): Event name. Matched as free text against the `event_name` of every active `event` sequence in the organization. - `key` (body, string, required): Caller-owned idempotency key for this event. Stable for "the same logical event", so a redelivery is a no-op. Surrounding whitespace is trimmed. - `supersedes` (body, array of ContactCancelEventDto): Events whose running enrollments this one REPLACES. Cancelled after this event claims its `key` and before it enrolls, so a redelivery with the same `key` cancels nothing and enrolls nothing. Use this for mutually exclusive states of one subject; use the top-level `cancel_events` for an unconditional exit. - `name` (body, string, required): Event name whose enrollments should be cancelled for this contact. - `reason` (body, string, one of `replied`, `booked`, `opted_out`, `manual`, `sequence_deactivated`, `converted`, `stage_reached`, `goal_met`, `schedule_cancelled`, `assessment_retake`): Why the journey ended. Defaults to `goal_met`. ## Conversations ### contacts_record_sms_consent `POST /api/v1/contacts/{contactId}/sms-consent`. Scope: `contacts:write`. Member: `deny`. Manager power: `contacts`. Confirm: required. Changes: `contacts`, `sequences`. Idempotent: send `idempotency_key`. Record a contact’s SMS opt-in or opt-out - `contactId` (path, string uuid, required) - `idempotency_key` (body, string): Your own key for this call. A retry with the same key and the same details within 24 hours returns the first answer and does not run the call again. The same key with other details is a 409 `idempotency_key_reused`. - `action` (body, string, required, one of `opt_in`, `opt_out`) - `source` (body, string): Where the contact gave or withdrew consent, for example "web form". Kept with an opt-in. ### conversations_get `GET /api/v1/conversations/{conversationId}`. Scope: `conversations:read`. Member: `scoped`. Get one conversation with its messages - `conversationId` (path, string uuid, required) ### conversations_hold `POST /api/v1/conversations/{conversationId}/hold`. Scope: `conversations:write`. Member: `sms_setting`. Changes: `conversations`. Hold an SMS or web chat conversation for a person: the AI stops answering - `conversationId` (path, string uuid, required) ### conversations_list `GET /api/v1/conversations`. Scope: `conversations:read`. Member: `scoped`. List email, SMS and web chat conversations - `cursor` (query, string): The `next_cursor` of the page before. Leave out for the first page. - `limit` (query, number) - `channel` (query, string, one of `email`, `sms`, `chat`): Only this channel. Every channel when left out. A search (`q`) leaves chat out. - `q` (query, string): Free text search ### conversations_reply `POST /api/v1/conversations/{conversationId}/reply`. Scope: `conversations:write`. Member: `sms_setting`. Confirm: required. Changes: `conversations`. Idempotent: send `idempotency_key`. Reply in an email, SMS or web chat conversation - `conversationId` (path, string uuid, required) - `idempotency_key` (body, string): Your own key for this call. A retry with the same key and the same details within 24 hours returns the first answer and does not run the call again. The same key with other details is a 409 `idempotency_key_reused`. - `body` (body, string, required): The text of the reply. At most 1600 characters for SMS and 4000 for chat. ### conversations_resume `POST /api/v1/conversations/{conversationId}/resume`. Scope: `conversations:write`. Member: `sms_setting`. Confirm: required. Changes: `conversations`. Idempotent: send `idempotency_key`. Hand an SMS or web chat conversation back to the AI - `conversationId` (path, string uuid, required) - `idempotency_key` (body, string): Your own key for this call. A retry with the same key and the same details within 24 hours returns the first answer and does not run the call again. The same key with other details is a 409 `idempotency_key_reused`. ### conversations_send_sms `POST /api/v1/conversations/sms`. Scope: `conversations:write`. Member: `sms_setting`. Confirm: required. Changes: `conversations`. Idempotent: send `idempotency_key`. Send an SMS to a contact who has consented - `idempotency_key` (body, string): Your own key for this call. A retry with the same key and the same details within 24 hours returns the first answer and does not run the call again. The same key with other details is a 409 `idempotency_key_reused`. - `contact_id` (body, string uuid): Send to this contact’s phone number - `phone` (body, string): Send to this number, with its country code - `body` (body, string, required) ## Forms ### forms_get `GET /api/v1/forms/{formId}`. Scope: `forms:read`. Member: `open`. Get one form with its fields - `formId` (path, string uuid, required) ### forms_list `GET /api/v1/forms`. Scope: `forms:read`. Member: `open`. List the organization’s forms ### forms_list_submissions `GET /api/v1/forms/{formId}/submissions`. Scope: `forms:read`. Member: `deny`. List the submissions of a form - `formId` (path, string uuid, required) - `page` (query, number): 1-based page number. (page - 1) * limit must be at most 2147483647. - `limit` (query, number): Page size (capped at 100) - `status` (query, string, one of `accepted`, `spam`) - `from` (query, string): ISO 8601 datetime with a time part. Only submissions at or after it. - `to` (query, string): ISO 8601 datetime with a time part. Only submissions at or before it. At most 366 days after `from`. - `search` (query, string): Case-insensitive match on part of the contact's email or the submitted email. At most 320 characters. ## Locations ### locations_booking_options `GET /api/v1/locations/{locationId}/booking-options`. Scope: `appointments:read`. Member: `scoped`. List the services and team members a location books - `locationId` (path, string uuid, required) ### locations_list `GET /api/v1/locations`. Scope: `appointments:read`. Member: `scoped`. List the locations appointments can be booked at ## MCP ### mcp_request `POST /api/v1/mcp`. Scope: any live key. Member: `open`. Send one MCP JSON-RPC message ## Me ### me_get `GET /api/v1/me`. Scope: any live key. Member: `open`. Describe the calling API key or connected client, the role it acts at and the locations it reaches ## Sequences ### contacts_list_enrollments `GET /api/v1/contacts/{contactId}/enrollments`. Scope: `sequences:read`. Member: `deny`. Manager power: `sequences`. List one contact’s enrollments across every sequence - `contactId` (path, string uuid, required) - `page` (query, number): 1-based page number - `limit` (query, number): Page size (capped at 100) ### sequences_activate `POST /api/v1/sequences/{sequenceId}/activate`. Scope: `sequences:write`. Member: `deny`. Manager power: `sequences`. Confirm: required. Changes: `sequences`. Turn a sequence on - `sequenceId` (path, string uuid, required) ### sequences_analytics `GET /api/v1/sequences/{sequenceId}/analytics`. Scope: `sequences:read`. Member: `deny`. Manager power: `sequences`. Get the send totals of a sequence - `sequenceId` (path, string uuid, required) ### sequences_cancel_enrollment `POST /api/v1/sequences/{sequenceId}/enrollments/{enrollmentId}/cancel`. Scope: `sequences:write`. Member: `deny`. Manager power: `sequences`. Confirm: required. Changes: `sequences`. Remove a contact from a sequence - `sequenceId` (path, string uuid, required) - `enrollmentId` (path, string uuid, required) ### sequences_cancel_schedule_run `POST /api/v1/sequences/{sequenceId}/schedule/runs/{runId}/cancel`. Scope: `sequences:write`. Member: `deny`. Manager power: `sequences`. Confirm: required. Changes: `sequences`. Cancel one run of a broadcast - `sequenceId` (path, string uuid, required) - `runId` (path, string uuid, required) ### sequences_capabilities `GET /api/v1/sequences/capabilities`. Scope: `sequences:read`. Member: `open`. What sequence authoring this server allows ### sequences_create `POST /api/v1/sequences`. Scope: `sequences:write`. Member: `deny`. Manager power: `sequences`. Changes: `sequences`. Idempotent: send `idempotency_key`. Create a draft sequence - `idempotency_key` (body, string): Your own key for this call. A retry with the same key and the same details within 24 hours returns the first answer and does not run the call again. The same key with other details is a 409 `idempotency_key_reused`. - `name` (body, string, required) - `description` (body, string) - `trigger_type` (body, string, required): The primary trigger, a `key` from `sequences_list_triggers` - `trigger_config` (body, object): Settings of the primary trigger - `triggers` (body, array of SequenceTriggerInputDto): Every trigger. The first is the primary one and must match `trigger_type` when both are sent. - `type` (body, string, required): A trigger `key` from `sequences_list_triggers` whose `authorable` is true - `config` (body, object): Trigger settings - `kind` (body, string, one of `automation`, `broadcast`): `automation` runs off a trigger. `broadcast` sends to an audience. - `audience_id` (body, string uuid): For a broadcast: its audience. An automation stores none. An audience of another organization answers 404. - `send_schedule_id` (body, string uuid): A saved send schedule. Null uses the organization default. - `location_id` (body, string uuid): The location the sequence runs for: it enrolls only contacts at that location. Null or omitted is organization-wide, which only an owner or admin may set. A manager must name one of their locations. On an update, a move is refused while any enrollment, live or finished, holds a contact who is not at the new location. - `is_active` (body, boolean, one of `false`): Only `false`, and `true` is a 400. A new sequence is always an inactive draft. On an update, `false` turns the sequence off and cancels every enrollment. Turn a sequence on with `sequences_activate`, and hold contacts in place with `sequences_pause`. - `steps` (body, array of SequenceStepInputDto, required): The ordered steps. On an update, replaces them all. - `step_index` (body, number, required): Zero-based position of the step - `channel` (body, string, required, one of `email`, `sms`, `wait`, `task`): `email` and `sms` message the contact. `wait` only waits. `task` notifies the team: its subject is the title and its body the note. - `delay_minutes` (body, number, required): Minutes to wait after the previous step before this one runs - `step_key` (body, string): Stable id of the step. Edges name steps by it. Keep it when you edit a step. Generated when omitted. - `subject_template` (body, string): Email subject, or the task title. May use `{{variables}}`. - `body_template` (body, string): Message body, or the task note. May use `{{variables}}`. - `preheader_template` (body, string): Email preview text shown next to the subject in the inbox - `body_document` (body, object): A designed email body (EmailDocument v1). Omit to keep the stored one. Null clears it. - `ai_enabled` (body, boolean): Let AI write the copy from `ai_prompt` at send time - `ai_prompt` (body, string): What the AI should write, when `ai_enabled` - `exit_conditions` (body, array of SequenceExitConditionInputDto): Replaces the exit conditions. An enrollment stops when any of them is met. - `condition` (body, object, required): A condition in the DSL `sequences_get_condition_vocabulary` describes, e.g. `{ "signal": "replied" }` - `cancel_reason` (body, string, required, one of `goal_met`, `converted`, `stage_reached`, `replied`, `booked`, `opted_out`): Recorded on the enrollment when this exit fires - `edges` (body, array of SequenceEdgeInputDto): Replaces the branch edges. Omit for a straight sequence. - `from_step_key` (body, string, required): `step_key` of the step the edge leaves - `to_step_key` (body, string): `step_key` it goes to. Null ends the sequence. - `condition` (body, object): A routing condition in the condition DSL. Omit for the fallback edge. - `priority` (body, number): Lower runs first. The first edge that matches wins. - `max_iterations` (body, number): Most passes through a loop. Required on an edge that goes back. - `variant_weight` (body, number): A/B split weight. Every edge from the step must carry one, and they sum to 100. - `send_window` (body, SendWindowInputDto): Hours of the day email and SMS may send. Null removes the window. - `start_minute` (body, number, required): Opens at this minute of the day, organization time. 540 is 09:00. - `end_minute` (body, number, required): Closes at this minute (exclusive). 1080 is 18:00. ### sequences_duplicate `POST /api/v1/sequences/{sequenceId}/duplicate`. Scope: `sequences:write`. Member: `deny`. Manager power: `sequences`. Changes: `sequences`. Idempotent: send `idempotency_key`. Copy a sequence into a new draft - `sequenceId` (path, string uuid, required) - `idempotency_key` (body, string): Your own key for this call. A retry with the same key and the same details within 24 hours returns the first answer and does not run the call again. The same key with other details is a 409 `idempotency_key_reused`. ### sequences_enroll `POST /api/v1/sequences/{sequenceId}/enrollments`. Scope: `sequences:write`. Member: `deny`. Manager power: `sequences`. Confirm: required. Changes: `sequences`. Enroll a contact in a sequence - `sequenceId` (path, string uuid, required) - `contact_id` (body, string uuid): The contact to enroll - `email` (body, string): Or the email of the contact to enroll, matched without regard to case - `start_step_key` (body, string): Start at this `step_key` instead of the first step ### sequences_get `GET /api/v1/sequences/{sequenceId}`. Scope: `sequences:read`. Member: `open`. Get one sequence with its steps - `sequenceId` (path, string uuid, required) ### sequences_get_condition_vocabulary `GET /api/v1/sequences/condition-vocabulary`. Scope: `sequences:read`. Member: `open`. List the signals a condition can test ### sequences_get_enrollment `GET /api/v1/sequences/{sequenceId}/enrollments/{enrollmentId}`. Scope: `sequences:read`. Member: `deny`. Manager power: `sequences`. Get the send log of one enrollment - `sequenceId` (path, string uuid, required) - `enrollmentId` (path, string uuid, required) ### sequences_get_schedule `GET /api/v1/sequences/{sequenceId}/schedule`. Scope: `sequences:read`. Member: `deny`. Manager power: `sequences`. Get a broadcast's send schedule and its runs - `sequenceId` (path, string uuid, required) - `page` (query, number): 1-based page number, at most 1000 - `limit` (query, number): Page size (capped at 100) ### sequences_list `GET /api/v1/sequences`. Scope: `sequences:read`. Member: `open`. List the organization’s sequences ### sequences_list_enrollments `GET /api/v1/sequences/{sequenceId}/enrollments`. Scope: `sequences:read`. Member: `deny`. Manager power: `sequences`. List the contacts enrolled in one sequence - `sequenceId` (path, string uuid, required) - `page` (query, number): 1-based page number - `limit` (query, number): Page size (capped at 100) - `status` (query, string, one of `active`, `paused`, `completed`, `cancelled`, `failed`): Only enrollments with this status - `contact_id` (query, string uuid): Only this contact's enrollments in the sequence - `email` (query, string): Only the enrollments of the contact with this email, in the key’s organization. Matched without regard to case. Takes precedence over `contact_id`. An email no contact carries returns an empty page, not a 404. When more than one contact carries the email, the one that owns it as its identity is used. ### sequences_list_send_schedules `GET /api/v1/sequences/send-schedules`. Scope: `sequences:read`. Member: `open`. List the send windows a sequence can use - `page` (query, number): 1-based page number, at most 1000 - `limit` (query, number): Page size (capped at 100) ### sequences_list_triggers `GET /api/v1/sequences/triggers`. Scope: `sequences:read`. Member: `open`. List the triggers a sequence can start on ### sequences_list_variables `GET /api/v1/sequences/variables`. Scope: `sequences:read`. Member: `open`. List the variables step copy may use ### sequences_pause `POST /api/v1/sequences/{sequenceId}/pause`. Scope: `sequences:write`. Member: `deny`. Manager power: `sequences`. Changes: `sequences`. Pause a sequence - `sequenceId` (path, string uuid, required) ### sequences_pause_schedule `POST /api/v1/sequences/{sequenceId}/schedule/pause`. Scope: `sequences:write`. Member: `deny`. Manager power: `sequences`. Changes: `sequences`. Pause a broadcast's schedule - `sequenceId` (path, string uuid, required) ### sequences_resume `POST /api/v1/sequences/{sequenceId}/resume`. Scope: `sequences:write`. Member: `deny`. Manager power: `sequences`. Confirm: required. Changes: `sequences`. Resume a paused sequence - `sequenceId` (path, string uuid, required) ### sequences_set_schedule `PUT /api/v1/sequences/{sequenceId}/schedule`. Scope: `sequences:write`. Member: `deny`. Manager power: `sequences`. Confirm: required. Changes: `sequences`. Set when a broadcast sends - `sequenceId` (path, string uuid, required) - `schedule_kind` (body, string, required, one of `once`, `recurring`): `once` sends at `send_at`. `recurring` sends on `rrule`. - `send_at` (body, string date-time): When a `once` schedule sends. Required for `once`. A real calendar date-time ending in `Z` or an offset such as `-05:00`, for example `2026-11-01T09:00:00Z`. At most 40 characters. - `rrule` (body, string): An RFC 5545 rule such as `FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0`, read in `timezone`. Required for `recurring`. Only these parts, each at most once: `FREQ` (`DAILY`, `WEEKLY` or `MONTHLY`), `INTERVAL` (1 to 100), `BYDAY` (1 to 7 distinct weekdays `MO` to `SU`, no number; on `DAILY` only with `INTERVAL=1`), `BYMONTHDAY` (`MONTHLY` only, 1 to 31 distinct days from 1 to 31 or -31 to -1), `BYSETPOS` (`MONTHLY` only, `1` or `-1`), `BYHOUR` (one hour, 0 to 23) and `BYMINUTE` (one minute, 0 to 59). Any other part, such as `COUNT` or `UNTIL`, answers 400. End a series with `ends_on`. - `timezone` (body, string): The IANA timezone `rrule` is read in - `starts_on` (body, string date): The first day a recurring schedule may send (YYYY-MM-DD), from 2000-01-01 to 2100-12-31 - `ends_on` (body, string date): The last day a recurring schedule may send (YYYY-MM-DD), from 2000-01-01 to 2100-12-31 and not before `starts_on` - `catchup_policy` (body, string, one of `skip`, `fire_once`): Sends a recurring schedule missed, for example while Iris was down: `skip` drops them, `fire_once` sends only the latest. - `throttle_per_minute` (body, number): Stored for a planned limit on sends a minute, from 1 to 2147483647. Iris does not enforce it yet. ### sequences_start_schedule `POST /api/v1/sequences/{sequenceId}/schedule/start`. Scope: `sequences:write`. Member: `deny`. Manager power: `sequences`. Confirm: required. Changes: `sequences`. Start a broadcast's schedule - `sequenceId` (path, string uuid, required) ### sequences_test_step `POST /api/v1/sequences/{sequenceId}/steps/{stepKey}/test`. Scope: `sequences:write`. Member: `deny`. Manager power: `sequences`. Confirm: required. Changes: none. Idempotent: send `idempotency_key`. Send a test of one step - `sequenceId` (path, string uuid, required) - `stepKey` (path, string, required): The `step_key` of the step, as `sequences_get` lists it. At most 128 characters. - `idempotency_key` (body, string): Your own key for this call. A retry with the same key and the same details within 24 hours returns the first answer and does not run the call again. The same key with other details is a 409 `idempotency_key_reused`. - `contact_id` (body, string uuid): Fill the step variables from this contact. The contact must belong to this organization. - `to_email` (body, string email): The address that gets the email. Needed for an email step when `contact_id` is not sent. - `to_phone` (body, string): The number that gets the text. Needed for an SMS step when `contact_id` is not sent. ### sequences_update `PATCH /api/v1/sequences/{sequenceId}`. Scope: `sequences:write`. Member: `deny`. Manager power: `sequences`. Confirm: required. Changes: `sequences`. Change a sequence - `sequenceId` (path, string uuid, required) - `name` (body, string) - `description` (body, string) - `trigger_type` (body, string): The primary trigger, a `key` from `sequences_list_triggers` - `trigger_config` (body, object): Settings of the primary trigger - `triggers` (body, array of SequenceTriggerInputDto): Every trigger. The first is the primary one and must match `trigger_type` when both are sent. - `type` (body, string, required): A trigger `key` from `sequences_list_triggers` whose `authorable` is true - `config` (body, object): Trigger settings - `kind` (body, string, one of `automation`, `broadcast`): `automation` runs off a trigger. `broadcast` sends to an audience. - `audience_id` (body, string uuid): For a broadcast: its audience. An automation stores none. An audience of another organization answers 404. - `send_schedule_id` (body, string uuid): A saved send schedule. Null uses the organization default. - `location_id` (body, string uuid): The location the sequence runs for: it enrolls only contacts at that location. Null or omitted is organization-wide, which only an owner or admin may set. A manager must name one of their locations. On an update, a move is refused while any enrollment, live or finished, holds a contact who is not at the new location. - `is_active` (body, boolean, one of `false`): Only `false`, and `true` is a 400. A new sequence is always an inactive draft. On an update, `false` turns the sequence off and cancels every enrollment. Turn a sequence on with `sequences_activate`, and hold contacts in place with `sequences_pause`. - `steps` (body, array of SequenceStepInputDto): The ordered steps. On an update, replaces them all. - `step_index` (body, number, required): Zero-based position of the step - `channel` (body, string, required, one of `email`, `sms`, `wait`, `task`): `email` and `sms` message the contact. `wait` only waits. `task` notifies the team: its subject is the title and its body the note. - `delay_minutes` (body, number, required): Minutes to wait after the previous step before this one runs - `step_key` (body, string): Stable id of the step. Edges name steps by it. Keep it when you edit a step. Generated when omitted. - `subject_template` (body, string): Email subject, or the task title. May use `{{variables}}`. - `body_template` (body, string): Message body, or the task note. May use `{{variables}}`. - `preheader_template` (body, string): Email preview text shown next to the subject in the inbox - `body_document` (body, object): A designed email body (EmailDocument v1). Omit to keep the stored one. Null clears it. - `ai_enabled` (body, boolean): Let AI write the copy from `ai_prompt` at send time - `ai_prompt` (body, string): What the AI should write, when `ai_enabled` - `exit_conditions` (body, array of SequenceExitConditionInputDto): Replaces the exit conditions. An enrollment stops when any of them is met. - `condition` (body, object, required): A condition in the DSL `sequences_get_condition_vocabulary` describes, e.g. `{ "signal": "replied" }` - `cancel_reason` (body, string, required, one of `goal_met`, `converted`, `stage_reached`, `replied`, `booked`, `opted_out`): Recorded on the enrollment when this exit fires - `edges` (body, array of SequenceEdgeInputDto): Replaces the branch edges. Omit for a straight sequence. - `from_step_key` (body, string, required): `step_key` of the step the edge leaves - `to_step_key` (body, string): `step_key` it goes to. Null ends the sequence. - `condition` (body, object): A routing condition in the condition DSL. Omit for the fallback edge. - `priority` (body, number): Lower runs first. The first edge that matches wins. - `max_iterations` (body, number): Most passes through a loop. Required on an edge that goes back. - `variant_weight` (body, number): A/B split weight. Every edge from the step must carry one, and they sum to 100. - `send_window` (body, SendWindowInputDto): Hours of the day email and SMS may send. Null removes the window. - `start_minute` (body, number, required): Opens at this minute of the day, organization time. 540 is 09:00. - `end_minute` (body, number, required): Closes at this minute (exclusive). 1080 is 18:00. ## Territories ### territories_list `GET /api/v1/territories`. Scope: `appointments:read`. Member: `deny`. List the service territories and their postal codes