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
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug_report.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,5 +35,5 @@ What should happen instead?

## Logs

Paste relevant logs with API keys, webhook secrets, phone numbers, and other
Paste relevant logs with API keys, phone numbers, and other
private data removed.
6 changes: 4 additions & 2 deletions .github/ISSUE_TEMPLATE/feature_request.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,12 +20,14 @@ Show the SDK API you would like to call.

## Scope

Is this within the Phase 1 server SDK scope?
Is this within the supported server SDK scope?

- Create/read calls
- Poll call results
- List call events
- Verify webhooks
- List/read published Goals
- Create/poll Goal Runs
- Parse finalized terminal webhook events

## Alternatives

Expand Down
2 changes: 1 addition & 1 deletion .github/pull_request_template.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ Describe the SDK behavior, documentation, or release workflow change.

## Checklist

- [ ] I kept this change within the Phase 1 server SDK scope.
- [ ] I kept this change within the supported server SDK scope.
- [ ] I did not add browser/client-side patterns that expose CALL-E API keys.
- [ ] I updated tests, examples, or docs when behavior changed.
- [ ] I ran the relevant local checks.
Expand Down
52 changes: 43 additions & 9 deletions .github/workflows/publish-python.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,13 +84,24 @@ jobs:
run: |
set -euo pipefail

version="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')"
if [ "${{ inputs.repository }}" = "pypi" ]; then
url="https://pypi.org/pypi/calle-ai/json"
url="https://pypi.org/pypi/calle-ai/${version}/json"
else
url="https://test.pypi.org/pypi/calle-ai/json"
url="https://test.pypi.org/pypi/calle-ai/${version}/json"
fi

curl --fail --silent --show-error "$url" >/dev/null
for attempt in 1 2 3 4 5 6 7 8 9 10; do
if curl --fail --silent --show-error "$url" >/dev/null; then
exit 0
fi

echo "Package version metadata is not visible yet, retrying in 10s..."
sleep 10
done

echo "::error::Published package version metadata did not become visible in time."
exit 1

- name: Smoke test published package install
run: |
Expand All@@ -103,13 +114,36 @@ jobs:
. "$smoke_dir/.venv/bin/activate"
python -m pip install --upgrade pip

if [ "${{ inputs.repository }}" = "pypi" ]; then
python -m pip install "calle-ai==$version"
else
python -m pip install \
installed=false
for attempt in 1 2 3 4 5 6 7 8 9 10; do
if [ "${{ inputs.repository }}" = "pypi" ]; then
if python -m pip install "calle-ai==$version"; then
installed=true
break
fi
elif python -m pip install \
--index-url https://test.pypi.org/simple/ \
--extra-index-url https://pypi.org/simple \
"calle-ai==$version"
"calle-ai==$version"; then
installed=true
break
fi

echo "Package install is not available yet, retrying in 10s..."
sleep 10
done

if [ "$installed" != "true" ]; then
echo "::error::Published package install did not become available in time."
exit 1
fi

python -c 'from calle import CalleClient; print(CalleClient)'
python - <<'PY'
from calle import CalleClient
from calle.generated.models import Goal, GoalRun

client = CalleClient(api_key="smoke")
assert callable(client.goals.run_and_wait)
client.close()
print(CalleClient, Goal, GoalRun)
PY
39 changes: 31 additions & 8 deletions CONTRIBUTING.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,29 +17,42 @@ export CALLE_BASE_URL="https://api.heycall-e.com"
export CALLE_EXAMPLE_PHONE="+14155550100"
uv run python examples/create_and_wait.py

export CALLE_WEBHOOK_SECRET="whsec_test_key"
export CALLE_BASE_URL="https://test-api.heycall-e.com"
export CALLE_GOAL_ID="<PUBLISHED_GOAL_ID>"
export CALLE_GOAL_PHONE="<AUTHORIZED_E164_PHONE>"
export CALLE_GOAL_VARIABLES='{"name":"Alex"}'
export CALLE_IDEMPOTENCY_KEY="<DURABLE_WORKFLOW_KEY>"
uv run python examples/run_goal_and_wait.py

uv run python examples/webhook_server.py
```

The webhook example listens on `POST /calle/webhook` and verifies
`CALL-E-Timestamp` plus `CALL-E-Signature` against the raw request body.
The webhook example listens on `POST /calle/webhook` and processes terminal
event JSON after post-call outcome and structured-result finalization. It
deduplicates deliveries with `CALL-E-Event-Id`; CALL-E does not send timestamp
or signature headers.

## Phase 1 scope
## Supported scope

In scope:

- Create a call.
- Read a call.
- Poll until a terminal call result.
- List call events.
- Verify and unwrap signed webhook events.
- List and read published Goals.
- Create a Goal Run with a durable idempotency key.
- Poll until a Goal Run has either a result or an error.
- Receive finalized terminal webhook events without requiring signature
material.

Out of scope for Phase 1:
Out of scope:

- Async client support.
- Batch calls.
- Cancel calls.
- Recurring or scheduled calls.
- Goal authoring and publishing.
- Project-level webhook management.
- Pydantic result schema helpers.

Expand All@@ -50,14 +63,24 @@ The SDK is generated and wrapped from `openapi/calle.openapi.yaml`.
When the OpenAPI contract changes:

1. Update `openapi/calle.openapi.yaml`.
2. Regenerate generated client code if the generated package is in use.
2. Regenerate generated client code:

```bash
uv run openapi-python-client generate \
--path openapi/calle.openapi.yaml \
--config openapi-python-client.yml \
--output-path src/calle/generated \
--meta none \
--overwrite
```

3. Update wrappers and tests for any changed behavior.
4. Run the full development check list above.

## Pull requests

Keep changes small and focused. Include tests for wrapper behavior, error
handling, webhook signature verification, and any changed API contract surface.
handling, webhook event handling, and any changed API contract surface.

Do not add browser examples or patterns that expose CALL-E API keys to client
code.
89 changes: 72 additions & 17 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@ pip install calle-ai
Pin the current stable release when your deployment process requires exact package reproducibility:

```bash
pip install calle-ai==0.2.0
pip install calle-ai==0.6.0
```

Use a local checkout for development and package smoke tests:
Expand All@@ -49,18 +49,82 @@ Run the create-and-wait example from a local checkout:
uv run python examples/create_and_wait.py
```

Run a published Goal with an explicit Goal, phone, variables, and durable
idempotency key:

```bash
export CALLE_GOAL_ID="<PUBLISHED_GOAL_ID>"
export CALLE_GOAL_PHONE="<AUTHORIZED_E164_PHONE>"
export CALLE_GOAL_VARIABLES='{"name":"Alex"}'
export CALLE_IDEMPOTENCY_KEY="<DURABLE_WORKFLOW_KEY>"
uv run python examples/run_goal_and_wait.py
```

To test against the test environment, explicitly set:

```bash
export CALLE_BASE_URL="https://test-api.heycall-e.com"
```

Run the webhook receiver example:

```bash
export CALLE_WEBHOOK_SECRET="whsec_test_key"
uv run python examples/webhook_server.py
```

The webhook receiver listens on `POST /calle/webhook` and verifies
`CALL-E-Timestamp` and `CALL-E-Signature` against the raw request body.
The webhook receiver listens on `POST /calle/webhook` and processes terminal
event JSON. CALL-E sends terminal events only after the post-call outcome and
requested structured results are finalized.

CALL-E webhook delivery does not use a webhook secret, `CALL-E-Timestamp`, or
`CALL-E-Signature`. Use the required `CALL-E-Event-Id` header to deduplicate
at-least-once deliveries before performing side effects. The receiver example
parses JSON directly and checks that this header matches the body event id.

The `client.webhooks.verify` and `client.webhooks.unwrap` methods implement the
legacy signed-payload contract from SDK `0.2`. They remain available for source
compatibility but are deprecated and are not compatible with current unsigned
CALL-E deliveries.

## Quickstart

Run a reusable published Goal. The Goal owns its input and result schemas;
each Run supplies only a phone number, per-Run variables, and a durable
idempotency key:

```python
import os
from calle import CalleClient

client = CalleClient(api_key=os.environ["CALLE_API_KEY"])

goal = client.goals.get("goal_delivery_confirmation")
print(goal["title"], goal["published_run_spec"]["input_schema"])

run = client.goals.run_and_wait(
goal_id=goal["id"],
phone="+14155550100",
variables={
"customer_name": "Taylor",
"order_reference": "ORD-8472",
"delivery_window": "July 24, 2:00-4:00 PM",
},
idempotency_key="delivery:ORD-8472:confirm-window:v1",
)

if run["result"] is not None:
print(run["result"])
else:
print(run["error"])
```

Persist the idempotency key before the first request and reuse it for network
retries. `wait_for_result` returns when either `result` or `error` is non-null;
an execution `status` of `completed` can still be waiting for result
materialization.

The generic one-shot call API remains available independently:

```python
import os
from calle import CalleClient
Expand DownExpand Up@@ -96,16 +160,6 @@ print(call["task_completed"], call["completion_confidence"], call["evidence"])
print(call["recipients"][0]["structured_result"])
```

## Webhook Verification

```python
event = client.webhooks.unwrap(
raw_body=raw_body,
headers=headers,
secret=os.environ["CALLE_WEBHOOK_SECRET"],
)
```

## Release

This repository publishes the Python distribution `calle-ai`. Application code
Expand All@@ -128,11 +182,12 @@ Manual stable PyPI publish:
```bash
python -m venv .venv
. .venv/bin/activate
pip install calle-ai==0.2.0
python -c 'from calle import CalleClient; print(CalleClient)'
pip install calle-ai==0.6.0
python -c 'from calle import CalleClient; c = CalleClient(api_key="smoke"); assert callable(c.goals.run_and_wait); c.close()'
```

The current stable version is `0.2.0`. Do not reuse a previously published PyPI version.
The current stable version is `0.6.0`. Do not reuse a previously published
PyPI version.

## Project Documents

Expand Down
32 changes: 27 additions & 5 deletions RELEASE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,7 +13,7 @@ calle-ai==0.1.0b1
The current production PyPI release version is:

```text
calle-ai==0.2.0
calle-ai==0.6.0
```

For this release, use token-based PyPI publishing with the GitHub Actions secret `PYPI_API_TOKEN`.
Expand All@@ -26,7 +26,29 @@ Run these checks before publishing:
bash scripts/validate.sh
```

The validation script checks the OpenAPI contract, tests, lint, types, examples, distribution metadata, wheel install, source distribution install, and imports `CalleClient` from fresh virtual environments.
The validation script checks the OpenAPI contract, tests, lint, types,
examples, distribution metadata, wheel install, source distribution install,
and Goal wrapper plus generated-model imports from fresh virtual environments.

## Test API Goal smoke

Before publishing a release that changes Goal behavior, run the local release
candidate against a published Goal in the test environment:

```bash
export CALLE_API_KEY="<TEST_API_KEY>"
export CALLE_BASE_URL="https://test-api.heycall-e.com"
export CALLE_GOAL_ID="<PUBLISHED_TEST_GOAL_ID>"
export CALLE_GOAL_PHONE="<AUTHORIZED_TEST_E164_PHONE>"
export CALLE_GOAL_VARIABLES='{"name":"Alex"}'
export CALLE_IDEMPOTENCY_KEY="<UNIQUE_DURABLE_TEST_KEY>"
uv run python examples/run_goal_and_wait.py
```

This smoke test creates a real phone call. Use an authorized test number and a
new idempotency key for a new logical test. Reuse the same key only when
retrying that exact request. Record the returned Goal Run id and verify that
exactly one of `result` or `error` is non-null.

## Stable PyPI publish

Expand All@@ -45,15 +67,15 @@ tmpdir="$(mktemp -d)"
python -m venv "$tmpdir/.venv"
. "$tmpdir/.venv/bin/activate"
python -m pip install --upgrade pip
python -m pip install calle-ai==0.2.0
python -c 'from calle import CalleClient; print(CalleClient)'
python -m pip install calle-ai==0.6.0
python -c 'from calle import CalleClient; c = CalleClient(api_key="smoke"); assert callable(c.goals.run_and_wait); c.close()'
```

## Version rules

- Patch releases fix SDK wrapper bugs, type issues, packaging metadata, README examples, or distribution issues without changing public API behavior.
- Minor releases add backward-compatible API fields, endpoints, or SDK helpers.
- Major releases make breaking public API, method signature, stable error, or webhook signature contract changes.
- Major releases make breaking public API, method signature, stable error, or webhook delivery contract changes.

Keep TypeScript, Python, OpenAPI, and public docs versions aligned by default. A single-language patch is allowed only when the shared API contract and cross-language behavior do not change.

Expand Down
17 changes: 13 additions & 4 deletions SECURITY.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,8 +21,17 @@ Send a private report to the CALL-E maintainers with:
## Secret handling

This SDK is for trusted server environments only. Do not expose CALL-E API keys
or webhook secrets in browser code, mobile apps, public logs, or client-side
bundles.
in browser code, mobile apps, public logs, or client-side bundles.

Webhook handlers must verify `CALL-E-Timestamp` and `CALL-E-Signature` against
the raw request body before parsing or trusting an event.
## Webhook receivers

CALL-E terminal webhooks do not include a webhook secret,
`CALL-E-Timestamp`, or `CALL-E-Signature`. Do not treat the event id or payload
as cryptographic proof of origin.

Treat the receiver as a public, untrusted-input boundary: accept only the
intended route, validate the JSON event shape, compare `CALL-E-Event-Id` with
the body event id, and persist that id before side effects so retries are
idempotent. If an integration requires origin assurance before a sensitive
action, fetch the referenced call through the authenticated Calls API and
compare its terminal snapshot.
Loading