Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .github/screenshots/calls-example.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/screenshots/calls-troubleshooting.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ artifacts/
playwright-report/
test-results/
__pycache__/
.venv/
.DS_Store
content/guides/regions.mdx
23 changes: 23 additions & 0 deletions content/guides/errors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,29 @@ automatically retry based only on an undocumented failure string.

## Recovery guidance

### Choose the next action

First distinguish an HTTP request failure from an accepted call's result.
The [complete Python example](/quickstart#run-a-complete-example) saves both
the original request and the returned Call ID for that purpose.

| Observation | What to check and do next |
| --- | --- |
| `invalid_request`, recipient, phone, or schema validation error | Correct the rejected input using the field-specific guidance below. Preserve the error code and details; do not retry unchanged invalid input. |
| `unauthorized` or `forbidden` | Check the key and its access to the resource. Keep credentials out of logs and support posts. |
| `insufficient_balance` | Resolve the account's billing condition before attempting more calls. A provider failure alone does not prove the balance is exhausted. |
| `rate_limit_exceeded` | Back off. For a retry of the same operation, retain the original request and idempotency key. Do not replace an uncertain call with a new key. |
| `provider_unavailable` on a Goal Run create request | This code applies before durable Goal Run acceptance. Preserve the request and error details; a later accepted Goal Run failure belongs to that run's `error` field. |
| Timeout, malformed response, or server error without a saved Call ID | The response alone may not establish acceptance. Follow [Calls recovery](/calls#recover-after-a-restart-or-lost-response); retain the original request and key. |
| A saved Call ID, including a polling error or terminal failure | Retrieve that call and inspect its status, failure context, and available transcript. Keep the ID for support. Do not issue another create request to discover its outcome. |

The `provider_unavailable` Goal Run contract does not establish the cause of an
arbitrary Calls API `503`. For support, retain the SDK version, UTC timestamp,
HTTP status, error code/details, and existing Call ID if available. Redact
credentials, phone numbers, and private transcript content before sharing.

### Code-specific guidance

`unauthorized` means the API key is missing or invalid. Check the `Authorization: Bearer` header.

`forbidden` means the key is valid but not allowed to use this resource or capability.
Expand Down
54 changes: 54 additions & 0 deletions content/guides/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,57 @@ Example output:
Before adding automatic retries, follow
[Recover after a restart or lost response](/calls#recover-after-a-restart-or-lost-response)
to persist the original request, idempotency key, and Call ID.

## Run a complete example

The [Python example](https://github.com/CALLE-AI/calle-docs/blob/main/examples/calls.py)
creates one real US English test call, saves its request and Call ID, waits for
the terminal result, and writes the full response to a private local directory.
It listens to a greeting and then asks the agent to end the call. This is a
real-service integration check, not a simulated business transaction.

Use Python 3.11 or later and a number you own or are authorized to test. If you
need a destination, follow the [official US English testing hotline announcement](https://discord.com/channels/1493880186826133504/1495622983253889054/1546414916515401788).
Calls use your real account and may consume credits. The hotline is a test
recipient, not a promise of free sandbox credentials or deterministic answers.

```bash
git clone https://github.com/CALLE-AI/calle-docs.git
cd calle-docs
python3 -m venv .venv
source .venv/bin/activate
python -m pip install calle-ai==0.7.0
export CALLE_API_KEY="<YOUR_API_KEY>"
export CALLE_TEST_PHONE="<AUTHORIZED_US_E164_PHONE>"
python examples/calls.py start ../calle-run --phone "$CALLE_TEST_PHONE"
```

`start` requires a new run directory and submits at most one create request.
The directory holds `request.json` (including the original idempotency key),
`call-id.json`, and, once available, `result.json`. Keep it private: it contains
the destination and call transcript. The API key is not written there. On Unix,
the directory is created with owner-only access; on Windows, use a private
directory protected by your account's file permissions.

After a polling timeout, application restart, or successful run, read the same
call again with:

```bash
python examples/calls.py resume ../calle-run
```

`resume` only retrieves the saved Call ID; it never creates a call. If a create
response was lost and no ID was saved, the script stops. Keep `request.json`
and follow [Calls recovery](/calls#recover-after-a-restart-or-lost-response)
before starting a replacement. Do not delete a run directory to bypass this check.

Stopping this process or reaching its five-minute polling timeout does not
cancel a call already accepted by CALL-E. Neither command retries a failed
create request automatically. An HTTP error is saved in `error.json`; see
[Choose the next action](/errors#choose-the-next-action).

An exit code of zero means a terminal response was retrieved. Read `status`,
`task_completed`, and `structured_result` separately and check the transcript
in `result.json`. A `null` structured result or `heard_greeting: "unknown"`
does not establish that a greeting was heard. A terminal `failed` or `canceled`
response is still a readable outcome, not a successful business task.
101 changes: 101 additions & 0 deletions examples/calls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""Create one real call, or resume reading its saved Call ID. Python 3.11+."""

import argparse
import json
import os
from pathlib import Path
import re
import sys
import uuid

from calle import CalleClient
from calle.errors import CalleAPIError, CalleConnectionError, CalleTimeoutError


def save(path, value):
with path.open("w", encoding="utf-8") as stream:
json.dump(value, stream, indent=2)
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())


def run(client, directory, phone=None):
if phone is not None:
if not re.fullmatch(r"\+[1-9][0-9]{7,14}", phone):
raise ValueError("Use an authorized E.164 phone number.")
# Exclusive creation prevents a repeated start from placing a second call.
directory.mkdir(mode=0o700)
operation = {
"idempotency_key": str(uuid.uuid4()),
"task": (
"Make one authorized developer integration test call. Listen to "
"the first complete greeting, then thank the other side and end "
"the call. Do not press menu keys or wait on hold. The purpose "
"is to verify that our application can read a real call result."
),
"recipients": [{"phones": [phone], "locale": "en-US", "region": "US"}],
"result_schema": {
"type": "object",
"properties": {
"heard_greeting": {"type": "string", "enum": ["yes", "no", "unknown"]},
"greeting_summary": {
"type": "string",
"description": "Summarize only the greeting actually heard; use unknown if none is available.",
},
},
"required": ["heard_greeting", "greeting_summary"],
"additionalProperties": False,
},
}
save(directory / "request.json", operation)
call = client.calls.create(**operation)
save(directory / "call-id.json", call["id"])
else:
if not (directory / "call-id.json").is_file():
raise ValueError(
"No saved Call ID. Keep request.json and reconcile the original "
"request using the Calls recovery guide before starting another call."
)
call_id = json.loads((directory / "call-id.json").read_text())
print(f"Call ID: {call_id}", flush=True)
call = client.calls.wait_for_result(call_id, timeout_seconds=300)
save(directory / "result.json", call)
print(json.dumps({key: call.get(key) for key in (
"status", "task_completed", "structured_result", "failure_code"
)}, indent=2))
print("Read result.json and its transcript before interpreting task or business success.")
return 0


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=["start", "resume"])
parser.add_argument("directory", type=Path, help="Private local run directory; start requires a new path.")
parser.add_argument("--phone", help="Authorized US English test destination; start only.")
args = parser.parse_args()
if (args.action == "start") != bool(args.phone):
parser.error("start requires --phone; resume must omit it.")
api_key = os.environ.get("CALLE_API_KEY")
if not api_key:
parser.error("Set CALLE_API_KEY before running.")
try:
with CalleClient(api_key=api_key) as client:
return run(client, args.directory, args.phone)
except CalleAPIError as exc:
save(args.directory / "error.json", {
"status_code": exc.status_code, "code": exc.code,
"message": str(exc), "details": exc.details,
})
print(f"HTTP {exc.status_code}: {exc.code}. Details saved privately in error.json.", file=sys.stderr)
except (CalleConnectionError, CalleTimeoutError, json.JSONDecodeError) as exc:
print(f"{type(exc).__name__}: response or outcome unavailable. Keep the run directory.", file=sys.stderr)
except (OSError, ValueError) as exc:
print(str(exc), file=sys.stderr)
return 1
print("Use resume if call-id.json exists; otherwise follow the Calls recovery guide. No automatic retry was made.", file=sys.stderr)
return 1


if __name__ == "__main__":
sys.exit(main())
67 changes: 67 additions & 0 deletions examples/test_calls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Supplemental failure/recovery checks; these do not replace a real call."""

import json
from pathlib import Path
import tempfile
import unittest

import httpx
from calle import CalleClient
from calle.errors import CalleAPIError, CalleTimeoutError

from calls import run


class RecoveryTest(unittest.TestCase):
def test_resume_after_poll_timeout_never_posts_and_keeps_null_result(self):
with tempfile.TemporaryDirectory() as root:
directory = Path(root) / "run"
methods = []

def respond(request):
methods.append(request.method)
if request.method == "POST":
saved = json.loads((directory / "request.json").read_text())
self.assertEqual(request.headers["Idempotency-Key"], saved.pop("idempotency_key"))
self.assertEqual(json.loads(request.content), saved)
return httpx.Response(201, json={"id": "call_test"})
self.assertEqual(json.loads((directory / "call-id.json").read_text()), "call_test")
if methods == ["POST", "GET"]:
raise httpx.ReadTimeout("interrupted", request=request)
return httpx.Response(200, json={"id": "call_test", "status": "failed", "structured_result": None})

with httpx.Client(base_url="https://example.invalid", transport=httpx.MockTransport(respond)) as http:
client = CalleClient(api_key="test", http_client=http)
with self.assertRaises(CalleTimeoutError):
run(client, directory, "+12025550123")
with self.assertRaises(FileExistsError):
run(client, directory, "+12025550123")
self.assertEqual(run(client, directory), 0)
self.assertEqual(methods, ["POST", "GET", "GET"])
self.assertIsNone(json.loads((directory / "result.json").read_text())["structured_result"])

def test_rejected_or_unreadable_create_keeps_request_and_resume_refuses(self):
for status, body, error in [
(422, '{"error":{"code":"invalid_request","message":"Rejected"}}', CalleAPIError),
(502, 'Bad Gateway', json.JSONDecodeError),
]:
with self.subTest(status=status), tempfile.TemporaryDirectory() as root:
directory = Path(root) / "run"
methods = []

def respond(request):
methods.append(request.method)
return httpx.Response(status, text=body)

with httpx.Client(base_url="https://example.invalid", transport=httpx.MockTransport(respond)) as http:
client = CalleClient(api_key="test", http_client=http)
with self.assertRaises(error):
run(client, directory, "+12025550123")
self.assertTrue((directory / "request.json").is_file())
with self.assertRaisesRegex(ValueError, "No saved Call ID"):
run(client, directory)
self.assertEqual(methods, ["POST"])


if __name__ == "__main__":
unittest.main()
3 changes: 1 addition & 2 deletions src/regions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@ export function extractRegions(readme) {
!section ||
!rows ||
rows.length < 3 ||
rows[0] !==
"| Country | Country Code | Calling Code | Languages | Default Line |" ||
!/^\| Country \| Country Code \| Calling Code \| Languages \| (?:Default Line|Line Region) \|$/.test(rows[0]) ||
!/^\|(?:[ \t]*:?-+:?[ \t]*\|){5}$/.test(rows[1]) ||
rows.some((row) => row.split("|").length !== 7) ||
rows.slice(2).some((row) =>
Expand Down
15 changes: 12 additions & 3 deletions tests/docs-site.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ Unrelated examples.`;
test("extracts only valid region coverage from the source README", () => {
expect(extractRegions(regionsReadme)).toBe(regionSection);
expect(extractRegions(regionsReadme.replaceAll("\n", "\r\n"))).toBe(regionSection);
expect(extractRegions(regionsReadme.replace("Default Line", "Line Region")))
.toBe(regionSection.replace("Default Line", "Line Region"));
for (const invalid of [
regionsReadme.replace("Supported Regions and Languages", "Removed section"),
regionsReadme.replace("| Languages |", "| Renamed column |"),
Expand All @@ -100,6 +102,13 @@ test("refreshes region coverage without rebuilding the docs", async ({ page }) =
await expect(page.getByRole("cell", { name: "Test language" })).toBeVisible();
await expect(page.getByText("Unrelated examples.")).toHaveCount(0);

await page.route(REGIONS_README_URL, (route) => route.fulfill({
contentType: "text/plain",
body: regionsReadme.replace("Default Line", "Line Region"),
}));
await page.reload();
await expect(page.getByRole("columnheader", { name: "Line Region" })).toBeVisible();

await page.setViewportSize({ width: 390, height: 844 });
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
await page.setViewportSize({ width: 1280, height: 720 });
Expand All @@ -113,13 +122,13 @@ test("refreshes region coverage without rebuilding the docs", async ({ page }) =

test("retains a readable region snapshot when refresh fails", async ({ page, request }) => {
await page.setViewportSize({ width: 390, height: 844 });
const tableHeader = "| Country | Country Code | Calling Code | Languages | Default Line |";
const tableHeader = /\| Country \| Country Code \| Calling Code \| Languages \| (?:Default Line|Line Region) \|/;
const html = await request.get("/regions");
expect(await html.text()).toContain("<table");
const markdown = await request.get("/regions.md");
expect(await markdown.text()).toContain(tableHeader);
expect(await markdown.text()).toMatch(tableHeader);
const llmsFull = await request.get("/llms-full.txt");
expect(await llmsFull.text()).toContain(tableHeader);
expect(await llmsFull.text()).toMatch(tableHeader);

for (const response of [
{ status: 503, body: "Unavailable" },
Expand Down