{
  "name": "Open Calls · approved native call and bounded result",
  "active": false,
  "meta": { "templateCredsSetupCompleted": false },
  "nodes": [
    {
      "id": "trigger",
      "name": "Run approved task",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [0, 0],
      "parameters": {}
    },
    {
      "id": "request",
      "name": "Approved request",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [220, 0],
      "parameters": {
        "mode": "raw",
        "jsonOutput": "{\n  \"approved\": false,\n  \"idempotencyKey\": \"n8n:REPLACE_WITH_PERSISTED_TASK_ID\",\n  \"request\": {\n    \"recipient\": \"Controlled receiver\",\n    \"phone\": \"+12025550123\",\n    \"objective\": \"Ask when the shop opens.\",\n    \"language\": \"en\",\n    \"maxMinutes\": 2,\n    \"maxAttempts\": 1,\n    \"maxChargeMicro\": 0,\n    \"mode\": \"ask\",\n    \"allowQuestions\": false\n  }\n}",
        "options": {}
      }
    },
    {
      "id": "prepare",
      "name": "Check approval",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [440, 0],
      "parameters": {
        "jsCode": "function prepareApproved(input) {\n  if (input.approved !== true)\n    throw new Error(\"Approve the exact request and quoted maximum first.\");\n  const key = input.idempotencyKey;\n  if (\n    typeof key !== \"string\" ||\n    !/^[A-Za-z0-9:_-]{1,160}$/.test(key) ||\n    key.includes(\"REPLACE\")\n  ) {\n    throw new Error(\n      \"Persist a unique request key before running; reuse it with the identical body.\",\n    );\n  }\n  const request = input.request;\n  const fields = [\n    \"recipient\",\n    \"phone\",\n    \"objective\",\n    \"context\",\n    \"language\",\n    \"maxMinutes\",\n    \"maxAttempts\",\n    \"maxChargeMicro\",\n    \"mode\",\n    \"allowQuestions\",\n  ];\n  if (\n    !request ||\n    Array.isArray(request) ||\n    typeof request !== \"object\" ||\n    Object.keys(request).some((key) => !fields.includes(key))\n  ) {\n    throw new Error(\"Use the documented informational-call request fields.\");\n  }\n  const text = (key, min, max) =>\n    typeof request[key] === \"string\" &&\n    request[key].length >= min &&\n    request[key].length <= max;\n  if (\n    !text(\"recipient\", 2, 120) ||\n    !text(\"objective\", 10, 2000) ||\n    !/^\\+[1-9][0-9]{7,14}$/.test(request.phone ?? \"\")\n  ) {\n    throw new Error(\n      \"Provide the approved recipient, international phone number and objective.\",\n    );\n  }\n  if (\n    (request.context !== undefined && !text(\"context\", 0, 4000)) ||\n    ![\"en\", \"he\", \"es\", \"fr\", \"de\", \"pt\", \"ar\", \"it\", \"ru\"].includes(\n      request.language,\n    )\n  ) {\n    throw new Error(\"Provide a supported language and bounded context.\");\n  }\n  if (\n    !Number.isInteger(request.maxMinutes) ||\n    request.maxMinutes < 1 ||\n    request.maxMinutes > 5 ||\n    ![1, 2].includes(request.maxAttempts) ||\n    request.mode !== \"ask\"\n  ) {\n    throw new Error(\n      \"This template requires informational mode, 1–5 minutes and 1–2 attempts.\",\n    );\n  }\n  if (\n    !Number.isSafeInteger(request.maxChargeMicro) ||\n    request.maxChargeMicro < 0 ||\n    request.maxChargeMicro > 1_000_000_000\n  ) {\n    throw new Error(\n      \"Use the exact quoted integer maximumMicro as maxChargeMicro.\",\n    );\n  }\n  if (\n    typeof request.allowQuestions !== \"boolean\" ||\n    (request.allowQuestions && request.maxMinutes < 3)\n  ) {\n    throw new Error(\n      \"Questions require at least three minutes and explicit approval.\",\n    );\n  }\n  return { idempotencyKey: key, request };\n}\n\nreturn [{json: prepareApproved($input.first().json)}];"
      }
    },
    {
      "id": "quote",
      "name": "Recheck quote",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [660, 0],
      "parameters": {
        "method": "POST",
        "url": "https://YOUR-OPEN-CALLS-HOST.example/api/v1/estimate",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBearerAuth",
        "options": {
          "timeout": 10000,
          "redirect": { "redirect": { "followRedirects": false } },
          "response": {
            "response": { "responseFormat": "json", "neverError": false }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify(Object.fromEntries(Object.entries($json.request).filter(([key]) => key !== 'maxChargeMicro'))) }}"
      },
      "credentials": { "httpBearerAuth": { "name": "Open Calls" } },
      "retryOnFail": false
    },
    {
      "id": "quote-match",
      "name": "Check approved maximum",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [880, 0],
      "parameters": {
        "jsCode": "function checkQuote(quote, approved) {\n  if (\n    quote.currency !== \"USD\" ||\n    !Number.isSafeInteger(quote.maximumMicro) ||\n    quote.maximumMicro !== approved.request.maxChargeMicro\n  ) {\n    throw new Error(\n      \"The quote changed. Obtain fresh approval; do not create a call automatically.\",\n    );\n  }\n  return approved;\n}\n\nreturn [{json: checkQuote($input.first().json, $('Check approval').first().json)}];"
      }
    },
    {
      "id": "create",
      "name": "Create call (idempotent)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [1100, 0],
      "parameters": {
        "method": "POST",
        "url": "https://YOUR-OPEN-CALLS-HOST.example/api/v1/calls",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBearerAuth",
        "options": {
          "timeout": 10000,
          "redirect": { "redirect": { "followRedirects": false } },
          "response": {
            "response": { "responseFormat": "json", "neverError": false }
          }
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "idempotency-key",
              "value": "={{ $json.idempotencyKey }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.request) }}"
      },
      "credentials": { "httpBearerAuth": { "name": "Open Calls" } },
      "retryOnFail": false
    },
    {
      "id": "start",
      "name": "Start polling",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [1320, 0],
      "parameters": {
        "jsCode": "function startPolling(call) {\n  if (\n    typeof call.id !== \"string\" ||\n    !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(\n      call.id,\n    )\n  ) {\n    throw new Error(\n      \"No valid call ID returned. Reconcile using the original body and request key.\",\n    );\n  }\n  return { callId: call.id };\n}\n\nreturn [{json: startPolling($input.first().json)}];"
      }
    },
    {
      "id": "wait",
      "name": "Wait 20 s",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [1540, 0],
      "parameters": { "amount": 20, "unit": "seconds" }
    },
    {
      "id": "read",
      "name": "Read call",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [1760, 0],
      "parameters": {
        "method": "GET",
        "url": "=https://YOUR-OPEN-CALLS-HOST.example/api/v1/calls/{{ $('Start polling').first().json.callId }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBearerAuth",
        "options": {
          "timeout": 10000,
          "redirect": { "redirect": { "followRedirects": false } },
          "response": {
            "response": { "responseFormat": "json", "neverError": false }
          }
        }
      },
      "credentials": { "httpBearerAuth": { "name": "Open Calls" } },
      "retryOnFail": false
    },
    {
      "id": "count",
      "name": "Count polls",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [1980, 0],
      "parameters": {
        "jsCode": "function countPolls(call, runIndex, expectedId) {\n  if (\n    call.id !== expectedId ||\n    !Number.isInteger(runIndex) ||\n    runIndex < 0 ||\n    runIndex >= 45\n  )\n    throw new Error(\"Unexpected polling state.\");\n  const polls = runIndex + 1;\n  return {\n    ...call,\n    polls,\n    pollingStopped: call.audit?.terminal === true || polls >= 45,\n  };\n}\n\nreturn [{json: countPolls($input.first().json, $runIndex, $('Start polling').first().json.callId)}];"
      }
    },
    {
      "id": "done",
      "name": "Final or 45 reads?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [2200, 0],
      "parameters": {
        "conditions": {
          "options": { "caseSensitive": true, "typeValidation": "strict" },
          "combinator": "and",
          "conditions": [
            {
              "id": "done",
              "leftValue": "={{ $json.pollingStopped }}",
              "rightValue": true,
              "operator": { "type": "boolean", "operation": "true" }
            }
          ]
        }
      }
    },
    {
      "id": "result",
      "name": "Grounded result",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [2420, -100],
      "parameters": {
        "jsCode": "function groundedResult(call) {\n  const audit = call.audit;\n  if (\n    !audit ||\n    typeof audit.terminal !== \"boolean\" ||\n    !Number.isSafeInteger(call.event_sequence) ||\n    call.event_sequence < 0\n  )\n    throw new Error(\"Missing native call audit.\");\n  const terminal = audit.terminal;\n  const charge = audit.billing?.charged_micro;\n  if (\n    terminal &&\n    (audit.billing?.state !== \"final\" ||\n      !Number.isSafeInteger(charge) ||\n      charge < 0)\n  )\n    throw new Error(\"Incomplete final receipt; read this call again.\");\n  if (!Array.isArray(audit.confirmed) || !Array.isArray(audit.unresolved))\n    throw new Error(\"Missing grounded evidence.\");\n  return {\n    callId: call.id,\n    eventSequence: call.event_sequence,\n    observationKey: `${call.id}:${call.event_sequence}`,\n    state: terminal ? \"final\" : \"pending\",\n    status: call.status,\n    objectiveCompleted: terminal && call.objective_completed === true,\n    simulation: call.result?.simulation === true,\n    outcome: audit.outcome,\n    confirmed: audit.confirmed,\n    unresolved: audit.unresolved,\n    pendingQuestion: audit.pending_question ?? null,\n    chargeMicro: terminal ? charge : null,\n    durationSeconds: audit.duration_seconds ?? null,\n    resultUrl: call.result_url,\n    note: terminal\n      ? \"Report confirmed evidence and unresolved items; call status alone is not success.\"\n      : \"Still pending. Resume by this call ID; do not place another call.\",\n  };\n}\n\nreturn [{json: groundedResult($input.first().json)}];"
      }
    }
  ],
  "connections": {
    "Run approved task": {
      "main": [[{ "node": "Approved request", "type": "main", "index": 0 }]]
    },
    "Approved request": {
      "main": [[{ "node": "Check approval", "type": "main", "index": 0 }]]
    },
    "Check approval": {
      "main": [[{ "node": "Recheck quote", "type": "main", "index": 0 }]]
    },
    "Recheck quote": {
      "main": [
        [{ "node": "Check approved maximum", "type": "main", "index": 0 }]
      ]
    },
    "Check approved maximum": {
      "main": [
        [{ "node": "Create call (idempotent)", "type": "main", "index": 0 }]
      ]
    },
    "Create call (idempotent)": {
      "main": [[{ "node": "Start polling", "type": "main", "index": 0 }]]
    },
    "Start polling": {
      "main": [[{ "node": "Wait 20 s", "type": "main", "index": 0 }]]
    },
    "Wait 20 s": {
      "main": [[{ "node": "Read call", "type": "main", "index": 0 }]]
    },
    "Read call": {
      "main": [[{ "node": "Count polls", "type": "main", "index": 0 }]]
    },
    "Count polls": {
      "main": [[{ "node": "Final or 45 reads?", "type": "main", "index": 0 }]]
    },
    "Final or 45 reads?": {
      "main": [
        [{ "node": "Grounded result", "type": "main", "index": 0 }],
        [{ "node": "Wait 20 s", "type": "main", "index": 0 }]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "saveDataSuccessExecution": "none",
    "saveDataErrorExecution": "none",
    "saveManualExecutions": false,
    "executionTimeout": 1200
  }
}
