references/booking-agent.md
# Booking agent: from an empty account to a bookable appointment agent
The tool definitions in your context are the authority on argument types. This file is the authority on **order and prerequisites**: which booking tool to call after which, what each one needs to exist first, which step a human must perform, and what `get_account_readiness` will report as a blocker until it is done.
Read [mcp-tool-surface.md](mcp-tool-surface.md) for write/read-back pairs and overwrite semantics; read [recipes.md](recipes.md#21-build-a-bookable-appointment-agent-on-a-fresh-account) for the worked example.
## Contents
1. [Runtime tools vs MCP tools](#1-runtime-tools-vs-mcp-tools)
2. [The ordered path](#2-the-ordered-path)
3. [Meeting type](#3-meeting-type)
4. [Hosts, schedule, calendar](#4-hosts-schedule-calendar)
5. [Reminders — the full contract](#5-reminders--the-full-contract)
6. [Host routing](#6-host-routing)
7. [Booking settings](#7-booking-settings)
8. [Operator meeting operations and outcomes](#8-operator-meeting-operations-and-outcomes)
9. [Calendly instead of a native calendar](#9-calendly-instead-of-a-native-calendar)
10. [External booker — the client's own booking endpoint](#10-external-booker--the-clients-own-booking-endpoint)
11. [Readiness and verification](#11-readiness-and-verification)
---
## 1. Runtime tools vs MCP tools
Two tool vocabularies share the word "tool", and only one of them is yours.
| Vocabulary | Who calls it | Examples |
|---|---|---|
| **MCP tools** — the ones in your context | You, on the operator's behalf | `create_meeting_type`, `add_executive`, `set_host_schedule`, `connect_calendar`, `set_reminder_rule`, `book_meeting` |
| **Runtime agent tools** — built into the conversational agent | The agent, mid-conversation with a lead | `get_available_slots`, `confirm_and_book`, `create_event`, `reschedule_event`, `cancel_event`, `request_channel_swap`, `schedule_followup`, `save_field`, `patch_metadata` |
**A runtime tool is not callable over MCP.** It has no MCP tool definition, it is never listed by `list_workflow_tools` unless the client registered a same-named HTTP tool, and calling it from here fails. Runtime tool names appear in this skill for exactly two purposes: stage-gating them via `available_in_statuses` (they honor the same gate as client tools) and disabling them in `disabled_builtin_tools` for an external booker. When an operator wants a meeting created, moved or cancelled *now*, the MCP equivalents are `book_meeting`, `reschedule_meeting`, `cancel_meeting` and `change_meeting_host` (§8) — never the runtime names.
Likewise, account discovery is `get_account_readiness` plus the channel list tools (`list_whatsapp_numbers`, `list_email_senders`, `list_phone_numbers`) and `get_integration_status`. No other inspection tool exists on the MCP.
---
## 2. The ordered path
Each step depends on the one before it: a meeting type needs a workflow, a host needs a meeting type to cover, a schedule needs a host, a calendar connection needs a team member, a reminder needs a channel resource, activation needs all of it. `get_account_readiness` reports the missing step as a blocker on the agent's `booking` block with the `fix_tools` to call; `review_agent_system_plan` refuses an appointment plan that omits a meeting type, a host, or a calendar plan.
| # | Step | Tool | Must already exist | Read back / done when |
|---|---|---|---|---|
| 0 | Discover | `get_account_readiness`, `list_team`, `list_workflows`, `get_integration_status` | — | You know the saved timezone, the team members (their `user_id`s), existing agents, and `calendars[]` / `booking_providers[]` |
| 1 | Create the agent, paused | `create_workflow({ goal_type: "appointment", timezone, language, statuses, fields, … })` | — | `status: "created"`. The three core stages and the virtual Discarded column always exist and are never part of your `statuses`; design your booking stages after them. `timezone` is the booking anchor every offered slot is rendered in — pass it explicitly or read the inherited one back |
| 2 | Meeting type | `create_meeting_type({ workflow_id, name, duration_minutes, location_type, … })` — or reuse the one `list_meeting_types` returns | Step 1 | Exactly one meeting type on the workflow (`status: "existing"` means reuse; `MULTIPLE_MEETING_TYPES` means consolidate first). Created **active**; bookable once a host with availability is assigned |
| 3 | Host | `add_executive({ workflow_id, user_id, meeting_type_ids? })` per human who takes meetings; `invite_user` first when the person is not on the team yet | Step 2, a team member | `list_executives` shows the host active and covering the meeting type. One eligible member and no host yet → `auto_assign_solo_host({ workflow_id })` does this step |
| 4 | Availability | `set_host_schedule({ user_id, timezone, slots: [{ weekday, start, end }] })`, plus `set_host_blocks` for days off | Step 3 | `get_host_availability({ user_id })` returns the schedule; the `BOOKING_AVAILABILITY_REQUIRED` blocker disappears |
| 5 | Calendar — **human step** | `connect_calendar({ user_id, provider: "google" \| "outlook" })` → `connect_url` | Step 3 (`confirm: true` when `user_id` is not the operator) | Send the URL to that host; they authorize in the browser. Poll `get_calendar_connections({ user_id })` until `status: "active"`. Never claim it is connected before the poll says so |
| 6 | Reminders | `get_reminder_catalog({ workflow_id })` → `set_reminder_rule` per rule (§5); `set_host_reminder_rule` for the rep's booking email | A channel resource the catalog marks `available_for_account` | `list_reminder_rules({ workflow_id })` / `list_host_reminder_rules` show each rule `is_active: true` |
| 7 | Routing (2+ hosts) | `set_meeting_type_routing` (§6) | Step 3 with several hosts | Rules read back on the meeting type |
| 8 | Preflight | `review_agent_system_plan({ plan })` with `meeting_type`, `hosts[]` and `calendar_plan` on the appointment agent | Steps 1–7 | `ready_for_signoff: true`, then the operator approves the summarized plan ("Does this look ok? Let me know and I'll build it."; never show the `plan_fingerprint`) |
| 9 | Activate | `set_workflow_active({ workflow_id, active: true })` | `get_account_readiness` shows zero booking blockers | `list_workflows` shows the agent active; a `get_workflow_slots` probe returns slots |
Steps 2–7 mutate; run them only after the review in step 8 has been approved — the review can be run on the plan before anything exists, which is the normal order: plan → review → sign-off → steps 1–7 → read back → activate. Keep the agent paused throughout.
---
## 3. Meeting type
`create_meeting_type` accepts the dashboard's fields; omitted ones take the dashboard defaults (30 min, 15 min buffer, 2 h notice, 14 days ahead, video).
| Field | Contract |
|---|---|
| `name` | Required, ≤120 chars |
| `duration_minutes` | 5–480 |
| `buffer_after_minutes` | 0–120, gap held after each meeting |
| `min_notice_hours` | 0–336 |
| `max_days_ahead` | 1–365 |
| `location_type` | `video` \| `phone` \| `in_person` |
| `video_provider` | `google_meet` \| `teams` \| `null` (whichever calendar the host connected). Only with `video` |
| `location_details` | **Required when `location_type` is `in_person`**; the address guests see |
| `allow_overbooking` + `max_bookings_per_slot` | 2–20 per slot; only with overbooking on |
| `event_title_template`, `event_description_template` | What the calendar invite shows |
| `agent_selection_criteria` | Free text the agent reads when picking a host — **not server-enforced**; §6 |
| `is_active` (`update_meeting_type` only) | Only one type per workflow can be active |
One workflow, one meeting type. If several exist, help the operator consolidate (`delete_meeting_type` reports `agent_assignments_removed` / `future_events_orphaned`; the last type of an appointment workflow cannot be deleted — `LAST_APPOINTMENT_TYPE`).
---
## 4. Hosts, schedule, calendar
**Hosts are team members.** `add_executive` takes a `user_id` from `list_team`; someone not on the team is invited first with `invite_user` and only becomes a host after accepting (`list_pending_invitations` shows the wait). Adding an existing host again fails with a conflict — read `list_executives` first. `update_executive` replaces the host's **complete** `meeting_type_ids` set and toggles `is_active`; `remove_executive` stops new bookings without touching existing meetings.
**Availability is per host, in the host's timezone.** `set_host_schedule` writes the complete weekly schedule (`weekday` 0 = Sunday … 6 = Saturday, `start`/`end` as `HH:MM`); an empty `slots` array clears it. `set_host_timezone` changes the zone the slots are read in (`null` falls back to the account); `set_host_blocks({ add: [{ date, start?, end?, all_day?, reason? }], remove: [ids] })` handles days off. The workflow timezone (step 1) is what the lead sees; the host timezone is what the schedule is expressed in — do not conflate them.
**A calendar connection is the human step.** `connect_calendar` returns a `connect_url` that only the host can complete; there is no token you can paste. Tell the operator exactly who must open which link, then poll `get_calendar_connections`. Statuses: `active` (usable), `expired` / `needs_reauth` / `error` (call `connect_calendar` again — a broken connection blocks that host's slots, a never-connected one merely yields none), `revoked`. `disconnect_calendar({ connection_id, confirm })` previews without `confirm: true`.
Pure-schedule hosts (schedule set, no calendar) are offered; conflicts with their real calendar are then invisible. State that trade-off when a host declines to connect.
---
## 5. Reminders — the full contract
Call `get_reminder_catalog({ workflow_id })` before writing any rule. It is the only source of truth for which channels this account can actually use — never assume from memory, and never propose a channel the catalog marks unavailable.
### `set_reminder_rule` arguments
| Argument | Contract |
|---|---|
| `trigger_event` | One the runtime actually dispatches: `event_created` (a booking happened; **re-dispatched for the new time after a reschedule**), `event_cancelled`, `call_analyzed` (post-**call**, ~30 s after the agent's call — not post-meeting), `event_enrolled` (event-goal agents). **There is no reschedule trigger** — a "reminder after reschedule" is the `event_created` rule firing again |
| `channel` | `whatsapp` \| `retell` \| `sms` \| `email`, each subject to `available_for_account` in the catalog: WhatsApp needs an active number, `retell` a callable number, `sms` an SMS-enabled number, `email` a verified sender. Default `whatsapp` |
| `template_name` | WhatsApp: the exact name of an **APPROVED** template whose intent matches (confirmation / reminder), never an opening or cold-contact template. `retell` / `sms` / `email`: a variant key — `default`, `notification_1h`, `notification_10min`, `notification_5min` |
| `delay_minutes` | **Signed.** `0` = at the trigger; `-1440` = 24 h before; positive = after |
| `delay_reference` | `trigger` (default; count from the moment the event fired) or `event_start` (count from the meeting start — the only sensible reference for pre-meeting reminders). The catalog may list `event_start_local`, `event_end`, `contact_schedule_start` for other agent kinds |
| `is_active` | Default `true`; write `false` for a rule whose WhatsApp template is still pending Meta approval |
| `workflow_id`, `name` | Scope the rule to the agent; name it for the operator |
Writes are idempotent upserts. There is no database check on `trigger_event` / `channel` — the tool validates against the catalog, which is why a hand-typed value must come from it.
### The default set
Propose the complete set in one message, scoped to the channels the catalog allows, and create nothing before the operator approves the batch:
| Rule | `trigger_event` | `channel` | `delay_minutes` | `delay_reference` |
|---|---|---|---|---|
| Confirmation at booking | `event_created` | `whatsapp` (or `email` / `sms`) | `0` | `trigger` |
| Reminder 24 h before | `event_created` | `whatsapp` | `-1440` | `event_start` |
| Reminder 2 h before | `event_created` | `whatsapp` | `-120` | `event_start` |
| Call 10 min before | `event_created` | `retell`, `template_name: "notification_10min"` | `-10` | `event_start` |
| Cancellation notice | `event_cancelled` | any available | `0` | `trigger` |
Dedupe against `list_reminder_rules` on `trigger_event + channel + delay_minutes` before proposing. For each WhatsApp rule, resolve the template first: `sync_whatsapp_templates`, then `list_whatsapp_templates({ status: "APPROVED" })`, match by intent; when nothing matches, draft one with `review_whatsapp_template_drafts` → `create_whatsapp_templates_batch` and create the rule `is_active: false` until Meta approves. Never satisfy a reminder with a template from the opening pool.
### Host notifications
`set_host_reminder_rule` is **email only** and its only trigger is `event_created` (`delay_reference` `trigger` or `event_start`). Host cancellation and reschedule alerts are on by default and are not configured through rules; `list_host_reminder_rules` may therefore look empty on an account that is already notifying.
---
## 6. Host routing
Two layers, and only one is enforced:
- `agent_selection_criteria` on the meeting type (`update_meeting_type`) is prose the agent reads when several hosts are available. Keep it short and ordered; never claim it is a server rule.
- `set_meeting_type_routing({ workflow_id, meeting_type_id?, agent_selection_rules })` is **enforced server-side at offer time and book time**. Reference hosts by `user_id` from `list_executives`; every referenced host must already be assigned to the meeting type. Shapes:
- Field tiers: `{ field: "<lead field key>", tiers: [{ min: 50000, agents: [<id>] }, { min: 0, agents: [<id>, <id>], weights: [2, 1] }, { min: -1e9, action: "disqualify" }] }` — tiers ordered by descending `min`; `disqualify` blocks booking for leads in that tier.
- Field-less rotation: `{ routing: "cycle", tiers: [{ agents: [<a>, <b>], mode: "cycle", pattern: [0, 1] }] }` — strict alternation; `mode: "round_robin"` (default) is an even split, `weights` skews it.
- `agent_selection_rules: null` clears.
- Zero or one host: no routing decision exists — do not write rules. After the operator adds a second host, offer once: keep the even split or define a rule.
`set_host_assigner` is the third option: the client's own endpoint decides the host (`tool` = an existing active workflow tool, `id_field` = the response field carrying the seller id, `external_key` mapping it to a Nexor user or `identity: true` when it already is one). `test_host_assigner` runs the call without booking. Use it only when the client owns the rotation, as in recipe 13.
---
## 7. Booking settings
| Setting | Tool | Notes |
|---|---|---|
| Everything at once | `get_workflow_booking_settings({ workflow_id })` | Read before changing any of the below |
| Guests on every invite | `set_default_attendees({ workflow_id, attendees: [{ email }] })` | Replaces the list |
| Lead confirmation email | `get_meeting_confirmation_email` / `set_meeting_confirmation_email({ workflow_id, meeting_type_id, email_config })` | `subject`, `title`, `greeting_body`, `cta_text`, `calendar_note`, `disclaimer`, `sections[]`, `sender_name`; `send_to_lead: false` suppresses the lead copy. Always `preview_meeting_confirmation_email` and show the operator the render before saving |
| Client-owned host choice | `set_host_assigner` | §6 |
| Client-owned calendar | `set_external_booking` | §10 |
---
## 8. Operator meeting operations and outcomes
These are the MCP-side equivalents of what the agent does at runtime. Every write previews when `confirm` is omitted and executes only with `confirm: true` after the operator approved that exact meeting.
| Ask | Tool | Contract |
|---|---|---|
| "What is booked / who no-showed" | `list_meetings({ workflow_id?, lead_id?, status?, date_from, date_to, date_field })`, `list_lead_meetings` | `status` ∈ `scheduled` \| `completed` \| `cancelled` \| `no_show` \| `rescheduled` |
| "What is our show rate" | `get_meeting_stats({ workflow_id?, date_from, date_to })` | `show_rate`, `completion_rate`, `no_show_rate` |
| "Which slots are free" | `get_workflow_slots({ workflow_id, from, to, meeting_type_id?, host_id? })` | Also the activation probe: empty slots on a fresh agent means step 4 or 5 is missing |
| "Book X for this lead" | `book_meeting({ workflow_id, lead_id, host_id, starts_at, idempotency_key, confirm })` | `starts_at` from `get_workflow_slots`; `idempotency_key` makes a retry return the same meeting instead of a duplicate; `invite_lead: false` skips the lead email |
| "Move it" | `reschedule_meeting({ meeting_id, starts_at \| slot, keep_host, confirm })` | `keep_host: false` requires `host_id` |
| "Give it to another host" | `change_meeting_host({ meeting_id, host_id, confirm })` | |
| "Cancel it" | `cancel_meeting({ meeting_id, reason?, confirm })` | Cancels on the provider **first**, then locally; the lead cancellation email always sends |
| Record the outcome | `set_meeting_status({ meeting_id, status: "no_show" \| "completed", note? })` | Outcomes feed `get_meeting_stats` and no-show recontact; record them, do not leave meetings `scheduled` forever |
| Notes from the meeting | `ingest_meeting_notes` | |
---
## 9. Calendly instead of a native calendar
When hosts book through Calendly, Calendly owns the calendar; Nexor mirrors bookings and offers Calendly's availability. Order:
1. `connect_calendly` → hosted authorization URL (human step); `get_calendly_status` until connected. `reconnect: true` rotates the webhook subscription.
2. `set_calendly_host_mode({ mode: "teams" })` (the only supported mode), then `map_calendly_hosts({ calendly_user_uri, host_user_id })` for every Calendly user → Nexor host. `list_calendly_event_types({ owner_uri? })` shows what each one offers.
3. `set_calendly_binding({ workflow_id, meeting_type_id, host_user_id, event_type_uri })` per host — a host without a binding is **silently excluded** from booking; `get_integration_status.booking_providers[].excluded_hosts` and `get_account_readiness` (`CALENDLY_BINDING_REQUIRED`) both surface it. `list_calendly_bindings({ workflow_id })` reads back; `event_type_uri: null` clears.
4. `provision_calendly({ wait: true })` runs the provisioning report; `disconnect_calendly({ confirm: true })` tears it down.
Steps 4 (host schedule) and 5 (native calendar) of §2 are replaced by this list; steps 2, 3, 6 and 8–9 stay. In the plan, express it as `calendar_plan: { provider: "calendly" }`.
---
## 10. External booker — the client's own booking endpoint
When the client's HTTP endpoint owns the calendar, Nexor does not generate slots; it mirrors every booking into a meeting so reminders, confirmation emails, funnel movement and stats keep working. Configure it with `set_external_booking({ workflow_id, external_booking })`, never through `update_workflow_config`.
**All four verbs are required: slots, create, reschedule, cancel.** `tool` (create), `reschedule_tool` and `cancel_tool` are required arguments, `reschedule_tool` must differ from `tool`, and every named tool must exist and be active on the workflow (`TOOL_NOT_FOUND`). The reason is not tidiness: a verb that is not configured is silently never mirrored — a lead who cancels through the agent keeps a live meeting, reminders keep firing, the dashboard says "scheduled", and the agent, facing an error with no branch for it, narrates a cancellation that never happened.
**The only exception is written acceptance.** If the client genuinely cannot expose cancel or reschedule, the operator must state in the conversation, in their own words, that they accept that cancellations and reschedules made through the agent will not reach their system and will leave phantom meetings and stale reminders. Record that sentence in the configuration spec next to the `external_booking` block. Without it, do not ship a booker with fewer than four verbs — offer the in-code providers (`native`, Calendly, GoHighLevel via `provider: "gohighlevel"`) instead. Do not treat "we'll add cancel later" as acceptance.
Other contract points: `fields` are dot-paths into the **raw** tool response (`data.slots.0.start`, never brackets); `starts_at` is the only required mapping; `success_path` names the field that must be truthy for the booking to count (an endpoint that signals failure with a 200 body otherwise mirrors a phantom booking); `provider` defaults to `external_booker`, which auto-gates the native booking tools — still set `disabled_builtin_tools` with the runtime names (§1) so the prompt and the tool set move together. In the plan, express it as `booking_provider: "external_booker"`.
---
## 11. Readiness and verification
| Check | Tool | Proves |
|---|---|---|
| Booking blockers per agent | `get_account_readiness` → agent `booking` block | Zero blockers (`MEETING_TYPE_REQUIRED`, `BOOKING_AVAILABILITY_REQUIRED`, `CALENDLY_BINDING_REQUIRED`, timezone warnings) and no `fix_tools` left |
| Calendar health | `get_integration_status.calendars[]` | Every host `status: "active"`; `booking_providers[].excluded_hosts` empty |
| Slots exist | `get_workflow_slots` over the next 7 days | Non-empty for every host |
| Reminders wired | `list_reminder_rules({ workflow_id })`, `list_host_reminder_rules` | The approved set, each `is_active: true` |
| Routing | read-back of `set_meeting_type_routing` | Rules name only assigned hosts |
| A real booking | `book_meeting` with `confirm: true` on a test lead, then `list_meetings` and `cancel_meeting` | The meeting exists with host, time in the workflow timezone and a meeting link; cancelling removes it on the provider |
An active agent with booking blockers cannot book. Do not report an appointment agent as done from `set_workflow_active` alone.
references/configuration-reference.md
# Configuration reference
The client-facing configuration surface for each primitive. Field names here are the real ones exposed through the product UI and public API.
## Contents
1. [Statuses and agent routing](#1-statuses-and-agent-routing)
2. [Intake: workflow fields vs metadata](#2-intake-workflow-fields-vs-metadata)
3. [Integration endpoints: deterministic callers and workflow HTTP/MCP tools](#3-integration-endpoints-deterministic-callers-and-workflow-httpmcp-tools)
4. [Getting leads in: the public API](#4-getting-leads-in-the-public-api)
5. [Notifications: outbound webhooks and status automations](#5-notifications-outbound-webhooks-and-status-automations)
6. [Cloud functions (event-driven JS)](#6-cloud-functions-event-driven-js)
7. [Scheduled functions (cron-driven JS)](#7-scheduled-functions-cron-driven-js)
8. [Background jobs (declarative cohort automation)](#8-background-jobs-declarative-cohort-automation)
9. [Workflow transfers](#9-workflow-transfers)
10. [Cadence: contact windows and outreach intensity](#10-cadence-contact-windows-and-outreach-intensity)
11. [Knowledge bases: account catalog and per-agent assignment](#11-knowledge-bases-account-catalog-and-per-agent-assignment)
12. [Account channel inventory and agent binding](#12-account-channel-inventory-and-agent-binding)
13. [Conversions: counting the outcome and moving the lead](#13-conversions-counting-the-outcome-and-moving-the-lead)
---
## 1. Statuses and agent routing
Statuses belong to a workflow (an agent). The list below is the **stored/dashboard shape** of a status, and `create_workflow` accepts nearly all of it directly (each status needs `key` plus `name` or its `label` alias; unknown keys forward to the backend) — so write the full funnel at create time and reserve `update_workflow_status` for edits and late-binding transfer targets. The statuses array is the *complete* pipeline: the backend seeds default stages on create, and the tool prunes them back to your list and reports the outcome in `pipeline_reconciliation`. See [mcp-tool-surface.md §5](mcp-tool-surface.md) for the exact create-time contract. Key config per status:
- **Identity:** `key` (snake_case, unique per workflow), `label`, `description` (an internal note — see below), `color`, `sort_order`, `category` (`active` | `won` | `lost` | `paused` | `deferred`; terminal statuses use `won` / `lost`).
- **Semantic flags:** `is_initial` (exactly one per workflow), `is_terminal` (at least one), stage-type markers `is_auto_contacted` / `is_auto_engaged` / `is_qualified` / `is_booking_target` (mutually exclusive — at most one stage each), `pause_bot` (the only per-status control that silences the agent), `futurology_queue` (a "park and re-contact later" bucket).
- **Agent-facing:** `entry_hint`, `variable_refs[]`, `requires_all_fields`, `required_field_keys[]`.
- **JSON blocks:** `transfer_config`, `timeout_config`, `transition_rules`, `assignment_config`.
**Reachability:** `update_workflow_status` writes everything above **except `variable_refs`, `futurology_queue`, and `sort_order`** — those three are dashboard-only there. Treat `variable_refs` as presentation, never as a gate: the enforcing gate is always `required_field_keys` / `requires_all_fields` / `transition_rules`. Set initial positions through the ordered `create_workflow` statuses. Reorder an existing pipeline only with `reorder_workflow_statuses`, never by patching numeric `sort_order` values through a mixed structure edit.
### Pipeline order
Every persisted pipeline starts with three protected semantic stages at `sort_order` 0–2: Lead created (`core_new`, normally key `new`), Contacted (`core_contacted`), and Engaged (`core_engaged`). Labels are editable, so identify this head from its semantic role/system kind, not displayed copy. Only stages after this head are reorderable.
Use `reorder_workflow_statuses({ workflow_id, ordered_status_keys })` as a complete-tail replacement:
1. Read `get_workflow` and collect every persisted status after Engaged.
2. Submit every collected key exactly once in the desired order. Do not include the protected head or raw position numbers.
3. Read `get_workflow` again and verify the head remains 0–2 and the submitted tail occupies 3 onward exactly.
An omission, duplicate, unknown key, changed pipeline membership, or malformed core fails without mutation. The dashboard may group On hold and terminal outcomes independently of raw order, so a reorder changes order within the UI's grouping rules rather than moving a status across every visible section. A synthetic Discarded column is not persisted and must never be submitted.
### How the agent decides where a lead goes
The prompt shows the agent its pipeline: every status's `label` and `key`, with `entry_hint` as the placement criterion and `variable_refs` showing which collected fields each status needs. The agent moves leads with the `set_lead_status` tool; the server blocks the move if `requires_all_fields` / `required_field_keys` are unmet.
**`entry_hint` is the routing rule.** `description` is an internal note: it never enters the conversational agent's prompt (its only consumers are the recontact decision agent, as an `entry_hint` fallback on terminal statuses, and the prompt optimizer's context). Placement rules go in `entry_hint`, referencing concrete field values:
> `entry_hint: "Monthly income is confirmed and below $800,000 — place the lead here."`
Keep hints specific (a hint under ~20 characters is flagged as too vague by the workflow validator).
### Value-range routing (e.g. "salary under / over X")
The canonical pattern for "when <variable> is below/above/equal to X, do Y":
1. Define the variable as a workflow field (e.g. `monthly_income`, type `currency`, `required: true`).
2. Create one status per range. Each status: `variable_refs: ["monthly_income"]`, `requires_all_fields: true`, an `entry_hint` stating the exact condition ("income is less than 800,000", "income is 800,000 or more"), and a `transition_rules` rule group encoding the same condition numerically with `auto_evaluate: true` — the hint guides the agent, the rule group routes deterministically.
3. Make the branch statuses that leave this agent's scope **terminal**, and attach `transfer_config` (§9) or a status automation / webhook (§5) to make something happen. Remember the terminal early-return above: the server does not block entry into `lost`/transfer terminals, so the rule groups (via `auto_evaluate`) — not agent placement — should own the routing.
The agent asks for the field, then the platform routes the lead and reacts from there. No code.
### The qualification gate — what the server enforces on `set_lead_status`
`is_qualified` is a stage-type marker for reporting and UI; it enforces nothing by itself (creating a qualified stage defaults `requires_all_fields` to true — that default is the gate). The server validates every transition in this order:
1. **Milestone guard:** leads sitting in an `is_booking_target` or `category: "won"` status cannot be demoted into a `futurology_queue` status.
2. **Booking integrity:** entering `is_booking_target` requires a live upcoming calendar event (native booking only).
3. **Terminal early-return: terminal targets skip the field and value gates — unless the target is `category: "won"` with a gate declared.** Gates protect non-terminal stages and `won`; entry into `lost`/transfer terminals is guided by `entry_hint`, not blocked by the server.
4. **Field-presence gate:** `required_field_keys` (exact list) takes precedence over `requires_all_fields` (all enabled+required workflow fields); missing keys block the move and are returned as `missing_fields`.
5. **Value gate:** `transition_rules.rule_groups` — groups are OR'd, conditions within a group are AND'd; operators `=`, `!=`, `>`, `>=`, `<`, `<=`; a missing/null value fails its condition.
Full `transition_rules` shape:
```json
{
"auto_evaluate": true,
"rule_groups": [
{ "label": "High income", "logic": "AND",
"conditions": [{ "field": "monthly_income", "operator": ">=", "value": 800000 }] }
],
"group_logic": "OR",
"llm_note": "Free text the agent sees for edge cases"
}
```
`auto_evaluate: true` transitions the lead as soon as its rules are satisfied, instead of waiting for the agent to call `set_lead_status`. (`logic` / `group_logic` are stored but the evaluator hardcodes OR-of-AND-groups.)
### Disqualification / discard statuses
A terminal status with `category: "lost"` is the discard mechanism. The disqualification rule is configured visibly in the agent (`entry_hint` + `transition_rules`) and processed internally during the conversation. `lost` means **"stop initiating," not "stop responding"**: all proactive outbound halts (cadence and jobs skip the lead as `status_lost`), the run cannot be reactivated — but if the lead writes in, the agent still replies briefly and kindly under built-in lost-lead behavior (no selling, no booking offers). Choose by intent:
- **Discard** ("stop pursuing"): terminal + `category: "lost"`. Attach a status automation or filtered webhook when the customer's CRM or external API must also be told. Add `pause_bot` only if the customer wants total silence, including to inbound messages.
- **Hold** ("stop responding while a human reviews"): `pause_bot` — the run stays live, the agent just goes quiet while the lead sits there.
- **Defer** ("not now, recontact later"): `futurology_queue` parking bucket + a re-activation mechanism (§8).
Status keys starting with `future_`, or exactly `contact_later` / `colder`, are treated as **soft terminals** — recontact machinery keeps running for them. Do not use those key shapes for a hard discard. Prompt-only instructions like "don't message unqualified leads" are speech, not structure — the terminal status is the enforcement.
### Status timeouts
`timeout_config: { timeout: <n>, unit: "minutes"|"hours"|"days", target_status_key: "<key>" }` is **the delay primitive**: after `timeout` in the status, the lead moves to `target_status_key`. It is exactly what the dashboard writes for **"If the lead does not reply → After N hrs → Move to"** on a stage (the UI offers hours only; the API also takes minutes and days — any other unit is silently read as hours). Write it with `set_status_timeout_rule`, or inline through `update_workflow_status` / `create_workflow`; read it back with `list_status_timeout_rules`.
- **Clock.** Time since the lead *entered* the status (`status_changed_at`), 24/7, scanned every 2 minutes. A reply does **not** reset it — only a status change does — so a waiting status must be one the agent leaves when the lead writes back (entry hints on the active stages), or a live conversation gets timed out.
- **Where it lives.** Any non-terminal, non-goal status; it is inert on a terminal or goal stage, so the wait always sits on the status *before* the exit. `target_status_key` must be a status **in the same workflow** — this is the platform's only status→status pointer (transfers cannot aim at a specific status in another agent, see §9). The three core stages accept it (`contacted --72h--> …` is the usual "never replied" exit).
- **Delayed handoff.** Aim it at a terminal status that carries `transfer_config` and the transfer fires on arrival. **Chain** statuses for multi-step waits — each hop has its own timeout and each timer starts on entry: `engaged --4h--> silent_4h --8h--> silent_12h --…--> handoff_followup (terminal → follow-up agent)`. Recipe 23.
- **Skipped.** Leads in human support, paused runs/agents, sandbox runs — and a *terminal* target while the lead has an upcoming meeting.
Never model a per-lead wait with a scheduled function, cloud function, background job or recontact `on_exhausted`: none of them can transfer, none is visible in the stage editor, and all of them duplicate a timer the platform already runs.
### Native member assignment (`assignment_config`)
`{ "mode": "round_robin" | "least_loaded", "agent_ids": ["<user_uuid>"], "restrict_booking_to_assignee": false }` on at most one stage: entering it assigns a human team member (empty `agent_ids` = the whole team; eligible roles are owner/admin/human agent). It does not pause the AI. When "assign sales reps round-robin" means rotating among the customer's own team members *in Nexor*, this native config is the entire answer — no endpoint, no tool. Reach for a client endpoint only when an external system owns the rotation (see the round-robin recipe).
---
## 2. Intake: workflow fields vs metadata
Nexor has **two** custom-data stores. Confusing them is the most common design error.
### Workflow fields — "what the agent should collect"
Per field: `key`, `label`, `description`, `type` (`text`, `number`, `integer`, `currency`, `boolean`, `date`, `datetime`, `email`, `phone`, `select`, `multiselect`, `url`), `required`, `options[]`, `extraction_hints`, `validation`, plus `metadata_key` and `intake_value_map`.
The prompt renders required fields as PENDING and optional ones as "collect if it comes up naturally." The agent saves values with `save_field` (a runtime tool — not callable over MCP; the operator-side write is `update_lead`); values live per workflow run (not in metadata). Status advancement gates on **fields** (`variable_refs`, `required_field_keys`) — never on metadata.
### Lead metadata — "data that arrives with or about the lead"
Free-form JSON on the lead. Written by:
- The public API at creation/upsert (shallow merge — existing keys survive, incoming keys overwrite).
- The agent mid-conversation via the `patch_metadata` tool (supports dotted paths like `address.city`). Contact data (email/phone/name) goes through `update_lead` instead — metadata does not change where messages are sent.
- Functions (`updateMetadata`), background jobs (`enrich_lead` with `target: "metadata.<path>"`), and post-tool hooks that write a value from a tool's JSON response into a metadata key.
- Bulk: `PATCH /api/public/leads/metadata` with `{ selector: "id"|"email"|"phone"|"external_id", values: [...], patch: {...} }` (up to 1000 per call).
The agent **reads** the full metadata object — it is injected into the prompt with the lead — and HTTP tool templates can reference `{{lead.metadata.X}}`.
### Incoming sync mapping: standard columns vs metadata
Resolve direction before mapping a request described as “sync.” If information is coming **into Nexor**, split the payload deliberately:
| Incoming value | Destination | Why |
|---|---|---|
| First name | `first_name` | Standard lead identity used throughout the product |
| Last name | `last_name` | Standard lead identity |
| Email | `email` | Standard contact destination and upsert key |
| Phone number | `phone` | Standard contact destination and upsert key |
| Any other fact the agent should know on later executions | `metadata.<key>` | Free-form lead context injected into the agent prompt |
For one existing lead, call `PATCH /api/public/leads/:id` (or the compatible `PUT`) with the standard keys at the top level and a `metadata` object for custom facts. Both methods shallow-merge `metadata`: unspecified existing keys survive and incoming keys overwrite matching keys. The MCP `update_lead` tool expresses the same contract as `{ lead_id, fields: { first_name?, last_name?, email?, phone? }, metadata: { ... } }`.
For several existing leads, use `PATCH /api/public/leads/metadata` with a selector and a non-empty patch. For creation/upsert, send the same split to `POST /api/public/leads`: standard columns at the top level, custom context in `metadata`.
A successful metadata write affects the **next agent execution**. It does not rewrite an already-running model call, change the email/phone used for delivery, or send data to an external system. When information is going **out of Nexor**, use an outbound webhook, status automation, cloud function, workflow tool, or scheduled function according to the trigger and cadence; metadata can supply payload values but is not the transport.
### The bridge: `metadata_key`
Set `metadata_key` on a workflow field to mean: "if the lead arrives with this metadata key, pre-fill the field instead of asking." `intake_value_map` normalizes the incoming vocabulary (e.g. `"SMB"` → `"small_business"`). One direction only: metadata → field. `save_field` does not write back to metadata.
**Rule of thumb:** "the agent must ask for X" → workflow field. "Our CRM sends X and the agent should know/use it" → metadata at creation, plus a `metadata_key` mapping if the agent would otherwise ask for it.
### Which reader can see which store
Choosing the store is not cosmetic — most readers can see exactly one of them. Check this table before deciding where a value lives; a fact needed by readers on both sides must exist in both stores.
| Reader | Workflow fields | Lead metadata |
|---|---|---|
| Status gates: `required_field_keys`, `requires_all_fields`, `transition_rules` | Yes | **No** |
| Status automation `args_template` | **No** | Yes (`{{lead.metadata.<key>}}`) |
| Outbound webhook payload / `payload_template` | **No** | Yes (the full lead is in the envelope) |
| HTTP tool `headers` / `query_parameters` / body templates | **No** | Yes (`{{lead.metadata.X}}`) |
| The agent's prompt | Yes (rendered as PENDING/collected) | Yes (full object injected) |
| Background job filters (`lead_filters`) | **No** | Yes (`lead.metadata.<key>`) |
| Background job steps | Yes (`evaluate_field`) | Yes (`evaluate_metadata`) |
| Scheduled function `lookup_config` | **No** | **No** (lead columns and `tags` only) |
| Function code (`ctx`) | Only what the event carries (`information.*` payload, `ctx.changed_fields`) | Yes (`ctx.lead.metadata`, `ctx.leads[].metadata`) |
The two asymmetries that break designs: a gate can never read metadata, and a deterministic sender (status automation, webhook, tool template) can never read a collected field value. When a status gates on `field_x` and an automation on that same status must transmit it, mirror the value into metadata — via a post-tool hook or the agent's `patch_metadata` — **before** the lead can reach that status. Bridging the other direction (`metadata_key`) only pre-fills fields at intake; `save_field` never writes back to metadata.
---
## 3. Integration endpoints: deterministic callers and workflow HTTP/MCP tools
A workflow HTTP/MCP tool defines how Nexor calls a client endpoint. Do not assume the AI agent should be the caller. Choose the caller by reliability and timing:
1. **Status change:** prefer a filtered outbound webhook when the client can act from the standard event, or a status automation when Nexor must run the endpoint exactly once with templated arguments (§5).
2. **Field/variable or other lead event:** prefer a cloud function on the narrowest event, such as `information.collected`, `information.updated`, or `lead.updated`, and guard on the relevant key/change (§6).
3. **Needed before the agent's first message:** make the tool a pre-execution hook with `set_workflow_tool_execution({ workflow_id, tool_id, mode: "on_entry" })`. It fires when the lead enters the workflow, ahead of the first agent message, with no model decision.
4. **Active-turn dependency:** let the agent call the tool only when it must receive the response before it can decide what to say or do next and no state trigger can run the integration first.
The first three paths are deterministic: the platform invokes them even if the model would have forgotten, skipped, or hallucinated a tool call. The client endpoint still owns the custom behavior in every path.
### Execution modes: `on_entry` vs `agent_decides`
`set_workflow_tool_execution` is the switch, and the two modes are mutually exclusive:
| | `on_entry` | `agent_decides` |
|---|---|---|
| Fires | On entry to the workflow, before the first agent message | When the model chooses to call it |
| Stage gating | **Rejected** — passing a non-empty `available_in_statuses` returns `INVALID_INPUT` | `available_in_statuses` gates it; `null`/omitted = every status |
| Reliability | Deterministic | Depends on model selection |
| Use for | Enrichment, eligibility, account context, priming a transfer target on arrival | Anything needing a conversational decision or arguments from the dialogue |
The underlying flag is `auto_run_on_entry`, also settable directly on `create_workflow_tool` / `update_workflow_tool`. Because the hook runs on *workflow* entry rather than status entry, it fires again for a lead transferred into a target agent — which is exactly how a target gets its context ready before it speaks. `call_once` still bounds successful executions per run, and `is_active` is an independent kill switch.
Core `workflow_tools` fields:
- **Identity and selection:** `name` (immutable snake_case), `description` (what it does and the exact condition for calling it), `is_active`.
- **Request:** `url`, `method` (`GET` | `POST` | `PUT` | `PATCH` | `DELETE`), `parameters` (JSON Schema), `headers`, `query_parameters`, `timeout_ms` (1,000–120,000; default 10,000).
- **Control:** `available_in_statuses` (status keys where execution is permitted), `call_once` (at most one successful call per tool per workflow run), `llm_response_fields` (response paths retained for the model).
Example — define the client's HubSpot round-robin endpoint as a backing tool, then invoke it from a status automation rather than relying on the agent:
```json
{
"name": "assign_sales_rep",
"description": "Assign the qualified lead to the next eligible sales representative in the client's CRM. This is run automatically when the lead enters qualified; do not call it conversationally or choose a representative yourself.",
"url": "https://customer.example.com/integrations/hubspot/assign",
"method": "POST",
"timeout_ms": 10000,
"parameters": {
"type": "object",
"properties": {
"lead_id": { "type": "string" },
"territory": { "type": "string" },
"product": { "type": "string" }
},
"required": ["lead_id", "territory", "product"]
},
"headers": { "Authorization": "Bearer {{env.HUBSPOT_ASSIGNMENT_TOKEN}}" },
"available_in_statuses": ["qualified"],
"call_once": true,
"llm_response_fields": ["rep_id", "rep_name", "rep_email", "assignment_id"],
"is_active": true
}
```
```json
{
"status_automations": [{
"key": "assign_rep_on_qualified",
"on_status_key": "qualified",
"action": "run_tool",
"tool": "assign_sales_rep",
"args_template": {
"lead_id": "{{lead.id}}",
"territory": "{{lead.metadata.territory}}",
"product": "{{lead.metadata.product}}"
},
"skip_if_metadata": "sales_assignment_id"
}]
}
```
`args_template` placeholders support `{{lead.<path>}}` / `{{lead.metadata.<key>}}` only — collected workflow-field values are **not** addressable. Pass `lead_id` plus stable lead/metadata paths and let the endpoint look up the rest, or mirror the needed field values into metadata (post-tool hook or the agent's `patch_metadata`) before the lead can reach the triggering status.
The endpoint should return a stable contract such as:
```json
{
"success": true,
"rep_id": "rep_123",
"rep_name": "Alex Rivera",
"rep_email": "alex@example.com",
"assignment_id": "assign_456"
}
```
HubSpot or the client service remains the source of truth for rotation state, eligibility, territory, capacity, and deduplication. The status transition is the reliable Nexor trigger. Persist `assignment_id` to `metadata.sales_assignment_id` with a post-tool hook so the result is auditable and later turns can use it. Do not reproduce the algorithm in the agent prompt, a status hint, or a Nexor function.
### Stage-based tool access
`available_in_statuses` is a structural execution gate. A non-empty array allows the tool only when the lead's live current status key is in the array; `null` or `[]` means unrestricted. The gate applies across messaging and voice tool execution. A status transition can unlock the tool within the same turn.
For "qualify first, then assign, show availability, and book":
1. Define the qualification fields and make them required.
2. Configure status `qualified` with `requires_all_fields: true` and the exact `required_field_keys`.
3. Attach `assign_sales_rep` to the `qualified` status automation so assignment fires without an agent decision. Keep `available_in_statuses: ["qualified"]` and `call_once: true` as additional protection.
4. Set `available_in_statuses: ["qualified"]` on conversational availability and booking tools when qualification is their only prerequisite. If they require the completed assignment response, gate them to a later `assignment_ready` status instead. Gate cancellation/rescheduling tools to the booked status if their lifecycle differs.
5. Keep the qualification prerequisite in each conversational tool description and the agent instructions for good selection behavior, but treat the status gate as the enforcement boundary.
6. Before activation, enter `qualified` without prompting the agent to assign anyone and prove the automation reaches the test endpoint exactly once. Also call availability/booking while unqualified and expect `tool_not_available_in_stage`; after qualification the same valid calls may reach their endpoints.
If availability or booking requires the completed assignment result, do not unlock those tools in the same `qualified` status and hope the agent waits. Add an `assignment_ready` status and gate them there. Note that a status automation can only run a tool — it cannot move the lead — so the transition into `assignment_ready` needs a cloud function: either one triggered on `workflow.status_entered` for `qualified` that calls the assignment endpoint with `axios`, persists the returned IDs, and sets `assignment_ready` only on success; or, if the status automation makes the call, a post-tool hook that persists `assignment_id` to metadata plus a `lead.updated` function guarded on that key that performs the transition. A failed call leaves the lead in `qualified`. This makes the dependency structural instead of conversational.
Use `call_once: true` for assignment, deal creation, or booking when a second successful call in the same workflow run would be wrong. Leave it false for read-only availability/search tools that may need a legitimate refresh. Make every side-effecting endpoint idempotent anyway, keyed by a stable lead/workflow operation identifier.
On failure, instruct the agent not to invent the result. It should explain the temporary problem, retry only under the configured policy, or hand off to a human. Store durable external IDs through a post-tool hook when later steps or deduplication need them. If a deterministic action's result must be available before the agent continues, persist it and unlock a subsequent status; use an agent call only when the platform cannot satisfy that immediate dependency.
---
## 4. Getting leads in: the public API
### `POST /api/public/leads`
Auth: `X-API-Key` header. Accepts one object or an array of up to 1000.
Fields: `first_name` (required), `last_name`, `email`, `phone`, `company`, `title`, `source`, `external_id`, `metadata`, `tags`, `workflow_id`, `campaign_id`, consent flags, and first-contact controls.
`metadata` is the default way to give an agent custom information about a lead. Its root is a JSON object with arbitrary customer-defined keys; each value may be any valid JSON value, including a string, number, boolean, null, array, or nested object. Nexor injects the complete metadata object into the runtime agent prompt across channels, preserving nested values. Pure context needs no workflow field, knowledge base, tool, or function. Add a workflow field with `metadata_key` only when the agent must ask for, validate, normalize, or gate on that value. Metadata updates shallow-merge top-level keys: untouched top-level keys remain, but replacing part of a nested object requires resending that complete nested object.
- **Upsert:** matches by email first, then normalized phone. On match: non-empty fields overwrite, metadata shallow-merges, response says `existed: true`. Duplicate entries within one batch resolve to one lead.
- **Enrollment:** pass `workflow_id` to enroll the lead and start the agent's cadence/first contact immediately. Controls: `skip_first_message: true` (enroll silently), `force_first_channel: "call"|"whatsapp"|"email"|"sms"`, or `force_first_message: { channel, content }` (send a literal opener instead of the agent's own). If the workflow is paused, enrollment waits for unpause. Without `workflow_id` the lead is created inert.
- A per-API-key `intake_field_map` can translate an arbitrary vendor payload shape into the canonical fields, so a third-party webhook can point at this endpoint unmodified.
### Inbound lead webhook (no API key)
`POST /hooks/<unguessable-token>` — a branded intake URL the customer pastes into a form builder, Zapier, or their CRM. Config per hook: `name`, `field_mapping`, `workflow_id`, `deduplicate`, `is_active`. Use when the sending system can't set headers.
---
## 5. Notifications: outbound webhooks and status automations
Two mechanisms fire when a lead reaches a status. Pick by delivery contract:
| | Outbound webhook | Status automation |
|---|---|---|
| Fires | Every matching event | Exactly once per lead per rule |
| Configured | Client-level subscription + filters | Per status, in workflow config |
| Body | Standard envelope or `payload_template` | Any HTTP tool + `args_template` |
| Use for | "Tell my external API whenever…" (logging, mirroring, alerting) | "Do this in my CRM / external API once when the lead gets here" (create CRM deal, trigger fulfillment) |
Both are more reliable than instructing the agent to notice the status and call a tool. The status transition itself invokes them. Prefer these whenever the transition fully defines the action timing.
### Outbound webhooks
Subscription config: `name`, `url` (HTTPS only), `events[]`, `filters[]` (up to 10, ANDed: `{ field, operator, value }`), `auth_type` (`none` | `header` | `basic` | `custom_headers`), `payload_template` (optional), `include_full_lead`, `signing_secret` (HMAC-SHA256, shown once), `retry_enabled` / `max_retries`. The dashboard's per-status "Attach webhook" (pipeline column menu) creates exactly this: a client-level subscription auto-filtered on `workflow_id` + `to_status.key`.
Events: `workflow_run.status_changed`, `meeting.created`, `meeting.updated`, `meeting.cancelled`, `meeting.completed`, `meeting.no_show`, `meeting.confirmed`, `outreach.failed`.
**Scope to a status with filters, not per-status config:** filter fields include `to_status.key`, `to_status.category`, `from_status.key`, `workflow_id`, `trigger_type`, `actor_type`. Operators: `equals`, `not_equals`, `in`, `not_in`, `contains`.
The `workflow_run.status_changed` payload includes the from/to status (key, label, category), the trigger (`trigger_type`, `actor_type`), the full lead (including metadata), the workflow, and the run — enough for most receivers without a follow-up API call. `payload_template` reshapes it with `{{ dot.path }}` tokens over that payload (`lead.*` including metadata, `status_change.*`, `workflow.*`); collected workflow-field values are not part of the event payload.
Delivery: asynchronous (typically seconds), at-least-once, signed (`X-Nexor-Signature`), retried on network errors/429/5xx (≈1m / 5m / 30m), not retried on other 4xx. Design the receiver to be idempotent on `delivery_id`. Caveat: a filter whose field is absent from an event's payload *passes* — subscribe a status-filtered webhook to status events only, not mixed with meeting events.
### Status automations
`status_automations[]` lives in `workflows.config` and has **no dedicated MCP tool** — write it with `update_workflow_config({ workflow_id, config: { status_automations: [...] } })`. The config bag merges at the top level, but the array is replaced wholesale: read `get_workflow` and send the complete array on every change. The `tool` value references a workflow tool **by name**, so the tool must exist first and must never be renamed afterwards.
Each rule:
```json
{
"key": "push_to_crm",
"on_status_key": "qualified",
"enabled": true,
"action": "run_tool",
"tool": "<name of a workflow HTTP/MCP tool>",
"args_template": { "email": "{{lead.email}}", "income": "{{lead.metadata.monthly_income}}" },
"skip_if_metadata": "crm_deal_id"
}
```
`run_tool` is the only supported `action`. Fires when the lead enters `on_status_key`; exactly once per (workflow, lead, rule key) — **forever**: re-entering the status never re-fires (idempotency ledger). One automatic retry on failure. `args_template` placeholders are `{{lead.<path>}}` / `{{lead.metadata.<key>}}` only; a missing template value aborts the rule rather than sending a broken body. Pair with a post-tool hook to write the response (e.g. a created CRM id) back into metadata.
---
## 6. Cloud functions (event-driven JS)
One lead, one event, custom JavaScript — the most flexible outbound integration path: native access to the lead that triggered the event, plus `axios` to call any external endpoint with any payload. Config: `name`, `description`, `trigger_event` (locked after creation), `code`, `is_active`.
Use an event cloud function instead of an agent tool when a field/variable or lead event defines the action. Select the narrowest trigger and guard the code to the relevant field/key so unrelated updates do not repeat the side effect. This preserves deterministic invocation while allowing custom computation or request shaping.
Triggers: `lead.created`, `lead.updated`, `lead.status_changed`, `lead.assigned`, `lead.tag_added`, `lead.unsubscribed`, `information.collected`, `information.updated`, `message.received`, `message.sent`, `call.completed`, `handoff.requested`, `conversion.detected`, `meeting.created`, `meeting.rescheduled`, `meeting.completed`, `meeting.no_show`, `task.created`, `task.executed`, `workflow.run_started`, `workflow.status_entered`, `workflow.status_left`, `workflow.run_completed`.
Runtime: `axios` and `fetch` are pre-injected globals (no imports); `env.KEY` reads Environment Variables; `ctx` carries the event and current lead at `ctx.lead`, also exposed as `lead` — update-type events include the changed keys (`ctx.changed_fields`) for guarding. Read that native lead directly; never GET or list `/api/public/leads` to reconstruct the event record. Limits: 5-minute deadline, 20 outbound requests per run.
For the event's own lead, use `updateLead(patch)` and `updateMetadata(patch)`. The buffered `effects` / `ctx.effects` surface also supports `assignToWorkflow(leadId, workflowId, reason?)`, `setLeadStatus(leadId, statusKey, workflowId?)`, `tagLead(leadId, tags)`, `assignLead(leadId, userId)`, `upsertLead(lead)`, `updateLead(leadId, patch)`, `updateMetadata(leadId, patch)`, `deactivateWorkflowRun(leadId)`, and `updateEnvironmentVariable(key, value)`. Effects apply only after the code finishes without throwing; cap 100.
**No chaining:** effect-driven updates do not re-emit events (recursion guard). A design that needs "event → change → another event reacts" belongs in a scheduled function.
Manual test runs preview effects without applying them — but `axios`/`fetch` calls are real even in a dry run.
---
## 7. Scheduled functions (cron-driven JS)
A cohort sweep — and the most flexible *inbound* integration path: resolve a trusted lead cohort, call any external API with `axios`, and create or edit leads with custom metadata, all in editable JavaScript. Config: five-field `cron_expression` + IANA `timezone`, a `lookup_config` query selecting the cohort, `max_leads_per_cycle` (1–500), `code`, `is_active`.
The dashboard's Supabase-style chain is an authoring representation. Persist the equivalent trusted JSON spec in `lookup_config`, for example:
```json
{
"leads": {
"query": {
"filters": [{ "column": "source", "op": "eq", "value": "referral" }],
"order": [{ "column": "created_at", "ascending": true }],
"limit": 500
}
}
}
```
Direct query operators are `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `like`, `ilike`, `is`, `in`, and `contains`; relative times use `{ "rel": { "unit": "days"|"hours", "value": n } }` and are recomputed each run. Filterable lead columns include contact fields, `source`, `external_id`, `created_at`, `last_contacted_at`, `last_response_at`, message counters, boolean flags, and `tags`. Leads do not store `workflow_id` or `current_status_id`; those belong to `workflow_runs`. For the current membership cohort, add `{ "column": "workflow_runs.workflow_id", "op": "eq", "value": "<workflow-id>" }` and `{ "column": "workflow_runs.is_active", "op": "eq", "value": true }` to the same query filters array. A status filter uses `workflow_runs.current_status_id`. The runner compiles all `workflow_runs.*` filters into one tenant-scoped relational `EXISTS`, so they match the same run. The `workflow_id` returned on each resolved `ctx.leads` row is a derived convenience projection from its active run, not a leads-table column. Always preview the cohort before activation.
Context: `ctx.leads[]` (each with `id`, contact fields, `tags`, `metadata`, `status`, `workflow_id`), `ctx.effects`, plus the same `axios`/`fetch`/`env`/`helpers` surface — and the same per-run outbound request limit — as cloud functions. The trusted lookup is the lead-read boundary: function code consumes or further filters this array and never GETs/lists `/api/public/leads` to resolve the same cohort. Every effect names its lead — there is no "the" lead. Batch calls to external systems instead of issuing one request per lead when their API supports it.
Effects API: `upsertLead({email?, phone?, firstName?, lastName?, source?, metadata?})`, `updateLead(leadId, patch)`, `updateMetadata(leadId, patch)`, `assignLead(leadId, userId)`, `assignToWorkflow(leadId, workflowId, reason?)`, `tagLead(leadId, tags)`, `setLeadStatus(leadId, statusKey, workflowId?)`, `deactivateWorkflowRun(leadId)`. Buffered, applied after successful completion, cap 2000.
Chaining differs from cloud functions: `assignLead` re-emits events (a sweep can assign and let an event function react per lead), but `tagLead` does **not** emit a tag event.
**Syncing leads in from an external tool (CRM, spreadsheet, own backend)** — two working shapes:
1. **Atomic import exception:** fetch from the external API with `axios`, then call `POST /api/public/leads` (API key stored in an Environment Variable) for each record only when the same run must perform upsert + metadata merge + `workflow_id` enrollment + first contact. This direct API path exists because the effect-based upsert does not return the new id; it is not a lead-read path.
2. **Two-phase effects:** run A calls `upsertLead` with a marker (`source: "crm_sync"`); run B's lookup filters on that marker and calls `assignToWorkflow` / `setLeadStatus` on the now-existing leads. Needed because effects return nothing — a function cannot learn a new lead's id in the same run.
Manual runs apply buffered effects just like a scheduled tick and require explicit operator confirmation. Preview the cohort separately and read that list before running or activating anything that messages people. (`assignToWorkflow` starts the target agent's cadence.)
---
## 8. Background jobs (declarative cohort automation)
"Filter leads → condition → action" without code.
- **Triggers:** `trigger_type: "cron"` (five-field expression, evaluated in UTC) or `"event"` with `event_config` — tag events are the reliable event trigger, with match types exact/contains/starts-with/ends-with/regex and `date_in_tag` (extract a date from the tag and act before/after it). Event jobs can be narrowed by source workflow and status.
- **Filters:** `lead_filters` over `lead.email | lead.phone | lead.source | lead.created_at | lead.metadata.<key>` (`eq`, `neq`, `in`, `not_in`, `is_null`, `is_not_null`, `gt`, `lt`, `gte`, `lte`, `contains`); `workflow_filters` (`{never_had_workflow}`, `{has_active_workflow}`, or `[{workflow_id, status_keys}]`); `exclusions: { skip_human_takeover, skip_paused }` (keep both on).
- **Steps (max 3):** `execute_tool` (call an HTTP tool, feed `response.*` into a condition), `condition`, `evaluate_metadata`, `evaluate_field` (over collected workflow fields), `evaluate_tag` (`date_in_tag`, `trigger_when: "future"|"past"`), `action`. Metadata/field operators include `exists`, `equals`, `greater_than`, `less_than`, `date_after_today`, `date_before_today`. **A branch step runs its selected nested action and then ends that lead's chain** — later top-level steps do not resume.
- **Actions:** `send_message` (`channel`, approved `template_name`, `then_status_key` — the send-once mechanism), `set_lead_status`, `assign_workflow`, `workflow_transfer` / `force_transfer`, `enrich_lead` (map values into lead columns or `metadata.<path>`), `deactivate_workflow_run`.
- **Safety:** `max_leads_per_cycle` (cap 500), `cooldown_minutes` (blocks re-acting on the same lead), `lead_concurrency`, `lead_delay_ms`, `dry_run: true` to preview. "Run Now" executes even an inactive job — a successful manual run does not prove the schedule is on.
**Not currently functional — do not design on:** `trigger_type: "api"`, `filter_mode: "workflow_scoped"` (use `lead_scoped` + `workflow_filters`), `rate_limit_per_hour`, the `ai_*` fields, and CRM-event triggers other than tags/payments.
---
## 9. Workflow transfers
The agent-to-agent handoff. Configured as `transfer_config: { "target_workflow_id": "<uuid>", "copy_fields": true }` on a **terminal** status (transfers auto-fire from the terminal-status path; a non-terminal status will not reliably trigger one). The target must belong to the same client and differ from the source workflow. `copy_fields` is accepted by the API but currently inert — the platform always snapshots all source fields into the transfer chain regardless. Cohort-scale transfers use the background-job `workflow_transfer` action; one-off transfers are available via API.
What the platform does on transfer:
1. Cancels the source run's queued cadence and re-contact tasks; deactivates it with outcome `transferred`.
2. Creates a **fresh** run on the target workflow, **always starting at the target's initial status** — `transfer_config` cannot aim at a specific status in another agent (the only status→status pointer is `timeout_config.target_status_key`, same workflow only). Never reactivates an old run, so A→B→A is safe.
3. Reassigns conversation threads so inbound messages route to the new run.
4. Builds a **transfer chain**: a snapshot of the source run's collected fields, accumulated across hops (agent C sees fields from A and B). Fields are **not** copied into the target's own field values — schemas differ by design; the target agent reads the chain.
5. First contact: if the messaging window is open, the target agent composes a contextual AI transition message; otherwise the target's template cascade runs. Never also aim a "welcome" automation at freshly-transferred leads — the handoff already speaks.
Design rules:
- Every hop restarts cadence. If the "transfer" is a stage of the same conversation, it's a status, not a transfer.
- The default shape of an agent is 2–3 modes split by intent and register (sell → follow up → discount), each behind a terminal transfer; one mode only on the operator's explicit, quoted instruction (`plan.decomposition.single_mode_explicitly_requested`), never five or more. `review_agent_system_plan` gates both ends deterministically.
- Auto-transfer is skipped while the lead is waiting on a human (support handoff).
- The target agent's prompt should explicitly read the transferred context ("budget and need are in the transfer chain — do not re-ask").
- The source agent does not speak the hand-off. Its prompt moves the lead to the boundary status when the criterion is met and ends the turn — no "I'll pass you to a colleague", no "the link is on its way", no farewell. The platform makes the target speak next (step 5); a narrated hand-off produces two voices and a promise the source cannot keep.
- The target agent's prompt carries an explicit arrival rule — what the lead already did, which transferred facts are never re-asked, and what the very first message is — because that message is composed from the target's own prompt when the window is open. A payment-link target's arrival message is one line of context plus the payment link (recipe 22); configure `set_payment_link` first, since both the built-in transactional tool and `{{payment_link}}` templates read it.
- Editing one member of an agent group starts by reading the group: the siblings, the statuses that transfer into and out of the edited agent, and the shared channel bindings. Both sides of every boundary must stay consistent.
### Boundary shapes: terminal handoff vs pause handoff
An identity switch (tone, persona, goal, tool set, channel mix, contact intensity) is always reached through a status the lead *exits* through. Two supported shapes:
Two conditions are checked *before* `transfer_config` is read, on every path that can fire a transfer (agent messaging, voice, status timeout, document transitions):
1. `is_terminal === true`. A non-terminal status carrying a `transfer_config` saves without error and never fires.
2. The status key is not a **soft terminal** — `future_*`, `contact_later`, or `colder`. These are excluded from the terminal side-effect path entirely, so a correctly terminal status with a valid `transfer_config` still will not transfer if you named it `future_nurture`.
Auto-transfer is additionally skipped while the lead is in human support (`in_support: true`); a manual admin-initiated transfer bypasses that gate.
| | Terminal handoff | Pause handoff |
|---|---|---|
| Status config | `is_terminal: true` + `transfer_config`, key not soft-terminal | `pause_bot: true`, no auto-fire |
| Who executes the transfer | The platform, on entry to the status | You must name it: background-job `workflow_transfer` / `force_transfer`, the one-off transfer API, or a human operator |
| Source run | Deactivated with outcome `transferred` | Stays live but silent until the executor runs |
| Use when | The routing criterion is fully known at the boundary — including *elapsed time*: put `timeout_config` on the waiting status and aim it at this terminal status | A human decision or an external signal that has not arrived yet. Not a wait: elapsed time is a status timeout, never a pause + job |
Terminal is the default; reach for the pause shape only when something outside the conversation must decide. A pause boundary without a named executor leaves the lead parked and silent indefinitely — verify the executor moves a test lead before activating. The pause shape is structurally safe: status `pause_bot` does not set `workflow_runs.is_paused`, so a background job carrying the standard `exclusions: { skip_paused: true }` still selects these leads.
Whichever shape, exactly one agent owns a lead at a time, the target starts at its own initial status, and its cadence begins from scratch. Do not attempt an identity switch inside a single agent's prompt: prompt text cannot change the tool set, the channel mix, the cadence, or the pipeline.
### Connection closure for multi-agent builds
Before creation, record every intended handoff as `{ source_agent_ref, source_status_key, target_agent_ref }`. Agent creation order is irrelevant because the connection is written only after the target has a real workflow id. Once all referenced ids exist, iterate the complete manifest and update every source terminal status with `transfer_config.target_workflow_id` set to the exact target id. If a target was created after its source, return to the source and apply the transfer then.
Finalization requires a read-back audit of every involved source agent. For each manifest row, verify that the source status is terminal, has a `transfer_config`, and points to the exact expected target id. Repair and re-read until no expected edge is missing, unresolved, or misdirected. A list of successfully created agents is not proof of a connected system; do not report success or activate it while any expected edge fails this audit.
---
## 10. Cadence: contact windows and outreach intensity
The outreach scheduler is **block-based**, edited in the dashboard's Contact Schedule section: time windows (`blocks[]`) plus per-channel intensity (`dayConfig`). The legacy step sequence (`cadence_steps`) is deprecated — never design on it. Endpoint: `GET/PUT /workflows/:id/block-config` with `{ blocks, dayConfig, timezone }` (public mirror `/api/public/workflows/:id/block-config`); via MCP, `get_workflow_cadence` / `set_workflow_cadence`.
Save semantics: `dayConfig` keys you omit **are preserved** (the server falls back to the existing value, then a default) — but `blocks` are upserted as a document. Always `get_workflow_cadence` first and send the full edited object; that is correct for both halves and is what the tool description requires.
### Windows (`blocks[]`)
`{ "block_index": 0, "label": "Working hours" (required), "start_hour": "09:00", "end_hour": "18:00", "days_of_week": [1,2,3,4,5], "is_enabled": true, "config": {} }` — `days_of_week` uses 0=Sunday…6=Saturday; hours are `"HH:MM"` (a bare `"18"` rejects the whole save, and validation runs before anything is deleted). Windows must not overlap; per-weekday "custom" schedules emit one block per day.
### Intensity (`dayConfig`)
- `max_days` (default 5): how many days the cadence pursues a lead; after that the run goes `colder`.
- `initial_/min_<calls|templates|emails|sms>_per_block` — WhatsApp = `templates`; touches decay linearly from `initial` to `min` across days. Touches interleave round-robin (`WhatsApp → call → WhatsApp → …`) with `template_to_call_delay_minutes` (default 3) between them.
- `daily_template_limit` (1000) / `hourly_template_limit` (100) — WhatsApp templates only.
- New agents use `config.contact_caps_mode: "automatic_v1"`: per-block counters are **ignored** and caps derive from window length (≤4h → 1 touch per channel, ≤6h → 2, longer → 3). An enabled channel never gets a zero cap — turning a channel off is `workflows.config.disabled_channels`, not a zero.
### The channel-hours rule ("WhatsApp 24/7, call only in business hours")
- **Calls are always gated to the block windows** — hard-coded, never 24/7. With no blocks configured, a failsafe window of 09:00–21:00 applies.
- **WhatsApp, email, and SMS run 24/7 by default.** Setting `workflows.config.gate_outbound_to_hours: true` gates them to the windows too — but the flag is **workflow-wide**; per-channel windows are not currently configurable.
- Appointment reminders are never gated (event-anchored).
- **Inbound replies bypass scheduling entirely**: a reply runs the agent immediately and cancels pending cadence (`cadence_state: "responded"`). Windows govern outbound initiative only.
### First contact and recontact
- Fresh runs fire an immediate "hot contact" cascade (default order whatsapp → call → email → sms; `workflows.config.first_contact_channel` picks the opener). The WhatsApp step fires at any hour; the call step waits for a window.
- Recontact (re-engage) is a second scheduler with its own per-workflow policy — `{ max_attempts (1–10, default 3), stale_after_hours (default 4), cooldown_hours (default 24), on_exhausted: "mark_cold" | "pause_run" | "nothing" }` — plus per-status rules (`trigger_status_key`, `channel_override`, `prompt_hint`, one active rule per status). It obeys the same channel-hours rule.
### What is NOT expressible
There is **no weekly frequency cap** — no "email at most N per week" knob exists for any channel. The real caps are per-block intensity, per-day (`max_days`, `daily_template_limit`), per-hour (`hourly_template_limit`), and per-recontact-rule (`max_attempts` + `cooldown_hours`). Approximate weekly-style limits with per-block intensity across `max_days`, or move that channel out of cadence into a background job with `cooldown_minutes`. Never promise a per-week guarantee.
### Timezone
`workflows.timezone` is the single source of truth (default `America/Santiago`). `timing_config.business_hours` is a **derived mirror** of the blocks, recomputed on every save — never present it as hand-editable, and never write it directly.
### Qualification router: fan out by outreach intensity
Use one qualification agent to collect a common set of fields, then create one terminal status per materially different follow-up motion. The source status selects the target; each target workflow owns its prompt, tools, channels, and cadence. The JSON below is valid as a `create_workflow` statuses array (it is also the stored/`update_workflow_status` shape) — the only part that must wait for a follow-up `update_workflow_status` pass is `transfer_config.target_workflow_id` when the target agent does not exist yet.
```json
[
{
"key": "qualified_now",
"label": "Qualified Now",
"category": "won",
"is_qualified": true,
"is_terminal": true,
"entry_hint": "The lead meets all qualification requirements and wants to act in the current buying window.",
"variable_refs": ["fit", "timeline", "need"],
"requires_all_fields": true,
"required_field_keys": ["fit", "timeline", "need"],
"transfer_config": { "target_workflow_id": "<high-intensity-sales-agent>" }
},
{
"key": "qualified_later",
"label": "Qualified for Later",
"category": "deferred",
"is_qualified": true,
"is_terminal": true,
"entry_hint": "The lead is a fit but their confirmed buying timeline is later; route them to long-term nurture.",
"variable_refs": ["fit", "timeline", "need"],
"requires_all_fields": true,
"required_field_keys": ["fit", "timeline", "need"],
"transfer_config": { "target_workflow_id": "<low-intensity-nurture-agent>" }
}
]
```
Keep the `entry_hint` conditions mutually exclusive and exhaustive for the qualified population. Add separate unqualified/discard branches as needed; do not force every lead into one of these two.
Configure the targets independently:
- **High-intensity sales:** use an appointment/sales goal, a prompt focused on acting now, only the tools needed for ownership/availability/booking, and an appropriately intensive block cadence.
- **Low-intensity nurture:** unless the customer says otherwise, a bare "nurture" (or "nurturing") ask means a **separate, low-intensity, email-only** workflow that reaches out roughly **once every 2-4 weeks** — not the sales cadence slowed down, and not the same agent on more channels. Default to email as the only outbound channel and one message per 14-28 days. Use a nurture-specific prompt, restrict channels to the intended channel mix, and configure a low-frequency recontact rule. For email every 2-4 weeks, use `stale_after_hours` and `cooldown_hours` of `336` (14 days) up to `672` (28 days), configure a usable email sender, set `workflows.config.disabled_channels: ["call", "whatsapp", "sms"]` so email is the only option, and choose `max_attempts` from the campaign horizon. This makes the lead eligible at that spacing; the re-engage decision may still skip a contact. There is no perpetual "twice per month" counter. Only widen the channel mix or tighten the spacing when the customer explicitly asks for it.
Transfers start fresh runs and may produce an immediate contextual handoff before the later cadence begins. If the customer wants no immediate handoff, verify that requirement against the supported transfer behavior instead of assuming the nurture schedule suppresses it. See the worked mapping in [recipes.md](recipes.md#6-route-qualified-leads-into-high-intensity-sales-or-low-intensity-nurture).
---
## 11. Knowledge bases: account catalog and per-agent assignment
Knowledge bases have two distinct scopes:
- `knowledge_bases` is the client/account catalog. A row can exist once and be reused by several agents.
- `workflow_knowledge_bases` is the assignment layer. A runtime agent can retrieve only from the KBs linked to that workflow, ordered by `priority`.
Use the public MCP tools in this order:
1. `list_knowledge_bases({})` inventories every non-archived KB owned by the authenticated client. This establishes what is available, not what any agent can use.
2. Build an exact desired set for each agent using real KB ids. Do not attach every account KB by default. If relevance is ambiguous, resolve it before changing assignments.
3. `list_knowledge_bases({ "workflow_id": "<agent-id>" })` reads the current per-agent assignments and priority order.
4. Call `attach_knowledge_base({ "workflow_id": "<agent-id>", "knowledge_base_id": "<kb-id>", "priority": 1 })` for each missing link. Omit `priority` only when appending is intentional.
5. Call `detach_knowledge_base({ "workflow_id": "<agent-id>", "knowledge_base_id": "<kb-id>" })` for an unexpected link. Detach removes only that agent's access; it does not delete the shared KB or its documents.
6. Repeat the workflow-scoped list and compare exact ids plus priority order with the desired set. Do not report completion until they match.
When the required KB does not exist, create it once at account scope with `create_knowledge_base`, then attach the returned real id to the intended agents. A created KB is not automatically available to an agent. Likewise, seeing a KB in the account-wide list is never proof that an agent can search it.
For a multi-agent build, preserve a knowledge manifest alongside the transfer manifest:
```json
[
{
"agent_ref": "qualifier",
"knowledge_bases": [
{ "knowledge_base_id": "<qualification-faq-id>", "priority": 1 }
]
},
{
"agent_ref": "closer",
"knowledge_bases": [
{ "knowledge_base_id": "<pricing-id>", "priority": 1 },
{ "knowledge_base_id": "<product-id>", "priority": 2 }
]
}
]
```
The final audit is set equality plus order for every agent: no missing KB, no extra KB, no wrong priority. Agent creation order does not affect this contract because assignments happen after real agent and KB ids exist.
## 12. Account channel inventory and agent binding
Never ask the customer to recall configuration that Nexor can read. Call `get_account_readiness` before planning, then the channel list tools (and `get_integration_status` when the agent books). **At birth this table is applied for you:** an agent gets every channel the account holds a verified resource for, and `create_workflow` binds them per mode (one WhatsApp number and one SMS route per agent, shared by its modes; the account-wide number for calls; the agent's email sender on every mode) and reports `channel_bindings`. Use the bindings below by hand only for an explicit operator request or to resolve an `owned_by_other_agent` outcome — never to take a resource from another agent without the operator's approval:
| Fact/resource | Source | Usable when | Agent binding |
|---|---|---|---|
| Account timezone | `get_account_readiness.account.timezone` | non-empty IANA timezone | cadence/block config timezone |
| WhatsApp number | `list_whatsapp_numbers.numbers[]` | `status === "active" && is_active !== false && Boolean(business_account_id)` | `assign_whatsapp_to_workflow({ number_id, workflow_id })` |
| Email sender | `list_email_senders.senders[]` | `can_send: true` (`verification_status` alone does not override an explicit `can_send: false`) | `update_workflow_config` with `config.email_sender_id` |
| Call number | `list_phone_numbers.numbers[]` | `is_active: true` and `provision_status !== "released"` | `assign_number_to_workflow({ number_id, workflow_id })` |
| SMS channel | same `list_phone_numbers` row | call-number conditions plus `sms_enabled: true` | `set_number_sms({ number_id, enabled: true, sms_workflow_id })` |
| Host calendar | `get_integration_status.calendars[]` / `get_calendar_connections({ user_id })` | `status === "active"` (`expired` / `needs_reauth` / `error` block that host's slots) | `connect_calendar({ user_id, provider })` — a human step; see [booking-agent.md](booking-agent.md#4-hosts-schedule-calendar) |
| Booking provider | `get_integration_status.booking_providers[]` | `excluded_hosts` empty | `set_calendly_binding` / `set_external_booking` |
Preserve the returned ids; phone and SMS are two capabilities of the same inventory row, not two unrelated catalogs. SMS is not a resource you provision — it rides on a phone number the account already has: as long as an active Twilio number exists, SMS is activated per agent by enabling it on that number and pointing its SMS route at the agent's workflow with `set_number_sms({ number_id, enabled: true, sms_workflow_id })`. SMS requires a Twilio-carrier number (Telnyx numbers cannot carry SMS); there is no country restriction. Preserve current `workflow_id` / `sms_workflow_id` / WhatsApp `workflow_id` as well. Email senders may be shared by multiple agents. WhatsApp has one direct `workflow_id`, call has one `workflow_id`, and SMS has one independent `sms_workflow_id`; call and SMS on the same physical number may point to different agents. Selecting an exclusive capability already assigned elsewhere is a reassignment.
Normalize those reads into one inventory before selecting anything — this is your working structure, not a tool response:
```json
{
"account": { "country": "CL", "timezone": "America/Santiago" },
"whatsapp": { "available_count": 1, "items": [{ "id": "...", "label": "...", "available": true, "assigned_to": { "workflow_id": "...", "workflow_name": "..." } }], "approved_openers": [{ "id": "...", "name": "intro_v1", "language": "es", "internal_type": "opening", "status": "APPROVED" }] },
"email": { "available_count": 1, "items": [{ "id": "...", "label": "...", "available": true, "can_send": true }] },
"phone": { "available_count": 1, "items": [{ "id": "...", "label": "...", "active": true, "assigned_to": null, "sms_enabled": true, "sms_assigned_to": null }] },
"sms": { "available_count": 1, "items": [{ "id": "...", "label": "Sales · +56…", "phone_number": "+56…", "active": true, "sms_enabled": true, "sms_assigned_to": { "workflow_id": "...", "workflow_name": "..." } }] }
}
```
Selection rules are deterministic:
- **One usable resource:** present two finite choices—use the exact observed number/sender, or configure a new one.
- **Several usable resources:** present every observed resource in a radio/list selector, plus configure a new one. Long lists should be searchable; never ask the customer to type an id or address already returned by the tool.
- **Zero usable resources:** state that none was observed and offer configure now versus continue without that channel. Do not ask whether one exists elsewhere.
- **Saved timezone:** use it by default. Ask for a timezone only when none is stored and local contact hours materially require it, or when the customer explicitly wants a different timezone.
Before mutation, run an account-wide allocation check. No WhatsApp, call, or SMS capability may have two intended direct owners. If a selected exclusive resource already has an owner, the confirmation summary must state its label/id, capability, old agent, new agent, and that inbound routing plus the old agent's direct outbound availability will change. Generic approval does not authorize that disruption. Immediately before each exclusive assignment write, repeat inventory and compare the live owner with the approved old owner. If it changed, abort that write and obtain new targeted approval against the current owner and impact.
When “configure another” is selected, suspend the build until a new real id exists. WhatsApp uses the chat connector and then repeats inventory. Email setup completes on the platform's Email integration surface and resumes only when `can_send: true`. Call setup — buying or provisioning a phone number for voice — completes on the Phone integration surface; that provisioning is billable and requires its own action. SMS is different: it is not a resource you provision. As long as the account already has an active Twilio number, SMS is activated per agent via `set_number_sms` (enabling SMS on that number and binding its `sms_workflow_id`) — no separate billable action. Only when the account has no number at all does buying one enter the SMS conversation. After the operator returns, repeat inventory and selection—never keep a placeholder id in the manifest.
After a workflow creation that carried an explicit resource selection, apply each selected id and synchronize `config.disabled_channels` plus `first_contact_channel` (an inherited creation already did this — read `channel_bindings` instead). Repeat the inventory read and `get_workflow` for every new agent and every displaced existing agent. Disable a lost capability on the displaced agent or assign its separately confirmed replacement. Relationship cleanup uses these exact calls: `assign_whatsapp_to_workflow({ number_id })` omits the optional `workflow_id` to unbind; `update_workflow_config({ workflow_id, config: { email_sender_id: null } })` explicitly clears email (omitting the key in a merge preserves the stale sender); `set_number_sms({ number_id, enabled: true })` omits the optional `sms_workflow_id` when SMS should stay enabled but unowned; `assign_number_to_workflow({ number_id })` omits the optional `workflow_id` and makes call routing client-scoped, so use it only when that account-wide impact was explicitly approved. Completion requires exact binding and config equality for every requested channel. Enabling a channel in cadence without assigning a usable sender/number does not satisfy the manifest.
For WhatsApp outreach, the `approved_openers` list above is the executable prerequisite inventory. It contains only `list_whatsapp_templates({ status: "APPROVED" })` rows whose `internal_type` is `greeting`, `opening`, `legacy_greeting`, or `outbound`. Require at least one real template id/name, configure the workflow's opening intent with `set_opening_templates({ workflow_id, mode, template_names })`, and read it back with `get_template_pool({ workflow_id })`. Activation is blocked until the selected opener remains approved and the observed opening pool matches.
---
## 13. Conversions: counting the outcome and moving the lead
Nexor records a conversion by itself only for outcomes it observes: a meeting the agent booked, a payment taken through a Nexor payment link, a Shopify order, a HubSpot deal reaching a won stage. Every other outcome — a contract signed in a CRM, an order paid in a store or point of sale, an account opened, a plan activated, a first deposit — exists in Nexor only if that system sends a conversion event.
### `POST /api/public/conversions` (MCP: `create_conversion`)
Auth: `X-API-Key`. **Exactly one lead identifier is required and it is the only requirement.**
| Field | Required | Notes |
|---|---|---|
| `lead_id` \| `lead_email` \| `lead_phone` | yes, exactly one | Must resolve to exactly one lead; no match or an ambiguous match is a 400 |
| `conversion_type_id` | no | Labels the event with a named conversion and inherits its currency. Without it the event is *untyped* and still counts |
| `amount` | no | Non-negative number. Without it no currency is stored |
| `currency` | no | Explicit value > the type's currency > `USD` |
| `description` | no | Free text, max 2,000 chars. `notes` is accepted as an alias; `description` wins if both are sent |
| `metadata` | no | Free-form JSON object, any keys and values, stored as sent |
| `workflow_id` | no | Credit this agent (and its group). Beats the type's scope and the lead's active agent |
| `master_workflow_id` | no | Alone (no `workflow_id`): a **group-level** conversion — no agent credited, the lead's active agent is not consulted, the group's own destination applies |
| `converted_at`, `workflow_run_id` | no | Backfills, and noting which run of that lead triggered it. `workflow_run_id` is provenance only: it never changes who is credited |
```json
{ "lead_email": "ana@empresa.cl", "amount": 129000, "currency": "CLP",
"description": "Contrato firmado",
"metadata": { "deal_id": "SO-88213", "seller": "mcastro", "branch": "Providencia", "plan": "anual" } }
```
Credit precedence: explicit `workflow_id`/`master_workflow_id` > the conversion type's scope > the lead's active agent. Use `master_workflow_id` alone when the sale belongs to the team rather than to one agent; group reports then show it under the group total, apart from agent-credited conversions.
`metadata` is where every fact about the conversion belongs — deal id, product, seller, branch, plan, campaign, line items — not the description. It is shown on the lead, echoed in the `conversion.detected` cloud-function event, and filterable on the list.
### `list_conversions`
All filters are optional and only narrow; an unfiltered call returns typed and untyped events together.
`lead_id`, `conversion_type_id` (the value `none` returns untyped events only), `workflow_id`, `master_workflow_id`, `metadata` (object; exact match on top-level keys), `limit`, `offset`. Over HTTP the metadata filter is the repeatable `metadata[key]=value` query parameter.
This is the read-back for a wiring: after one real event, `list_conversions({ lead_id })` must return it. Nothing is "done" before that.
### `set_conversion_destination`
Where the lead goes when a conversion lands. One call per scope:
```json
set_conversion_destination({ "workflow_id": "<agent>", "destination_workflow_id": "<target agent>", "destination_status_key": "welcome" })
set_conversion_destination({ "agent_group_id": "<group>", "destination_workflow_id": "<target agent>", "destination_status_key": "welcome" })
set_conversion_destination({ "workflow_id": "<agent>", "destination_workflow_id": null, "destination_status_key": null })
```
Pass `null` destinations to clear. The status must belong to the destination agent and both must belong to the account; invalid pairs are rejected at save time and re-checked when a conversion arrives. Selecting the same agent as destination is allowed — it is a status change.
**Precedence, first match wins:** the conversion type's destination (typed events only) > the destination on the agent running the lead > the destination on that agent's group > none, in which case the event is still recorded and the lead stays where it is. Nothing is inferred from "the account only has one agent".
Read it back with `get_workflow` or `list_workflows`, which return `conversion_destination: { workflow_id, workflow_name, status_id, status_key, status_label, source: "own" | "group" } | null` — `source` tells you whether the agent has its own destination or is inheriting the group's.
### Named conversion types are optional
`set_conversion_type` still defines a named conversion for reporting by name and a default currency (`list_conversion_types` reads them). It is never a prerequisite for sending an event: untyped events count, move the lead, appear on the Conversions page in their own row, and can be given a type later.
references/mcp-tool-surface.md
# MCP tool surface: how configuration actually gets written
The tool definitions in your context are the authority on **arguments**. This file is the authority on **sequence, reachability, and blast radius** — what to call in what order, what each write silently overwrites, and which parts of the configuration surface cannot be reached through MCP at all.
Read [configuration-reference.md](configuration-reference.md) for what the payloads *mean*. Read this for how to write them without destroying something.
## Contents
1. [The mandatory build protocol](#1-the-mandatory-build-protocol)
2. [Write tool → read-back tool](#2-write-tool--read-back-tool)
3. [Merge, replace, and destroy semantics](#3-merge-replace-and-destroy-semantics)
4. [What MCP cannot reach](#4-what-mcp-cannot-reach)
5. [Naming traps](#5-naming-traps)
6. [The verification surface](#6-the-verification-surface)
---
## 1. The mandatory build protocol
The platform ships its own required process, returned by `describe_agent_configuration`. It is not optional and it is not advice — `review_agent_system_plan` returns a `signoff_prompt` that explicitly instructs you not to mutate anything until the user approves the summarized plan.
1. **`describe_agent_configuration`** — call before proposing any non-trivial or multi-agent build. Returns the current canonical surface map, clarification checklist, and required process.
2. **Read current state** — `get_account_readiness`, `list_workflows`, `get_workflow`, `list_workflow_tools`, `list_knowledge_bases`, `list_webhooks`, `get_workflow_cadence`; for appointment agents also `list_meeting_types`, `list_executives`, `get_calendar_connections` and `get_integration_status` (`calendars[]`, `booking_providers[]`).
3. **`review_agent_system_plan({ plan })`** — preflight the whole system before mutating. It validates the plan and returns `ready_for_signoff`, `blocking_issues`, `clarification_questions`, and an internal `plan_fingerprint` (never shown to the user).
4. **Get explicit user approval of the plan.** Show the returned summary verbatim and close with "Does this look ok? Let me know and I'll build it." Never show the `plan_fingerprint`, any hash, or the word "fingerprint" to the user.
5. **Create paused, configure, read back.** `create_workflow` always creates a PAUSED workflow — that is the safety property the whole protocol rests on. Do not activate as part of the build.
6. **`set_workflow_active`** only after read-back and a separate confirmation.
### The plan shape `review_agent_system_plan` validates
```json
{
"agents": [{
"ref": "qualifier",
"name": "Qualification agent",
"goal_type": "qualification",
"primary_responsibility": "One sentence. Missing → clarification question.",
"language": "es",
"timezone": "America/Santiago",
"channels": ["whatsapp", "email"],
"statuses": [
{ "key": "new", "is_initial": true },
{ "key": "qualified_now", "entry_hint": "…", "transfer_to_agent_ref": "closer" }
],
"tools": [], "pre_execution_hooks": [], "activate": false
}],
"status_webhooks": [{ "agent_ref": "qualifier", "status_key": "qualified_now", "url": "https://…" }],
"cloud_functions": [], "scheduled_functions": [], "background_jobs": [],
"open_questions": ["Anything you are assuming — these surface as clarifications."]
}
```
**This is the native form of the Law 3 connection manifest.** `transfer_to_agent_ref` is validated against the set of defined agent refs, so a handoff pointing at an agent that does not exist in the plan is a *blocking* issue before anything is created. Use agent refs here; real `target_workflow_id`s only exist after creation.
What the validator blocks: missing `name` / `goal_type`, zero statuses, duplicate agent refs or status keys, a status with no key, a `transfer_to_agent_ref` that matches no agent, a `status_webhook` with no valid `agent_ref` / `status_key`. For `goal_type: "appointment"` it also blocks on a missing `meeting_type: { name, duration_minutes }`, an empty `hosts: [{ user_id | email | name }]`, and the absence of both a `calendar_plan: { provider: "google" | "outlook" | "calendly" }` (or `hosts[].calendar_provider`) and an external `booking_provider` (`gohighlevel` | `external_booker`) — see [booking-agent.md](booking-agent.md#2-the-ordered-path).
What it raises as a clarification (also blocks `ready_for_signoff`): missing `primary_responsibility`, `language`, `timezone`, `channels`, or a non-initial status with neither `entry_hint` nor `transition_rules` — i.e. **the validator enforces Law 2's "every stage needs an entry criterion" for you.**
What it warns about: `activate: true` on any agent, and the presence of functions (their test runs make real external HTTP calls).
---
## 2. Write tool → read-back tool
Every write below has a paired read. A configuration is not done when the write returns 200; it is done when the read shows the intended value.
| Concern | Write | Read back with |
|---|---|---|
| Agent name, description, objective, identity, group | `update_workflow` — accepts `name`, `description`, `goal_statement`, `language`, `timezone`, `region_style`, `agent_name`, `agent_role`, `company_name`, `begin_message`, `master_workflow_id`; every call must carry at least one. **Verified, not echoed:** the response carries the persisted `identity` read-back and `verified_fields`; a field the store did not keep fails with `CONFLICT` / `FIELDS_NOT_PERSISTED` naming it — never report that field as updated. `null` / non-string identity values are rejected `INVALID_INPUT` (they used to be silently dropped); to clear `region_style` send `''` | `get_workflow` |
| Pause state | `set_workflow_active` — **not** an `update_workflow` parameter | `list_workflows` |
| Agent rename | `update_workflow({ name })` | `get_workflow`, `list_workflows` |
| Agent-group membership | `update_workflow({ master_workflow_id })` moves the mode into another agent; `null` is refused — a mode always belongs to an agent | `list_agent_groups`, `list_workflows` |
| Channels | channel-binding tools + `config.disabled_channels` — **not** parameters of `update_workflow` (see §4) | `get_workflow`, channel inventory tools |
| iMessage number → agent | `assign_imessage_to_workflow({ number_id, workflow_id })`; omit `workflow_id` to unbind (one iMessage number per agent **group** — siblings under the same `master_workflow_id` share it like WhatsApp; read `owner` + `sharedWith` before re-assigning) | `list_imessage_numbers`, `get_imessage_number` |
| iMessage display identity (name + photo leads see) | `set_imessage_display_name({ number_id, first_name?, last_name? })`, `set_imessage_profile_photo({ number_id, image_url })` — once set by hand the name no longer follows the agent persona | `get_imessage_number` |
| Buy an iMessage number (**charges $350, full first month today, never prorated**) | `get_imessage_offer` → explicit confirmation → `provision_imessage_number({ accept_charge: true })` | `list_imessage_numbers` (poll to `setupStatus: ready`) |
| Agent/workflow deletion | `delete_workflow` — first call without `confirm:true` for a write-free preview; repeat with `confirm:true` after explicit approval. Archives the agent and releases its channels. **Refuses with HTTP 409 `AGENT_HAS_LEADS` (with distinct `lead_count`) when the agent still holds active leads in its pipeline** (at least one active `workflow_run`) | `list_workflows` (the agent is gone/archived) |
| Agent-group create/rename/delete | `create_agent_group({ description? })` (the FIRST write of any build: `create_workflow` requires its id as `master_workflow_id`; Nexor picks the agent's human first name — a `name` argument is ignored and echoed as `requested_name_ignored`; the result's `birth_defaults` lists the channels the agent is born with), `update_agent_group` (rename only on an explicit request), `delete_agent_group` | `list_agent_groups`, `list_workflows` |
| Un-archive an agent | `restore_workflow` — inverse of `delete_workflow`; the agent comes back **paused** with its channels detached (they were released on archive). Find it first with `list_workflows({ include_archived: true })` — archived rows carry `archived: true` + `deleted_at` | `list_workflows`, `get_workflow` |
| Payment link (goal `payment_link`) | `set_payment_link` — `source: 'fixed'` (+`url`, optional `label`), `'tool'` (+`tool`, optional `capture` dot-path), `'lead'` (+`metadata_key`) or `'none'` to clear. Validated per source and read back; **never hand-write `config.payment_link` through `update_workflow_config`** (any shape merges "successfully" there but is never read). Writing the URL inside the prompt does NOT configure it | `get_workflow` (`config.payment_link`) |
| Reversible lead archive | `delete_lead` / `bulk_archive_leads` under `leads:write`; clarify ambiguous delete requests before choosing archive | `get_lead` / `get_leads` |
| Permanent lead deletion | `hard_delete_lead` / `bulk_hard_delete_leads` under separately granted `leads:delete`; first call without `confirm:true` for a write-free exact-id preview, repeat with `confirm:true` only after explicit approval | Success response plus the backend forensic audit |
Deleting an agent group is relationship-only: it removes the group and clears each member's group assignment. It must never pause, archive, soft-delete, hard-delete, or reconfigure a member agent. Read back both groups and workflows and verify that every former member still exists with `master_workflow_id: null`.
| Opening (first) message per channel | `set_opening_message` — `channel:'call'` → `message` (spoken opening line; `''` restores the language default), `agent_speaks_first` (default true), `silence_ms` (1000–30000 when the lead speaks first); `channel:'email'` → fixed first `subject` + `body` (`enabled:false` + empty removes it); `channel:'webchat'` → widget `message`; `channel:'sms'` → fixed first outbound SMS `message` (a legally required STOP notice is still appended for US numbers); `channel:'messenger'` → fixed first reply `message` in a new Messenger conversation. `''` clears any of them so the agent composes from the prompt. `create_workflow` also takes `begin_message` so a call agent is born with its opener. WhatsApp openers stay approved templates (`set_opening_templates`) because Meta must approve the text | `get_workflow` → `opening_messages.{call,email,webchat,sms,messenger}` |
| Base prompt | `set_workflow_prompt` — returns `{ status: "saved", prompt_length }` **only** when the backend proves the stored length matches what was sent; otherwise it fails `CONFLICT` / `PROMPT_NOT_SAVED`. A prompt is written only when you see `saved` | `get_workflow`, `get_workflow_prompt_history` |
| Per-channel prompt | `set_channel_prompt` — one call per channel; for `sms` and `imessage` this is the only prompt the outbound composer reads when it exists. A behavior change the operator did not scope to one channel goes here for every channel with a body, in addition to `set_workflow_prompt` | `get_workflow` → channel prompts |
| Create a mode (always INSIDE an agent: `master_workflow_id` required — `create_agent_group` first, or an existing agent's id) + skeleton statuses + fields. Inherits the agent's channels, contact schedule, persona name, language and timezone (siblings, else the account) and binds the channel resources itself — omit `channels` / `contact_schedule` / `agent_name` unless the operator explicitly asked for something different; read `inheritance` and `channel_bindings` in the result | `create_workflow` | `get_workflow`, `list_agent_groups`, `list_workflows` (`enabled_channels`) |
| Add/edit statuses & fields (upsert, never deletes) | `update_workflow_structure` | `get_workflow` |
| Reorder every status after the protected semantic head | `reorder_workflow_statuses` with the complete persisted tail | `get_workflow` |
| **Per-status gates, hints, transfers, timeouts, pause** | `update_workflow_status` | `get_workflow` |
| Delete a status | `delete_workflow_status` (after `get_pipeline_impact`) | `get_workflow` |
| Workflow-scoped HTTP tool | `create_workflow_tool` / `update_workflow_tool` | `list_workflow_tools` |
| Reusable customer API tool | `configure_customer_api_tool` or `create_client_tool` + `assign_tool_to_workflow` | `list_client_tools`, `list_workflow_tools` |
| Tool execution mode (hook vs conversational) | `set_workflow_tool_execution` | `list_workflow_tools` |
| Tool stage gate only | `set_tool_stage_gate` | `list_workflow_tools` |
| `config` bag — incl. **`status_automations`**, `gate_outbound_to_hours`, `disabled_channels` | `update_workflow_config` | `get_workflow` |
| Cadence windows and intensity | `set_workflow_cadence` | `get_workflow_cadence` |
| Status-scoped webhook | `configure_status_webhook` | `list_webhooks`, `get_webhook` |
| General event webhook | `create_webhook` / `update_webhook` | `list_webhooks` |
| Reminders / host notifications / recontact | `get_reminder_catalog` first (per-account channel availability, dispatched trigger events), then `set_reminder_rule`, `set_host_reminder_rule` (email only, `event_created` only), `set_recontact_rule` | `list_reminder_rules`, `list_host_reminder_rules`, `list_recontact_rules` |
| Meeting type (one per appointment agent) | `create_meeting_type` (returns `status: "existing"` when one exists; `MULTIPLE_MEETING_TYPES` when several) / `update_meeting_type` (incl. `is_active`) / `delete_meeting_type` (never the last one) | `list_meeting_types` |
| Hosts (executives) | `add_executive({ workflow_id, user_id, meeting_type_ids? })` / `update_executive` (replaces the full `meeting_type_ids` set) / `remove_executive`; `auto_assign_solo_host` when one member is eligible | `list_executives` |
| Host availability | `set_host_schedule` (complete weekly `slots`, host timezone), `set_host_timezone`, `set_host_blocks({ add, remove })` | `get_host_availability` |
| Host calendar — **human step** | `connect_calendar({ user_id, provider })` → `connect_url` the host opens; `disconnect_calendar({ connection_id, confirm })` | `get_calendar_connections` (`status: "active"`), `get_integration_status.calendars[]` |
| Host routing (enforced) | `set_meeting_type_routing({ agent_selection_rules })`; `null` clears. Prose preference: `update_meeting_type({ agent_selection_criteria })` | the tool's read-back; `list_meeting_types` |
| Booking settings | `set_default_attendees`, `set_meeting_confirmation_email` (after `preview_meeting_confirmation_email`), `set_host_assigner` (+ `test_host_assigner`) | `get_workflow_booking_settings`, `get_meeting_confirmation_email` |
| Client-owned booker | `set_external_booking` — `tool`, `reschedule_tool`, `cancel_tool` all required; `null` clears. **Never** through `update_workflow_config` | the tool's read-back, `get_integration_status.booking_providers[]` |
| Calendly | `connect_calendly` (human step) → `set_calendly_host_mode` → `map_calendly_hosts` → `set_calendly_binding` per host → `provision_calendly`; `disconnect_calendly({ confirm })` | `get_calendly_status`, `list_calendly_bindings`, `list_calendly_event_types` |
| Operator meeting operations | `book_meeting` (`idempotency_key`, `confirm`), `reschedule_meeting`, `change_meeting_host`, `cancel_meeting` — preview without `confirm: true`; `set_meeting_status({ status: "no_show" \| "completed" })` | `list_meetings`, `list_lead_meetings`, `get_meeting_stats`, `get_workflow_slots` |
| CRM connection | `connect_crm({ provider: "hubspot" \| "zoho" \| "kommo" \| "gohighlevel" })` → `connect_url` or hosted token form (human step); `disconnect_crm` | `get_crm_connection_status`, `get_integration_status` |
| Status timeout | `set_status_timeout_rule` | `list_status_timeout_rules` |
| Knowledge assignment | `attach_knowledge_base` / `detach_knowledge_base` | `list_knowledge_bases({ workflow_id })` |
| Cohort automation | `create_background_job` / `update_background_job` | `get_background_job` |
| Event-driven JS | `create_cloud_function` | `get_cloud_function` |
| Cron JS | `create_scheduled_function` | `get_scheduled_function` |
| Secrets | `set_function_environment_variable` | `list_function_environment_variables` |
| Inbound lead routing (pre-agent) | `set_processor`, `set_processor_rule` | `list_processors`, `get_processor` |
| Go live | `set_workflow_active` | `list_workflows` |
**`status_automations` has no dedicated tool.** It lives in the workflow `config` bag and is written with `update_workflow_config({ workflow_id, config: { status_automations: [...] } })`. Because that call merges top-level keys, you must send the *entire* `status_automations` array every time — the array itself is replaced wholesale, not merged element-by-element. Read `get_workflow` first and append to the existing array.
### Correct build order
`create_workflow` accepts the full funnel inline — statuses carry the complete stage config (`entry_hint`, `category`, `color`, `is_initial` / `is_terminal` and the other `is_*` flags, `transition_rules`, `requires_all_fields` / `required_field_keys`, `timeout_config`, `transfer_config`, `pause_bot`, `assignment_config`), so design the whole pipeline before the call instead of planning a second pass:
1. `create_workflow` — name, goal_type, the ordered statuses with their full stage config (each status needs `key` plus `name` or its `label` alias; unknown status keys are forwarded to the backend as-is), and the **complete** `fields` array (see §4 — this is your only chance for most field properties). Statuses without flags derive them: first = initial, last = terminal. **Set the identity and language here too:** `language` (BCP-47 from the enum, e.g. `en-US` / `es-CL` — match the conversation; without it the agent is born `es-CL`), `agent_name`, `agent_role`, `company_name` (without them it is born as "Agent" / "sales"). The result carries a top-level `status`: `"created"` or `"created_with_warnings"` — with warnings, resolve `pipeline_reconciliation` before configuring anything else.
2. **Check the `pipeline_reconciliation` report in the response** (see "Seeded default pipelines" below) and resolve every leftover, missing key, and warning before configuring anything else.
3. `update_workflow_status` — only for later edits, or for `transfer_config` targets that did not exist yet at create time.
4. Tools — `create_workflow_tool` / `configure_customer_api_tool`, then `set_workflow_tool_execution` or `set_tool_stage_gate`.
5. `update_workflow_config` — `status_automations` and behavior flags. Tools must exist first: an automation references a tool **by name**.
6. Webhooks, rules, jobs, functions, knowledge bases. For an appointment agent the rules step expands to the booking path — meeting type → hosts → availability → calendar (human step) → `get_reminder_catalog` → reminders → routing — in [booking-agent.md](booking-agent.md#2-the-ordered-path).
7. **Transfers last** — `update_workflow_status(..., transfer_config)` once every target agent has a real id.
8. Read everything back, then `set_workflow_active`.
### Seeded default pipelines
The backend's create endpoint seeds a default funnel (New, Contacted, In conversation, plus system stages such as "Esperando respuesta humana") and **appends** your statuses to it. `create_workflow` reconciles this for you: after creating, it reads the workflow back, re-asserts your initial/terminal flags, deletes seeded statuses that are not in your list, and returns `{ status, workflow, pipeline_reconciliation }` where `status` is `"created"` or `"created_with_warnings"` and the report carries `status` (`ok` / `needs_attention`), `requested_status_keys`, `seeded_statuses_removed`, `unrequested_statuses_remaining` (each with the backend's reason — e.g. `STATUS_HAS_LEADS` when the idempotency guard returned an existing agent, or a protected goal-target stage that cannot be deleted), `missing_requested_statuses`, `duplicate_labels` (two stages sharing one label — typically a protected seeded goal-target such as "Payment link sent" plus your own stage with the same label; keep the seeded key and delete yours, or rename one), and `warnings`. `created_with_warnings` is never a built agent.
Treat the report as part of the write: a build step is not done until `unrequested_statuses_remaining`, `missing_requested_statuses`, and `warnings` are all empty or explicitly resolved (`delete_workflow_status` after `get_pipeline_impact` for leftovers, `update_workflow_structure` for missing keys). Never leave seeded stages in a designed funnel — they are invisible to your gates, hints, and transfers, and leads routed into them go stale. Pass `prune_unrequested_statuses: false` only when you deliberately want to keep the defaults.
---
## 3. Merge, replace, and destroy semantics
The single most dangerous class of mistake: assuming a write merges when it replaces.
| Tool | Semantics | What you must do |
|---|---|---|
| `update_workflow` | Field-level merge over name/description/`goal_statement`, the identity fields (`language`, `timezone`, `region_style`, `agent_name`, `agent_role`, `company_name`, `begin_message`) and `master_workflow_id` | Pass only what changes, and **at least one** accepted field — a call with none fails `INVALID_INPUT`. Pause state goes through `set_workflow_active`, channels through channel-binding tools |
| `update_workflow.master_workflow_id` | Relationship replacement; UUID moves/assigns, `null` is refused (no mode without an agent) | Resolve the group with `list_agent_groups`; read back `list_workflows` |
| `update_workflow_config` | **Top-level key merge**; nested values replaced wholesale | Read `get_workflow`, send the full array/object for any key you touch |
| `update_workflow_config` with `replace: true` | **Destroys the entire config bag** | Effectively never use it |
| `update_workflow_structure` | Upsert by key; **never deletes** | Removing a status needs `delete_workflow_status` |
| `reorder_workflow_statuses` | Atomic complete-tail replacement; the first three semantic stages stay fixed | Read `get_workflow`, submit every persisted tail key exactly once, then read back and compare |
| `update_workflow_status` | Field-level merge, but `null` **clears** the block | Pass `null` only to intentionally clear |
| `set_workflow_prompt` | **Full overwrite** of the global prompt; channel prompts untouched, so a behavior change is incomplete until each channel with a body gets it via `set_channel_prompt` | `get_workflow` first, edit, send whole, then the channel prompts |
| `set_workflow_cadence` | Whole-document PUT for `blocks`; `dayConfig` keys omitted are **preserved** | `get_workflow_cadence` first, send the full edited document |
| `set_workflow_qualification` | Merge by natural key; `replace: true` deletes and replaces | Default to merge |
| `set_workflow_voice` | Switches the voice AND/OR merges tuning keys | To change the voice pass **one** of `gender` (`male`/`female`), `voice_id`, or `voice_name` — the server resolves and writes the effective voice, and returns `resolved_voice.preview_url` to share. Tuning knobs (`voiceSpeed`, …) go in the `voice` object |
| `delete_workflow_status` | **Destructive.** Fails with `STATUS_HAS_LEADS` unless `migration_target` is given | Always `get_pipeline_impact` first |
| `delete_workflow_tool` | Soft-disable by default; `hard: true` is permanent | Leave `hard` off |
| `detach_knowledge_base` | Removes only the link | Never `delete_kb_document` to revoke one agent's access |
`create_workflow` is idempotent server-side — a guarded duplicate returns the existing id rather than creating a second agent. Its post-create reconciliation never deletes a status holding leads (the delete is refused server-side and surfaces in `unrequested_statuses_remaining` instead), so a duplicate-return with live leads stays safe.
---
## 4. What MCP cannot reach
Do not design a configuration that depends on these; the write will be rejected (`additionalProperties: false`) or the property simply has no tool.
- **Deleting an agent that still has active pipeline leads.** Agent deletion itself *is* available through the two-step `delete_workflow` contract: preview without `confirm:true`, obtain explicit approval for that exact agent, then repeat with `confirm:true`. The one thing MCP cannot force is deleting an agent whose pipeline still holds active leads (at least one active `workflow_run`) — the backend refuses with HTTP 409 `AGENT_HAS_LEADS` (with distinct `lead_count`), exactly as the dashboard Delete button is blocked. When this happens, do not dead-end: report the count and offer to move those leads to another agent, archive them reversibly, or permanently erase exact leads only under the separate `leads:delete` scope and its own preview-then-confirm flow. **Never** archive, delete, or status-change leads as a silent way to empty the pipeline. Agent-group deletion (`delete_agent_group`) stays relationship-only — never delete, pause, or mutate a member agent as a substitute.
- **`variable_refs`** — not a parameter of `update_workflow_status`. Statuses still gate correctly via `required_field_keys` / `requires_all_fields` / `transition_rules`; `variable_refs` only drives the dashboard's per-status hint of which fields matter. **Express every gate through `required_field_keys`, never by assuming `variable_refs` was set.**
- **`futurology_queue`** — no MCP parameter. A "park and recontact later" bucket must be built from a non-terminal status plus a recontact rule or background job, or configured in the dashboard.
- **Raw `sort_order` edits on an existing pipeline.** Set initial order through `create_workflow`; later order changes go through `reorder_workflow_statuses`, which protects the semantic head and rewrites the complete tail contiguously.
- **`channels` and pause state are not `update_workflow` parameters.** (`create_workflow` binds the inherited channels at birth; the tools below are for an explicit change afterwards.) `update_workflow` covers name, description, `goal_statement`, the agent identity (`language`, `timezone`, `region_style`, `agent_name`, `agent_role`, `company_name`, `begin_message`) and `master_workflow_id` — but the channel mix lives in the channel-binding tools (`assign_whatsapp_to_workflow`, `config.email_sender_id`, `assign_number_to_workflow`, `set_number_sms`, `assign_imessage_to_workflow`) plus `config.disabled_channels`, and pausing/activating is `set_workflow_active`. A call carrying only a `workflow_id` (or only unaccepted keys) fails `INVALID_INPUT`; always include at least one accepted field. SMS is not provisioned like the others: it is activated per agent with `set_number_sms({ number_id, enabled: true, sms_workflow_id })` on a phone number the account already has (a Twilio-carrier number; Telnyx cannot carry SMS), so it needs no buy-a-number step when an active Twilio number already exists.
- **Runtime agent tools.** `get_available_slots`, `confirm_and_book`, `create_event`, `reschedule_event`, `cancel_event`, `request_channel_swap`, `schedule_followup`, `save_field` and `patch_metadata` are built into the conversational agent and are **not callable over MCP** — there is no tool definition for them in your context and `list_workflow_tools` does not list them. They are named in configuration only as gate targets (`available_in_statuses`) or as entries in `disabled_builtin_tools` for an external booker. The operator-side equivalents are `book_meeting`, `reschedule_meeting`, `change_meeting_host`, `cancel_meeting` and `update_lead`.
- **Most workflow-field properties after creation.** `create_workflow.fields[]` accepts arbitrary properties, but `update_workflow_structure.fields[]` accepts **only** `key`, `label`, `type`, `required`, `sort_order`. So `metadata_key`, `intake_value_map`, `extraction_hints`, `options`, `validation`, and `description` are reachable **only in the initial `create_workflow` call**.
**This is a Law 1 constraint with teeth:** the metadata → field bridge (`metadata_key`) and select-field `options` must be part of the variable ledger *before* you create the agent. Getting them wrong means recreating the agent or finishing in the dashboard. Write the complete `fields` array on the first call.
---
## 5. Naming traps
- **`create_workflow` statuses take the full stage config — use it.** Each status needs `key` plus `name` (or its `label` alias) and accepts `is_initial`, `is_terminal`, `sort_order`, `entry_hint`, `category`, `color`, `transition_rules`, `requires_all_fields` / `required_field_keys`, `timeout_config`, `transfer_config`, `pause_bot`, `assignment_config` and the other `is_*` flags; unknown keys are forwarded to the backend. Deferring stage config to a follow-up pass leaves a window where the funnel is wrong — configure at create and reserve `update_workflow_status` for edits and late-binding `transfer_config` targets. One plan-only key remains: `transfer_to_agent_ref` belongs to `review_agent_system_plan` and must be resolved to a real `target_workflow_id` before it appears in any `transfer_config`.
- **`update_workflow_structure` statuses use `label` and accept only `key`, `label`, `is_initial`, `is_terminal`, `sort_order`.** `create_workflow` takes either `name` or `label`, but sending `name` — or any rich stage key — to `update_workflow_structure` fails `additionalProperties: false`; rich config on an existing status goes through `update_workflow_status`, and whole-pipeline ordering goes through `reorder_workflow_statuses`.
- **Tool `name` is immutable.** Renaming means `delete_workflow_tool` + recreate. `status_automations.tool` references it by name, so a rename silently breaks every automation pointing at it.
- **`goal_type` is a fixed vocabulary:** `appointment`, `sale`, `qualification`, `information`, `payment_link`, `support`, `custom`, `quote`, `document_collection`. `custom` **does not schedule meetings** — pair it with `update_workflow({ goal_statement })`, which is what actually renders the objective into the prompt.
- **Three unrelated things are called "paused."** Confusing them produces silent no-ops:
- `workflows.is_paused` — the agent is off. Set by `set_workflow_active`. All new agents start here.
- `workflow_runs.is_paused` — one lead's run is paused. Set by `stop_automation`. This is what background-job `exclusions.skip_paused` filters on.
- status `pause_bot` — the agent goes quiet while the lead sits in that status. **Does not set `workflow_runs.is_paused`**, so a background job with `skip_paused: true` still processes these leads. That is precisely why the Law 3 pause boundary works: the job executing the handoff can still see the parked lead.
- **Appointment workflows need more than an active meeting type to book.** `set_workflow_active` succeeds with no host, no availability and no calendar; the agent then offers nothing. `get_account_readiness` lists the blockers per agent (`MEETING_TYPE_REQUIRED`, `BOOKING_AVAILABILITY_REQUIRED`, `CALENDLY_BINDING_REQUIRED`) with `fix_tools`; `get_workflow_slots` is the probe that proves the path is complete.
- **`set_reminder_rule` has no reschedule trigger.** The dispatched events are `event_created` (re-fired for the new time after a reschedule), `event_cancelled`, `call_analyzed` (post-call, not post-meeting) and `event_enrolled`; `get_reminder_catalog` is the source of truth and also says which channels this account can use. A hand-typed value that is not in the catalog is a rule that never fires.
---
## 6. The verification surface
Preview and dry-run tools exist for nearly every risky primitive. Using them is the difference between a build you can defend and one you hope about.
| Check | Tool | What proves success |
|---|---|---|
| Whole-system preflight | `review_agent_system_plan` | `ready_for_signoff: true`, zero blocking issues |
| Funnel exactness after create | `pipeline_reconciliation` in the `create_workflow` response, then `get_workflow` | Status keys are exactly the designed set; empty `unrequested_statuses_remaining`, `missing_requested_statuses`, and `warnings` |
| Existing pipeline order | `get_workflow` before and after `reorder_workflow_statuses` | Protected semantic head remains at 0–2; complete persisted tail exactly matches the submitted keys at 3 onward |
| Status deletion safety | `get_pipeline_impact` | Lead counts per status before deleting |
| Cron correctness | `preview_scheduled_function_schedule` | The next N fire times in the right timezone |
| Cohort size | `preview_scheduled_function_cohort` | The candidate list before anything runs |
| Job behavior | `create_background_job` with `dry_run: true`, then `run_background_job` | Candidate list and intended actions, nothing sent |
| Webhook wiring | `test_webhook` | A delivery your receiver actually got |
| Function logic | `run_cloud_function` → `get_cloud_function_run` | Effects previewed. **Outbound HTTP is real** — point at test endpoints |
| Transfer edges | `get_workflow` on each source | `transfer_config.target_workflow_id` equals the real target id |
| Knowledge assignment | `list_knowledge_bases({ workflow_id })` | Exact id set and priority order |
| Stage gate | Call the tool before and after the gating status | `tool_not_available_in_stage`, then success |
| Booking readiness | `get_account_readiness` → the agent's `booking` block; `get_integration_status.calendars[]` | Zero blockers, no `fix_tools`, every host calendar `active` |
| Slots exist | `get_workflow_slots({ workflow_id, from, to })` | Non-empty for every host over the next week |
| A real booking round-trips | `book_meeting({ …, confirm: true })` on a test lead → `list_meetings` → `cancel_meeting({ confirm: true })` | Host, time in the workflow timezone and meeting link present; the cancel removes it on the provider |
`run_background_job` executes even an inactive job, and `run_scheduled_function` requires `confirm` because manual runs apply **real** effects. A successful manual run never proves the schedule is on — check `is_active` separately.
references/recipes.md
# Recipes: customer ask → exact configuration
Worked mappings. Start from the closest one, keep the shape, swap the domain values. Field names and semantics are defined in [configuration-reference.md](configuration-reference.md).
## Contents
1. [“Notify us when a lead reaches X”](#1-notify-us-when-a-lead-reaches-x)
2. [“When a lead is qualified, create a deal in our CRM — once”](#2-when-a-lead-is-qualified-create-a-deal-in-our-crm--once)
3. [“Sync leads in from our CRM / database every night”](#3-sync-leads-in-from-our-crm--database-every-night)
4. [“Our form sends custom data; the agent should use it”](#4-our-form-sends-custom-data-the-agent-should-use-it)
5. [“Qualify by salary; branch on the amount”](#5-qualify-by-salary-branch-on-the-amount)
6. [“Route qualified leads into high-intensity sales or low-intensity nurture”](#6-route-qualified-leads-into-high-intensity-sales-or-low-intensity-nurture)
7. [“Remind the lead before the meeting”](#7-remind-the-lead-before-the-meeting)
8. [“After every completed meeting, send a survey — once”](#8-after-every-completed-meeting-send-a-survey--once)
9. [“When a lead unsubscribes, flag them in our CRM”](#9-when-a-lead-unsubscribes-flag-them-in-our-crm)
10. [“Re-activate leads whose follow-up date arrived”](#10-re-activate-leads-whose-follow-up-date-arrived)
11. [“Enrich every new lead from an external API”](#11-enrich-every-new-lead-from-an-external-api)
12. [“One sales conversation with contacted / qualified / booked stages”](#12-one-sales-conversation-with-contacted--qualified--booked-stages)
13. [“Once qualified, assign a HubSpot sales rep round-robin and then book”](#13-once-qualified-assign-a-hubspot-sales-rep-round-robin-and-then-book)
14. [“If the lead doesn’t qualify, stop contacting them”](#14-if-the-lead-doesnt-qualify-stop-contacting-them)
15. [“WhatsApp 24/7 instantly, but only call during business hours”](#15-whatsapp-247-instantly-but-only-call-during-business-hours)
16. [“Give each agent only the knowledge bases it needs”](#16-give-each-agent-only-the-knowledge-bases-it-needs)
17. [“Partway through, the agent should change its tone / start selling / hand over”](#17-partway-through-the-agent-should-change-its-tone--start-selling--hand-over)
18. [“Create two agents using the account’s existing channels”](#18-create-two-agents-using-the-accounts-existing-channels)
19. [Build a two-agent system end to end, from brief to live](#19-build-a-two-agent-system-end-to-end-from-brief-to-live)
20. [“Change the agent's call voice — make it male / female / a specific voice”](#20-change-the-agents-call-voice--make-it-male--female--a-specific-voice)
21. [Build a bookable appointment agent on a fresh account](#21-build-a-bookable-appointment-agent-on-a-fresh-account)
22. [“Once the lead says yes, a payment agent sends the link”](#22-once-the-lead-says-yes-a-payment-agent-sends-the-link)
23. [“If they go quiet for N hours, follow up, then hand them to the follow-up agent”](#23-if-they-go-quiet-for-n-hours-follow-up-then-hand-them-to-the-follow-up-agent)
24. [“The deal closes in our CRM — count it in Nexor”](#24-the-deal-closes-in-our-crm--count-it-in-nexor)
25. [“Sell tickets on our ecommerce and follow up with a discount”](#25-sell-tickets-on-our-ecommerce-and-follow-up-with-a-discount)
---
## 1. “Notify us when a lead reaches X”
**Primitive:** outbound webhook. Every "I want to be notified when…" maps here first.
```json
{
"name": "Notify on qualified",
"url": "https://customer.example.com/nexor-events",
"events": ["workflow_run.status_changed"],
"filters": [{ "field": "to_status.key", "operator": "equals", "value": "qualified" }],
"auth_type": "header"
}
```
The payload already carries the full lead (including metadata) and the from/to statuses — the receiver rarely needs a follow-up call. For Slack/email-style notifications, point the URL at the customer's alerting endpoint or a relay they own.
Variants: filter `to_status.category equals won` for "any win"; add `workflow_id` to scope to one agent; subscribe `meeting.created` / `meeting.no_show` for booking notifications. Keep status-filtered subscriptions on status events only (an absent filter field passes).
**Rejected rungs:** an agent tool only fires mid-conversation (misses timeout/job/human-driven status changes); a cloud function is code for what config already does.
---
## 2. “When a lead is qualified, create a deal in our CRM — once”
**Primitive:** status automation (act once per lead, custom body), not a webhook (fires every occurrence, fixed envelope unless templated).
1. Define a workflow HTTP/MCP tool for the CRM's create-deal endpoint (auth via `{{env.CRM_API_KEY}}`).
2. Add to workflow config:
```json
{
"status_automations": [{
"key": "create_crm_deal",
"on_status_key": "qualified",
"action": "run_tool",
"tool": "crm_create_deal",
"args_template": {
"email": "{{lead.email}}",
"name": "{{lead.first_name}} {{lead.last_name}}",
"budget": "{{lead.metadata.budget}}"
},
"skip_if_metadata": "crm_deal_id"
}]
}
```
3. Add a post-tool hook writing the returned deal id into `metadata.crm_deal_id` — that makes `skip_if_metadata` a second idempotency layer and gives later automations the CRM reference.
**Rejected rungs:** a webhook fires on every matching transition and needs receiver-side dedup; an agent tool depends on the model noticing the status change — the automation is invoked by the transition itself.
---
## 3. “Sync leads in from our CRM / database every night”
**Primitive:** scheduled function calling the public leads API. ("Syncing information into Nexor" is usually just the API; the scheduled function is the cron wrapper when the external tool can't push.)
- If the external tool **can push** (webhooks, Zapier, forms): skip the function entirely — point it at `POST /api/public/leads` with an API key, or at a branded inbound hook URL if it can't set headers. Done.
- If Nexor must **pull**, create a scheduled function (e.g. `0 2 * * *`, customer's timezone):
```js
const { data } = await axios.get("https://crm.example.com/api/contacts?updated_since=yesterday", {
headers: { Authorization: `Bearer ${env.CRM_API_KEY}` }
});
const leads = data.contacts.map(c => ({
first_name: c.firstName, last_name: c.lastName,
email: c.email, phone: c.phone,
source: "crm_sync",
metadata: { crm_id: c.id, plan: c.plan, region: c.region },
workflow_id: env.SALES_WORKFLOW_ID,
skip_first_message: true
}));
await axios.post(`${env.NEXOR_API_BASE}/api/public/leads`, leads, {
headers: { "X-API-Key": env.NEXOR_API_KEY }
});
```
The API upserts (email → phone match), shallow-merges metadata, and enrolls in one request — always the array form (up to 1000 leads per call), never one request per lead: the per-run outbound request limit makes the loop a bug, not a style choice. Set `skip_first_message: false` only when the sync should start first contact immediately — the cron hour is when messages go out, and anything that messages humans needs the customer's explicit sign-off before activation.
**Rejected rungs:** the `upsertLead` effect can create the lead but cannot enroll it in the same run (effects return nothing) — only use it with a two-phase sweep when calling the API back is undesirable.
---
## 4. “Our form sends custom data; the agent should use it”
**Primitive:** metadata at intake + `metadata_key` bridges. Whenever the customer says "the agent should know/use custom information about the lead," configure the intake through metadata.
1. The sending external tool includes the data in the lead's `metadata` object on `POST /api/public/leads` (or via the inbound hook's `field_mapping`). The root is an object, while its customer-defined values may use any JSON shape:
```json
{
"metadata": {
"plan": "pro",
"score": 87.5,
"eligible": true,
"preferences": ["email", "morning"],
"crm": { "deal_id": "D-42", "products": [{ "sku": "A1", "qty": 2 }] }
}
}
```
2. The agent automatically sees the complete metadata object in its runtime prompt — every key and nested value is available as context. For purely contextual data ("mention their plan" or "use the CRM products"), metadata plus a prompt instruction is enough; do not add a field, KB, tool, or function for each key.
3. For data the agent would otherwise **ask** for, define a workflow field with `metadata_key` so it pre-fills and the agent skips the question:
```json
{ "key": "company_size", "label": "Company size", "type": "select",
"options": ["1-10", "11-50", "51-200", "200+"],
"required": true, "metadata_key": "company_size",
"intake_value_map": { "SMB": "11-50", "Enterprise": "200+" } }
```
Leads arriving without the key get asked; leads arriving with it don't. Status gates (`required_field_keys`) then work identically for both. Later metadata writes shallow-merge top-level keys, so resend the whole `crm` object when changing only `crm.deal_id`.
**Rejected rungs:** making the agent re-ask for data the form already sent wastes turns; a cloud function copying metadata into fields duplicates the `metadata_key` bridge.
---
## 5. “Qualify by salary; branch on the amount”
**Primitive:** one workflow field + one status per range (`entry_hint` carries the condition) + transfer/automation on the branch statuses. The pattern for any "when <variable> is under / over / equal to X, do Y."
1. Field: `{ "key": "monthly_income", "type": "currency", "required": true, "extraction_hints": "Ask naturally during the conversation; accept approximate figures." }`
2. Statuses (both `variable_refs: ["monthly_income"]`, `requires_all_fields: true`):
- `income_qualified` — `entry_hint: "Monthly income is confirmed and at least 800,000 — place the lead here."`, `is_terminal: true`, `transfer_config: { "target_workflow_id": "<premium-booking-agent>" }`
- `income_below_threshold` — `entry_hint: "Monthly income is confirmed and below 800,000 — place the lead here."` Route per the customer's intent: a terminal `category: "lost"` status if unqualified means discard (recipe 14), a terminal status with `transfer_config` to a nurture agent, a `futurology_queue` parking status for later recontact, or a status automation notifying their system.
3. Encode each range as a server-evaluated rule so routing is deterministic:
```json
{ "transition_rules": {
"auto_evaluate": true,
"rule_groups": [{ "label": "Below threshold", "logic": "AND",
"conditions": [{ "field": "monthly_income", "operator": "<", "value": 800000 }] }],
"group_logic": "OR" } }
```
The platform routes the lead the moment `save_field` stores the income — the branch never depends on the agent choosing to call `set_lead_status`; the `entry_hint` stays as guidance. Note the server's blocking gates protect only non-terminal and `won` targets — entry into terminal branch statuses (transfer/lost) is not blocked server-side, which is exactly why the rule groups, not agent placement, should own the routing.
4. Equality/multi-band cases are just more statuses (`under 800k` / `exactly the promo tier` / `over 800k`), each with an unambiguous, mutually exclusive `entry_hint`.
The rule lives in `entry_hint` — not `description`, which the agent never sees.
**Rejected rungs:** a cloud function on `information.collected` could compare numbers in code, but auto-evaluated statuses give the same determinism while keeping the routing visible and editable by operators — the code earns nothing.
---
## 6. “Route qualified leads into high-intensity sales or low-intensity nurture”
**Primitive:** a qualification-router workflow with multiple terminal transfer statuses. One agent qualifies and filters; each target agent implements a different conversation and outreach intensity.
1. Define the shared qualification fields on the source: fit, need, and buying timeline. Require them before either qualified branch.
2. Create two mutually exclusive terminal statuses. `create_workflow` statuses accept this full shape directly (only `transfer_config.target_workflow_id` must wait for the per-status pass when the target agent does not exist yet):
```json
[
{
"key": "qualified_now",
"label": "Qualified Now",
"category": "won",
"is_qualified": true,
"is_terminal": true,
"entry_hint": "All qualification fields are complete, the lead is a fit, and the confirmed timeline is now.",
"variable_refs": ["fit", "need", "timeline"],
"requires_all_fields": true,
"required_field_keys": ["fit", "need", "timeline"],
"transfer_config": { "target_workflow_id": "<sales-now-agent>" }
},
{
"key": "qualified_later",
"label": "Qualified for Later",
"category": "deferred",
"is_qualified": true,
"is_terminal": true,
"entry_hint": "All qualification fields are complete and the lead is a fit, but the confirmed timeline is later.",
"variable_refs": ["fit", "need", "timeline"],
"requires_all_fields": true,
"required_field_keys": ["fit", "need", "timeline"],
"transfer_config": { "target_workflow_id": "<nurture-later-agent>" }
}
]
```
3. Configure `<sales-now-agent>` with a hyper-specific sales/appointment prompt, only the ownership and booking tools it needs, stage gates for those tools, and the high-intensity contact schedule approved by the customer.
4. Configure `<nurture-later-agent>` with an educational, low-pressure prompt and email as its only outbound nurture channel. A bare "nurture" ask defaults to exactly this: a separate, low-intensity, **email-only** workflow that sends roughly **one email every 2-4 weeks** (`336`–`672` hours) — not the sales cadence slowed down. Do not copy the source qualification fields into it; instruct it to read fit, need, and timeline from the transfer chain.
5. For a three-month nurture example at approximately two emails per month, configure the target's active nurture status with:
```json
{
"name": "Qualified later — biweekly email",
"workflow_id": "<nurture-later-agent>",
"trigger_status_key": "nurture_active",
"stale_after_hours": 336,
"stale_reference": "last_message_at",
"cooldown_hours": 336,
"max_attempts": 6,
"on_exhausted": "mark_cold",
"priority": 5,
"prompt_hint": "Send one useful, low-pressure nurture email based on the transferred need and timeline. Do not re-qualify or push for an immediate meeting.",
"conditions": {},
"is_active": true
}
```
Enable the client's re-engage agent before creating this rule. Ensure the nurture workflow has a usable email sender and set `workflows.config.disabled_channels: ["call", "whatsapp", "sms"]` if email must be the only choice. `336` hours makes the lead eligible every 14 days; the re-engage decision may still skip a contact. `max_attempts: 6` bounds this example to six attempts, so change both frequency and horizon to the customer's approved policy. If exactly two sends per month is a hard deterministic contract rather than nurture guidance, use a scheduled/background automation with send-once state instead of an AI recontact rule.
6. Treat both handoffs as an expected connection manifest. After the source and both targets have real ids, update the source statuses even if the source was created first, then read the source back and prove `qualified_now → <sales-now-agent id>` and `qualified_later → <nurture-later-agent id>`. Repair and re-read any missing or misdirected edge before calling the system complete.
7. Verify each branch with a raw conversation: `qualified_now` must create a fresh sales run and start the sales cadence; `qualified_later` must create a fresh nurture run with the biweekly rule. Confirm both targets read the transfer chain without asking for fit, need, or timeline again.
Handoff messaging is composed automatically (contextual transition when the messaging window is open, otherwise the target's template cascade). Do **not** also aim a welcome job/automation at freshly transferred leads, and do **not** script hand-off dialogue on the source — the source moves the lead and ends its turn; the target speaks next (recipe 22). Apply the 80% test within one intent only: if the two targets share the same intent and register and nearly all prompt, tools, channels, and schedule, use one mode with more statuses instead — never merge two different intents or registers into one mode.
---
## 7. “Remind the lead before the meeting”
**Primitive:** rules. Nothing else — not a cron, not a function. Host notifications ("tell the rep when a meeting books") are also rules; only "notify our *external API / CRM*" needs a webhook on `meeting.created`.
1. `get_reminder_catalog({ workflow_id })` — which channels this account can use (`available_for_account`), which trigger events are dispatched, and the default templates. Propose only channels it marks available.
2. `list_reminder_rules({ workflow_id })` — dedupe on `trigger_event + channel + delay_minutes`.
3. For each WhatsApp rule, resolve an APPROVED template by intent (`sync_whatsapp_templates` → `list_whatsapp_templates({ status: "APPROVED" })`); when none matches, draft one (`review_whatsapp_template_drafts` → `create_whatsapp_templates_batch`) and create the rule `is_active: false` until Meta approves. Never reuse an opening/cold-contact template.
4. Present the whole set once, then write it after approval:
```json
[
{ "trigger_event": "event_created", "channel": "whatsapp", "template_name": "meeting_confirmation", "delay_minutes": 0, "delay_reference": "trigger", "workflow_id": "<id>", "name": "Confirmation" },
{ "trigger_event": "event_created", "channel": "whatsapp", "template_name": "meeting_reminder", "delay_minutes": -1440, "delay_reference": "event_start", "workflow_id": "<id>", "name": "24h before" },
{ "trigger_event": "event_created", "channel": "whatsapp", "template_name": "meeting_reminder", "delay_minutes": -120, "delay_reference": "event_start", "workflow_id": "<id>", "name": "2h before" },
{ "trigger_event": "event_created", "channel": "retell", "template_name": "notification_10min", "delay_minutes": -10, "delay_reference": "event_start", "workflow_id": "<id>", "name": "Call 10 min before" }
]
```
Each row is one `set_reminder_rule` call. `delay_minutes` is signed; `delay_reference: "event_start"` counts from the meeting, `"trigger"` from the booking. There is no reschedule trigger — `event_created` fires again for the new time. A rep notification is `set_host_reminder_rule({ trigger_event: "event_created", channel: "email", template_name: "default", workflow_id })`; host cancellation/reschedule alerts are on by default. Full contract: [booking-agent.md §5](booking-agent.md#5-reminders--the-full-contract).
**Rejected rungs:** a cron job or function re-derives timing the rules engine already owns — pure maintenance with no new capability; a channel the catalog marks unavailable is a rule that never sends.
---
## 8. “After every completed meeting, send a survey — once”
**Primitive:** background job (dry-run first).
```json
{
"trigger_type": "cron", "cron_expression": "0 18 * * *",
"workflow_filters": [{ "workflow_id": "<sales-agent>", "status_keys": ["meeting_completed"] }],
"steps": [{ "type": "action",
"action": { "action": "send_message",
"channel": "whatsapp", "template_name": "satisfaction_survey",
"then_status_key": "surveyed" } }],
"max_leads_per_cycle": 200, "cooldown_minutes": 10080, "dry_run": true,
"exclusions": { "skip_human_takeover": true, "skip_paused": true }
}
```
`then_status_key` is the send-once mechanism: once surveyed, the lead leaves the filter. Cohort messaging without it re-sends every cycle. Template must be approved. Read the dry-run candidate list, then set `dry_run: false`.
**Rejected rungs:** a `meeting.completed` cloud function cannot send templated messages (function effects are lead/metadata updates only); the job's `then_status_key` gives send-once for free.
---
## 9. “When a lead unsubscribes, flag them in our CRM”
**Primitive:** cloud function on `lead.unsubscribed` → CRM HTTP call, plus `updateMetadata({ crm_flagged: true })`. An agent tool is wrong here — unsubscribes also happen outside conversations. An outbound webhook cannot carry this either: `lead.unsubscribed` is not a webhook event (webhooks cover status changes, meeting lifecycle, and outreach failures). The function is the deterministic path; its code is earned by the trigger, not the payload shaping.
---
## 10. “Re-activate leads whose follow-up date arrived”
**Primitive:** background job first; scheduled function only if the date needs computing.
- Date parked in a collected field: cron job, `evaluate_field` with `date_before_today`, `on_match` → `set_lead_status` back to an active status (or `assign_workflow` to a recontact agent).
- Date parked in a tag (`followup:2026-09-01`): `evaluate_tag` with `date_in_tag`, `trigger_when: "past"`.
- Date must be computed (e.g. "90 days after their contract ends" from metadata math): scheduled function — lookup on the parked status/tag, compute in code, `setLeadStatus` per lead.
Statuses meant for parking should be `futurology_queue` buckets so booked/won leads can't be demoted into them.
A wait measured from *entering a stage* ("after 3 days in Proposal sent…") is not a stored date and not this recipe: it is `timeout_config` on that stage (recipe 23).
---
## 11. “Enrich every new lead from an external API”
**Primitive:** decided by *when* the enrichment must be ready.
**If it must be ready before the agent's first message → pre-execution hook, not code.** Register the enrichment endpoint as a workflow tool, then:
```json
{ "tool": "set_workflow_tool_execution",
"args": { "workflow_id": "<agent-id>", "tool_id": "<tool-id>", "mode": "on_entry", "call_once": true } }
```
It fires on workflow entry, ahead of the first message, with no model decision and no JavaScript. It also re-fires when a lead is *transferred in*, which is how a target agent arrives with context already loaded. Note it cannot be stage-gated — the call is rejected if you pass `available_in_statuses`.
**If it must react to a later event → cloud function** on `lead.created`, or `lead.updated` guarded on the relevant field actually changing (`ctx.changed_fields`). Fetch with `axios` + `env.KEY`, then `updateMetadata({ ...enrichment })` / `updateLead({...})`. Code is earned by the event, not the payload shaping.
**Rejected rungs:** a cloud function on `lead.created` to enrich before first contact re-implements what `mode: "on_entry"` does natively, and needs the `skip_first_message: true` + re-activate dance to win the race; an agent-callable enrichment tool can be skipped entirely.
---
## 12. “One sales conversation with contacted / qualified / booked stages”
**Primitive:** one mode, statuses only — because contacted / qualified / booked are one intent (close this sale) in one register. Splitting a *single* intent into three modes chained by transfers is the canonical mistake: every hop resets cadence, fragments conversation history, and re-triggers first-contact logic. This recipe is not a licence for one broad mode: the moment a second intent or register appears (a follow-up chase, a discount, a payment step) it becomes its own mode (recipes 22, 23, 25), and the default shape of an agent is 2–3 modes.
---
## 13. “Once qualified, assign a HubSpot sales rep round-robin and then book”
**Primitives:** required intake fields → `qualified` status → deterministic status automation for round robin → `assignment_ready` after success → stage-gated availability and booking tools. The client's endpoint owns round robin; state transitions invoke and verify assignment without depending on the model, while the agent retains only the tools that require conversational choices.
First check who owns the rotation: if "round robin" just means distributing leads among the customer's own team members *in Nexor*, native `assignment_config: { "mode": "round_robin", "agent_ids": [] }` on the qualified stage does it with zero integration (reference §1) — stop there. This recipe is for rotation owned by the client's CRM or endpoint.
1. Define every qualification input as a required workflow field. Configure `qualified` with `requires_all_fields: true` and `required_field_keys` containing those keys. Its `entry_hint` must state the customer's actual qualification rule.
2. Register the client's assignment endpoint as the backing tool `assign_sales_rep`. Pass stable facts such as `lead_id`, territory, and product. Require a response containing `rep_id`, `rep_name`, and `assignment_id`; never ask the model to choose among representatives.
3. Invoke it deterministically when the lead enters `qualified`:
```json
{
"status_automations": [{
"key": "assign_rep_on_qualified",
"on_status_key": "qualified",
"action": "run_tool",
"tool": "assign_sales_rep",
"args_template": {
"lead_id": "{{lead.id}}",
"territory": "{{lead.metadata.territory}}",
"product": "{{lead.metadata.product}}"
},
"skip_if_metadata": "sales_assignment_id"
}]
}
```
`args_template` supports `{{lead.<path>}}` / `{{lead.metadata.<key>}}` only — collected workflow fields are not addressable. Pass `lead_id` and stable metadata paths (mirror needed field values into metadata via a post-tool hook or the agent's `patch_metadata` before qualification), or let the endpoint fetch the details itself.
4. Configure tool access and duplicate-call protection:
```json
[
{
"name": "assign_sales_rep",
"available_in_statuses": ["qualified"],
"call_once": true,
"llm_response_fields": ["rep_id", "rep_name", "rep_email", "assignment_id"]
},
{
"name": "get_available_slots",
"available_in_statuses": ["assignment_ready"],
"call_once": false
},
{
"name": "create_event",
"available_in_statuses": ["assignment_ready"],
"call_once": true
}
]
```
`get_available_slots` and `create_event` are runtime agent tools — not callable over MCP. They appear here only because `available_in_statuses` gates them exactly like a client tool; you never invoke them yourself. If the client's endpoint should pick the host for Nexor's own booking instead of running its own booker, `set_host_assigner({ workflow_id, tool: "assign_sales_rep", id_field: "rep_id", external_key: "<crm key>" })` is the native shape — see [booking-agent.md §6](booking-agent.md#6-host-routing).
5. Booking here depends on the assigned representative, so gate availability and booking to `assignment_ready`, not merely `qualified` — and make the transition structural. A status automation can only run a tool; it cannot move the lead. Two working shapes:
- Keep the status automation as the caller; a post-tool hook persists `assignment_id` to `metadata.sales_assignment_id`, and a cloud function on `lead.updated` guarded on that key moves the lead to `assignment_ready`.
- Or replace the status automation with one cloud function on `workflow.status_entered` for `qualified`: call the assignment endpoint with `axios`, persist the returned IDs, and set `assignment_ready` only on success — call and transition succeed or fail together.
A failed call leaves the lead in `qualified`. If booking did *not* depend on the assignment result, skip `assignment_ready` and gate availability/booking at `qualified` directly.
6. Give the agent the execution sequence. Do not make it responsible for firing round robin:
```text
Collect and save every required qualification field. Move the lead to qualified only when
the configured qualification rule is satisfied; entering that status assigns the representative
automatically. Never select a representative or call the assignment tool conversationally.
Wait until the workflow reaches assignment_ready. Then offer scheduling and call get_available_slots only when
the lead wants to book. Call create_event with a returned slot. If any integration fails, do not
claim that assignment or booking succeeded.
```
7. Keep rotation state, rep eligibility/capacity, territory rules, and HubSpot ownership in the client endpoint. Make assignment and booking idempotent and store `assignment_id` / meeting ID in metadata through post-tool hooks.
8. Verify determinism and boundaries: enter `qualified` without telling the agent to call assignment and confirm the endpoint runs exactly once. Confirm failed assignment never reaches `assignment_ready`. Before that status, availability and booking must return `tool_not_available_in_stage` without reaching their endpoints; after it, they may run. Confirm repeated assignment/booking is blocked or returns the original idempotent result.
**Rejected rungs:** an agent-selected assignment tool can be skipped or hallucinated; prompt-only instructions do not prevent premature booking calls; reimplementing round robin in Nexor creates a second, drifting source of truth. Prefer the single-function shape over the automation when assignment needs custom computation, a multi-step request, or when success must atomically unlock `assignment_ready` (a status automation cannot move the lead).
---
## 14. “If the lead doesn’t qualify, stop contacting them”
**Primitive:** agent config only — a terminal `category: "lost"` status carrying the discard rule. The rule is configured visibly in the agent's behavior and processed internally during the conversation; the status ends outreach deterministically.
1. Put the discard rule in the status's `entry_hint` (e.g. `unqualified` — "Lead confirmed there is no budget / they are outside the service area — place the lead here."), with `variable_refs` + `requires_all_fields` when the rule depends on collected field values.
2. Configure `category: "lost"` and `is_terminal: true`. Entering the status stops all proactive outbound (cadence and jobs skip the lead) and the run cannot be reactivated. The agent still replies briefly and kindly if the lead writes in — built-in lost-lead behavior: no selling, no booking offers. Add `pause_bot` only if the customer wants total silence, including to inbound messages. Encode the disqualifying condition in `transition_rules.rule_groups` with `auto_evaluate: true` when field values fully determine it.
3. Do not confuse the neighbors: `pause_bot` is a hold (agent stops responding while a human reviews; the run stays live) and `futurology_queue` is a deferral (park now, recontact later — recipe 10). Only the terminal `lost` status discards. Avoid status keys starting with `future_` (or `contact_later` / `colder`) for a hard discard — those are soft terminals and recontact keeps running for them.
4. If the customer's CRM or external API must also know, attach a filtered webhook (recipe 1) or status automation (recipe 2) to the discard status.
**Rejected rungs:** a background job that "deactivates unqualified leads" re-derives what the status already enforces; a prompt line saying "don't message unqualified leads" is speech, not structure — nothing stops the cadence.
---
## 15. “WhatsApp 24/7 instantly, but only call during business hours”
**Primitive:** cadence config (reference §10). This is mostly default behavior plus contact windows — no code, no per-channel scheduler.
1. Define the contact windows (blocks) in the workflow's timezone (`workflows.timezone`): e.g. `09:00–13:00` and `15:00–19:00`, `days_of_week: [1,2,3,4,5]`.
2. Calls are automatically restricted to those windows — that gating is hard-coded and can't be turned off (failsafe 09:00–21:00 if no blocks exist).
3. Leave `config.gate_outbound_to_hours` at its default `false` so WhatsApp, email, and SMS stay 24/7. Set it `true` only when the customer wants *everything* inside the windows — the flag is workflow-wide; per-channel windows are not configurable.
4. Instant behavior is built in: fresh runs fire the hot-contact cascade immediately (default whatsapp → call → email → sms; `first_contact_channel` picks the opener; the call step waits for a window), and inbound replies always get an immediate agent response regardless of windows.
5. Per-window intensity comes from `dayConfig` (`initial_*_per_block` decaying to `min_*`) or automatically from window length under `contact_caps_mode: "automatic_v1"` (≤4h → 1 touch per channel, ≤6h → 2, longer → 3). Turn a channel fully off with `config.disabled_channels`, never with a zero cap.
6. "Email at most 4–5 times a week" is **not expressible as a weekly cap** — no weekly knob exists. Approximate it: `initial_emails_per_block: 1` with one block per day gives ≈5 sends over `max_days: 5`, or move email out of cadence into a background job with `cooldown_minutes` (e.g. 1440–2880). Tell the customer which approximation you chose; do not promise a per-week guarantee.
**Rejected rungs:** a cloud/scheduled function re-implementing the scheduler fights the cadence engine (and still can't ungate calls); prompt instructions like "only call in the morning" don't control outbound scheduling at all.
---
## 16. “Give each agent only the knowledge bases it needs”
**Primitive:** account-level knowledge-base catalog plus per-agent assignment links (reference §11). Knowledge ownership and agent access are separate.
Suppose the account owns `General Company FAQ`, `Qualification Policy`, and `Pricing & Products`, while a qualification agent should use the first two and a closer should use the first and third.
1. Call `list_knowledge_bases({})` once to resolve the three real KB ids from the account catalog.
2. Record the exact desired assignment manifest before writing:
```json
[
{
"agent_ref": "qualifier",
"knowledge_bases": ["<general-faq-id>", "<qualification-policy-id>"]
},
{
"agent_ref": "closer",
"knowledge_bases": ["<general-faq-id>", "<pricing-products-id>"]
}
]
```
3. After both agents have real ids, call `list_knowledge_bases({ "workflow_id": "<agent-id>" })` for each. Attach every missing expected link and detach every unexpected link. Detaching from one agent does not delete the KB or affect another agent that shares it.
4. Read both workflow-scoped lists again. Completion means exact id-set equality and the intended priority order for each agent. The account-wide result is not verification.
5. If a required KB does not exist, create it once at account scope, attach its returned id only where planned, and include it in the same read-back audit.
**Rejected rungs:** copying KB content into prompts creates stale duplicate facts; attaching every account KB to every agent leaks irrelevant context; deleting a KB to remove one agent's access can break every other agent sharing it.
---
## 17. “Partway through, the agent should change its tone / start selling / hand over”
**Primitive:** decided by the Law 2 / Law 3 discriminator — one more status, or a boundary status plus a transfer. Never prompt prose.
**The illegal shape.** A single agent whose prompt says *“start friendly and informative; once the lead confirms budget, switch to a closing tone and push for the meeting.”* Nothing about that switch is enforceable: the tool set does not change, no automation can fire on it, no webhook can observe it, no job can filter on it, and the model may switch early, late, or never. Same for a `mode` boolean written to metadata — no gate, filter, or automation reads it as a stage.
**Step 1 — run the discriminator.** What actually changes at the switch?
- Only what is *known* about the lead changes; goal, persona, tools, channels, and cadence stay the same → **Law 2: one more status.**
- Goal, prompt content, tool set, channel mix, or contact intensity changes → **Law 3: a second mode behind a boundary status.** This is the default answer whenever the *intent* changes (informing → selling → chasing) or the *register* changes (friendly → insistent); the operator must explicitly ask for one single mode to keep it in one prompt (SKILL.md, "Default shape").
**Step 2a — the status answer (Law 2).** Make the switch a stage of the same pipeline:
```json
{
"key": "budget_confirmed",
"label": "Budget confirmed",
"entry_hint": "The lead has stated and saved a budget at or above the qualifying amount — place the lead here.",
"variable_refs": ["budget"],
"required_field_keys": ["budget"],
"transition_rules": {
"auto_evaluate": true,
"rule_groups": [{ "label": "Qualifying budget", "logic": "AND",
"conditions": [{ "field": "budget", "operator": ">=", "value": 5000 }] }],
"group_logic": "OR"
}
}
```
The emphasis shift belongs in that status's `entry_hint` and in per-status prompt guidance — but the *enforcement* is structural: gate `get_available_slots` / `create_event` with `available_in_statuses: ["budget_confirmed"]`, and attach any CRM push as a status automation on `on_status_key: "budget_confirmed"`. The agent cannot book early even if it decides to talk like a closer early.
**Step 2b — the agent answer (Law 3).** If the closer genuinely has a different goal, prompt, tool set, or cadence, make `budget_confirmed` a boundary status and hand the lead over:
```json
{
"key": "budget_confirmed",
"label": "Budget confirmed — to closer",
"category": "won",
"is_terminal": true,
"entry_hint": "The lead has stated and saved a qualifying budget — place the lead here.",
"variable_refs": ["budget"],
"required_field_keys": ["budget"],
"transfer_config": { "target_workflow_id": "<closer-agent-id>" }
}
```
The closing tone, the booking tools, and the higher-intensity cadence now live on `<closer-agent-id>`, not in a paragraph. Instruct the closer to read budget from the transfer chain rather than re-asking — transferred fields are a read-only snapshot, not copied into its own fields. Write the boundary silently on the source: when the criterion is met it moves the lead and ends its turn, with no hand-off dialogue; the closer speaks next from its own arrival rule (recipe 22 shows the payment-link variant).
If the handover must wait on *elapsed time* ("48h with no reply, then hand over"), that is still the terminal shape: `timeout_config` on the waiting status aimed at the terminal transfer status (recipe 23). Only when it must wait on something outside the conversation (a human review, a nightly batch, an external signal) use the pause boundary instead: `pause_bot: true` on the status and a background job with the `workflow_transfer` action as the named executor. A pause boundary with no executor parks the lead silently forever.
**Step 3 — verify structurally, not conversationally.** Before the boundary, the closer-only tools must return `tool_not_available_in_stage`; after it, a fresh run must exist on the target agent at *its* initial status with the source run deactivated. A conversation that merely *sounds* like it switched proves nothing.
**Rejected rungs:** a prompt-only persona switch is speech, not structure; a `mode` flag in metadata is invisible to every gate and filter; a tool whose purpose is “change tone” gives the model a decision the pipeline should own; three agents for what is one conversation with three stages resets cadence at every hop (recipe 12).
---
## 18. “Create two agents using the account’s existing channels”
**Primitive:** account channel inventory + allocation manifest + per-agent binding (reference §12). Availability and assignment are different.
Suppose the account has one WhatsApp number and one active call route assigned to a distinct legacy **Intake** workflow, two send-capable email senders, and that phone's SMS route is unassigned. The customer asks to create two new agents: **Qualifier** and **Closer**.
1. Run `get_account_readiness`, then `list_whatsapp_numbers`, `list_email_senders` and `list_phone_numbers`. Use the saved timezone. Never ask whether the account already has those channels.
2. Build one allocation table before any mutation:
```json
[
{ "agent_ref": "qualifier", "channel": "whatsapp", "resource_id": "<wa-id>", "current_owner": "<legacy-intake-id>" },
{ "agent_ref": "qualifier", "channel": "call", "resource_id": "<phone-id>", "current_owner": "<legacy-intake-id>" },
{ "agent_ref": "closer", "channel": "email", "resource_id": "<closer-email-id>", "current_owner": null },
{ "agent_ref": "closer", "channel": "sms", "resource_id": "<phone-id>", "current_owner": null }
]
```
The same physical phone id is valid here because call and SMS have independent owner columns. Assigning that WhatsApp or call capability to **Closer** too would be an invalid duplicate claim. Sharing one email sender would be valid.
3. The final summary must explicitly say: “Move WhatsApp `<label>` and the call route on `<phone label>` from legacy **Intake** to new **Qualifier**; inbound WhatsApp/calls will route to **Qualifier**, and **Intake** loses those direct channels.” Require approval of that exact impact. Immediately before both assignment writes, re-run inventory; if either owner is no longer **Intake**, stop and re-approve against the live owner.
4. Apply ids only after approval. Set `disabled_channels` to match each agent's manifest and make sure `first_contact_channel` remains enabled and usable. If a new resource is chosen, pause: complete its integration setup, repeat inventory, replace the placeholder with a real id, and then return to review.
5. Read back both new agents plus displaced legacy **Intake**. The selected ids, `disabled_channels`, `first_contact_channel`, direct owners, and SMS owner must all match. Disable WhatsApp/call on **Intake** or apply its separately approved replacements. If **Qualifier** initiates WhatsApp outreach, select a real row from `approved_openers`, call `set_opening_templates`, and verify the workflow-specific result with `get_template_pool` before activation.
**Rejected rungs:** asking “do you have WhatsApp?” ignores account data; assigning the same exclusive capability to both agents causes a reconciliation loop; treating SMS and call as one owner discards supported routing; generic build approval does not authorize breaking an existing agent; a placeholder “new number” is not a binding.
**Note on SMS above:** SMS is never provisioned here. Because the account already has an active Twilio number, SMS is activated per agent on that existing number with `set_number_sms({ number_id, enabled: true, sms_workflow_id })` — no buy-a-number step (a Twilio-carrier number is required; there is no country restriction).
---
## 19. Build a two-agent system end to end, from brief to live
**Primitive:** the full protocol. This is the shape every multi-agent build follows; earlier recipes are the parts. Brief: *"Qualify inbound leads, and once they're qualified hand them to a closer that books meetings."*
### Phase 1 — orient (no mutations)
```
describe_agent_configuration # the platform's own current surface map + required process
list_workflows / get_workflow # what already exists; reuse before creating
list_client_tools, list_webhooks, list_knowledge_bases({})
get_account_readiness # saved timezone, blockers, next tools
list_whatsapp_numbers, list_email_senders, list_phone_numbers # real channel resources (recipe 18)
```
### Phase 2 — decompose with the three laws
Two agents, because the closer differs in goal, prompt, tools, and cadence — not merely in what is known (SKILL.md, "Law 2 or Law 3?"). Write the three ledgers before touching the plan:
- **Variables:** `fit`, `need`, `timeline` (all `required`), plus `territory` arriving as metadata. Because a status automation must later send `territory`, it must exist in metadata — it does, so no mirroring is needed.
- **Stages (qualifier):** `new` (initial) → `engaged` → `qualified` (terminal boundary) and `unqualified` (terminal, `category: "lost"`).
- **Boundary:** `qualified` → closer, terminal handoff. Note the key is *not* `future_*` / `contact_later` / `colder`.
### Phase 3 — preflight and sign-off
```json
{ "tool": "review_agent_system_plan", "args": { "plan": {
"agents": [
{ "ref": "qualifier", "name": "Qualifier", "goal_type": "qualification",
"primary_responsibility": "Collect fit, need and timeline, then route.",
"language": "es", "timezone": "America/Santiago", "channels": ["whatsapp", "email"],
"statuses": [
{ "key": "new", "is_initial": true },
{ "key": "engaged", "entry_hint": "The lead has replied at least once." },
{ "key": "qualified", "entry_hint": "fit, need and timeline are saved and the lead is a fit.",
"transfer_to_agent_ref": "closer" },
{ "key": "unqualified", "entry_hint": "The lead confirmed no need or no budget." }
], "activate": false },
{ "ref": "closer", "name": "Closer", "goal_type": "appointment",
"primary_responsibility": "Book a meeting with an already-qualified lead.",
"language": "es", "timezone": "America/Santiago", "channels": ["whatsapp"],
"statuses": [ { "key": "new", "is_initial": true },
{ "key": "booked", "entry_hint": "A meeting exists on the calendar." } ],
"activate": false }
],
"open_questions": ["Confirm the closer's cadence intensity."] } } }
```
Resolve every `blocking_issue` and `clarification_question`, then show the summary and close with "Does this look ok? Let me know and I'll build it." **No mutation happens before the user approves the summarized plan. The `plan_fingerprint` is internal: never show it or the word "fingerprint" to the user.**
### Phase 4 — build (agents are created paused)
```
create_workflow(name:"Qualifier", goal_type:"qualification",
statuses:[{key:"new",name:"New",is_initial:true},
{key:"engaged",name:"Engaged",entry_hint:"…"},
{key:"qualified",name:"Qualified",category:"won",is_terminal:true,
entry_hint:"…", required_field_keys:["fit","need","timeline"],
transition_rules:{auto_evaluate:true, rule_groups:[…]}},
{key:"unqualified",name:"Unqualified",category:"lost",is_terminal:true,entry_hint:"…"}],
fields:[{key:"fit",label:"Fit",type:"text",required:true,
extraction_hints:"…", metadata_key:"fit"}, …])
```
The `fields` array must be **complete here** — `metadata_key`, `options`, `extraction_hints` and `validation` cannot be added later through MCP.
**`create_workflow` statuses carry the full stage config, and the array is the entire funnel.** Each status needs `key` plus `name` (or `label`); `is_initial`/`is_terminal`, `sort_order`, `entry_hint`, `category`, gates, `timeout_config`, `pause_bot` and the rest are all settable at create — configure everything except `transfer_config` targets that do not have real ids yet. The backend seeds a default pipeline on create and the tool prunes it back to your list, returning a `pipeline_reconciliation` report — **check it after every create** and resolve any leftover, missing, or warning entries before continuing. Plan-only key: `transfer_to_agent_ref` belongs to `review_agent_system_plan`, never to `create_workflow`. `update_workflow_structure` statuses still use `label` only.
```
create_workflow(name:"Closer", goal_type:"appointment", …) # note: needs an active meeting type to activate
create_workflow_tool(closer, …) → set_tool_stage_gate(closer, <id>, ["new"])
update_workflow_config(qualifier, { config: { status_automations: [ … ] } }) # send the whole array
configure_status_webhook(qualifier, "qualified", …)
attach_knowledge_base(qualifier, <policy-kb>, priority:1)
```
**Transfers last**, once the closer has a real id:
```
update_workflow_status(qualifier, "qualified", transfer_config:{ target_workflow_id:"<closer real id>" })
```
### Phase 5 — read back, then activate separately
```
get_workflow(qualifier) # qualified: is_terminal ✓, transfer_config → closer's real id ✓
get_workflow(closer) # initial status, prompt, gates
list_workflow_tools(closer) # stage gate present
list_knowledge_bases({workflow_id: …}) # exact ids and priority order
list_meeting_types(closer) # appointment agents cannot activate without one
set_workflow_active(closer, true) # target first, so a transfer never lands on a paused agent
set_workflow_active(qualifier, true)
```
**Activate the target before the source.** The transfer path does not check whether the target is paused: it deactivates the source run and creates the target run either way. If the target is still paused at that moment, the lead has been handed off into an agent that will not speak. Ordering activation target-first removes the window entirely.
**Rejected rungs:** creating both agents then wiring transfers from memory (creation order never satisfies a connection — read back); building three agents for qualify/engage/book (recipe 12); activating during the build; naming the boundary `future_qualified` (soft terminal — the transfer silently never fires).
---
## 20. “Change the agent's call voice — make it male / female / a specific voice”
**Primitive:** agent config only — the voice the call agent speaks with. One tool does the whole job: `set_workflow_voice`. It resolves the request against the client's voice catalog and writes the *effective* voice where the call runtime reads it — you do not edit `ai_config` by hand.
The one trap this recipe exists to kill: **gender is not a free-text prompt line, and it is not `ai_config.style.gender` alone.** Telling the agent "usa una voz de hombre" in the prompt, or flipping only the grammatical gender, does **not** change the voice the lead hears. The lead keeps hearing the old voice. You must select a real catalog voice.
**When the ask names a gender ("que sea voz de hombre" / "a female voice"):**
```
set_workflow_voice(workflow_id, gender:"male")
```
The server picks a male voice in the workflow's language, sets it as the effective voice, and aligns the grammatical gender to match. The response returns `resolved_voice` — read back `resolved_voice.name` and share `resolved_voice.preview_url` (a playable .mp3) so the user can hear it before trusting it.
**When the ask names a specific voice, or you want to let the user choose:**
```
list_workflow_voices(workflow_id) # each voice has a preview_url sample
set_workflow_voice(workflow_id, voice_id:"<id from the list>")
# or, to match by name:
set_workflow_voice(workflow_id, voice_name:"Kailey", gender:"female")
```
Always pass the `voice_id` (not the catalog row `id`) when you have it. Combine `voice_name` + `gender` when a name is ambiguous.
**Tuning (speed, temperature, volume) is a separate concern** — those go in the `voice` object and do not select a voice:
```
set_workflow_voice(workflow_id, voice:{ voiceSpeed:1.1 })
```
You can switch and tune in one call: `set_workflow_voice(workflow_id, gender:"male", voice:{ voiceSpeed:1.05 })`.
**Failure modes to surface, not swallow:** an unknown `voice_id` or a gender with no match in the workflow's language returns `INVALID_VOICE` with `available_voices`. Do not silently fall back — tell the user the catalog has no such voice for their language, list what is available (with preview URLs), and offer to add one via the add-language flow.
**Rejected rungs:** a prompt line asking the agent to "sound male" (speech, not the TTS voice); setting only the grammatical gender field (changes es/pt self-reference wording, never the voice); hand-writing `ai_config.voice.voice_id` (the call runtime reads top-level `ai_config.voice_id`, so a nested-only write is ignored — the exact bug this tool now prevents).
---
## 21. Build a bookable appointment agent on a fresh account
**Primitive:** the booking path — agent config + meeting type + hosts + availability + calendar + rules. Brief: *"An agent that books 30-minute video demos with our two sales reps, with WhatsApp reminders."* Full contracts in [booking-agent.md](booking-agent.md).
### Phase 1 — orient (no mutations)
```
get_account_readiness # timezone, team, existing agents, per-agent booking blockers
list_team # user_id of each rep
get_integration_status # calendars[] and booking_providers[] — who is already connected
get_reminder_catalog # which reminder channels this account can use
```
### Phase 2 — plan and review
Plan the appointment agent with its booking block, then `review_agent_system_plan`; it refuses the plan until `meeting_type`, `hosts[]` and a calendar plan are present:
```json
{ "agents": [{
"ref": "demo_booker", "name": "Demo booker", "goal_type": "appointment",
"primary_responsibility": "Qualify interest and book a 30-minute demo.",
"language": "en-US", "timezone": "America/New_York", "channels": ["whatsapp"],
"statuses": [
{ "key": "new", "is_initial": true },
{ "key": "demo_booked", "entry_hint": "A demo slot has been confirmed with the lead — place the lead here.", "is_booking_target": true }
],
"meeting_type": { "name": "Demo", "duration_minutes": 30, "location_type": "video", "video_provider": "google_meet" },
"hosts": [{ "user_id": "<rep-1>" }, { "user_id": "<rep-2>" }],
"calendar_plan": { "provider": "google" },
"activate": false
}], "open_questions": [] }
```
Show the summary, close with "Does this look ok? Let me know and I'll build it.", wait for the user's approval (never show the `plan_fingerprint`).
### Phase 3 — build, in dependency order (agent stays paused)
```
create_workflow({ goal_type:"appointment", timezone:"America/New_York", language:"en-US", statuses, fields })
create_meeting_type({ workflow_id, name:"Demo", duration_minutes:30, location_type:"video", video_provider:"google_meet" })
add_executive({ workflow_id, user_id:"<rep-1>" }) # and rep-2
set_host_schedule({ user_id:"<rep-1>", timezone:"America/New_York",
slots:[{ weekday:1, start:"09:00", end:"17:00" }, …] }) # and rep-2
connect_calendar({ user_id:"<rep-1>", provider:"google", confirm:true }) # → connect_url — send it to rep-1
connect_calendar({ user_id:"<rep-2>", provider:"google", confirm:true }) # → connect_url — send it to rep-2
set_reminder_rule(…) # the recipe-7 set, channels the catalog allows
set_host_reminder_rule({ trigger_event:"event_created", channel:"email", template_name:"default", workflow_id })
set_meeting_type_routing({ workflow_id, agent_selection_rules:{ routing:"cycle", tiers:[{ agents:["<rep-1>","<rep-2>"], mode:"round_robin" }] } })
```
The calendar step is the human one: say exactly who must open which link, then poll `get_calendar_connections({ user_id })` for each rep until `status: "active"`. Do not proceed to activation on a promise.
### Phase 4 — read back, then activate separately
```
get_account_readiness # the agent's booking block: zero blockers, no fix_tools
get_integration_status # calendars[] both active
list_executives, get_host_availability, list_reminder_rules, list_meeting_types
get_workflow_slots({ workflow_id, from:"<today>", to:"<today+7d>" }) # non-empty for both hosts
```
Only then, after a separate confirmation, `set_workflow_active`. Prove it with one `book_meeting({ …, idempotency_key, confirm:true })` on a test lead, `list_meetings`, and `cancel_meeting({ meeting_id, confirm:true })`.
**Rejected rungs:** activating on `create_meeting_type` alone (no host, no slots — the agent offers nothing and says so to every lead); asking the reps to "share their calendar" instead of sending the `connect_url` (there is no other way in); a reminder on a channel the catalog marks unavailable; writing routing as prompt prose when `set_meeting_type_routing` enforces it; calling `get_available_slots` or `confirm_and_book` from here — they are runtime tools, not callable over MCP.
---
## 22. “Once the lead says yes, a payment agent sends the link”
**Primitive:** Law 3 — a boundary status on the selling/qualifying agent with `transfer_config` to a payment agent (goal `payment_link`), plus `set_payment_link` on that target. The link is never prose in a prompt.
**What the platform does at the boundary — design around it.** The moment the source agent places the lead in the terminal status, the platform deactivates the source run, creates a fresh run on the payment agent, and makes the **payment agent speak next**: with the messaging window open (or an active iMessage thread) it composes its arrival message from *its own* prompt plus the carried context (the runtime tells it: continue naturally, do not introduce yourself again, do not repeat answered questions, move toward this workflow's goal); with the window closed it sends its opening WhatsApp template. Two consequences:
- **The source never speaks the hand-off.** No "te paso con mi colega de pagos", no "en un momento te llega el link", no farewell, no preview of what happens next. The source's last turn is the natural end of its own job (confirming the choice); the status move is silent, and the next message the lead sees comes from the payment agent. Hand-off dialogue produces two voices, a promise the source cannot keep, and a duplicated first message. Write the source prompt so that when the acceptance criterion is met it places the lead in the boundary status and ends its turn — it does not announce, narrate, or explain the transfer.
- **The payment agent's first message is the payment link, in context.** Its prompt states the arrival rule explicitly: the lead arrives having already decided — read what they chose, the amount, and any delivery/date details from the transfer chain (and lead metadata), acknowledge that in one line, and send the link. No greeting, no self-introduction, no re-qualification, no "how can I help", no re-asking anything the chain already holds. Everything after that turn is payment support: confirm payment, handle a failed or expired link, answer price objections, re-send.
**Build:**
1. **Source** (selling/qualifying agent). One terminal status with the acceptance criterion and the pre-payment facts as required fields:
```json
{
"key": "ready_to_pay",
"label": "Ready to pay",
"category": "won",
"is_terminal": true,
"entry_hint": "The lead has explicitly accepted the offer and every pre-payment detail (plan or item, quantity, amount, date/address when required) is confirmed and saved — place the lead here.",
"required_field_keys": ["selected_plan", "amount"],
"transfer_config": { "target_workflow_id": "<payment-agent-id>" }
}
```
Source prompt rule (prose, in the agent's language): *"When the lead accepts and every pre-payment detail is saved, move them to Ready to pay and end your turn. Do not announce a hand-off, a colleague, or an incoming link."*
2. **Target** (payment agent, goal `payment_link`). Configure the link first with `set_payment_link` (`fixed` URL, `tool` capture path, or `lead` metadata key) and read `config.payment_link` back with `get_workflow`. The agent sends the link through its built-in transactional retrieval, which reads the lead's link and then `config.payment_link` — with neither configured it cannot send, and a `{{payment_link}}` template is refused (`payment_link_missing`), so do this before any read-back. Arrival rule in the payment agent's prompt: *"The lead arrives having already accepted <plan/item> for <amount>; those facts are in the transferred context and are never re-asked. Your first message acknowledges exactly that in one sentence and sends the payment link — nothing else: no greeting, no introduction, no questions. If the link cannot be retrieved, say it is being prepared and escalate."* For the window-closed WhatsApp path, `set_opening_templates` with an approved template whose body carries `{{payment_link}}` and the same context-first tone (no greeting-and-ask). On other channels the prompt rule governs the AI turn.
3. **Connection manifest:** `ready_to_pay → <payment-agent-id>`; reconcile and read back per recipe 19.
**Verify** with a raw conversation driven to acceptance: (a) the source's last outbound message contains no hand-off language; (b) a fresh run exists on the payment agent at its initial status and the source run is `transferred`; (c) the payment agent's first outbound message contains the link and names the accepted item/amount; (d) nothing re-asks a field the chain already holds.
**Editing either side later:** these two prompts are one contract. Before rewriting the source's closing behaviour or the payment agent's first message, read the other agent's relevant status and prompt (they share an agent group — read the group, not the single agent) so the boundary stays silent on one side and context-aware on the other.
**Rejected rungs:** one agent whose prompt says "after they accept, send the link" when the payment side needs a different persona, tool set, or cadence (Law 3, recipe 17); a source prompt that "warms up" the transfer; a welcome job or automation aimed at transferred leads (the arrival message already speaks); the URL written into a prompt (`set_payment_link` is the only path the runtime reads).
---
## 23. “If they go quiet for N hours, follow up, then hand them to the follow-up agent”
**Primitive:** status timeouts (`timeout_config`) chained across statuses of the same agent, ending in a terminal status that carries `transfer_config`. No code. This is exactly what the dashboard writes when the operator ticks **“If the lead does not reply → After N hrs → Move to”** on a stage and **“Continue with another agent”** on the terminal stage — so it is the first thing to reach for, and the one to name to the user.
**Rejected rungs:** a scheduled function sweeping “leads silent for 4h” (re-implements the timer, invisible in the stage editor, cannot transfer without extra code); a background job with `workflow_transfer` (right only when a human or an external signal decides — §9 pause shape); recontact `on_exhausted` (it can only mark cold, pause or do nothing — it never transfers, and it cannot name a target status); a prompt line “after 24 hours move them to Follow up” (the agent has no clock between turns).
**How the timer works — read before designing.** The clock is time since the lead *entered* the status (`status_changed_at`), 24/7, checked every 2 minutes. `unit` is `minutes | hours | days`; the dashboard shows hours only. A reply does **not** reset it — only a status change does. Leads in human support, paused runs/agents and sandbox runs are skipped; a terminal target is skipped while the lead has an upcoming meeting. The timeout is inert on a terminal status or the goal stage, so the wait always sits on the status *before* the exit.
Two cases from the brief in the title:
**Case A — never replied: nudge at 24h and 72h, then hand off.** The opener and its nudges are the outreach cadence (`set_workflow_cadence`: windows, `max_days`, touches per block); recontact rules are refused on `new` / `contacted` (409 `CADENCE_OWNED_STAGE`). The handoff is one timeout on `contacted` (a core stage; it accepts timeouts):
```json
set_status_timeout_rule({ "workflow_id": "<qualifier>", "status_key": "contacted", "timeout": 72, "unit": "hours", "target_status_key": "handoff_followup" })
update_workflow_status({ "workflow_id": "<qualifier>", "status_key": "handoff_followup", "is_terminal": true, "transfer_config": { "target_workflow_id": "<follow-up agent>" } })
```
**Case B — replied, then went silent: touches at 4h, 12h, 24h, 48h, then hand off.** Unequal gaps mean one waiting status per gap; each `timeout` is the gap *from the previous hop*, not the cumulative figure:
```text
engaged --4h--> silent_4h --8h--> silent_12h --12h--> silent_24h --24h--> silent_48h --<final wait>--> handoff_followup (terminal, transfer_config → follow-up agent)
```
Build the chain in one `create_workflow` call (statuses carry `timeout_config` inline) or with one `set_status_timeout_rule` per hop; add `transfer_config` once the follow-up agent has a real id. The last hop's timeout is the answer to “how long after the final touch do we hand over” — ask the user for that number instead of inventing it.
The status timeout moves the lead **silently**. For the touch at each hop choose one, in this order:
1. **Hand off earlier and let the follow-up agent's cadence do the touches.** Different contact intensity is the textbook reason for a second agent (Law 3): `engaged --4h--> handoff_followup` and the follow-up agent's cadence/recontact policy owns 12h/24h/48h. Fewer moving parts, fully operator-visible.
2. **Exact unequal gaps inside one agent:** one recontact rule per waiting status — `set_recontact_rule({ "workflow_id", "trigger_status_key": "silent_4h", "name": "Nudge 1", "stale_reference": "status_changed_at", "stale_after_hours": 1, "max_attempts": 1, "on_exhausted": "nothing", "prompt_hint": "<what this nudge says>" })`. `stale_after_hours` is at least 1, so the touch lands about an hour after the hop; shift the hop by that hour if the exact figure matters. Recontact already skips a lead who replied.
**Non-negotiable in both cases:** a reply must take the lead *out* of the waiting statuses, or the chain hands off a live conversation. Write the active stage's `entry_hint` so the agent moves a lead who writes back out of `silent_*` (e.g. “the lead answered after a silence — resume here”), and keep the waiting statuses' own `entry_hint` limited to “no reply for N hours” so the agent never parks a talking lead there.
**Verify:** `list_status_timeout_rules({ workflow_id })` shows every hop with the intended unit and target; `get_workflow_status(handoff_followup)` shows `is_terminal: true` and the `transfer_config`; drive one test lead into `silent_12h` with a short `unit: "minutes"` timeout, let it reply, and confirm the run is back on an active stage (no timeout fires); let another go quiet through the chain and confirm a fresh run exists on the follow-up agent at its initial status with the source run `transferred`. Restore the real hours before activation.
---
## 24. “The deal closes in our CRM — count it in Nexor”
**Primitive:** a conversion event posted by the external tool that owns the outcome — their CRM, ERP or own backend — (`POST /api/public/conversions`, `create_conversion` over MCP), plus `set_conversion_destination` on the source agent so the lead moves when one arrives. No code on the Nexor side.
**Rejected rungs:** counting it in the prompt (“when they say they signed, mark it”) — the agent is not in that conversation and the signature happens days later, elsewhere; an inbound lead webhook (it upserts a lead, it does not record an outcome); an outbound webhook (that is Nexor telling them, the direction is reversed); a scheduled function *first* (correct only when their external tool cannot make any outgoing call — see below).
**Applies to** every goal that is not booking a meeting or collecting a Nexor payment link: qualification, information, support, document collection, quote, sale, custom. The agent qualifies, a human or an external tool closes, and without this the account reports zero conversions while the business is closing every week.
**Ask exactly two things.** Which external tool (CRM, ERP, own backend, spreadsheet) knows the moment of conversion, and how that tool identifies the person: the email, the phone, or the Nexor `lead_id` it stored from an outbound webhook or the leads API response. Do not ask what to put in `metadata` — propose keys from what the customer already said (deal id, product, seller, branch, plan) and let them trim.
**1. Set the destination first**, so the lead lands somewhere the moment the first real event arrives:
```json
set_conversion_destination({ "workflow_id": "<qualifier>", "destination_workflow_id": "<onboarding>", "destination_status_key": "welcome" })
```
Use `agent_group_id` instead of `workflow_id` when every agent in the group should share the fallback. A named conversion type is optional: add one with `set_conversion_type` only when the customer wants the events labelled by name in reports or a default currency — it is never a prerequisite.
**2. The request their external tool sends** when the deal reaches its won stage (from the CRM's own webhook/automation, from a Zapier / Make / n8n HTTP step, or from their application):
```http
POST https://api.getnexor.ai/api/public/conversions
X-API-Key: $NEXOR_API_KEY
Content-Type: application/json
{ "lead_email": "{{person.email}}",
"amount": {{deal.value}},
"currency": "CLP",
"description": "Contrato firmado",
"metadata": { "deal_id": "{{deal.id}}", "owner": "{{deal.owner}}", "product": "{{deal.product}}" } }
```
Only the lead identifier is required. Everything the business will want to slice by later goes in `metadata`, never inside the description: it is shown on the lead, delivered to cloud functions on `conversion.detected`, and filterable with `metadata[key]=value`.
**3. When their external tool cannot call out at all**, invert the direction: a Scheduled Function on a cron that queries their API for deals closed since the last run and posts one conversion per lead. Same endpoint, same payload. Do not offer this when a webhook or a Zapier step is available — it adds a polling window and code to maintain.
**Already covered without any of this:** a Shopify store (orders are recorded automatically) and HubSpot deal sync (a won deal emits the conversion). Confirm those, then ask whether any *other* outcome closes outside them.
**Verify:** post one real event for a test lead, then `list_conversions({ lead_id: "<test lead>" })` must return it with the amount, description and metadata as sent; `get_workflow({ workflow_id: "<qualifier>" })` must read back `conversion_destination` with the expected agent and status and `source: "own"` (or `"group"` when the fallback is the group's); and the test lead must now have a fresh run on the destination agent. Until both read-backs pass, the wiring is pending, not done.
---
## 25. “Sell tickets on our ecommerce and follow up with a discount”
**Primitive:** the default shape — 2–3 modes split by intent and register, chained by terminal transfers, inside one agent. Brief: *"We sell event tickets on our online store. The agent should sell them, and if people don't buy, follow up and offer a discount."*
**Sizing.** Three intents with three registers: get the lead to buy now (warm, informative, answers questions, sends the checkout link); bring back a lead who did not complete checkout (short reminders on a cadence, resolves blockers, no new offer); close a still-unconverted lead with the approved discount (one concession, a deadline, then close or stop). That is three modes. Two if the operator has no plain-reminder motion and the discount *is* the follow-up (the reminder and the concession then share intent and register). Never one broad "sales" mode — the review blocks a single-mode plan unless the operator explicitly asked for exactly one (`plan.decomposition.single_mode_explicitly_requested` with their quoted words) — and never a mode per message.
**Mode ledger:**
| ref | intent | register | boundary out |
|---|---|---|---|
| `sell_tickets` (entry) | buy a ticket now | warm, informative, checkout link | `not_purchased` (terminal, after the stage timeout) → `follow_up_unpaid` |
| `follow_up_unpaid` | complete the abandoned checkout | short reminders on a cadence, blockers only | `still_unpaid` (terminal) → `offer_discount` |
| `offer_discount` | accept the approved discount | one concession, deadline, close or stop | `purchased` / `declined` (terminal, no transfer) |
**Preflight** (plan-local refs, one plan agent per mode; only `sell_tickets` is the entry point, so no `entry_point` flags are needed):
```json
{
"agents": [
{
"ref": "sell_tickets", "name": "Sell event tickets", "goal_type": "sale",
"primary_responsibility": "Get the lead to buy a ticket on the store.",
"language": "en", "timezone": "America/Los_Angeles", "channels": ["whatsapp"],
"statuses": [
{ "key": "new", "is_initial": true },
{ "key": "link_sent", "entry_hint": "The checkout link was sent for a chosen event and quantity." },
{ "key": "purchased", "entry_hint": "The order is confirmed.", "is_terminal": true, "category": "won" },
{ "key": "not_purchased", "entry_hint": "No order after the wait window.", "is_terminal": true, "transfer_to_agent_ref": "follow_up_unpaid" }
]
},
{
"ref": "follow_up_unpaid", "name": "Follow up abandoned checkouts", "goal_type": "sale",
"primary_responsibility": "Bring the lead back to complete the purchase without changing the offer.",
"language": "en", "timezone": "America/Los_Angeles", "channels": ["whatsapp"],
"statuses": [
{ "key": "new", "is_initial": true },
{ "key": "purchased", "entry_hint": "The order is confirmed.", "is_terminal": true, "category": "won" },
{ "key": "still_unpaid", "entry_hint": "Reminders exhausted and no order.", "is_terminal": true, "transfer_to_agent_ref": "offer_discount" }
]
},
{
"ref": "offer_discount", "name": "Offer the discount", "goal_type": "sale",
"primary_responsibility": "Close with the approved discount or stop.",
"language": "en", "timezone": "America/Los_Angeles", "channels": ["whatsapp"],
"statuses": [
{ "key": "new", "is_initial": true },
{ "key": "purchased", "entry_hint": "The discounted order is confirmed.", "is_terminal": true, "category": "won" },
{ "key": "declined", "entry_hint": "The deadline passed or the lead refused.", "is_terminal": true, "category": "lost" }
]
}
]
}
```
`review_agent_system_plan` returns `decomposition: { mode_count: 3, entry_points: ["sell_tickets"], orphan_modes: [] }` and no decomposition issue; show that in the summary, close with "Does this look ok? Let me know and I'll build it." and get sign-off; never show the `plan_fingerprint`.
**Build:** `create_workflow` per mode, each with its complete funnel; the wait before `not_purchased` is `timeout_config` on `link_sent` aimed at `not_purchased` (recipe 23 — the delay is a stage timeout, never a job); the checkout link is a `set_payment_link` `lead` key or `fixed` URL when the store gives one link per event, or a customer API tool that creates the cart (recipe 11 shape); the follow-up cadence is `set_workflow_cadence` on `follow_up_unpaid`; the discount code and its deadline are Law 1 variables in the `offer_discount` prompt's transferred context, not prose. Reconcile the connection manifest (`not_purchased → <follow_up_unpaid id>`, `still_unpaid → <offer_discount id>`) once every id is real, per recipe 19.
**Verify:** a raw conversation that ignores the link must, after the timeout, show a `transferred` source run and a fresh `follow_up_unpaid` run; the follow-up's first message references the chosen event without re-asking it (recipe 22 arrival rule); after its cadence exhausts, a fresh `offer_discount` run whose first message carries the discount and the deadline.
**Rejected rungs:** one mode whose prompt says "if they don't buy, remind them, then offer 10% off" (three intents and registers in one unfenced prompt: nothing gates the discount, nothing cadences the reminders separately, nothing can transfer); five modes (choose event → send link → remind once → remind twice → discount) — the middle three share intent and register and are statuses or cadence steps, not modes; a scheduled function polling for abandoned carts when the stage timeout already expresses the wait.
SKILL.md
---
name: automation-architecture
description: Map plain-language Nexor requirements to exact configuration and build it through Nexor MCP tools. Covers arbitrary lead metadata, notifications, CRM sync, field/status events, qualification gates, workflow tools and hooks, webhooks, jobs, cloud/scheduled functions, cadence, channel and knowledge-base assignment, appointment agents (meeting types, hosts, availability, calendar connection, reminders, host routing, Calendly and external bookers), multi-agent systems, and transfers. Use when a customer asks how an agent can receive or use custom lead information, describes custom behavior, an integration, routing, meeting ownership, wants an agent that books meetings, or asks for a multi-agent build. Discover account configuration before questions, decompose variables, stages, and boundaries, split every build into modes by intent (2–3 by default, one mode only on the operator's explicit instruction, never 5+), require plan review and sign-off before mutation, prefer deterministic primitives, and read back every expected channel, connection, and knowledge assignment regardless of creation order.
---
# Automation architecture
## Goal
Turn a customer use case into an exact, minimal Nexor configuration: named primitives with concrete field values, connected end to end. The deliverable is a configuration, not a recommendation.
## The three laws of decomposition
Apply these before choosing any primitive. They are not style preferences: each names the only shape the platform can gate, trigger, template, or verify. A design that breaks one is unreliable even when it demos correctly, because the platform cannot see the part that lives in prose.
### Law 1 — every piece of information the system needs is a variable
If the build depends on knowing something, it exists as a named workflow field or as lead `metadata`. Never as prose in a prompt, never as "the agent will remember it from the conversation."
- The agent must obtain it → workflow field, `required: true`, with `extraction_hints`.
- An external tool (a CRM, a form, the customer's own backend) supplies lead identity/contact information → write `first_name`, `last_name`, `email`, and `phone` through the standard lead create/update contract. Never hide those values in metadata; metadata does not change Nexor's contact destinations.
- An external tool supplies other contextual information → put it directly in the lead's `metadata` JSON object. Keys may hold strings, numbers, booleans, null, arrays, or nested objects; the complete metadata object is injected into the next runtime agent prompt, so the agent can access every parameter and value without one field per key.
- The agent must ask for, validate, normalize, or gate on externally supplied information → add a workflow field with `metadata_key` (and `intake_value_map` when needed) so metadata pre-fills the field instead of being asked. **`metadata_key`, `options`, `extraction_hints`, and `validation` can only be written in the initial `create_workflow` call** — `update_workflow_structure` accepts just `key`, `label`, `type`, `required`, `sort_order`. Finish the variable ledger before creating the agent.
- A tool produces it → `llm_response_fields` keeps it in the turn; a post-tool hook writes it to metadata when a later step needs it.
- **Gates read fields only.** `required_field_keys`, `requires_all_fields`, and `transition_rules` cannot see metadata.
- **Deterministic senders read metadata only.** Status-automation `args_template`, webhook `payload_template`, and HTTP tool templates address `{{lead.<path>}}` / `{{lead.metadata.<key>}}` and cannot read collected field values. Anything an automation must transmit must be mirrored into metadata (post-tool hook or `patch_metadata`) before the lead can reach the triggering status. The full reader/store matrix is in [references/configuration-reference.md](references/configuration-reference.md#which-reader-can-see-which-store).
For every fact the design uses, name its key and its store. A fact that lives only in conversation history cannot gate a status, route a lead, fill a request body, or filter a job.
### Law 2 — every state change inside one agent is a status
If the agent's behavior, permissions, or obligations change at some moment, that moment is a status in that agent's pipeline. Never a "phase" described in the prompt, never a boolean parked in metadata.
Statuses are the only handle the rest of the platform can grab. A stage that is not a status is invisible to `workflow_tools.available_in_statuses`, `status_automations.on_status_key`, webhook filters on `to_status.key`, `timeout_config`, `transition_rules` / `required_field_keys`, background-job `workflow_filters.status_keys`, recontact `trigger_status_key`, and `assignment_config`.
Give each stage a `key`, a `label`, an `entry_hint` stating its entry criterion in terms of Law 1 variables, and an explicit gate. When field values fully determine the move, add `transition_rules.rule_groups` with `auto_evaluate: true` so the platform advances the lead instead of depending on the agent calling `set_lead_status`.
Express every gate as `required_field_keys` (or `requires_all_fields`) plus `transition_rules`. **`variable_refs` is not writable through MCP** — it is a dashboard display hint, so a design that relies on it has no gate at all. `review_agent_system_plan` enforces this law for you: any non-initial status with neither `entry_hint` nor `transition_rules` comes back as a clarification question.
When reordering an existing pipeline, preserve the semantic core exactly: Lead created (`core_new`), Contacted (`core_contacted`), and Engaged (`core_engaged`) stay first through third. Call `get_workflow`, pass every remaining persisted status key exactly once to `reorder_workflow_statuses`, then call `get_workflow` again and compare the complete order before reporting success. Never reorder by sending raw `sort_order` values, include a synthetic Discarded column, or infer core identity from editable labels. Read [references/configuration-reference.md](references/configuration-reference.md#pipeline-order) for grouping behavior and [references/mcp-tool-surface.md](references/mcp-tool-surface.md) for the write contract.
### Law 3 — every switch of agent identity happens at a boundary status that transfers
Changing tone, persona, goal, prompt content, tool set, channel mix, or contact intensity means a different agent. The switch is never made inside a prompt. It is made by a boundary status the lead exits through:
- **Terminal handoff (default):** `is_terminal: true` plus `transfer_config: { "target_workflow_id": "<real id>" }`. This is the only shape that auto-fires a transfer. Every agent path checks `is_terminal` before reading `transfer_config`, so **a non-terminal status with a `transfer_config` never fires** — the config saves cleanly and silently does nothing.
- **Never name a boundary status `future_*`, `contact_later`, or `colder`.** Those keys are treated as soft terminals and are excluded from the terminal side-effect path *before* `transfer_config` is read, so the transfer is skipped even on a correctly terminal status. This is the most silent failure in the whole surface.
- **Pause boundary:** `pause_bot: true` when the handoff is executed out of band — a background job's `workflow_transfer` / `force_transfer` action, the one-off transfer API, or a human. A pause boundary must name its executor or the lead sits silent forever. It is safe to build on: `pause_bot` is a *status* flag and does not set `workflow_runs.is_paused`, so a job with the standard `exclusions.skip_paused: true` still picks these leads up.
Either shape ends the source agent's ownership at the boundary: exactly one agent is live per lead. The target always starts at its own initial status, its cadence starts over, and the source's collected fields travel as a read-only transfer-chain snapshot.
Forbidden shapes: a prompt that says "once qualified, switch to a closing tone"; a tool whose purpose is to change the agent's persona; two agents live on the same lead; a transfer aimed at a specific status inside the target.
### Law 2 or Law 3?
Same goal, same persona, same tool set, same cadence — only more known about the lead → **status**. Different goal, prompt, tools, channels, or contact intensity → **new mode behind a boundary status**. The ~80% test runs only *within one intent*: two candidate modes that share the same intent and register, and would share ~80% of prompt, tools, and schedule, collapse into one mode with more statuses. It never merges two different intents.
### Default shape: split by intent, 2–3 modes
What the operator sees and names is the **agent** (the master workflow). What you build inside it are **modes** (workflows): one per intent. The default is to split, not to write one broad prompt. Unless the operator says explicitly and unmistakably that they want exactly one mode ("I absolutely want this to be one single mode"), decompose:
- **A new mode whenever the intent changes** — what the conversation is trying to get the lead to do: sell, follow up, offer a discount, book, collect a payment, retain.
- **A new mode whenever the register changes** — tone, intensity, cadence, persona, or channel mix, even inside "the same sale": a warm informative opener, an insistent reminder, and a single concession are three registers.
- **2–3 modes for a typical ask.** "Sell tickets on our ecommerce and follow up with a discount" is two or three modes (sell → follow up unpaid → offer the discount), not one and not five. Recipe 25 in [references/recipes.md](references/recipes.md) works it end to end.
- **One mode only on explicit instruction.** Quote the operator's words in `plan.decomposition` as `single_mode_explicitly_requested: true` plus `operator_instruction`; `review_agent_system_plan` blocks a one-mode plan without them.
- **Never 5 or more.** More than four modes fragments one conversation (every hop resets cadence and re-triggers first contact); the review blocks it unless `large_system_explicitly_requested` carries the operator's words. Merge modes that share both intent and register.
- **Every mode is reachable.** The first listed mode is the entry point (or mark others `entry_point: true`); every other mode is the target of some mode's terminal transfer, or the review asks how leads reach it.
`describe_agent_configuration` returns this contract as `mode_split`; state each mode's intent and register in the plan, and read the review's `decomposition` summary back to the operator.
## The primitives
Everything a customer asks for lands on one or more of these:
| # | Primitive | Trigger | Code? | Scope |
|---|-----------|---------|-------|-------|
| 1 | Agent config (statuses + intake fields) | Lead's message, mid-conversation | No | One lead, in-turn |
| 2 | Workflow HTTP/MCP tool | Agent needs external information or action during its turn | No | One lead, synchronous |
| 3 | Rules (reminders / host notifications / recontact) | Meeting lifecycle, staleness | No | One lead |
| 4 | Outbound webhook | Platform event (status change, meeting lifecycle) | No | Notify an external API (CRM, ERP, own backend), every occurrence |
| 5 | Status automation | Lead reaches a specific status | No | Call one HTTP/MCP tool, exactly once per lead |
| 6 | Background job | `cron` or tag event | No | Filtered cohort, declarative steps |
| 7 | Cloud function | A `lead.*` / `workflow.*` / `meeting.*` event | JS | One lead per event |
| 8 | Scheduled function | Cron + timezone | JS | Cohort from a lookup query |
| 9 | Workflow transfer | Terminal status with `transfer_config` | No | One lead, agent-to-agent handoff |
| 10 | Intake API / inbound webhook | An external tool (CRM, form, Zapier, own backend) pushes a lead in | No | Lead creation + enrollment |
| 11 | Cadence config (contact blocks + day config) | Outreach scheduling: windows, per-channel intensity, hot contact | No | A workflow's entire outbound initiative |
| 12 | Knowledge-base assignment | Agent needs selected account-owned knowledge at runtime | No | Exact per-agent subset, ordered by retrieval priority |
| 13 | Pre-execution hook (tool with `mode: "on_entry"`) | Lead enters the workflow, before the first agent message | No | One lead, deterministic, cannot be stage-gated |
| 14 | Pre-processor | Inbound lead arrives, before any agent owns it | No | Ordered condition rules choosing the entry workflow |
| 15 | Stage timeout (`timeout_config`) | Time elapsed since the lead entered a status ("no reply for 48h") | No | One lead; moves it to another status of the same agent — the platform's only delay primitive. Aimed at a terminal status with `transfer_config`, it is a delayed handoff |
## Decision ladder — config beats code
Config is operator-visible, editable in the UI, and cannot crash; code is invisible to operators and yours to maintain forever. Walk the ladder top-down and stop at the first rung that expresses the requirement.
1. **Can native conversation config express it?** A status, intake field, prompt rule, or deterministic branch → agent config. "Ask for X and branch on it" is statuses + fields, not code. Laws 1 and 2 have usually already placed most of this rung.
2. **Is it a wait?** "If they don't reply in N hours…", "after 3 days move them to…", "hand off to the follow-up agent after 48h" → `timeout_config` on the waiting status (`set_status_timeout_rule`, or inline in `create_workflow` / `update_workflow_status`), pointing at the next status — or at a terminal status with `transfer_config` for a delayed handoff. Chain statuses for multi-step waits; the clock is time since entering the status and a reply does not reset it. This is the dashboard's "If the lead does not reply → After N hrs → Move to" control. A scheduled function, background job, cloud function or recontact `on_exhausted` is never the answer to a per-lead wait (recipe 23).
3. **Is it a standard lifecycle nudge?** Booking confirmations, pre-meeting reminders, host notifications, stale-lead recontact → rules. Configure and stop.
4. **Can a state change define exactly when it should happen?** Prefer deterministic execution over asking the model to choose a tool. Status change → filtered outbound webhook when the receiver can act from the event, or status automation when Nexor must call a configured endpoint once with a custom body. Field/variable or other lead event → trigger cloud function (`information.collected`, `information.updated`, `lead.updated`, or the narrowest supported event) guarded to the relevant key/change.
5. **Must the result exist before the agent's first message?** → pre-execution hook: `set_workflow_tool_execution({ mode: "on_entry" })`. The platform runs the tool the moment the lead enters the workflow, before the agent speaks, with no model decision involved. This is the right shape for enrichment, eligibility lookups, and account context — and for a transfer *target* that needs data ready on arrival. It cannot be stage-gated (the two modes are mutually exclusive), so use it only when "on entry" really is the moment.
6. **Does the agent genuinely need an external result before it can continue the active turn, with no state event that can run it first?** Expose the client endpoint as a workflow HTTP/MCP tool in `mode: "agent_decides"`. This is the least reliable rung because the model must select and call it. Keep client-specific logic in the client's system, make the contract narrow, and gate it with `available_in_statuses` when valid only after a stage such as `qualified`.
7. **Is it "filter leads → condition → action"?** → background job with declarative steps and actions. Always set `max_leads_per_cycle` and `cooldown_minutes`; dry-run anything that messages humans.
8. **Does asynchronous work need custom logic or an external API the job vocabulary can't express?** Trigger grammar routes it: "*when* a lead …" → cloud function (event, one lead). "*every* morning / Monday …" → scheduled function (cron, cohort). A "when" that tolerates hours of latency across many leads → prefer the scheduled sweep (its dry run shows the whole cohort first). A "when" that is just elapsed time in a stage is rung 2, not this one.
9. **Does the lead need a fundamentally different conversation or outreach intensity?** Goal, persona, cadence, channel mix, or tool set changes → workflow transfer from a boundary status (Law 3). A qualification agent can fan out through terminal statuses such as `qualified_now` and `qualified_later`, each with its own `transfer_config`; the targets own the high-intensity sales or low-frequency nurture behavior. A plain "nurture" ask defaults to its own low-intensity, **email-only** workflow that reaches out roughly **once every 2-4 weeks** — never the sales cadence slowed down or the same agent on more channels. If both targets share the same intent and register and would share 80% of their prompt, tools, and schedule, keep one mode with more statuses — but never collapse two different intents into one prompt: the default shape is 2–3 modes (see "Default shape" above).
## The integration compass
When the requirement is *integration* — moving data between Nexor and an external tool (a CRM, an ERP, the customer's own backend) — direction and cadence pick the primitive. Functions are the fully flexible path: editable JavaScript with `axios` available, so any external API is reachable in both directions.
### Canonical Nexor API origins
Use these exact API origins when a configuration, example, or customer integration must call Nexor directly:
| Environment | API origin |
|-------------|------------|
| Staging | `https://api-staging.getnexor.ai` |
| Production | `https://api.getnexor.ai` |
Append the documented route to the origin, for example `/api/public/leads`. Do not substitute a dashboard origin, an MCP origin, `staging-api.getnexor.ai`, or a legacy `onrender.com` backend. Keep the credential and API origin in the same environment, and resolve the target environment before any side-effecting call instead of silently choosing production.
These origins are for integrations that genuinely need to call Nexor. They are not the default data path inside Nexor Functions. Runtime-native access takes precedence:
- In an event Cloud Function, read the triggering lead from `ctx.lead` or `lead`; never refetch it from `/api/public/leads`.
- In a Scheduled Function, translate get/list/search/filter-leads requirements into `lookup_config`, preview that cohort, and consume the resolved `ctx.leads` array in code. Do not query `/api/public/leads` for the same cohort. Leads have no `workflow_id` column: current agent/workflow scope is the pair `workflow_runs.workflow_id = <id>` and `workflow_runs.is_active = true` in the relationship-aware query.
- Use `updateLead` / `updateMetadata` and `effects.*` for supported Nexor mutations. Reserve direct Nexor API calls for a genuinely unsupported atomic operation, such as create/upsert + workflow enrollment + first contact when the new id is required in the same run—not for redundant reads.
### Resolve sync direction before choosing a surface
Treat “sync this information” as incomplete until the direction is explicit. Determine whether the data is moving **into Nexor** or **out of Nexor** before recommending or configuring anything. If the request does not make the direction clear, present those two mutually exclusive choices and wait for the answer; do not guess from the word “sync.”
For data moving **into Nexor**, classify every incoming key before mapping it:
- Put lead identity and delivery coordinates in the standard lead columns: `first_name`, `last_name`, `email`, and `phone`. Update them through the normal lead create/update contract, never through metadata. Metadata does not change the address or number Nexor uses to contact the lead.
- Put every other reusable customer fact the agent should know in `lead.metadata`. Create/upsert with a `metadata` object or shallow-merge it with `PATCH`/`PUT /api/public/leads/:id`; use `PATCH /api/public/leads/metadata` for bulk merges. The complete metadata object is loaded into the next agent execution, so a value written now is available on the next turn/run without copying it into prompt prose.
- Add a workflow field with `metadata_key` only when the same value must pre-fill an intake question or participate in a field-only status gate. Metadata remains the source of inbound context; the field is the structural bridge.
For data moving **out of Nexor**, choose the outbound trigger from the table below. Metadata may be the source value included in a payload, but writing metadata is not an outbound sync mechanism and sends nothing by itself.
| Direction | Cadence | Use | Why |
|-----------|---------|-----|-----|
| Nexor → external | Status change | Outbound webhook or status automation | Platform state fires it deterministically; no model tool-selection decision |
| Nexor → external | Field/variable or lead event | Cloud function | The event fires deterministically; custom code can inspect the changed key and call any endpoint |
| Nexor ↔ external | During the agent's turn, no usable state trigger | Workflow HTTP/MCP tool | Use only when the agent needs the response immediately; the client's endpoint remains the source of truth, but model-selected invocation is less reliable |
| Nexor → external | Batch | Scheduled function sweep | Cohort in `ctx.leads[]`, push out per lead or aggregated |
| External → Nexor | Push (they call you) | Public leads API / inbound hook | Upsert + metadata + enrollment in one request, no code |
| External → Nexor | Pull (Nexor calls them) | Scheduled function | Query the entire leads object with Supabase-style chained filters, `axios` the external API, then create/edit leads with custom metadata via effects or the public API |
| External → Nexor | The outcome happened elsewhere (paid, signed, activated) | Conversion event: `POST /api/public/conversions` with a lead identifier (+ `amount`, `currency`, `description`, `metadata`) | Deterministic; Nexor moves the lead to the agent/status configured on the type, the agent or its group |
Deterministic mechanisms win when they express the timing: rules, webhooks, status automations, and event-triggered cloud functions do not depend on the model remembering or deciding to call a tool. Do not copy a client's round-robin, CRM ownership, pricing, or eligibility logic into a prompt or function when their endpoint already owns it. Use an agent-callable tool only when the active conversation truly requires its response before continuing.
## Client-owned capabilities and deterministic execution
Treat the client's endpoint as the capability and Nexor as the orchestrator:
1. Identify whether a status, field, variable, lead, meeting, or workflow event defines the moment of execution. If it does, wire that event to a webhook, status automation, or cloud function; do not ask the agent to call the endpoint.
2. Keep the authoritative business decision in the endpoint. Nexor passes facts; neither the agent nor a function should duplicate the client's algorithm.
3. Use a conversational HTTP/MCP tool only when the agent must obtain the result inside the active turn and a deterministic trigger cannot run it first.
4. For remaining agent-callable tools, set `available_in_statuses` to the exact valid status keys. Pair a `qualified` gate with `requires_all_fields` / `required_field_keys` so availability and booking cannot run before qualification.
5. Use `call_once` for irreversible conversational operations that must run once per workflow run; leave it off read-only tools that may need a legitimate refresh.
6. Define success, failure, persistence, and retry behavior. Never let the agent invent an assignment, slot, price, or external result after a failed call.
Read the workflow-tool and stage-gate contract in [references/configuration-reference.md](references/configuration-reference.md) and use the round-robin mapping in [references/recipes.md](references/recipes.md) when the ask resembles sales-rep assignment.
## Meeting-host selection
Meeting-host selection decides which of an appointment agent's assigned executives supplies the slots shown to a lead. It is not lead ownership, pipeline assignment, or an external CRM round robin.
1. Call `list_meeting_types` and `list_executives` for the exact workflow before proposing a change. The agent must use exactly one existing meeting type. If several exist, stop and help the operator consolidate them to one; never create or silently choose another type.
2. Treat zero or one assigned executive as having no routing decision. With two or more executives and no selection criteria, explain that availability is distributed evenly by round robin. After adding a second executive, proactively offer a choice between that default and an explicit host-selection rule.
3. Build a rule only from facts the booking agent can actually know: standard lead properties, lead metadata, or workflow fields returned by `get_workflow`. Reference assigned executives by the `user_id` returned by `list_executives`. If a rule names an unknown or unassigned person, resolve that mismatch with the operator (`invite_user`, then `add_executive`) before writing anything.
4. Choose the layer by what must be true. A field-driven or weighted split ("renta over 50000 to Ana, the rest 2:1 between Bruno and Carla, negatives disqualified"; "strict alternation") is **enforced** with `set_meeting_type_routing({ workflow_id, agent_selection_rules })` — tiers by `field` with `min`, `agents` (user ids), `mode` `round_robin` / `cycle`, `weights`, `pattern`, and `action: "disqualify"`; it is applied at offer time and book time. A soft preference in prose is `agent_selection_criteria` via `update_meeting_type`: keep it short and ordered, and never claim it is server-enforced — the booking agent reads it when choosing among returned slots.
5. Read the rules or criteria back (`list_meeting_types` / the `set_meeting_type_routing` response) and compare exactly. Report success only after the same meeting type contains the requested value; otherwise report the mismatch and do not imply that routing changed.
Do not use legacy meeting-selection tools, direct database access, or a second meeting type to express routing. When the client's own endpoint decides the host, use `set_host_assigner` instead — see [references/booking-agent.md](references/booking-agent.md#6-host-routing).
## Booking agent
An appointment agent is not bookable when it is created. Six prerequisites must exist, in dependency order, before `set_workflow_active` produces an agent that can offer a slot — and `get_account_readiness` reports each missing one as a booking blocker with the tools that fix it. The complete argument contracts are in [references/booking-agent.md](references/booking-agent.md); the order is:
1. `create_workflow({ goal_type: "appointment", timezone, … })` — pass the IANA `timezone` explicitly: it is the anchor every offered slot is rendered in. The three core stages and the virtual Discarded column always exist; design your booking stages after them.
2. `create_meeting_type({ workflow_id, name, duration_minutes, location_type })` — or reuse the single type `list_meeting_types` returns. One workflow, one meeting type; `location_type: "in_person"` requires `location_details`. Created active, bookable once a host with availability exists.
3. `add_executive({ workflow_id, user_id })` for every human who takes meetings (`list_team` for the id; `invite_user` first for someone not on the team). One eligible member → `auto_assign_solo_host`.
4. `set_host_schedule({ user_id, timezone, slots: [{ weekday, start, end }] })` — the complete weekly schedule, in the host's timezone; `set_host_blocks` for days off.
5. `connect_calendar({ user_id, provider: "google" | "outlook" })` → a `connect_url` **only the host can complete in a browser**. Send it, say who must open it, and poll `get_calendar_connections({ user_id })` until `status: "active"`. Never report a calendar as connected before the poll says so. Calendly replaces steps 4–5 (`connect_calendly` → `map_calendly_hosts` → `set_calendly_binding`).
6. `get_reminder_catalog({ workflow_id })`, then `set_reminder_rule` per rule with the full contract — `trigger_event` (`event_created`, `event_cancelled`, `call_analyzed`, `event_enrolled`; **no reschedule trigger** exists, `event_created` re-fires for the new time), `channel` (only one the catalog marks available for this account), `template_name` (an APPROVED WhatsApp template, or a variant key for `retell` / `sms` / `email`), signed `delay_minutes`, `delay_reference` `trigger` or `event_start`. `set_host_reminder_rule` is email-only and fires on `event_created` only.
7. With two or more hosts, `set_meeting_type_routing` (Meeting-host selection above).
8. `review_agent_system_plan({ plan })` — the appointment agent carries `meeting_type`, `hosts[]` and `calendar_plan` (or `booking_provider`); the review refuses an appointment plan without them. Sign-off on the summarized plan ("Does this look ok? Let me know and I'll build it.") precedes every mutation in steps 1–7.
9. Read everything back — `get_account_readiness` shows zero booking blockers and `get_workflow_slots` returns slots — then `set_workflow_active`, confirmed separately.
Operating the agent afterwards is a different tool family: `list_meetings` / `get_meeting_stats` to see bookings, `book_meeting` / `reschedule_meeting` / `change_meeting_host` / `cancel_meeting` for operator-driven changes (each previews without `confirm: true`), and `set_meeting_status({ status: "no_show" | "completed" })` to record outcomes. **`get_available_slots`, `confirm_and_book`, `create_event`, `reschedule_event`, `cancel_event`, `request_channel_swap` and `schedule_followup` are runtime tools the agent uses mid-conversation — not callable over MCP.** They appear in configuration only as names to stage-gate or to disable for an external booker.
When the client's own endpoint owns the calendar, `set_external_booking` requires all four verbs — slots, create (`tool`), `reschedule_tool`, `cancel_tool` — because a missing verb is silently never mirrored: phantom meetings, reminders that keep firing. The only exception is the operator accepting that limitation explicitly, in writing, in the conversation; record the sentence in the spec. See [references/booking-agent.md](references/booking-agent.md#10-external-booker--the-clients-own-booking-endpoint).
## Workflow
1. Restate the ask as **trigger → condition → action**, resolve sync direction as **into Nexor / out of Nexor** before choosing a surface, classify its timing as **in-turn / event / batch / inbound**, and separate what the agent must **ask** (intake fields), what the customer's systems **send** (standard lead identity columns versus metadata), and what must **happen** (tool call or automation).
2. Apply the three laws to draft the skeleton before choosing any primitive: list every fact the design needs as a named variable with its store and its readers (Law 1); list every in-agent stage as a status key with its entry criterion and gate (Law 2); list every identity switch as a boundary status with its shape, target agent, and — for a pause boundary — its executor (Law 3). Then size the system: one mode per intent and per register, 2–3 modes by default, one mode only when the operator explicitly asked for exactly one, never 5+ (see "Default shape"). Anything you cannot place in one of those three lists is not designed yet; place it before continuing.
3. Call `describe_agent_configuration` before proposing any non-trivial or multi-agent build, and read current state with `list_workflows` / `get_workflow` / `list_workflow_tools` / `list_webhooks`. The platform ships its own required process and surface map; follow it rather than a remembered one. When the agent being edited belongs to an agent group (`master_workflow_id`), the group is the default unit of reading: its siblings, the statuses that transfer into and out of the edited agent, and the shared channel bindings — never just the one agent.
4. Walk the ladder. Note every rung you skip and why — that reasoning is part of the deliverable.
5. Prefer a deterministic state trigger. For a status change choose webhook/status automation first; for a field/variable or other lead event choose a cloud function. Use an in-turn tool only when the agent must receive the result before continuing, then gate it to the statuses where it is valid.
6. Read [references/recipes.md](references/recipes.md) and start from the closest worked mapping; read [references/configuration-reference.md](references/configuration-reference.md) to fill in exact field names and values for the chosen primitives; read [references/mcp-tool-surface.md](references/mcp-tool-surface.md) before the first write.
7. Read the account before planning: call `get_account_readiness` plus `list_whatsapp_numbers`, `list_email_senders`, and `list_phone_numbers` (and `get_integration_status` for `calendars[]` / `booking_providers[]` when the agent books). Treat SMS as the active phone-number rows with `sms_enabled: true`. Account facts are evidence, not clarification prompts: never ask whether the customer already has a WhatsApp number, email sender, call number, SMS channel, or saved timezone. **Channels and contact hours are never questions, for any agent — not even mixed into another question.** An agent is born with every channel the account holds a verified resource for (`get_account_readiness.inventory.channels`), and every mode inherits its agent's channels, contact schedule and persona name at `create_workflow` (see step 10). Plan modes without a `channels` key (a named channel goes into the description and prompt as intent, never as a binding or a question) and derive `contact_schedule` from what the operator already said; with no hint the Mon–Fri 09:00–18:00 default applies and the summary names what the agent was born with in one line. `review_agent_system_plan` never asks which channels to enable and returns `birth_defaults` (the channels every new agent gets). Run the resource flow in step 8 only when the operator explicitly asks this mode to use a specific resource or a narrower mix, or to repair a `channel_bindings` outcome reported by `create_workflow`.
8. **Only on an explicit operator request** (a specific number or sender, a narrower mix for one mode, or a `create_workflow` result reporting `owned_by_other_agent` / `channel_binding_failed`): write an expected channel manifest for that agent: enabled channel plus the exact selected resource id for WhatsApp, email, call, and SMS. Include each resource's current owner. Email senders are shareable; a WhatsApp number has one direct owner, a call number has one `workflow_id`, and an SMS route has one `sms_workflow_id`. Call and SMS may legitimately route the same physical number to different agents. Reject or resolve duplicate exclusive claims across the whole plan before mutation. For the genuinely provisioned channels (WhatsApp, email, call): if exactly one usable resource exists, offer that observed resource versus configuring a new one; if several exist, show every observed resource in a finite selector plus the new-resource path; if none exist, say so and offer setup versus continuing without the channel. **SMS is not one of those provisioned channels** — it is not "set up," it is activated per agent on a phone number the account already has. As long as an active Twilio number exists, do not start a buy-a-number flow: the only open question is which agent(s) should receive SMS, then activate it with `set_number_sms({ number_id, enabled: true, sms_workflow_id })`. SMS requires a Twilio-carrier number (Telnyx numbers cannot carry SMS); there is no country restriction. Only when the account has no phone number at all does provisioning enter the SMS conversation, falling to the same number-setup path as call. Never turn these finite choices into open-text questions.
9. **Preflight the whole system with `review_agent_system_plan({ plan })` before mutating anything**, expressing handoffs as `transfer_to_agent_ref` between plan-local agent refs, one plan agent per mode. A single-mode plan is blocked unless `plan.decomposition` carries `single_mode_explicitly_requested: true` and the operator's quoted `operator_instruction`; five or more modes are blocked unless `large_system_explicitly_requested` carries it; a mode nothing transfers into raises a clarification unless it is `entry_point: true`. Resolve every `blocking_issue` and `clarification_question` until `ready_for_signoff` is true, then show the returned summary and ask the exact `signoff_prompt` question. Do not call any create/update/run tool until the user approves the `plan_fingerprint`.
10. **An agent first, then its modes — always.** Every workflow is a mode *inside* an agent (master workflow); a workflow never exists on its own, whatever the request looks like ("create an agent", "create a workflow", "add a mode", one or many). `create_workflow` requires `master_workflow_id` and refuses a call without it, and the API never leaves a mode without an agent. So the first write of any build is the agent: `create_agent_group({ description })` (or the id of an existing one from `list_agent_groups` when the operator means that agent, or the agent whose chat you are already in), then every `create_workflow` with that id. **The operator never names an agent at creation and you never ask for a name:** Nexor picks a human first name from its pool (skipping the account's existing agents) and returns it; a `name` argument is ignored and echoed as `requested_name_ignored`. Present the agent by the returned name; rename later with `update_agent_group` only when the operator explicitly asks. Put the placement in the plan as `plan.agent_group = { id }` or `{ description? }` so `review_agent_system_plan` can check it. **The agent is born with the account's channels and every mode inherits them:** `create_workflow` resolves, before the create, the mode's channels, contact schedule, persona name (`agent_name` = the agent's name), language and timezone from the agent's other live modes — or, for the first mode, from the account (every channel with a verified resource, the backend's schedule default or the operator's hours hint) — then binds the resources (one WhatsApp number and one SMS route per agent shared by its modes, the account-wide number for calls, the agent's email sender on every mode) and returns `inheritance` plus `channel_bindings`. So omit `channels`, `contact_schedule` and `agent_name` on `create_workflow` unless the operator explicitly asked this mode for something different, and never ask about them. An agent with no modes is unfinished: never end a build with an empty agent, and never detach a mode from every agent (`update_workflow.master_workflow_id: null` is refused — move it to another agent instead).
11. Mutate in dependency order so nothing references an id or key that does not exist yet: `create_agent_group` (unless the agent exists) → `create_workflow` (the **complete** funnel — statuses carry their full stage config including gates, hints, timeouts and flags, plus the **complete** fields array; most field properties cannot be added later) → check the returned `pipeline_reconciliation` report and resolve every leftover seeded status, missing key, and warning before the next step → workflow tools → `update_workflow_config` for status automations → webhooks and rules → for an appointment agent, meeting type → hosts → availability → calendar (human step) → reminders (see Booking agent) → channel bindings only for an explicit resource request or a reported binding outcome (`create_workflow` already bound the inherited channels) → transfers (`update_workflow_status` with `transfer_config` once targets have real ids) → knowledge-base attachments. Agents are created paused; keep them paused through the whole build. See [references/mcp-tool-surface.md](references/mcp-tool-surface.md) for the exact tool per step and what each write overwrites.
12. For every agent build, inventory the account catalog with `list_knowledge_bases` **without** `workflow_id`, then write an expected knowledge manifest: one row per agent with the exact account-owned KB ids/names it should use and their retrieval order. Account ownership is availability, not assignment. Infer relevance only when the KB description and agent responsibility make it unambiguous; otherwise resolve the choice before mutating. Never attach every account KB by default unless the operator explicitly wants every agent to use all of them.
13. For a multi-agent design, write an expected connection manifest before mutating anything: one row per handoff with `source_agent_ref`, `source_status_key`, `boundary_shape` (terminal transfer or pause + executor), and `target_agent_ref`. Keep this manifest independent of creation order.
13. After every involved agent has a real id, reconcile all three manifests. For channels, read each `create_workflow` result's `inheritance` (what the mode was born with and from where) and `channel_bindings` (per channel: `bound`, `already_bound_in_group`, `client_scoped_available`, `owned_by_other_agent`, `no_resource`, `inventory_unavailable`); `bound` / `already_bound_in_group` / `client_scoped_available` need nothing more. `owned_by_other_agent` means the tool refused to take another agent's resource: ask the operator before moving it (name the resource, old owner, new owner and routing impact), never move it silently. `no_resource` means the account has nothing on that channel: say so in one line, offer to connect one, and never "fix" it by pointing `first_contact_channel` at a channel the account has not connected. Only for an explicit operator request write selected real ids yourself (`config.email_sender_id`, `assign_whatsapp_to_workflow`, `assign_number_to_workflow`, and `set_number_sms`), synchronize `config.disabled_channels`, and re-run the inventory. Never silently steal a number already assigned to another agent: approval must name the resource, capability, old owner, new owner, and routing impact. Include every displaced agent in read-back and either disable its lost capability or apply its separately confirmed replacement. For transfers, update each source status with the destination's real id, including returning to an earlier-created source after a later target is created. For knowledge, attach missing expected KBs and detach unexpected KBs from each agent without deleting the client-owned KB. Read every involved agent, its channel inventory, and its attached KB list back. Repair and re-read until there are zero missing, unresolved, misdirected, or extra channels, connections, and assignments. Do not finalize, report success, or activate while any manifest is incomplete.
14. Produce the configuration spec (see Output).
15. State the verification path: which previews/dry runs, channel/connection/KB read-backs, and blocked/allowed tool tests to perform, and what they must show, before anything is activated.
## Facts that decide designs
These are the semantics customers (and naive designs) most often get wrong:
- **`create_workflow`'s statuses are the entire funnel — the backend seeds defaults, and seeded stages must not survive.** The create endpoint seeds a default pipeline (New, Contacted, In conversation, system stages) and appends your statuses to it; the MCP tool reconciles this automatically and returns a `pipeline_reconciliation` report. The funnel is correct only when the result's top-level `status` is `"created"` (i.e. `unrequested_statuses_remaining`, `missing_requested_statuses`, `duplicate_labels`, and `warnings` are all empty) — resolve leftovers with `get_pipeline_impact` + `delete_workflow_status` and missing keys with `update_workflow_structure` before configuring anything that references a status. A seeded stage left in place is invisible to your design's gates, hints, and transfers, and duplicates (e.g. a stray `payment_sent` next to your `send_link`) mis-route leads into dead stages.
- **The agent routes on `entry_hint`, not `description`.** A status's `description` never reaches the prompt. The placement rule ("annual income is under 800,000 — place the lead here") goes in `entry_hint`; gate advancement with `variable_refs` / `required_field_keys`. When the branch is fully determined by saved field values, encode it as `transition_rules.rule_groups` (server-evaluated `field` / `operator` / `value` conditions) with `auto_evaluate: true`, so the platform routes the lead the moment the fields are saved instead of depending on the agent calling `set_lead_status`.
- **`is_qualified` marks; gates enforce.** The qualification stage type is a reporting/UI flag. The server-side gate is `required_field_keys` (takes precedence) / `requires_all_fields` plus `transition_rules.rule_groups` — and gates only block entry into non-terminal statuses and `won`-category terminals; entry into `lost`/transfer terminals is guided by `entry_hint`, not blocked.
- **Channel hours are one flag, not per-channel.** Calls are always gated to the cadence windows (hard-coded, never 24/7); WhatsApp/email/SMS run 24/7 unless `config.gate_outbound_to_hours: true` gates them all together. Inbound replies bypass scheduling entirely. No weekly frequency cap exists for any channel — express "at most N per week" with per-block intensity, `disabled_channels`, or a background job with `cooldown_minutes`, and say which approximation you chose.
- **Discard rules are statuses, not automations — and `lost` stops initiating, not responding.** "If the lead doesn't qualify, stop contacting them" is a terminal `category: "lost"` status whose `entry_hint` and `transition_rules` carry the rule, configured visibly in the agent and processed during the conversation. Entering it halts all proactive outbound and the run cannot be reactivated — but the agent still replies briefly if the lead writes in (built-in lost-lead behavior: no selling, no booking offers). `pause_bot` is the only control that fully silences replies; `futurology_queue` defers for recontact; only a terminal `lost` status discards.
- **Metadata is the default custom-context path, not the intake config.** Whenever a customer asks how to give the agent arbitrary information about a lead, offer the lead's `metadata` JSON object first. Its values may use any JSON shape and the complete object reaches the runtime prompt. `workflow_fields` defines only what the agent asks for, validates, or uses to gate status advancement; bridge the specific inbound key with `metadata_key` only for those needs (one direction: metadata → field prefill). Metadata updates shallow-merge top-level keys, so resend a complete nested object when changing only part of it.
- **“Sync” has a direction before it has a primitive.** Establish whether information is entering or leaving Nexor. For inbound data, map `first_name`, `last_name`, `email`, and `phone` to standard lead columns and put other agent-readable facts in metadata; those metadata values become available on the next agent execution. For outbound data, use an outbound trigger—metadata writes do not transmit anything.
- **Deterministic triggers beat model-selected tools.** If "when" can be expressed as a status, field, variable, lead, meeting, or workflow event, let the platform fire the integration. Tool descriptions and prompt instructions can still be hallucinated or skipped; event subscriptions and triggers do not depend on model choice.
- **Tool descriptions guide; stage gates enforce.** Put invocation guidance in the tool description and prompt, but use `workflow_tools.available_in_statuses` to block execution outside valid statuses. `null` / an empty list means unrestricted. Pair the gate with required fields on the qualifying status; changing to that status can unlock the tool within the same turn.
- **Custom logic belongs behind the endpoint.** If HubSpot or a client service already performs round robin, territory routing, eligibility, or pricing, a deterministic trigger should call it whenever possible; an agent tool calls it only when the response is needed in-turn. Nexor should not maintain a second copy of that logic.
- **"Notify me" has two shapes.** A webhook subscription filtered on `to_status.key` fires on *every* matching status change (your endpoint dedupes); a status automation fires *exactly once per lead per rule* and can template the request body from lead data.
- **Transfers fire only from hard-terminal statuses.** `is_terminal` is checked before `transfer_config` is even read, and soft-terminal keys (`future_*`, `contact_later`, `colder`) are filtered out first — either mistake saves cleanly and never fires. Auto-transfer is also skipped while the lead is in human support (`in_support`). Transfers always create a fresh run on the target agent **at its initial status** — a transfer cannot land on a chosen status in another agent (the only status→status pointer is `timeout_config.target_status_key`, same workflow only). Source cadence is cancelled, the target's cadence starts over, and collected fields travel as a read-only transfer-chain snapshot — they are not copied into the target's fields. If the "transfer" is just a stage of the same conversation, it's a status, not a transfer.
- **Agent creation order never satisfies a connection.** A handoff exists only after the source's terminal status has been read back with `transfer_config.target_workflow_id` equal to the destination's real id. Preserve the expected connection manifest across the build, wait until all referenced ids exist, then reconcile every source. Creating A before B means returning to A after B exists; creating B before A changes nothing. A multi-agent build is incomplete until every expected edge passes this read-back check.
- **Account knowledge is not agent knowledge.** `knowledge_bases` is the client-owned catalog; `workflow_knowledge_bases` is the per-agent assignment. One KB may be shared by several agents, and each agent may receive a different ordered subset. Listing the account catalog proves only that a KB exists. Agent access exists only after `list_knowledge_bases({ workflow_id })` reads that KB back on the intended agent. Create or reuse the account KB first, attach by its real id, detach only the relationship when removing access, and never delete shared content to change one agent's assignment.
- **An agent is born named, with the account's channels, and its modes inherit everything.** Nexor picks the agent's human first name at `create_agent_group` (never ask for one, never pass one; rename later only on an explicit request). The agent has every channel the account holds a verified resource for, and each `create_workflow` inherits the agent's channels, contact schedule, persona name, language and timezone — from the agent's other modes, else from the account — and binds the resources itself (`inheritance` + `channel_bindings` in the result). Channels and contact hours are never questions, for any agent: no channel picker, no "use this number or connect another?", no "business hours or 24/7?", no "channel & hours" form, and no `channels` key in the plan. The resource flow runs only when the operator explicitly asks for a specific resource or a narrower mix, or to resolve an `owned_by_other_agent` outcome — which the tool never resolves silently.
- **Contact hours at birth derive from the operator's words.** A mode inherits its agent's windows when the agent already has modes; the first mode gets Mon–Fri 09:00–18:00 only when nobody said anything about hours. Any hint — after-hours, overnight, 24/7, weekends, specific times, a use case that only exists outside office hours — becomes an explicit `contact_schedule` on that mode in the plan and in `create_workflow` (`{mode:"always"}` or `{mode:"windows", windows:[{start,end,days_of_week}]}`); `review_agent_system_plan` holds sign-off when a mode's own words hint at hours and no schedule is set. Never ask for hours the conversation already implied; when the default applies, say so in one line ("contacts Mon–Fri 09:00–18:00; can be changed"). Existing agents change hours with `set_workflow_cadence`.
- **Account channels are not questions.** The account already knows its saved timezone, connected WhatsApp numbers, send-capable email senders, active call numbers, and which active numbers currently have SMS enabled. Read them before planning; every new agent is born with all of them and `create_workflow` binds them. The selector below applies only when the operator explicitly asks for a specific resource: one resource means “use this exact resource or configure another”; several means a real resource selector; zero means saying so in one line and offering setup. Never ask “do you already have a WhatsApp number?” or request an email/phone the platform can list. `verification_status: "verified"` is not enough when email `can_send` is explicitly false. **SMS is not provisioned and is not "set up" as its own channel** — it rides on a phone number the account already has. As long as an active Twilio number exists, do not start a buy-a-number flow: read the phone inventory and the only question is which agent(s) should receive SMS, then activate it per agent via `set_number_sms({ number_id, enabled: true, sms_workflow_id })`. SMS requires a Twilio-carrier number (Telnyx numbers cannot carry SMS); there is no country restriction. `sms_enabled: false` on an active Twilio row means SMS is simply not activated yet—not that a channel is missing. Only when the account has no number at all does provisioning enter the SMS conversation.
- **The source selects intensity; the target implements it.** Put mutually exclusive routing criteria in source `entry_hint` values and one `transfer_config` on each terminal branch. Put sales urgency, booking tools, nurture tone, enabled channels, and contact frequency on the corresponding target workflows — never on the source status.
- **The source never speaks the hand-off; the target's first message is designed.** On transfer the platform makes the target agent speak next — a contextual transition composed from the target's own prompt when the messaging window is open, its opening template otherwise. So the source prompt moves the lead to the boundary status and ends the turn with no hand-off dialogue (no "te paso con mi colega", no "the link is on its way", no farewell), and the target prompt carries an explicit arrival rule. For a payment-link agent that arrival message is one line acknowledging what was accepted plus the payment link, with `set_payment_link` configured beforehand (recipe 22).
- **An agent inside an agent group is never edited in isolation.** If the agent being edited has a `master_workflow_id`, default to reading the whole group first: `list_workflows` for the siblings, `get_workflow` on the edited agent and on every sibling whose statuses transfer into or out of it, and the shared channel bindings (WhatsApp and iMessage numbers are shared across a group). Know who hands the lead in, on which status and with which context, and who receives it, before touching a prompt, a status, a first message, or a transfer — one agent's change alters what its neighbours must say and not say.
- **Function effects are buffered and return nothing**, so a scheduled function cannot create a lead and enroll it in the same run. To sync leads in *and* start conversations, call the public leads API (it upserts, merges metadata, enrolls, and starts first contact in one request) from the function, or use a two-phase sweep.
- **Send-once is stateful, never assumed.** Cohort messaging without `then_status_key` (or a cooldown) re-sends every cycle.
- **Nexor only sees conversions that happen in the conversation.** A meeting the agent books or a Nexor payment link is counted automatically; anything that closes in a CRM, ERP, POS, store or billing system needs the conversion event webhook (`POST /api/public/conversions`, `create_conversion` over MCP). A conversion type is optional; the lead identifier alone (`lead_id`, `lead_email` or `lead_phone`) is enough. Put every fact about the conversion (deal id, product, seller, branch, plan) in the event's free-form `metadata`, never in the description — metadata reaches the lead, the `conversion.detected` cloud-function event and the list filters. Destination precedence: type > agent (`set_conversion_destination`) > group. Credit follows the same idea: pass `workflow_id` to credit one agent, or `master_workflow_id` alone to record a group-level conversion (no agent credited, the group's destination applies); explicit target > type scope > the lead's active agent.
- **A tool can run without the model choosing to.** `set_workflow_tool_execution({ mode: "on_entry" })` makes a tool a pre-execution hook that fires when the lead enters the workflow, before the first agent message. `on_entry` and `agent_decides` are mutually exclusive, and an on-entry hook **cannot** be stage-gated — the call is rejected if you pass `available_in_statuses`. Before proposing a conversational tool, ask whether "on entry" is actually the moment; if it is, the model never gets a chance to skip it.
- **Status automations have no tool of their own.** They live in the workflow `config` bag and are written with `update_workflow_config({ config: { status_automations: [...] } })`. The bag merges at the top level but the array is replaced wholesale, so read `get_workflow` and send the complete array every time. Rules fire on `on_status_key` for `enabled !== false`, exactly once per (workflow, lead, rule key) forever via a ledger, with one automatic retry. A missing `args_template` value aborts the rule instead of sending a partial body — which is why the value must be in metadata before the lead can arrive.
- **Three unrelated things are called "paused."** `workflows.is_paused` (the agent is off — every new agent starts here), `workflow_runs.is_paused` (one lead's run, set by `stop_automation`, and what job `exclusions.skip_paused` filters on), and status `pause_bot` (the agent goes quiet while the lead sits there). They do not imply each other.
- **The platform validates your plan before you build it.** `review_agent_system_plan` blocks on undefined transfer targets, duplicate refs, and missing goals, and raises clarifications for missing responsibility, language, timezone, channels, or a status with no entry criterion. For an appointment agent it also blocks on a missing meeting type, an empty host list, or the absence of both a calendar plan and an external booking provider. It returns an internal `plan_fingerprint` and the exact sign-off question. Never show the `plan_fingerprint`, any hash, or the word "fingerprint" to the user: close the summary with "Does this look ok? Let me know and I'll build it." Treat a clean review plus explicit user approval as the precondition for the first mutation, not a formality.
- **An active appointment agent with booking blockers cannot book.** `set_workflow_active` does not check for a meeting type, a host with availability, or a connected calendar; `get_account_readiness` does, per agent, under `booking`. Activation is the last step of the Booking agent path, never the proof that it worked — `get_workflow_slots` returning slots is.
- **Runtime tools are the agent's, not yours.** `get_available_slots`, `confirm_and_book`, `create_event`, `reschedule_event`, `cancel_event`, `request_channel_swap`, `schedule_followup`, `save_field` and `patch_metadata` run inside the conversation and are not callable over MCP. Name them only to gate (`available_in_statuses`) or disable (`disabled_builtin_tools`) them; use `book_meeting`, `reschedule_meeting`, `cancel_meeting` and `update_lead` when the operator wants the action performed now.
## Guardrails
- Never mutate before `review_agent_system_plan` returns `ready_for_signoff: true` **and** the user approves the summarized plan (ask "Does this look ok? Let me know and I'll build it."; the `plan_fingerprint` stays internal and is never shown). Agents are created paused; keep them paused until read-back passes and activation is confirmed separately.
- **Deletion is explicit, two-step, and scope-gated; never empty an agent's pipeline silently.** Use these deletion capabilities only when the corresponding tools are exposed in the current MCP session; a host product may intentionally omit them. `delete_workflow` archives an agent and releases its channels, but the backend refuses with HTTP 409 `AGENT_HAS_LEADS` (with distinct `lead_count`) while any active `workflow_run` remains. Call it once without `confirm:true` to obtain its write-free `needs_confirmation` preview, then call it with `confirm:true` only after the operator approves that exact agent. When blocked, report the count and offer an explicit choice to move those leads, archive them reversibly with the `leads:write` tools, or permanently erase them with `hard_delete_lead` / `bulk_hard_delete_leads` only when those tools are exposed and the key has the separately granted, non-default `leads:delete` scope. Permanent lead deletion follows the same preview-then-confirm contract: first call without `confirm:true`, present the exact lead id(s), then repeat with `confirm:true` after approval. Clarify every ambiguous “delete/remove” request as reversible archive versus permanent erasure. Permanent erasure is irreversible and forensically audited. Never archive, delete, or status-change leads as an unannounced workaround for agent deletion. Agent-group deletion (`delete_agent_group`) stays relationship-only — never delete, pause, or mutate a member agent as a substitute.
- Know what each write destroys before sending it. `set_workflow_prompt` overwrites the whole prompt, `update_workflow_config` replaces any array you touch, `set_workflow_cadence` is a whole-document PUT for blocks, and `update_workflow_config({ replace: true })` wipes the config bag. Read the current value first — see [references/mcp-tool-surface.md](references/mcp-tool-surface.md#3-merge-replace-and-destroy-semantics).
- Know what each write *accepts* before sending it. `update_workflow` mutates `name`, `description`, `goal_statement`, the agent identity (`language`, `timezone`, `region_style`, `agent_name`, `agent_role`, `company_name`, `begin_message`), and `master_workflow_id` (the agent to move the mode into — never `null`: a mode always belongs to an agent, so it is moved, not detached); every call must carry at least one of them or it fails `INVALID_INPUT`. Pause state goes through `set_workflow_active` and the channel mix through channel-binding tools plus `config.disabled_channels` — neither is an `update_workflow` parameter. Persona and business rules are prompt text: `set_workflow_prompt` / `set_channel_prompt`, never identity fields.
- **A behavior change lands on every prompt surface, not just the base prompt.** When the operator adds a rule, a condition, or a branch driven by a lead field or metadata ("you will now receive X; if X > 50 do Y") without naming a channel, write it into the base prompt with `set_workflow_prompt` **and** into every channel prompt that has a body (`get_workflow` → channel prompts) with one `set_channel_prompt` per channel, phrased in that channel's register and merged into the existing text. SMS and iMessage outbound messages are composed from the channel prompt alone when one exists, and the call and WhatsApp overlays are what the agent follows turn by turn, so a rule left only in the base prompt silently never reaches those channels. Scope it to one channel only when the operator named that channel; report which surfaces you wrote.
- **Say what is Nexor and what is outside it.** You speak in the first person, so "the system" and "your system" are ambiguous: the operator cannot tell whether you mean Nexor or their own tooling. Call Nexor "Nexor" (or the agent, the status, the webhook, the function). Call anything outside it by what it is: "your CRM", "your ERP", "your own backend", "your spreadsheet", "your external API", or, when you do not know, "your external API (outside Nexor)". An outbound webhook "notifies your external API"; an inbound webhook "receives leads from your external tool"; a conversion "is sent by your CRM or backend to Nexor"; an HTTP tool "calls your external API". Reuse the proper name once the customer gives it ("your HubSpot", "your Shopify").
- A one-mode plan without the operator's explicit single-mode instruction is a design error, not a simplification: split at every change of intent or register (2–3 modes by default) before asking for sign-off. A plan of five or more modes is the opposite error; merge modes that share both intent and register.
- Reject any design that breaks a law and fix its shape rather than compensating with prompt wording. Name the violated law: a needed fact carried only in prompt prose or conversation history (Law 1); a stage change with no status key (Law 2); a tone, persona, or tool-set switch that is not a boundary status with a named target agent (Law 3). Prompt text is speech; fields, statuses, and transfers are structure — only structure is enforced.
- A pause boundary with no named executor is an unfinished transfer. Either make the boundary terminal with `transfer_config`, or state the job/API/human that performs the handoff and verify it moves a test lead.
- A build whose goal is not booking or a Nexor payment is incomplete until the conversion source is named and either wired (test event read back with `list_conversions`) or explicitly deferred by the customer.
- Never make a fact reachable in only one store when both a gate and an automation need it. If a status gates on `field_x` and an `args_template` must send it, the value must exist as a field *and* in metadata before the lead reaches that status.
- Dry-run first for anything that messages humans or mutates many leads, and read the candidate list before activating. Manual function runs preview effects without applying them — but outbound HTTP in a dry run is **real**; point test runs at test endpoints.
- Get explicit customer confirmation before activating any cohort automation or anything that sends messages.
- Make side-effecting tools idempotent and return a persistent external operation ID. Set `call_once` where a second successful call would be wrong, and define what the agent says when the endpoint times out or fails.
- For every proposed agent tool, state why a webhook, status automation, or event cloud function cannot provide the same behavior. If no immediate conversational dependency exists, replace the tool with the deterministic mechanism.
- Test every stage-gated tool twice: a call before the allowed status must return `tool_not_available_in_stage`, and the same valid call after qualification must reach the endpoint. Do not rely on prompt wording as the gate.
- Do not design on unsupported surface: API-triggered background jobs, `workflow_scoped` filter mode, and CRM-event job triggers (other than tag and payment events) are not currently functional — see the reference for the supported list.
- Never place credentials in function code or tool config; store them as Environment Variables and reference `env.KEY` / `{{env.KEY}}`.
- If the requirement genuinely fits no primitive, say exactly what is missing instead of forcing an approximation.
- Treat multi-agent finalization as graph closure, not agent-count completion. If any expected transfer is missing, unresolved, attached to the wrong source status, or points to the wrong target id, report the build as incomplete and continue reconciliation or surface the exact failed edge. Never emit a success claim for a partially connected system.
- Treat knowledge configuration as assignment closure. Compare each agent's observed KB ids and priority order with its expected manifest; attach missing links and detach extra links, then read back again. Never infer access from the account catalog, and never claim an agent is fully configured while its KB set differs from the plan.
- Treat channel configuration as assignment closure. Compare every requested channel and selected resource id with the post-build account inventory and workflow config. Never infer a binding from “channel enabled,” never reassign an already-bound number without targeted confirmation, and re-read the exclusive owner immediately before the write so stale approval cannot move a resource from a different incumbent. Never claim completion while a selected sender/number is missing, unusable, bound to the wrong agent, disabled by config, or contradicted by `first_contact_channel`. If WhatsApp will initiate outreach, list an `APPROVED` greeting/opening/legacy_greeting/outbound template, configure it with `set_opening_templates`, and read it back with `get_template_pool` before activation.
## Output
Produce a configuration spec containing:
- A **variable ledger** (Law 1): every fact the design needs, its `key`, its store (workflow field / metadata / both), how it is obtained (agent asks, intake payload, tool response), and what reads it (gate, template, filter, prompt).
- A **mode ledger**: every mode with its intent (what it gets the lead to do), its register (tone, intensity, cadence, channel mix), and the boundary that makes it a separate mode — 2–3 rows by default, one row only with the operator's quoted single-mode instruction.
- A **stage ledger** (Law 2): every status key per agent with its `entry_hint` criterion, its gate (`required_field_keys` / `transition_rules`), and what entering it unlocks or fires.
- Each primitive used: its name, its trigger, and the exact configuration (JSON snippets with real field names from the reference).
- For every workflow HTTP/MCP tool: endpoint owner, input/output schema, `available_in_statuses`, `call_once`, response fields stored or shown to the agent, prompt invocation rule, and failure behavior.
- For every integration action: explain why its invocation is deterministic, or explicitly justify why an agent-selected tool is unavoidable and accept the lower reliability.
- For every transfer branch: define its source status criterion and target workflow, then specify the target's goal, prompt focus, tools, channels, contact cadence/recontact rule, and transferred fields it must not ask for again.
- A connection manifest for every multi-agent system (Law 3): each edge's source agent, source boundary status, boundary shape (terminal transfer, or pause plus its named executor), and target agent — followed by observed read-back evidence for each edge (`source agent + source status → target agent id`). The order agents were created must not affect this list.
- A knowledge manifest for every created or edited agent, followed by observed read-back evidence for the exact assigned KB ids/names and priority order. Distinguish account-owned availability from per-agent access.
- A channel manifest for every created or edited agent, followed by observed read-back evidence for the exact WhatsApp number, email sender, call number, and SMS ids selected and assigned. SMS read-back is the `sms_workflow_id` binding on the account's Twilio number, not a separately provisioned resource. Distinguish account availability from per-agent binding and call out any reassignment explicitly.
- How the pieces connect (e.g. "field `salary` → status `low_income` via `entry_hint` → `transfer_config` → agent B").
- The ladder rungs you rejected and why, in one line each.
- A verification checklist: the previews/dry runs to perform and the observable result that means "safe to activate."
## Resources
- Read [references/recipes.md](references/recipes.md) when matching a customer ask to a known pattern — start from the closest recipe.
- Read [references/configuration-reference.md](references/configuration-reference.md) when writing the concrete configuration for any primitive.
- Read [references/mcp-tool-surface.md](references/mcp-tool-surface.md) before the first write: which tool writes what, its read-back pair, what it overwrites, and which properties MCP cannot reach at all.
- Read [references/booking-agent.md](references/booking-agent.md) when the agent books meetings: the ordered path (meeting type → hosts → availability → calendar → reminders), the full reminder-rule contract, host routing, operator meeting operations, and the Calendly and external-booker alternatives.