> ## Documentation Index
> Fetch the complete documentation index at: https://docs.goyappr.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Create a campaign

> Creates the campaign in `draft`. Nothing is dialed until you enroll contacts and
call `POST /campaigns/{id}/launch`. Required scope `campaigns:manage`.

Only the fields below may be sent. Any other key — including every engine-owned
field (`status`, `spent_cents`, `daily_admitted_count`, `last_tick_result`, …) and
every typo — is rejected with `400` listing the writable set.

**None of the configuration fields have defaults.** The `example` values show what
the dashboard prefills, but a field you omit stays `null` and `launch` refuses with
`422 CAMPAIGN_NOT_READY` naming it. This is deliberate: how many times to call
someone, and when to stop, are decisions the platform will not make on your behalf.
Send every field on create, or fill the gaps with `PATCH` before launching.


A campaign is a paced list of outbound calls over enrolled contacts. You describe *who* to call, *which agent* calls them, *when to stop* calling a person, and *how fast* the list may be worked; the platform does the rest.

A campaign is **always created in `draft`**, whatever you send. Nothing is dialed until you call `POST /campaigns/{id}/launch`, so a draft is a safe place to enroll contacts and tune pacing.

Required scope: `campaigns:manage`.

<Warning>
  **No configuration field has a default.** The "Suggested" column below is what the
  dashboard prefills for a human, not what the API substitutes for you. A field you never
  send stays `null`, and `POST /campaigns/{id}/launch` returns `422 CAMPAIGN_NOT_READY`
  naming it.

  This is deliberate. How many times to call someone, and when to stop, are decisions the
  platform will not quietly make on your behalf. Send every field on create, or fill the
  gaps with `PATCH` before you launch.
</Warning>

## The full journey

1. **Create the draft** — `POST /campaigns` with a `name`, the `agent_id` that will do the talking, the `from_phone_number_id` to call from, a `regulatory_basis`, and at least one stop rule.
2. **Enroll contacts** — `POST /campaigns/{id}/leads` with `lead_ids` (leads you already have) and/or `phone_numbers` (numbers that become leads). Numbers on the do-not-call list are reported and skipped.
3. **Tune it** — `PATCH /campaigns/{id}` for pacing, attempt caps, stop rules, budget. Re-read with `GET /campaigns/{id}`.
4. **Launch** — `POST /campaigns/{id}/launch`. A preflight runs first and returns `422 CAMPAIGN_NOT_READY` with a specific reason when the campaign cannot dial. On success the status becomes `running`.
5. **Watch it** — `GET /campaigns/{id}/stats` for progress counters and `last_tick_result`, the machine-readable answer to "why is nothing happening right now". `GET /campaigns/{id}/leads` for per-contact state.
6. **Control it** — `POST /campaigns/{id}/pause`, `/resume`, `/stop` (terminal), or `DELETE /campaigns/{id}` to archive.

## A campaign call is an ordinary outbound call

A campaign hands calls to the same outbound queue that `POST /calls` uses, at the same weight. The pacing fields below control **how fast a campaign offers calls to that queue** — they never buy priority over anything else in the workspace and they cannot raise your concurrency. If the queue is busy, a campaign waits its turn like any other call.

Dialing is also gated by your workspace calling hours (`GET /call-windows`) and by your credit balance, both re-checked continuously while the campaign runs.

## Identity and targeting

| Field                  | Type   | Suggested | Notes                                                                                                                                                                                                                                                                                                                                    |
| ---------------------- | ------ | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`                 | string | —         | **Required.** Trimmed. Names do not have to be unique — "Follow-up" is a fine name for this month's and last month's, and a campaign is identified by its `id` everywhere that matters.                                                                                                                                                  |
| `description`          | string | `null`    | Free text for your own bookkeeping.                                                                                                                                                                                                                                                                                                      |
| `agent_id`             | uuid   | `null`    | The agent that places the calls. Required before launch. The agent must have a positive `max_call_duration_secs` so worst-case spend is bounded.                                                                                                                                                                                         |
| `from_phone_number_id` | uuid   | `null`    | A number you own (`GET /phone-numbers`) and that is active. Required before launch.                                                                                                                                                                                                                                                      |
| `regulatory_basis`     | string | `null`    | **Required before launch.** `lawful_basis_confirmed` (a single attestation that you have consent or another lawful basis — what the dashboard records), or one of the specific bases `consent`, `existing_customer`, `non_marketing`, `registry_screened`. Recorded with the launch event as the record of why these people were called. |

## Stop rules — when to stop calling one person

Two independent per-contact stop conditions apply, whichever fires first:

* **`max_attempts`** — the attempt cap.
* **The stop-disposition set** — landing one of these outcomes retires that contact permanently. Anything else is retried until the cap.

| Field                  | Type    | Suggested | Notes                                                                                                                                                                                     |
| ---------------------- | ------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `stop_disposition_ids` | uuid\[] | `[]`      | Disposition **IDs** (`GET /dispositions`), never labels — labels are renameable, IDs are stable. Every ID must belong to your workspace.                                                  |
| `stop_on_no_answer`    | boolean | `false`   | Retire a contact the first time nobody picks up.                                                                                                                                          |
| `stop_on_voicemail`    | boolean | `false`   | Retire a contact when the call reaches voicemail.                                                                                                                                         |
| `stop_on_unclassified` | boolean | `false`   | What to do when the outcome that arrives is "no clear outcome" — the call happened but matched none of your outcomes. `false` retries, `true` retires. Not a timeout; see the note below. |

At least one of `stop_disposition_ids`, `stop_on_no_answer` or `stop_on_voicemail` must be set before launch. A campaign with no stop rule would call every contact until the attempt cap, so the preflight refuses it.

To stop calling people you have already spoken to, create a disposition for that outcome (`POST /dispositions`) and put its ID in `stop_disposition_ids`. There is no built-in "reached a human" rule: what counts as a real conversation differs per workspace, so it belongs in your own outcome list where you can see and change it.

**Do not put `No Answer`, `Failed`, `Voicemail` or `Unclassified` in `stop_disposition_ids`.** Those labels are also assigned automatically, and a real 90-second conversation can legitimately end up carrying one of them — putting them in the set would retire genuine conversations as never-reached. Use the `stop_on_no_answer` / `stop_on_voicemail` / `stop_on_unclassified` booleans instead: they are evaluated on the call outcome rather than on the label.

Categorization happens **after** a call ends, not during it, so a contact sits in `awaiting_disposition` until its outcome lands. It is never re-dialed while in that state, and there is no deadline after which the platform decides without one.

<Note>
  **A call's outcome is authoritative, and a contact is never advanced without one.**
  Outcomes are assigned asynchronously after a call ends, so a contact sits in
  `awaiting_disposition` until its outcome arrives — however long that takes. Only that
  contact waits; the pacer keeps admitting every other contact in the campaign. There is
  no timeout that decides on your behalf, because "we never learned what happened" is
  not a basis for calling someone again.
</Note>

## Attempts and retries

| Field                     | Type    | Suggested | Notes                                                                                                                                                                                                                                                                                                            |
| ------------------------- | ------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `max_attempts`            | integer | `3`       | `1`–`999`. Dial attempts per contact before it becomes `exhausted`. Set it as high as your follow-up actually goes.                                                                                                                                                                                              |
| `max_infra_retries`       | integer | `3`       | `0`–`20`. A separate budget for failures where no call ever reached the person. These never consume a dial attempt and are never terminal for the contact.                                                                                                                                                       |
| `retry_no_answer_seconds` | integer | `3600`    | `30`–`604800`. Wait before re-offering a contact nobody picked up. Also used when the call reached voicemail or a busy line.                                                                                                                                                                                     |
| `retry_completed_seconds` | integer | `86400`   | `60`–`604800`. Wait before re-offering a contact whose call connected but did not hit a stop rule.                                                                                                                                                                                                               |
| `randomize_retry_time`    | boolean | `false`   | Which time of day the retry lands on. `false` keeps the wait exact — a one-week wait retries at the same hour a week later. `true` picks a different hour inside the calling window, so repeat attempts do not always arrive at the same moment. Either way the retry is never earlier than the configured wait. |
| `double_dial_enabled`     | boolean | `false`   | Ring a second time shortly after an unanswered first ring. The pair counts as one attempt.                                                                                                                                                                                                                       |
| `double_dial_gap_seconds` | integer | `90`      | `10`–`3600`. Gap between the two rings. Required before launch even when `double_dial_enabled` is `false`.                                                                                                                                                                                                       |

## Pacing

| Field                       | Type    | Suggested | Notes                                                                                                                                                                      |
| --------------------------- | ------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `max_calls_per_day`         | integer | `200`     | `1`–`100000`. Resets at midnight in your workspace timezone.                                                                                                               |
| `min_seconds_between_calls` | integer | `30`      | `0`–`86400`. Minimum spacing between two calls from this campaign.                                                                                                         |
| `max_in_flight`             | integer | `2`       | `1`–`8`. How many calls this campaign may have outstanding at once. Self-restraint, not a reservation — it stops one campaign monopolising shared outbound capacity.       |
| `calling_window`            | object  | `{}`      | Optional per-campaign narrowing, `{ tz, days: [0..6], start: "HH:MM", end: "HH:MM" }`. Leave it `{}` to follow the workspace calling hours, which gate dialing either way. |

## Money and schedule

| Field          | Type            | Suggested | Notes                                                                                                                                                                                                                            |
| -------------- | --------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `budget_cents` | integer \| null | `null`    | Positive integer, or `null` for no cap. Enforced against spend **plus** the worst case of everything currently in flight, so a campaign cannot overshoot by an entire live cohort. On hit the campaign moves to `paused_budget`. |
| `starts_at`    | timestamp       | `null`    | ISO-8601. Records the intended start. It does **not** launch the campaign for you — a campaign only dials while it is `running`.                                                                                                 |
| `ends_at`      | timestamp       | `null`    | ISO-8601. Records the intended end.                                                                                                                                                                                              |

## Fields you cannot set

Unknown keys are **rejected with 400** rather than ignored, so a typo like `stop_dispositions` can never leave you believing you configured a kill switch that is not there.

`retry_rules` is rejected too. It was an earlier structured retry matrix that the dialer never evaluated; the scalar fields above are the whole retry configuration.

These are engine-owned and rejected the same way: `status`, `from_number`, `daily_admitted_count`, `daily_window_date`, `last_admitted_at`, `spent_cents`, `reserved_cents`, `estimate_cents`, `last_tick_at`, `last_tick_result`, `last_error`, `started_at`, `completed_at`, `total_leads`, `stats`.

## Example request

```bash theme={null}
curl -X POST "https://api.goyappr.com/campaigns" \
  -H "Authorization: Bearer $YAPPR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "July renewals",
    "description": "One call per renewal lead, up to three tries.",
    "agent_id": "7e8a91c1-0000-4c11-9a00-000000000001",
    "from_phone_number_id": "2d9f4b6a-0000-4a3c-8b21-000000000002",
    "regulatory_basis": "existing_customer",
    "stop_disposition_ids": [
      "5c1d9a2e-0000-4b10-9f31-000000000010",
      "5c1d9a2e-0000-4b10-9f31-000000000011"
    ],
    "stop_on_no_answer": false,
    "stop_on_voicemail": true,
    "stop_on_unclassified": false,
    "max_attempts": 3,
    "max_infra_retries": 3,
    "retry_no_answer_seconds": 3600,
    "retry_completed_seconds": 86400,
    "randomize_retry_time": true,
    "double_dial_enabled": false,
    "double_dial_gap_seconds": 90,
    "max_calls_per_day": 150,
    "min_seconds_between_calls": 45,
    "max_in_flight": 2,
    "budget_cents": 50000
  }'
```

## Example response

HTTP 201. `agent`, `from_phone_number` and `stop_dispositions` expand the IDs you supplied — each is the complete referenced object, abbreviated here for brevity.

```json theme={null}
{
  "id": "b3f1c0d2-5a44-4f0e-9c11-7a2e8d3f0001",
  "company_id": "fe493f11-0000-0000-0000-000000000001",
  "name": "July renewals",
  "description": "One call per renewal lead, up to three tries.",
  "status": "draft",
  "agent_id": "7e8a91c1-0000-4c11-9a00-000000000001",
  "from_phone_number_id": "2d9f4b6a-0000-4a3c-8b21-000000000002",
  "from_number": null,
  "calling_window": {},
  "stop_disposition_ids": [
    "5c1d9a2e-0000-4b10-9f31-000000000010",
    "5c1d9a2e-0000-4b10-9f31-000000000011"
  ],
  "stop_on_no_answer": false,
  "stop_on_voicemail": true,
  "stop_on_unclassified": false,
  "max_attempts": 3,
  "max_infra_retries": 3,
  "retry_no_answer_seconds": 3600,
  "retry_completed_seconds": 86400,
  "randomize_retry_time": true,
  "double_dial_enabled": false,
  "double_dial_gap_seconds": 90,
  "max_calls_per_day": 150,
  "min_seconds_between_calls": 45,
  "max_in_flight": 2,
  "daily_admitted_count": 0,
  "daily_window_date": null,
  "last_admitted_at": null,
  "budget_cents": 50000,
  "estimate_cents": null,
  "spent_cents": 0,
  "reserved_cents": 0,
  "regulatory_basis": "existing_customer",
  "last_tick_at": null,
  "last_tick_result": null,
  "last_error": null,
  "starts_at": null,
  "ends_at": null,
  "started_at": null,
  "completed_at": null,
  "total_leads": 0,
  "stats": {},
  "created_at": "2026-07-28T09:14:22.123Z",
  "updated_at": "2026-07-28T09:14:22.123Z",
  "created_by": null,
  "agent": {
    "id": "7e8a91c1-0000-4c11-9a00-000000000001",
    "name": "Renewals agent",
    "max_call_duration_secs": 300
  },
  "from_phone_number": {
    "id": "2d9f4b6a-0000-4a3c-8b21-000000000002",
    "phone_number": "+972737000000",
    "is_active": true,
    "status": "active"
  },
  "stop_dispositions": [
    { "id": "5c1d9a2e-0000-4b10-9f31-000000000010", "label": "Do Not Call" },
    { "id": "5c1d9a2e-0000-4b10-9f31-000000000011", "label": "Not Interested" }
  ]
}
```

## Errors

| HTTP | Code                 | When                                                                                                                         |
| ---- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| 400  | —                    | `name` missing, empty, or not a string.                                                                                      |
| 400  | —                    | Unknown or read-only field in the body. The message lists every writable field.                                              |
| 400  | —                    | A numeric field is outside its allowed range, a boolean field is not a boolean, or `budget_cents` is not a positive integer. |
| 400  | —                    | `regulatory_basis` is not one of the four accepted values.                                                                   |
| 400  | —                    | `stop_disposition_ids` is not an array of ID strings, or contains an ID that does not belong to your workspace.              |
| 400  | —                    | `retry_rules` or `calling_window` is not an object.                                                                          |
| 400  | —                    | Request body is not valid JSON.                                                                                              |
| 401  | `INSUFFICIENT_SCOPE` | API key lacks `campaigns:manage`.                                                                                            |


## OpenAPI

````yaml POST /campaigns
openapi: 3.1.0
info:
  title: Yappr API
  description: >
    Create and manage AI voice agents, purchase phone numbers, configure tools,
    and initiate calls — all via REST.
  version: 1.0.0
  contact:
    url: https://goyappr.com
servers:
  - url: https://api.goyappr.com
    description: Production
security:
  - apiKey: []
paths:
  /campaigns:
    post:
      tags:
        - Campaigns
      summary: Create campaign
      description: >
        Creates the campaign in `draft`. Nothing is dialed until you enroll
        contacts and

        call `POST /campaigns/{id}/launch`. Required scope `campaigns:manage`.


        Only the fields below may be sent. Any other key — including every
        engine-owned

        field (`status`, `spent_cents`, `daily_admitted_count`,
        `last_tick_result`, …) and

        every typo — is rejected with `400` listing the writable set.


        **None of the configuration fields have defaults.** The `example` values
        show what

        the dashboard prefills, but a field you omit stays `null` and `launch`
        refuses with

        `422 CAMPAIGN_NOT_READY` naming it. This is deliberate: how many times
        to call

        someone, and when to stop, are decisions the platform will not make on
        your behalf.

        Send every field on create, or fill the gaps with `PATCH` before
        launching.
      operationId: createCampaign
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - name
              properties:
                name:
                  type: string
                  example: July reactivation
                description:
                  type: string
                  nullable: true
                agent_id:
                  type: string
                  format: uuid
                  description: Required before launch.
                from_phone_number_id:
                  type: string
                  format: uuid
                  description: Required before launch.
                retry_rules:
                  type: object
                  additionalProperties: true
                calling_window:
                  type: object
                  additionalProperties: true
                  description: >-
                    Optional narrowing of the workspace calling hours. Cannot
                    widen them.
                stop_disposition_ids:
                  type: array
                  items:
                    type: string
                    format: uuid
                  description: >
                    Disposition IDs (never labels) that retire a contact
                    permanently. Do not include `No Answer`, `Failed` or
                    `Voicemail` — use `stop_on_no_answer` / `stop_on_voicemail`
                    instead.
                stop_on_no_answer:
                  type: boolean
                  example: false
                stop_on_voicemail:
                  type: boolean
                  example: false
                stop_on_unclassified:
                  type: boolean
                  example: false
                max_attempts:
                  type: integer
                  minimum: 1
                  maximum: 999
                  example: 3
                max_infra_retries:
                  type: integer
                  minimum: 0
                  maximum: 20
                  example: 3
                retry_no_answer_seconds:
                  type: integer
                  minimum: 30
                  maximum: 604800
                  example: 3600
                retry_completed_seconds:
                  type: integer
                  minimum: 60
                  maximum: 604800
                  example: 86400
                randomize_retry_time:
                  type: boolean
                  example: false
                  description: >-
                    false keeps a retry at the same time of day; true picks
                    another hour inside the calling window.
                double_dial_enabled:
                  type: boolean
                  example: false
                double_dial_gap_seconds:
                  type: integer
                  minimum: 10
                  maximum: 3600
                  example: 90
                max_calls_per_day:
                  type: integer
                  minimum: 1
                  maximum: 100000
                  example: 200
                min_seconds_between_calls:
                  type: integer
                  minimum: 0
                  maximum: 86400
                  example: 30
                max_in_flight:
                  type: integer
                  minimum: 1
                  maximum: 8
                  example: 2
                budget_cents:
                  type: integer
                  minimum: 1
                  nullable: true
                  description: Hard spend cap. Null for no cap.
                regulatory_basis:
                  type: string
                  enum:
                    - lawful_basis_confirmed
                    - consent
                    - existing_customer
                    - non_marketing
                    - registry_screened
                  description: Required before launch.
                starts_at:
                  type: string
                  format: date-time
                  nullable: true
                ends_at:
                  type: string
                  format: date-time
                  nullable: true
      responses:
        '201':
          description: Campaign created in `draft`
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Campaign'
        '400':
          description: >
            Validation error — unknown or read-only field, missing `name`, a
            numeric field out of range, a `regulatory_basis` outside the enum,
            or a `stop_disposition_ids` entry that isn't one of your company's
            dispositions.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Insufficient scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
components:
  schemas:
    Campaign:
      type: object
      description: >
        A paced bulk-outbound dialing job over enrolled contacts.


        A campaign is an **admission-only scheduler**. It decides *when* each
        enrolled

        contact becomes eligible, then performs one ordinary outbound call — the
        same

        queue, the same priority, and the same concurrency limits as a call
        placed with

        `POST /calls`. The pacing fields below only control how fast a campaign
        hands

        calls to that queue; a campaign call never takes precedence over
        anything else.


        Campaigns are always created as `draft` and are launched with

        `POST /campaigns/{id}/launch`.


        **Two independent per-contact stop conditions** apply, whichever fires
        first:

        `max_attempts`, and the stop-disposition set (`stop_disposition_ids`
        plus the

        `stop_on_*` booleans). Landing a stop disposition retires that contact

        permanently; any other outcome retries until the attempt cap.


        Fields marked *Engine-owned* are written by the campaign engine and are
        never

        writable. Sending one on create or update returns `400`, as does any
        field name

        that isn't on the writable list — a misspelled `stop_dispositions` is
        rejected

        rather than silently ignored, so you can never believe a kill switch is
        armed

        when it is not.
      properties:
        id:
          type: string
          format: uuid
        company_id:
          type: string
          format: uuid
        name:
          type: string
          example: July reactivation
        description:
          type: string
          nullable: true
        status:
          type: string
          enum:
            - draft
            - scheduled
            - running
            - paused
            - paused_insufficient_credit
            - paused_budget
            - paused_infra
            - paused_config
            - completed
            - stopped
            - archived
          description: >
            Engine-owned. Move it with the transition endpoints

            (`launch` / `pause` / `resume` / `stop`), never with `PATCH`.


            `draft` — created, not dialing.

            `scheduled` — waiting for `starts_at`.

            `running` — admitting contacts.

            `paused` — paused by you. A manual pause is **never** lifted
            automatically; it

            survives a top-up.

            `paused_insufficient_credit` — balance is below the minimum needed
            to place a

            call. **Auto-resumes** shortly after the balance is topped up.

            `paused_budget` — `budget_cents` reached.

            `paused_infra` — repeated platform-side failures.

            `paused_config` — a permanent configuration problem (from-number no
            longer

            active, agent missing a call-duration cap, no reachable calling
            window).

            `completed` — every enrolled contact reached a terminal state.

            `stopped` — stopped by you. Terminal.

            `archived` — soft-deleted by `DELETE /campaigns/{id}`. Terminal.
        agent_id:
          type: string
          format: uuid
          nullable: true
          description: Agent that runs the calls. Required before launch.
        agent:
          nullable: true
          description: Full Agent object for `agent_id`.
          allOf:
            - $ref: '#/components/schemas/Agent'
        from_phone_number_id:
          type: string
          format: uuid
          nullable: true
          description: >-
            Number the campaign calls from. Required before launch, and must
            still be active at launch time.
        from_phone_number:
          nullable: true
          description: Full PhoneNumber object for `from_phone_number_id`.
          allOf:
            - $ref: '#/components/schemas/PhoneNumber'
        from_number:
          type: string
          nullable: true
          description: >-
            Engine-owned. E.164 snapshot of the from-number, kept for audit even
            if the number is later released.
          example: '+972551234567'
        retry_rules:
          type: object
          additionalProperties: true
          description: >
            Optional per-outcome retry overrides. Free-form object — the
            explicit `max_attempts` / `retry_*_seconds` fields cover the common
            cases.
        calling_window:
          type: object
          additionalProperties: true
          description: >
            Optional campaign-level narrowing of the workspace calling hours.
            Omit to inherit the workspace schedule (`GET /call-windows`). A
            campaign can only ever dial inside the workspace window — this field
            cannot widen it.
        stop_disposition_ids:
          type: array
          items:
            type: string
            format: uuid
          description: >
            Disposition **IDs** that retire a contact permanently when assigned
            — the

            campaign's kill switch. Always IDs, never labels: labels are
            renameable, and a

            rename would silently disarm the rule.


            Do **not** list `No Answer`, `Failed` or `Voicemail` here. Those
            three are also

            auto-assigned to calls nobody picked up, so using them as stop
            dispositions

            retires contacts you did in fact speak to. Use `stop_on_no_answer`
            and

            `stop_on_voicemail` instead.


            Must contain dispositions belonging to your company; anything else
            is rejected

            with `400`.
        stop_dispositions:
          type: array
          description: Full Disposition objects for `stop_disposition_ids`.
          items:
            $ref: '#/components/schemas/Disposition'
        stop_on_no_answer:
          type: boolean
          example: false
          description: Retire a contact the first time a call goes unanswered.
        stop_on_voicemail:
          type: boolean
          example: false
          description: Retire a contact the first time a call reaches an answering machine.
        stop_on_unclassified:
          type: boolean
          example: false
          description: >
            What to do when the outcome that arrives is `Unclassified` — the
            call happened but could not be matched to any of your outcomes.
            `false` retries the contact, `true` retires it.

            This is not a timeout. A call's outcome is authoritative and a
            contact is never advanced without one: if classification is slow,
            only that contact waits while the campaign keeps calling everyone
            else.
        max_attempts:
          type: integer
          minimum: 1
          maximum: 999
          example: 3
          description: >
            Per-contact dial cap. One of the two independent stop conditions —
            the other is the stop-disposition set.
        max_infra_retries:
          type: integer
          minimum: 0
          maximum: 20
          example: 3
          description: >
            Separate budget for retries after a platform-side failure — a call
            that never reached the person. These do not consume `max_attempts`
            and are never terminal.
        retry_no_answer_seconds:
          type: integer
          minimum: 30
          maximum: 604800
          example: 3600
          description: Delay before retrying a contact who did not answer.
        retry_completed_seconds:
          type: integer
          minimum: 60
          maximum: 604800
          example: 86400
          description: >-
            Delay before retrying a contact whose call completed but landed on a
            non-stop disposition.
        randomize_retry_time:
          type: boolean
          example: false
          description: >
            Which time of day a retry lands on. `false` keeps the wait exact, so
            a one-week wait retries at the same hour a week later. `true` picks
            a different hour inside the campaign's calling window, so repeat
            attempts do not always arrive at the same moment. The wait length
            itself is unchanged either way — a randomized retry is still never
            earlier than the configured wait.
        double_dial_enabled:
          type: boolean
          example: false
          description: Ring a second time shortly after an unanswered first ring.
        double_dial_gap_seconds:
          type: integer
          minimum: 10
          maximum: 3600
          example: 90
          description: Gap between the two rings when `double_dial_enabled` is true.
        max_calls_per_day:
          type: integer
          minimum: 1
          maximum: 100000
          example: 200
          description: Daily admission cap, counted against the workspace timezone day.
        min_seconds_between_calls:
          type: integer
          minimum: 0
          maximum: 86400
          example: 30
          description: >-
            Minimum spacing between two calls handed to the queue by this
            campaign.
        max_in_flight:
          type: integer
          minimum: 1
          maximum: 8
          example: 2
          description: >
            Calls this campaign may have live at once. Platform concurrency
            limits still apply on top — raising this does not buy the campaign
            extra capacity.
        budget_cents:
          type: integer
          minimum: 1
          nullable: true
          description: >
            Hard spend cap for the campaign, enforced against `spent_cents` +
            `reserved_cents` (credits are debited when a call ends, so in-flight
            cost has to count). `null` means no cap. On hit the campaign moves
            to `paused_budget`.
        regulatory_basis:
          type: string
          nullable: true
          enum:
            - lawful_basis_confirmed
            - consent
            - existing_customer
            - non_marketing
            - registry_screened
          description: >
            Your lawful basis for calling this list. **Required before launch**
            — recorded on the campaign's launch audit record alongside the
            enrolled count.

            `lawful_basis_confirmed` is what the dashboard records: a single
            attestation that the caller has consent or another lawful basis for
            everyone on the list. The four specific values are for callers that
            know which one applies.
        starts_at:
          type: string
          format: date-time
          nullable: true
          description: Do not admit contacts before this time.
        ends_at:
          type: string
          format: date-time
          nullable: true
          description: Do not admit contacts after this time.
        daily_admitted_count:
          type: integer
          description: >-
            Engine-owned. Calls handed to the queue during the current day
            window.
        daily_window_date:
          type: string
          format: date
          nullable: true
          description: Engine-owned. Day `daily_admitted_count` belongs to.
        last_admitted_at:
          type: string
          format: date-time
          nullable: true
          description: Engine-owned. When this campaign last handed a call to the queue.
        estimate_cents:
          type: integer
          nullable: true
          description: Engine-owned. Projected campaign cost at the time of launch.
        spent_cents:
          type: integer
          description: Engine-owned. Settled spend so far.
        reserved_cents:
          type: integer
          description: >-
            Engine-owned. Worst-case cost of in-flight calls, held against
            `budget_cents` until they settle.
        last_tick_at:
          type: string
          format: date-time
          nullable: true
          description: Engine-owned. Last time the engine evaluated this campaign.
        last_tick_result:
          type: string
          nullable: true
          description: >
            Engine-owned. Machine-readable answer to "why is nothing happening
            right now" — e.g. daily cap reached, outside the calling window,
            waiting on in-flight calls.
        last_error:
          type: string
          nullable: true
          description: Engine-owned. Last error the engine recorded for this campaign.
        started_at:
          type: string
          format: date-time
          nullable: true
          description: Engine-owned. First launch time.
        completed_at:
          type: string
          format: date-time
          nullable: true
          description: Engine-owned. When the last contact reached a terminal state.
        total_leads:
          type: integer
          description: Engine-owned. Contacts currently enrolled.
        stats:
          type: object
          additionalProperties: true
          description: Engine-owned counter roll-up. Prefer `GET /campaigns/{id}/stats`.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        created_by:
          type: string
          format: uuid
          nullable: true
          description: Null when the campaign was created via the API.
    Error:
      type: object
      properties:
        error:
          type: string
        code:
          type: string
    Agent:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        type:
          type: string
          enum:
            - prompt
            - flow
          default: prompt
          description: >
            `prompt` (default, legacy): a single `system_prompt` drives the
            call; the LLM decides when to call attached tools. `flow`: the call
            is driven by a `flow_config` graph of conversation, tool-call, and
            routing nodes. `type` is **immutable** post-create — to change
            types, create a new agent.
        flow_config:
          oneOf:
            - $ref: '#/components/schemas/FlowConfig'
            - type: 'null'
          description: >
            The flow graph. Required when `type='flow'`, must be omitted/null
            when `type='prompt'`.
        system_prompt:
          type: string
        description:
          type: string
          nullable: true
        voice:
          type: string
          enum:
            - Michal
            - Yonatan
            - David
            - Rachel
            - Gil
            - Noa
            - Maya
            - Adam
            - Shira
            - Avigail
            - Amir
            - Liat
            - Omer
            - Tamar
            - Tom
            - Benny
            - Nir
            - Natan
            - Yael
            - Dvora
            - Yosef
            - Shir
            - Anat
            - Ariel
            - Roi
            - Shlomo
            - Dana
            - Alon
            - Ruth
            - Yuval
            - Keren
            - Eitan
            - Hila
            - Ido
            - Boaz
            - Tali
            - Erez
            - Efrat
          description: >
            The persona the agent speaks in, and the only thing that selects
            between Yappr's two voice families — there is no separate engine
            field, and `engine` / `engine_voice` are rejected if you send them.

            `Keren, Eitan, Hila, Ido, Boaz, Tali, Erez, Efrat` are the second
            family, available to every workspace. Agents on these voices handle
            their own turn-taking and expressiveness, so `temperature`,
            `vad_stop_secs`, `vad_start_secs` and `vad_confidence` are rejected
            on them, and they cannot be used on a `type='flow'` agent. Any other
            name returns `400` listing every voice you may use.

            Always the name the agent actually speaks in, whichever family it is
            on.
        background_sound:
          type: string
          nullable: true
          enum:
            - call_center
            - open_office
            - cafe
            - outdoor
          description: >-
            Ambient background sound mixed under the agent's voice during calls.
            Null means silent (default).
        background_sound_volume:
          type: number
          minimum: 0
          maximum: 0.6
          default: 0.3
          description: >-
            Volume of the background sound, 0.0–0.6. Capped to protect
            turn-taking.
        language:
          type: string
          enum:
            - he
            - en
        temperature:
          type: number
          minimum: 0
          maximum: 2
        greeting_message:
          type: string
          nullable: true
        agent_speaks_first:
          type: boolean
        vad_stop_secs:
          type: number
          minimum: 0.05
          maximum: 5
          default: 0.5
          description: Seconds of silence before VAD confirms speech has stopped
        vad_start_secs:
          type: number
          minimum: 0.05
          maximum: 2
          default: 0.2
          description: Seconds of speech before VAD confirms speech has started
        vad_confidence:
          type: number
          minimum: 0
          maximum: 1
          default: 0.7
          description: Minimum confidence threshold for voice detection
        silence_timeout_secs:
          type: number
          minimum: 10
          maximum: 900
          default: 60
          description: >-
            Seconds of caller silence before auto-hangup. Prevents idle calls
            wasting credits.
        max_continuous_speech_secs:
          type: number
          minimum: 0
          maximum: 300
          default: 120
          description: >-
            Max seconds one party can speak non-stop before auto-hangup. Catches
            answering machines. 0 = disabled.
        max_call_duration_secs:
          type: number
          minimum: 0
          maximum: 3600
          default: 600
          description: >-
            Hard cap on total call duration regardless of activity. 0 = no cap
            of the agent's own; the platform still ends the call after 65
            minutes (3900 s), with the disconnect reason `Platform call limit
            reached`.
        lead_memory_enabled:
          type: boolean
          default: true
          description: >-
            When true, the matched lead's long-term memory context is injected
            into the system prompt at call time.
        is_active:
          type: boolean
        webhook_url:
          type: string
          format: uri
          nullable: true
        webhook_events:
          type: array
          description: >
            Events posted to `webhook_url` as `{event, timestamp, agent_id,
            company_id, call_id, data}`.

            `call.failed` and `call.no_answer` carry `data.hangup_cause` when
            the cause is known. For a call

            from a number in [your own Telnyx
            account](/concepts/bring-your-own-carrier) that Telnyx

            refused while it rang, `call.failed` has `data.hangup_cause`:


            - `carrier_rejected` — Telnyx refused the call (SIP 401, 403 or
            407).

            - `carrier_number_invalid` — Telnyx could not route the number
            called (SIP 404, 484 or 604).


            The SIP code is not in the webhook; the carrier account keeps the
            last one in `last_error.sip_code`.

            One Telnyx refused before it rang sends no webhook when `POST
            /calls` was placing it (the

            request answers `422`); from the queue it sends `call.failed` with
            `data.error_reason` instead.
          items:
            type: string
            enum:
              - call.started
              - call.answered
              - call.ended
              - call.failed
              - call.no_answer
              - call.dnc_blocked
              - transcript.ready
              - call.analyzed
        webhook_headers:
          type: object
          nullable: true
          additionalProperties:
            type: string
          description: >
            Custom HTTP headers sent with every webhook delivery for this agent
            (e.g. an auth token).

            Flat name → string-value map, or null to clear. Headers that would
            override request routing

            or HTTP framing (Host, Content-Length, Transfer-Encoding,
            Connection, Expect, Keep-Alive,

            TE, Trailer, Upgrade, Proxy-*) are rejected with 400.
          example:
            Authorization: Bearer sk_live_…
            X-Source: yappr
        extraction_parameters:
          type: array
          description: >-
            AI extraction parameters — after each call, values are extracted
            from the transcript and included in the call.analyzed webhook
            payload (and stored on the call log).
          items:
            type: object
            required:
              - name
              - description
            properties:
              name:
                type: string
                example: customerName
                description: Parameter key in the extracted_data output
              description:
                type: string
                example: The caller's full name as mentioned during the conversation
                description: Instructions for the AI on what to extract
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    PhoneNumber:
      type: object
      properties:
        id:
          type: string
          format: uuid
        number:
          type: string
          example: '+972551234567'
        friendly_name:
          type: string
          nullable: true
        provider:
          type: string
          description: >
            Where the number comes from: `telnyx` for a number bought from
            Yappr,

            `external` for a number in your own Telnyx account, added to a

            [carrier account](/api-reference/carrier-accounts/add-number).
        status:
          type: string
          enum:
            - active
            - pending_requirements
            - suspended
        is_active:
          type: boolean
        inbound_agent_id:
          type: string
          format: uuid
          nullable: true
        outbound_agent_id:
          type: string
          format: uuid
          nullable: true
        sip_inbound_configured:
          type: boolean
        sip_outbound_configured:
          type: boolean
        country_code:
          type: string
          nullable: true
        monthly_cost:
          type: number
          nullable: true
        created_at:
          type: string
          format: date-time
        carrier_account:
          type: object
          nullable: true
          description: >
            The Telnyx account an `external` number calls through. `null` on
            numbers

            bought from Yappr.
          properties:
            id:
              type: string
              format: uuid
            name:
              type: string
            provider:
              type: string
              enum:
                - telnyx
            status:
              type: string
              enum:
                - untested
                - active
                - paused
        ownership_verified_at:
          type: string
          format: date-time
          nullable: true
          description: >
            When your Telnyx API key last proved that this `external` number is
            an

            active number in your account. `null` means Telnyx no longer lists
            it: the

            number is not callable until a
            [test](/api-reference/carrier-accounts/test)

            finds it again. Always `null` on numbers bought from Yappr.
    Disposition:
      type: object
      properties:
        id:
          type: string
          format: uuid
        label:
          type: string
        color:
          type: string
          nullable: true
          example: '#22c55e'
        position:
          type: integer
        is_protected:
          type: boolean
          description: Protected dispositions cannot be deleted.
        created_at:
          type: string
          format: date-time
    FlowConfig:
      type: object
      required:
        - nodes
      description: >
        The full flow definition stored in `agents.flow_config`. Constraints
        enforced at validate-time

        (any failure returns 400 `FLOW_INVALID` with one issue per problem — see
        `FlowInvalidError`):


        - Exactly one node of `type=start` (`no_start` / `multiple_starts`).

        - Start node has a `next_step_id` (`start_unwired`).

        - All `next_step_id` references resolve to a real node id
        (`unknown_target_node`).

        - Node ids are unique.

        - Every conversation node has non-empty `instructions`
        (`instructions_missing`).

        - Every conversation transition has a non-empty `description`
        (`transition_description_missing`).
          The description is the natural-language trigger the voice agent reads at runtime to pick a path —
          a bare `label` is not sufficient.
        - Every tool_call node has a `tool_id` (`tool_id_missing`).

        - At most 127 unique typed extraction contracts may be exposed in one
        flow
          (`too_many_extraction_contracts`). Nodes with identical effective tool/config schemas
          deduplicate; integration arguments participate only in `ai_extract` mode.
        - Every integration_call node has a valid `action` for its `provider`
        (`action_invalid`),
          an `integration_id` (`integration_id_missing`), and that integration belongs to the caller's
          company and is `active` (`integration_not_in_company`). Provider validity is enforced at
          schema parse time (`schema_invalid` if missing/unknown).
        - tool_call / integration_call nodes have a wired `success_next_step_id`
        (`success_not_wired`).

        - transfer nodes have a `transfer_to` (`transfer_to_missing`).

        - **Only `end` and `transfer` nodes may be terminal.** conversation,
        tool_call, and integration_call
          nodes must have at least one outgoing edge (a transition for conversation; `success` for the others).
          Violations: `terminal_not_allowed` per offending node, `no_terminal` if the flow has none at all.
        - Every node must be reachable from start (`unreachable_node`).
      properties:
        nodes:
          type: array
          minItems: 1
          items:
            $ref: '#/components/schemas/FlowNode'
        flow_config_version:
          type: string
          default: '1'
        metadata:
          type: object
          additionalProperties: true
          description: >-
            Optional metadata surfaced to the eval LLM context (e.g. agent
            purpose, customer name conventions).
          properties:
            custom_metadata_keys:
              type: array
              items:
                type: string
              description: >
                User-declared keys that this flow's `args_template` token
                interpolation expects to

                find under the `{{metadata.<key>}}` namespace at dispatch time.
                Surfaced in the

                dashboard so callers know which keys to pass in `POST /calls
                body.metadata`.

                Keys not listed here still resolve at runtime if passed (and to
                empty string if

                not) — this list is purely a hint for the UI / API consumers.
    FlowNode:
      description: Discriminated by `type`.
      oneOf:
        - $ref: '#/components/schemas/StartNode'
        - $ref: '#/components/schemas/ConversationNode'
        - $ref: '#/components/schemas/ToolCallNode'
        - $ref: '#/components/schemas/IntegrationCallNode'
        - $ref: '#/components/schemas/TransferNode'
        - $ref: '#/components/schemas/EndNode'
      discriminator:
        propertyName: type
        mapping:
          start:
            $ref: '#/components/schemas/StartNode'
          conversation:
            $ref: '#/components/schemas/ConversationNode'
          tool_call:
            $ref: '#/components/schemas/ToolCallNode'
          integration_call:
            $ref: '#/components/schemas/IntegrationCallNode'
          transfer:
            $ref: '#/components/schemas/TransferNode'
          end:
            $ref: '#/components/schemas/EndNode'
    StartNode:
      type: object
      required:
        - id
        - type
      properties:
        id:
          type: string
        type:
          type: string
          enum:
            - start
        name:
          type: string
        position:
          $ref: '#/components/schemas/FlowNodePosition'
        agent_speaks_first:
          type: boolean
          default: true
          description: >
            Whether the bot speaks first on call connect. When false, the bot
            waits silently for the caller to speak first (silence_timeout_secs
            on the agent still applies). For flow agents this OVERRIDES the
            agent-level agent.agent_speaks_first / agent.greeting_message
            fields.
        greeting:
          type: string
          nullable: true
          description: Spoken greeting (only used when agent_speaks_first is true)
        is_literal:
          type: boolean
          default: false
          description: >-
            When true, speak `greeting` verbatim. When false, treat it as an LLM
            instruction.
        next_step_id:
          type: string
          nullable: true
        auto_advance:
          type: boolean
          default: true
          description: >
            Whether to enter the first conversation node immediately on session
            start (default `true`). When `false`, the bot's greeting is
            delivered in start-node context only; the first conversation node is
            entered only after the user's first reply (via internal
            advancement).
    ConversationNode:
      type: object
      required:
        - id
        - type
        - instructions
        - transitions
      allOf:
        - $ref: '#/components/schemas/GlobalNodeFields'
      properties:
        id:
          type: string
        type:
          type: string
          enum:
            - conversation
        name:
          type: string
        position:
          $ref: '#/components/schemas/FlowNodePosition'
        instructions:
          type: string
          description: >-
            Layered on top of agent.system_prompt as a system message at step
            entry
        transitions:
          type: array
          items:
            $ref: '#/components/schemas/Transition'
    ToolCallNode:
      type: object
      required:
        - id
        - type
        - transitions
      description: >
        Deterministic tool dispatch. Tool args are owned by the tool itself

        (`payload_config.static_parameters` for literals plus

        `payload_config.extraction_parameters` for runtime-extracted values) —

        a `tool_call` node carries no per-node `args_template`. The same tool

        used by N flow nodes always sends the same shape; if you need a

        different shape per step, create a separate tool or supply a

        `config_override`. At call start, the effective referenced-tool config
        is

        registered as a flat argument-submission schema: one named string field

        per `extraction_parameters` entry, with `required` (default `true`)

        controlling which fields must be collected. The model never submits a

        `node_id` or nested `args` object. The runtime assembles standard call

        metadata and static parameters; the submitter exposes only extraction

        fields. Payload merge order is standard metadata, then static
        parameters,

        then extracted values, so extracted values win deliberate name

        collisions. Keep names unique unless that override is intentional.

        Schemas remain fixed for that live call; tool/config-override edits
        apply

        on the next call. Stale

        `args_template` payloads on this node type are silently dropped at parse

        time.
      properties:
        id:
          type: string
        type:
          type: string
          enum:
            - tool_call
        name:
          type: string
        position:
          $ref: '#/components/schemas/FlowNodePosition'
        tool_id:
          type: string
          format: uuid
          nullable: true
          description: References an existing row in the company's tools table
        config_override:
          type: object
          additionalProperties: true
          nullable: true
          description: >-
            Per-node override merged shallow over the referenced tool's config
            (array replacement). Open-shape — each tool type has its own valid
            keys.
        transitions:
          $ref: '#/components/schemas/ToolCallTransitions'
        pre_fire_announcement:
          type: boolean
          nullable: true
          description: >
            When true, the platform plays a short hold tone the moment this node
            fires so the caller

            doesn't sit in silence while the action runs. The tone is
            platform-controlled (not

            configurable) and stops automatically when the action returns. Use
            it for actions you

            expect to take more than ~500 ms (webhook to a slow CRM, complex DB
            lookup); skip it for

            fast actions to avoid making the call feel chatty.
        timeout_secs:
          type: number
          nullable: true
          minimum: 1
          maximum: 600
          description: >
            Per-node hard cap on execution time. When the action doesn't return
            in this many seconds,

            the runtime cancels it and routes to `error_next_step_id` with a
            `tool_timeout_after_Ns`

            error. When null/omitted on a webhook tool, the controller uses the
            effective tool

            `config.timeout_seconds` plus one second of dispatch overhead. Other
            tool nodes use the

            platform default (30s). Set this only when the flow needs a
            different node-level limit.
    IntegrationCallNode:
      type: object
      required:
        - id
        - type
        - provider
        - integration_id
        - action
        - transitions
      description: >
        Calls an OAuth-backed third-party integration (e.g. Google Calendar,
        Gmail) directly from the flow,

        without going through the `tools` table. The integration config —
        provider, account, action — lives on

        the node itself; the runtime resolves each entry in `args_template` per
        its declared mode (literal /

        ai_extract), interpolating `{{node.arg}}` and `{{metadata.key}}`
        mustache tokens, and dispatches

        against the matching integration client. Routing is deterministic and
        identical to `tool_call`

        (success / error / custom JSONPath branches, mutually exclusive, exactly
        one out-edge per fire).


        `provider` is locked at node creation; you cannot flip Calendar → Gmail
        on an existing node.

        `integration_id` must reference an `active` row in the caller's company
        `integrations` table whose

        `provider` matches this node's `provider`. `action` must be one of the
        actions in the catalog for the

        chosen provider.


        Action catalog:

        - `google_calendar.create_event` — required args: `summary`,
        `start_time`, `end_time`. Optional: `attendees`, `description`,
        `location`, `calendar_id`, `time_zone`.

        - `google_calendar.list_events` — optional args: `time_min`, `time_max`,
        `max_results`, `query`, `calendar_id`, `time_zone`.

        - `google_calendar.check_availability` — required args: `start_time`,
        `end_time`. Optional: `calendar_id`, `time_zone`.

        - `google_calendar.cancel_event` — required args: `event_id`. The
        runtime auto-resolves which calendar the event lives on (tries `primary`
        first, falls back to scanning the user's other writable calendars on a
        404), so `calendar_id` is intentionally NOT in the catalog for this
        action.

        - `gmail.send_email` — required args: `to`, `subject`, `body`. Optional:
        `html`, `cc`, `bcc`.


        For Google Calendar actions: `calendar_id` defaults to the user's
        `primary` calendar when blank. `time_zone` is an IANA name
        (`"Asia/Jerusalem"`) — when set, both Google's response is pinned to
        that zone and the event being created is stamped with it.


        **Calendar response post-processing**: `create_event`, `list_events`,
        and `check_availability` responses are sanitized for the LLM before they
        reach the agent — the voice model's ISO 8601 parser handles offsets
        unreliably, so the runtime strips the offset and seconds from each
        event's `dateTime` (leaving wall-clock format `"2026-05-10 16:30"`),
        removes per-event `timeZone` fields, and prepends a top-level `timeZone`
        + `timeZone_note` anchor. The agent only ever sees the sanitized view;
        JSONPath custom transitions (`transitions.custom[].jsonpath`) match
        against the sanitized view too. The raw, untouched Google response is
        preserved separately on the call event as `raw_response_preview` (see
        `FlowTraceToolCall` schema) for audit.
      properties:
        id:
          type: string
        type:
          type: string
          enum:
            - integration_call
        name:
          type: string
        position:
          $ref: '#/components/schemas/FlowNodePosition'
        provider:
          type: string
          enum:
            - google_calendar
            - gmail
          description: >-
            Locked at node creation. Must match the linked integration's
            provider.
        integration_id:
          type: string
          format: uuid
          description: >-
            References an active row in the caller's company integrations table
            whose provider matches this node's provider.
        action:
          type: string
          description: >-
            Provider-scoped action identifier — see the action catalog in the
            description.
          example: create_event
        args_template:
          type: object
          additionalProperties:
            $ref: '#/components/schemas/ArgValue'
          description: >
            Map of arg name → `ArgValue`. Required keys depend on the chosen
            action (see catalog above).

            Each `ArgValue` is one of: literal string, `{mode:'literal'}`, or
            `{mode:'ai_extract'}`.

            Both `literal.value` and `ai_extract.description` may contain
            `{{<node_id>.<arg_name>}}`

            tokens (cross-node slot refs to earlier `ai_extract` args) and
            `{{metadata.<key>}}`

            tokens (per-call metadata, built-in keys plus any user-declared
            custom keys; see

            `CallMetadataKey` and `FlowConfig.metadata.custom_metadata_keys`).
        pre_fire_announcement:
          type: boolean
          nullable: true
          description: >
            When true, the platform plays a short hold tone the moment this node
            fires so the caller

            doesn't sit in silence while the action runs. Tone is
            platform-controlled (not

            configurable) and stops automatically when the action returns.
            Recommended for

            `create_event` / `send_email` / network-bound actions; skip for
            `check_availability`

            which is fast.
        timeout_secs:
          type: number
          nullable: true
          minimum: 1
          maximum: 600
          description: >
            Per-node hard cap on execution time. When the action doesn't return
            in this many seconds,

            the runtime cancels it and routes to `error_next_step_id`. Null =
            use the platform

            default (30s).
        transitions:
          $ref: '#/components/schemas/ToolCallTransitions'
    TransferNode:
      type: object
      required:
        - id
        - type
      allOf:
        - $ref: '#/components/schemas/GlobalNodeFields'
      properties:
        id:
          type: string
        type:
          type: string
          enum:
            - transfer
        name:
          type: string
        position:
          $ref: '#/components/schemas/FlowNodePosition'
        transfer_to:
          type: string
          nullable: true
          example: '+972501234567'
        transfer_message:
          type: string
          nullable: true
    EndNode:
      type: object
      required:
        - id
        - type
      allOf:
        - $ref: '#/components/schemas/GlobalNodeFields'
      description: >
        Terminal. Reaching an End node hangs up the call. For per-call data
        extraction or webhook delivery, use the agent-level
        `extraction_parameters` and `webhook_url` / `webhook_events` fields on
        the agent (those apply uniformly to prompt + flow agents).
      properties:
        id:
          type: string
        type:
          type: string
          enum:
            - end
        name:
          type: string
        position:
          $ref: '#/components/schemas/FlowNodePosition'
        farewell:
          type: string
          nullable: true
        is_literal:
          type: boolean
          default: false
    FlowNodePosition:
      type: object
      description: >-
        UI-only persisted x/y on the React Flow canvas. The runtime ignores
        this.
      properties:
        x:
          type: number
        'y':
          type: number
    GlobalNodeFields:
      type: object
      description: >
        Optional fields available on every node EXCEPT start and tool_call. When
        `is_global`

        is true, the node is reachable from any conversation node without an
        explicit edge —

        the eval LLM gets it as an additional candidate transition with a strong
        "prefer

        labeled transitions" bias. Use sparingly (recommended ≤3 globals per
        flow). Good for

        misclassification recovery and universal escape hatches; bad for
        happy-path shortcuts.
      properties:
        is_global:
          type: boolean
          default: false
          description: >-
            Mark this node as globally reachable. Only valid on
            conversation/end/transfer nodes.
        global_jump_description:
          type: string
          nullable: true
          description: >
            REQUIRED when is_global=true. Natural-language condition the eval
            LLM uses to decide whether

            to jump here. Describe a clear user-side signal, not an agent
            intent.

            Example: "User reveals they're actually an owner, not a tenant".
    Transition:
      type: object
      required:
        - id
        - label
        - description
        - next_step_id
      description: >
        A labeled outgoing branch from a conversation node. The voice agent
        picks one

        (or stays) at each user-turn boundary by calling the `pick_transition`
        tool.
      properties:
        id:
          type: string
          description: Stable transition id (used in the flow graph and traces)
        label:
          type: string
          description: >-
            Short human-readable label, also surfaced in the dashboard and call
            traces
        description:
          type: string
          minLength: 1
          description: >
            REQUIRED, non-empty. The natural-language trigger the voice agent
            reads at runtime

            to decide whether to take this path. Describe a user-side signal
            (what the caller

            said or implied), not an agent intent. Example: "Caller confirmed
            they want a demo

            scheduled" — not "agent books the demo". An empty description is
            rejected with

            error code `transition_description_missing`.
        next_step_id:
          type: string
          description: Destination node id
    ToolCallTransitions:
      type: object
      description: >
        Routing for a tool_call node. Routing is deterministic, no LLM. error
        fires on hard failures

        (timeout, 4xx/5xx, integration disconnected). Otherwise custom[] is
        evaluated in declaration order

        and the first match wins; success fires only if no custom matched.
        Exactly one out-edge per fire.
      properties:
        success_next_step_id:
          type: string
          nullable: true
        error_next_step_id:
          type: string
          nullable: true
        custom:
          type: array
          default: []
          items:
            $ref: '#/components/schemas/CustomTransition'
    ArgValue:
      description: >
        Discriminated union for one entry in
        `IntegrationCallNode.args_template`. Each arg can be one of

        three shapes:

          1. Bare string — shorthand for `{mode: 'literal', value: '<the string>'}`.
          2. `{mode: 'literal',    value}` — sent as-is.
          3. `{mode: 'ai_extract', description}` — the live agent runtime extracts this arg from the
             conversation right before the action fires; `description` guides extraction.

        Both `value` and `description` strings may contain mustache tokens that
        are interpolated at

        dispatch time:

          - `{{<node_id>.<arg_name>}}` — resolves to the value an earlier node AI-extracted from
            the conversation. Both `integration_call` AND `tool_call` source nodes are addressable.
            For `integration_call` sources the referenced arg must be declared in `ai_extract` mode
            in that node's `args_template` (validated at save). For `tool_call` sources the arg
            name must match an entry in the linked tool's `config.payload_config.extraction_parameters`
            — these aren't double-checked at save time because they live on the tool config (not the
            flow_config), so a typo renders to empty string at runtime; design an `error` branch
            on the downstream node. Refs to nodes that don't exist raise `args_template_dangling_reference`.
          - `{{metadata.<key>}}` — resolves against per-call metadata. Built-in keys: `id`,
            `direction`, `agent_number`, `user_number`, `agent_name`. User-defined keys come from
            the `metadata` dict passed to `POST /calls`; declare expected custom keys via
            `FlowConfig.metadata.custom_metadata_keys`. Missing metadata keys resolve to an empty
            string at runtime — NOT a save-time error.
      oneOf:
        - type: string
          description: >-
            Literal-mode shorthand. May contain `{{node.arg}}` /
            `{{metadata.key}}` tokens.
        - type: object
          required:
            - mode
            - value
          properties:
            mode:
              type: string
              enum:
                - literal
            value:
              type: string
              description: >-
                Literal value. May contain `{{node.arg}}` / `{{metadata.key}}`
                tokens.
        - type: object
          required:
            - mode
            - description
          properties:
            mode:
              type: string
              enum:
                - ai_extract
            description:
              type: string
              description: >-
                Natural-language hint the runtime uses to bind this slot from
                conversation. May contain `{{node.arg}}` / `{{metadata.key}}`
                tokens to splice prior context into the extraction prompt.
    CustomTransition:
      type: object
      required:
        - id
        - label
        - jsonpath
        - equals
        - next_step_id
      description: >
        Deterministic branch for a tool-call node, matched by JSONPath equality
        against the tool's parsed response body.

        Custom branches are evaluated top-to-bottom, first match wins; mutually
        exclusive with success/error (only one

        out-edge fires per tool fire). See
        /docs/api-reference/agents/flow-config-schema for the full runtime
        semantics.
      properties:
        id:
          type: string
        label:
          type: string
        jsonpath:
          type: string
          example: $.status
          description: >
            Dotted path into the tool's parsed response body (root `$`).
            Supported: `$.foo.bar`, `$.list[0].name`,

            `$.items[2]`. NOT supported: recursive descent (`$..`), wildcards
            (`$.*`), filter expressions.
        equals:
          type: string
          example: no_availability
          description: >
            Compared via string equality after JSON-style stringification of the
            extracted value:

            booleans become "true"/"false" (lowercase), null becomes "null",
            numbers via str(). Match the

            stringified form exactly or the branch never fires.
        next_step_id:
          type: string
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: API Key
      description: >-
        Your Yappr API key (e.g. `ypr_live_...`). Generate one in the dashboard
        under **Settings → API Keys**.

````