From c35ec5c2f9ebf25649c921efe41e684e9ed8c6fd Mon Sep 17 00:00:00 2001 From: dimavrem22 Date: Tue, 10 Mar 2026 16:01:16 +0000 Subject: [PATCH 01/56] Fix README package names and update Python examples to sync API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pip install inkbox-mail → inkbox - from inkbox_mail → from inkbox.mail - @inkbox/mail → @inkbox/sdk - Python examples: async/await → synchronous (matches actual SDK) Co-Authored-By: Claude Opus 4.6 --- README.md | 88 ++++++++++++++++++++++++++----------------------------- 1 file changed, 42 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 79f1ed3..0d75a19 100644 --- a/README.md +++ b/README.md @@ -4,63 +4,59 @@ Official SDKs for the [Inkbox Mail API](https://inkbox.ai) — API-first email f | Package | Language | Install | |---|---|---| -| [`inkbox-mail`](./python/) | Python ≥ 3.11 | `pip install inkbox-mail` | -| [`@inkbox/mail`](./typescript/) | TypeScript / Node ≥ 18 | `npm install @inkbox/mail` | +| [`inkbox`](./python/) | Python ≥ 3.11 | `pip install inkbox` | +| [`@inkbox/sdk`](./typescript/) | TypeScript / Node ≥ 18 | `npm install @inkbox/sdk` | --- ## Python ```python -import asyncio -from inkbox_mail import InkboxMail - -async def main(): - async with InkboxMail(api_key="sk-...") as client: - - # Create a mailbox - mailbox = await client.mailboxes.create(display_name="Agent 01") - - # Send an email - await client.messages.send( - mailbox.id, - to=["user@example.com"], - subject="Hello from Inkbox", - body_text="Hi there!", - ) - - # Iterate over all messages (pagination handled automatically) - async for msg in client.messages.list(mailbox.id): - print(msg.subject, msg.from_address) - - # Reply to a message - detail = await client.messages.get(mailbox.id, msg.id) - await client.messages.send( - mailbox.id, - to=detail.to_addresses, - subject=f"Re: {detail.subject}", - body_text="Got it, thanks!", - in_reply_to_message_id=detail.message_id, - ) - - # Search - results = await client.mailboxes.search(mailbox.id, q="invoice") - - # Webhooks (secret is one-time — save it immediately) - hook = await client.webhooks.create( - mailbox.id, - url="https://yourapp.com/hooks/mail", - event_types=["message.received"], - ) - print(hook.secret) # save this - -asyncio.run(main()) +from inkbox.mail import InkboxMail + +with InkboxMail(api_key="sk-...") as client: + + # Create a mailbox + mailbox = client.mailboxes.create(display_name="Agent 01") + + # Send an email + client.messages.send( + mailbox.id, + to=["user@example.com"], + subject="Hello from Inkbox", + body_text="Hi there!", + ) + + # Iterate over all messages (pagination handled automatically) + for msg in client.messages.list(mailbox.id): + print(msg.subject, msg.from_address) + + # Reply to a message + detail = client.messages.get(mailbox.id, msg.id) + client.messages.send( + mailbox.id, + to=detail.to_addresses, + subject=f"Re: {detail.subject}", + body_text="Got it, thanks!", + in_reply_to_message_id=detail.message_id, + ) + + # Search + results = client.mailboxes.search(mailbox.id, q="invoice") + + # Webhooks (secret is one-time — save it immediately) + hook = client.webhooks.create( + mailbox.id, + url="https://yourapp.com/hooks/mail", + event_types=["message.received"], + ) + print(hook.secret) # save this ``` ## TypeScript ```ts -import { InkboxMail } from "@inkbox/mail"; +import { InkboxMail } from "@inkbox/sdk"; const client = new InkboxMail({ apiKey: "sk-..." }); From 2aef4b1450db56b1175b8f133ef736ce05b28ae1 Mon Sep 17 00:00:00 2001 From: dimavrem22 Date: Tue, 10 Mar 2026 16:27:17 +0000 Subject: [PATCH 02/56] Add Phone SDK for Python and TypeScript - Python: inkbox.phone package with InkboxPhone client, 5 models, 4 resource classes (numbers, calls, transcripts, webhooks) - TypeScript: @inkbox/sdk/phone subpath export with matching resources - 16 methods covering: provision/release numbers, place/list/get calls, list transcripts, search transcripts, webhook CRUD Co-Authored-By: Claude Opus 4.6 --- python/inkbox/phone/__init__.py | 24 +++ python/inkbox/phone/_http.py | 68 +++++++ python/inkbox/phone/client.py | 61 ++++++ python/inkbox/phone/exceptions.py | 25 +++ python/inkbox/phone/resources/__init__.py | 11 ++ python/inkbox/phone/resources/calls.py | 85 +++++++++ python/inkbox/phone/resources/numbers.py | 112 +++++++++++ python/inkbox/phone/resources/transcripts.py | 36 ++++ python/inkbox/phone/resources/webhooks.py | 80 ++++++++ python/inkbox/phone/types.py | 140 ++++++++++++++ typescript/package.json | 4 + typescript/src/phone/client.ts | 63 +++++++ typescript/src/phone/index.ts | 8 + typescript/src/phone/resources/calls.ts | 78 ++++++++ typescript/src/phone/resources/numbers.ts | 118 ++++++++++++ typescript/src/phone/resources/transcripts.ts | 32 ++++ typescript/src/phone/resources/webhooks.ts | 81 ++++++++ typescript/src/phone/types.ts | 177 ++++++++++++++++++ 18 files changed, 1203 insertions(+) create mode 100644 python/inkbox/phone/__init__.py create mode 100644 python/inkbox/phone/_http.py create mode 100644 python/inkbox/phone/client.py create mode 100644 python/inkbox/phone/exceptions.py create mode 100644 python/inkbox/phone/resources/__init__.py create mode 100644 python/inkbox/phone/resources/calls.py create mode 100644 python/inkbox/phone/resources/numbers.py create mode 100644 python/inkbox/phone/resources/transcripts.py create mode 100644 python/inkbox/phone/resources/webhooks.py create mode 100644 python/inkbox/phone/types.py create mode 100644 typescript/src/phone/client.ts create mode 100644 typescript/src/phone/index.ts create mode 100644 typescript/src/phone/resources/calls.ts create mode 100644 typescript/src/phone/resources/numbers.ts create mode 100644 typescript/src/phone/resources/transcripts.ts create mode 100644 typescript/src/phone/resources/webhooks.ts create mode 100644 typescript/src/phone/types.ts diff --git a/python/inkbox/phone/__init__.py b/python/inkbox/phone/__init__.py new file mode 100644 index 0000000..aee5180 --- /dev/null +++ b/python/inkbox/phone/__init__.py @@ -0,0 +1,24 @@ +""" +inkbox.phone — Python SDK for the Inkbox Phone API. +""" + +from inkbox.phone.client import InkboxPhone +from inkbox.phone.exceptions import InkboxAPIError, InkboxError +from inkbox.phone.types import ( + PhoneCall, + PhoneNumber, + PhoneTranscript, + PhoneWebhook, + PhoneWebhookCreateResult, +) + +__all__ = [ + "InkboxPhone", + "InkboxError", + "InkboxAPIError", + "PhoneCall", + "PhoneNumber", + "PhoneTranscript", + "PhoneWebhook", + "PhoneWebhookCreateResult", +] diff --git a/python/inkbox/phone/_http.py b/python/inkbox/phone/_http.py new file mode 100644 index 0000000..b03c1cd --- /dev/null +++ b/python/inkbox/phone/_http.py @@ -0,0 +1,68 @@ +""" +inkbox/phone/_http.py + +Sync HTTP transport (internal). +""" + +from __future__ import annotations + +from typing import Any + +import httpx + +from inkbox.phone.exceptions import InkboxAPIError + +_DEFAULT_TIMEOUT = 30.0 + + +class HttpTransport: + def __init__(self, api_key: str, base_url: str, timeout: float = _DEFAULT_TIMEOUT) -> None: + self._client = httpx.Client( + base_url=base_url, + headers={ + "X-Service-Token": api_key, + "Accept": "application/json", + }, + timeout=timeout, + ) + + def get(self, path: str, *, params: dict[str, Any] | None = None) -> Any: + cleaned = {k: v for k, v in (params or {}).items() if v is not None} + resp = self._client.get(path, params=cleaned) + _raise_for_status(resp) + return resp.json() + + def post(self, path: str, *, json: dict[str, Any] | None = None) -> Any: + resp = self._client.post(path, json=json) + _raise_for_status(resp) + if resp.status_code == 204: + return None + return resp.json() + + def patch(self, path: str, *, json: dict[str, Any]) -> Any: + resp = self._client.patch(path, json=json) + _raise_for_status(resp) + return resp.json() + + def delete(self, path: str) -> None: + resp = self._client.delete(path) + _raise_for_status(resp) + + def close(self) -> None: + self._client.close() + + def __enter__(self) -> HttpTransport: + return self + + def __exit__(self, *_: object) -> None: + self.close() + + +def _raise_for_status(resp: httpx.Response) -> None: + if resp.status_code < 400: + return + try: + detail = resp.json().get("detail", resp.text) + except Exception: + detail = resp.text + raise InkboxAPIError(status_code=resp.status_code, detail=str(detail)) diff --git a/python/inkbox/phone/client.py b/python/inkbox/phone/client.py new file mode 100644 index 0000000..7864404 --- /dev/null +++ b/python/inkbox/phone/client.py @@ -0,0 +1,61 @@ +""" +inkbox/phone/client.py + +Top-level InkboxPhone client. +""" + +from __future__ import annotations + +from inkbox.phone._http import HttpTransport +from inkbox.phone.resources.numbers import PhoneNumbersResource +from inkbox.phone.resources.calls import CallsResource +from inkbox.phone.resources.transcripts import TranscriptsResource +from inkbox.phone.resources.webhooks import PhoneWebhooksResource + +_DEFAULT_BASE_URL = "https://api.inkbox.ai/api/v1/phone" + + +class InkboxPhone: + """Client for the Inkbox Phone API. + + Args: + api_key: Your Inkbox API key (``X-Service-Token``). + base_url: Override the API base URL (useful for self-hosting or testing). + timeout: Request timeout in seconds (default 30). + + Example:: + + from inkbox.phone import InkboxPhone + + with InkboxPhone(api_key="sk-...") as client: + number = client.numbers.provision(type="toll_free") + call = client.calls.place( + from_number=number.number, + to_number="+15167251294", + stream_url="wss://your-agent.example.com/ws", + ) + print(call.status) + """ + + def __init__( + self, + api_key: str, + *, + base_url: str = _DEFAULT_BASE_URL, + timeout: float = 30.0, + ) -> None: + self._http = HttpTransport(api_key=api_key, base_url=base_url, timeout=timeout) + self.numbers = PhoneNumbersResource(self._http) + self.calls = CallsResource(self._http) + self.transcripts = TranscriptsResource(self._http) + self.webhooks = PhoneWebhooksResource(self._http) + + def close(self) -> None: + """Close the underlying HTTP connection pool.""" + self._http.close() + + def __enter__(self) -> InkboxPhone: + return self + + def __exit__(self, *_: object) -> None: + self.close() diff --git a/python/inkbox/phone/exceptions.py b/python/inkbox/phone/exceptions.py new file mode 100644 index 0000000..6f05a5b --- /dev/null +++ b/python/inkbox/phone/exceptions.py @@ -0,0 +1,25 @@ +""" +inkbox/phone/exceptions.py + +Exception types raised by the SDK. +""" + +from __future__ import annotations + + +class InkboxError(Exception): + """Base exception for all Inkbox SDK errors.""" + + +class InkboxAPIError(InkboxError): + """Raised when the API returns a 4xx or 5xx response. + + Attributes: + status_code: HTTP status code. + detail: Error detail from the response body. + """ + + def __init__(self, status_code: int, detail: str) -> None: + super().__init__(f"HTTP {status_code}: {detail}") + self.status_code = status_code + self.detail = detail diff --git a/python/inkbox/phone/resources/__init__.py b/python/inkbox/phone/resources/__init__.py new file mode 100644 index 0000000..bc4f6c5 --- /dev/null +++ b/python/inkbox/phone/resources/__init__.py @@ -0,0 +1,11 @@ +from inkbox.phone.resources.numbers import PhoneNumbersResource +from inkbox.phone.resources.calls import CallsResource +from inkbox.phone.resources.transcripts import TranscriptsResource +from inkbox.phone.resources.webhooks import PhoneWebhooksResource + +__all__ = [ + "PhoneNumbersResource", + "CallsResource", + "TranscriptsResource", + "PhoneWebhooksResource", +] diff --git a/python/inkbox/phone/resources/calls.py b/python/inkbox/phone/resources/calls.py new file mode 100644 index 0000000..e3cb6f6 --- /dev/null +++ b/python/inkbox/phone/resources/calls.py @@ -0,0 +1,85 @@ +""" +inkbox/phone/resources/calls.py + +Call operations: list, get, place. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from uuid import UUID + +from inkbox.phone.types import PhoneCall + +if TYPE_CHECKING: + from inkbox.phone._http import HttpTransport + + +class CallsResource: + def __init__(self, http: HttpTransport) -> None: + self._http = http + + def list( + self, + phone_number_id: UUID | str, + *, + limit: int = 50, + offset: int = 0, + ) -> list[PhoneCall]: + """List calls for a phone number, newest first. + + Args: + phone_number_id: UUID of the phone number. + limit: Max results to return (1–200). + offset: Pagination offset. + """ + data = self._http.get( + f"/numbers/{phone_number_id}/calls", + params={"limit": limit, "offset": offset}, + ) + return [PhoneCall._from_dict(c) for c in data] + + def get( + self, + phone_number_id: UUID | str, + call_id: UUID | str, + ) -> PhoneCall: + """Get a single call by ID. + + Args: + phone_number_id: UUID of the phone number. + call_id: UUID of the call. + """ + data = self._http.get(f"/numbers/{phone_number_id}/calls/{call_id}") + return PhoneCall._from_dict(data) + + def place( + self, + *, + from_number: str, + to_number: str, + stream_url: str, + call_mode: str | None = None, + webhook_url: str | None = None, + ) -> PhoneCall: + """Place an outbound call. + + Args: + from_number: E.164 number to call from. Must belong to your org and be active. + to_number: E.164 number to call. + stream_url: WebSocket URL for audio bridging. + call_mode: Pipeline ownership: ``"client_llm_only"``, ``"client_llm_tts"``, + or ``"client_llm_tts_stt"``. + webhook_url: Custom webhook URL for call lifecycle events. + """ + body: dict[str, Any] = { + "from_number": from_number, + "to_number": to_number, + "stream_url": stream_url, + } + if call_mode is not None: + body["call_mode"] = call_mode + if webhook_url is not None: + body["webhook_url"] = webhook_url + data = self._http.post("/place-call", json=body) + return PhoneCall._from_dict(data) diff --git a/python/inkbox/phone/resources/numbers.py b/python/inkbox/phone/resources/numbers.py new file mode 100644 index 0000000..227bc7d --- /dev/null +++ b/python/inkbox/phone/resources/numbers.py @@ -0,0 +1,112 @@ +""" +inkbox/phone/resources/numbers.py + +Phone number CRUD, provisioning, release, and transcript search. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from uuid import UUID + +from inkbox.phone.types import PhoneNumber, PhoneTranscript + +if TYPE_CHECKING: + from inkbox.phone._http import HttpTransport + +_BASE = "/numbers" + + +class PhoneNumbersResource: + def __init__(self, http: HttpTransport) -> None: + self._http = http + + def list(self) -> list[PhoneNumber]: + """List all phone numbers for your organisation.""" + data = self._http.get(_BASE) + return [PhoneNumber._from_dict(n) for n in data] + + def get(self, phone_number_id: UUID | str) -> PhoneNumber: + """Get a phone number by ID.""" + data = self._http.get(f"{_BASE}/{phone_number_id}") + return PhoneNumber._from_dict(data) + + def update( + self, + phone_number_id: UUID | str, + *, + incoming_call_action: str | None = None, + default_stream_url: str | None = None, + default_pipeline_mode: str | None = None, + ) -> PhoneNumber: + """Update phone number settings. + + Pass only the fields you want to change; omitted fields are left as-is. + + Args: + phone_number_id: UUID of the phone number. + incoming_call_action: ``"auto_accept"``, ``"auto_reject"``, or ``"webhook"``. + default_stream_url: WebSocket URL for audio bridging on ``auto_accept``. + default_pipeline_mode: ``"client_llm_only"``, ``"client_llm_tts"``, + or ``"client_llm_tts_stt"``. + """ + body: dict[str, Any] = {} + if incoming_call_action is not None: + body["incoming_call_action"] = incoming_call_action + if default_stream_url is not None: + body["default_stream_url"] = default_stream_url + if default_pipeline_mode is not None: + body["default_pipeline_mode"] = default_pipeline_mode + data = self._http.patch(f"{_BASE}/{phone_number_id}", json=body) + return PhoneNumber._from_dict(data) + + def provision( + self, + *, + type: str = "toll_free", + state: str | None = None, + ) -> PhoneNumber: + """Provision a new phone number via Telnyx. + + Args: + type: ``"toll_free"`` or ``"local"``. + state: US state abbreviation (e.g. ``"NY"``). Only valid for ``local`` numbers. + + Returns: + The provisioned phone number. + """ + body: dict[str, Any] = {"type": type} + if state is not None: + body["state"] = state + data = self._http.post(f"{_BASE}/provision", json=body) + return PhoneNumber._from_dict(data) + + def release(self, *, number: str) -> None: + """Release (delete) a phone number. + + Args: + number: E.164 formatted phone number to release (e.g. ``"+18555690147"``). + """ + self._http.post(f"{_BASE}/release", json={"number": number}) + + def search_transcripts( + self, + phone_number_id: UUID | str, + *, + q: str, + party: str | None = None, + limit: int = 50, + ) -> list[PhoneTranscript]: + """Full-text search across transcripts for a phone number. + + Args: + phone_number_id: UUID of the phone number. + q: Search query string. + party: Filter by speaker: ``"local"`` or ``"remote"``. + limit: Maximum number of results (1–200). + """ + data = self._http.get( + f"{_BASE}/{phone_number_id}/search", + params={"q": q, "party": party, "limit": limit}, + ) + return [PhoneTranscript._from_dict(t) for t in data] diff --git a/python/inkbox/phone/resources/transcripts.py b/python/inkbox/phone/resources/transcripts.py new file mode 100644 index 0000000..6ae0ad2 --- /dev/null +++ b/python/inkbox/phone/resources/transcripts.py @@ -0,0 +1,36 @@ +""" +inkbox/phone/resources/transcripts.py + +Transcript retrieval. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID + +from inkbox.phone.types import PhoneTranscript + +if TYPE_CHECKING: + from inkbox.phone._http import HttpTransport + + +class TranscriptsResource: + def __init__(self, http: HttpTransport) -> None: + self._http = http + + def list( + self, + phone_number_id: UUID | str, + call_id: UUID | str, + ) -> list[PhoneTranscript]: + """List all transcript segments for a call, ordered by sequence number. + + Args: + phone_number_id: UUID of the phone number. + call_id: UUID of the call. + """ + data = self._http.get( + f"/numbers/{phone_number_id}/calls/{call_id}/transcripts", + ) + return [PhoneTranscript._from_dict(t) for t in data] diff --git a/python/inkbox/phone/resources/webhooks.py b/python/inkbox/phone/resources/webhooks.py new file mode 100644 index 0000000..313f77d --- /dev/null +++ b/python/inkbox/phone/resources/webhooks.py @@ -0,0 +1,80 @@ +""" +inkbox/phone/resources/webhooks.py + +Phone webhook CRUD. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from uuid import UUID + +from inkbox.phone.types import PhoneWebhook, PhoneWebhookCreateResult + +if TYPE_CHECKING: + from inkbox.phone._http import HttpTransport + + +class PhoneWebhooksResource: + def __init__(self, http: HttpTransport) -> None: + self._http = http + + def create( + self, + phone_number_id: UUID | str, + *, + url: str, + event_types: list[str], + ) -> PhoneWebhookCreateResult: + """Register a webhook subscription for a phone number. + + Args: + phone_number_id: UUID of the phone number. + url: HTTPS endpoint that will receive webhook POST requests. + event_types: Events to subscribe to (e.g. ``["incoming_call"]``). + + Returns: + The created webhook. ``secret`` is the HMAC-SHA256 signing key — + save it immediately, as it will not be returned again. + """ + data = self._http.post( + f"/numbers/{phone_number_id}/webhooks", + json={"url": url, "event_types": event_types}, + ) + return PhoneWebhookCreateResult._from_dict(data) + + def list(self, phone_number_id: UUID | str) -> list[PhoneWebhook]: + """List all active webhooks for a phone number.""" + data = self._http.get(f"/numbers/{phone_number_id}/webhooks") + return [PhoneWebhook._from_dict(w) for w in data] + + def update( + self, + phone_number_id: UUID | str, + webhook_id: UUID | str, + *, + url: str | None = None, + event_types: list[str] | None = None, + ) -> PhoneWebhook: + """Update a webhook subscription. + + Pass only the fields you want to change; omitted fields are left as-is. + """ + body: dict[str, Any] = {} + if url is not None: + body["url"] = url + if event_types is not None: + body["event_types"] = event_types + data = self._http.patch( + f"/numbers/{phone_number_id}/webhooks/{webhook_id}", + json=body, + ) + return PhoneWebhook._from_dict(data) + + def delete( + self, + phone_number_id: UUID | str, + webhook_id: UUID | str, + ) -> None: + """Delete a webhook subscription.""" + self._http.delete(f"/numbers/{phone_number_id}/webhooks/{webhook_id}") diff --git a/python/inkbox/phone/types.py b/python/inkbox/phone/types.py new file mode 100644 index 0000000..3addb36 --- /dev/null +++ b/python/inkbox/phone/types.py @@ -0,0 +1,140 @@ +""" +inkbox/phone/types.py + +Dataclasses mirroring the Inkbox Phone API response models. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Any +from uuid import UUID + + +def _dt(value: str | None) -> datetime | None: + return datetime.fromisoformat(value) if value else None + + +@dataclass +class PhoneNumber: + """A phone number owned by your organisation.""" + + id: UUID + number: str + type: str + status: str + incoming_call_action: str + default_stream_url: str | None + default_pipeline_mode: str + created_at: datetime + updated_at: datetime + + @classmethod + def _from_dict(cls, d: dict[str, Any]) -> PhoneNumber: + return cls( + id=UUID(d["id"]), + number=d["number"], + type=d["type"], + status=d["status"], + incoming_call_action=d["incoming_call_action"], + default_stream_url=d.get("default_stream_url"), + default_pipeline_mode=d.get("default_pipeline_mode", "client_llm_only"), + created_at=datetime.fromisoformat(d["created_at"]), + updated_at=datetime.fromisoformat(d["updated_at"]), + ) + + +@dataclass +class PhoneCall: + """A phone call record.""" + + id: UUID + local_phone_number: str + remote_phone_number: str + direction: str + status: str + pipeline_mode: str + stream_url: str | None + started_at: datetime | None + ended_at: datetime | None + created_at: datetime + updated_at: datetime + + @classmethod + def _from_dict(cls, d: dict[str, Any]) -> PhoneCall: + return cls( + id=UUID(d["id"]), + local_phone_number=d["local_phone_number"], + remote_phone_number=d["remote_phone_number"], + direction=d["direction"], + status=d["status"], + pipeline_mode=d.get("pipeline_mode", "client_llm_only"), + stream_url=d.get("stream_url"), + started_at=_dt(d.get("started_at")), + ended_at=_dt(d.get("ended_at")), + created_at=datetime.fromisoformat(d["created_at"]), + updated_at=datetime.fromisoformat(d["updated_at"]), + ) + + +@dataclass +class PhoneTranscript: + """A transcript segment from a phone call.""" + + id: UUID + call_id: UUID + seq: int + ts_ms: int + party: str + text: str + created_at: datetime + + @classmethod + def _from_dict(cls, d: dict[str, Any]) -> PhoneTranscript: + return cls( + id=UUID(d["id"]), + call_id=UUID(d["call_id"]), + seq=d["seq"], + ts_ms=d["ts_ms"], + party=d["party"], + text=d["text"], + created_at=datetime.fromisoformat(d["created_at"]), + ) + + +@dataclass +class PhoneWebhook: + """A webhook subscription for a phone number.""" + + id: UUID + source_id: UUID + source_type: str + url: str + event_types: list[str] + status: str + created_at: datetime + + @classmethod + def _from_dict(cls, d: dict[str, Any]) -> PhoneWebhook: + return cls( + id=UUID(d["id"]), + source_id=UUID(d["source_id"]), + source_type=d["source_type"], + url=d["url"], + event_types=d["event_types"], + status=d["status"], + created_at=datetime.fromisoformat(d["created_at"]), + ) + + +@dataclass +class PhoneWebhookCreateResult(PhoneWebhook): + """Returned only on webhook creation. Includes the one-time HMAC signing secret.""" + + secret: str = "" + + @classmethod + def _from_dict(cls, d: dict[str, Any]) -> PhoneWebhookCreateResult: # type: ignore[override] + base = PhoneWebhook._from_dict(d) + return cls(**base.__dict__, secret=d["secret"]) diff --git a/typescript/package.json b/typescript/package.json index 1f8e93c..f597654 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -10,6 +10,10 @@ ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" + }, + "./phone": { + "import": "./dist/phone/index.js", + "types": "./dist/phone/index.d.ts" } }, "files": ["dist"], diff --git a/typescript/src/phone/client.ts b/typescript/src/phone/client.ts new file mode 100644 index 0000000..c41a5b5 --- /dev/null +++ b/typescript/src/phone/client.ts @@ -0,0 +1,63 @@ +/** + * inkbox-phone/client.ts + * + * Top-level InkboxPhone client. + */ + +import { HttpTransport } from "../_http.js"; +import { PhoneNumbersResource } from "./resources/numbers.js"; +import { CallsResource } from "./resources/calls.js"; +import { TranscriptsResource } from "./resources/transcripts.js"; +import { PhoneWebhooksResource } from "./resources/webhooks.js"; + +const DEFAULT_BASE_URL = "https://api.inkbox.ai/api/v1/phone"; + +export interface InkboxPhoneOptions { + /** Your Inkbox API key (sent as `X-Service-Token`). */ + apiKey: string; + /** Override the API base URL (useful for self-hosting or testing). */ + baseUrl?: string; + /** Request timeout in milliseconds. Defaults to 30 000. */ + timeoutMs?: number; +} + +/** + * Client for the Inkbox Phone API. + * + * @example + * ```ts + * import { InkboxPhone } from "@inkbox/sdk/phone"; + * + * const client = new InkboxPhone({ apiKey: "sk-..." }); + * + * const number = await client.numbers.provision(); + * + * const call = await client.calls.place({ + * fromNumber: number.number, + * toNumber: "+15167251294", + * streamUrl: "wss://your-agent.example.com/ws", + * }); + * + * console.log(call.status); + * ``` + */ +export class InkboxPhone { + readonly numbers: PhoneNumbersResource; + readonly calls: CallsResource; + readonly transcripts: TranscriptsResource; + readonly webhooks: PhoneWebhooksResource; + + private readonly http: HttpTransport; + + constructor(options: InkboxPhoneOptions) { + this.http = new HttpTransport( + options.apiKey, + options.baseUrl ?? DEFAULT_BASE_URL, + options.timeoutMs ?? 30_000, + ); + this.numbers = new PhoneNumbersResource(this.http); + this.calls = new CallsResource(this.http); + this.transcripts = new TranscriptsResource(this.http); + this.webhooks = new PhoneWebhooksResource(this.http); + } +} diff --git a/typescript/src/phone/index.ts b/typescript/src/phone/index.ts new file mode 100644 index 0000000..1bf4c6c --- /dev/null +++ b/typescript/src/phone/index.ts @@ -0,0 +1,8 @@ +export { InkboxPhone } from "./client.js"; +export type { + PhoneNumber, + PhoneCall, + PhoneTranscript, + PhoneWebhook, + PhoneWebhookCreateResult, +} from "./types.js"; diff --git a/typescript/src/phone/resources/calls.ts b/typescript/src/phone/resources/calls.ts new file mode 100644 index 0000000..0db484d --- /dev/null +++ b/typescript/src/phone/resources/calls.ts @@ -0,0 +1,78 @@ +/** + * inkbox-phone/resources/calls.ts + * + * Call operations: list, get, place. + */ + +import { HttpTransport } from "../../_http.js"; +import { + PhoneCall, + RawPhoneCall, + parsePhoneCall, +} from "../types.js"; + +export class CallsResource { + constructor(private readonly http: HttpTransport) {} + + /** + * List calls for a phone number, newest first. + * + * @param phoneNumberId - UUID of the phone number. + * @param options.limit - Max results (1–200). Defaults to 50. + * @param options.offset - Pagination offset. Defaults to 0. + */ + async list( + phoneNumberId: string, + options?: { limit?: number; offset?: number }, + ): Promise { + const data = await this.http.get( + `/numbers/${phoneNumberId}/calls`, + { limit: options?.limit ?? 50, offset: options?.offset ?? 0 }, + ); + return data.map(parsePhoneCall); + } + + /** + * Get a single call by ID. + * + * @param phoneNumberId - UUID of the phone number. + * @param callId - UUID of the call. + */ + async get(phoneNumberId: string, callId: string): Promise { + const data = await this.http.get( + `/numbers/${phoneNumberId}/calls/${callId}`, + ); + return parsePhoneCall(data); + } + + /** + * Place an outbound call. + * + * @param options.fromNumber - E.164 number to call from. Must belong to your org and be active. + * @param options.toNumber - E.164 number to call. + * @param options.streamUrl - WebSocket URL for audio bridging. + * @param options.callMode - Pipeline ownership: `"client_llm_only"`, `"client_llm_tts"`, or `"client_llm_tts_stt"`. + * @param options.webhookUrl - Custom webhook URL for call lifecycle events. + */ + async place(options: { + fromNumber: string; + toNumber: string; + streamUrl: string; + callMode?: string; + webhookUrl?: string; + }): Promise { + const body: Record = { + from_number: options.fromNumber, + to_number: options.toNumber, + stream_url: options.streamUrl, + }; + if (options.callMode !== undefined) { + body["call_mode"] = options.callMode; + } + if (options.webhookUrl !== undefined) { + body["webhook_url"] = options.webhookUrl; + } + const data = await this.http.post("/place-call", body); + return parsePhoneCall(data); + } +} diff --git a/typescript/src/phone/resources/numbers.ts b/typescript/src/phone/resources/numbers.ts new file mode 100644 index 0000000..17aca92 --- /dev/null +++ b/typescript/src/phone/resources/numbers.ts @@ -0,0 +1,118 @@ +/** + * inkbox-phone/resources/numbers.ts + * + * Phone number CRUD, provisioning, release, and transcript search. + */ + +import { HttpTransport } from "../../_http.js"; +import { + PhoneNumber, + PhoneTranscript, + RawPhoneNumber, + RawPhoneTranscript, + parsePhoneNumber, + parsePhoneTranscript, +} from "../types.js"; + +const BASE = "/numbers"; + +export class PhoneNumbersResource { + constructor(private readonly http: HttpTransport) {} + + /** List all phone numbers for your organisation. */ + async list(): Promise { + const data = await this.http.get(BASE); + return data.map(parsePhoneNumber); + } + + /** Get a phone number by ID. */ + async get(phoneNumberId: string): Promise { + const data = await this.http.get( + `${BASE}/${phoneNumberId}`, + ); + return parsePhoneNumber(data); + } + + /** + * Update phone number settings. Only provided fields are updated. + * + * @param phoneNumberId - UUID of the phone number. + * @param options.incomingCallAction - `"auto_accept"`, `"auto_reject"`, or `"webhook"`. + * @param options.defaultStreamUrl - WebSocket URL for audio bridging on `auto_accept`. + * @param options.defaultPipelineMode - `"client_llm_only"`, `"client_llm_tts"`, or `"client_llm_tts_stt"`. + */ + async update( + phoneNumberId: string, + options: { + incomingCallAction?: string; + defaultStreamUrl?: string; + defaultPipelineMode?: string; + }, + ): Promise { + const body: Record = {}; + if (options.incomingCallAction !== undefined) { + body["incoming_call_action"] = options.incomingCallAction; + } + if (options.defaultStreamUrl !== undefined) { + body["default_stream_url"] = options.defaultStreamUrl; + } + if (options.defaultPipelineMode !== undefined) { + body["default_pipeline_mode"] = options.defaultPipelineMode; + } + const data = await this.http.patch( + `${BASE}/${phoneNumberId}`, + body, + ); + return parsePhoneNumber(data); + } + + /** + * Provision a new phone number via Telnyx. + * + * @param options.type - `"toll_free"` or `"local"`. Defaults to `"toll_free"`. + * @param options.state - US state abbreviation (e.g. `"NY"`). Only valid for `local` numbers. + */ + async provision( + options?: { type?: string; state?: string }, + ): Promise { + const body: Record = { + type: options?.type ?? "toll_free", + }; + if (options?.state !== undefined) { + body["state"] = options.state; + } + const data = await this.http.post( + `${BASE}/provision`, + body, + ); + return parsePhoneNumber(data); + } + + /** + * Release (delete) a phone number. + * + * @param number - E.164 formatted phone number to release. + */ + async release(number: string): Promise { + await this.http.post(`${BASE}/release`, { number }); + } + + /** + * Full-text search across transcripts for a phone number. + * + * @param phoneNumberId - UUID of the phone number. + * @param options.q - Search query string. + * @param options.party - Filter by speaker: `"local"` or `"remote"`. + * @param options.limit - Maximum number of results (1–200). Defaults to 50. + */ + async searchTranscripts( + phoneNumberId: string, + options: { q: string; party?: string; limit?: number }, + ): Promise { + const data = await this.http.get( + `${BASE}/${phoneNumberId}/search`, + { q: options.q, party: options.party, limit: options.limit ?? 50 }, + ); + return data.map(parsePhoneTranscript); + } +} diff --git a/typescript/src/phone/resources/transcripts.ts b/typescript/src/phone/resources/transcripts.ts new file mode 100644 index 0000000..97f4f71 --- /dev/null +++ b/typescript/src/phone/resources/transcripts.ts @@ -0,0 +1,32 @@ +/** + * inkbox-phone/resources/transcripts.ts + * + * Transcript retrieval. + */ + +import { HttpTransport } from "../../_http.js"; +import { + PhoneTranscript, + RawPhoneTranscript, + parsePhoneTranscript, +} from "../types.js"; + +export class TranscriptsResource { + constructor(private readonly http: HttpTransport) {} + + /** + * List all transcript segments for a call, ordered by sequence number. + * + * @param phoneNumberId - UUID of the phone number. + * @param callId - UUID of the call. + */ + async list( + phoneNumberId: string, + callId: string, + ): Promise { + const data = await this.http.get( + `/numbers/${phoneNumberId}/calls/${callId}/transcripts`, + ); + return data.map(parsePhoneTranscript); + } +} diff --git a/typescript/src/phone/resources/webhooks.ts b/typescript/src/phone/resources/webhooks.ts new file mode 100644 index 0000000..4c94b6d --- /dev/null +++ b/typescript/src/phone/resources/webhooks.ts @@ -0,0 +1,81 @@ +/** + * inkbox-phone/resources/webhooks.ts + * + * Phone webhook CRUD. + */ + +import { HttpTransport } from "../../_http.js"; +import { + PhoneWebhook, + PhoneWebhookCreateResult, + RawPhoneWebhook, + RawPhoneWebhookCreateResult, + parsePhoneWebhook, + parsePhoneWebhookCreateResult, +} from "../types.js"; + +export class PhoneWebhooksResource { + constructor(private readonly http: HttpTransport) {} + + /** + * Register a webhook subscription for a phone number. + * + * @param phoneNumberId - UUID of the phone number. + * @param options.url - HTTPS endpoint that will receive webhook POST requests. + * @param options.eventTypes - Events to subscribe to (e.g. `["incoming_call"]`). + * @returns The created webhook. `secret` is the one-time HMAC-SHA256 signing + * key — save it immediately, as it will not be returned again. + */ + async create( + phoneNumberId: string, + options: { url: string; eventTypes: string[] }, + ): Promise { + const data = await this.http.post( + `/numbers/${phoneNumberId}/webhooks`, + { url: options.url, event_types: options.eventTypes }, + ); + return parsePhoneWebhookCreateResult(data); + } + + /** List all active webhooks for a phone number. */ + async list(phoneNumberId: string): Promise { + const data = await this.http.get( + `/numbers/${phoneNumberId}/webhooks`, + ); + return data.map(parsePhoneWebhook); + } + + /** + * Update a webhook subscription. Only provided fields are updated. + * + * @param phoneNumberId - UUID of the phone number. + * @param webhookId - UUID of the webhook. + * @param options.url - New destination URL. + * @param options.eventTypes - New event subscriptions. + */ + async update( + phoneNumberId: string, + webhookId: string, + options: { url?: string; eventTypes?: string[] }, + ): Promise { + const body: Record = {}; + if (options.url !== undefined) { + body["url"] = options.url; + } + if (options.eventTypes !== undefined) { + body["event_types"] = options.eventTypes; + } + const data = await this.http.patch( + `/numbers/${phoneNumberId}/webhooks/${webhookId}`, + body, + ); + return parsePhoneWebhook(data); + } + + /** Delete a webhook subscription. */ + async delete(phoneNumberId: string, webhookId: string): Promise { + await this.http.delete( + `/numbers/${phoneNumberId}/webhooks/${webhookId}`, + ); + } +} diff --git a/typescript/src/phone/types.ts b/typescript/src/phone/types.ts new file mode 100644 index 0000000..8feb02d --- /dev/null +++ b/typescript/src/phone/types.ts @@ -0,0 +1,177 @@ +/** + * inkbox-phone TypeScript SDK — public types. + */ + +export interface PhoneNumber { + id: string; + number: string; + /** "toll_free" | "local" */ + type: string; + /** "active" | "paused" | "released" */ + status: string; + /** "auto_accept" | "auto_reject" | "webhook" */ + incomingCallAction: string; + defaultStreamUrl: string | null; + /** "client_llm_only" | "client_llm_tts" | "client_llm_tts_stt" */ + defaultPipelineMode: string; + createdAt: Date; + updatedAt: Date; +} + +export interface PhoneCall { + id: string; + localPhoneNumber: string; + remotePhoneNumber: string; + /** "outbound" | "inbound" */ + direction: string; + /** "initiated" | "ringing" | "answered" | "completed" | "failed" | "canceled" */ + status: string; + /** "client_llm_only" | "client_llm_tts" | "client_llm_tts_stt" */ + pipelineMode: string; + streamUrl: string | null; + startedAt: Date | null; + endedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export interface PhoneTranscript { + id: string; + callId: string; + seq: number; + tsMs: number; + /** "local" | "remote" | "system" */ + party: string; + text: string; + createdAt: Date; +} + +export interface PhoneWebhook { + id: string; + sourceId: string; + sourceType: string; + url: string; + eventTypes: string[]; + /** "active" | "paused" | "deleted" */ + status: string; + createdAt: Date; +} + +export interface PhoneWebhookCreateResult extends PhoneWebhook { + /** One-time HMAC-SHA256 signing secret. Save immediately — not returned again. */ + secret: string; +} + +// ---- internal raw API shapes (snake_case from JSON) ---- + +export interface RawPhoneNumber { + id: string; + number: string; + type: string; + status: string; + incoming_call_action: string; + default_stream_url: string | null; + default_pipeline_mode: string; + created_at: string; + updated_at: string; +} + +export interface RawPhoneCall { + id: string; + local_phone_number: string; + remote_phone_number: string; + direction: string; + status: string; + pipeline_mode: string; + stream_url: string | null; + started_at: string | null; + ended_at: string | null; + created_at: string; + updated_at: string; +} + +export interface RawPhoneTranscript { + id: string; + call_id: string; + seq: number; + ts_ms: number; + party: string; + text: string; + created_at: string; +} + +export interface RawPhoneWebhook { + id: string; + source_id: string; + source_type: string; + url: string; + event_types: string[]; + status: string; + created_at: string; +} + +export interface RawPhoneWebhookCreateResult extends RawPhoneWebhook { + secret: string; +} + +// ---- parsers ---- + +export function parsePhoneNumber(r: RawPhoneNumber): PhoneNumber { + return { + id: r.id, + number: r.number, + type: r.type, + status: r.status, + incomingCallAction: r.incoming_call_action, + defaultStreamUrl: r.default_stream_url, + defaultPipelineMode: r.default_pipeline_mode ?? "client_llm_only", + createdAt: new Date(r.created_at), + updatedAt: new Date(r.updated_at), + }; +} + +export function parsePhoneCall(r: RawPhoneCall): PhoneCall { + return { + id: r.id, + localPhoneNumber: r.local_phone_number, + remotePhoneNumber: r.remote_phone_number, + direction: r.direction, + status: r.status, + pipelineMode: r.pipeline_mode ?? "client_llm_only", + streamUrl: r.stream_url, + startedAt: r.started_at ? new Date(r.started_at) : null, + endedAt: r.ended_at ? new Date(r.ended_at) : null, + createdAt: new Date(r.created_at), + updatedAt: new Date(r.updated_at), + }; +} + +export function parsePhoneTranscript(r: RawPhoneTranscript): PhoneTranscript { + return { + id: r.id, + callId: r.call_id, + seq: r.seq, + tsMs: r.ts_ms, + party: r.party, + text: r.text, + createdAt: new Date(r.created_at), + }; +} + +export function parsePhoneWebhook(r: RawPhoneWebhook): PhoneWebhook { + return { + id: r.id, + sourceId: r.source_id, + sourceType: r.source_type, + url: r.url, + eventTypes: r.event_types, + status: r.status, + createdAt: new Date(r.created_at), + }; +} + +export function parsePhoneWebhookCreateResult( + r: RawPhoneWebhookCreateResult, +): PhoneWebhookCreateResult { + return { ...parsePhoneWebhook(r), secret: r.secret }; +} From 26f467a9ce6bc5c107c5809ab7a841dc2badc0e0 Mon Sep 17 00:00:00 2001 From: dimavrem22 Date: Tue, 10 Mar 2026 18:09:59 +0000 Subject: [PATCH 03/56] phone, unit tests, test scripts --- .github/workflows/tests.yml | 68 +++++ python/.coverage | Bin 0 -> 53248 bytes python/publish.sh | 12 +- python/pyproject.toml | 14 +- python/tests/__init__.py | 0 python/tests/conftest.py | 36 +++ python/tests/sample_data.py | 52 ++++ python/tests/sample_data_mail.py | 93 +++++++ python/tests/test_calls.py | 113 +++++++++ python/tests/test_client.py | 26 ++ python/tests/test_mail_client.py | 27 ++ python/tests/test_mail_mailboxes.py | 104 ++++++++ python/tests/test_mail_messages.py | 176 +++++++++++++ python/tests/test_mail_threads.py | 67 +++++ python/tests/test_mail_types.py | 121 +++++++++ python/tests/test_mail_webhooks.py | 58 +++++ python/tests/test_numbers.py | 166 +++++++++++++ python/tests/test_transcripts.py | 43 ++++ python/tests/test_types.py | 96 +++++++ python/tests/test_webhooks.py | 96 +++++++ python/uv.lock | 373 ++++++++++++++++++++++++++++ scripts/test_phone.py | 141 +++++++++++ scripts/test_phone.ts | 150 +++++++++++ 23 files changed, 2026 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 python/.coverage create mode 100644 python/tests/__init__.py create mode 100644 python/tests/conftest.py create mode 100644 python/tests/sample_data.py create mode 100644 python/tests/sample_data_mail.py create mode 100644 python/tests/test_calls.py create mode 100644 python/tests/test_client.py create mode 100644 python/tests/test_mail_client.py create mode 100644 python/tests/test_mail_mailboxes.py create mode 100644 python/tests/test_mail_messages.py create mode 100644 python/tests/test_mail_threads.py create mode 100644 python/tests/test_mail_types.py create mode 100644 python/tests/test_mail_webhooks.py create mode 100644 python/tests/test_numbers.py create mode 100644 python/tests/test_transcripts.py create mode 100644 python/tests/test_types.py create mode 100644 python/tests/test_webhooks.py create mode 100644 python/uv.lock create mode 100644 scripts/test_phone.py create mode 100644 scripts/test_phone.ts diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..8ae0cb9 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,68 @@ +# .github/workflows/tests.yml + +name: Lint & Unit Tests + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +jobs: + python: + name: Python – Lint & Test + runs-on: ubuntu-latest + defaults: + run: + shell: bash -l {0} + working-directory: python + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + version: "latest" + + - name: Cache uv dependencies + uses: actions/cache@v4 + with: + path: ~/.cache/uv + key: ${{ runner.os }}-uv-python-${{ hashFiles('python/pyproject.toml') }} + + - name: Install dependencies + run: uv sync --dev + + - name: Lint + run: uv run ruff check . + + - name: Run unit tests + run: uv run pytest tests/ -v --cov=inkbox --cov-report=term-missing --cov-fail-under=70 + + typescript: + name: TypeScript – Lint & Build + runs-on: ubuntu-latest + defaults: + run: + shell: bash -l {0} + working-directory: typescript + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "18" + cache: "npm" + cache-dependency-path: typescript/package-lock.json + + - name: Install dependencies + run: npm install --ignore-scripts + + - name: Type check + run: npx tsc --noEmit diff --git a/python/.coverage b/python/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..ee886091821303df63f08956c7cd4f4a084d0bbd GIT binary patch literal 53248 zcmeI4ZD<_F8OL{T@BMafPH#CJ+118vkk#lV^1GdaEjdma8mQCSj{AbotJ6x_=pbmS^fywo^7deV>$*WLY|{>yjj;=%vvs zI1+Rc4K65^Th2E+O-aKSzooIWk{UlJvGdyfY)G9}|DE}vdN_3^VDiIV9#M$vTqckRTNPL7>Esh=JjfAXa6=jnUWIvqnp`k3z6 z^Lo{E^eL-k>Q-gaDjJ?unby4-GpJp2%}KsyQ68;YyyCEs=Vt|LlB7Iynp9LBt86%n z`mDJ)C@M5s^UMX$&mbkHHC^E~^gRvn0o^gDOvkJgP1m<#k2N`v9{9#mI+5?{k}sRW zC&MYyU#86)%4oXs#1tti+Rh}me!?*-#TnBb)QwdSi#FN0;Q8J(d4iBXQ!7__=a7WouxD>1xsj}AH=GUKH|z9e)8Dr443P64^`&YRYp!OlcK)E= zlpGXpRO~^WJ3$ffU?!eF(j`k2mF9vwSE7$j1*7KK;<`YcUl^q~^-@PNk?-r1FVBfU z;dlxYmglaXq}qaJHG6~5;pH304Id@#LBdB-sifOeV#si5-R~TV=GHMY62LGVKG1Z# zQIRbDM5B!xOE%~eC3~Vh)wMvl>6_@)^v@e>DF>MYvO8sf!lMW;b zozYZ0f3k0*&{++E!szO0y1pZx$oKZj%PA2?joxY`iM9|5dUBm0`}2$R352>NB3^!ZC^GnwZ!P5y$pICRk{ z(QHyREQf|H&B6W(p!158230mD?3yRymtTtTlI&yeXTV2^TEm6@h1@y)ca{rx->-B=nRxLO8JZx=pU{U6Rx#v zO!~u}cS?)K`5E&rb^dDfsNtEORW@~AMHnLjYu<46qGM8FV=Wbi5wV%IR{3f^PbI+| z>U%TLuY6SO@GdTl@^`*VPV$d(^*%X~f9N5(UTchCe{w3g^huy>1im&dBW;njrmz`g zU!+-@J|eXS7YE+LkYD;O@EOtX_6Z+dP^<9)DLue9_%gdK(H{;F009sH0T2KI5C8!X z009sH0T2Lzdyjx3N8|+G|3}z0iQS|(93TJ!AOHd&00JNY0w4eaAOHd&00P^RfErPD zvEbiA9?r>1?~ca*02~?~-8Z~HOp%)7gJyf08|)?brUU{YX2f^{Lm@ zU#auzzRb0j-XVI7h%;hU zW+&{0k?Nv1V^@N!vSF1*9MiRH4m|{9WZs;ZvF%xxew94dO*#@=(BaL{kB=w0it(;3 zno%}gm!56Fb?om{Bs;Vf-eOfqo#>#JGoi(r05}h#B$SNjwrGB#U{x%yP$2V$ucV*i5*fEsTNwS#laJqu%xRLcINuA z_E?&fbcAVstrB|9O~oxbR+S#*QC&QqB0aIK=qVbd64!7b84{mwNDy!1iLjxv)`7+Y zOQ`7+@vV|<>5ii@MS3F0xq^W--C#Y9#Y&*G`Sjb$`NIL6LKw1M)oSRq3&+B$l&>ZSQkM61V8`;KmY_l z00ck)1V8`;K;WJxpwPbyX#D$sncb1-4+jW<00@8p2!H?xfB*=900@8p2!OynNI+4v zl=%IB_KL(lV7J&c_Gk77_FMJ~_8s;dv)L*3I6KS+SRc!?SoT)-o$Omw6$c1_00@8p z2!H?xfB*=900@8p2z+b=@;y;G^>A|e#n)Co?0bJ{<=D+zOMkn%a^+LGZYtQFSw8#q zUDDIDmzQdCx{Gq|OH)qbzV1#s|7>FUtnv+o>$Su1eB-uN>4gei>bU>CS`6OXqecRJzPX-hO^phE6`$ zNp@Xb+IeT?e=9HCet%_dY2}^&&b>j_sc9;{zeia2@>!OmE4`bnIUxP~!`G#=y-CVA z+||rjk)><5?Hm7k`pg+Qm!QJk9aOmE`Bz?jNvX>5IOSw>=aVr?I~iHYqI9Nf!b<7X z$}@_lP{z)zu=9cMCzBCgQ+->C#N=oym6gTs|CueZ+w5ibWA-!l19}eNCEELco9+Ny zWdC9BvK#Cw`zw2ky~_T`e$RgMv2`&T0s#;J0T2KI5C8!X009sH0T2Lz?Mi@e*yUu8 zNHX0bNq31P(J7Kthe+Z%k;Irtl&nainn)t5NIEkj=}C*EJ0+5?q)0jvBFTvx1Tu?> zD=jLLtRfOM5~Uje;`@Iow_S0 Cleaning previous builds" rm -rf dist/ build/ *.egg-info -echo "==> Installing build tools" -python -m pip install --quiet build twine - echo "==> Building" -python -m build +uv build echo "==> Uploading to $REPO" -twine upload --repository "$REPO" dist/* +if [[ "$REPO" == "testpypi" ]]; then + REPO_URL="https://test.pypi.org/legacy/" +else + REPO_URL="https://upload.pypi.org/legacy/" +fi +uv run --with twine twine upload --repository-url "$REPO_URL" dist/* echo "" if [[ "$REPO" == "testpypi" ]]; then diff --git a/python/pyproject.toml b/python/pyproject.toml index 7b965b0..8ae0d64 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,13 +4,21 @@ build-backend = "hatchling.build" [project] name = "inkbox" -version = "0.1.0" +version = "0.1.0.2" description = "Python SDK for the Inkbox API" readme = "README.md" requires-python = ">=3.11" license = { text = "MIT" } dependencies = [ "httpx>=0.27", + "python-dotenv>=1.2.2", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-cov>=5.0", + "ruff>=0.4", ] [project.urls] @@ -18,5 +26,9 @@ Homepage = "https://inkbox.ai" Repository = "https://github.com/vectorlyapp/inkbox/tree/main/inkbox/python" "Bug Tracker" = "https://github.com/vectorlyapp/inkbox/issues" +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = [".", "tests"] + [tool.hatch.build.targets.wheel] packages = ["inkbox"] diff --git a/python/tests/__init__.py b/python/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/tests/conftest.py b/python/tests/conftest.py new file mode 100644 index 0000000..31a3024 --- /dev/null +++ b/python/tests/conftest.py @@ -0,0 +1,36 @@ +"""Shared fixtures for Inkbox Phone SDK tests.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from inkbox.phone import InkboxPhone + + +class FakeHttpTransport: + """Mock HTTP transport that returns pre-configured responses.""" + + def __init__(self) -> None: + self.get = MagicMock() + self.post = MagicMock() + self.patch = MagicMock() + self.delete = MagicMock() + self.close = MagicMock() + + +@pytest.fixture +def transport() -> FakeHttpTransport: + return FakeHttpTransport() + + +@pytest.fixture +def client(transport: FakeHttpTransport) -> InkboxPhone: + c = InkboxPhone(api_key="sk-test") + c._http = transport # type: ignore[attr-defined] + c.numbers._http = transport + c.calls._http = transport + c.transcripts._http = transport + c.webhooks._http = transport + return c diff --git a/python/tests/sample_data.py b/python/tests/sample_data.py new file mode 100644 index 0000000..444c6ab --- /dev/null +++ b/python/tests/sample_data.py @@ -0,0 +1,52 @@ +"""Sample API response dicts for tests.""" + +PHONE_NUMBER_DICT = { + "id": "aaaa1111-0000-0000-0000-000000000001", + "number": "+18335794607", + "type": "toll_free", + "status": "active", + "incoming_call_action": "auto_reject", + "default_stream_url": None, + "default_pipeline_mode": "client_llm_only", + "created_at": "2026-03-09T00:00:00Z", + "updated_at": "2026-03-09T00:00:00Z", +} + +PHONE_CALL_DICT = { + "id": "bbbb2222-0000-0000-0000-000000000001", + "local_phone_number": "+18335794607", + "remote_phone_number": "+15167251294", + "direction": "outbound", + "status": "completed", + "pipeline_mode": "client_llm_only", + "stream_url": "wss://agent.example.com/ws", + "started_at": "2026-03-09T00:01:00Z", + "ended_at": "2026-03-09T00:05:00Z", + "created_at": "2026-03-09T00:00:00Z", + "updated_at": "2026-03-09T00:05:00Z", +} + +PHONE_TRANSCRIPT_DICT = { + "id": "cccc3333-0000-0000-0000-000000000001", + "call_id": "bbbb2222-0000-0000-0000-000000000001", + "seq": 0, + "ts_ms": 1500, + "party": "local", + "text": "Hello, how can I help you?", + "created_at": "2026-03-09T00:01:01Z", +} + +PHONE_WEBHOOK_DICT = { + "id": "dddd4444-0000-0000-0000-000000000001", + "source_id": "aaaa1111-0000-0000-0000-000000000001", + "source_type": "phone_number", + "url": "https://example.com/webhooks/phone", + "event_types": ["incoming_call"], + "status": "active", + "created_at": "2026-03-09T00:00:00Z", +} + +PHONE_WEBHOOK_CREATE_DICT = { + **PHONE_WEBHOOK_DICT, + "secret": "test-hmac-secret-abc123", +} diff --git a/python/tests/sample_data_mail.py b/python/tests/sample_data_mail.py new file mode 100644 index 0000000..0b1be7f --- /dev/null +++ b/python/tests/sample_data_mail.py @@ -0,0 +1,93 @@ +"""Sample API response dicts for mail tests.""" + +MAILBOX_DICT = { + "id": "aaaa1111-0000-0000-0000-000000000001", + "email_address": "agent01@inkbox.ai", + "display_name": "Agent 01", + "status": "active", + "created_at": "2026-03-09T00:00:00Z", + "updated_at": "2026-03-09T00:00:00Z", +} + +MESSAGE_DICT = { + "id": "bbbb2222-0000-0000-0000-000000000001", + "mailbox_id": "aaaa1111-0000-0000-0000-000000000001", + "thread_id": "eeee5555-0000-0000-0000-000000000001", + "message_id": "", + "from_address": "user@example.com", + "to_addresses": ["agent01@inkbox.ai"], + "cc_addresses": None, + "subject": "Hello from test", + "snippet": "Hi there, this is a test message...", + "direction": "inbound", + "status": "delivered", + "is_read": False, + "is_starred": False, + "has_attachments": False, + "created_at": "2026-03-09T00:00:00Z", +} + +MESSAGE_DETAIL_DICT = { + **MESSAGE_DICT, + "body_text": "Hi there, this is a test message body.", + "body_html": "

Hi there, this is a test message body.

", + "bcc_addresses": None, + "in_reply_to": None, + "references": None, + "attachment_metadata": None, + "ses_message_id": "ses-abc123", + "updated_at": "2026-03-09T00:00:00Z", +} + +THREAD_DICT = { + "id": "eeee5555-0000-0000-0000-000000000001", + "mailbox_id": "aaaa1111-0000-0000-0000-000000000001", + "subject": "Hello from test", + "status": "active", + "message_count": 2, + "last_message_at": "2026-03-09T00:05:00Z", + "created_at": "2026-03-09T00:00:00Z", +} + +THREAD_DETAIL_DICT = { + **THREAD_DICT, + "messages": [MESSAGE_DICT], +} + +WEBHOOK_DICT = { + "id": "dddd4444-0000-0000-0000-000000000001", + "mailbox_id": "aaaa1111-0000-0000-0000-000000000001", + "url": "https://example.com/hooks/mail", + "event_types": ["message.received"], + "status": "active", + "created_at": "2026-03-09T00:00:00Z", +} + +WEBHOOK_CREATE_DICT = { + **WEBHOOK_DICT, + "secret": "test-hmac-secret-mail-abc123", +} + +CURSOR_PAGE_MESSAGES = { + "items": [MESSAGE_DICT], + "next_cursor": None, + "has_more": False, +} + +CURSOR_PAGE_MESSAGES_MULTI = { + "items": [MESSAGE_DICT], + "next_cursor": "cursor-abc", + "has_more": True, +} + +CURSOR_PAGE_THREADS = { + "items": [THREAD_DICT], + "next_cursor": None, + "has_more": False, +} + +CURSOR_PAGE_SEARCH = { + "items": [MESSAGE_DICT], + "next_cursor": None, + "has_more": False, +} diff --git a/python/tests/test_calls.py b/python/tests/test_calls.py new file mode 100644 index 0000000..12dd885 --- /dev/null +++ b/python/tests/test_calls.py @@ -0,0 +1,113 @@ +"""Tests for CallsResource.""" + +from uuid import UUID + +from sample_data import PHONE_CALL_DICT + + +NUM_ID = "aaaa1111-0000-0000-0000-000000000001" +CALL_ID = "bbbb2222-0000-0000-0000-000000000001" + + +class TestCallsList: + def test_returns_calls(self, client, transport): + transport.get.return_value = [PHONE_CALL_DICT] + + calls = client.calls.list(NUM_ID, limit=5) + + transport.get.assert_called_once_with( + f"/numbers/{NUM_ID}/calls", + params={"limit": 5, "offset": 0}, + ) + assert len(calls) == 1 + assert calls[0].direction == "outbound" + assert calls[0].remote_phone_number == "+15167251294" + + def test_default_limit_and_offset(self, client, transport): + transport.get.return_value = [] + + client.calls.list(NUM_ID) + + transport.get.assert_called_once_with( + f"/numbers/{NUM_ID}/calls", + params={"limit": 50, "offset": 0}, + ) + + def test_custom_offset(self, client, transport): + transport.get.return_value = [] + + client.calls.list(NUM_ID, limit=10, offset=20) + + transport.get.assert_called_once_with( + f"/numbers/{NUM_ID}/calls", + params={"limit": 10, "offset": 20}, + ) + + +class TestCallsGet: + def test_returns_call(self, client, transport): + transport.get.return_value = PHONE_CALL_DICT + + call = client.calls.get(NUM_ID, CALL_ID) + + transport.get.assert_called_once_with(f"/numbers/{NUM_ID}/calls/{CALL_ID}") + assert call.id == UUID(CALL_ID) + assert call.status == "completed" + assert call.pipeline_mode == "client_llm_only" + assert call.stream_url == "wss://agent.example.com/ws" + assert call.started_at is not None + assert call.ended_at is not None + + +class TestCallsPlace: + def test_place_outbound_call(self, client, transport): + transport.post.return_value = { + **PHONE_CALL_DICT, + "status": "ringing", + "started_at": None, + "ended_at": None, + } + + call = client.calls.place( + from_number="+18335794607", + to_number="+15167251294", + stream_url="wss://agent.example.com/ws", + ) + + transport.post.assert_called_once_with( + "/place-call", + json={ + "from_number": "+18335794607", + "to_number": "+15167251294", + "stream_url": "wss://agent.example.com/ws", + }, + ) + assert call.status == "ringing" + + def test_place_with_call_mode_and_webhook(self, client, transport): + transport.post.return_value = PHONE_CALL_DICT + + client.calls.place( + from_number="+18335794607", + to_number="+15167251294", + stream_url="wss://agent.example.com/ws", + call_mode="client_llm_tts_stt", + webhook_url="https://example.com/hook", + ) + + _, kwargs = transport.post.call_args + assert kwargs["json"]["call_mode"] == "client_llm_tts_stt" + assert kwargs["json"]["webhook_url"] == "https://example.com/hook" + + def test_optional_fields_omitted_when_none(self, client, transport): + transport.post.return_value = PHONE_CALL_DICT + + client.calls.place( + from_number="+18335794607", + to_number="+15167251294", + stream_url="wss://agent.example.com/ws", + ) + + _, kwargs = transport.post.call_args + assert "call_mode" not in kwargs["json"] + assert "webhook_url" not in kwargs["json"] diff --git a/python/tests/test_client.py b/python/tests/test_client.py new file mode 100644 index 0000000..89b412f --- /dev/null +++ b/python/tests/test_client.py @@ -0,0 +1,26 @@ +"""Tests for InkboxPhone client.""" + +from inkbox.phone import InkboxPhone +from inkbox.phone.resources.numbers import PhoneNumbersResource +from inkbox.phone.resources.calls import CallsResource +from inkbox.phone.resources.transcripts import TranscriptsResource +from inkbox.phone.resources.webhooks import PhoneWebhooksResource + + +class TestInkboxPhoneClient: + def test_creates_resource_instances(self): + client = InkboxPhone(api_key="sk-test") + + assert isinstance(client.numbers, PhoneNumbersResource) + assert isinstance(client.calls, CallsResource) + assert isinstance(client.transcripts, TranscriptsResource) + assert isinstance(client.webhooks, PhoneWebhooksResource) + + def test_context_manager(self): + with InkboxPhone(api_key="sk-test") as client: + assert isinstance(client, InkboxPhone) + + def test_custom_base_url(self): + client = InkboxPhone(api_key="sk-test", base_url="http://localhost:8000") + assert client._http._client.base_url == "http://localhost:8000" + client.close() diff --git a/python/tests/test_mail_client.py b/python/tests/test_mail_client.py new file mode 100644 index 0000000..6215b78 --- /dev/null +++ b/python/tests/test_mail_client.py @@ -0,0 +1,27 @@ +"""Tests for InkboxMail client.""" + +from inkbox.mail import InkboxMail +from inkbox.mail.resources.mailboxes import MailboxesResource +from inkbox.mail.resources.messages import MessagesResource +from inkbox.mail.resources.threads import ThreadsResource +from inkbox.mail.resources.webhooks import WebhooksResource + + +class TestInkboxMailClient: + def test_creates_resource_instances(self): + client = InkboxMail(api_key="sk-test") + + assert isinstance(client.mailboxes, MailboxesResource) + assert isinstance(client.messages, MessagesResource) + assert isinstance(client.threads, ThreadsResource) + assert isinstance(client.webhooks, WebhooksResource) + client.close() + + def test_context_manager(self): + with InkboxMail(api_key="sk-test") as client: + assert isinstance(client, InkboxMail) + + def test_custom_base_url(self): + client = InkboxMail(api_key="sk-test", base_url="http://localhost:8000") + assert client._http._client.base_url == "http://localhost:8000" + client.close() diff --git a/python/tests/test_mail_mailboxes.py b/python/tests/test_mail_mailboxes.py new file mode 100644 index 0000000..2d13a1d --- /dev/null +++ b/python/tests/test_mail_mailboxes.py @@ -0,0 +1,104 @@ +"""Tests for MailboxesResource.""" + +from unittest.mock import MagicMock +from uuid import UUID + +from sample_data_mail import MAILBOX_DICT, CURSOR_PAGE_SEARCH, MESSAGE_DICT +from inkbox.mail.resources.mailboxes import MailboxesResource + + +def _resource(): + http = MagicMock() + return MailboxesResource(http), http + + +class TestMailboxesList: + def test_returns_mailboxes(self): + res, http = _resource() + http.get.return_value = [MAILBOX_DICT] + + mailboxes = res.list() + + http.get.assert_called_once_with("/mailboxes") + assert len(mailboxes) == 1 + assert mailboxes[0].email_address == "agent01@inkbox.ai" + + def test_empty_list(self): + res, http = _resource() + http.get.return_value = [] + + assert res.list() == [] + + +class TestMailboxesGet: + def test_returns_mailbox(self): + res, http = _resource() + http.get.return_value = MAILBOX_DICT + uid = "aaaa1111-0000-0000-0000-000000000001" + + mailbox = res.get(uid) + + http.get.assert_called_once_with(f"/mailboxes/{uid}") + assert mailbox.id == UUID(uid) + assert mailbox.display_name == "Agent 01" + + +class TestMailboxesCreate: + def test_create_with_display_name(self): + res, http = _resource() + http.post.return_value = MAILBOX_DICT + + mailbox = res.create(display_name="Agent 01") + + http.post.assert_called_once_with( + "/mailboxes", json={"display_name": "Agent 01"} + ) + assert mailbox.display_name == "Agent 01" + + def test_create_without_display_name(self): + res, http = _resource() + http.post.return_value = {**MAILBOX_DICT, "display_name": None} + + mailbox = res.create() + + http.post.assert_called_once_with("/mailboxes", json={}) + assert mailbox.display_name is None + + +class TestMailboxesDelete: + def test_deletes_mailbox(self): + res, http = _resource() + uid = "aaaa1111-0000-0000-0000-000000000001" + + res.delete(uid) + + http.delete.assert_called_once_with(f"/mailboxes/{uid}") + + +class TestMailboxesSearch: + def test_search_returns_messages(self): + res, http = _resource() + http.get.return_value = CURSOR_PAGE_SEARCH + uid = "aaaa1111-0000-0000-0000-000000000001" + + results = res.search(uid, q="invoice") + + http.get.assert_called_once_with( + f"/mailboxes/{uid}/search", + params={"q": "invoice", "limit": 50}, + ) + assert len(results) == 1 + assert results[0].subject == "Hello from test" + + def test_search_with_custom_limit(self): + res, http = _resource() + http.get.return_value = {"items": [], "next_cursor": None, "has_more": False} + uid = "aaaa1111-0000-0000-0000-000000000001" + + results = res.search(uid, q="test", limit=10) + + http.get.assert_called_once_with( + f"/mailboxes/{uid}/search", + params={"q": "test", "limit": 10}, + ) + assert results == [] diff --git a/python/tests/test_mail_messages.py b/python/tests/test_mail_messages.py new file mode 100644 index 0000000..813e684 --- /dev/null +++ b/python/tests/test_mail_messages.py @@ -0,0 +1,176 @@ +"""Tests for MessagesResource.""" + +from unittest.mock import MagicMock + +from sample_data_mail import ( + MESSAGE_DICT, + MESSAGE_DETAIL_DICT, + CURSOR_PAGE_MESSAGES, + CURSOR_PAGE_MESSAGES_MULTI, +) +from inkbox.mail.resources.messages import MessagesResource + + +MBOX = "aaaa1111-0000-0000-0000-000000000001" +MSG = "bbbb2222-0000-0000-0000-000000000001" + + +def _resource(): + http = MagicMock() + return MessagesResource(http), http + + +class TestMessagesList: + def test_iterates_single_page(self): + res, http = _resource() + http.get.return_value = CURSOR_PAGE_MESSAGES + + messages = list(res.list(MBOX)) + + assert len(messages) == 1 + assert messages[0].subject == "Hello from test" + + def test_iterates_multiple_pages(self): + res, http = _resource() + page2 = {"items": [MESSAGE_DICT], "next_cursor": None, "has_more": False} + http.get.side_effect = [CURSOR_PAGE_MESSAGES_MULTI, page2] + + messages = list(res.list(MBOX)) + + assert len(messages) == 2 + assert http.get.call_count == 2 + + def test_empty_page(self): + res, http = _resource() + http.get.return_value = {"items": [], "next_cursor": None, "has_more": False} + + messages = list(res.list(MBOX)) + + assert messages == [] + + +class TestMessagesGet: + def test_returns_message_detail(self): + res, http = _resource() + http.get.return_value = MESSAGE_DETAIL_DICT + + detail = res.get(MBOX, MSG) + + http.get.assert_called_once_with(f"/mailboxes/{MBOX}/messages/{MSG}") + assert detail.body_text == "Hi there, this is a test message body." + assert detail.ses_message_id == "ses-abc123" + + +class TestMessagesSend: + def test_send_basic(self): + res, http = _resource() + http.post.return_value = MESSAGE_DICT + + msg = res.send(MBOX, to=["user@example.com"], subject="Test") + + http.post.assert_called_once_with( + f"/mailboxes/{MBOX}/messages", + json={ + "recipients": {"to": ["user@example.com"]}, + "subject": "Test", + }, + ) + assert msg.subject == "Hello from test" + + def test_send_with_all_options(self): + res, http = _resource() + http.post.return_value = MESSAGE_DICT + + res.send( + MBOX, + to=["a@b.com"], + subject="Re: test", + body_text="reply text", + body_html="

reply

", + cc=["cc@b.com"], + bcc=["bcc@b.com"], + in_reply_to_message_id="", + attachments=[{"filename": "f.txt", "content_type": "text/plain", "content_base64": "aGk="}], + ) + + _, kwargs = http.post.call_args + body = kwargs["json"] + assert body["recipients"] == {"to": ["a@b.com"], "cc": ["cc@b.com"], "bcc": ["bcc@b.com"]} + assert body["body_text"] == "reply text" + assert body["body_html"] == "

reply

" + assert body["in_reply_to_message_id"] == "" + assert body["attachments"] == [{"filename": "f.txt", "content_type": "text/plain", "content_base64": "aGk="}] + + def test_optional_fields_omitted(self): + res, http = _resource() + http.post.return_value = MESSAGE_DICT + + res.send(MBOX, to=["a@b.com"], subject="Test") + + _, kwargs = http.post.call_args + body = kwargs["json"] + assert "body_text" not in body + assert "body_html" not in body + assert "cc" not in body["recipients"] + assert "bcc" not in body["recipients"] + assert "in_reply_to_message_id" not in body + assert "attachments" not in body + + +class TestMessagesUpdateFlags: + def test_mark_read(self): + res, http = _resource() + http.patch.return_value = {**MESSAGE_DICT, "is_read": True} + + msg = res.mark_read(MBOX, MSG) + + http.patch.assert_called_once_with( + f"/mailboxes/{MBOX}/messages/{MSG}", + json={"is_read": True}, + ) + assert msg.is_read is True + + def test_mark_unread(self): + res, http = _resource() + http.patch.return_value = MESSAGE_DICT + + res.mark_unread(MBOX, MSG) + + _, kwargs = http.patch.call_args + assert kwargs["json"] == {"is_read": False} + + def test_star(self): + res, http = _resource() + http.patch.return_value = {**MESSAGE_DICT, "is_starred": True} + + msg = res.star(MBOX, MSG) + + _, kwargs = http.patch.call_args + assert kwargs["json"] == {"is_starred": True} + + def test_unstar(self): + res, http = _resource() + http.patch.return_value = MESSAGE_DICT + + res.unstar(MBOX, MSG) + + _, kwargs = http.patch.call_args + assert kwargs["json"] == {"is_starred": False} + + def test_update_both_flags(self): + res, http = _resource() + http.patch.return_value = MESSAGE_DICT + + res.update_flags(MBOX, MSG, is_read=True, is_starred=True) + + _, kwargs = http.patch.call_args + assert kwargs["json"] == {"is_read": True, "is_starred": True} + + +class TestMessagesDelete: + def test_deletes_message(self): + res, http = _resource() + + res.delete(MBOX, MSG) + + http.delete.assert_called_once_with(f"/mailboxes/{MBOX}/messages/{MSG}") diff --git a/python/tests/test_mail_threads.py b/python/tests/test_mail_threads.py new file mode 100644 index 0000000..ce3cb5b --- /dev/null +++ b/python/tests/test_mail_threads.py @@ -0,0 +1,67 @@ +"""Tests for ThreadsResource.""" + +from unittest.mock import MagicMock + +from sample_data_mail import THREAD_DICT, THREAD_DETAIL_DICT, CURSOR_PAGE_THREADS +from inkbox.mail.resources.threads import ThreadsResource + + +MBOX = "aaaa1111-0000-0000-0000-000000000001" +THREAD_ID = "eeee5555-0000-0000-0000-000000000001" + + +def _resource(): + http = MagicMock() + return ThreadsResource(http), http + + +class TestThreadsList: + def test_iterates_single_page(self): + res, http = _resource() + http.get.return_value = CURSOR_PAGE_THREADS + + threads = list(res.list(MBOX)) + + assert len(threads) == 1 + assert threads[0].subject == "Hello from test" + assert threads[0].message_count == 2 + + def test_empty_page(self): + res, http = _resource() + http.get.return_value = {"items": [], "next_cursor": None, "has_more": False} + + threads = list(res.list(MBOX)) + + assert threads == [] + + def test_multi_page(self): + res, http = _resource() + page1 = {"items": [THREAD_DICT], "next_cursor": "cur1", "has_more": True} + page2 = {"items": [THREAD_DICT], "next_cursor": None, "has_more": False} + http.get.side_effect = [page1, page2] + + threads = list(res.list(MBOX)) + + assert len(threads) == 2 + assert http.get.call_count == 2 + + +class TestThreadsGet: + def test_returns_thread_detail(self): + res, http = _resource() + http.get.return_value = THREAD_DETAIL_DICT + + detail = res.get(MBOX, THREAD_ID) + + http.get.assert_called_once_with(f"/mailboxes/{MBOX}/threads/{THREAD_ID}") + assert detail.subject == "Hello from test" + assert len(detail.messages) == 1 + + +class TestThreadsDelete: + def test_deletes_thread(self): + res, http = _resource() + + res.delete(MBOX, THREAD_ID) + + http.delete.assert_called_once_with(f"/mailboxes/{MBOX}/threads/{THREAD_ID}") diff --git a/python/tests/test_mail_types.py b/python/tests/test_mail_types.py new file mode 100644 index 0000000..2498472 --- /dev/null +++ b/python/tests/test_mail_types.py @@ -0,0 +1,121 @@ +"""Tests for mail type parsing.""" + +from datetime import datetime +from uuid import UUID + +from sample_data_mail import ( + MAILBOX_DICT, + MESSAGE_DICT, + MESSAGE_DETAIL_DICT, + THREAD_DICT, + THREAD_DETAIL_DICT, + WEBHOOK_DICT, + WEBHOOK_CREATE_DICT, +) +from inkbox.mail.types import ( + Mailbox, + Message, + MessageDetail, + Thread, + ThreadDetail, + Webhook, + WebhookCreateResult, +) + + +class TestMailboxParsing: + def test_from_dict(self): + m = Mailbox._from_dict(MAILBOX_DICT) + + assert isinstance(m.id, UUID) + assert m.email_address == "agent01@inkbox.ai" + assert m.display_name == "Agent 01" + assert m.status == "active" + assert isinstance(m.created_at, datetime) + assert isinstance(m.updated_at, datetime) + + +class TestMessageParsing: + def test_from_dict(self): + m = Message._from_dict(MESSAGE_DICT) + + assert isinstance(m.id, UUID) + assert isinstance(m.mailbox_id, UUID) + assert m.thread_id == UUID("eeee5555-0000-0000-0000-000000000001") + assert m.message_id == "" + assert m.from_address == "user@example.com" + assert m.to_addresses == ["agent01@inkbox.ai"] + assert m.cc_addresses is None + assert m.subject == "Hello from test" + assert m.direction == "inbound" + assert m.is_read is False + assert m.is_starred is False + assert m.has_attachments is False + assert isinstance(m.created_at, datetime) + + def test_null_thread_id(self): + d = {**MESSAGE_DICT, "thread_id": None} + m = Message._from_dict(d) + assert m.thread_id is None + + +class TestMessageDetailParsing: + def test_from_dict(self): + m = MessageDetail._from_dict(MESSAGE_DETAIL_DICT) + + assert m.body_text == "Hi there, this is a test message body." + assert m.body_html == "

Hi there, this is a test message body.

" + assert m.bcc_addresses is None + assert m.in_reply_to is None + assert m.references is None + assert m.attachment_metadata is None + assert m.ses_message_id == "ses-abc123" + assert isinstance(m.updated_at, datetime) + # inherits base fields + assert m.from_address == "user@example.com" + assert m.subject == "Hello from test" + + +class TestThreadParsing: + def test_from_dict(self): + t = Thread._from_dict(THREAD_DICT) + + assert isinstance(t.id, UUID) + assert isinstance(t.mailbox_id, UUID) + assert t.subject == "Hello from test" + assert t.status == "active" + assert t.message_count == 2 + assert isinstance(t.last_message_at, datetime) + assert isinstance(t.created_at, datetime) + + +class TestThreadDetailParsing: + def test_from_dict(self): + t = ThreadDetail._from_dict(THREAD_DETAIL_DICT) + + assert t.subject == "Hello from test" + assert len(t.messages) == 1 + assert isinstance(t.messages[0], Message) + assert t.messages[0].from_address == "user@example.com" + + def test_empty_messages(self): + d = {**THREAD_DICT, "messages": []} + t = ThreadDetail._from_dict(d) + assert t.messages == [] + + +class TestWebhookParsing: + def test_from_dict(self): + w = Webhook._from_dict(WEBHOOK_DICT) + + assert isinstance(w.id, UUID) + assert isinstance(w.mailbox_id, UUID) + assert w.url == "https://example.com/hooks/mail" + assert w.event_types == ["message.received"] + assert w.status == "active" + + def test_create_result_includes_secret(self): + w = WebhookCreateResult._from_dict(WEBHOOK_CREATE_DICT) + + assert w.secret == "test-hmac-secret-mail-abc123" + assert w.url == "https://example.com/hooks/mail" diff --git a/python/tests/test_mail_webhooks.py b/python/tests/test_mail_webhooks.py new file mode 100644 index 0000000..21594b1 --- /dev/null +++ b/python/tests/test_mail_webhooks.py @@ -0,0 +1,58 @@ +"""Tests for mail WebhooksResource.""" + +from unittest.mock import MagicMock +from uuid import UUID + +from sample_data_mail import WEBHOOK_DICT, WEBHOOK_CREATE_DICT +from inkbox.mail.resources.webhooks import WebhooksResource + + +MBOX = "aaaa1111-0000-0000-0000-000000000001" +WH_ID = "dddd4444-0000-0000-0000-000000000001" + + +def _resource(): + http = MagicMock() + return WebhooksResource(http), http + + +class TestWebhooksCreate: + def test_creates_webhook_with_secret(self): + res, http = _resource() + http.post.return_value = WEBHOOK_CREATE_DICT + + hook = res.create(MBOX, url="https://example.com/hooks/mail", event_types=["message.received"]) + + http.post.assert_called_once_with( + f"/mailboxes/{MBOX}/webhooks", + json={"url": "https://example.com/hooks/mail", "event_types": ["message.received"]}, + ) + assert hook.secret == "test-hmac-secret-mail-abc123" + assert hook.url == "https://example.com/hooks/mail" + + +class TestWebhooksList: + def test_returns_webhooks(self): + res, http = _resource() + http.get.return_value = [WEBHOOK_DICT] + + webhooks = res.list(MBOX) + + http.get.assert_called_once_with(f"/mailboxes/{MBOX}/webhooks") + assert len(webhooks) == 1 + assert webhooks[0].id == UUID(WH_ID) + + def test_empty_list(self): + res, http = _resource() + http.get.return_value = [] + + assert res.list(MBOX) == [] + + +class TestWebhooksDelete: + def test_deletes_webhook(self): + res, http = _resource() + + res.delete(MBOX, WH_ID) + + http.delete.assert_called_once_with(f"/mailboxes/{MBOX}/webhooks/{WH_ID}") diff --git a/python/tests/test_numbers.py b/python/tests/test_numbers.py new file mode 100644 index 0000000..78df43d --- /dev/null +++ b/python/tests/test_numbers.py @@ -0,0 +1,166 @@ +"""Tests for PhoneNumbersResource.""" + +from uuid import UUID + +from sample_data import PHONE_NUMBER_DICT, PHONE_TRANSCRIPT_DICT + + +class TestNumbersList: + def test_returns_list_of_phone_numbers(self, client, transport): + transport.get.return_value = [PHONE_NUMBER_DICT] + + numbers = client.numbers.list() + + transport.get.assert_called_once_with("/numbers") + assert len(numbers) == 1 + assert numbers[0].number == "+18335794607" + assert numbers[0].type == "toll_free" + assert numbers[0].status == "active" + assert numbers[0].default_pipeline_mode == "client_llm_only" + + def test_empty_list(self, client, transport): + transport.get.return_value = [] + + numbers = client.numbers.list() + + assert numbers == [] + + +class TestNumbersGet: + def test_returns_phone_number(self, client, transport): + transport.get.return_value = PHONE_NUMBER_DICT + uid = "aaaa1111-0000-0000-0000-000000000001" + + number = client.numbers.get(uid) + + transport.get.assert_called_once_with(f"/numbers/{uid}") + assert number.id == UUID(uid) + assert number.number == "+18335794607" + assert number.incoming_call_action == "auto_reject" + + +class TestNumbersUpdate: + def test_update_incoming_call_action(self, client, transport): + updated = {**PHONE_NUMBER_DICT, "incoming_call_action": "webhook"} + transport.patch.return_value = updated + uid = "aaaa1111-0000-0000-0000-000000000001" + + result = client.numbers.update(uid, incoming_call_action="webhook") + + transport.patch.assert_called_once_with( + f"/numbers/{uid}", + json={"incoming_call_action": "webhook"}, + ) + assert result.incoming_call_action == "webhook" + + def test_update_multiple_fields(self, client, transport): + updated = { + **PHONE_NUMBER_DICT, + "incoming_call_action": "auto_accept", + "default_stream_url": "wss://agent.example.com/ws", + "default_pipeline_mode": "client_llm_tts_stt", + } + transport.patch.return_value = updated + uid = "aaaa1111-0000-0000-0000-000000000001" + + result = client.numbers.update( + uid, + incoming_call_action="auto_accept", + default_stream_url="wss://agent.example.com/ws", + default_pipeline_mode="client_llm_tts_stt", + ) + + transport.patch.assert_called_once_with( + f"/numbers/{uid}", + json={ + "incoming_call_action": "auto_accept", + "default_stream_url": "wss://agent.example.com/ws", + "default_pipeline_mode": "client_llm_tts_stt", + }, + ) + assert result.default_stream_url == "wss://agent.example.com/ws" + assert result.default_pipeline_mode == "client_llm_tts_stt" + + def test_omitted_fields_not_sent(self, client, transport): + transport.patch.return_value = PHONE_NUMBER_DICT + uid = "aaaa1111-0000-0000-0000-000000000001" + + client.numbers.update(uid, incoming_call_action="auto_reject") + + _, kwargs = transport.patch.call_args + assert "default_stream_url" not in kwargs["json"] + assert "default_pipeline_mode" not in kwargs["json"] + + +class TestNumbersProvision: + def test_provision_toll_free(self, client, transport): + transport.post.return_value = PHONE_NUMBER_DICT + + number = client.numbers.provision(type="toll_free") + + transport.post.assert_called_once_with( + "/numbers/provision", + json={"type": "toll_free"}, + ) + assert number.type == "toll_free" + + def test_provision_local_with_state(self, client, transport): + local = {**PHONE_NUMBER_DICT, "type": "local", "number": "+12125551234"} + transport.post.return_value = local + + number = client.numbers.provision(type="local", state="NY") + + transport.post.assert_called_once_with( + "/numbers/provision", + json={"type": "local", "state": "NY"}, + ) + assert number.type == "local" + + def test_provision_defaults_to_toll_free(self, client, transport): + transport.post.return_value = PHONE_NUMBER_DICT + + client.numbers.provision() + + _, kwargs = transport.post.call_args + assert kwargs["json"]["type"] == "toll_free" + + +class TestNumbersRelease: + def test_release_posts_number(self, client, transport): + transport.post.return_value = None + + client.numbers.release(number="+18335794607") + + transport.post.assert_called_once_with( + "/numbers/release", + json={"number": "+18335794607"}, + ) + + +class TestNumbersSearchTranscripts: + def test_search_with_query(self, client, transport): + transport.get.return_value = [PHONE_TRANSCRIPT_DICT] + uid = "aaaa1111-0000-0000-0000-000000000001" + + results = client.numbers.search_transcripts(uid, q="hello") + + transport.get.assert_called_once_with( + f"/numbers/{uid}/search", + params={"q": "hello", "party": None, "limit": 50}, + ) + assert len(results) == 1 + assert results[0].text == "Hello, how can I help you?" + + def test_search_with_party_and_limit(self, client, transport): + transport.get.return_value = [] + uid = "aaaa1111-0000-0000-0000-000000000001" + + results = client.numbers.search_transcripts( + uid, q="test", party="remote", limit=10 + ) + + transport.get.assert_called_once_with( + f"/numbers/{uid}/search", + params={"q": "test", "party": "remote", "limit": 10}, + ) + assert results == [] diff --git a/python/tests/test_transcripts.py b/python/tests/test_transcripts.py new file mode 100644 index 0000000..db4f479 --- /dev/null +++ b/python/tests/test_transcripts.py @@ -0,0 +1,43 @@ +"""Tests for TranscriptsResource.""" + +from uuid import UUID + +from sample_data import PHONE_TRANSCRIPT_DICT + + +NUM_ID = "aaaa1111-0000-0000-0000-000000000001" +CALL_ID = "bbbb2222-0000-0000-0000-000000000001" + + +class TestTranscriptsList: + def test_returns_transcripts(self, client, transport): + second = { + **PHONE_TRANSCRIPT_DICT, + "id": "cccc3333-0000-0000-0000-000000000002", + "seq": 1, + "ts_ms": 3000, + "party": "remote", + "text": "I need help with my account.", + } + transport.get.return_value = [PHONE_TRANSCRIPT_DICT, second] + + transcripts = client.transcripts.list(NUM_ID, CALL_ID) + + transport.get.assert_called_once_with( + f"/numbers/{NUM_ID}/calls/{CALL_ID}/transcripts", + ) + assert len(transcripts) == 2 + assert transcripts[0].seq == 0 + assert transcripts[0].party == "local" + assert transcripts[0].text == "Hello, how can I help you?" + assert transcripts[0].ts_ms == 1500 + assert transcripts[0].call_id == UUID(CALL_ID) + assert transcripts[1].seq == 1 + assert transcripts[1].party == "remote" + + def test_empty_transcripts(self, client, transport): + transport.get.return_value = [] + + transcripts = client.transcripts.list(NUM_ID, CALL_ID) + + assert transcripts == [] diff --git a/python/tests/test_types.py b/python/tests/test_types.py new file mode 100644 index 0000000..c1453a5 --- /dev/null +++ b/python/tests/test_types.py @@ -0,0 +1,96 @@ +"""Tests for type parsing.""" + +from datetime import datetime +from uuid import UUID + +from sample_data import ( + PHONE_NUMBER_DICT, + PHONE_CALL_DICT, + PHONE_TRANSCRIPT_DICT, + PHONE_WEBHOOK_DICT, + PHONE_WEBHOOK_CREATE_DICT, +) +from inkbox.phone.types import ( + PhoneNumber, + PhoneCall, + PhoneTranscript, + PhoneWebhook, + PhoneWebhookCreateResult, +) + + +class TestPhoneNumberParsing: + def test_from_dict(self): + n = PhoneNumber._from_dict(PHONE_NUMBER_DICT) + + assert isinstance(n.id, UUID) + assert n.number == "+18335794607" + assert n.type == "toll_free" + assert n.status == "active" + assert n.incoming_call_action == "auto_reject" + assert n.default_stream_url is None + assert n.default_pipeline_mode == "client_llm_only" + assert isinstance(n.created_at, datetime) + assert isinstance(n.updated_at, datetime) + + def test_default_pipeline_mode_when_missing(self): + d = {**PHONE_NUMBER_DICT} + del d["default_pipeline_mode"] + + n = PhoneNumber._from_dict(d) + + assert n.default_pipeline_mode == "client_llm_only" + + +class TestPhoneCallParsing: + def test_from_dict(self): + c = PhoneCall._from_dict(PHONE_CALL_DICT) + + assert isinstance(c.id, UUID) + assert c.local_phone_number == "+18335794607" + assert c.remote_phone_number == "+15167251294" + assert c.direction == "outbound" + assert c.status == "completed" + assert c.pipeline_mode == "client_llm_only" + assert c.stream_url == "wss://agent.example.com/ws" + assert isinstance(c.started_at, datetime) + assert isinstance(c.ended_at, datetime) + + def test_nullable_timestamps(self): + d = {**PHONE_CALL_DICT, "started_at": None, "ended_at": None} + + c = PhoneCall._from_dict(d) + + assert c.started_at is None + assert c.ended_at is None + + +class TestPhoneTranscriptParsing: + def test_from_dict(self): + t = PhoneTranscript._from_dict(PHONE_TRANSCRIPT_DICT) + + assert isinstance(t.id, UUID) + assert isinstance(t.call_id, UUID) + assert t.seq == 0 + assert t.ts_ms == 1500 + assert t.party == "local" + assert t.text == "Hello, how can I help you?" + assert isinstance(t.created_at, datetime) + + +class TestPhoneWebhookParsing: + def test_from_dict(self): + w = PhoneWebhook._from_dict(PHONE_WEBHOOK_DICT) + + assert isinstance(w.id, UUID) + assert isinstance(w.source_id, UUID) + assert w.source_type == "phone_number" + assert w.url == "https://example.com/webhooks/phone" + assert w.event_types == ["incoming_call"] + assert w.status == "active" + + def test_create_result_includes_secret(self): + w = PhoneWebhookCreateResult._from_dict(PHONE_WEBHOOK_CREATE_DICT) + + assert w.secret == "test-hmac-secret-abc123" + assert w.url == "https://example.com/webhooks/phone" diff --git a/python/tests/test_webhooks.py b/python/tests/test_webhooks.py new file mode 100644 index 0000000..965c8d9 --- /dev/null +++ b/python/tests/test_webhooks.py @@ -0,0 +1,96 @@ +"""Tests for PhoneWebhooksResource.""" + +from uuid import UUID + +from sample_data import PHONE_WEBHOOK_DICT, PHONE_WEBHOOK_CREATE_DICT + + +NUM_ID = "aaaa1111-0000-0000-0000-000000000001" +WH_ID = "dddd4444-0000-0000-0000-000000000001" + + +class TestWebhooksCreate: + def test_creates_webhook_with_secret(self, client, transport): + transport.post.return_value = PHONE_WEBHOOK_CREATE_DICT + + hook = client.webhooks.create( + NUM_ID, + url="https://example.com/webhooks/phone", + event_types=["incoming_call"], + ) + + transport.post.assert_called_once_with( + f"/numbers/{NUM_ID}/webhooks", + json={ + "url": "https://example.com/webhooks/phone", + "event_types": ["incoming_call"], + }, + ) + assert hook.secret == "test-hmac-secret-abc123" + assert hook.url == "https://example.com/webhooks/phone" + assert hook.source_type == "phone_number" + assert hook.event_types == ["incoming_call"] + + +class TestWebhooksList: + def test_returns_webhooks(self, client, transport): + transport.get.return_value = [PHONE_WEBHOOK_DICT] + + webhooks = client.webhooks.list(NUM_ID) + + transport.get.assert_called_once_with(f"/numbers/{NUM_ID}/webhooks") + assert len(webhooks) == 1 + assert webhooks[0].id == UUID(WH_ID) + assert webhooks[0].status == "active" + + def test_empty_list(self, client, transport): + transport.get.return_value = [] + + webhooks = client.webhooks.list(NUM_ID) + + assert webhooks == [] + + +class TestWebhooksUpdate: + def test_update_url(self, client, transport): + updated = {**PHONE_WEBHOOK_DICT, "url": "https://new.example.com/hook"} + transport.patch.return_value = updated + + result = client.webhooks.update( + NUM_ID, WH_ID, url="https://new.example.com/hook" + ) + + transport.patch.assert_called_once_with( + f"/numbers/{NUM_ID}/webhooks/{WH_ID}", + json={"url": "https://new.example.com/hook"}, + ) + assert result.url == "https://new.example.com/hook" + + def test_update_event_types(self, client, transport): + updated = {**PHONE_WEBHOOK_DICT, "event_types": ["incoming_call", "message.received"]} + transport.patch.return_value = updated + + result = client.webhooks.update( + NUM_ID, WH_ID, event_types=["incoming_call", "message.received"] + ) + + _, kwargs = transport.patch.call_args + assert kwargs["json"] == {"event_types": ["incoming_call", "message.received"]} + assert result.event_types == ["incoming_call", "message.received"] + + def test_omitted_fields_not_sent(self, client, transport): + transport.patch.return_value = PHONE_WEBHOOK_DICT + + client.webhooks.update(NUM_ID, WH_ID, url="https://example.com/hook") + + _, kwargs = transport.patch.call_args + assert "event_types" not in kwargs["json"] + + +class TestWebhooksDelete: + def test_deletes_webhook(self, client, transport): + client.webhooks.delete(NUM_ID, WH_ID) + + transport.delete.assert_called_once_with( + f"/numbers/{NUM_ID}/webhooks/{WH_ID}" + ) diff --git a/python/uv.lock b/python/uv.lock new file mode 100644 index 0000000..616a310 --- /dev/null +++ b/python/uv.lock @@ -0,0 +1,373 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, + { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, + { url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" }, + { url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" }, + { url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" }, + { url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" }, + { url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" }, + { url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" }, + { url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" }, + { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, + { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, + { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, + { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, + { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, + { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, + { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, + { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, + { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, + { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, + { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, + { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, + { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, + { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, + { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, + { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, + { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, + { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, + { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "inkbox" +version = "0.1.0.2" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "python-dotenv" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" }, + { name = "python-dotenv", specifier = ">=1.2.2" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4" }, +] +provides-extras = ["dev"] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" }, + { url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" }, + { url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" }, + { url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" }, + { url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" }, + { url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" }, + { url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" }, + { url = "https://files.pythonhosted.org/packages/28/3a/950367aee7c69027f4f422059227b290ed780366b6aecee5de5039d50fa8/ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74", size = 10551676, upload-time = "2026-03-05T20:06:13.705Z" }, + { url = "https://files.pythonhosted.org/packages/b8/00/bf077a505b4e649bdd3c47ff8ec967735ce2544c8e4a43aba42ee9bf935d/ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe", size = 11678972, upload-time = "2026-03-05T20:06:45.379Z" }, + { url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] diff --git a/scripts/test_phone.py b/scripts/test_phone.py new file mode 100644 index 0000000..728d942 --- /dev/null +++ b/scripts/test_phone.py @@ -0,0 +1,141 @@ +""" +Quick smoke test for the Inkbox Phone Python SDK. + +Loads INKBOX_API_KEY from ../.env and exercises every endpoint. +""" + +import os +import sys +from pathlib import Path + +from dotenv import load_dotenv + +load_dotenv(Path(__file__).resolve().parent.parent / ".env") + +API_KEY = os.environ.get("INKBOX_API_KEY") +if not API_KEY: + print("ERROR: INKBOX_API_KEY not set in .env or environment") + sys.exit(1) + +from inkbox.phone import InkboxPhone + +def main(): + with InkboxPhone(api_key=API_KEY) as client: + + # --- Numbers --- + print("=== Listing phone numbers ===") + numbers = client.numbers.list() + for n in numbers: + print(f" {n.number} type={n.type} status={n.status} action={n.incoming_call_action}") + + if not numbers: + print("\nNo numbers found. Provisioning a toll-free number...") + number = client.numbers.provision(type="toll_free") + print(f" Provisioned: {number.number} (id={number.id})") + else: + number = numbers[0] + print(f"\nUsing first number: {number.number} (id={number.id})") + + print("\n=== Get phone number ===") + fetched = client.numbers.get(number.id) + print(f" {fetched.number} pipeline_mode={fetched.default_pipeline_mode}") + + print("\n=== Update phone number ===") + updated = client.numbers.update( + number.id, + incoming_call_action="auto_reject", + ) + print(f" incoming_call_action={updated.incoming_call_action}") + + # --- Calls --- + print("\n=== Listing calls ===") + calls = client.calls.list(number.id, limit=5) + for c in calls: + print(f" {c.id} {c.direction} {c.remote_phone_number} status={c.status}") + + if calls: + call = calls[0] + print(f"\n=== Get call {call.id} ===") + detail = client.calls.get(number.id, call.id) + print(f" {detail.direction} {detail.remote_phone_number} pipeline={detail.pipeline_mode}") + + # --- Transcripts (first call) --- + print(f"\n=== Listing transcripts for call {call.id} ===") + transcripts = client.transcripts.list(number.id, call.id) + for t in transcripts: + print(f" [{t.party}] seq={t.seq} ts={t.ts_ms}ms: {t.text[:80]}") + + # --- Outbound call transcripts --- + outbound = [c for c in calls if c.direction == "outbound"] + if outbound: + ob = outbound[0] + print(f"\n=== Get outbound call {ob.id} ===") + ob_detail = client.calls.get(number.id, ob.id) + print(f" {ob_detail.direction} {ob_detail.remote_phone_number} status={ob_detail.status}") + + print(f"\n=== Listing transcripts for outbound call {ob.id} ===") + ob_transcripts = client.transcripts.list(number.id, ob.id) + if ob_transcripts: + for t in ob_transcripts: + print(f" [{t.party}] seq={t.seq} ts={t.ts_ms}ms: {t.text[:80]}") + else: + print(" (no transcripts)") + else: + # Try fetching more calls to find an outbound one + all_calls = client.calls.list(number.id, limit=200) + outbound = [c for c in all_calls if c.direction == "outbound"] + if outbound: + ob = outbound[0] + print(f"\n=== Get outbound call {ob.id} ===") + ob_detail = client.calls.get(number.id, ob.id) + print(f" {ob_detail.direction} {ob_detail.remote_phone_number} status={ob_detail.status}") + + print(f"\n=== Listing transcripts for outbound call {ob.id} ===") + ob_transcripts = client.transcripts.list(number.id, ob.id) + if ob_transcripts: + for t in ob_transcripts: + print(f" [{t.party}] seq={t.seq} ts={t.ts_ms}ms: {t.text[:80]}") + else: + print(" (no transcripts)") + else: + print("\n (no outbound calls found)") + + # --- Search --- + print("\n=== Search transcripts ===") + results = client.numbers.search_transcripts(number.id, q="hello") + print(f" Found {len(results)} results") + for r in results[:3]: + print(f" [{r.party}] {r.text[:80]}") + + # --- Webhooks --- + print("\n=== Listing webhooks ===") + webhooks = client.webhooks.list(number.id) + for wh in webhooks: + print(f" {wh.id} url={wh.url} events={wh.event_types}") + + print("\n=== Creating webhook ===") + hook = client.webhooks.create( + number.id, + url="https://example.com/test-webhook", + event_types=["incoming_call"], + ) + print(f" Created: {hook.id}") + print(f" Secret: {hook.secret}") + + print("\n=== Updating webhook ===") + updated_hook = client.webhooks.update( + number.id, + hook.id, + url="https://example.com/updated-webhook", + ) + print(f" Updated URL: {updated_hook.url}") + + print("\n=== Deleting webhook ===") + client.webhooks.delete(number.id, hook.id) + print(" Deleted.") + + print("\nAll tests passed!") + + +if __name__ == "__main__": + main() diff --git a/scripts/test_phone.ts b/scripts/test_phone.ts new file mode 100644 index 0000000..542d127 --- /dev/null +++ b/scripts/test_phone.ts @@ -0,0 +1,150 @@ +/** + * Quick smoke test for the Inkbox Phone TypeScript SDK. + * + * Loads INKBOX_API_KEY from ../.env and exercises every endpoint. + */ + +import { readFileSync } from "fs"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; +import { InkboxPhone } from "../typescript/src/phone/index.js"; + +// Load .env from repo root +const __dirname = dirname(fileURLToPath(import.meta.url)); +const envFile = resolve(__dirname, "../.env"); +try { + const contents = readFileSync(envFile, "utf-8"); + for (const line of contents.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) continue; + const idx = trimmed.indexOf("="); + const key = trimmed.slice(0, idx).trim(); + const value = trimmed.slice(idx + 1).trim(); + if (!process.env[key]) process.env[key] = value; + } +} catch {} + +const API_KEY = process.env.INKBOX_API_KEY; +if (!API_KEY) { + console.error("ERROR: INKBOX_API_KEY not set in .env or environment"); + process.exit(1); +} + +const client = new InkboxPhone({ apiKey: API_KEY }); + +async function main() { + // --- Numbers --- + console.log("=== Listing phone numbers ==="); + const numbers = await client.numbers.list(); + for (const n of numbers) { + console.log(` ${n.number} type=${n.type} status=${n.status} action=${n.incomingCallAction}`); + } + + let number; + if (numbers.length === 0) { + console.log("\nNo numbers found. Provisioning a toll-free number..."); + number = await client.numbers.provision({ type: "toll_free" }); + console.log(` Provisioned: ${number.number} (id=${number.id})`); + } else { + number = numbers[0]; + console.log(`\nUsing first number: ${number.number} (id=${number.id})`); + } + + console.log("\n=== Get phone number ==="); + const fetched = await client.numbers.get(number.id); + console.log(` ${fetched.number} pipeline_mode=${fetched.defaultPipelineMode}`); + + console.log("\n=== Update phone number ==="); + const updated = await client.numbers.update(number.id, { + incomingCallAction: "auto_reject", + }); + console.log(` incomingCallAction=${updated.incomingCallAction}`); + + // --- Calls --- + console.log("\n=== Listing calls ==="); + const calls = await client.calls.list(number.id, { limit: 5 }); + for (const c of calls) { + console.log(` ${c.id} ${c.direction} ${c.remotePhoneNumber} status=${c.status}`); + } + + if (calls.length > 0) { + const call = calls[0]; + console.log(`\n=== Get call ${call.id} ===`); + const detail = await client.calls.get(number.id, call.id); + console.log(` ${detail.direction} ${detail.remotePhoneNumber} pipeline=${detail.pipelineMode}`); + + // --- Transcripts (first call) --- + console.log(`\n=== Listing transcripts for call ${call.id} ===`); + const transcripts = await client.transcripts.list(number.id, call.id); + for (const t of transcripts) { + console.log(` [${t.party}] seq=${t.seq} ts=${t.tsMs}ms: ${t.text.slice(0, 80)}`); + } + + // --- Outbound call transcripts --- + let outbound = calls.filter((c) => c.direction === "outbound"); + if (outbound.length === 0) { + // Try fetching more calls to find an outbound one + const allCalls = await client.calls.list(number.id, { limit: 200 }); + outbound = allCalls.filter((c) => c.direction === "outbound"); + } + + if (outbound.length > 0) { + const ob = outbound[0]; + console.log(`\n=== Get outbound call ${ob.id} ===`); + const obDetail = await client.calls.get(number.id, ob.id); + console.log(` ${obDetail.direction} ${obDetail.remotePhoneNumber} status=${obDetail.status}`); + + console.log(`\n=== Listing transcripts for outbound call ${ob.id} ===`); + const obTranscripts = await client.transcripts.list(number.id, ob.id); + if (obTranscripts.length > 0) { + for (const t of obTranscripts) { + console.log(` [${t.party}] seq=${t.seq} ts=${t.tsMs}ms: ${t.text.slice(0, 80)}`); + } + } else { + console.log(" (no transcripts)"); + } + } else { + console.log("\n (no outbound calls found)"); + } + } + + // --- Search --- + console.log("\n=== Search transcripts ==="); + const results = await client.numbers.searchTranscripts(number.id, { q: "hello" }); + console.log(` Found ${results.length} results`); + for (const r of results.slice(0, 3)) { + console.log(` [${r.party}] ${r.text.slice(0, 80)}`); + } + + // --- Webhooks --- + console.log("\n=== Listing webhooks ==="); + const webhooks = await client.webhooks.list(number.id); + for (const wh of webhooks) { + console.log(` ${wh.id} url=${wh.url} events=${wh.eventTypes}`); + } + + console.log("\n=== Creating webhook ==="); + const hook = await client.webhooks.create(number.id, { + url: "https://example.com/test-webhook", + eventTypes: ["incoming_call"], + }); + console.log(` Created: ${hook.id}`); + console.log(` Secret: ${hook.secret}`); + + console.log("\n=== Updating webhook ==="); + const updatedHook = await client.webhooks.update(number.id, hook.id, { + url: "https://example.com/updated-webhook", + }); + console.log(` Updated URL: ${updatedHook.url}`); + + console.log("\n=== Deleting webhook ==="); + await client.webhooks.delete(number.id, hook.id); + console.log(" Deleted."); + + console.log("\nAll tests passed!"); +} + +main().catch((err) => { + console.error("FAILED:", err); + process.exit(1); +}); From 5c93d0cfb79960e6e3283ef870ac977a6897ff97 Mon Sep 17 00:00:00 2001 From: dimavrem22 Date: Tue, 10 Mar 2026 18:14:17 +0000 Subject: [PATCH 04/56] Fix CI: use uv sync --extra dev and remove npm cache config - Python: uv sync --extra dev installs ruff/pytest/pytest-cov - TypeScript: remove cache config (no package-lock.json) Co-Authored-By: Claude Opus 4.6 --- .github/workflows/tests.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8ae0cb9..5075ade 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -35,7 +35,7 @@ jobs: key: ${{ runner.os }}-uv-python-${{ hashFiles('python/pyproject.toml') }} - name: Install dependencies - run: uv sync --dev + run: uv sync --extra dev - name: Lint run: uv run ruff check . @@ -58,8 +58,6 @@ jobs: uses: actions/setup-node@v4 with: node-version: "18" - cache: "npm" - cache-dependency-path: typescript/package-lock.json - name: Install dependencies run: npm install --ignore-scripts From ea0860222bee1f72166b7599d6e362d166b0c392 Mon Sep 17 00:00:00 2001 From: dimavrem22 Date: Tue, 10 Mar 2026 18:15:08 +0000 Subject: [PATCH 05/56] Fix lint: remove unused import and variable Co-Authored-By: Claude Opus 4.6 --- python/tests/test_mail_mailboxes.py | 2 +- python/tests/test_mail_messages.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/python/tests/test_mail_mailboxes.py b/python/tests/test_mail_mailboxes.py index 2d13a1d..48f94d5 100644 --- a/python/tests/test_mail_mailboxes.py +++ b/python/tests/test_mail_mailboxes.py @@ -3,7 +3,7 @@ from unittest.mock import MagicMock from uuid import UUID -from sample_data_mail import MAILBOX_DICT, CURSOR_PAGE_SEARCH, MESSAGE_DICT +from sample_data_mail import MAILBOX_DICT, CURSOR_PAGE_SEARCH from inkbox.mail.resources.mailboxes import MailboxesResource diff --git a/python/tests/test_mail_messages.py b/python/tests/test_mail_messages.py index 813e684..b0a3008 100644 --- a/python/tests/test_mail_messages.py +++ b/python/tests/test_mail_messages.py @@ -143,7 +143,7 @@ def test_star(self): res, http = _resource() http.patch.return_value = {**MESSAGE_DICT, "is_starred": True} - msg = res.star(MBOX, MSG) + res.star(MBOX, MSG) _, kwargs = http.patch.call_args assert kwargs["json"] == {"is_starred": True} From 29b3e0ada099bc0ab8f050673060114c9719c451 Mon Sep 17 00:00:00 2001 From: dimavrem22 Date: Tue, 10 Mar 2026 18:18:27 +0000 Subject: [PATCH 06/56] Fix lint: move import to top of file in test script Co-Authored-By: Claude Opus 4.6 --- scripts/test_phone.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/test_phone.py b/scripts/test_phone.py index 728d942..1551baf 100644 --- a/scripts/test_phone.py +++ b/scripts/test_phone.py @@ -9,6 +9,7 @@ from pathlib import Path from dotenv import load_dotenv +from inkbox.phone import InkboxPhone load_dotenv(Path(__file__).resolve().parent.parent / ".env") @@ -17,8 +18,6 @@ print("ERROR: INKBOX_API_KEY not set in .env or environment") sys.exit(1) -from inkbox.phone import InkboxPhone - def main(): with InkboxPhone(api_key=API_KEY) as client: From a89741e96fc0f1980a2e4d5bf50065c0601d2889 Mon Sep 17 00:00:00 2001 From: dimavrem22 Date: Tue, 10 Mar 2026 18:31:29 +0000 Subject: [PATCH 07/56] Update place-call: stream_url optional, rename call_mode to pipeline_mode - stream_url now falls back to phone number's default_stream_url - call_mode renamed to pipeline_mode to match actual API field name - Updated both Python and TypeScript SDKs + tests Co-Authored-By: Claude Opus 4.6 --- python/inkbox/phone/resources/calls.py | 19 +++++++++++-------- python/tests/test_calls.py | 10 +++++----- typescript/src/phone/resources/calls.ts | 16 +++++++++------- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/python/inkbox/phone/resources/calls.py b/python/inkbox/phone/resources/calls.py index e3cb6f6..397b4b2 100644 --- a/python/inkbox/phone/resources/calls.py +++ b/python/inkbox/phone/resources/calls.py @@ -58,8 +58,8 @@ def place( *, from_number: str, to_number: str, - stream_url: str, - call_mode: str | None = None, + stream_url: str | None = None, + pipeline_mode: str | None = None, webhook_url: str | None = None, ) -> PhoneCall: """Place an outbound call. @@ -67,18 +67,21 @@ def place( Args: from_number: E.164 number to call from. Must belong to your org and be active. to_number: E.164 number to call. - stream_url: WebSocket URL for audio bridging. - call_mode: Pipeline ownership: ``"client_llm_only"``, ``"client_llm_tts"``, - or ``"client_llm_tts_stt"``. + stream_url: WebSocket URL for audio bridging. Falls back to the phone + number's ``default_stream_url``. Returns 400 if neither is set. + pipeline_mode: Pipeline ownership: ``"client_llm_only"``, ``"client_llm_tts"``, + or ``"client_llm_tts_stt"``. Falls back to the phone number's + ``default_pipeline_mode``. webhook_url: Custom webhook URL for call lifecycle events. """ body: dict[str, Any] = { "from_number": from_number, "to_number": to_number, - "stream_url": stream_url, } - if call_mode is not None: - body["call_mode"] = call_mode + if stream_url is not None: + body["stream_url"] = stream_url + if pipeline_mode is not None: + body["pipeline_mode"] = pipeline_mode if webhook_url is not None: body["webhook_url"] = webhook_url data = self._http.post("/place-call", json=body) diff --git a/python/tests/test_calls.py b/python/tests/test_calls.py index 12dd885..389fddb 100644 --- a/python/tests/test_calls.py +++ b/python/tests/test_calls.py @@ -84,19 +84,19 @@ def test_place_outbound_call(self, client, transport): ) assert call.status == "ringing" - def test_place_with_call_mode_and_webhook(self, client, transport): + def test_place_with_pipeline_mode_and_webhook(self, client, transport): transport.post.return_value = PHONE_CALL_DICT client.calls.place( from_number="+18335794607", to_number="+15167251294", stream_url="wss://agent.example.com/ws", - call_mode="client_llm_tts_stt", + pipeline_mode="client_llm_tts_stt", webhook_url="https://example.com/hook", ) _, kwargs = transport.post.call_args - assert kwargs["json"]["call_mode"] == "client_llm_tts_stt" + assert kwargs["json"]["pipeline_mode"] == "client_llm_tts_stt" assert kwargs["json"]["webhook_url"] == "https://example.com/hook" def test_optional_fields_omitted_when_none(self, client, transport): @@ -105,9 +105,9 @@ def test_optional_fields_omitted_when_none(self, client, transport): client.calls.place( from_number="+18335794607", to_number="+15167251294", - stream_url="wss://agent.example.com/ws", ) _, kwargs = transport.post.call_args - assert "call_mode" not in kwargs["json"] + assert "stream_url" not in kwargs["json"] + assert "pipeline_mode" not in kwargs["json"] assert "webhook_url" not in kwargs["json"] diff --git a/typescript/src/phone/resources/calls.ts b/typescript/src/phone/resources/calls.ts index 0db484d..302e18f 100644 --- a/typescript/src/phone/resources/calls.ts +++ b/typescript/src/phone/resources/calls.ts @@ -50,24 +50,26 @@ export class CallsResource { * * @param options.fromNumber - E.164 number to call from. Must belong to your org and be active. * @param options.toNumber - E.164 number to call. - * @param options.streamUrl - WebSocket URL for audio bridging. - * @param options.callMode - Pipeline ownership: `"client_llm_only"`, `"client_llm_tts"`, or `"client_llm_tts_stt"`. + * @param options.streamUrl - WebSocket URL for audio bridging. Falls back to the phone number's `defaultStreamUrl`. Returns 400 if neither is set. + * @param options.pipelineMode - Pipeline ownership: `"client_llm_only"`, `"client_llm_tts"`, or `"client_llm_tts_stt"`. Falls back to the phone number's `defaultPipelineMode`. * @param options.webhookUrl - Custom webhook URL for call lifecycle events. */ async place(options: { fromNumber: string; toNumber: string; - streamUrl: string; - callMode?: string; + streamUrl?: string; + pipelineMode?: string; webhookUrl?: string; }): Promise { const body: Record = { from_number: options.fromNumber, to_number: options.toNumber, - stream_url: options.streamUrl, }; - if (options.callMode !== undefined) { - body["call_mode"] = options.callMode; + if (options.streamUrl !== undefined) { + body["stream_url"] = options.streamUrl; + } + if (options.pipelineMode !== undefined) { + body["pipeline_mode"] = options.pipelineMode; } if (options.webhookUrl !== undefined) { body["webhook_url"] = options.webhookUrl; From 41d7a8706a663623316735e307513bd5c43cfc80 Mon Sep 17 00:00:00 2001 From: dimavrem22 Date: Wed, 11 Mar 2026 16:19:02 +0000 Subject: [PATCH 08/56] Remove auto-provisioning from test scripts, exit if no numbers attached Co-Authored-By: Claude Opus 4.6 --- scripts/test_phone.py | 11 +++++------ scripts/test_phone.ts | 12 +++++------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/scripts/test_phone.py b/scripts/test_phone.py index 1551baf..9d2c827 100644 --- a/scripts/test_phone.py +++ b/scripts/test_phone.py @@ -28,12 +28,11 @@ def main(): print(f" {n.number} type={n.type} status={n.status} action={n.incoming_call_action}") if not numbers: - print("\nNo numbers found. Provisioning a toll-free number...") - number = client.numbers.provision(type="toll_free") - print(f" Provisioned: {number.number} (id={number.id})") - else: - number = numbers[0] - print(f"\nUsing first number: {number.number} (id={number.id})") + print("ERROR: No phone numbers found. Attach a number to your account first.") + sys.exit(1) + + number = numbers[0] + print(f"\nUsing first number: {number.number} (id={number.id})") print("\n=== Get phone number ===") fetched = client.numbers.get(number.id) diff --git a/scripts/test_phone.ts b/scripts/test_phone.ts index 542d127..ede00c1 100644 --- a/scripts/test_phone.ts +++ b/scripts/test_phone.ts @@ -40,16 +40,14 @@ async function main() { console.log(` ${n.number} type=${n.type} status=${n.status} action=${n.incomingCallAction}`); } - let number; if (numbers.length === 0) { - console.log("\nNo numbers found. Provisioning a toll-free number..."); - number = await client.numbers.provision({ type: "toll_free" }); - console.log(` Provisioned: ${number.number} (id=${number.id})`); - } else { - number = numbers[0]; - console.log(`\nUsing first number: ${number.number} (id=${number.id})`); + console.error("ERROR: No phone numbers found. Attach a number to your account first."); + process.exit(1); } + const number = numbers[0]; + console.log(`\nUsing first number: ${number.number} (id=${number.id})`); + console.log("\n=== Get phone number ==="); const fetched = await client.numbers.get(number.id); console.log(` ${fetched.number} pipeline_mode=${fetched.defaultPipelineMode}`); From 0112ff968f012d76939c16323849d36a36b273bd Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:24:40 -0400 Subject: [PATCH 09/56] feat: updated endpoints --- README.md | 205 ++++++++++++++++-- python/inkbox/identities/__init__.py | 1 + python/inkbox/identities/_http.py | 68 ++++++ python/inkbox/identities/client.py | 54 +++++ python/inkbox/identities/exceptions.py | 25 +++ .../inkbox/identities/resources/__init__.py | 0 .../inkbox/identities/resources/identities.py | 133 ++++++++++++ python/inkbox/identities/types.py | 111 ++++++++++ python/inkbox/mail/resources/mailboxes.py | 58 ++++- python/inkbox/mail/resources/messages.py | 48 ++-- python/inkbox/mail/resources/threads.py | 22 +- python/inkbox/mail/resources/webhooks.py | 14 +- python/inkbox/phone/resources/calls.py | 25 +-- python/inkbox/phone/resources/numbers.py | 30 ++- python/inkbox/phone/types.py | 59 ++++- typescript/src/identities/client.ts | 52 +++++ typescript/src/identities/index.ts | 7 + .../src/identities/resources/identities.ts | 131 +++++++++++ typescript/src/identities/types.ts | 125 +++++++++++ typescript/src/phone/index.ts | 2 + typescript/src/phone/resources/calls.ts | 23 +- typescript/src/phone/resources/numbers.ts | 43 ++-- typescript/src/phone/types.ts | 76 +++++-- typescript/src/resources/mailboxes.ts | 59 +++-- typescript/src/resources/messages.ts | 42 ++-- typescript/src/resources/threads.ts | 16 +- typescript/src/resources/webhooks.ts | 14 +- 27 files changed, 1232 insertions(+), 211 deletions(-) create mode 100644 python/inkbox/identities/__init__.py create mode 100644 python/inkbox/identities/_http.py create mode 100644 python/inkbox/identities/client.py create mode 100644 python/inkbox/identities/exceptions.py create mode 100644 python/inkbox/identities/resources/__init__.py create mode 100644 python/inkbox/identities/resources/identities.py create mode 100644 python/inkbox/identities/types.py create mode 100644 typescript/src/identities/client.ts create mode 100644 typescript/src/identities/index.ts create mode 100644 typescript/src/identities/resources/identities.ts create mode 100644 typescript/src/identities/types.ts diff --git a/README.md b/README.md index 0d75a19..8194346 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Inkbox Mail SDK +# Inkbox SDK -Official SDKs for the [Inkbox Mail API](https://inkbox.ai) — API-first email for AI agents. +Official SDKs for the [Inkbox API](https://inkbox.ai) — API-first communication infrastructure for AI agents (email, phone, identities). | Package | Language | Install | |---|---|---| @@ -9,77 +9,148 @@ Official SDKs for the [Inkbox Mail API](https://inkbox.ai) — API-first email f --- -## Python +## Identities + +Agent identities are the central concept — a named agent (e.g. `"sales-agent"`) that owns a mailbox and/or phone number. + +### Python + +```python +from inkbox.identities import InkboxIdentities + +with InkboxIdentities(api_key="sk-...") as client: + + # Create an identity + identity = client.identities.create(agent_handle="sales-agent") + + # Assign channels (mailbox / phone number must already exist) + detail = client.identities.assign_mailbox( + "sales-agent", mailbox_id="" + ) + detail = client.identities.assign_phone_number( + "sales-agent", phone_number_id="" + ) + + print(detail.mailbox.email_address) + print(detail.phone_number.number) + + # List, get, update, delete + identities = client.identities.list() + detail = client.identities.get("sales-agent") + client.identities.update("sales-agent", status="paused") + client.identities.delete("sales-agent") +``` + +### TypeScript + +```ts +import { InkboxIdentities } from "@inkbox/sdk/identities"; + +const client = new InkboxIdentities({ apiKey: "sk-..." }); + +// Create an identity +const identity = await client.identities.create({ agentHandle: "sales-agent" }); + +// Assign channels +const detail = await client.identities.assignMailbox("sales-agent", { + mailboxId: "", +}); +console.log(detail.mailbox?.emailAddress); + +// List, get, update, delete +const identities = await client.identities.list(); +const d = await client.identities.get("sales-agent"); +await client.identities.update("sales-agent", { status: "paused" }); +await client.identities.delete("sales-agent"); +``` + +--- + +## Mail + +### Python ```python from inkbox.mail import InkboxMail with InkboxMail(api_key="sk-...") as client: - # Create a mailbox - mailbox = client.mailboxes.create(display_name="Agent 01") + # Create a mailbox (agent identity must already exist) + mailbox = client.mailboxes.create( + agent_handle="sales-agent", + display_name="Sales Agent", + ) # Send an email client.messages.send( - mailbox.id, + mailbox.email_address, to=["user@example.com"], subject="Hello from Inkbox", body_text="Hi there!", ) # Iterate over all messages (pagination handled automatically) - for msg in client.messages.list(mailbox.id): + for msg in client.messages.list(mailbox.email_address): print(msg.subject, msg.from_address) # Reply to a message - detail = client.messages.get(mailbox.id, msg.id) + detail = client.messages.get(mailbox.email_address, msg.id) client.messages.send( - mailbox.id, + mailbox.email_address, to=detail.to_addresses, subject=f"Re: {detail.subject}", body_text="Got it, thanks!", in_reply_to_message_id=detail.message_id, ) + # Update mailbox display name + client.mailboxes.update(mailbox.email_address, display_name="Support Agent") + # Search - results = client.mailboxes.search(mailbox.id, q="invoice") + results = client.mailboxes.search(mailbox.email_address, q="invoice") # Webhooks (secret is one-time — save it immediately) hook = client.webhooks.create( - mailbox.id, + mailbox.email_address, url="https://yourapp.com/hooks/mail", event_types=["message.received"], ) print(hook.secret) # save this ``` -## TypeScript +### TypeScript ```ts import { InkboxMail } from "@inkbox/sdk"; const client = new InkboxMail({ apiKey: "sk-..." }); -// Create a mailbox -const mailbox = await client.mailboxes.create({ displayName: "Agent 01" }); +// Create a mailbox (agent identity must already exist) +const mailbox = await client.mailboxes.create({ + agentHandle: "sales-agent", + displayName: "Sales Agent", +}); // Send an email -await client.messages.send(mailbox.id, { +await client.messages.send(mailbox.emailAddress, { to: ["user@example.com"], subject: "Hello from Inkbox", bodyText: "Hi there!", }); // Iterate over all messages (pagination handled automatically) -for await (const msg of client.messages.list(mailbox.id)) { +for await (const msg of client.messages.list(mailbox.emailAddress)) { console.log(msg.subject, msg.fromAddress); } +// Update mailbox display name +await client.mailboxes.update(mailbox.emailAddress, { displayName: "Support Agent" }); + // Search -const results = await client.mailboxes.search(mailbox.id, { q: "invoice" }); +const results = await client.mailboxes.search(mailbox.emailAddress, { q: "invoice" }); // Webhooks (secret is one-time — save it immediately) -const hook = await client.webhooks.create(mailbox.id, { +const hook = await client.webhooks.create(mailbox.emailAddress, { url: "https://yourapp.com/hooks/mail", eventTypes: ["message.received"], }); @@ -88,6 +159,104 @@ console.log(hook.secret); // save this --- +## Phone + +### Python + +```python +from inkbox.phone import InkboxPhone + +with InkboxPhone(api_key="sk-...") as client: + + # Provision a phone number (agent identity must already exist) + number = client.numbers.provision( + agent_handle="sales-agent", + type="toll_free", + ) + + # Update settings + client.numbers.update( + number.id, + incoming_call_action="auto_accept", + client_websocket_url="wss://your-agent.example.com/ws", + ) + + # Place an outbound call + call = client.calls.place( + from_number=number.number, + to_number="+15167251294", + client_websocket_url="wss://your-agent.example.com/ws", + ) + print(call.status) + print(call.rate_limit.calls_remaining) + + # List calls and transcripts + calls = client.calls.list(number.id) + transcripts = client.transcripts.list(number.id, calls[0].id) + + # Search transcripts + results = client.numbers.search_transcripts(number.id, q="appointment") + + # Webhooks + hook = client.webhooks.create( + number.id, + url="https://yourapp.com/hooks/phone", + event_types=["call.completed"], + ) + print(hook.secret) # save this + + # Release a number + client.numbers.release(number.id) +``` + +### TypeScript + +```ts +import { InkboxPhone } from "@inkbox/sdk/phone"; + +const client = new InkboxPhone({ apiKey: "sk-..." }); + +// Provision a phone number (agent identity must already exist) +const number = await client.numbers.provision({ + agentHandle: "sales-agent", + type: "toll_free", +}); + +// Update settings +await client.numbers.update(number.id, { + incomingCallAction: "auto_accept", + clientWebsocketUrl: "wss://your-agent.example.com/ws", +}); + +// Place an outbound call +const call = await client.calls.place({ + fromNumber: number.number, + toNumber: "+15167251294", + clientWebsocketUrl: "wss://your-agent.example.com/ws", +}); +console.log(call.status); +console.log(call.rateLimit.callsRemaining); + +// List calls and transcripts +const calls = await client.calls.list(number.id); +const transcripts = await client.transcripts.list(number.id, calls[0].id); + +// Search transcripts +const results = await client.numbers.searchTranscripts(number.id, { q: "appointment" }); + +// Webhooks +const hook = await client.webhooks.create(number.id, { + url: "https://yourapp.com/hooks/phone", + eventTypes: ["call.completed"], +}); +console.log(hook.secret); // save this + +// Release a number +await client.numbers.release(number.id); +``` + +--- + ## License MIT diff --git a/python/inkbox/identities/__init__.py b/python/inkbox/identities/__init__.py new file mode 100644 index 0000000..d2f8a46 --- /dev/null +++ b/python/inkbox/identities/__init__.py @@ -0,0 +1 @@ +# inkbox identities namespace diff --git a/python/inkbox/identities/_http.py b/python/inkbox/identities/_http.py new file mode 100644 index 0000000..5629f63 --- /dev/null +++ b/python/inkbox/identities/_http.py @@ -0,0 +1,68 @@ +""" +inkbox/identities/_http.py + +Sync HTTP transport (internal). +""" + +from __future__ import annotations + +from typing import Any + +import httpx + +from inkbox.identities.exceptions import InkboxAPIError + +_DEFAULT_TIMEOUT = 30.0 + + +class HttpTransport: + def __init__(self, api_key: str, base_url: str, timeout: float = _DEFAULT_TIMEOUT) -> None: + self._client = httpx.Client( + base_url=base_url, + headers={ + "X-Service-Token": api_key, + "Accept": "application/json", + }, + timeout=timeout, + ) + + def get(self, path: str, *, params: dict[str, Any] | None = None) -> Any: + cleaned = {k: v for k, v in (params or {}).items() if v is not None} + resp = self._client.get(path, params=cleaned) + _raise_for_status(resp) + return resp.json() + + def post(self, path: str, *, json: dict[str, Any] | None = None) -> Any: + resp = self._client.post(path, json=json) + _raise_for_status(resp) + if resp.status_code == 204: + return None + return resp.json() + + def patch(self, path: str, *, json: dict[str, Any]) -> Any: + resp = self._client.patch(path, json=json) + _raise_for_status(resp) + return resp.json() + + def delete(self, path: str) -> None: + resp = self._client.delete(path) + _raise_for_status(resp) + + def close(self) -> None: + self._client.close() + + def __enter__(self) -> HttpTransport: + return self + + def __exit__(self, *_: object) -> None: + self.close() + + +def _raise_for_status(resp: httpx.Response) -> None: + if resp.status_code < 400: + return + try: + detail = resp.json().get("detail", resp.text) + except Exception: + detail = resp.text + raise InkboxAPIError(status_code=resp.status_code, detail=str(detail)) diff --git a/python/inkbox/identities/client.py b/python/inkbox/identities/client.py new file mode 100644 index 0000000..f1a82e9 --- /dev/null +++ b/python/inkbox/identities/client.py @@ -0,0 +1,54 @@ +""" +inkbox/identities/client.py + +Top-level InkboxIdentities client. +""" + +from __future__ import annotations + +from inkbox.identities._http import HttpTransport +from inkbox.identities.resources.identities import IdentitiesResource + +_DEFAULT_BASE_URL = "https://api.inkbox.ai/api/v1/identities" + + +class InkboxIdentities: + """Client for the Inkbox Identities API. + + Args: + api_key: Your Inkbox API key (``X-Service-Token``). + base_url: Override the API base URL (useful for self-hosting or testing). + timeout: Request timeout in seconds (default 30). + + Example:: + + from inkbox.identities import InkboxIdentities + + with InkboxIdentities(api_key="sk-...") as client: + identity = client.identities.create(agent_handle="sales-agent") + detail = client.identities.assign_mailbox( + "sales-agent", + mailbox_id="", + ) + print(detail.mailbox.email_address) + """ + + def __init__( + self, + api_key: str, + *, + base_url: str = _DEFAULT_BASE_URL, + timeout: float = 30.0, + ) -> None: + self._http = HttpTransport(api_key=api_key, base_url=base_url, timeout=timeout) + self.identities = IdentitiesResource(self._http) + + def close(self) -> None: + """Close the underlying HTTP connection pool.""" + self._http.close() + + def __enter__(self) -> InkboxIdentities: + return self + + def __exit__(self, *_: object) -> None: + self.close() diff --git a/python/inkbox/identities/exceptions.py b/python/inkbox/identities/exceptions.py new file mode 100644 index 0000000..2a90826 --- /dev/null +++ b/python/inkbox/identities/exceptions.py @@ -0,0 +1,25 @@ +""" +inkbox/identities/exceptions.py + +Exception types raised by the SDK. +""" + +from __future__ import annotations + + +class InkboxError(Exception): + """Base exception for all Inkbox SDK errors.""" + + +class InkboxAPIError(InkboxError): + """Raised when the API returns a 4xx or 5xx response. + + Attributes: + status_code: HTTP status code. + detail: Error detail from the response body. + """ + + def __init__(self, status_code: int, detail: str) -> None: + super().__init__(f"HTTP {status_code}: {detail}") + self.status_code = status_code + self.detail = detail diff --git a/python/inkbox/identities/resources/__init__.py b/python/inkbox/identities/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/inkbox/identities/resources/identities.py b/python/inkbox/identities/resources/identities.py new file mode 100644 index 0000000..30a3a1c --- /dev/null +++ b/python/inkbox/identities/resources/identities.py @@ -0,0 +1,133 @@ +""" +inkbox/identities/resources/identities.py + +Identity CRUD and channel assignment. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from uuid import UUID + +from inkbox.identities.types import AgentIdentity, AgentIdentityDetail + +if TYPE_CHECKING: + from inkbox.identities._http import HttpTransport + + +class IdentitiesResource: + def __init__(self, http: HttpTransport) -> None: + self._http = http + + def create(self, *, agent_handle: str) -> AgentIdentity: + """Create a new agent identity. + + Args: + agent_handle: Unique handle for this identity within your organisation + (e.g. ``"sales-agent"`` or ``"@sales-agent"``). + + Returns: + The created identity. + """ + data = self._http.post("/", json={"agent_handle": agent_handle}) + return AgentIdentity._from_dict(data) + + def list(self) -> list[AgentIdentity]: + """List all identities for your organisation.""" + data = self._http.get("/") + return [AgentIdentity._from_dict(i) for i in data] + + def get(self, agent_handle: str) -> AgentIdentityDetail: + """Get an identity with its linked channels (mailbox, phone number). + + Args: + agent_handle: Handle of the identity to fetch. + """ + data = self._http.get(f"/{agent_handle}") + return AgentIdentityDetail._from_dict(data) + + def update( + self, + agent_handle: str, + *, + new_handle: str | None = None, + status: str | None = None, + ) -> AgentIdentity: + """Update an identity's handle or status. + + Only provided fields are applied; omitted fields are left unchanged. + + Args: + agent_handle: Current handle of the identity to update. + new_handle: New handle value. + status: New lifecycle status: ``"active"`` or ``"paused"``. + """ + body: dict[str, Any] = {} + if new_handle is not None: + body["agent_handle"] = new_handle + if status is not None: + body["status"] = status + data = self._http.patch(f"/{agent_handle}", json=body) + return AgentIdentity._from_dict(data) + + def delete(self, agent_handle: str) -> None: + """Soft-delete an identity. + + Unlinks any assigned channels without deleting them. + + Args: + agent_handle: Handle of the identity to delete. + """ + self._http.delete(f"/{agent_handle}") + + def assign_mailbox( + self, + agent_handle: str, + *, + mailbox_id: UUID | str, + ) -> AgentIdentityDetail: + """Assign a mailbox to an identity. + + Args: + agent_handle: Handle of the identity. + mailbox_id: UUID of the mailbox to assign. + """ + data = self._http.post( + f"/{agent_handle}/mailbox", + json={"mailbox_id": str(mailbox_id)}, + ) + return AgentIdentityDetail._from_dict(data) + + def unlink_mailbox(self, agent_handle: str) -> None: + """Unlink the mailbox from an identity (does not delete the mailbox). + + Args: + agent_handle: Handle of the identity. + """ + self._http.delete(f"/{agent_handle}/mailbox") + + def assign_phone_number( + self, + agent_handle: str, + *, + phone_number_id: UUID | str, + ) -> AgentIdentityDetail: + """Assign a phone number to an identity. + + Args: + agent_handle: Handle of the identity. + phone_number_id: UUID of the phone number to assign. + """ + data = self._http.post( + f"/{agent_handle}/phone_number", + json={"phone_number_id": str(phone_number_id)}, + ) + return AgentIdentityDetail._from_dict(data) + + def unlink_phone_number(self, agent_handle: str) -> None: + """Unlink the phone number from an identity (does not delete the number). + + Args: + agent_handle: Handle of the identity. + """ + self._http.delete(f"/{agent_handle}/phone_number") diff --git a/python/inkbox/identities/types.py b/python/inkbox/identities/types.py new file mode 100644 index 0000000..0222cc7 --- /dev/null +++ b/python/inkbox/identities/types.py @@ -0,0 +1,111 @@ +""" +inkbox/identities/types.py + +Dataclasses mirroring the Inkbox Identities API response models. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any +from uuid import UUID + + +def _dt(value: str | None) -> datetime | None: + return datetime.fromisoformat(value) if value else None + + +@dataclass +class IdentityMailbox: + """Mailbox channel linked to an agent identity.""" + + id: UUID + email_address: str + display_name: str | None + status: str + created_at: datetime + updated_at: datetime + + @classmethod + def _from_dict(cls, d: dict[str, Any]) -> IdentityMailbox: + return cls( + id=UUID(d["id"]), + email_address=d["email_address"], + display_name=d.get("display_name"), + status=d["status"], + created_at=datetime.fromisoformat(d["created_at"]), + updated_at=datetime.fromisoformat(d["updated_at"]), + ) + + +@dataclass +class IdentityPhoneNumber: + """Phone number channel linked to an agent identity.""" + + id: UUID + number: str + type: str + status: str + incoming_call_action: str + client_websocket_url: str | None + created_at: datetime + updated_at: datetime + + @classmethod + def _from_dict(cls, d: dict[str, Any]) -> IdentityPhoneNumber: + return cls( + id=UUID(d["id"]), + number=d["number"], + type=d["type"], + status=d["status"], + incoming_call_action=d["incoming_call_action"], + client_websocket_url=d.get("client_websocket_url"), + created_at=datetime.fromisoformat(d["created_at"]), + updated_at=datetime.fromisoformat(d["updated_at"]), + ) + + +@dataclass +class AgentIdentity: + """An agent identity returned by create, list, and update endpoints.""" + + id: UUID + organization_id: str + agent_handle: str + status: str + created_at: datetime + updated_at: datetime + + @classmethod + def _from_dict(cls, d: dict[str, Any]) -> AgentIdentity: + return cls( + id=UUID(d["id"]), + organization_id=d["organization_id"], + agent_handle=d["agent_handle"], + status=d["status"], + created_at=datetime.fromisoformat(d["created_at"]), + updated_at=datetime.fromisoformat(d["updated_at"]), + ) + + +@dataclass +class AgentIdentityDetail(AgentIdentity): + """Agent identity with linked communication channels. + + Returned by get, assign-mailbox, and assign-phone-number endpoints. + """ + + mailbox: IdentityMailbox | None = field(default=None) + phone_number: IdentityPhoneNumber | None = field(default=None) + + @classmethod + def _from_dict(cls, d: dict[str, Any]) -> AgentIdentityDetail: # type: ignore[override] + base = AgentIdentity._from_dict(d) + mailbox_data = d.get("mailbox") + phone_data = d.get("phone_number") + return cls( + **base.__dict__, + mailbox=IdentityMailbox._from_dict(mailbox_data) if mailbox_data else None, + phone_number=IdentityPhoneNumber._from_dict(phone_data) if phone_data else None, + ) diff --git a/python/inkbox/mail/resources/mailboxes.py b/python/inkbox/mail/resources/mailboxes.py index 2e9d682..4a7ebe4 100644 --- a/python/inkbox/mail/resources/mailboxes.py +++ b/python/inkbox/mail/resources/mailboxes.py @@ -7,7 +7,6 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any -from uuid import UUID from inkbox.mail.types import Mailbox, Message @@ -24,19 +23,22 @@ def __init__(self, http: HttpTransport) -> None: def create( self, *, + agent_handle: str, display_name: str | None = None, ) -> Mailbox: - """Create a new mailbox. + """Create a new mailbox and assign it to an agent identity. The email address is automatically generated by the server. Args: + agent_handle: Handle of the agent identity to assign this mailbox to + (e.g. ``"sales-agent"`` or ``"@sales-agent"``). display_name: Optional human-readable name shown as the sender. Returns: The created mailbox. """ - body: dict[str, Any] = {} + body: dict[str, Any] = {"agent_handle": agent_handle} if display_name is not None: body["display_name"] = display_name data = self._http.post(_BASE, json=body) @@ -47,18 +49,50 @@ def list(self) -> list[Mailbox]: data = self._http.get(_BASE) return [Mailbox._from_dict(m) for m in data] - def get(self, mailbox_id: UUID | str) -> Mailbox: - """Get a mailbox by ID.""" - data = self._http.get(f"{_BASE}/{mailbox_id}") + def get(self, email_address: str) -> Mailbox: + """Get a mailbox by its email address. + + Args: + email_address: Full email address of the mailbox + (e.g. ``"abc-xyz@inkboxmail.com"``). + """ + data = self._http.get(f"{_BASE}/{email_address}") + return Mailbox._from_dict(data) + + def update( + self, + email_address: str, + *, + display_name: str | None = None, + ) -> Mailbox: + """Update mutable mailbox fields. + + Only provided fields are applied; omitted fields are left unchanged. + + Args: + email_address: Full email address of the mailbox to update. + display_name: New human-readable sender name. + + Returns: + The updated mailbox. + """ + body: dict[str, Any] = {} + if display_name is not None: + body["display_name"] = display_name + data = self._http.patch(f"{_BASE}/{email_address}", json=body) return Mailbox._from_dict(data) - def delete(self, mailbox_id: UUID | str) -> None: - """Delete a mailbox.""" - self._http.delete(f"{_BASE}/{mailbox_id}") + def delete(self, email_address: str) -> None: + """Delete a mailbox. + + Args: + email_address: Full email address of the mailbox to delete. + """ + self._http.delete(f"{_BASE}/{email_address}") def search( self, - mailbox_id: UUID | str, + email_address: str, *, q: str, limit: int = 50, @@ -66,7 +100,7 @@ def search( """Full-text search across messages in a mailbox. Args: - mailbox_id: UUID of the mailbox to search. + email_address: Full email address of the mailbox to search. q: Search query string. limit: Maximum number of results (1–100). @@ -74,7 +108,7 @@ def search( Matching messages ranked by relevance. """ data = self._http.get( - f"{_BASE}/{mailbox_id}/search", + f"{_BASE}/{email_address}/search", params={"q": q, "limit": limit}, ) return [Message._from_dict(m) for m in data["items"]] diff --git a/python/inkbox/mail/resources/messages.py b/python/inkbox/mail/resources/messages.py index 86fe94d..6800016 100644 --- a/python/inkbox/mail/resources/messages.py +++ b/python/inkbox/mail/resources/messages.py @@ -23,7 +23,7 @@ def __init__(self, http: HttpTransport) -> None: def list( self, - mailbox_id: UUID | str, + email_address: str, *, page_size: int = _DEFAULT_PAGE_SIZE, ) -> Iterator[Message]: @@ -32,26 +32,26 @@ def list( Pagination is handled automatically — just iterate. Args: - mailbox_id: UUID of the mailbox. + email_address: Full email address of the mailbox. page_size: Number of messages fetched per API call (1–100). Example:: - for msg in client.messages.list(mailbox_id): + for msg in client.messages.list(email_address): print(msg.subject, msg.from_address) """ - return self._paginate(mailbox_id, page_size=page_size) + return self._paginate(email_address, page_size=page_size) def _paginate( self, - mailbox_id: UUID | str, + email_address: str, *, page_size: int, ) -> Iterator[Message]: cursor: str | None = None while True: page = self._http.get( - f"/mailboxes/{mailbox_id}/messages", + f"/mailboxes/{email_address}/messages", params={"limit": page_size, "cursor": cursor}, ) for item in page["items"]: @@ -60,22 +60,22 @@ def _paginate( break cursor = page["next_cursor"] - def get(self, mailbox_id: UUID | str, message_id: UUID | str) -> MessageDetail: + def get(self, email_address: str, message_id: UUID | str) -> MessageDetail: """Get a message with full body content. Args: - mailbox_id: UUID of the owning mailbox. + email_address: Full email address of the owning mailbox. message_id: UUID of the message. Returns: Full message including ``body_text`` and ``body_html``. """ - data = self._http.get(f"/mailboxes/{mailbox_id}/messages/{message_id}") + data = self._http.get(f"/mailboxes/{email_address}/messages/{message_id}") return MessageDetail._from_dict(data) def send( self, - mailbox_id: UUID | str, + email_address: str, *, to: list[str], subject: str, @@ -89,7 +89,7 @@ def send( """Send an email from a mailbox. Args: - mailbox_id: UUID of the sending mailbox. + email_address: Full email address of the sending mailbox. to: Primary recipient addresses (at least one required). subject: Email subject line. body_text: Plain-text body. @@ -122,12 +122,12 @@ def send( if attachments is not None: body["attachments"] = attachments - data = self._http.post(f"/mailboxes/{mailbox_id}/messages", json=body) + data = self._http.post(f"/mailboxes/{email_address}/messages", json=body) return Message._from_dict(data) def update_flags( self, - mailbox_id: UUID | str, + email_address: str, message_id: UUID | str, *, is_read: bool | None = None, @@ -143,26 +143,26 @@ def update_flags( if is_starred is not None: body["is_starred"] = is_starred data = self._http.patch( - f"/mailboxes/{mailbox_id}/messages/{message_id}", json=body + f"/mailboxes/{email_address}/messages/{message_id}", json=body ) return Message._from_dict(data) - def mark_read(self, mailbox_id: UUID | str, message_id: UUID | str) -> Message: + def mark_read(self, email_address: str, message_id: UUID | str) -> Message: """Mark a message as read.""" - return self.update_flags(mailbox_id, message_id, is_read=True) + return self.update_flags(email_address, message_id, is_read=True) - def mark_unread(self, mailbox_id: UUID | str, message_id: UUID | str) -> Message: + def mark_unread(self, email_address: str, message_id: UUID | str) -> Message: """Mark a message as unread.""" - return self.update_flags(mailbox_id, message_id, is_read=False) + return self.update_flags(email_address, message_id, is_read=False) - def star(self, mailbox_id: UUID | str, message_id: UUID | str) -> Message: + def star(self, email_address: str, message_id: UUID | str) -> Message: """Star a message.""" - return self.update_flags(mailbox_id, message_id, is_starred=True) + return self.update_flags(email_address, message_id, is_starred=True) - def unstar(self, mailbox_id: UUID | str, message_id: UUID | str) -> Message: + def unstar(self, email_address: str, message_id: UUID | str) -> Message: """Unstar a message.""" - return self.update_flags(mailbox_id, message_id, is_starred=False) + return self.update_flags(email_address, message_id, is_starred=False) - def delete(self, mailbox_id: UUID | str, message_id: UUID | str) -> None: + def delete(self, email_address: str, message_id: UUID | str) -> None: """Delete a message.""" - self._http.delete(f"/mailboxes/{mailbox_id}/messages/{message_id}") + self._http.delete(f"/mailboxes/{email_address}/messages/{message_id}") diff --git a/python/inkbox/mail/resources/threads.py b/python/inkbox/mail/resources/threads.py index 9e1ec67..2cb124f 100644 --- a/python/inkbox/mail/resources/threads.py +++ b/python/inkbox/mail/resources/threads.py @@ -23,7 +23,7 @@ def __init__(self, http: HttpTransport) -> None: def list( self, - mailbox_id: UUID | str, + email_address: str, *, page_size: int = _DEFAULT_PAGE_SIZE, ) -> Iterator[Thread]: @@ -32,26 +32,26 @@ def list( Pagination is handled automatically — just iterate. Args: - mailbox_id: UUID of the mailbox. + email_address: Full email address of the mailbox. page_size: Number of threads fetched per API call (1–100). Example:: - for thread in client.threads.list(mailbox_id): + for thread in client.threads.list(email_address): print(thread.subject, thread.message_count) """ - return self._paginate(mailbox_id, page_size=page_size) + return self._paginate(email_address, page_size=page_size) def _paginate( self, - mailbox_id: UUID | str, + email_address: str, *, page_size: int, ) -> Iterator[Thread]: cursor: str | None = None while True: page = self._http.get( - f"/mailboxes/{mailbox_id}/threads", + f"/mailboxes/{email_address}/threads", params={"limit": page_size, "cursor": cursor}, ) for item in page["items"]: @@ -60,19 +60,19 @@ def _paginate( break cursor = page["next_cursor"] - def get(self, mailbox_id: UUID | str, thread_id: UUID | str) -> ThreadDetail: + def get(self, email_address: str, thread_id: UUID | str) -> ThreadDetail: """Get a thread with all its messages inlined. Args: - mailbox_id: UUID of the owning mailbox. + email_address: Full email address of the owning mailbox. thread_id: UUID of the thread. Returns: Thread detail with all messages (oldest-first). """ - data = self._http.get(f"/mailboxes/{mailbox_id}/threads/{thread_id}") + data = self._http.get(f"/mailboxes/{email_address}/threads/{thread_id}") return ThreadDetail._from_dict(data) - def delete(self, mailbox_id: UUID | str, thread_id: UUID | str) -> None: + def delete(self, email_address: str, thread_id: UUID | str) -> None: """Delete a thread.""" - self._http.delete(f"/mailboxes/{mailbox_id}/threads/{thread_id}") + self._http.delete(f"/mailboxes/{email_address}/threads/{thread_id}") diff --git a/python/inkbox/mail/resources/webhooks.py b/python/inkbox/mail/resources/webhooks.py index 5e645ef..755a6d6 100644 --- a/python/inkbox/mail/resources/webhooks.py +++ b/python/inkbox/mail/resources/webhooks.py @@ -21,7 +21,7 @@ def __init__(self, http: HttpTransport) -> None: def create( self, - mailbox_id: UUID | str, + email_address: str, *, url: str, event_types: list[str], @@ -29,7 +29,7 @@ def create( """Register a webhook subscription for a mailbox. Args: - mailbox_id: UUID of the mailbox to subscribe to. + email_address: Full email address of the mailbox to subscribe to. url: HTTPS endpoint that will receive webhook POST requests. event_types: Events to subscribe to. Valid values: ``"message.received"``, ``"message.sent"``. @@ -39,16 +39,16 @@ def create( save it immediately, as it will not be returned again. """ data = self._http.post( - f"/mailboxes/{mailbox_id}/webhooks", + f"/mailboxes/{email_address}/webhooks", json={"url": url, "event_types": event_types}, ) return WebhookCreateResult._from_dict(data) - def list(self, mailbox_id: UUID | str) -> list[Webhook]: + def list(self, email_address: str) -> list[Webhook]: """List all active webhooks for a mailbox.""" - data = self._http.get(f"/mailboxes/{mailbox_id}/webhooks") + data = self._http.get(f"/mailboxes/{email_address}/webhooks") return [Webhook._from_dict(w) for w in data] - def delete(self, mailbox_id: UUID | str, webhook_id: UUID | str) -> None: + def delete(self, email_address: str, webhook_id: UUID | str) -> None: """Delete a webhook subscription.""" - self._http.delete(f"/mailboxes/{mailbox_id}/webhooks/{webhook_id}") + self._http.delete(f"/mailboxes/{email_address}/webhooks/{webhook_id}") diff --git a/python/inkbox/phone/resources/calls.py b/python/inkbox/phone/resources/calls.py index 397b4b2..488d5aa 100644 --- a/python/inkbox/phone/resources/calls.py +++ b/python/inkbox/phone/resources/calls.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Any from uuid import UUID -from inkbox.phone.types import PhoneCall +from inkbox.phone.types import PhoneCall, PhoneCallWithRateLimit if TYPE_CHECKING: from inkbox.phone._http import HttpTransport @@ -58,31 +58,28 @@ def place( *, from_number: str, to_number: str, - stream_url: str | None = None, - pipeline_mode: str | None = None, + client_websocket_url: str | None = None, webhook_url: str | None = None, - ) -> PhoneCall: + ) -> PhoneCallWithRateLimit: """Place an outbound call. Args: from_number: E.164 number to call from. Must belong to your org and be active. to_number: E.164 number to call. - stream_url: WebSocket URL for audio bridging. Falls back to the phone - number's ``default_stream_url``. Returns 400 if neither is set. - pipeline_mode: Pipeline ownership: ``"client_llm_only"``, ``"client_llm_tts"``, - or ``"client_llm_tts_stt"``. Falls back to the phone number's - ``default_pipeline_mode``. + client_websocket_url: WebSocket URL (wss://) for audio bridging. Falls back + to the phone number's ``client_websocket_url``. webhook_url: Custom webhook URL for call lifecycle events. + + Returns: + The created call record with current rate limit info. """ body: dict[str, Any] = { "from_number": from_number, "to_number": to_number, } - if stream_url is not None: - body["stream_url"] = stream_url - if pipeline_mode is not None: - body["pipeline_mode"] = pipeline_mode + if client_websocket_url is not None: + body["client_websocket_url"] = client_websocket_url if webhook_url is not None: body["webhook_url"] = webhook_url data = self._http.post("/place-call", json=body) - return PhoneCall._from_dict(data) + return PhoneCallWithRateLimit._from_dict(data) diff --git a/python/inkbox/phone/resources/numbers.py b/python/inkbox/phone/resources/numbers.py index 227bc7d..c948147 100644 --- a/python/inkbox/phone/resources/numbers.py +++ b/python/inkbox/phone/resources/numbers.py @@ -36,8 +36,7 @@ def update( phone_number_id: UUID | str, *, incoming_call_action: str | None = None, - default_stream_url: str | None = None, - default_pipeline_mode: str | None = None, + client_websocket_url: str | None = None, ) -> PhoneNumber: """Update phone number settings. @@ -46,48 +45,47 @@ def update( Args: phone_number_id: UUID of the phone number. incoming_call_action: ``"auto_accept"``, ``"auto_reject"``, or ``"webhook"``. - default_stream_url: WebSocket URL for audio bridging on ``auto_accept``. - default_pipeline_mode: ``"client_llm_only"``, ``"client_llm_tts"``, - or ``"client_llm_tts_stt"``. + client_websocket_url: WebSocket URL (wss://) for audio bridging on ``auto_accept``. """ body: dict[str, Any] = {} if incoming_call_action is not None: body["incoming_call_action"] = incoming_call_action - if default_stream_url is not None: - body["default_stream_url"] = default_stream_url - if default_pipeline_mode is not None: - body["default_pipeline_mode"] = default_pipeline_mode + if client_websocket_url is not None: + body["client_websocket_url"] = client_websocket_url data = self._http.patch(f"{_BASE}/{phone_number_id}", json=body) return PhoneNumber._from_dict(data) def provision( self, *, + agent_handle: str, type: str = "toll_free", state: str | None = None, ) -> PhoneNumber: - """Provision a new phone number via Telnyx. + """Provision a new phone number via Telnyx and assign it to an agent identity. Args: + agent_handle: Handle of the agent identity to assign this number to + (e.g. ``"sales-agent"`` or ``"@sales-agent"``). type: ``"toll_free"`` or ``"local"``. state: US state abbreviation (e.g. ``"NY"``). Only valid for ``local`` numbers. Returns: The provisioned phone number. """ - body: dict[str, Any] = {"type": type} + body: dict[str, Any] = {"agent_handle": agent_handle, "type": type} if state is not None: body["state"] = state - data = self._http.post(f"{_BASE}/provision", json=body) + data = self._http.post(_BASE, json=body) return PhoneNumber._from_dict(data) - def release(self, *, number: str) -> None: - """Release (delete) a phone number. + def release(self, phone_number_id: UUID | str) -> None: + """Release (delete) a phone number by ID. Args: - number: E.164 formatted phone number to release (e.g. ``"+18555690147"``). + phone_number_id: UUID of the phone number to release. """ - self._http.post(f"{_BASE}/release", json={"number": number}) + self._http.delete(f"{_BASE}/{phone_number_id}") def search_transcripts( self, diff --git a/python/inkbox/phone/types.py b/python/inkbox/phone/types.py index 3addb36..c2259eb 100644 --- a/python/inkbox/phone/types.py +++ b/python/inkbox/phone/types.py @@ -25,8 +25,7 @@ class PhoneNumber: type: str status: str incoming_call_action: str - default_stream_url: str | None - default_pipeline_mode: str + client_websocket_url: str | None created_at: datetime updated_at: datetime @@ -38,8 +37,7 @@ def _from_dict(cls, d: dict[str, Any]) -> PhoneNumber: type=d["type"], status=d["status"], incoming_call_action=d["incoming_call_action"], - default_stream_url=d.get("default_stream_url"), - default_pipeline_mode=d.get("default_pipeline_mode", "client_llm_only"), + client_websocket_url=d.get("client_websocket_url"), created_at=datetime.fromisoformat(d["created_at"]), updated_at=datetime.fromisoformat(d["updated_at"]), ) @@ -54,8 +52,10 @@ class PhoneCall: remote_phone_number: str direction: str status: str - pipeline_mode: str - stream_url: str | None + client_websocket_url: str | None + use_inkbox_tts: bool | None + use_inkbox_stt: bool | None + hangup_reason: str | None started_at: datetime | None ended_at: datetime | None created_at: datetime @@ -69,8 +69,10 @@ def _from_dict(cls, d: dict[str, Any]) -> PhoneCall: remote_phone_number=d["remote_phone_number"], direction=d["direction"], status=d["status"], - pipeline_mode=d.get("pipeline_mode", "client_llm_only"), - stream_url=d.get("stream_url"), + client_websocket_url=d.get("client_websocket_url"), + use_inkbox_tts=d.get("use_inkbox_tts"), + use_inkbox_stt=d.get("use_inkbox_stt"), + hangup_reason=d.get("hangup_reason"), started_at=_dt(d.get("started_at")), ended_at=_dt(d.get("ended_at")), created_at=datetime.fromisoformat(d["created_at"]), @@ -78,6 +80,47 @@ def _from_dict(cls, d: dict[str, Any]) -> PhoneCall: ) +@dataclass +class RateLimitInfo: + """Rolling 24-hour rate limit snapshot for an organisation.""" + + calls_used: int + calls_remaining: int + calls_limit: int + minutes_used: float + minutes_remaining: float + minutes_limit: int + + @classmethod + def _from_dict(cls, d: dict[str, Any]) -> RateLimitInfo: + return cls( + calls_used=d["calls_used"], + calls_remaining=d["calls_remaining"], + calls_limit=d["calls_limit"], + minutes_used=d["minutes_used"], + minutes_remaining=d["minutes_remaining"], + minutes_limit=d["minutes_limit"], + ) + + +@dataclass +class PhoneCallWithRateLimit(PhoneCall): + """PhoneCall extended with the caller's current rate limit snapshot. + + Returned by the place-call endpoint. + """ + + rate_limit: RateLimitInfo = None # type: ignore[assignment] + + @classmethod + def _from_dict(cls, d: dict[str, Any]) -> PhoneCallWithRateLimit: # type: ignore[override] + base = PhoneCall._from_dict(d) + return cls( + **base.__dict__, + rate_limit=RateLimitInfo._from_dict(d["rate_limit"]), + ) + + @dataclass class PhoneTranscript: """A transcript segment from a phone call.""" diff --git a/typescript/src/identities/client.ts b/typescript/src/identities/client.ts new file mode 100644 index 0000000..20ecf8b --- /dev/null +++ b/typescript/src/identities/client.ts @@ -0,0 +1,52 @@ +/** + * inkbox-identities/client.ts + * + * Top-level InkboxIdentities client. + */ + +import { HttpTransport } from "../_http.js"; +import { IdentitiesResource } from "./resources/identities.js"; + +const DEFAULT_BASE_URL = "https://api.inkbox.ai/api/v1/identities"; + +export interface InkboxIdentitiesOptions { + /** Your Inkbox API key (sent as `X-Service-Token`). */ + apiKey: string; + /** Override the API base URL (useful for self-hosting or testing). */ + baseUrl?: string; + /** Request timeout in milliseconds. Defaults to 30 000. */ + timeoutMs?: number; +} + +/** + * Client for the Inkbox Identities API. + * + * @example + * ```ts + * import { InkboxIdentities } from "@inkbox/sdk/identities"; + * + * const client = new InkboxIdentities({ apiKey: "sk-..." }); + * + * const identity = await client.identities.create({ agentHandle: "sales-agent" }); + * + * const detail = await client.identities.assignMailbox("sales-agent", { + * mailboxId: "", + * }); + * + * console.log(detail.mailbox?.emailAddress); + * ``` + */ +export class InkboxIdentities { + readonly identities: IdentitiesResource; + + private readonly http: HttpTransport; + + constructor(options: InkboxIdentitiesOptions) { + this.http = new HttpTransport( + options.apiKey, + options.baseUrl ?? DEFAULT_BASE_URL, + options.timeoutMs ?? 30_000, + ); + this.identities = new IdentitiesResource(this.http); + } +} diff --git a/typescript/src/identities/index.ts b/typescript/src/identities/index.ts new file mode 100644 index 0000000..7ee11c0 --- /dev/null +++ b/typescript/src/identities/index.ts @@ -0,0 +1,7 @@ +export { InkboxIdentities } from "./client.js"; +export type { + AgentIdentity, + AgentIdentityDetail, + IdentityMailbox, + IdentityPhoneNumber, +} from "./types.js"; diff --git a/typescript/src/identities/resources/identities.ts b/typescript/src/identities/resources/identities.ts new file mode 100644 index 0000000..4d3644c --- /dev/null +++ b/typescript/src/identities/resources/identities.ts @@ -0,0 +1,131 @@ +/** + * inkbox-identities/resources/identities.ts + * + * Identity CRUD and channel assignment. + */ + +import { HttpTransport } from "../../_http.js"; +import { + AgentIdentity, + AgentIdentityDetail, + RawAgentIdentity, + RawAgentIdentityDetail, + parseAgentIdentity, + parseAgentIdentityDetail, +} from "../types.js"; + +export class IdentitiesResource { + constructor(private readonly http: HttpTransport) {} + + /** + * Create a new agent identity. + * + * @param options.agentHandle - Unique handle for this identity within your organisation + * (e.g. `"sales-agent"` or `"@sales-agent"`). + */ + async create(options: { agentHandle: string }): Promise { + const data = await this.http.post("/", { + agent_handle: options.agentHandle, + }); + return parseAgentIdentity(data); + } + + /** List all identities for your organisation. */ + async list(): Promise { + const data = await this.http.get("/"); + return data.map(parseAgentIdentity); + } + + /** + * Get an identity with its linked channels (mailbox, phone number). + * + * @param agentHandle - Handle of the identity to fetch. + */ + async get(agentHandle: string): Promise { + const data = await this.http.get(`/${agentHandle}`); + return parseAgentIdentityDetail(data); + } + + /** + * Update an identity's handle or status. + * + * Only provided fields are applied; omitted fields are left unchanged. + * + * @param agentHandle - Current handle of the identity to update. + * @param options.newHandle - New handle value. + * @param options.status - New lifecycle status: `"active"` or `"paused"`. + */ + async update( + agentHandle: string, + options: { newHandle?: string; status?: string }, + ): Promise { + const body: Record = {}; + if (options.newHandle !== undefined) body["agent_handle"] = options.newHandle; + if (options.status !== undefined) body["status"] = options.status; + const data = await this.http.patch(`/${agentHandle}`, body); + return parseAgentIdentity(data); + } + + /** + * Soft-delete an identity. + * + * Unlinks any assigned channels without deleting them. + * + * @param agentHandle - Handle of the identity to delete. + */ + async delete(agentHandle: string): Promise { + await this.http.delete(`/${agentHandle}`); + } + + /** + * Assign a mailbox to an identity. + * + * @param agentHandle - Handle of the identity. + * @param options.mailboxId - UUID of the mailbox to assign. + */ + async assignMailbox( + agentHandle: string, + options: { mailboxId: string }, + ): Promise { + const data = await this.http.post( + `/${agentHandle}/mailbox`, + { mailbox_id: options.mailboxId }, + ); + return parseAgentIdentityDetail(data); + } + + /** + * Unlink the mailbox from an identity (does not delete the mailbox). + * + * @param agentHandle - Handle of the identity. + */ + async unlinkMailbox(agentHandle: string): Promise { + await this.http.delete(`/${agentHandle}/mailbox`); + } + + /** + * Assign a phone number to an identity. + * + * @param agentHandle - Handle of the identity. + * @param options.phoneNumberId - UUID of the phone number to assign. + */ + async assignPhoneNumber( + agentHandle: string, + options: { phoneNumberId: string }, + ): Promise { + const data = await this.http.post( + `/${agentHandle}/phone_number`, + { phone_number_id: options.phoneNumberId }, + ); + return parseAgentIdentityDetail(data); + } + + /** + * Unlink the phone number from an identity (does not delete the number). + * + * @param agentHandle - Handle of the identity. + */ + async unlinkPhoneNumber(agentHandle: string): Promise { + await this.http.delete(`/${agentHandle}/phone_number`); + } +} diff --git a/typescript/src/identities/types.ts b/typescript/src/identities/types.ts new file mode 100644 index 0000000..89ca6f4 --- /dev/null +++ b/typescript/src/identities/types.ts @@ -0,0 +1,125 @@ +/** + * inkbox-identities TypeScript SDK — public types. + */ + +export interface IdentityMailbox { + id: string; + emailAddress: string; + displayName: string | null; + /** "active" | "paused" | "deleted" */ + status: string; + createdAt: Date; + updatedAt: Date; +} + +export interface IdentityPhoneNumber { + id: string; + number: string; + /** "toll_free" | "local" */ + type: string; + /** "active" | "paused" | "released" */ + status: string; + /** "auto_accept" | "auto_reject" | "webhook" */ + incomingCallAction: string; + clientWebsocketUrl: string | null; + createdAt: Date; + updatedAt: Date; +} + +export interface AgentIdentity { + id: string; + organizationId: string; + agentHandle: string; + /** "active" | "paused" | "deleted" */ + status: string; + createdAt: Date; + updatedAt: Date; +} + +export interface AgentIdentityDetail extends AgentIdentity { + /** Mailbox assigned to this identity, or null if unlinked. */ + mailbox: IdentityMailbox | null; + /** Phone number assigned to this identity, or null if unlinked. */ + phoneNumber: IdentityPhoneNumber | null; +} + +// ---- internal raw API shapes (snake_case from JSON) ---- + +export interface RawIdentityMailbox { + id: string; + email_address: string; + display_name: string | null; + status: string; + created_at: string; + updated_at: string; +} + +export interface RawIdentityPhoneNumber { + id: string; + number: string; + type: string; + status: string; + incoming_call_action: string; + client_websocket_url: string | null; + created_at: string; + updated_at: string; +} + +export interface RawAgentIdentity { + id: string; + organization_id: string; + agent_handle: string; + status: string; + created_at: string; + updated_at: string; +} + +export interface RawAgentIdentityDetail extends RawAgentIdentity { + mailbox: RawIdentityMailbox | null; + phone_number: RawIdentityPhoneNumber | null; +} + +// ---- parsers ---- + +export function parseIdentityMailbox(r: RawIdentityMailbox): IdentityMailbox { + return { + id: r.id, + emailAddress: r.email_address, + displayName: r.display_name, + status: r.status, + createdAt: new Date(r.created_at), + updatedAt: new Date(r.updated_at), + }; +} + +export function parseIdentityPhoneNumber(r: RawIdentityPhoneNumber): IdentityPhoneNumber { + return { + id: r.id, + number: r.number, + type: r.type, + status: r.status, + incomingCallAction: r.incoming_call_action, + clientWebsocketUrl: r.client_websocket_url, + createdAt: new Date(r.created_at), + updatedAt: new Date(r.updated_at), + }; +} + +export function parseAgentIdentity(r: RawAgentIdentity): AgentIdentity { + return { + id: r.id, + organizationId: r.organization_id, + agentHandle: r.agent_handle, + status: r.status, + createdAt: new Date(r.created_at), + updatedAt: new Date(r.updated_at), + }; +} + +export function parseAgentIdentityDetail(r: RawAgentIdentityDetail): AgentIdentityDetail { + return { + ...parseAgentIdentity(r), + mailbox: r.mailbox ? parseIdentityMailbox(r.mailbox) : null, + phoneNumber: r.phone_number ? parseIdentityPhoneNumber(r.phone_number) : null, + }; +} diff --git a/typescript/src/phone/index.ts b/typescript/src/phone/index.ts index 1bf4c6c..e395dd7 100644 --- a/typescript/src/phone/index.ts +++ b/typescript/src/phone/index.ts @@ -2,6 +2,8 @@ export { InkboxPhone } from "./client.js"; export type { PhoneNumber, PhoneCall, + PhoneCallWithRateLimit, + RateLimitInfo, PhoneTranscript, PhoneWebhook, PhoneWebhookCreateResult, diff --git a/typescript/src/phone/resources/calls.ts b/typescript/src/phone/resources/calls.ts index 302e18f..40ff86e 100644 --- a/typescript/src/phone/resources/calls.ts +++ b/typescript/src/phone/resources/calls.ts @@ -7,8 +7,11 @@ import { HttpTransport } from "../../_http.js"; import { PhoneCall, + PhoneCallWithRateLimit, RawPhoneCall, + RawPhoneCallWithRateLimit, parsePhoneCall, + parsePhoneCallWithRateLimit, } from "../types.js"; export class CallsResource { @@ -50,31 +53,27 @@ export class CallsResource { * * @param options.fromNumber - E.164 number to call from. Must belong to your org and be active. * @param options.toNumber - E.164 number to call. - * @param options.streamUrl - WebSocket URL for audio bridging. Falls back to the phone number's `defaultStreamUrl`. Returns 400 if neither is set. - * @param options.pipelineMode - Pipeline ownership: `"client_llm_only"`, `"client_llm_tts"`, or `"client_llm_tts_stt"`. Falls back to the phone number's `defaultPipelineMode`. + * @param options.clientWebsocketUrl - WebSocket URL (wss://) for audio bridging. Falls back to the phone number's `clientWebsocketUrl`. * @param options.webhookUrl - Custom webhook URL for call lifecycle events. + * @returns The created call record with current rate limit info. */ async place(options: { fromNumber: string; toNumber: string; - streamUrl?: string; - pipelineMode?: string; + clientWebsocketUrl?: string; webhookUrl?: string; - }): Promise { + }): Promise { const body: Record = { from_number: options.fromNumber, to_number: options.toNumber, }; - if (options.streamUrl !== undefined) { - body["stream_url"] = options.streamUrl; - } - if (options.pipelineMode !== undefined) { - body["pipeline_mode"] = options.pipelineMode; + if (options.clientWebsocketUrl !== undefined) { + body["client_websocket_url"] = options.clientWebsocketUrl; } if (options.webhookUrl !== undefined) { body["webhook_url"] = options.webhookUrl; } - const data = await this.http.post("/place-call", body); - return parsePhoneCall(data); + const data = await this.http.post("/place-call", body); + return parsePhoneCallWithRateLimit(data); } } diff --git a/typescript/src/phone/resources/numbers.ts b/typescript/src/phone/resources/numbers.ts index 17aca92..0be8b50 100644 --- a/typescript/src/phone/resources/numbers.ts +++ b/typescript/src/phone/resources/numbers.ts @@ -38,26 +38,21 @@ export class PhoneNumbersResource { * * @param phoneNumberId - UUID of the phone number. * @param options.incomingCallAction - `"auto_accept"`, `"auto_reject"`, or `"webhook"`. - * @param options.defaultStreamUrl - WebSocket URL for audio bridging on `auto_accept`. - * @param options.defaultPipelineMode - `"client_llm_only"`, `"client_llm_tts"`, or `"client_llm_tts_stt"`. + * @param options.clientWebsocketUrl - WebSocket URL (wss://) for audio bridging on `auto_accept`. */ async update( phoneNumberId: string, options: { incomingCallAction?: string; - defaultStreamUrl?: string; - defaultPipelineMode?: string; + clientWebsocketUrl?: string; }, ): Promise { const body: Record = {}; if (options.incomingCallAction !== undefined) { body["incoming_call_action"] = options.incomingCallAction; } - if (options.defaultStreamUrl !== undefined) { - body["default_stream_url"] = options.defaultStreamUrl; - } - if (options.defaultPipelineMode !== undefined) { - body["default_pipeline_mode"] = options.defaultPipelineMode; + if (options.clientWebsocketUrl !== undefined) { + body["client_websocket_url"] = options.clientWebsocketUrl; } const data = await this.http.patch( `${BASE}/${phoneNumberId}`, @@ -67,34 +62,36 @@ export class PhoneNumbersResource { } /** - * Provision a new phone number via Telnyx. + * Provision a new phone number via Telnyx and assign it to an agent identity. * + * @param options.agentHandle - Handle of the agent identity to assign this number to + * (e.g. `"sales-agent"` or `"@sales-agent"`). * @param options.type - `"toll_free"` or `"local"`. Defaults to `"toll_free"`. * @param options.state - US state abbreviation (e.g. `"NY"`). Only valid for `local` numbers. */ - async provision( - options?: { type?: string; state?: string }, - ): Promise { + async provision(options: { + agentHandle: string; + type?: string; + state?: string; + }): Promise { const body: Record = { - type: options?.type ?? "toll_free", + agent_handle: options.agentHandle, + type: options.type ?? "toll_free", }; - if (options?.state !== undefined) { + if (options.state !== undefined) { body["state"] = options.state; } - const data = await this.http.post( - `${BASE}/provision`, - body, - ); + const data = await this.http.post(BASE, body); return parsePhoneNumber(data); } /** - * Release (delete) a phone number. + * Release (delete) a phone number by ID. * - * @param number - E.164 formatted phone number to release. + * @param phoneNumberId - UUID of the phone number to release. */ - async release(number: string): Promise { - await this.http.post(`${BASE}/release`, { number }); + async release(phoneNumberId: string): Promise { + await this.http.delete(`${BASE}/${phoneNumberId}`); } /** diff --git a/typescript/src/phone/types.ts b/typescript/src/phone/types.ts index 8feb02d..4f6d4b7 100644 --- a/typescript/src/phone/types.ts +++ b/typescript/src/phone/types.ts @@ -11,9 +11,7 @@ export interface PhoneNumber { status: string; /** "auto_accept" | "auto_reject" | "webhook" */ incomingCallAction: string; - defaultStreamUrl: string | null; - /** "client_llm_only" | "client_llm_tts" | "client_llm_tts_stt" */ - defaultPipelineMode: string; + clientWebsocketUrl: string | null; createdAt: Date; updatedAt: Date; } @@ -26,15 +24,30 @@ export interface PhoneCall { direction: string; /** "initiated" | "ringing" | "answered" | "completed" | "failed" | "canceled" */ status: string; - /** "client_llm_only" | "client_llm_tts" | "client_llm_tts_stt" */ - pipelineMode: string; - streamUrl: string | null; + clientWebsocketUrl: string | null; + useInkboxTts: boolean | null; + useInkboxStt: boolean | null; + /** "local" | "remote" | "max_duration" | "voicemail" | "rejected" */ + hangupReason: string | null; startedAt: Date | null; endedAt: Date | null; createdAt: Date; updatedAt: Date; } +export interface RateLimitInfo { + callsUsed: number; + callsRemaining: number; + callsLimit: number; + minutesUsed: number; + minutesRemaining: number; + minutesLimit: number; +} + +export interface PhoneCallWithRateLimit extends PhoneCall { + rateLimit: RateLimitInfo; +} + export interface PhoneTranscript { id: string; callId: string; @@ -70,8 +83,7 @@ export interface RawPhoneNumber { type: string; status: string; incoming_call_action: string; - default_stream_url: string | null; - default_pipeline_mode: string; + client_websocket_url: string | null; created_at: string; updated_at: string; } @@ -82,14 +94,29 @@ export interface RawPhoneCall { remote_phone_number: string; direction: string; status: string; - pipeline_mode: string; - stream_url: string | null; + client_websocket_url: string | null; + use_inkbox_tts: boolean | null; + use_inkbox_stt: boolean | null; + hangup_reason: string | null; started_at: string | null; ended_at: string | null; created_at: string; updated_at: string; } +export interface RawRateLimitInfo { + calls_used: number; + calls_remaining: number; + calls_limit: number; + minutes_used: number; + minutes_remaining: number; + minutes_limit: number; +} + +export interface RawPhoneCallWithRateLimit extends RawPhoneCall { + rate_limit: RawRateLimitInfo; +} + export interface RawPhoneTranscript { id: string; call_id: string; @@ -123,8 +150,7 @@ export function parsePhoneNumber(r: RawPhoneNumber): PhoneNumber { type: r.type, status: r.status, incomingCallAction: r.incoming_call_action, - defaultStreamUrl: r.default_stream_url, - defaultPipelineMode: r.default_pipeline_mode ?? "client_llm_only", + clientWebsocketUrl: r.client_websocket_url, createdAt: new Date(r.created_at), updatedAt: new Date(r.updated_at), }; @@ -137,8 +163,10 @@ export function parsePhoneCall(r: RawPhoneCall): PhoneCall { remotePhoneNumber: r.remote_phone_number, direction: r.direction, status: r.status, - pipelineMode: r.pipeline_mode ?? "client_llm_only", - streamUrl: r.stream_url, + clientWebsocketUrl: r.client_websocket_url, + useInkboxTts: r.use_inkbox_tts, + useInkboxStt: r.use_inkbox_stt, + hangupReason: r.hangup_reason, startedAt: r.started_at ? new Date(r.started_at) : null, endedAt: r.ended_at ? new Date(r.ended_at) : null, createdAt: new Date(r.created_at), @@ -146,6 +174,26 @@ export function parsePhoneCall(r: RawPhoneCall): PhoneCall { }; } +export function parseRateLimitInfo(r: RawRateLimitInfo): RateLimitInfo { + return { + callsUsed: r.calls_used, + callsRemaining: r.calls_remaining, + callsLimit: r.calls_limit, + minutesUsed: r.minutes_used, + minutesRemaining: r.minutes_remaining, + minutesLimit: r.minutes_limit, + }; +} + +export function parsePhoneCallWithRateLimit( + r: RawPhoneCallWithRateLimit, +): PhoneCallWithRateLimit { + return { + ...parsePhoneCall(r), + rateLimit: parseRateLimitInfo(r.rate_limit), + }; +} + export function parsePhoneTranscript(r: RawPhoneTranscript): PhoneTranscript { return { id: r.id, diff --git a/typescript/src/resources/mailboxes.ts b/typescript/src/resources/mailboxes.ts index 30997a4..8f92b45 100644 --- a/typescript/src/resources/mailboxes.ts +++ b/typescript/src/resources/mailboxes.ts @@ -21,15 +21,17 @@ export class MailboxesResource { constructor(private readonly http: HttpTransport) {} /** - * Create a new mailbox. + * Create a new mailbox and assign it to an agent identity. * * The email address is automatically generated by the server. * - * @param displayName - Optional human-readable name shown as the sender. + * @param options.agentHandle - Handle of the agent identity to assign this mailbox to + * (e.g. `"sales-agent"` or `"@sales-agent"`). + * @param options.displayName - Optional human-readable name shown as the sender. */ - async create(options?: { displayName?: string }): Promise { - const body: Record = {}; - if (options?.displayName !== undefined) { + async create(options: { agentHandle: string; displayName?: string }): Promise { + const body: Record = { agent_handle: options.agentHandle }; + if (options.displayName !== undefined) { body["display_name"] = options.displayName; } const data = await this.http.post(BASE, body); @@ -42,30 +44,55 @@ export class MailboxesResource { return data.map(parseMailbox); } - /** Get a mailbox by ID. */ - async get(mailboxId: string): Promise { - const data = await this.http.get(`${BASE}/${mailboxId}`); + /** + * Get a mailbox by its email address. + * + * @param emailAddress - Full email address of the mailbox (e.g. `"abc-xyz@inkboxmail.com"`). + */ + async get(emailAddress: string): Promise { + const data = await this.http.get(`${BASE}/${emailAddress}`); return parseMailbox(data); } - /** Delete a mailbox. */ - async delete(mailboxId: string): Promise { - await this.http.delete(`${BASE}/${mailboxId}`); + /** + * Update mutable mailbox fields. + * + * Only provided fields are applied; omitted fields are left unchanged. + * + * @param emailAddress - Full email address of the mailbox to update. + * @param options.displayName - New human-readable sender name. + */ + async update(emailAddress: string, options: { displayName?: string }): Promise { + const body: Record = {}; + if (options.displayName !== undefined) { + body["display_name"] = options.displayName; + } + const data = await this.http.patch(`${BASE}/${emailAddress}`, body); + return parseMailbox(data); + } + + /** + * Delete a mailbox. + * + * @param emailAddress - Full email address of the mailbox to delete. + */ + async delete(emailAddress: string): Promise { + await this.http.delete(`${BASE}/${emailAddress}`); } /** * Full-text search across messages in a mailbox. * - * @param mailboxId - UUID of the mailbox to search. - * @param q - Search query string. - * @param limit - Maximum number of results (1–100). Defaults to 50. + * @param emailAddress - Full email address of the mailbox to search. + * @param options.q - Search query string. + * @param options.limit - Maximum number of results (1–100). Defaults to 50. */ async search( - mailboxId: string, + emailAddress: string, options: { q: string; limit?: number }, ): Promise { const data = await this.http.get>( - `${BASE}/${mailboxId}/search`, + `${BASE}/${emailAddress}/search`, { q: options.q, limit: options.limit ?? 50 }, ); return data.items.map(parseMessage); diff --git a/typescript/src/resources/messages.ts b/typescript/src/resources/messages.ts index 3ec5da5..11bd2fb 100644 --- a/typescript/src/resources/messages.ts +++ b/typescript/src/resources/messages.ts @@ -26,13 +26,13 @@ export class MessagesResource { * * @example * ```ts - * for await (const msg of client.messages.list(mailboxId)) { + * for await (const msg of client.messages.list(emailAddress)) { * console.log(msg.subject, msg.fromAddress); * } * ``` */ async *list( - mailboxId: string, + emailAddress: string, options?: { pageSize?: number }, ): AsyncGenerator { const limit = options?.pageSize ?? DEFAULT_PAGE_SIZE; @@ -40,7 +40,7 @@ export class MessagesResource { while (true) { const page = await this.http.get>( - `/mailboxes/${mailboxId}/messages`, + `/mailboxes/${emailAddress}/messages`, { limit, cursor }, ); for (const item of page.items) { @@ -54,12 +54,12 @@ export class MessagesResource { /** * Get a message with full body content. * - * @param mailboxId - UUID of the owning mailbox. + * @param emailAddress - Full email address of the owning mailbox. * @param messageId - UUID of the message. */ - async get(mailboxId: string, messageId: string): Promise { + async get(emailAddress: string, messageId: string): Promise { const data = await this.http.get( - `/mailboxes/${mailboxId}/messages/${messageId}`, + `/mailboxes/${emailAddress}/messages/${messageId}`, ); return parseMessageDetail(data); } @@ -67,7 +67,7 @@ export class MessagesResource { /** * Send an email from a mailbox. * - * @param mailboxId - UUID of the sending mailbox. + * @param emailAddress - Full email address of the sending mailbox. * @param options.to - Primary recipient addresses (at least one required). * @param options.subject - Email subject line. * @param options.bodyText - Plain-text body. @@ -81,7 +81,7 @@ export class MessagesResource { * file content). Max total size: 25 MB. Blocked: `.exe`, `.bat`, `.scr`. */ async send( - mailboxId: string, + emailAddress: string, options: { to: string[]; subject: string; @@ -119,7 +119,7 @@ export class MessagesResource { } const data = await this.http.post( - `/mailboxes/${mailboxId}/messages`, + `/mailboxes/${emailAddress}/messages`, body, ); return parseMessage(data); @@ -131,7 +131,7 @@ export class MessagesResource { * Pass only the flags you want to change; omitted flags are left as-is. */ async updateFlags( - mailboxId: string, + emailAddress: string, messageId: string, flags: { isRead?: boolean; isStarred?: boolean }, ): Promise { @@ -140,34 +140,34 @@ export class MessagesResource { if (flags.isStarred !== undefined) body["is_starred"] = flags.isStarred; const data = await this.http.patch( - `/mailboxes/${mailboxId}/messages/${messageId}`, + `/mailboxes/${emailAddress}/messages/${messageId}`, body, ); return parseMessage(data); } /** Mark a message as read. */ - async markRead(mailboxId: string, messageId: string): Promise { - return this.updateFlags(mailboxId, messageId, { isRead: true }); + async markRead(emailAddress: string, messageId: string): Promise { + return this.updateFlags(emailAddress, messageId, { isRead: true }); } /** Mark a message as unread. */ - async markUnread(mailboxId: string, messageId: string): Promise { - return this.updateFlags(mailboxId, messageId, { isRead: false }); + async markUnread(emailAddress: string, messageId: string): Promise { + return this.updateFlags(emailAddress, messageId, { isRead: false }); } /** Star a message. */ - async star(mailboxId: string, messageId: string): Promise { - return this.updateFlags(mailboxId, messageId, { isStarred: true }); + async star(emailAddress: string, messageId: string): Promise { + return this.updateFlags(emailAddress, messageId, { isStarred: true }); } /** Unstar a message. */ - async unstar(mailboxId: string, messageId: string): Promise { - return this.updateFlags(mailboxId, messageId, { isStarred: false }); + async unstar(emailAddress: string, messageId: string): Promise { + return this.updateFlags(emailAddress, messageId, { isStarred: false }); } /** Delete a message. */ - async delete(mailboxId: string, messageId: string): Promise { - await this.http.delete(`/mailboxes/${mailboxId}/messages/${messageId}`); + async delete(emailAddress: string, messageId: string): Promise { + await this.http.delete(`/mailboxes/${emailAddress}/messages/${messageId}`); } } diff --git a/typescript/src/resources/threads.ts b/typescript/src/resources/threads.ts index dc0cdc8..0648b37 100644 --- a/typescript/src/resources/threads.ts +++ b/typescript/src/resources/threads.ts @@ -26,13 +26,13 @@ export class ThreadsResource { * * @example * ```ts - * for await (const thread of client.threads.list(mailboxId)) { + * for await (const thread of client.threads.list(emailAddress)) { * console.log(thread.subject, thread.messageCount); * } * ``` */ async *list( - mailboxId: string, + emailAddress: string, options?: { pageSize?: number }, ): AsyncGenerator { const limit = options?.pageSize ?? DEFAULT_PAGE_SIZE; @@ -40,7 +40,7 @@ export class ThreadsResource { while (true) { const page = await this.http.get>( - `/mailboxes/${mailboxId}/threads`, + `/mailboxes/${emailAddress}/threads`, { limit, cursor }, ); for (const item of page.items) { @@ -54,18 +54,18 @@ export class ThreadsResource { /** * Get a thread with all its messages inlined. * - * @param mailboxId - UUID of the owning mailbox. + * @param emailAddress - Full email address of the owning mailbox. * @param threadId - UUID of the thread. */ - async get(mailboxId: string, threadId: string): Promise { + async get(emailAddress: string, threadId: string): Promise { const data = await this.http.get( - `/mailboxes/${mailboxId}/threads/${threadId}`, + `/mailboxes/${emailAddress}/threads/${threadId}`, ); return parseThreadDetail(data); } /** Delete a thread. */ - async delete(mailboxId: string, threadId: string): Promise { - await this.http.delete(`/mailboxes/${mailboxId}/threads/${threadId}`); + async delete(emailAddress: string, threadId: string): Promise { + await this.http.delete(`/mailboxes/${emailAddress}/threads/${threadId}`); } } diff --git a/typescript/src/resources/webhooks.ts b/typescript/src/resources/webhooks.ts index 12d48db..8d3a7a9 100644 --- a/typescript/src/resources/webhooks.ts +++ b/typescript/src/resources/webhooks.ts @@ -20,7 +20,7 @@ export class WebhooksResource { /** * Register a webhook subscription for a mailbox. * - * @param mailboxId - UUID of the mailbox to subscribe to. + * @param emailAddress - Full email address of the mailbox to subscribe to. * @param options.url - HTTPS endpoint that will receive webhook POST requests. * @param options.eventTypes - Events to subscribe to. * Valid values: `"message.received"`, `"message.sent"`. @@ -28,26 +28,26 @@ export class WebhooksResource { * key — save it immediately, as it will not be returned again. */ async create( - mailboxId: string, + emailAddress: string, options: { url: string; eventTypes: string[] }, ): Promise { const data = await this.http.post( - `/mailboxes/${mailboxId}/webhooks`, + `/mailboxes/${emailAddress}/webhooks`, { url: options.url, event_types: options.eventTypes }, ); return parseWebhookCreateResult(data); } /** List all active webhooks for a mailbox. */ - async list(mailboxId: string): Promise { + async list(emailAddress: string): Promise { const data = await this.http.get( - `/mailboxes/${mailboxId}/webhooks`, + `/mailboxes/${emailAddress}/webhooks`, ); return data.map(parseWebhook); } /** Delete a webhook subscription. */ - async delete(mailboxId: string, webhookId: string): Promise { - await this.http.delete(`/mailboxes/${mailboxId}/webhooks/${webhookId}`); + async delete(emailAddress: string, webhookId: string): Promise { + await this.http.delete(`/mailboxes/${emailAddress}/webhooks/${webhookId}`); } } From 2c8874665f07ba00b673f529bf80ca73a1c8fa16 Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:27:33 -0400 Subject: [PATCH 10/56] feat: mit license --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..df4f98c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Vectorly, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 3a2d12dc16c1307eb38df3831052525d91fc0657 Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:36:24 -0400 Subject: [PATCH 11/56] rm scriprts and fix tests --- python/.coverage | Bin 53248 -> 53248 bytes python/inkbox/mail/resources/mailboxes.py | 7 +- python/inkbox/phone/resources/calls.py | 13 +- python/inkbox/phone/resources/numbers.py | 29 +++-- python/inkbox/phone/types.py | 20 ++- scripts/test_phone.py | 139 -------------------- scripts/test_phone.ts | 148 ---------------------- 7 files changed, 34 insertions(+), 322 deletions(-) delete mode 100644 scripts/test_phone.py delete mode 100644 scripts/test_phone.ts diff --git a/python/.coverage b/python/.coverage index ee886091821303df63f08956c7cd4f4a084d0bbd..4b7e9212fdf31d40f306493785e83625cde70090 100644 GIT binary patch literal 53248 zcmeI4Z)_W98Nl!Cv(M*$uM;Qr(xmiOF_NtLLsXTnE7h8|(Wp8^OPjP^OWm`5iLbSN z&fU39CXI5TQYWE-v`L6b2=<9h8w|c6brP(TU|$9T@nM1wK%*h1f!G*-rf#&nM=X~VwBbuekv$|1MEO{H3;&`6hBg-7eN$^U* z%RNLm5!?$%dC&P;rxG{zX#^PbrE#i_}oQ}WTty^l=EPM$mzmEjm2l_zCG zzaUptL!Q-2s;pHCT3)fV%A9N!RkwD{R10*?f;?EYXvHzso}Oj30<^N!IS^4bw6bC> z%IDO@5muq0%~BUECj*42+FXU!kcS%0hh;;ZRSmV0S53!?A+0bR9scUYsF+Eo`O7Nn zlVarIFV@x@$Y^!tz$^&L>qdcEpEHz7zNnfbveN8fUI#lDET^}IULLn}xujKCH!G%Q zX?jIg7u0;sQVXkgaD+m04}j$g~sS0r$ninVo5#1FuvU%=r@BI%SobrL*fS41RV3-r|?KB4TFqX8!U# za}oi>;JJCFi+I?X$)8Ef8Nz9EJqt!^nHWzY7a+PV+Eh?*uSu@;;-BDN@qnciOBb|Qk#OY{|L>jcedg}{o1;G>&g zRji_XyX`a^3?B@IGrM}%GMt)_ouKdGOQ`@e`H3K-UxrDazJhHg)0~#c2i3*V^GXS3 zld7T_5V9}_J1c-pD;5-}tmO2X#r&6EGWVowPs?6MKw`&{(hNGVDl&xt|6n)#FSe&`=(Ppfn*wSIBgQHcIJ?<;owc0uw5=tQ4GZ zr-R~=c%i7?CeB%n_A8cZX=PQWRhTi%u`Vd4oHtY`Y^Wp-yjx z2ZML=9XiC>3HnZV$q{-hXK&`k%+{^EU287}EA&WO&i=Nxoyt;P*fbeL}Nd2)k;KRl2C5iV3`x^fPuw!gu+t{`!^eRB!=E&RR{dL(vG#UvY0VIF~kN^@u0!RP}AOR$R z1dsp{XcCAAH}USr0D)+*Uut{`K)?SV2tCD-7s;XI_2hSxdlP?1EGF)aUyUD&{WJEh z*q-QLqEAMLrJqY>Ns4?gvRC}Gcv&0_zaB1zZb3slkN^@u0!Y9o0%r#V&OR|dZNejS z+eV}2N&l;@7 zs%nDp)AxYzNuPy#@SMtk{M|mw&zCfC0E%Wq2he1TmQ@Aexn2-{zz@^QidOP?>IF4d z)b(=|ztRIb9`1k+s|Zho7O09`x;1^sfX5P@M@FNwltVp28mt|$8p1e{jQ4KQ@(BR7eI~v@e z&50n_muYjQ<&EF^0Ne9n(9w~h_W0Au5Ge6w-E}(B7PT{izx3U(Jr)GH+x+v-X55|# zfZ!ee3HE?K!h`6oeu(x6rQiP#gr4Te_r)RcE%Ax47OIDy4*w$dO!Tj@$>`Uk_e-xy z(a3X=eNu%SPClFbAh|E`$M~y>$KuCh|4MA}>82OOAOR$R1dss7ofGU6ydTs2|KI-) zXo5Z6IbEK0re6{4DZe$f-70K6CD^!`0 zR%%0Dun+iR`r7;d-ke}R+zB16{eMrVy5ZUXr)LCv+`n$P&e>fM?1TP^U48%G)hRxn zC`g?Y?4AB~q-}a2{<3$yKY2{BC;T(H8M28Jg1y~8xgN-I-@Ikz#?$kGU2IOU5Bp;N zN+s?4|EO=v%1S-%{$KKKfE^8P&_;ZVrUSb_^4p-@KLY_bl`|!Z}A%yG(#k~l6%0K?cDRXUg2i? zBarbxx|LDqx#b)BJMSHvp61gc6b^MQ-T!j@)!znQFD{z^CunaR}YNC?ty0)`R+ z&g29$lsi;E8%zWtV<5>)z4x1uNPyOizsUtcydX(Qo__z|OLUIhATN<;$#dix@)Ws5 zbod0|adMvgo4iHdA=k+7$ZyDx$?N22yRuv;qO5V3bEKPZvOI#3BI~hLl36P@B*I?a)r84KYv_ zCT_qu?u?}9PGgKTCM8Ogg*zW{;m$-7qvC>g)fhJ76cfSD-|ao;+Xq*hs1R4W=`Le4klx8cS@9_nG((2(Uy^aI3G5cQB|+R;zDPlEelv(Lw`kwkTJ4 zzLiS`zKsG{Os!S=e8Esq^7)jwi$oM=2TsWS{iJY#P(-$9fg)B*k?~N}9|?+5RN2vO zP;6SY&FUYYo-P<#^y-f6fXyZ)#6UrJ>JTqRxl<8}%}fL$1&iw#vS%~jsRh`)>b9*a z5cdZ}DHskN6u^-M}%V z+2Ys%ry~T{4q^!DD5eyu>;a~u6yJ*{+2XvKQlbY>Y7w@7lo}8Xk5Z~feVqR()gfz+ z*|~FIUVXXgtuYQxgd=y5T2bx#xTl7*GKoKaU8 Uvx>QV@#*tNK-8o+S6tnH08yRgMgRZ+ diff --git a/python/inkbox/mail/resources/mailboxes.py b/python/inkbox/mail/resources/mailboxes.py index 4a7ebe4..6ea88d1 100644 --- a/python/inkbox/mail/resources/mailboxes.py +++ b/python/inkbox/mail/resources/mailboxes.py @@ -23,22 +23,19 @@ def __init__(self, http: HttpTransport) -> None: def create( self, *, - agent_handle: str, display_name: str | None = None, ) -> Mailbox: - """Create a new mailbox and assign it to an agent identity. + """Create a new mailbox. The email address is automatically generated by the server. Args: - agent_handle: Handle of the agent identity to assign this mailbox to - (e.g. ``"sales-agent"`` or ``"@sales-agent"``). display_name: Optional human-readable name shown as the sender. Returns: The created mailbox. """ - body: dict[str, Any] = {"agent_handle": agent_handle} + body: dict[str, Any] = {} if display_name is not None: body["display_name"] = display_name data = self._http.post(_BASE, json=body) diff --git a/python/inkbox/phone/resources/calls.py b/python/inkbox/phone/resources/calls.py index 488d5aa..8a085a0 100644 --- a/python/inkbox/phone/resources/calls.py +++ b/python/inkbox/phone/resources/calls.py @@ -58,7 +58,8 @@ def place( *, from_number: str, to_number: str, - client_websocket_url: str | None = None, + stream_url: str | None = None, + pipeline_mode: str | None = None, webhook_url: str | None = None, ) -> PhoneCallWithRateLimit: """Place an outbound call. @@ -66,8 +67,8 @@ def place( Args: from_number: E.164 number to call from. Must belong to your org and be active. to_number: E.164 number to call. - client_websocket_url: WebSocket URL (wss://) for audio bridging. Falls back - to the phone number's ``client_websocket_url``. + stream_url: WebSocket URL (wss://) for audio bridging. + pipeline_mode: Pipeline mode override for this call. webhook_url: Custom webhook URL for call lifecycle events. Returns: @@ -77,8 +78,10 @@ def place( "from_number": from_number, "to_number": to_number, } - if client_websocket_url is not None: - body["client_websocket_url"] = client_websocket_url + if stream_url is not None: + body["stream_url"] = stream_url + if pipeline_mode is not None: + body["pipeline_mode"] = pipeline_mode if webhook_url is not None: body["webhook_url"] = webhook_url data = self._http.post("/place-call", json=body) diff --git a/python/inkbox/phone/resources/numbers.py b/python/inkbox/phone/resources/numbers.py index c948147..2c26ad0 100644 --- a/python/inkbox/phone/resources/numbers.py +++ b/python/inkbox/phone/resources/numbers.py @@ -36,7 +36,8 @@ def update( phone_number_id: UUID | str, *, incoming_call_action: str | None = None, - client_websocket_url: str | None = None, + default_stream_url: str | None = None, + default_pipeline_mode: str | None = None, ) -> PhoneNumber: """Update phone number settings. @@ -45,47 +46,47 @@ def update( Args: phone_number_id: UUID of the phone number. incoming_call_action: ``"auto_accept"``, ``"auto_reject"``, or ``"webhook"``. - client_websocket_url: WebSocket URL (wss://) for audio bridging on ``auto_accept``. + default_stream_url: Default WebSocket URL (wss://) for audio bridging. + default_pipeline_mode: Default pipeline mode for calls on this number. """ body: dict[str, Any] = {} if incoming_call_action is not None: body["incoming_call_action"] = incoming_call_action - if client_websocket_url is not None: - body["client_websocket_url"] = client_websocket_url + if default_stream_url is not None: + body["default_stream_url"] = default_stream_url + if default_pipeline_mode is not None: + body["default_pipeline_mode"] = default_pipeline_mode data = self._http.patch(f"{_BASE}/{phone_number_id}", json=body) return PhoneNumber._from_dict(data) def provision( self, *, - agent_handle: str, type: str = "toll_free", state: str | None = None, ) -> PhoneNumber: - """Provision a new phone number via Telnyx and assign it to an agent identity. + """Provision a new phone number. Args: - agent_handle: Handle of the agent identity to assign this number to - (e.g. ``"sales-agent"`` or ``"@sales-agent"``). type: ``"toll_free"`` or ``"local"``. state: US state abbreviation (e.g. ``"NY"``). Only valid for ``local`` numbers. Returns: The provisioned phone number. """ - body: dict[str, Any] = {"agent_handle": agent_handle, "type": type} + body: dict[str, Any] = {"type": type} if state is not None: body["state"] = state - data = self._http.post(_BASE, json=body) + data = self._http.post(f"{_BASE}/provision", json=body) return PhoneNumber._from_dict(data) - def release(self, phone_number_id: UUID | str) -> None: - """Release (delete) a phone number by ID. + def release(self, *, number: str) -> None: + """Release a phone number. Args: - phone_number_id: UUID of the phone number to release. + number: E.164 phone number to release (e.g. ``"+18335794607"``). """ - self._http.delete(f"{_BASE}/{phone_number_id}") + self._http.post(f"{_BASE}/release", json={"number": number}) def search_transcripts( self, diff --git a/python/inkbox/phone/types.py b/python/inkbox/phone/types.py index c2259eb..1ca07be 100644 --- a/python/inkbox/phone/types.py +++ b/python/inkbox/phone/types.py @@ -25,7 +25,8 @@ class PhoneNumber: type: str status: str incoming_call_action: str - client_websocket_url: str | None + default_stream_url: str | None + default_pipeline_mode: str created_at: datetime updated_at: datetime @@ -37,7 +38,8 @@ def _from_dict(cls, d: dict[str, Any]) -> PhoneNumber: type=d["type"], status=d["status"], incoming_call_action=d["incoming_call_action"], - client_websocket_url=d.get("client_websocket_url"), + default_stream_url=d.get("default_stream_url"), + default_pipeline_mode=d.get("default_pipeline_mode", "client_llm_only"), created_at=datetime.fromisoformat(d["created_at"]), updated_at=datetime.fromisoformat(d["updated_at"]), ) @@ -52,10 +54,8 @@ class PhoneCall: remote_phone_number: str direction: str status: str - client_websocket_url: str | None - use_inkbox_tts: bool | None - use_inkbox_stt: bool | None - hangup_reason: str | None + pipeline_mode: str | None + stream_url: str | None started_at: datetime | None ended_at: datetime | None created_at: datetime @@ -69,10 +69,8 @@ def _from_dict(cls, d: dict[str, Any]) -> PhoneCall: remote_phone_number=d["remote_phone_number"], direction=d["direction"], status=d["status"], - client_websocket_url=d.get("client_websocket_url"), - use_inkbox_tts=d.get("use_inkbox_tts"), - use_inkbox_stt=d.get("use_inkbox_stt"), - hangup_reason=d.get("hangup_reason"), + pipeline_mode=d.get("pipeline_mode"), + stream_url=d.get("stream_url"), started_at=_dt(d.get("started_at")), ended_at=_dt(d.get("ended_at")), created_at=datetime.fromisoformat(d["created_at"]), @@ -117,7 +115,7 @@ def _from_dict(cls, d: dict[str, Any]) -> PhoneCallWithRateLimit: # type: ignor base = PhoneCall._from_dict(d) return cls( **base.__dict__, - rate_limit=RateLimitInfo._from_dict(d["rate_limit"]), + rate_limit=RateLimitInfo._from_dict(d["rate_limit"]) if d.get("rate_limit") else None, ) diff --git a/scripts/test_phone.py b/scripts/test_phone.py deleted file mode 100644 index 9d2c827..0000000 --- a/scripts/test_phone.py +++ /dev/null @@ -1,139 +0,0 @@ -""" -Quick smoke test for the Inkbox Phone Python SDK. - -Loads INKBOX_API_KEY from ../.env and exercises every endpoint. -""" - -import os -import sys -from pathlib import Path - -from dotenv import load_dotenv -from inkbox.phone import InkboxPhone - -load_dotenv(Path(__file__).resolve().parent.parent / ".env") - -API_KEY = os.environ.get("INKBOX_API_KEY") -if not API_KEY: - print("ERROR: INKBOX_API_KEY not set in .env or environment") - sys.exit(1) - -def main(): - with InkboxPhone(api_key=API_KEY) as client: - - # --- Numbers --- - print("=== Listing phone numbers ===") - numbers = client.numbers.list() - for n in numbers: - print(f" {n.number} type={n.type} status={n.status} action={n.incoming_call_action}") - - if not numbers: - print("ERROR: No phone numbers found. Attach a number to your account first.") - sys.exit(1) - - number = numbers[0] - print(f"\nUsing first number: {number.number} (id={number.id})") - - print("\n=== Get phone number ===") - fetched = client.numbers.get(number.id) - print(f" {fetched.number} pipeline_mode={fetched.default_pipeline_mode}") - - print("\n=== Update phone number ===") - updated = client.numbers.update( - number.id, - incoming_call_action="auto_reject", - ) - print(f" incoming_call_action={updated.incoming_call_action}") - - # --- Calls --- - print("\n=== Listing calls ===") - calls = client.calls.list(number.id, limit=5) - for c in calls: - print(f" {c.id} {c.direction} {c.remote_phone_number} status={c.status}") - - if calls: - call = calls[0] - print(f"\n=== Get call {call.id} ===") - detail = client.calls.get(number.id, call.id) - print(f" {detail.direction} {detail.remote_phone_number} pipeline={detail.pipeline_mode}") - - # --- Transcripts (first call) --- - print(f"\n=== Listing transcripts for call {call.id} ===") - transcripts = client.transcripts.list(number.id, call.id) - for t in transcripts: - print(f" [{t.party}] seq={t.seq} ts={t.ts_ms}ms: {t.text[:80]}") - - # --- Outbound call transcripts --- - outbound = [c for c in calls if c.direction == "outbound"] - if outbound: - ob = outbound[0] - print(f"\n=== Get outbound call {ob.id} ===") - ob_detail = client.calls.get(number.id, ob.id) - print(f" {ob_detail.direction} {ob_detail.remote_phone_number} status={ob_detail.status}") - - print(f"\n=== Listing transcripts for outbound call {ob.id} ===") - ob_transcripts = client.transcripts.list(number.id, ob.id) - if ob_transcripts: - for t in ob_transcripts: - print(f" [{t.party}] seq={t.seq} ts={t.ts_ms}ms: {t.text[:80]}") - else: - print(" (no transcripts)") - else: - # Try fetching more calls to find an outbound one - all_calls = client.calls.list(number.id, limit=200) - outbound = [c for c in all_calls if c.direction == "outbound"] - if outbound: - ob = outbound[0] - print(f"\n=== Get outbound call {ob.id} ===") - ob_detail = client.calls.get(number.id, ob.id) - print(f" {ob_detail.direction} {ob_detail.remote_phone_number} status={ob_detail.status}") - - print(f"\n=== Listing transcripts for outbound call {ob.id} ===") - ob_transcripts = client.transcripts.list(number.id, ob.id) - if ob_transcripts: - for t in ob_transcripts: - print(f" [{t.party}] seq={t.seq} ts={t.ts_ms}ms: {t.text[:80]}") - else: - print(" (no transcripts)") - else: - print("\n (no outbound calls found)") - - # --- Search --- - print("\n=== Search transcripts ===") - results = client.numbers.search_transcripts(number.id, q="hello") - print(f" Found {len(results)} results") - for r in results[:3]: - print(f" [{r.party}] {r.text[:80]}") - - # --- Webhooks --- - print("\n=== Listing webhooks ===") - webhooks = client.webhooks.list(number.id) - for wh in webhooks: - print(f" {wh.id} url={wh.url} events={wh.event_types}") - - print("\n=== Creating webhook ===") - hook = client.webhooks.create( - number.id, - url="https://example.com/test-webhook", - event_types=["incoming_call"], - ) - print(f" Created: {hook.id}") - print(f" Secret: {hook.secret}") - - print("\n=== Updating webhook ===") - updated_hook = client.webhooks.update( - number.id, - hook.id, - url="https://example.com/updated-webhook", - ) - print(f" Updated URL: {updated_hook.url}") - - print("\n=== Deleting webhook ===") - client.webhooks.delete(number.id, hook.id) - print(" Deleted.") - - print("\nAll tests passed!") - - -if __name__ == "__main__": - main() diff --git a/scripts/test_phone.ts b/scripts/test_phone.ts deleted file mode 100644 index ede00c1..0000000 --- a/scripts/test_phone.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * Quick smoke test for the Inkbox Phone TypeScript SDK. - * - * Loads INKBOX_API_KEY from ../.env and exercises every endpoint. - */ - -import { readFileSync } from "fs"; -import { resolve, dirname } from "path"; -import { fileURLToPath } from "url"; -import { InkboxPhone } from "../typescript/src/phone/index.js"; - -// Load .env from repo root -const __dirname = dirname(fileURLToPath(import.meta.url)); -const envFile = resolve(__dirname, "../.env"); -try { - const contents = readFileSync(envFile, "utf-8"); - for (const line of contents.split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) continue; - const idx = trimmed.indexOf("="); - const key = trimmed.slice(0, idx).trim(); - const value = trimmed.slice(idx + 1).trim(); - if (!process.env[key]) process.env[key] = value; - } -} catch {} - -const API_KEY = process.env.INKBOX_API_KEY; -if (!API_KEY) { - console.error("ERROR: INKBOX_API_KEY not set in .env or environment"); - process.exit(1); -} - -const client = new InkboxPhone({ apiKey: API_KEY }); - -async function main() { - // --- Numbers --- - console.log("=== Listing phone numbers ==="); - const numbers = await client.numbers.list(); - for (const n of numbers) { - console.log(` ${n.number} type=${n.type} status=${n.status} action=${n.incomingCallAction}`); - } - - if (numbers.length === 0) { - console.error("ERROR: No phone numbers found. Attach a number to your account first."); - process.exit(1); - } - - const number = numbers[0]; - console.log(`\nUsing first number: ${number.number} (id=${number.id})`); - - console.log("\n=== Get phone number ==="); - const fetched = await client.numbers.get(number.id); - console.log(` ${fetched.number} pipeline_mode=${fetched.defaultPipelineMode}`); - - console.log("\n=== Update phone number ==="); - const updated = await client.numbers.update(number.id, { - incomingCallAction: "auto_reject", - }); - console.log(` incomingCallAction=${updated.incomingCallAction}`); - - // --- Calls --- - console.log("\n=== Listing calls ==="); - const calls = await client.calls.list(number.id, { limit: 5 }); - for (const c of calls) { - console.log(` ${c.id} ${c.direction} ${c.remotePhoneNumber} status=${c.status}`); - } - - if (calls.length > 0) { - const call = calls[0]; - console.log(`\n=== Get call ${call.id} ===`); - const detail = await client.calls.get(number.id, call.id); - console.log(` ${detail.direction} ${detail.remotePhoneNumber} pipeline=${detail.pipelineMode}`); - - // --- Transcripts (first call) --- - console.log(`\n=== Listing transcripts for call ${call.id} ===`); - const transcripts = await client.transcripts.list(number.id, call.id); - for (const t of transcripts) { - console.log(` [${t.party}] seq=${t.seq} ts=${t.tsMs}ms: ${t.text.slice(0, 80)}`); - } - - // --- Outbound call transcripts --- - let outbound = calls.filter((c) => c.direction === "outbound"); - if (outbound.length === 0) { - // Try fetching more calls to find an outbound one - const allCalls = await client.calls.list(number.id, { limit: 200 }); - outbound = allCalls.filter((c) => c.direction === "outbound"); - } - - if (outbound.length > 0) { - const ob = outbound[0]; - console.log(`\n=== Get outbound call ${ob.id} ===`); - const obDetail = await client.calls.get(number.id, ob.id); - console.log(` ${obDetail.direction} ${obDetail.remotePhoneNumber} status=${obDetail.status}`); - - console.log(`\n=== Listing transcripts for outbound call ${ob.id} ===`); - const obTranscripts = await client.transcripts.list(number.id, ob.id); - if (obTranscripts.length > 0) { - for (const t of obTranscripts) { - console.log(` [${t.party}] seq=${t.seq} ts=${t.tsMs}ms: ${t.text.slice(0, 80)}`); - } - } else { - console.log(" (no transcripts)"); - } - } else { - console.log("\n (no outbound calls found)"); - } - } - - // --- Search --- - console.log("\n=== Search transcripts ==="); - const results = await client.numbers.searchTranscripts(number.id, { q: "hello" }); - console.log(` Found ${results.length} results`); - for (const r of results.slice(0, 3)) { - console.log(` [${r.party}] ${r.text.slice(0, 80)}`); - } - - // --- Webhooks --- - console.log("\n=== Listing webhooks ==="); - const webhooks = await client.webhooks.list(number.id); - for (const wh of webhooks) { - console.log(` ${wh.id} url=${wh.url} events=${wh.eventTypes}`); - } - - console.log("\n=== Creating webhook ==="); - const hook = await client.webhooks.create(number.id, { - url: "https://example.com/test-webhook", - eventTypes: ["incoming_call"], - }); - console.log(` Created: ${hook.id}`); - console.log(` Secret: ${hook.secret}`); - - console.log("\n=== Updating webhook ==="); - const updatedHook = await client.webhooks.update(number.id, hook.id, { - url: "https://example.com/updated-webhook", - }); - console.log(` Updated URL: ${updatedHook.url}`); - - console.log("\n=== Deleting webhook ==="); - await client.webhooks.delete(number.id, hook.id); - console.log(" Deleted."); - - console.log("\nAll tests passed!"); -} - -main().catch((err) => { - console.error("FAILED:", err); - process.exit(1); -}); From 49cae6c265af20251aa2e6c05891974b5cff8cbc Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:44:39 -0400 Subject: [PATCH 12/56] feat: examples --- examples/python/list_calls.py | 21 ++++++++++++ examples/python/list_phone_numbers.py | 16 +++++++++ examples/python/manage_identities.py | 33 +++++++++++++++++++ examples/python/manage_webhooks.py | 32 ++++++++++++++++++ examples/typescript/list-calls.ts | 22 +++++++++++++ examples/typescript/list-phone-numbers.ts | 16 +++++++++ examples/typescript/manage-identities.ts | 40 +++++++++++++++++++++++ examples/typescript/manage-webhooks.ts | 28 ++++++++++++++++ 8 files changed, 208 insertions(+) create mode 100644 examples/python/list_calls.py create mode 100644 examples/python/list_phone_numbers.py create mode 100644 examples/python/manage_identities.py create mode 100644 examples/python/manage_webhooks.py create mode 100644 examples/typescript/list-calls.ts create mode 100644 examples/typescript/list-phone-numbers.ts create mode 100644 examples/typescript/manage-identities.ts create mode 100644 examples/typescript/manage-webhooks.ts diff --git a/examples/python/list_calls.py b/examples/python/list_calls.py new file mode 100644 index 0000000..7d8967a --- /dev/null +++ b/examples/python/list_calls.py @@ -0,0 +1,21 @@ +""" +List recent calls and their transcripts for a phone number. + +Usage: + INKBOX_API_KEY=sk-... PHONE_NUMBER_ID= python list_calls.py +""" + +import os +from inkbox.phone import InkboxPhone + +client = InkboxPhone(api_key=os.environ["INKBOX_API_KEY"]) +phone_number_id = os.environ["PHONE_NUMBER_ID"] + +calls = client.calls.list(phone_number_id, limit=10) + +for call in calls: + print(f"\n{call.id} {call.direction} {call.remote_phone_number} status={call.status}") + + transcripts = client.transcripts.list(phone_number_id, call.id) + for t in transcripts: + print(f" [{t.party}] {t.text}") diff --git a/examples/python/list_phone_numbers.py b/examples/python/list_phone_numbers.py new file mode 100644 index 0000000..d0d9482 --- /dev/null +++ b/examples/python/list_phone_numbers.py @@ -0,0 +1,16 @@ +""" +List all phone numbers attached to your Inkbox account. + +Usage: + INKBOX_API_KEY=sk-... python list_phone_numbers.py +""" + +import os +from inkbox.phone import InkboxPhone + +client = InkboxPhone(api_key=os.environ["INKBOX_API_KEY"]) + +numbers = client.numbers.list() + +for n in numbers: + print(f"{n.number} type={n.type} status={n.status}") diff --git a/examples/python/manage_identities.py b/examples/python/manage_identities.py new file mode 100644 index 0000000..2b90ad1 --- /dev/null +++ b/examples/python/manage_identities.py @@ -0,0 +1,33 @@ +""" +Create an agent identity and assign communication channels to it. + +Usage: + INKBOX_API_KEY=sk-... MAILBOX_ID= PHONE_NUMBER_ID= python manage_identities.py +""" + +import os +from inkbox.identities import InkboxIdentities + +with InkboxIdentities(api_key=os.environ["INKBOX_API_KEY"]) as client: + # Create + identity = client.identities.create(agent_handle="sales-agent") + print(f"Created identity: {identity.agent_handle} (id={identity.id})") + + # Assign channels + if mailbox_id := os.environ.get("MAILBOX_ID"): + with_mailbox = client.identities.assign_mailbox("sales-agent", mailbox_id=mailbox_id) + print(f"Assigned mailbox: {with_mailbox.mailbox.email_address}") + + if phone_number_id := os.environ.get("PHONE_NUMBER_ID"): + with_phone = client.identities.assign_phone_number("sales-agent", phone_number_id=phone_number_id) + print(f"Assigned phone: {with_phone.phone_number.number}") + + # List all identities + all_identities = client.identities.list() + print(f"\nAll identities ({len(all_identities)}):") + for ident in all_identities: + print(f" {ident.agent_handle} status={ident.status}") + + # Clean up + client.identities.delete("sales-agent") + print("\nDeleted sales-agent.") diff --git a/examples/python/manage_webhooks.py b/examples/python/manage_webhooks.py new file mode 100644 index 0000000..4fe392b --- /dev/null +++ b/examples/python/manage_webhooks.py @@ -0,0 +1,32 @@ +""" +Create, update, and delete a webhook on a phone number. + +Usage: + INKBOX_API_KEY=sk-... PHONE_NUMBER_ID= python manage_webhooks.py +""" + +import os +from inkbox.phone import InkboxPhone + +client = InkboxPhone(api_key=os.environ["INKBOX_API_KEY"]) +phone_number_id = os.environ["PHONE_NUMBER_ID"] + +# Create +hook = client.webhooks.create( + phone_number_id, + url="https://example.com/webhook", + event_types=["incoming_call"], +) +print(f"Created webhook {hook.id} secret={hook.secret}") + +# Update +updated = client.webhooks.update( + phone_number_id, + hook.id, + url="https://example.com/webhook-v2", +) +print(f"Updated URL: {updated.url}") + +# Delete +client.webhooks.delete(phone_number_id, hook.id) +print("Deleted.") diff --git a/examples/typescript/list-calls.ts b/examples/typescript/list-calls.ts new file mode 100644 index 0000000..7a8abbc --- /dev/null +++ b/examples/typescript/list-calls.ts @@ -0,0 +1,22 @@ +/** + * List recent calls and their transcripts for a phone number. + * + * Usage: + * INKBOX_API_KEY=sk-... PHONE_NUMBER_ID= npx ts-node list-calls.ts + */ + +import { InkboxPhone } from "../../typescript/src/phone/index.js"; + +const client = new InkboxPhone({ apiKey: process.env.INKBOX_API_KEY! }); +const phoneNumberId = process.env.PHONE_NUMBER_ID!; + +const calls = await client.calls.list(phoneNumberId, { limit: 10 }); + +for (const call of calls) { + console.log(`\n${call.id} ${call.direction} ${call.remotePhoneNumber} status=${call.status}`); + + const transcripts = await client.transcripts.list(phoneNumberId, call.id); + for (const t of transcripts) { + console.log(` [${t.party}] ${t.text}`); + } +} diff --git a/examples/typescript/list-phone-numbers.ts b/examples/typescript/list-phone-numbers.ts new file mode 100644 index 0000000..406dc0f --- /dev/null +++ b/examples/typescript/list-phone-numbers.ts @@ -0,0 +1,16 @@ +/** + * List all phone numbers attached to your Inkbox account. + * + * Usage: + * INKBOX_API_KEY=sk-... npx ts-node list-phone-numbers.ts + */ + +import { InkboxPhone } from "../../typescript/src/phone/index.js"; + +const client = new InkboxPhone({ apiKey: process.env.INKBOX_API_KEY! }); + +const numbers = await client.numbers.list(); + +for (const n of numbers) { + console.log(`${n.number} type=${n.type} status=${n.status}`); +} diff --git a/examples/typescript/manage-identities.ts b/examples/typescript/manage-identities.ts new file mode 100644 index 0000000..893aa7d --- /dev/null +++ b/examples/typescript/manage-identities.ts @@ -0,0 +1,40 @@ +/** + * Create an agent identity and assign communication channels to it. + * + * Usage: + * INKBOX_API_KEY=sk-... MAILBOX_ID= PHONE_NUMBER_ID= npx ts-node manage-identities.ts + */ + +import { InkboxIdentities } from "../../typescript/src/identities/index.js"; + +const client = new InkboxIdentities({ apiKey: process.env.INKBOX_API_KEY! }); + +// Create +const identity = await client.identities.create({ agentHandle: "sales-agent" }); +console.log(`Created identity: ${identity.agentHandle} (id=${identity.id})`); + +// Assign channels +if (process.env.MAILBOX_ID) { + const withMailbox = await client.identities.assignMailbox("sales-agent", { + mailboxId: process.env.MAILBOX_ID, + }); + console.log(`Assigned mailbox: ${withMailbox.mailbox?.emailAddress}`); +} + +if (process.env.PHONE_NUMBER_ID) { + const withPhone = await client.identities.assignPhoneNumber("sales-agent", { + phoneNumberId: process.env.PHONE_NUMBER_ID, + }); + console.log(`Assigned phone: ${withPhone.phoneNumber?.number}`); +} + +// List all identities +const all = await client.identities.list(); +console.log(`\nAll identities (${all.length}):`); +for (const id of all) { + console.log(` ${id.agentHandle} status=${id.status}`); +} + +// Clean up +await client.identities.delete("sales-agent"); +console.log("\nDeleted sales-agent."); diff --git a/examples/typescript/manage-webhooks.ts b/examples/typescript/manage-webhooks.ts new file mode 100644 index 0000000..055ee62 --- /dev/null +++ b/examples/typescript/manage-webhooks.ts @@ -0,0 +1,28 @@ +/** + * Create, update, and delete a webhook on a phone number. + * + * Usage: + * INKBOX_API_KEY=sk-... PHONE_NUMBER_ID= npx ts-node manage-webhooks.ts + */ + +import { InkboxPhone } from "../../typescript/src/phone/index.js"; + +const client = new InkboxPhone({ apiKey: process.env.INKBOX_API_KEY! }); +const phoneNumberId = process.env.PHONE_NUMBER_ID!; + +// Create +const hook = await client.webhooks.create(phoneNumberId, { + url: "https://example.com/webhook", + eventTypes: ["incoming_call"], +}); +console.log(`Created webhook ${hook.id} secret=${hook.secret}`); + +// Update +const updated = await client.webhooks.update(phoneNumberId, hook.id, { + url: "https://example.com/webhook-v2", +}); +console.log(`Updated URL: ${updated.url}`); + +// Delete +await client.webhooks.delete(phoneNumberId, hook.id); +console.log("Deleted."); From ffa4ac52d7dc369469959ce3c8292ae56d1ec898 Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:48:25 -0400 Subject: [PATCH 13/56] add more tests --- python/.coverage | Bin 53248 -> 53248 bytes python/tests/sample_data_identities.py | 36 ++++++ python/tests/test_exceptions.py | 32 ++++++ python/tests/test_identities.py | 145 +++++++++++++++++++++++++ python/tests/test_identities_client.py | 20 ++++ python/tests/test_identities_types.py | 71 ++++++++++++ python/tests/test_mail_mailboxes.py | 24 ++++ 7 files changed, 328 insertions(+) create mode 100644 python/tests/sample_data_identities.py create mode 100644 python/tests/test_exceptions.py create mode 100644 python/tests/test_identities.py create mode 100644 python/tests/test_identities_client.py create mode 100644 python/tests/test_identities_types.py diff --git a/python/.coverage b/python/.coverage index 4b7e9212fdf31d40f306493785e83625cde70090..6d5ccec1b94d05a723d73d235738dbf6147609e7 100644 GIT binary patch delta 996 zcmYk3TSydP6vt{(o%znLd*2=NLKi8hAWFzIt-K_HX5=L{wKsDKGcda8x`X6H zgyfJAf%=vht;$j;l((J=9}1Fu5EO-spfFIv61Q(Y)O`A#|NorB`G0(EeOy}~H*_pl zT@-JN3K~QggjJzy_;I;lsnd+fk|N_skM6-@8_C<_cxO zziYr|q!e0FgO6C%J44I)KGxe+P>*Un#LwIE6{Dq4j-M%dFRDwz8Y&kI@s6mXp}4Q? zo?uLb&Tl19krv{kS?o@P;1xcay)DfM5(?~|AQ>tpARSHLmgDXS=bM55b-xWk3Zr`{2IsbGyDMG z#r3#2?y$=^qG>!UowVvbqlwLw-;u=WpP5WY>)d=|WIWN7Z4Z)ex=pVLB{P-bjz}{r z2Z-a5i6eTPeo|+Mdd1C`Gu%ooUa$>_BMu)i_e$oT{s0XK~is{lJusV;K% zIMcs5##98sc+{yyFtIZn`oU3U;)~@4yCTf6BSdd)WAskvn#dj6C!X`RALP(YHbk= z0wV{4PI^m>bY%$)N{sMgR&rn-`l7`XJIm?QwpJcL940i^oKTWw zZ0gX~l;s4ezEnC0Y6mVQO4*0{?d|5d-qiWNH0#fl(Ank_IOs~J2l5A7msLBeuBkVa z6yC&Cd7dw8zk;b9@4w_ zS6fPRv6I`@j>Vcs%wE z@6oo=%1lHS^lCF3OH`tG&q!uaFv6@UF<7IPghOl{RkN`R4_2hF0V$YM>YVGca+hA* zGBDV=E)+Hd#bFguk{U3EaCW)TCT!(P*?9}0?=uH%yBOu{)UVyza7VRjvFM1aLzRL_ zKaTFGGFUG6_${VmptDpJVb(e{xiODo6&j7+@-r%{qi%NZBWvM}cwa#BiZG`rtRzlS z!;B$dW{``!ZEi;Go86KvvBD?Zz4MNyiN#546^U4ob)5(XLdb)D1V3RKCg2|2g)tb0 ze)fb{;Uav4HTVqg;H?3#;UO%+GkDBiupVf`xRUXTe!+}z7-0@I#G$(!io=0{Ln0iq n%^_MGA(O*1I3hYnSmSV24u}7!0GKCR<{;9a*2@1FSN8n|L Date: Thu, 12 Mar 2026 15:09:43 -0400 Subject: [PATCH 14/56] include ts tests --- typescript/package.json | 6 +- typescript/tests/errors.test.ts | 25 +++ .../tests/identities/identities.test.ts | 163 ++++++++++++++ typescript/tests/identities/types.test.ts | 66 ++++++ typescript/tests/mail/mailboxes.test.ts | 135 ++++++++++++ typescript/tests/mail/messages.test.ts | 199 ++++++++++++++++++ typescript/tests/mail/threads.test.ts | 82 ++++++++ typescript/tests/mail/types.test.ts | 110 ++++++++++ typescript/tests/mail/webhooks.test.ts | 62 ++++++ typescript/tests/phone/calls.test.ts | 101 +++++++++ typescript/tests/phone/numbers.test.ts | 136 ++++++++++++ typescript/tests/phone/transcripts.test.ts | 38 ++++ typescript/tests/phone/types.test.ts | 107 ++++++++++ typescript/tests/phone/webhooks.test.ts | 96 +++++++++ typescript/tests/sampleData.ts | 194 +++++++++++++++++ typescript/vitest.config.ts | 7 + 16 files changed, 1526 insertions(+), 1 deletion(-) create mode 100644 typescript/tests/errors.test.ts create mode 100644 typescript/tests/identities/identities.test.ts create mode 100644 typescript/tests/identities/types.test.ts create mode 100644 typescript/tests/mail/mailboxes.test.ts create mode 100644 typescript/tests/mail/messages.test.ts create mode 100644 typescript/tests/mail/threads.test.ts create mode 100644 typescript/tests/mail/types.test.ts create mode 100644 typescript/tests/mail/webhooks.test.ts create mode 100644 typescript/tests/phone/calls.test.ts create mode 100644 typescript/tests/phone/numbers.test.ts create mode 100644 typescript/tests/phone/transcripts.test.ts create mode 100644 typescript/tests/phone/types.test.ts create mode 100644 typescript/tests/phone/webhooks.test.ts create mode 100644 typescript/tests/sampleData.ts create mode 100644 typescript/vitest.config.ts diff --git a/typescript/package.json b/typescript/package.json index f597654..707e129 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -19,10 +19,14 @@ "files": ["dist"], "scripts": { "build": "tsc", + "test": "vitest run", + "test:coverage": "vitest run --coverage", "prepublishOnly": "npm run build" }, "devDependencies": { - "typescript": "^5.4.0" + "@vitest/coverage-v8": "^2.0.0", + "typescript": "^5.4.0", + "vitest": "^2.0.0" }, "repository": { "type": "git", diff --git a/typescript/tests/errors.test.ts b/typescript/tests/errors.test.ts new file mode 100644 index 0000000..16159e0 --- /dev/null +++ b/typescript/tests/errors.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { InkboxAPIError } from "../src/_http.js"; + +describe("InkboxAPIError", () => { + it("formats the message correctly", () => { + const err = new InkboxAPIError(404, "not found"); + expect(err.message).toBe("HTTP 404: not found"); + }); + + it("exposes statusCode and detail", () => { + const err = new InkboxAPIError(422, "validation error"); + expect(err.statusCode).toBe(422); + expect(err.detail).toBe("validation error"); + }); + + it("sets name to InkboxAPIError", () => { + const err = new InkboxAPIError(500, "server error"); + expect(err.name).toBe("InkboxAPIError"); + }); + + it("is an instance of Error", () => { + const err = new InkboxAPIError(403, "forbidden"); + expect(err).toBeInstanceOf(Error); + }); +}); diff --git a/typescript/tests/identities/identities.test.ts b/typescript/tests/identities/identities.test.ts new file mode 100644 index 0000000..d1aeef7 --- /dev/null +++ b/typescript/tests/identities/identities.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, vi } from "vitest"; +import { IdentitiesResource } from "../../src/identities/resources/identities.js"; +import type { HttpTransport } from "../../src/_http.js"; +import { RAW_IDENTITY, RAW_IDENTITY_DETAIL } from "../sampleData.js"; + +function mockHttp() { + return { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + } as unknown as HttpTransport; +} + +const HANDLE = "sales-agent"; + +describe("IdentitiesResource.create", () => { + it("posts and returns AgentIdentity", async () => { + const http = mockHttp(); + vi.mocked(http.post).mockResolvedValue(RAW_IDENTITY); + const res = new IdentitiesResource(http); + + const identity = await res.create({ agentHandle: HANDLE }); + + expect(http.post).toHaveBeenCalledWith("/", { agent_handle: HANDLE }); + expect(identity.agentHandle).toBe(HANDLE); + }); +}); + +describe("IdentitiesResource.list", () => { + it("returns list of identities", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue([RAW_IDENTITY]); + const res = new IdentitiesResource(http); + + const identities = await res.list(); + + expect(http.get).toHaveBeenCalledWith("/"); + expect(identities).toHaveLength(1); + expect(identities[0].agentHandle).toBe(HANDLE); + }); + + it("returns empty list", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue([]); + const res = new IdentitiesResource(http); + expect(await res.list()).toEqual([]); + }); +}); + +describe("IdentitiesResource.get", () => { + it("returns AgentIdentityDetail", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue(RAW_IDENTITY_DETAIL); + const res = new IdentitiesResource(http); + + const detail = await res.get(HANDLE); + + expect(http.get).toHaveBeenCalledWith(`/${HANDLE}`); + expect(detail.mailbox!.emailAddress).toBe("sales-agent@inkbox.ai"); + }); +}); + +describe("IdentitiesResource.update", () => { + it("sends newHandle", async () => { + const http = mockHttp(); + vi.mocked(http.patch).mockResolvedValue({ ...RAW_IDENTITY, agent_handle: "new-handle" }); + const res = new IdentitiesResource(http); + + const result = await res.update(HANDLE, { newHandle: "new-handle" }); + + expect(http.patch).toHaveBeenCalledWith(`/${HANDLE}`, { agent_handle: "new-handle" }); + expect(result.agentHandle).toBe("new-handle"); + }); + + it("sends status", async () => { + const http = mockHttp(); + vi.mocked(http.patch).mockResolvedValue({ ...RAW_IDENTITY, status: "paused" }); + const res = new IdentitiesResource(http); + + const result = await res.update(HANDLE, { status: "paused" }); + + expect(http.patch).toHaveBeenCalledWith(`/${HANDLE}`, { status: "paused" }); + expect(result.status).toBe("paused"); + }); + + it("omits undefined fields", async () => { + const http = mockHttp(); + vi.mocked(http.patch).mockResolvedValue(RAW_IDENTITY); + const res = new IdentitiesResource(http); + + await res.update(HANDLE, { status: "active" }); + + const [, body] = vi.mocked(http.patch).mock.calls[0] as [string, Record]; + expect(body["agent_handle"]).toBeUndefined(); + }); +}); + +describe("IdentitiesResource.delete", () => { + it("calls delete on the correct path", async () => { + const http = mockHttp(); + vi.mocked(http.delete).mockResolvedValue(undefined); + const res = new IdentitiesResource(http); + + await res.delete(HANDLE); + + expect(http.delete).toHaveBeenCalledWith(`/${HANDLE}`); + }); +}); + +describe("IdentitiesResource.assignMailbox", () => { + it("posts mailbox_id and returns detail", async () => { + const http = mockHttp(); + vi.mocked(http.post).mockResolvedValue(RAW_IDENTITY_DETAIL); + const res = new IdentitiesResource(http); + const mailboxId = "aaaa1111-0000-0000-0000-000000000001"; + + const detail = await res.assignMailbox(HANDLE, { mailboxId }); + + expect(http.post).toHaveBeenCalledWith(`/${HANDLE}/mailbox`, { mailbox_id: mailboxId }); + expect(detail.mailbox!.emailAddress).toBe("sales-agent@inkbox.ai"); + }); +}); + +describe("IdentitiesResource.unlinkMailbox", () => { + it("deletes mailbox link", async () => { + const http = mockHttp(); + vi.mocked(http.delete).mockResolvedValue(undefined); + const res = new IdentitiesResource(http); + + await res.unlinkMailbox(HANDLE); + + expect(http.delete).toHaveBeenCalledWith(`/${HANDLE}/mailbox`); + }); +}); + +describe("IdentitiesResource.assignPhoneNumber", () => { + it("posts phone_number_id and returns detail", async () => { + const http = mockHttp(); + vi.mocked(http.post).mockResolvedValue(RAW_IDENTITY_DETAIL); + const res = new IdentitiesResource(http); + const phoneNumberId = "bbbb2222-0000-0000-0000-000000000001"; + + const detail = await res.assignPhoneNumber(HANDLE, { phoneNumberId }); + + expect(http.post).toHaveBeenCalledWith(`/${HANDLE}/phone_number`, { + phone_number_id: phoneNumberId, + }); + expect(detail.phoneNumber!.number).toBe("+18335794607"); + }); +}); + +describe("IdentitiesResource.unlinkPhoneNumber", () => { + it("deletes phone number link", async () => { + const http = mockHttp(); + vi.mocked(http.delete).mockResolvedValue(undefined); + const res = new IdentitiesResource(http); + + await res.unlinkPhoneNumber(HANDLE); + + expect(http.delete).toHaveBeenCalledWith(`/${HANDLE}/phone_number`); + }); +}); diff --git a/typescript/tests/identities/types.test.ts b/typescript/tests/identities/types.test.ts new file mode 100644 index 0000000..21fde4a --- /dev/null +++ b/typescript/tests/identities/types.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from "vitest"; +import { + parseAgentIdentity, + parseAgentIdentityDetail, + parseIdentityMailbox, + parseIdentityPhoneNumber, +} from "../../src/identities/types.js"; +import { + RAW_IDENTITY, + RAW_IDENTITY_DETAIL, + RAW_IDENTITY_MAILBOX, + RAW_IDENTITY_PHONE, +} from "../sampleData.js"; + +describe("parseAgentIdentity", () => { + it("converts all fields", () => { + const i = parseAgentIdentity(RAW_IDENTITY); + expect(i.id).toBe(RAW_IDENTITY.id); + expect(i.organizationId).toBe("org-abc123"); + expect(i.agentHandle).toBe("sales-agent"); + expect(i.status).toBe("active"); + expect(i.createdAt).toBeInstanceOf(Date); + expect(i.updatedAt).toBeInstanceOf(Date); + }); +}); + +describe("parseAgentIdentityDetail", () => { + it("includes nested mailbox and phone number", () => { + const d = parseAgentIdentityDetail(RAW_IDENTITY_DETAIL); + expect(d.agentHandle).toBe("sales-agent"); + expect(d.mailbox).not.toBeNull(); + expect(d.mailbox!.emailAddress).toBe("sales-agent@inkbox.ai"); + expect(d.phoneNumber).not.toBeNull(); + expect(d.phoneNumber!.number).toBe("+18335794607"); + }); + + it("returns null for missing channels", () => { + const d = parseAgentIdentityDetail({ ...RAW_IDENTITY, mailbox: null, phone_number: null }); + expect(d.mailbox).toBeNull(); + expect(d.phoneNumber).toBeNull(); + }); +}); + +describe("parseIdentityMailbox", () => { + it("converts all fields", () => { + const m = parseIdentityMailbox(RAW_IDENTITY_MAILBOX); + expect(m.id).toBe(RAW_IDENTITY_MAILBOX.id); + expect(m.emailAddress).toBe("sales-agent@inkbox.ai"); + expect(m.displayName).toBe("Sales Agent"); + expect(m.status).toBe("active"); + expect(m.createdAt).toBeInstanceOf(Date); + expect(m.updatedAt).toBeInstanceOf(Date); + }); +}); + +describe("parseIdentityPhoneNumber", () => { + it("converts all fields", () => { + const p = parseIdentityPhoneNumber(RAW_IDENTITY_PHONE); + expect(p.id).toBe(RAW_IDENTITY_PHONE.id); + expect(p.number).toBe("+18335794607"); + expect(p.type).toBe("toll_free"); + expect(p.incomingCallAction).toBe("auto_reject"); + expect(p.clientWebsocketUrl).toBeNull(); + expect(p.createdAt).toBeInstanceOf(Date); + }); +}); diff --git a/typescript/tests/mail/mailboxes.test.ts b/typescript/tests/mail/mailboxes.test.ts new file mode 100644 index 0000000..efe75e3 --- /dev/null +++ b/typescript/tests/mail/mailboxes.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, vi } from "vitest"; +import { MailboxesResource } from "../../src/resources/mailboxes.js"; +import type { HttpTransport } from "../../src/_http.js"; +import { RAW_MAILBOX, RAW_MESSAGE, CURSOR_PAGE_MESSAGES } from "../sampleData.js"; + +function mockHttp() { + return { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + } as unknown as HttpTransport; +} + +const ADDR = "agent01@inkbox.ai"; + +describe("MailboxesResource.create", () => { + it("creates with displayName", async () => { + const http = mockHttp(); + vi.mocked(http.post).mockResolvedValue(RAW_MAILBOX); + const res = new MailboxesResource(http); + + const mailbox = await res.create({ agentHandle: "sales-agent", displayName: "Agent 01" }); + + expect(http.post).toHaveBeenCalledWith("/mailboxes", { + agent_handle: "sales-agent", + display_name: "Agent 01", + }); + expect(mailbox.emailAddress).toBe("agent01@inkbox.ai"); + }); + + it("creates without displayName omits the field", async () => { + const http = mockHttp(); + vi.mocked(http.post).mockResolvedValue(RAW_MAILBOX); + const res = new MailboxesResource(http); + + await res.create({ agentHandle: "sales-agent" }); + + const [, body] = vi.mocked(http.post).mock.calls[0]; + expect((body as Record)["display_name"]).toBeUndefined(); + }); +}); + +describe("MailboxesResource.list", () => { + it("returns array of mailboxes", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue([RAW_MAILBOX]); + const res = new MailboxesResource(http); + + const mailboxes = await res.list(); + + expect(http.get).toHaveBeenCalledWith("/mailboxes"); + expect(mailboxes).toHaveLength(1); + expect(mailboxes[0].emailAddress).toBe("agent01@inkbox.ai"); + }); + + it("returns empty array", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue([]); + const res = new MailboxesResource(http); + expect(await res.list()).toEqual([]); + }); +}); + +describe("MailboxesResource.get", () => { + it("fetches by email address", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue(RAW_MAILBOX); + const res = new MailboxesResource(http); + + const mailbox = await res.get(ADDR); + + expect(http.get).toHaveBeenCalledWith(`/mailboxes/${ADDR}`); + expect(mailbox.displayName).toBe("Agent 01"); + }); +}); + +describe("MailboxesResource.update", () => { + it("sends displayName", async () => { + const http = mockHttp(); + vi.mocked(http.patch).mockResolvedValue({ ...RAW_MAILBOX, display_name: "New Name" }); + const res = new MailboxesResource(http); + + const mailbox = await res.update(ADDR, { displayName: "New Name" }); + + expect(http.patch).toHaveBeenCalledWith(`/mailboxes/${ADDR}`, { display_name: "New Name" }); + expect(mailbox.displayName).toBe("New Name"); + }); + + it("sends empty body when no options provided", async () => { + const http = mockHttp(); + vi.mocked(http.patch).mockResolvedValue(RAW_MAILBOX); + const res = new MailboxesResource(http); + + await res.update(ADDR, {}); + + expect(http.patch).toHaveBeenCalledWith(`/mailboxes/${ADDR}`, {}); + }); +}); + +describe("MailboxesResource.delete", () => { + it("calls delete on the correct path", async () => { + const http = mockHttp(); + vi.mocked(http.delete).mockResolvedValue(undefined); + const res = new MailboxesResource(http); + + await res.delete(ADDR); + + expect(http.delete).toHaveBeenCalledWith(`/mailboxes/${ADDR}`); + }); +}); + +describe("MailboxesResource.search", () => { + it("passes q and default limit", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue(CURSOR_PAGE_MESSAGES); + const res = new MailboxesResource(http); + + const results = await res.search(ADDR, { q: "invoice" }); + + expect(http.get).toHaveBeenCalledWith(`/mailboxes/${ADDR}/search`, { q: "invoice", limit: 50 }); + expect(results).toHaveLength(1); + expect(results[0].subject).toBe("Hello from test"); + }); + + it("passes custom limit", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue({ items: [], next_cursor: null, has_more: false }); + const res = new MailboxesResource(http); + + await res.search(ADDR, { q: "test", limit: 10 }); + + expect(http.get).toHaveBeenCalledWith(`/mailboxes/${ADDR}/search`, { q: "test", limit: 10 }); + }); +}); diff --git a/typescript/tests/mail/messages.test.ts b/typescript/tests/mail/messages.test.ts new file mode 100644 index 0000000..48b5975 --- /dev/null +++ b/typescript/tests/mail/messages.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, vi } from "vitest"; +import { MessagesResource } from "../../src/resources/messages.js"; +import type { HttpTransport } from "../../src/_http.js"; +import { + RAW_MESSAGE, + RAW_MESSAGE_DETAIL, + CURSOR_PAGE_MESSAGES, + CURSOR_PAGE_MESSAGES_MULTI, +} from "../sampleData.js"; + +function mockHttp() { + return { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + } as unknown as HttpTransport; +} + +const ADDR = "agent01@inkbox.ai"; +const MSG_ID = "bbbb2222-0000-0000-0000-000000000001"; + +async function collect(gen: AsyncGenerator): Promise { + const items: T[] = []; + for await (const item of gen) items.push(item); + return items; +} + +describe("MessagesResource.list", () => { + it("yields messages from a single page", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue(CURSOR_PAGE_MESSAGES); + const res = new MessagesResource(http); + + const messages = await collect(res.list(ADDR)); + + expect(http.get).toHaveBeenCalledTimes(1); + expect(messages).toHaveLength(1); + expect(messages[0].id).toBe(RAW_MESSAGE.id); + }); + + it("paginates through multiple pages", async () => { + const http = mockHttp(); + vi.mocked(http.get) + .mockResolvedValueOnce(CURSOR_PAGE_MESSAGES_MULTI) + .mockResolvedValueOnce(CURSOR_PAGE_MESSAGES); + const res = new MessagesResource(http); + + const messages = await collect(res.list(ADDR)); + + expect(http.get).toHaveBeenCalledTimes(2); + expect(messages).toHaveLength(2); + }); + + it("returns empty for empty page", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue({ items: [], next_cursor: null, has_more: false }); + const res = new MessagesResource(http); + + const messages = await collect(res.list(ADDR)); + expect(messages).toHaveLength(0); + }); +}); + +describe("MessagesResource.get", () => { + it("returns MessageDetail", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue(RAW_MESSAGE_DETAIL); + const res = new MessagesResource(http); + + const detail = await res.get(ADDR, MSG_ID); + + expect(http.get).toHaveBeenCalledWith(`/mailboxes/${ADDR}/messages/${MSG_ID}`); + expect(detail.bodyText).toBe("Hi there, this is a test message body."); + }); +}); + +describe("MessagesResource.send", () => { + it("sends basic message", async () => { + const http = mockHttp(); + vi.mocked(http.post).mockResolvedValue(RAW_MESSAGE); + const res = new MessagesResource(http); + + const msg = await res.send(ADDR, { to: ["user@example.com"], subject: "Hi" }); + + const [path, body] = vi.mocked(http.post).mock.calls[0]; + expect(path).toBe(`/mailboxes/${ADDR}/messages`); + expect((body as Record)["subject"]).toBe("Hi"); + expect(msg.fromAddress).toBe("user@example.com"); + }); + + it("includes all optional fields", async () => { + const http = mockHttp(); + vi.mocked(http.post).mockResolvedValue(RAW_MESSAGE); + const res = new MessagesResource(http); + + await res.send(ADDR, { + to: ["a@example.com"], + subject: "Test", + bodyText: "plain", + bodyHtml: "

html

", + cc: ["b@example.com"], + bcc: ["c@example.com"], + inReplyToMessageId: "", + }); + + const [, body] = vi.mocked(http.post).mock.calls[0] as [string, Record]; + expect(body["body_text"]).toBe("plain"); + expect(body["body_html"]).toBe("

html

"); + expect(body["in_reply_to_message_id"]).toBe(""); + expect((body["recipients"] as Record)["cc"]).toEqual(["b@example.com"]); + }); + + it("omits optional fields when not provided", async () => { + const http = mockHttp(); + vi.mocked(http.post).mockResolvedValue(RAW_MESSAGE); + const res = new MessagesResource(http); + + await res.send(ADDR, { to: ["a@example.com"], subject: "Test" }); + + const [, body] = vi.mocked(http.post).mock.calls[0] as [string, Record]; + expect(body["body_text"]).toBeUndefined(); + expect(body["in_reply_to_message_id"]).toBeUndefined(); + }); +}); + +describe("MessagesResource.updateFlags", () => { + it("sends is_read flag", async () => { + const http = mockHttp(); + vi.mocked(http.patch).mockResolvedValue(RAW_MESSAGE); + const res = new MessagesResource(http); + + await res.updateFlags(ADDR, MSG_ID, { isRead: true }); + + expect(http.patch).toHaveBeenCalledWith( + `/mailboxes/${ADDR}/messages/${MSG_ID}`, + { is_read: true }, + ); + }); + + it("sends both flags", async () => { + const http = mockHttp(); + vi.mocked(http.patch).mockResolvedValue(RAW_MESSAGE); + const res = new MessagesResource(http); + + await res.updateFlags(ADDR, MSG_ID, { isRead: false, isStarred: true }); + + expect(http.patch).toHaveBeenCalledWith( + `/mailboxes/${ADDR}/messages/${MSG_ID}`, + { is_read: false, is_starred: true }, + ); + }); +}); + +describe("MessagesResource convenience methods", () => { + it("markRead delegates to updateFlags", async () => { + const http = mockHttp(); + vi.mocked(http.patch).mockResolvedValue(RAW_MESSAGE); + const res = new MessagesResource(http); + await res.markRead(ADDR, MSG_ID); + expect(http.patch).toHaveBeenCalledWith(expect.any(String), { is_read: true }); + }); + + it("markUnread delegates to updateFlags", async () => { + const http = mockHttp(); + vi.mocked(http.patch).mockResolvedValue(RAW_MESSAGE); + const res = new MessagesResource(http); + await res.markUnread(ADDR, MSG_ID); + expect(http.patch).toHaveBeenCalledWith(expect.any(String), { is_read: false }); + }); + + it("star delegates to updateFlags", async () => { + const http = mockHttp(); + vi.mocked(http.patch).mockResolvedValue(RAW_MESSAGE); + const res = new MessagesResource(http); + await res.star(ADDR, MSG_ID); + expect(http.patch).toHaveBeenCalledWith(expect.any(String), { is_starred: true }); + }); + + it("unstar delegates to updateFlags", async () => { + const http = mockHttp(); + vi.mocked(http.patch).mockResolvedValue(RAW_MESSAGE); + const res = new MessagesResource(http); + await res.unstar(ADDR, MSG_ID); + expect(http.patch).toHaveBeenCalledWith(expect.any(String), { is_starred: false }); + }); +}); + +describe("MessagesResource.delete", () => { + it("calls delete on the correct path", async () => { + const http = mockHttp(); + vi.mocked(http.delete).mockResolvedValue(undefined); + const res = new MessagesResource(http); + + await res.delete(ADDR, MSG_ID); + + expect(http.delete).toHaveBeenCalledWith(`/mailboxes/${ADDR}/messages/${MSG_ID}`); + }); +}); diff --git a/typescript/tests/mail/threads.test.ts b/typescript/tests/mail/threads.test.ts new file mode 100644 index 0000000..8928b7b --- /dev/null +++ b/typescript/tests/mail/threads.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi } from "vitest"; +import { ThreadsResource } from "../../src/resources/threads.js"; +import type { HttpTransport } from "../../src/_http.js"; +import { RAW_THREAD, RAW_THREAD_DETAIL, CURSOR_PAGE_THREADS } from "../sampleData.js"; + +function mockHttp() { + return { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + } as unknown as HttpTransport; +} + +const ADDR = "agent01@inkbox.ai"; +const THREAD_ID = "eeee5555-0000-0000-0000-000000000001"; + +async function collect(gen: AsyncGenerator): Promise { + const items: T[] = []; + for await (const item of gen) items.push(item); + return items; +} + +describe("ThreadsResource.list", () => { + it("yields threads from a single page", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue(CURSOR_PAGE_THREADS); + const res = new ThreadsResource(http); + + const threads = await collect(res.list(ADDR)); + + expect(http.get).toHaveBeenCalledTimes(1); + expect(threads).toHaveLength(1); + expect(threads[0].subject).toBe("Hello from test"); + }); + + it("paginates through multiple pages", async () => { + const http = mockHttp(); + vi.mocked(http.get) + .mockResolvedValueOnce({ items: [RAW_THREAD], next_cursor: "cur-1", has_more: true }) + .mockResolvedValueOnce(CURSOR_PAGE_THREADS); + const res = new ThreadsResource(http); + + const threads = await collect(res.list(ADDR)); + + expect(http.get).toHaveBeenCalledTimes(2); + expect(threads).toHaveLength(2); + }); + + it("returns empty for empty page", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue({ items: [], next_cursor: null, has_more: false }); + const res = new ThreadsResource(http); + expect(await collect(res.list(ADDR))).toHaveLength(0); + }); +}); + +describe("ThreadsResource.get", () => { + it("returns ThreadDetail with nested messages", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue(RAW_THREAD_DETAIL); + const res = new ThreadsResource(http); + + const detail = await res.get(ADDR, THREAD_ID); + + expect(http.get).toHaveBeenCalledWith(`/mailboxes/${ADDR}/threads/${THREAD_ID}`); + expect(detail.messageCount).toBe(2); + expect(detail.messages).toHaveLength(1); + }); +}); + +describe("ThreadsResource.delete", () => { + it("calls delete on the correct path", async () => { + const http = mockHttp(); + vi.mocked(http.delete).mockResolvedValue(undefined); + const res = new ThreadsResource(http); + + await res.delete(ADDR, THREAD_ID); + + expect(http.delete).toHaveBeenCalledWith(`/mailboxes/${ADDR}/threads/${THREAD_ID}`); + }); +}); diff --git a/typescript/tests/mail/types.test.ts b/typescript/tests/mail/types.test.ts new file mode 100644 index 0000000..9849152 --- /dev/null +++ b/typescript/tests/mail/types.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from "vitest"; +import { + parseMailbox, + parseMessage, + parseMessageDetail, + parseThread, + parseThreadDetail, + parseWebhook, + parseWebhookCreateResult, +} from "../../src/types.js"; +import { + RAW_MAILBOX, + RAW_MESSAGE, + RAW_MESSAGE_DETAIL, + RAW_THREAD, + RAW_THREAD_DETAIL, + RAW_WEBHOOK, + RAW_WEBHOOK_CREATE, +} from "../sampleData.js"; + +describe("parseMailbox", () => { + it("converts snake_case to camelCase with Date instances", () => { + const m = parseMailbox(RAW_MAILBOX); + expect(m.id).toBe(RAW_MAILBOX.id); + expect(m.emailAddress).toBe("agent01@inkbox.ai"); + expect(m.displayName).toBe("Agent 01"); + expect(m.status).toBe("active"); + expect(m.createdAt).toBeInstanceOf(Date); + expect(m.updatedAt).toBeInstanceOf(Date); + }); +}); + +describe("parseMessage", () => { + it("converts all fields", () => { + const msg = parseMessage(RAW_MESSAGE); + expect(msg.id).toBe(RAW_MESSAGE.id); + expect(msg.mailboxId).toBe(RAW_MESSAGE.mailbox_id); + expect(msg.threadId).toBe(RAW_MESSAGE.thread_id); + expect(msg.fromAddress).toBe("user@example.com"); + expect(msg.toAddresses).toEqual(["agent01@inkbox.ai"]); + expect(msg.ccAddresses).toBeNull(); + expect(msg.subject).toBe("Hello from test"); + expect(msg.isRead).toBe(false); + expect(msg.isStarred).toBe(false); + expect(msg.hasAttachments).toBe(false); + expect(msg.createdAt).toBeInstanceOf(Date); + }); + + it("handles null threadId", () => { + const msg = parseMessage({ ...RAW_MESSAGE, thread_id: null }); + expect(msg.threadId).toBeNull(); + }); +}); + +describe("parseMessageDetail", () => { + it("includes body and header fields", () => { + const detail = parseMessageDetail(RAW_MESSAGE_DETAIL); + expect(detail.bodyText).toBe("Hi there, this is a test message body."); + expect(detail.bodyHtml).toBe("

Hi there, this is a test message body.

"); + expect(detail.bccAddresses).toBeNull(); + expect(detail.inReplyTo).toBeNull(); + expect(detail.sesMessageId).toBe("ses-abc123"); + expect(detail.updatedAt).toBeInstanceOf(Date); + }); +}); + +describe("parseThread", () => { + it("converts all fields", () => { + const t = parseThread(RAW_THREAD); + expect(t.id).toBe(RAW_THREAD.id); + expect(t.mailboxId).toBe(RAW_THREAD.mailbox_id); + expect(t.subject).toBe("Hello from test"); + expect(t.messageCount).toBe(2); + expect(t.lastMessageAt).toBeInstanceOf(Date); + expect(t.createdAt).toBeInstanceOf(Date); + }); +}); + +describe("parseThreadDetail", () => { + it("includes nested messages", () => { + const td = parseThreadDetail(RAW_THREAD_DETAIL); + expect(td.messages).toHaveLength(1); + expect(td.messages[0].id).toBe(RAW_MESSAGE.id); + }); + + it("returns empty messages array when missing", () => { + const td = parseThreadDetail({ ...RAW_THREAD }); + expect(td.messages).toEqual([]); + }); +}); + +describe("parseWebhook", () => { + it("converts all fields", () => { + const w = parseWebhook(RAW_WEBHOOK); + expect(w.id).toBe(RAW_WEBHOOK.id); + expect(w.mailboxId).toBe(RAW_WEBHOOK.mailbox_id); + expect(w.url).toBe("https://example.com/hooks/mail"); + expect(w.eventTypes).toEqual(["message.received"]); + expect(w.status).toBe("active"); + expect(w.createdAt).toBeInstanceOf(Date); + }); +}); + +describe("parseWebhookCreateResult", () => { + it("includes secret", () => { + const w = parseWebhookCreateResult(RAW_WEBHOOK_CREATE); + expect(w.secret).toBe("test-hmac-secret-mail-abc123"); + expect(w.url).toBe("https://example.com/hooks/mail"); + }); +}); diff --git a/typescript/tests/mail/webhooks.test.ts b/typescript/tests/mail/webhooks.test.ts new file mode 100644 index 0000000..15d670f --- /dev/null +++ b/typescript/tests/mail/webhooks.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi } from "vitest"; +import { WebhooksResource } from "../../src/resources/webhooks.js"; +import type { HttpTransport } from "../../src/_http.js"; +import { RAW_WEBHOOK, RAW_WEBHOOK_CREATE } from "../sampleData.js"; + +function mockHttp() { + return { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + } as unknown as HttpTransport; +} + +const ADDR = "agent01@inkbox.ai"; +const WEBHOOK_ID = "dddd4444-0000-0000-0000-000000000001"; + +describe("WebhooksResource.create", () => { + it("posts and returns WebhookCreateResult with secret", async () => { + const http = mockHttp(); + vi.mocked(http.post).mockResolvedValue(RAW_WEBHOOK_CREATE); + const res = new WebhooksResource(http); + + const result = await res.create(ADDR, { + url: "https://example.com/hooks/mail", + eventTypes: ["message.received"], + }); + + expect(http.post).toHaveBeenCalledWith(`/mailboxes/${ADDR}/webhooks`, { + url: "https://example.com/hooks/mail", + event_types: ["message.received"], + }); + expect(result.secret).toBe("test-hmac-secret-mail-abc123"); + expect(result.url).toBe("https://example.com/hooks/mail"); + }); +}); + +describe("WebhooksResource.list", () => { + it("returns list of webhooks", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue([RAW_WEBHOOK]); + const res = new WebhooksResource(http); + + const webhooks = await res.list(ADDR); + + expect(http.get).toHaveBeenCalledWith(`/mailboxes/${ADDR}/webhooks`); + expect(webhooks).toHaveLength(1); + expect(webhooks[0].eventTypes).toEqual(["message.received"]); + }); +}); + +describe("WebhooksResource.delete", () => { + it("calls delete on the correct path", async () => { + const http = mockHttp(); + vi.mocked(http.delete).mockResolvedValue(undefined); + const res = new WebhooksResource(http); + + await res.delete(ADDR, WEBHOOK_ID); + + expect(http.delete).toHaveBeenCalledWith(`/mailboxes/${ADDR}/webhooks/${WEBHOOK_ID}`); + }); +}); diff --git a/typescript/tests/phone/calls.test.ts b/typescript/tests/phone/calls.test.ts new file mode 100644 index 0000000..dc463f5 --- /dev/null +++ b/typescript/tests/phone/calls.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi } from "vitest"; +import { CallsResource } from "../../src/phone/resources/calls.js"; +import type { HttpTransport } from "../../src/_http.js"; +import { RAW_PHONE_CALL, RAW_PHONE_CALL_WITH_RATE_LIMIT } from "../sampleData.js"; + +function mockHttp() { + return { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + } as unknown as HttpTransport; +} + +const NUM_ID = "aaaa1111-0000-0000-0000-000000000001"; +const CALL_ID = "bbbb2222-0000-0000-0000-000000000001"; + +describe("CallsResource.list", () => { + it("uses default limit and offset", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue([RAW_PHONE_CALL]); + const res = new CallsResource(http); + + const calls = await res.list(NUM_ID); + + expect(http.get).toHaveBeenCalledWith(`/numbers/${NUM_ID}/calls`, { limit: 50, offset: 0 }); + expect(calls).toHaveLength(1); + expect(calls[0].direction).toBe("outbound"); + }); + + it("passes custom limit and offset", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue([]); + const res = new CallsResource(http); + + await res.list(NUM_ID, { limit: 10, offset: 20 }); + + expect(http.get).toHaveBeenCalledWith(`/numbers/${NUM_ID}/calls`, { limit: 10, offset: 20 }); + }); +}); + +describe("CallsResource.get", () => { + it("fetches by ID", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue(RAW_PHONE_CALL); + const res = new CallsResource(http); + + const call = await res.get(NUM_ID, CALL_ID); + + expect(http.get).toHaveBeenCalledWith(`/numbers/${NUM_ID}/calls/${CALL_ID}`); + expect(call.status).toBe("completed"); + }); +}); + +describe("CallsResource.place", () => { + it("places call with required fields", async () => { + const http = mockHttp(); + vi.mocked(http.post).mockResolvedValue(RAW_PHONE_CALL_WITH_RATE_LIMIT); + const res = new CallsResource(http); + + const call = await res.place({ + fromNumber: "+18335794607", + toNumber: "+15167251294", + }); + + expect(http.post).toHaveBeenCalledWith("/place-call", { + from_number: "+18335794607", + to_number: "+15167251294", + }); + expect(call.rateLimit.callsUsed).toBe(5); + }); + + it("includes optional fields when provided", async () => { + const http = mockHttp(); + vi.mocked(http.post).mockResolvedValue(RAW_PHONE_CALL_WITH_RATE_LIMIT); + const res = new CallsResource(http); + + await res.place({ + fromNumber: "+18335794607", + toNumber: "+15167251294", + clientWebsocketUrl: "wss://agent.example.com/ws", + webhookUrl: "https://example.com/hook", + }); + + const [, body] = vi.mocked(http.post).mock.calls[0] as [string, Record]; + expect(body["client_websocket_url"]).toBe("wss://agent.example.com/ws"); + expect(body["webhook_url"]).toBe("https://example.com/hook"); + }); + + it("omits optional fields when not provided", async () => { + const http = mockHttp(); + vi.mocked(http.post).mockResolvedValue(RAW_PHONE_CALL_WITH_RATE_LIMIT); + const res = new CallsResource(http); + + await res.place({ fromNumber: "+18335794607", toNumber: "+15167251294" }); + + const [, body] = vi.mocked(http.post).mock.calls[0] as [string, Record]; + expect(body["client_websocket_url"]).toBeUndefined(); + expect(body["webhook_url"]).toBeUndefined(); + }); +}); diff --git a/typescript/tests/phone/numbers.test.ts b/typescript/tests/phone/numbers.test.ts new file mode 100644 index 0000000..e9f7349 --- /dev/null +++ b/typescript/tests/phone/numbers.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, vi } from "vitest"; +import { PhoneNumbersResource } from "../../src/phone/resources/numbers.js"; +import type { HttpTransport } from "../../src/_http.js"; +import { RAW_PHONE_NUMBER, RAW_PHONE_TRANSCRIPT } from "../sampleData.js"; + +function mockHttp() { + return { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + } as unknown as HttpTransport; +} + +const NUM_ID = "aaaa1111-0000-0000-0000-000000000001"; + +describe("PhoneNumbersResource.list", () => { + it("returns list of phone numbers", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue([RAW_PHONE_NUMBER]); + const res = new PhoneNumbersResource(http); + + const numbers = await res.list(); + + expect(http.get).toHaveBeenCalledWith("/numbers"); + expect(numbers).toHaveLength(1); + expect(numbers[0].number).toBe("+18335794607"); + }); +}); + +describe("PhoneNumbersResource.get", () => { + it("fetches by ID", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue(RAW_PHONE_NUMBER); + const res = new PhoneNumbersResource(http); + + const number = await res.get(NUM_ID); + + expect(http.get).toHaveBeenCalledWith(`/numbers/${NUM_ID}`); + expect(number.incomingCallAction).toBe("auto_reject"); + }); +}); + +describe("PhoneNumbersResource.update", () => { + it("sends incomingCallAction", async () => { + const http = mockHttp(); + vi.mocked(http.patch).mockResolvedValue(RAW_PHONE_NUMBER); + const res = new PhoneNumbersResource(http); + + await res.update(NUM_ID, { incomingCallAction: "auto_accept" }); + + expect(http.patch).toHaveBeenCalledWith(`/numbers/${NUM_ID}`, { + incoming_call_action: "auto_accept", + }); + }); + + it("omits undefined fields", async () => { + const http = mockHttp(); + vi.mocked(http.patch).mockResolvedValue(RAW_PHONE_NUMBER); + const res = new PhoneNumbersResource(http); + + await res.update(NUM_ID, {}); + + expect(http.patch).toHaveBeenCalledWith(`/numbers/${NUM_ID}`, {}); + }); +}); + +describe("PhoneNumbersResource.provision", () => { + it("defaults to toll_free", async () => { + const http = mockHttp(); + vi.mocked(http.post).mockResolvedValue(RAW_PHONE_NUMBER); + const res = new PhoneNumbersResource(http); + + await res.provision({ agentHandle: "sales-agent" }); + + expect(http.post).toHaveBeenCalledWith("/numbers", { + agent_handle: "sales-agent", + type: "toll_free", + }); + }); + + it("passes type and state for local numbers", async () => { + const http = mockHttp(); + vi.mocked(http.post).mockResolvedValue({ ...RAW_PHONE_NUMBER, type: "local" }); + const res = new PhoneNumbersResource(http); + + const number = await res.provision({ agentHandle: "sales-agent", type: "local", state: "NY" }); + + const [, body] = vi.mocked(http.post).mock.calls[0] as [string, Record]; + expect(body["state"]).toBe("NY"); + expect(number.type).toBe("local"); + }); +}); + +describe("PhoneNumbersResource.release", () => { + it("deletes by ID", async () => { + const http = mockHttp(); + vi.mocked(http.delete).mockResolvedValue(undefined); + const res = new PhoneNumbersResource(http); + + await res.release(NUM_ID); + + expect(http.delete).toHaveBeenCalledWith(`/numbers/${NUM_ID}`); + }); +}); + +describe("PhoneNumbersResource.searchTranscripts", () => { + it("passes query and defaults", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue([RAW_PHONE_TRANSCRIPT]); + const res = new PhoneNumbersResource(http); + + const results = await res.searchTranscripts(NUM_ID, { q: "hello" }); + + expect(http.get).toHaveBeenCalledWith(`/numbers/${NUM_ID}/search`, { + q: "hello", + party: undefined, + limit: 50, + }); + expect(results[0].text).toBe("Hello, how can I help you?"); + }); + + it("passes party and custom limit", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue([]); + const res = new PhoneNumbersResource(http); + + await res.searchTranscripts(NUM_ID, { q: "test", party: "remote", limit: 10 }); + + expect(http.get).toHaveBeenCalledWith(`/numbers/${NUM_ID}/search`, { + q: "test", + party: "remote", + limit: 10, + }); + }); +}); diff --git a/typescript/tests/phone/transcripts.test.ts b/typescript/tests/phone/transcripts.test.ts new file mode 100644 index 0000000..d5e757b --- /dev/null +++ b/typescript/tests/phone/transcripts.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, vi } from "vitest"; +import { TranscriptsResource } from "../../src/phone/resources/transcripts.js"; +import type { HttpTransport } from "../../src/_http.js"; +import { RAW_PHONE_TRANSCRIPT } from "../sampleData.js"; + +function mockHttp() { + return { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + } as unknown as HttpTransport; +} + +const NUM_ID = "aaaa1111-0000-0000-0000-000000000001"; +const CALL_ID = "bbbb2222-0000-0000-0000-000000000001"; + +describe("TranscriptsResource.list", () => { + it("returns transcript segments", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue([RAW_PHONE_TRANSCRIPT]); + const res = new TranscriptsResource(http); + + const transcripts = await res.list(NUM_ID, CALL_ID); + + expect(http.get).toHaveBeenCalledWith(`/numbers/${NUM_ID}/calls/${CALL_ID}/transcripts`); + expect(transcripts).toHaveLength(1); + expect(transcripts[0].text).toBe("Hello, how can I help you?"); + expect(transcripts[0].seq).toBe(0); + }); + + it("returns empty array", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue([]); + const res = new TranscriptsResource(http); + expect(await res.list(NUM_ID, CALL_ID)).toEqual([]); + }); +}); diff --git a/typescript/tests/phone/types.test.ts b/typescript/tests/phone/types.test.ts new file mode 100644 index 0000000..fee8bf2 --- /dev/null +++ b/typescript/tests/phone/types.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from "vitest"; +import { + parsePhoneNumber, + parsePhoneCall, + parseRateLimitInfo, + parsePhoneCallWithRateLimit, + parsePhoneTranscript, + parsePhoneWebhook, + parsePhoneWebhookCreateResult, +} from "../../src/phone/types.js"; +import { + RAW_PHONE_NUMBER, + RAW_PHONE_CALL, + RAW_RATE_LIMIT, + RAW_PHONE_CALL_WITH_RATE_LIMIT, + RAW_PHONE_TRANSCRIPT, + RAW_PHONE_WEBHOOK, + RAW_PHONE_WEBHOOK_CREATE, +} from "../sampleData.js"; + +describe("parsePhoneNumber", () => { + it("converts all fields", () => { + const n = parsePhoneNumber(RAW_PHONE_NUMBER); + expect(n.id).toBe(RAW_PHONE_NUMBER.id); + expect(n.number).toBe("+18335794607"); + expect(n.type).toBe("toll_free"); + expect(n.status).toBe("active"); + expect(n.incomingCallAction).toBe("auto_reject"); + expect(n.clientWebsocketUrl).toBeNull(); + expect(n.createdAt).toBeInstanceOf(Date); + expect(n.updatedAt).toBeInstanceOf(Date); + }); +}); + +describe("parsePhoneCall", () => { + it("converts all fields", () => { + const c = parsePhoneCall(RAW_PHONE_CALL); + expect(c.id).toBe(RAW_PHONE_CALL.id); + expect(c.localPhoneNumber).toBe("+18335794607"); + expect(c.remotePhoneNumber).toBe("+15167251294"); + expect(c.direction).toBe("outbound"); + expect(c.status).toBe("completed"); + expect(c.clientWebsocketUrl).toBe("wss://agent.example.com/ws"); + expect(c.startedAt).toBeInstanceOf(Date); + expect(c.endedAt).toBeInstanceOf(Date); + }); + + it("handles null timestamps", () => { + const c = parsePhoneCall({ ...RAW_PHONE_CALL, started_at: null, ended_at: null }); + expect(c.startedAt).toBeNull(); + expect(c.endedAt).toBeNull(); + }); +}); + +describe("parseRateLimitInfo", () => { + it("converts all fields", () => { + const r = parseRateLimitInfo(RAW_RATE_LIMIT); + expect(r.callsUsed).toBe(5); + expect(r.callsRemaining).toBe(95); + expect(r.callsLimit).toBe(100); + expect(r.minutesUsed).toBe(12.5); + expect(r.minutesRemaining).toBe(987.5); + expect(r.minutesLimit).toBe(1000); + }); +}); + +describe("parsePhoneCallWithRateLimit", () => { + it("includes rateLimit", () => { + const c = parsePhoneCallWithRateLimit(RAW_PHONE_CALL_WITH_RATE_LIMIT); + expect(c.rateLimit.callsUsed).toBe(5); + expect(c.status).toBe("completed"); + }); +}); + +describe("parsePhoneTranscript", () => { + it("converts all fields", () => { + const t = parsePhoneTranscript(RAW_PHONE_TRANSCRIPT); + expect(t.id).toBe(RAW_PHONE_TRANSCRIPT.id); + expect(t.callId).toBe(RAW_PHONE_TRANSCRIPT.call_id); + expect(t.seq).toBe(0); + expect(t.tsMs).toBe(1500); + expect(t.party).toBe("local"); + expect(t.text).toBe("Hello, how can I help you?"); + expect(t.createdAt).toBeInstanceOf(Date); + }); +}); + +describe("parsePhoneWebhook", () => { + it("converts all fields", () => { + const w = parsePhoneWebhook(RAW_PHONE_WEBHOOK); + expect(w.id).toBe(RAW_PHONE_WEBHOOK.id); + expect(w.sourceId).toBe(RAW_PHONE_WEBHOOK.source_id); + expect(w.sourceType).toBe("phone_number"); + expect(w.url).toBe("https://example.com/webhooks/phone"); + expect(w.eventTypes).toEqual(["incoming_call"]); + expect(w.status).toBe("active"); + expect(w.createdAt).toBeInstanceOf(Date); + }); +}); + +describe("parsePhoneWebhookCreateResult", () => { + it("includes secret", () => { + const w = parsePhoneWebhookCreateResult(RAW_PHONE_WEBHOOK_CREATE); + expect(w.secret).toBe("test-hmac-secret-abc123"); + expect(w.url).toBe("https://example.com/webhooks/phone"); + }); +}); diff --git a/typescript/tests/phone/webhooks.test.ts b/typescript/tests/phone/webhooks.test.ts new file mode 100644 index 0000000..0a7bf4b --- /dev/null +++ b/typescript/tests/phone/webhooks.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi } from "vitest"; +import { PhoneWebhooksResource } from "../../src/phone/resources/webhooks.js"; +import type { HttpTransport } from "../../src/_http.js"; +import { RAW_PHONE_WEBHOOK, RAW_PHONE_WEBHOOK_CREATE } from "../sampleData.js"; + +function mockHttp() { + return { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + } as unknown as HttpTransport; +} + +const NUM_ID = "aaaa1111-0000-0000-0000-000000000001"; +const WEBHOOK_ID = "dddd4444-0000-0000-0000-000000000001"; + +describe("PhoneWebhooksResource.create", () => { + it("posts and returns WebhookCreateResult with secret", async () => { + const http = mockHttp(); + vi.mocked(http.post).mockResolvedValue(RAW_PHONE_WEBHOOK_CREATE); + const res = new PhoneWebhooksResource(http); + + const result = await res.create(NUM_ID, { + url: "https://example.com/webhooks/phone", + eventTypes: ["incoming_call"], + }); + + expect(http.post).toHaveBeenCalledWith(`/numbers/${NUM_ID}/webhooks`, { + url: "https://example.com/webhooks/phone", + event_types: ["incoming_call"], + }); + expect(result.secret).toBe("test-hmac-secret-abc123"); + }); +}); + +describe("PhoneWebhooksResource.list", () => { + it("returns list of webhooks", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue([RAW_PHONE_WEBHOOK]); + const res = new PhoneWebhooksResource(http); + + const webhooks = await res.list(NUM_ID); + + expect(http.get).toHaveBeenCalledWith(`/numbers/${NUM_ID}/webhooks`); + expect(webhooks).toHaveLength(1); + expect(webhooks[0].eventTypes).toEqual(["incoming_call"]); + }); +}); + +describe("PhoneWebhooksResource.update", () => { + it("sends url", async () => { + const http = mockHttp(); + vi.mocked(http.patch).mockResolvedValue(RAW_PHONE_WEBHOOK); + const res = new PhoneWebhooksResource(http); + + await res.update(NUM_ID, WEBHOOK_ID, { url: "https://new.example.com/hook" }); + + expect(http.patch).toHaveBeenCalledWith(`/numbers/${NUM_ID}/webhooks/${WEBHOOK_ID}`, { + url: "https://new.example.com/hook", + }); + }); + + it("sends eventTypes", async () => { + const http = mockHttp(); + vi.mocked(http.patch).mockResolvedValue(RAW_PHONE_WEBHOOK); + const res = new PhoneWebhooksResource(http); + + await res.update(NUM_ID, WEBHOOK_ID, { eventTypes: ["call.completed"] }); + + const [, body] = vi.mocked(http.patch).mock.calls[0] as [string, Record]; + expect(body["event_types"]).toEqual(["call.completed"]); + }); + + it("omits undefined fields", async () => { + const http = mockHttp(); + vi.mocked(http.patch).mockResolvedValue(RAW_PHONE_WEBHOOK); + const res = new PhoneWebhooksResource(http); + + await res.update(NUM_ID, WEBHOOK_ID, {}); + + expect(http.patch).toHaveBeenCalledWith(`/numbers/${NUM_ID}/webhooks/${WEBHOOK_ID}`, {}); + }); +}); + +describe("PhoneWebhooksResource.delete", () => { + it("calls delete on the correct path", async () => { + const http = mockHttp(); + vi.mocked(http.delete).mockResolvedValue(undefined); + const res = new PhoneWebhooksResource(http); + + await res.delete(NUM_ID, WEBHOOK_ID); + + expect(http.delete).toHaveBeenCalledWith(`/numbers/${NUM_ID}/webhooks/${WEBHOOK_ID}`); + }); +}); diff --git a/typescript/tests/sampleData.ts b/typescript/tests/sampleData.ts new file mode 100644 index 0000000..7c6d174 --- /dev/null +++ b/typescript/tests/sampleData.ts @@ -0,0 +1,194 @@ +/** Shared raw (snake_case) API response fixtures for tests. */ + +// ---- Mail ---- + +export const RAW_MAILBOX = { + id: "aaaa1111-0000-0000-0000-000000000001", + email_address: "agent01@inkbox.ai", + display_name: "Agent 01", + status: "active", + created_at: "2026-03-09T00:00:00Z", + updated_at: "2026-03-09T00:00:00Z", +}; + +export const RAW_MESSAGE = { + id: "bbbb2222-0000-0000-0000-000000000001", + mailbox_id: "aaaa1111-0000-0000-0000-000000000001", + thread_id: "eeee5555-0000-0000-0000-000000000001", + message_id: "", + from_address: "user@example.com", + to_addresses: ["agent01@inkbox.ai"], + cc_addresses: null, + subject: "Hello from test", + snippet: "Hi there, this is a test message...", + direction: "inbound", + status: "delivered", + is_read: false, + is_starred: false, + has_attachments: false, + created_at: "2026-03-09T00:00:00Z", +}; + +export const RAW_MESSAGE_DETAIL = { + ...RAW_MESSAGE, + body_text: "Hi there, this is a test message body.", + body_html: "

Hi there, this is a test message body.

", + bcc_addresses: null, + in_reply_to: null, + references: null, + attachment_metadata: null, + ses_message_id: "ses-abc123", + updated_at: "2026-03-09T00:00:00Z", +}; + +export const RAW_THREAD = { + id: "eeee5555-0000-0000-0000-000000000001", + mailbox_id: "aaaa1111-0000-0000-0000-000000000001", + subject: "Hello from test", + status: "active", + message_count: 2, + last_message_at: "2026-03-09T00:05:00Z", + created_at: "2026-03-09T00:00:00Z", +}; + +export const RAW_THREAD_DETAIL = { + ...RAW_THREAD, + messages: [RAW_MESSAGE], +}; + +export const RAW_WEBHOOK = { + id: "dddd4444-0000-0000-0000-000000000001", + mailbox_id: "aaaa1111-0000-0000-0000-000000000001", + url: "https://example.com/hooks/mail", + event_types: ["message.received"], + status: "active", + created_at: "2026-03-09T00:00:00Z", +}; + +export const RAW_WEBHOOK_CREATE = { + ...RAW_WEBHOOK, + secret: "test-hmac-secret-mail-abc123", +}; + +export const CURSOR_PAGE_MESSAGES = { + items: [RAW_MESSAGE], + next_cursor: null, + has_more: false, +}; + +export const CURSOR_PAGE_MESSAGES_MULTI = { + items: [RAW_MESSAGE], + next_cursor: "cursor-abc", + has_more: true, +}; + +export const CURSOR_PAGE_THREADS = { + items: [RAW_THREAD], + next_cursor: null, + has_more: false, +}; + +// ---- Phone ---- + +export const RAW_PHONE_NUMBER = { + id: "aaaa1111-0000-0000-0000-000000000001", + number: "+18335794607", + type: "toll_free", + status: "active", + incoming_call_action: "auto_reject", + client_websocket_url: null, + created_at: "2026-03-09T00:00:00Z", + updated_at: "2026-03-09T00:00:00Z", +}; + +export const RAW_PHONE_CALL = { + id: "bbbb2222-0000-0000-0000-000000000001", + local_phone_number: "+18335794607", + remote_phone_number: "+15167251294", + direction: "outbound", + status: "completed", + client_websocket_url: "wss://agent.example.com/ws", + use_inkbox_tts: null, + use_inkbox_stt: null, + hangup_reason: null, + started_at: "2026-03-09T00:01:00Z", + ended_at: "2026-03-09T00:05:00Z", + created_at: "2026-03-09T00:00:00Z", + updated_at: "2026-03-09T00:05:00Z", +}; + +export const RAW_RATE_LIMIT = { + calls_used: 5, + calls_remaining: 95, + calls_limit: 100, + minutes_used: 12.5, + minutes_remaining: 987.5, + minutes_limit: 1000, +}; + +export const RAW_PHONE_CALL_WITH_RATE_LIMIT = { + ...RAW_PHONE_CALL, + rate_limit: RAW_RATE_LIMIT, +}; + +export const RAW_PHONE_TRANSCRIPT = { + id: "cccc3333-0000-0000-0000-000000000001", + call_id: "bbbb2222-0000-0000-0000-000000000001", + seq: 0, + ts_ms: 1500, + party: "local", + text: "Hello, how can I help you?", + created_at: "2026-03-09T00:01:01Z", +}; + +export const RAW_PHONE_WEBHOOK = { + id: "dddd4444-0000-0000-0000-000000000001", + source_id: "aaaa1111-0000-0000-0000-000000000001", + source_type: "phone_number", + url: "https://example.com/webhooks/phone", + event_types: ["incoming_call"], + status: "active", + created_at: "2026-03-09T00:00:00Z", +}; + +export const RAW_PHONE_WEBHOOK_CREATE = { + ...RAW_PHONE_WEBHOOK, + secret: "test-hmac-secret-abc123", +}; + +// ---- Identities ---- + +export const RAW_IDENTITY_MAILBOX = { + id: "aaaa1111-0000-0000-0000-000000000001", + email_address: "sales-agent@inkbox.ai", + display_name: "Sales Agent", + status: "active", + created_at: "2026-03-09T00:00:00Z", + updated_at: "2026-03-09T00:00:00Z", +}; + +export const RAW_IDENTITY_PHONE = { + id: "bbbb2222-0000-0000-0000-000000000001", + number: "+18335794607", + type: "toll_free", + status: "active", + incoming_call_action: "auto_reject", + client_websocket_url: null, + created_at: "2026-03-09T00:00:00Z", + updated_at: "2026-03-09T00:00:00Z", +}; + +export const RAW_IDENTITY = { + id: "eeee5555-0000-0000-0000-000000000001", + organization_id: "org-abc123", + agent_handle: "sales-agent", + status: "active", + created_at: "2026-03-09T00:00:00Z", + updated_at: "2026-03-09T00:00:00Z", +}; + +export const RAW_IDENTITY_DETAIL = { + ...RAW_IDENTITY, + mailbox: RAW_IDENTITY_MAILBOX, + phone_number: RAW_IDENTITY_PHONE, +}; diff --git a/typescript/vitest.config.ts b/typescript/vitest.config.ts new file mode 100644 index 0000000..f624398 --- /dev/null +++ b/typescript/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + }, +}); From 477e298256bfe29f00f7a79179b6317643857094 Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:17:36 -0400 Subject: [PATCH 15/56] update api key placeholders --- README.md | 12 ++++++------ examples/python/list_calls.py | 2 +- examples/python/list_phone_numbers.py | 2 +- examples/python/manage_identities.py | 2 +- examples/python/manage_webhooks.py | 2 +- examples/typescript/list-calls.ts | 2 +- examples/typescript/list-phone-numbers.ts | 2 +- examples/typescript/manage-identities.ts | 2 +- examples/typescript/manage-webhooks.ts | 2 +- python/README.md | 2 +- python/inkbox/identities/client.py | 2 +- python/inkbox/mail/client.py | 4 ++-- python/inkbox/phone/client.py | 2 +- typescript/src/client.ts | 2 +- typescript/src/identities/client.ts | 2 +- typescript/src/phone/client.ts | 2 +- 16 files changed, 22 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 8194346..c294779 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Agent identities are the central concept — a named agent (e.g. `"sales-agent"` ```python from inkbox.identities import InkboxIdentities -with InkboxIdentities(api_key="sk-...") as client: +with InkboxIdentities(api_key="ApiKey_...") as client: # Create an identity identity = client.identities.create(agent_handle="sales-agent") @@ -46,7 +46,7 @@ with InkboxIdentities(api_key="sk-...") as client: ```ts import { InkboxIdentities } from "@inkbox/sdk/identities"; -const client = new InkboxIdentities({ apiKey: "sk-..." }); +const client = new InkboxIdentities({ apiKey: "ApiKey_..." }); // Create an identity const identity = await client.identities.create({ agentHandle: "sales-agent" }); @@ -73,7 +73,7 @@ await client.identities.delete("sales-agent"); ```python from inkbox.mail import InkboxMail -with InkboxMail(api_key="sk-...") as client: +with InkboxMail(api_key="ApiKey_...") as client: # Create a mailbox (agent identity must already exist) mailbox = client.mailboxes.create( @@ -123,7 +123,7 @@ with InkboxMail(api_key="sk-...") as client: ```ts import { InkboxMail } from "@inkbox/sdk"; -const client = new InkboxMail({ apiKey: "sk-..." }); +const client = new InkboxMail({ apiKey: "ApiKey_..." }); // Create a mailbox (agent identity must already exist) const mailbox = await client.mailboxes.create({ @@ -166,7 +166,7 @@ console.log(hook.secret); // save this ```python from inkbox.phone import InkboxPhone -with InkboxPhone(api_key="sk-...") as client: +with InkboxPhone(api_key="ApiKey_...") as client: # Provision a phone number (agent identity must already exist) number = client.numbers.provision( @@ -214,7 +214,7 @@ with InkboxPhone(api_key="sk-...") as client: ```ts import { InkboxPhone } from "@inkbox/sdk/phone"; -const client = new InkboxPhone({ apiKey: "sk-..." }); +const client = new InkboxPhone({ apiKey: "ApiKey_..." }); // Provision a phone number (agent identity must already exist) const number = await client.numbers.provision({ diff --git a/examples/python/list_calls.py b/examples/python/list_calls.py index 7d8967a..3400a4a 100644 --- a/examples/python/list_calls.py +++ b/examples/python/list_calls.py @@ -2,7 +2,7 @@ List recent calls and their transcripts for a phone number. Usage: - INKBOX_API_KEY=sk-... PHONE_NUMBER_ID= python list_calls.py + INKBOX_API_KEY=ApiKey_... PHONE_NUMBER_ID= python list_calls.py """ import os diff --git a/examples/python/list_phone_numbers.py b/examples/python/list_phone_numbers.py index d0d9482..58ee10b 100644 --- a/examples/python/list_phone_numbers.py +++ b/examples/python/list_phone_numbers.py @@ -2,7 +2,7 @@ List all phone numbers attached to your Inkbox account. Usage: - INKBOX_API_KEY=sk-... python list_phone_numbers.py + INKBOX_API_KEY=ApiKey_... python list_phone_numbers.py """ import os diff --git a/examples/python/manage_identities.py b/examples/python/manage_identities.py index 2b90ad1..1632c0b 100644 --- a/examples/python/manage_identities.py +++ b/examples/python/manage_identities.py @@ -2,7 +2,7 @@ Create an agent identity and assign communication channels to it. Usage: - INKBOX_API_KEY=sk-... MAILBOX_ID= PHONE_NUMBER_ID= python manage_identities.py + INKBOX_API_KEY=ApiKey_... MAILBOX_ID= PHONE_NUMBER_ID= python manage_identities.py """ import os diff --git a/examples/python/manage_webhooks.py b/examples/python/manage_webhooks.py index 4fe392b..6a251c2 100644 --- a/examples/python/manage_webhooks.py +++ b/examples/python/manage_webhooks.py @@ -2,7 +2,7 @@ Create, update, and delete a webhook on a phone number. Usage: - INKBOX_API_KEY=sk-... PHONE_NUMBER_ID= python manage_webhooks.py + INKBOX_API_KEY=ApiKey_... PHONE_NUMBER_ID= python manage_webhooks.py """ import os diff --git a/examples/typescript/list-calls.ts b/examples/typescript/list-calls.ts index 7a8abbc..503a2fb 100644 --- a/examples/typescript/list-calls.ts +++ b/examples/typescript/list-calls.ts @@ -2,7 +2,7 @@ * List recent calls and their transcripts for a phone number. * * Usage: - * INKBOX_API_KEY=sk-... PHONE_NUMBER_ID= npx ts-node list-calls.ts + * INKBOX_API_KEY=ApiKey_... PHONE_NUMBER_ID= npx ts-node list-calls.ts */ import { InkboxPhone } from "../../typescript/src/phone/index.js"; diff --git a/examples/typescript/list-phone-numbers.ts b/examples/typescript/list-phone-numbers.ts index 406dc0f..c465484 100644 --- a/examples/typescript/list-phone-numbers.ts +++ b/examples/typescript/list-phone-numbers.ts @@ -2,7 +2,7 @@ * List all phone numbers attached to your Inkbox account. * * Usage: - * INKBOX_API_KEY=sk-... npx ts-node list-phone-numbers.ts + * INKBOX_API_KEY=ApiKey_... npx ts-node list-phone-numbers.ts */ import { InkboxPhone } from "../../typescript/src/phone/index.js"; diff --git a/examples/typescript/manage-identities.ts b/examples/typescript/manage-identities.ts index 893aa7d..684395b 100644 --- a/examples/typescript/manage-identities.ts +++ b/examples/typescript/manage-identities.ts @@ -2,7 +2,7 @@ * Create an agent identity and assign communication channels to it. * * Usage: - * INKBOX_API_KEY=sk-... MAILBOX_ID= PHONE_NUMBER_ID= npx ts-node manage-identities.ts + * INKBOX_API_KEY=ApiKey_... MAILBOX_ID= PHONE_NUMBER_ID= npx ts-node manage-identities.ts */ import { InkboxIdentities } from "../../typescript/src/identities/index.js"; diff --git a/examples/typescript/manage-webhooks.ts b/examples/typescript/manage-webhooks.ts index 055ee62..2f7d0e1 100644 --- a/examples/typescript/manage-webhooks.ts +++ b/examples/typescript/manage-webhooks.ts @@ -2,7 +2,7 @@ * Create, update, and delete a webhook on a phone number. * * Usage: - * INKBOX_API_KEY=sk-... PHONE_NUMBER_ID= npx ts-node manage-webhooks.ts + * INKBOX_API_KEY=ApiKey_... PHONE_NUMBER_ID= npx ts-node manage-webhooks.ts */ import { InkboxPhone } from "../../typescript/src/phone/index.js"; diff --git a/python/README.md b/python/README.md index e5cd836..0217912 100644 --- a/python/README.md +++ b/python/README.md @@ -13,7 +13,7 @@ pip install inkbox ```python from inkbox.mail import InkboxMail -client = InkboxMail(api_key="sk-...") +client = InkboxMail(api_key="ApiKey_...") # Create a mailbox mailbox = client.mailboxes.create(display_name="Agent 01") diff --git a/python/inkbox/identities/client.py b/python/inkbox/identities/client.py index f1a82e9..440214b 100644 --- a/python/inkbox/identities/client.py +++ b/python/inkbox/identities/client.py @@ -24,7 +24,7 @@ class InkboxIdentities: from inkbox.identities import InkboxIdentities - with InkboxIdentities(api_key="sk-...") as client: + with InkboxIdentities(api_key="ApiKey_...") as client: identity = client.identities.create(agent_handle="sales-agent") detail = client.identities.assign_mailbox( "sales-agent", diff --git a/python/inkbox/mail/client.py b/python/inkbox/mail/client.py index be77f1a..5bbdbbe 100644 --- a/python/inkbox/mail/client.py +++ b/python/inkbox/mail/client.py @@ -27,7 +27,7 @@ class InkboxMail: from inkbox.mail import InkboxMail - client = InkboxMail(api_key="sk-...") + client = InkboxMail(api_key="ApiKey_...") mailbox = client.mailboxes.create(display_name="Agent 01") @@ -45,7 +45,7 @@ class InkboxMail: The client can also be used as a context manager:: - with InkboxMail(api_key="sk-...") as client: + with InkboxMail(api_key="ApiKey_...") as client: mailboxes = client.mailboxes.list() """ diff --git a/python/inkbox/phone/client.py b/python/inkbox/phone/client.py index 7864404..2bfaca1 100644 --- a/python/inkbox/phone/client.py +++ b/python/inkbox/phone/client.py @@ -27,7 +27,7 @@ class InkboxPhone: from inkbox.phone import InkboxPhone - with InkboxPhone(api_key="sk-...") as client: + with InkboxPhone(api_key="ApiKey_...") as client: number = client.numbers.provision(type="toll_free") call = client.calls.place( from_number=number.number, diff --git a/typescript/src/client.ts b/typescript/src/client.ts index 91bb805..99f3fe2 100644 --- a/typescript/src/client.ts +++ b/typescript/src/client.ts @@ -28,7 +28,7 @@ export interface InkboxMailOptions { * ```ts * import { InkboxMail } from "@inkbox/mail"; * - * const client = new InkboxMail({ apiKey: "sk-..." }); + * const client = new InkboxMail({ apiKey: "ApiKey_..." }); * * const mailbox = await client.mailboxes.create({ displayName: "Agent 01" }); * diff --git a/typescript/src/identities/client.ts b/typescript/src/identities/client.ts index 20ecf8b..39140b5 100644 --- a/typescript/src/identities/client.ts +++ b/typescript/src/identities/client.ts @@ -25,7 +25,7 @@ export interface InkboxIdentitiesOptions { * ```ts * import { InkboxIdentities } from "@inkbox/sdk/identities"; * - * const client = new InkboxIdentities({ apiKey: "sk-..." }); + * const client = new InkboxIdentities({ apiKey: "ApiKey_..." }); * * const identity = await client.identities.create({ agentHandle: "sales-agent" }); * diff --git a/typescript/src/phone/client.ts b/typescript/src/phone/client.ts index c41a5b5..2dd29c7 100644 --- a/typescript/src/phone/client.ts +++ b/typescript/src/phone/client.ts @@ -28,7 +28,7 @@ export interface InkboxPhoneOptions { * ```ts * import { InkboxPhone } from "@inkbox/sdk/phone"; * - * const client = new InkboxPhone({ apiKey: "sk-..." }); + * const client = new InkboxPhone({ apiKey: "ApiKey_..." }); * * const number = await client.numbers.provision(); * From e6aa25aecd31ed5ace5e4c0e10bf1515358c293f Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:23:04 -0400 Subject: [PATCH 16/56] add more examples --- examples/python/list_messages_and_threads.py | 33 ++++++++++++++++ examples/python/manage_mail_webhooks.py | 30 ++++++++++++++ examples/python/manage_mailboxes.py | 34 ++++++++++++++++ examples/python/send_email.py | 32 +++++++++++++++ .../typescript/list-messages-and-threads.ts | 35 +++++++++++++++++ examples/typescript/manage-mail-webhooks.ts | 29 ++++++++++++++ examples/typescript/manage-mailboxes.ts | 39 +++++++++++++++++++ examples/typescript/send-email.ts | 29 ++++++++++++++ python/tests/test_identities.py | 1 - 9 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 examples/python/list_messages_and_threads.py create mode 100644 examples/python/manage_mail_webhooks.py create mode 100644 examples/python/manage_mailboxes.py create mode 100644 examples/python/send_email.py create mode 100644 examples/typescript/list-messages-and-threads.ts create mode 100644 examples/typescript/manage-mail-webhooks.ts create mode 100644 examples/typescript/manage-mailboxes.ts create mode 100644 examples/typescript/send-email.ts diff --git a/examples/python/list_messages_and_threads.py b/examples/python/list_messages_and_threads.py new file mode 100644 index 0000000..550b03f --- /dev/null +++ b/examples/python/list_messages_and_threads.py @@ -0,0 +1,33 @@ +""" +List messages and threads in a mailbox, and read a full thread. + +Usage: + INKBOX_API_KEY=ApiKey_... MAILBOX_ADDRESS=agent@inkboxmail.com python list_messages_and_threads.py +""" + +import os +from inkbox.mail import InkboxMail + +client = InkboxMail(api_key=os.environ["INKBOX_API_KEY"]) +mailbox_address = os.environ["MAILBOX_ADDRESS"] + +# List the 5 most recent messages +print("=== Recent messages ===") +for i, msg in enumerate(client.messages.list(mailbox_address)): + print(f"{msg.id} {msg.subject} from={msg.from_address} read={msg.is_read}") + if i >= 4: + break + +# List threads and fetch the first one in full +print("\n=== Threads ===") +first_thread_id = None +for thread in client.threads.list(mailbox_address): + print(f"{thread.id} {thread.subject!r} messages={thread.message_count}") + if first_thread_id is None: + first_thread_id = thread.id + +if first_thread_id: + thread = client.threads.get(mailbox_address, first_thread_id) + print(f"\nFull thread: {thread.subject!r} ({len(thread.messages)} messages)") + for msg in thread.messages: + print(f" [{msg.from_address}] {msg.subject}") diff --git a/examples/python/manage_mail_webhooks.py b/examples/python/manage_mail_webhooks.py new file mode 100644 index 0000000..485e4b8 --- /dev/null +++ b/examples/python/manage_mail_webhooks.py @@ -0,0 +1,30 @@ +""" +Register and delete a webhook on a mailbox. + +Usage: + INKBOX_API_KEY=ApiKey_... MAILBOX_ADDRESS=agent@inkboxmail.com python manage_mail_webhooks.py +""" + +import os +from inkbox.mail import InkboxMail + +client = InkboxMail(api_key=os.environ["INKBOX_API_KEY"]) +mailbox_address = os.environ["MAILBOX_ADDRESS"] + +# Create +hook = client.webhooks.create( + mailbox_address, + url="https://example.com/webhook", + event_types=["message.received", "message.sent"], +) +print(f"Created webhook {hook.id} secret={hook.secret}") + +# List +all_hooks = client.webhooks.list(mailbox_address) +print(f"Active webhooks: {len(all_hooks)}") +for w in all_hooks: + print(f" {w.id} url={w.url} events={', '.join(w.event_types)}") + +# Delete +client.webhooks.delete(mailbox_address, hook.id) +print("Deleted.") diff --git a/examples/python/manage_mailboxes.py b/examples/python/manage_mailboxes.py new file mode 100644 index 0000000..2f9c878 --- /dev/null +++ b/examples/python/manage_mailboxes.py @@ -0,0 +1,34 @@ +""" +Create, update, search, and delete a mailbox. + +Usage: + INKBOX_API_KEY=ApiKey_... AGENT_HANDLE=sales-agent python manage_mailboxes.py +""" + +import os +from inkbox.mail import InkboxMail + +client = InkboxMail(api_key=os.environ["INKBOX_API_KEY"]) +agent_handle = os.environ.get("AGENT_HANDLE", "sales-agent") + +# Create +mailbox = client.mailboxes.create(agent_handle=agent_handle, display_name="Sales Agent") +print(f"Created mailbox: {mailbox.email_address} display_name={mailbox.display_name!r}") + +# List all mailboxes +all_mailboxes = client.mailboxes.list() +print(f"\nAll mailboxes ({len(all_mailboxes)}):") +for m in all_mailboxes: + print(f" {m.email_address} status={m.status}") + +# Update display name +updated = client.mailboxes.update(mailbox.email_address, display_name="Sales Agent (updated)") +print(f"\nUpdated display_name: {updated.display_name}") + +# Full-text search +results = client.mailboxes.search(mailbox.email_address, q="hello") +print(f'\nSearch results for "hello": {len(results)} messages') + +# Delete +client.mailboxes.delete(mailbox.email_address) +print("Deleted.") diff --git a/examples/python/send_email.py b/examples/python/send_email.py new file mode 100644 index 0000000..7186d8d --- /dev/null +++ b/examples/python/send_email.py @@ -0,0 +1,32 @@ +""" +Send an email (and reply) from an Inkbox mailbox. + +Usage: + INKBOX_API_KEY=ApiKey_... MAILBOX_ADDRESS=agent@inkboxmail.com python send_email.py +""" + +import os +from inkbox.mail import InkboxMail + +client = InkboxMail(api_key=os.environ["INKBOX_API_KEY"]) +mailbox_address = os.environ["MAILBOX_ADDRESS"] + +# Send a new email +sent = client.messages.send( + mailbox_address, + to=["recipient@example.com"], + subject="Hello from Inkbox", + body_text="Hi there! This message was sent via the Inkbox SDK.", + body_html="

Hi there! This message was sent via the Inkbox SDK.

", +) +print(f"Sent message {sent.id} subject={sent.subject!r}") + +# Reply to that message (threads it automatically) +reply = client.messages.send( + mailbox_address, + to=["recipient@example.com"], + subject=f"Re: {sent.subject}", + body_text="Following up on my previous message.", + in_reply_to_message_id=str(sent.id), +) +print(f"Sent reply {reply.id}") diff --git a/examples/typescript/list-messages-and-threads.ts b/examples/typescript/list-messages-and-threads.ts new file mode 100644 index 0000000..9d32b09 --- /dev/null +++ b/examples/typescript/list-messages-and-threads.ts @@ -0,0 +1,35 @@ +/** + * List messages and threads in a mailbox, and read a full thread. + * + * Usage: + * INKBOX_API_KEY=ApiKey_... MAILBOX_ADDRESS=agent@inkboxmail.com npx ts-node list-messages-and-threads.ts + */ + +import { InkboxMail } from "../../typescript/src/client.js"; + +const client = new InkboxMail({ apiKey: process.env.INKBOX_API_KEY! }); +const mailboxAddress = process.env.MAILBOX_ADDRESS!; + +// List the 5 most recent messages +console.log("=== Recent messages ==="); +let count = 0; +for await (const msg of client.messages.list(mailboxAddress)) { + console.log(`${msg.id} ${msg.subject} from=${msg.fromAddress} read=${msg.isRead}`); + if (++count >= 5) break; +} + +// List threads and fetch the first one in full +console.log("\n=== Threads ==="); +let firstThreadId: string | undefined; +for await (const thread of client.threads.list(mailboxAddress)) { + console.log(`${thread.id} "${thread.subject}" messages=${thread.messageCount}`); + firstThreadId ??= thread.id; +} + +if (firstThreadId) { + const thread = await client.threads.get(mailboxAddress, firstThreadId); + console.log(`\nFull thread: "${thread.subject}" (${thread.messages.length} messages)`); + for (const msg of thread.messages) { + console.log(` [${msg.fromAddress}] ${msg.subject}`); + } +} diff --git a/examples/typescript/manage-mail-webhooks.ts b/examples/typescript/manage-mail-webhooks.ts new file mode 100644 index 0000000..e6cc7a5 --- /dev/null +++ b/examples/typescript/manage-mail-webhooks.ts @@ -0,0 +1,29 @@ +/** + * Register and delete a webhook on a mailbox. + * + * Usage: + * INKBOX_API_KEY=ApiKey_... MAILBOX_ADDRESS=agent@inkboxmail.com npx ts-node manage-mail-webhooks.ts + */ + +import { InkboxMail } from "../../typescript/src/client.js"; + +const client = new InkboxMail({ apiKey: process.env.INKBOX_API_KEY! }); +const mailboxAddress = process.env.MAILBOX_ADDRESS!; + +// Create +const hook = await client.webhooks.create(mailboxAddress, { + url: "https://example.com/webhook", + eventTypes: ["message.received", "message.sent"], +}); +console.log(`Created webhook ${hook.id} secret=${hook.secret}`); + +// List +const all = await client.webhooks.list(mailboxAddress); +console.log(`Active webhooks: ${all.length}`); +for (const w of all) { + console.log(` ${w.id} url=${w.url} events=${w.eventTypes.join(", ")}`); +} + +// Delete +await client.webhooks.delete(mailboxAddress, hook.id); +console.log("Deleted."); diff --git a/examples/typescript/manage-mailboxes.ts b/examples/typescript/manage-mailboxes.ts new file mode 100644 index 0000000..406d6e0 --- /dev/null +++ b/examples/typescript/manage-mailboxes.ts @@ -0,0 +1,39 @@ +/** + * Create, update, search, and delete a mailbox. + * + * Usage: + * INKBOX_API_KEY=ApiKey_... AGENT_HANDLE=sales-agent npx ts-node manage-mailboxes.ts + */ + +import { InkboxMail } from "../../typescript/src/client.js"; + +const client = new InkboxMail({ apiKey: process.env.INKBOX_API_KEY! }); +const agentHandle = process.env.AGENT_HANDLE ?? "sales-agent"; + +// Create +const mailbox = await client.mailboxes.create({ + agentHandle, + displayName: "Sales Agent", +}); +console.log(`Created mailbox: ${mailbox.emailAddress} displayName="${mailbox.displayName}"`); + +// List all mailboxes +const all = await client.mailboxes.list(); +console.log(`\nAll mailboxes (${all.length}):`); +for (const m of all) { + console.log(` ${m.emailAddress} status=${m.status}`); +} + +// Update display name +const updated = await client.mailboxes.update(mailbox.emailAddress, { + displayName: "Sales Agent (updated)", +}); +console.log(`\nUpdated displayName: ${updated.displayName}`); + +// Full-text search +const results = await client.mailboxes.search(mailbox.emailAddress, { q: "hello" }); +console.log(`\nSearch results for "hello": ${results.length} messages`); + +// Delete +await client.mailboxes.delete(mailbox.emailAddress); +console.log("Deleted."); diff --git a/examples/typescript/send-email.ts b/examples/typescript/send-email.ts new file mode 100644 index 0000000..b554ff1 --- /dev/null +++ b/examples/typescript/send-email.ts @@ -0,0 +1,29 @@ +/** + * Send an email (and reply) from an Inkbox mailbox. + * + * Usage: + * INKBOX_API_KEY=ApiKey_... MAILBOX_ADDRESS=agent@inkboxmail.com npx ts-node send-email.ts + */ + +import { InkboxMail } from "../../typescript/src/client.js"; + +const client = new InkboxMail({ apiKey: process.env.INKBOX_API_KEY! }); +const mailboxAddress = process.env.MAILBOX_ADDRESS!; + +// Send a new email +const sent = await client.messages.send(mailboxAddress, { + to: ["recipient@example.com"], + subject: "Hello from Inkbox", + bodyText: "Hi there! This message was sent via the Inkbox SDK.", + bodyHtml: "

Hi there! This message was sent via the Inkbox SDK.

", +}); +console.log(`Sent message ${sent.id} subject="${sent.subject}"`); + +// Reply to that message (threads it automatically) +const reply = await client.messages.send(mailboxAddress, { + to: ["recipient@example.com"], + subject: `Re: ${sent.subject}`, + bodyText: "Following up on my previous message.", + inReplyToMessageId: sent.id, +}); +console.log(`Sent reply ${reply.id}`); diff --git a/python/tests/test_identities.py b/python/tests/test_identities.py index 133f7eb..d780fdc 100644 --- a/python/tests/test_identities.py +++ b/python/tests/test_identities.py @@ -1,7 +1,6 @@ """Tests for IdentitiesResource.""" from unittest.mock import MagicMock -from uuid import UUID from sample_data_identities import IDENTITY_DICT, IDENTITY_DETAIL_DICT from inkbox.identities.resources.identities import IdentitiesResource From cdee0ee2b946237b27390190e4ae8d9d0e42f844 Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:47:33 -0400 Subject: [PATCH 17/56] make examples more relevant --- .../{send_email.py => agent_send_email.py} | 14 +++---- ...e_mailboxes.py => create_agent_mailbox.py} | 12 +++--- examples/python/create_agent_phone_number.py | 35 ++++++++++++++++++ ...numbers.py => list_agent_phone_numbers.py} | 2 +- .../{list_calls.py => read_agent_calls.py} | 2 +- ..._and_threads.py => read_agent_messages.py} | 8 ++-- ...hooks.py => receive_agent_call_webhook.py} | 12 +++--- ...ooks.py => receive_agent_email_webhook.py} | 12 +++--- ...entities.py => register_agent_identity.py} | 10 ++--- .../{send-email.ts => agent-send-email.ts} | 14 +++---- ...e-mailboxes.ts => create-agent-mailbox.ts} | 12 +++--- .../typescript/create-agent-phone-number.ts | 37 +++++++++++++++++++ ...numbers.ts => list-agent-phone-numbers.ts} | 2 +- .../{list-calls.ts => read-agent-calls.ts} | 2 +- ...-and-threads.ts => read-agent-messages.ts} | 8 ++-- ...hooks.ts => receive-agent-call-webhook.ts} | 12 +++--- ...ooks.ts => receive-agent-email-webhook.ts} | 12 +++--- ...entities.ts => register-agent-identity.ts} | 10 ++--- 18 files changed, 144 insertions(+), 72 deletions(-) rename examples/python/{send_email.py => agent_send_email.py} (63%) rename examples/python/{manage_mailboxes.py => create_agent_mailbox.py} (72%) create mode 100644 examples/python/create_agent_phone_number.py rename examples/python/{list_phone_numbers.py => list_agent_phone_numbers.py} (81%) rename examples/python/{list_calls.py => read_agent_calls.py} (87%) rename examples/python/{list_messages_and_threads.py => read_agent_messages.py} (83%) rename examples/python/{manage_webhooks.py => receive_agent_call_webhook.py} (65%) rename examples/python/{manage_mail_webhooks.py => receive_agent_email_webhook.py} (68%) rename examples/python/{manage_identities.py => register_agent_identity.py} (84%) rename examples/typescript/{send-email.ts => agent-send-email.ts} (65%) rename examples/typescript/{manage-mailboxes.ts => create-agent-mailbox.ts} (78%) create mode 100644 examples/typescript/create-agent-phone-number.ts rename examples/typescript/{list-phone-numbers.ts => list-agent-phone-numbers.ts} (83%) rename examples/typescript/{list-calls.ts => read-agent-calls.ts} (97%) rename examples/typescript/{list-messages-and-threads.ts => read-agent-messages.ts} (83%) rename examples/typescript/{manage-webhooks.ts => receive-agent-call-webhook.ts} (72%) rename examples/typescript/{manage-mail-webhooks.ts => receive-agent-email-webhook.ts} (69%) rename examples/typescript/{manage-identities.ts => register-agent-identity.ts} (84%) diff --git a/examples/python/send_email.py b/examples/python/agent_send_email.py similarity index 63% rename from examples/python/send_email.py rename to examples/python/agent_send_email.py index 7186d8d..8663c09 100644 --- a/examples/python/send_email.py +++ b/examples/python/agent_send_email.py @@ -2,7 +2,7 @@ Send an email (and reply) from an Inkbox mailbox. Usage: - INKBOX_API_KEY=ApiKey_... MAILBOX_ADDRESS=agent@inkboxmail.com python send_email.py + INKBOX_API_KEY=ApiKey_... MAILBOX_ADDRESS=agent@inkboxmail.com python agent_send_email.py """ import os @@ -11,22 +11,22 @@ client = InkboxMail(api_key=os.environ["INKBOX_API_KEY"]) mailbox_address = os.environ["MAILBOX_ADDRESS"] -# Send a new email +# Agent sends outbound email sent = client.messages.send( mailbox_address, to=["recipient@example.com"], - subject="Hello from Inkbox", - body_text="Hi there! This message was sent via the Inkbox SDK.", - body_html="

Hi there! This message was sent via the Inkbox SDK.

", + subject="Hello from your AI sales agent", + body_text="Hi there! I'm your AI sales agent reaching out via Inkbox.", + body_html="

Hi there! I'm your AI sales agent reaching out via Inkbox.

", ) print(f"Sent message {sent.id} subject={sent.subject!r}") -# Reply to that message (threads it automatically) +# Agent sends threaded reply reply = client.messages.send( mailbox_address, to=["recipient@example.com"], subject=f"Re: {sent.subject}", - body_text="Following up on my previous message.", + body_text="Following up as your AI sales agent.", in_reply_to_message_id=str(sent.id), ) print(f"Sent reply {reply.id}") diff --git a/examples/python/manage_mailboxes.py b/examples/python/create_agent_mailbox.py similarity index 72% rename from examples/python/manage_mailboxes.py rename to examples/python/create_agent_mailbox.py index 2f9c878..0da8a89 100644 --- a/examples/python/manage_mailboxes.py +++ b/examples/python/create_agent_mailbox.py @@ -2,7 +2,7 @@ Create, update, search, and delete a mailbox. Usage: - INKBOX_API_KEY=ApiKey_... AGENT_HANDLE=sales-agent python manage_mailboxes.py + INKBOX_API_KEY=ApiKey_... AGENT_HANDLE=sales-agent python create_agent_mailbox.py """ import os @@ -11,13 +11,13 @@ client = InkboxMail(api_key=os.environ["INKBOX_API_KEY"]) agent_handle = os.environ.get("AGENT_HANDLE", "sales-agent") -# Create +# Create agent mailbox mailbox = client.mailboxes.create(agent_handle=agent_handle, display_name="Sales Agent") -print(f"Created mailbox: {mailbox.email_address} display_name={mailbox.display_name!r}") +print(f"Agent mailbox created: {mailbox.email_address} display_name={mailbox.display_name!r}") # List all mailboxes all_mailboxes = client.mailboxes.list() -print(f"\nAll mailboxes ({len(all_mailboxes)}):") +print(f"\nAll agent mailboxes ({len(all_mailboxes)}):") for m in all_mailboxes: print(f" {m.email_address} status={m.status}") @@ -29,6 +29,6 @@ results = client.mailboxes.search(mailbox.email_address, q="hello") print(f'\nSearch results for "hello": {len(results)} messages') -# Delete +# Delete agent mailbox client.mailboxes.delete(mailbox.email_address) -print("Deleted.") +print("Agent mailbox deleted.") diff --git a/examples/python/create_agent_phone_number.py b/examples/python/create_agent_phone_number.py new file mode 100644 index 0000000..cd78d86 --- /dev/null +++ b/examples/python/create_agent_phone_number.py @@ -0,0 +1,35 @@ +""" +Provision, update, and release a phone number. + +Usage: + INKBOX_API_KEY=ApiKey_... python create_agent_phone_number.py + INKBOX_API_KEY=ApiKey_... NUMBER_TYPE=local STATE=NY python create_agent_phone_number.py +""" + +import os +from inkbox.phone import InkboxPhone + +client = InkboxPhone(api_key=os.environ["INKBOX_API_KEY"]) +number_type = os.environ.get("NUMBER_TYPE", "toll_free") +state = os.environ.get("STATE") + +# Provision agent phone number +kwargs = {"type": number_type} +if state: + kwargs["state"] = state +number = client.numbers.provision(**kwargs) +print(f"Agent phone number provisioned: {number.number} type={number.type} status={number.status}") + +# List all numbers +all_numbers = client.numbers.list() +print(f"\nAll agent phone numbers ({len(all_numbers)}):") +for n in all_numbers: + print(f" {n.number} type={n.type} status={n.status}") + +# Update incoming call action +updated = client.numbers.update(number.id, incoming_call_action="auto_accept") +print(f"\nUpdated incoming_call_action: {updated.incoming_call_action}") + +# Release agent phone number +client.numbers.release(number=number.number) +print("Agent phone number released.") diff --git a/examples/python/list_phone_numbers.py b/examples/python/list_agent_phone_numbers.py similarity index 81% rename from examples/python/list_phone_numbers.py rename to examples/python/list_agent_phone_numbers.py index 58ee10b..0b38454 100644 --- a/examples/python/list_phone_numbers.py +++ b/examples/python/list_agent_phone_numbers.py @@ -2,7 +2,7 @@ List all phone numbers attached to your Inkbox account. Usage: - INKBOX_API_KEY=ApiKey_... python list_phone_numbers.py + INKBOX_API_KEY=ApiKey_... python list_agent_phone_numbers.py """ import os diff --git a/examples/python/list_calls.py b/examples/python/read_agent_calls.py similarity index 87% rename from examples/python/list_calls.py rename to examples/python/read_agent_calls.py index 3400a4a..50b986c 100644 --- a/examples/python/list_calls.py +++ b/examples/python/read_agent_calls.py @@ -2,7 +2,7 @@ List recent calls and their transcripts for a phone number. Usage: - INKBOX_API_KEY=ApiKey_... PHONE_NUMBER_ID= python list_calls.py + INKBOX_API_KEY=ApiKey_... PHONE_NUMBER_ID= python read_agent_calls.py """ import os diff --git a/examples/python/list_messages_and_threads.py b/examples/python/read_agent_messages.py similarity index 83% rename from examples/python/list_messages_and_threads.py rename to examples/python/read_agent_messages.py index 550b03f..ad243ff 100644 --- a/examples/python/list_messages_and_threads.py +++ b/examples/python/read_agent_messages.py @@ -2,7 +2,7 @@ List messages and threads in a mailbox, and read a full thread. Usage: - INKBOX_API_KEY=ApiKey_... MAILBOX_ADDRESS=agent@inkboxmail.com python list_messages_and_threads.py + INKBOX_API_KEY=ApiKey_... MAILBOX_ADDRESS=agent@inkboxmail.com python read_agent_messages.py """ import os @@ -12,14 +12,14 @@ mailbox_address = os.environ["MAILBOX_ADDRESS"] # List the 5 most recent messages -print("=== Recent messages ===") +print("=== Agent inbox ===") for i, msg in enumerate(client.messages.list(mailbox_address)): print(f"{msg.id} {msg.subject} from={msg.from_address} read={msg.is_read}") if i >= 4: break # List threads and fetch the first one in full -print("\n=== Threads ===") +print("\n=== Agent threads ===") first_thread_id = None for thread in client.threads.list(mailbox_address): print(f"{thread.id} {thread.subject!r} messages={thread.message_count}") @@ -28,6 +28,6 @@ if first_thread_id: thread = client.threads.get(mailbox_address, first_thread_id) - print(f"\nFull thread: {thread.subject!r} ({len(thread.messages)} messages)") + print(f"\nAgent conversation: {thread.subject!r} ({len(thread.messages)} messages)") for msg in thread.messages: print(f" [{msg.from_address}] {msg.subject}") diff --git a/examples/python/manage_webhooks.py b/examples/python/receive_agent_call_webhook.py similarity index 65% rename from examples/python/manage_webhooks.py rename to examples/python/receive_agent_call_webhook.py index 6a251c2..2c2c2cc 100644 --- a/examples/python/manage_webhooks.py +++ b/examples/python/receive_agent_call_webhook.py @@ -2,7 +2,7 @@ Create, update, and delete a webhook on a phone number. Usage: - INKBOX_API_KEY=ApiKey_... PHONE_NUMBER_ID= python manage_webhooks.py + INKBOX_API_KEY=ApiKey_... PHONE_NUMBER_ID= python receive_agent_call_webhook.py """ import os @@ -11,15 +11,15 @@ client = InkboxPhone(api_key=os.environ["INKBOX_API_KEY"]) phone_number_id = os.environ["PHONE_NUMBER_ID"] -# Create +# Register webhook for agent phone number hook = client.webhooks.create( phone_number_id, url="https://example.com/webhook", event_types=["incoming_call"], ) -print(f"Created webhook {hook.id} secret={hook.secret}") +print(f"Registered agent phone webhook {hook.id} secret={hook.secret}") -# Update +# Update agent phone webhook updated = client.webhooks.update( phone_number_id, hook.id, @@ -27,6 +27,6 @@ ) print(f"Updated URL: {updated.url}") -# Delete +# Remove agent phone webhook client.webhooks.delete(phone_number_id, hook.id) -print("Deleted.") +print("Agent phone webhook removed.") diff --git a/examples/python/manage_mail_webhooks.py b/examples/python/receive_agent_email_webhook.py similarity index 68% rename from examples/python/manage_mail_webhooks.py rename to examples/python/receive_agent_email_webhook.py index 485e4b8..42c6ea4 100644 --- a/examples/python/manage_mail_webhooks.py +++ b/examples/python/receive_agent_email_webhook.py @@ -2,7 +2,7 @@ Register and delete a webhook on a mailbox. Usage: - INKBOX_API_KEY=ApiKey_... MAILBOX_ADDRESS=agent@inkboxmail.com python manage_mail_webhooks.py + INKBOX_API_KEY=ApiKey_... MAILBOX_ADDRESS=agent@inkboxmail.com python receive_agent_email_webhook.py """ import os @@ -11,20 +11,20 @@ client = InkboxMail(api_key=os.environ["INKBOX_API_KEY"]) mailbox_address = os.environ["MAILBOX_ADDRESS"] -# Create +# Register webhook for agent mailbox hook = client.webhooks.create( mailbox_address, url="https://example.com/webhook", event_types=["message.received", "message.sent"], ) -print(f"Created webhook {hook.id} secret={hook.secret}") +print(f"Registered agent mailbox webhook {hook.id} secret={hook.secret}") # List all_hooks = client.webhooks.list(mailbox_address) -print(f"Active webhooks: {len(all_hooks)}") +print(f"Active agent mailbox webhooks: {len(all_hooks)}") for w in all_hooks: print(f" {w.id} url={w.url} events={', '.join(w.event_types)}") -# Delete +# Remove agent mailbox webhook client.webhooks.delete(mailbox_address, hook.id) -print("Deleted.") +print("Agent mailbox webhook removed.") diff --git a/examples/python/manage_identities.py b/examples/python/register_agent_identity.py similarity index 84% rename from examples/python/manage_identities.py rename to examples/python/register_agent_identity.py index 1632c0b..c2a8a0c 100644 --- a/examples/python/manage_identities.py +++ b/examples/python/register_agent_identity.py @@ -2,16 +2,16 @@ Create an agent identity and assign communication channels to it. Usage: - INKBOX_API_KEY=ApiKey_... MAILBOX_ID= PHONE_NUMBER_ID= python manage_identities.py + INKBOX_API_KEY=ApiKey_... MAILBOX_ID= PHONE_NUMBER_ID= python register_agent_identity.py """ import os from inkbox.identities import InkboxIdentities with InkboxIdentities(api_key=os.environ["INKBOX_API_KEY"]) as client: - # Create + # Register agent identity identity = client.identities.create(agent_handle="sales-agent") - print(f"Created identity: {identity.agent_handle} (id={identity.id})") + print(f"Registered agent: {identity.agent_handle} (id={identity.id})") # Assign channels if mailbox_id := os.environ.get("MAILBOX_ID"): @@ -28,6 +28,6 @@ for ident in all_identities: print(f" {ident.agent_handle} status={ident.status}") - # Clean up + # Unregister agent client.identities.delete("sales-agent") - print("\nDeleted sales-agent.") + print("\nUnregistered agent sales-agent.") diff --git a/examples/typescript/send-email.ts b/examples/typescript/agent-send-email.ts similarity index 65% rename from examples/typescript/send-email.ts rename to examples/typescript/agent-send-email.ts index b554ff1..fd9976b 100644 --- a/examples/typescript/send-email.ts +++ b/examples/typescript/agent-send-email.ts @@ -2,7 +2,7 @@ * Send an email (and reply) from an Inkbox mailbox. * * Usage: - * INKBOX_API_KEY=ApiKey_... MAILBOX_ADDRESS=agent@inkboxmail.com npx ts-node send-email.ts + * INKBOX_API_KEY=ApiKey_... MAILBOX_ADDRESS=agent@inkboxmail.com npx ts-node agent-send-email.ts */ import { InkboxMail } from "../../typescript/src/client.js"; @@ -10,20 +10,20 @@ import { InkboxMail } from "../../typescript/src/client.js"; const client = new InkboxMail({ apiKey: process.env.INKBOX_API_KEY! }); const mailboxAddress = process.env.MAILBOX_ADDRESS!; -// Send a new email +// Agent sends outbound email const sent = await client.messages.send(mailboxAddress, { to: ["recipient@example.com"], - subject: "Hello from Inkbox", - bodyText: "Hi there! This message was sent via the Inkbox SDK.", - bodyHtml: "

Hi there! This message was sent via the Inkbox SDK.

", + subject: "Hello from your AI sales agent", + bodyText: "Hi there! I'm your AI sales agent reaching out via Inkbox.", + bodyHtml: "

Hi there! I'm your AI sales agent reaching out via Inkbox.

", }); console.log(`Sent message ${sent.id} subject="${sent.subject}"`); -// Reply to that message (threads it automatically) +// Agent sends threaded reply const reply = await client.messages.send(mailboxAddress, { to: ["recipient@example.com"], subject: `Re: ${sent.subject}`, - bodyText: "Following up on my previous message.", + bodyText: "Following up as your AI sales agent.", inReplyToMessageId: sent.id, }); console.log(`Sent reply ${reply.id}`); diff --git a/examples/typescript/manage-mailboxes.ts b/examples/typescript/create-agent-mailbox.ts similarity index 78% rename from examples/typescript/manage-mailboxes.ts rename to examples/typescript/create-agent-mailbox.ts index 406d6e0..3e2a197 100644 --- a/examples/typescript/manage-mailboxes.ts +++ b/examples/typescript/create-agent-mailbox.ts @@ -2,7 +2,7 @@ * Create, update, search, and delete a mailbox. * * Usage: - * INKBOX_API_KEY=ApiKey_... AGENT_HANDLE=sales-agent npx ts-node manage-mailboxes.ts + * INKBOX_API_KEY=ApiKey_... AGENT_HANDLE=sales-agent npx ts-node create-agent-mailbox.ts */ import { InkboxMail } from "../../typescript/src/client.js"; @@ -10,16 +10,16 @@ import { InkboxMail } from "../../typescript/src/client.js"; const client = new InkboxMail({ apiKey: process.env.INKBOX_API_KEY! }); const agentHandle = process.env.AGENT_HANDLE ?? "sales-agent"; -// Create +// Create agent mailbox const mailbox = await client.mailboxes.create({ agentHandle, displayName: "Sales Agent", }); -console.log(`Created mailbox: ${mailbox.emailAddress} displayName="${mailbox.displayName}"`); +console.log(`Agent mailbox created: ${mailbox.emailAddress} displayName="${mailbox.displayName}"`); // List all mailboxes const all = await client.mailboxes.list(); -console.log(`\nAll mailboxes (${all.length}):`); +console.log(`\nAll agent mailboxes (${all.length}):`); for (const m of all) { console.log(` ${m.emailAddress} status=${m.status}`); } @@ -34,6 +34,6 @@ console.log(`\nUpdated displayName: ${updated.displayName}`); const results = await client.mailboxes.search(mailbox.emailAddress, { q: "hello" }); console.log(`\nSearch results for "hello": ${results.length} messages`); -// Delete +// Delete agent mailbox await client.mailboxes.delete(mailbox.emailAddress); -console.log("Deleted."); +console.log("Agent mailbox deleted."); diff --git a/examples/typescript/create-agent-phone-number.ts b/examples/typescript/create-agent-phone-number.ts new file mode 100644 index 0000000..5259e75 --- /dev/null +++ b/examples/typescript/create-agent-phone-number.ts @@ -0,0 +1,37 @@ +/** + * Provision, update, and release a phone number. + * + * Usage: + * INKBOX_API_KEY=ApiKey_... npx ts-node create-agent-phone-number.ts + * INKBOX_API_KEY=ApiKey_... NUMBER_TYPE=local STATE=NY npx ts-node create-agent-phone-number.ts + */ + +import { InkboxPhone } from "../../typescript/src/phone/index.js"; + +const client = new InkboxPhone({ apiKey: process.env.INKBOX_API_KEY! }); +const numberType = process.env.NUMBER_TYPE ?? "toll_free"; +const state = process.env.STATE; + +// Provision agent phone number +const number = await client.numbers.provision({ + type: numberType, + ...(state ? { state } : {}), +}); +console.log(`Agent phone number provisioned: ${number.number} type=${number.type} status=${number.status}`); + +// List all numbers +const all = await client.numbers.list(); +console.log(`\nAll agent phone numbers (${all.length}):`); +for (const n of all) { + console.log(` ${n.number} type=${n.type} status=${n.status}`); +} + +// Update incoming call action +const updated = await client.numbers.update(number.id, { + incomingCallAction: "auto_accept", +}); +console.log(`\nUpdated incomingCallAction: ${updated.incomingCallAction}`); + +// Release agent phone number +await client.numbers.release(number.id); +console.log("Agent phone number released."); diff --git a/examples/typescript/list-phone-numbers.ts b/examples/typescript/list-agent-phone-numbers.ts similarity index 83% rename from examples/typescript/list-phone-numbers.ts rename to examples/typescript/list-agent-phone-numbers.ts index c465484..282ca70 100644 --- a/examples/typescript/list-phone-numbers.ts +++ b/examples/typescript/list-agent-phone-numbers.ts @@ -2,7 +2,7 @@ * List all phone numbers attached to your Inkbox account. * * Usage: - * INKBOX_API_KEY=ApiKey_... npx ts-node list-phone-numbers.ts + * INKBOX_API_KEY=ApiKey_... npx ts-node list-agent-phone-numbers.ts */ import { InkboxPhone } from "../../typescript/src/phone/index.js"; diff --git a/examples/typescript/list-calls.ts b/examples/typescript/read-agent-calls.ts similarity index 97% rename from examples/typescript/list-calls.ts rename to examples/typescript/read-agent-calls.ts index 503a2fb..3fbbc7b 100644 --- a/examples/typescript/list-calls.ts +++ b/examples/typescript/read-agent-calls.ts @@ -2,7 +2,7 @@ * List recent calls and their transcripts for a phone number. * * Usage: - * INKBOX_API_KEY=ApiKey_... PHONE_NUMBER_ID= npx ts-node list-calls.ts + * INKBOX_API_KEY=ApiKey_... PHONE_NUMBER_ID= npx ts-node read-agent-calls.ts */ import { InkboxPhone } from "../../typescript/src/phone/index.js"; diff --git a/examples/typescript/list-messages-and-threads.ts b/examples/typescript/read-agent-messages.ts similarity index 83% rename from examples/typescript/list-messages-and-threads.ts rename to examples/typescript/read-agent-messages.ts index 9d32b09..9c7b2ba 100644 --- a/examples/typescript/list-messages-and-threads.ts +++ b/examples/typescript/read-agent-messages.ts @@ -2,7 +2,7 @@ * List messages and threads in a mailbox, and read a full thread. * * Usage: - * INKBOX_API_KEY=ApiKey_... MAILBOX_ADDRESS=agent@inkboxmail.com npx ts-node list-messages-and-threads.ts + * INKBOX_API_KEY=ApiKey_... MAILBOX_ADDRESS=agent@inkboxmail.com npx ts-node read-agent-messages.ts */ import { InkboxMail } from "../../typescript/src/client.js"; @@ -11,7 +11,7 @@ const client = new InkboxMail({ apiKey: process.env.INKBOX_API_KEY! }); const mailboxAddress = process.env.MAILBOX_ADDRESS!; // List the 5 most recent messages -console.log("=== Recent messages ==="); +console.log("=== Agent inbox ==="); let count = 0; for await (const msg of client.messages.list(mailboxAddress)) { console.log(`${msg.id} ${msg.subject} from=${msg.fromAddress} read=${msg.isRead}`); @@ -19,7 +19,7 @@ for await (const msg of client.messages.list(mailboxAddress)) { } // List threads and fetch the first one in full -console.log("\n=== Threads ==="); +console.log("\n=== Agent threads ==="); let firstThreadId: string | undefined; for await (const thread of client.threads.list(mailboxAddress)) { console.log(`${thread.id} "${thread.subject}" messages=${thread.messageCount}`); @@ -28,7 +28,7 @@ for await (const thread of client.threads.list(mailboxAddress)) { if (firstThreadId) { const thread = await client.threads.get(mailboxAddress, firstThreadId); - console.log(`\nFull thread: "${thread.subject}" (${thread.messages.length} messages)`); + console.log(`\nAgent conversation: "${thread.subject}" (${thread.messages.length} messages)`); for (const msg of thread.messages) { console.log(` [${msg.fromAddress}] ${msg.subject}`); } diff --git a/examples/typescript/manage-webhooks.ts b/examples/typescript/receive-agent-call-webhook.ts similarity index 72% rename from examples/typescript/manage-webhooks.ts rename to examples/typescript/receive-agent-call-webhook.ts index 2f7d0e1..4022f5f 100644 --- a/examples/typescript/manage-webhooks.ts +++ b/examples/typescript/receive-agent-call-webhook.ts @@ -2,7 +2,7 @@ * Create, update, and delete a webhook on a phone number. * * Usage: - * INKBOX_API_KEY=ApiKey_... PHONE_NUMBER_ID= npx ts-node manage-webhooks.ts + * INKBOX_API_KEY=ApiKey_... PHONE_NUMBER_ID= npx ts-node receive-agent-call-webhook.ts */ import { InkboxPhone } from "../../typescript/src/phone/index.js"; @@ -10,19 +10,19 @@ import { InkboxPhone } from "../../typescript/src/phone/index.js"; const client = new InkboxPhone({ apiKey: process.env.INKBOX_API_KEY! }); const phoneNumberId = process.env.PHONE_NUMBER_ID!; -// Create +// Register webhook for agent phone number const hook = await client.webhooks.create(phoneNumberId, { url: "https://example.com/webhook", eventTypes: ["incoming_call"], }); -console.log(`Created webhook ${hook.id} secret=${hook.secret}`); +console.log(`Registered agent phone webhook ${hook.id} secret=${hook.secret}`); -// Update +// Update agent phone webhook const updated = await client.webhooks.update(phoneNumberId, hook.id, { url: "https://example.com/webhook-v2", }); console.log(`Updated URL: ${updated.url}`); -// Delete +// Remove agent phone webhook await client.webhooks.delete(phoneNumberId, hook.id); -console.log("Deleted."); +console.log("Agent phone webhook removed."); diff --git a/examples/typescript/manage-mail-webhooks.ts b/examples/typescript/receive-agent-email-webhook.ts similarity index 69% rename from examples/typescript/manage-mail-webhooks.ts rename to examples/typescript/receive-agent-email-webhook.ts index e6cc7a5..5c1a404 100644 --- a/examples/typescript/manage-mail-webhooks.ts +++ b/examples/typescript/receive-agent-email-webhook.ts @@ -2,7 +2,7 @@ * Register and delete a webhook on a mailbox. * * Usage: - * INKBOX_API_KEY=ApiKey_... MAILBOX_ADDRESS=agent@inkboxmail.com npx ts-node manage-mail-webhooks.ts + * INKBOX_API_KEY=ApiKey_... MAILBOX_ADDRESS=agent@inkboxmail.com npx ts-node receive-agent-email-webhook.ts */ import { InkboxMail } from "../../typescript/src/client.js"; @@ -10,20 +10,20 @@ import { InkboxMail } from "../../typescript/src/client.js"; const client = new InkboxMail({ apiKey: process.env.INKBOX_API_KEY! }); const mailboxAddress = process.env.MAILBOX_ADDRESS!; -// Create +// Register webhook for agent mailbox const hook = await client.webhooks.create(mailboxAddress, { url: "https://example.com/webhook", eventTypes: ["message.received", "message.sent"], }); -console.log(`Created webhook ${hook.id} secret=${hook.secret}`); +console.log(`Registered agent mailbox webhook ${hook.id} secret=${hook.secret}`); // List const all = await client.webhooks.list(mailboxAddress); -console.log(`Active webhooks: ${all.length}`); +console.log(`Active agent mailbox webhooks: ${all.length}`); for (const w of all) { console.log(` ${w.id} url=${w.url} events=${w.eventTypes.join(", ")}`); } -// Delete +// Remove agent mailbox webhook await client.webhooks.delete(mailboxAddress, hook.id); -console.log("Deleted."); +console.log("Agent mailbox webhook removed."); diff --git a/examples/typescript/manage-identities.ts b/examples/typescript/register-agent-identity.ts similarity index 84% rename from examples/typescript/manage-identities.ts rename to examples/typescript/register-agent-identity.ts index 684395b..b17bcc1 100644 --- a/examples/typescript/manage-identities.ts +++ b/examples/typescript/register-agent-identity.ts @@ -2,16 +2,16 @@ * Create an agent identity and assign communication channels to it. * * Usage: - * INKBOX_API_KEY=ApiKey_... MAILBOX_ID= PHONE_NUMBER_ID= npx ts-node manage-identities.ts + * INKBOX_API_KEY=ApiKey_... MAILBOX_ID= PHONE_NUMBER_ID= npx ts-node register-agent-identity.ts */ import { InkboxIdentities } from "../../typescript/src/identities/index.js"; const client = new InkboxIdentities({ apiKey: process.env.INKBOX_API_KEY! }); -// Create +// Register agent identity const identity = await client.identities.create({ agentHandle: "sales-agent" }); -console.log(`Created identity: ${identity.agentHandle} (id=${identity.id})`); +console.log(`Registered agent: ${identity.agentHandle} (id=${identity.id})`); // Assign channels if (process.env.MAILBOX_ID) { @@ -35,6 +35,6 @@ for (const id of all) { console.log(` ${id.agentHandle} status=${id.status}`); } -// Clean up +// Unregister agent await client.identities.delete("sales-agent"); -console.log("\nDeleted sales-agent."); +console.log("\nUnregistered agent sales-agent."); From 4d0f59d472f77fb1ae79fa7aba0fdde0e6de8c69 Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:45:37 -0400 Subject: [PATCH 18/56] adapt endpoint updates --- python/inkbox/mail/__init__.py | 6 +- python/inkbox/mail/client.py | 16 +++-- python/inkbox/mail/resources/__init__.py | 4 +- python/inkbox/mail/resources/mailboxes.py | 17 +++-- python/inkbox/mail/resources/messages.py | 36 +++++++++- python/inkbox/mail/resources/webhooks.py | 54 --------------- python/inkbox/mail/types.py | 35 +++------- python/inkbox/phone/__init__.py | 8 +-- python/inkbox/phone/client.py | 6 +- python/inkbox/phone/resources/__init__.py | 2 - python/inkbox/phone/resources/calls.py | 13 ++-- python/inkbox/phone/resources/numbers.py | 58 +++++++++++----- python/inkbox/phone/resources/webhooks.py | 80 --------------------- python/inkbox/phone/types.py | 55 ++++----------- typescript/src/client.ts | 23 +++--- typescript/src/index.ts | 3 +- typescript/src/phone/client.ts | 7 +- typescript/src/phone/index.ts | 2 - typescript/src/phone/resources/numbers.ts | 23 +++++- typescript/src/phone/resources/webhooks.ts | 81 ---------------------- typescript/src/phone/types.ts | 50 +------------ typescript/src/resources/mailboxes.ts | 10 ++- typescript/src/resources/messages.ts | 27 +++++++- typescript/src/resources/webhooks.ts | 53 -------------- typescript/src/types.ts | 58 +++++----------- 25 files changed, 226 insertions(+), 501 deletions(-) delete mode 100644 python/inkbox/mail/resources/webhooks.py delete mode 100644 python/inkbox/phone/resources/webhooks.py delete mode 100644 typescript/src/phone/resources/webhooks.ts delete mode 100644 typescript/src/resources/webhooks.ts diff --git a/python/inkbox/mail/__init__.py b/python/inkbox/mail/__init__.py index c20c2b6..195931a 100644 --- a/python/inkbox/mail/__init__.py +++ b/python/inkbox/mail/__init__.py @@ -8,10 +8,9 @@ Mailbox, Message, MessageDetail, + SigningKey, Thread, ThreadDetail, - Webhook, - WebhookCreateResult, ) __all__ = [ @@ -21,8 +20,7 @@ "Mailbox", "Message", "MessageDetail", + "SigningKey", "Thread", "ThreadDetail", - "Webhook", - "WebhookCreateResult", ] diff --git a/python/inkbox/mail/client.py b/python/inkbox/mail/client.py index 5bbdbbe..c2e9872 100644 --- a/python/inkbox/mail/client.py +++ b/python/inkbox/mail/client.py @@ -9,8 +9,8 @@ from inkbox.mail._http import HttpTransport from inkbox.mail.resources.mailboxes import MailboxesResource from inkbox.mail.resources.messages import MessagesResource +from inkbox.mail.resources.signing_keys import SigningKeysResource from inkbox.mail.resources.threads import ThreadsResource -from inkbox.mail.resources.webhooks import WebhooksResource _DEFAULT_BASE_URL = "https://api.inkbox.ai/api/v1/mail" @@ -29,16 +29,16 @@ class InkboxMail: client = InkboxMail(api_key="ApiKey_...") - mailbox = client.mailboxes.create(display_name="Agent 01") + mailbox = client.mailboxes.create(agent_handle="sales-agent") client.messages.send( - mailbox.id, + mailbox.email_address, to=["user@example.com"], subject="Hello from Inkbox", body_text="Hi there!", ) - for msg in client.messages.list(mailbox.id): + for msg in client.messages.list(mailbox.email_address): print(msg.subject, msg.from_address) client.close() @@ -57,14 +57,18 @@ def __init__( timeout: float = 30.0, ) -> None: self._http = HttpTransport(api_key=api_key, base_url=base_url, timeout=timeout) + # Signing keys live at the API root (one level up from /mail) + _api_root = base_url.rstrip("/").removesuffix("/mail") + self._api_http = HttpTransport(api_key=api_key, base_url=_api_root, timeout=timeout) self.mailboxes = MailboxesResource(self._http) self.messages = MessagesResource(self._http) self.threads = ThreadsResource(self._http) - self.webhooks = WebhooksResource(self._http) + self.signing_keys = SigningKeysResource(self._api_http) def close(self) -> None: - """Close the underlying HTTP connection pool.""" + """Close the underlying HTTP connection pools.""" self._http.close() + self._api_http.close() def __enter__(self) -> InkboxMail: return self diff --git a/python/inkbox/mail/resources/__init__.py b/python/inkbox/mail/resources/__init__.py index a2cb00b..64b86c4 100644 --- a/python/inkbox/mail/resources/__init__.py +++ b/python/inkbox/mail/resources/__init__.py @@ -1,11 +1,11 @@ from inkbox.mail.resources.mailboxes import MailboxesResource from inkbox.mail.resources.messages import MessagesResource +from inkbox.mail.resources.signing_keys import SigningKeysResource from inkbox.mail.resources.threads import ThreadsResource -from inkbox.mail.resources.webhooks import WebhooksResource __all__ = [ "MailboxesResource", "MessagesResource", + "SigningKeysResource", "ThreadsResource", - "WebhooksResource", ] diff --git a/python/inkbox/mail/resources/mailboxes.py b/python/inkbox/mail/resources/mailboxes.py index 6ea88d1..8661bd1 100644 --- a/python/inkbox/mail/resources/mailboxes.py +++ b/python/inkbox/mail/resources/mailboxes.py @@ -14,6 +14,7 @@ from inkbox.mail._http import HttpTransport _BASE = "/mailboxes" +_UNSET = object() class MailboxesResource: @@ -23,19 +24,22 @@ def __init__(self, http: HttpTransport) -> None: def create( self, *, + agent_handle: str, display_name: str | None = None, ) -> Mailbox: - """Create a new mailbox. + """Create a new mailbox and assign it to an agent identity. The email address is automatically generated by the server. Args: + agent_handle: Handle of the agent identity to assign this mailbox to + (e.g. ``"sales-agent"`` or ``"@sales-agent"``). display_name: Optional human-readable name shown as the sender. Returns: The created mailbox. """ - body: dict[str, Any] = {} + body: dict[str, Any] = {"agent_handle": agent_handle} if display_name is not None: body["display_name"] = display_name data = self._http.post(_BASE, json=body) @@ -60,22 +64,27 @@ def update( self, email_address: str, *, - display_name: str | None = None, + display_name: str | None = _UNSET, # type: ignore[assignment] + webhook_url: str | None = _UNSET, # type: ignore[assignment] ) -> Mailbox: """Update mutable mailbox fields. Only provided fields are applied; omitted fields are left unchanged. + Pass ``webhook_url=None`` to unsubscribe from webhooks. Args: email_address: Full email address of the mailbox to update. display_name: New human-readable sender name. + webhook_url: HTTPS URL to receive webhook events, or ``None`` to unsubscribe. Returns: The updated mailbox. """ body: dict[str, Any] = {} - if display_name is not None: + if display_name is not _UNSET: body["display_name"] = display_name + if webhook_url is not _UNSET: + body["webhook_url"] = webhook_url data = self._http.patch(f"{_BASE}/{email_address}", json=body) return Mailbox._from_dict(data) diff --git a/python/inkbox/mail/resources/messages.py b/python/inkbox/mail/resources/messages.py index 6800016..f720693 100644 --- a/python/inkbox/mail/resources/messages.py +++ b/python/inkbox/mail/resources/messages.py @@ -26,6 +26,7 @@ def list( email_address: str, *, page_size: int = _DEFAULT_PAGE_SIZE, + direction: str | None = None, ) -> Iterator[Message]: """Iterator over all messages in a mailbox, newest first. @@ -34,25 +35,30 @@ def list( Args: email_address: Full email address of the mailbox. page_size: Number of messages fetched per API call (1–100). + direction: Filter by direction: ``"inbound"`` or ``"outbound"``. Example:: for msg in client.messages.list(email_address): print(msg.subject, msg.from_address) """ - return self._paginate(email_address, page_size=page_size) + return self._paginate(email_address, page_size=page_size, direction=direction) def _paginate( self, email_address: str, *, page_size: int, + direction: str | None = None, ) -> Iterator[Message]: cursor: str | None = None while True: + params: dict[str, Any] = {"limit": page_size, "cursor": cursor} + if direction is not None: + params["direction"] = direction page = self._http.get( f"/mailboxes/{email_address}/messages", - params={"limit": page_size, "cursor": cursor}, + params=params, ) for item in page["items"]: yield Message._from_dict(item) @@ -166,3 +172,29 @@ def unstar(self, email_address: str, message_id: UUID | str) -> Message: def delete(self, email_address: str, message_id: UUID | str) -> None: """Delete a message.""" self._http.delete(f"/mailboxes/{email_address}/messages/{message_id}") + + def get_attachment( + self, + email_address: str, + message_id: UUID | str, + filename: str, + *, + redirect: bool = False, + ) -> dict[str, Any]: + """Get a presigned URL for a message attachment. + + Args: + email_address: Full email address of the owning mailbox. + message_id: UUID of the message. + filename: Attachment filename. + redirect: If ``True``, follows the 302 redirect and returns the final + URL as ``{"url": str}``. If ``False`` (default), returns + ``{"url": str, "filename": str, "expires_in": int}``. + + Returns: + Dict with ``url``, ``filename``, and ``expires_in`` (seconds). + """ + return self._http.get( + f"/mailboxes/{email_address}/messages/{message_id}/attachments/{filename}", + params={"redirect": "true" if redirect else "false"}, + ) diff --git a/python/inkbox/mail/resources/webhooks.py b/python/inkbox/mail/resources/webhooks.py deleted file mode 100644 index 755a6d6..0000000 --- a/python/inkbox/mail/resources/webhooks.py +++ /dev/null @@ -1,54 +0,0 @@ -""" -inkbox/mail/resources/webhooks.py - -Webhook CRUD. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING -from uuid import UUID - -from inkbox.mail.types import Webhook, WebhookCreateResult - -if TYPE_CHECKING: - from inkbox.mail._http import HttpTransport - - -class WebhooksResource: - def __init__(self, http: HttpTransport) -> None: - self._http = http - - def create( - self, - email_address: str, - *, - url: str, - event_types: list[str], - ) -> WebhookCreateResult: - """Register a webhook subscription for a mailbox. - - Args: - email_address: Full email address of the mailbox to subscribe to. - url: HTTPS endpoint that will receive webhook POST requests. - event_types: Events to subscribe to. - Valid values: ``"message.received"``, ``"message.sent"``. - - Returns: - The created webhook. ``secret`` is the HMAC-SHA256 signing key — - save it immediately, as it will not be returned again. - """ - data = self._http.post( - f"/mailboxes/{email_address}/webhooks", - json={"url": url, "event_types": event_types}, - ) - return WebhookCreateResult._from_dict(data) - - def list(self, email_address: str) -> list[Webhook]: - """List all active webhooks for a mailbox.""" - data = self._http.get(f"/mailboxes/{email_address}/webhooks") - return [Webhook._from_dict(w) for w in data] - - def delete(self, email_address: str, webhook_id: UUID | str) -> None: - """Delete a webhook subscription.""" - self._http.delete(f"/mailboxes/{email_address}/webhooks/{webhook_id}") diff --git a/python/inkbox/mail/types.py b/python/inkbox/mail/types.py index 7af8989..8df4ec4 100644 --- a/python/inkbox/mail/types.py +++ b/python/inkbox/mail/types.py @@ -23,6 +23,7 @@ class Mailbox: id: UUID email_address: str display_name: str | None + webhook_url: str | None status: str created_at: datetime updated_at: datetime @@ -33,6 +34,7 @@ def _from_dict(cls, d: dict[str, Any]) -> Mailbox: id=UUID(d["id"]), email_address=d["email_address"], display_name=d.get("display_name"), + webhook_url=d.get("webhook_url"), status=d["status"], created_at=datetime.fromisoformat(d["created_at"]), updated_at=datetime.fromisoformat(d["updated_at"]), @@ -154,35 +156,18 @@ def _from_dict(cls, d: dict[str, Any]) -> ThreadDetail: # type: ignore[override @dataclass -class Webhook: - """A webhook subscription for a mailbox.""" +class SigningKey: + """Org-level webhook signing key. - id: UUID - mailbox_id: UUID - url: str - event_types: list[str] - status: str + Returned once on creation/rotation — store ``signing_key`` securely. + """ + + signing_key: str created_at: datetime @classmethod - def _from_dict(cls, d: dict[str, Any]) -> Webhook: + def _from_dict(cls, d: dict[str, Any]) -> SigningKey: return cls( - id=UUID(d["id"]), - mailbox_id=UUID(d["mailbox_id"]), - url=d["url"], - event_types=d["event_types"], - status=d["status"], + signing_key=d["signing_key"], created_at=datetime.fromisoformat(d["created_at"]), ) - - -@dataclass -class WebhookCreateResult(Webhook): - """Returned only on webhook creation. Includes the one-time HMAC signing secret.""" - - secret: str = "" - - @classmethod - def _from_dict(cls, d: dict[str, Any]) -> WebhookCreateResult: # type: ignore[override] - base = Webhook._from_dict(d) - return cls(**base.__dict__, secret=d["secret"]) diff --git a/python/inkbox/phone/__init__.py b/python/inkbox/phone/__init__.py index aee5180..5824788 100644 --- a/python/inkbox/phone/__init__.py +++ b/python/inkbox/phone/__init__.py @@ -6,10 +6,10 @@ from inkbox.phone.exceptions import InkboxAPIError, InkboxError from inkbox.phone.types import ( PhoneCall, + PhoneCallWithRateLimit, PhoneNumber, PhoneTranscript, - PhoneWebhook, - PhoneWebhookCreateResult, + RateLimitInfo, ) __all__ = [ @@ -17,8 +17,8 @@ "InkboxError", "InkboxAPIError", "PhoneCall", + "PhoneCallWithRateLimit", "PhoneNumber", "PhoneTranscript", - "PhoneWebhook", - "PhoneWebhookCreateResult", + "RateLimitInfo", ] diff --git a/python/inkbox/phone/client.py b/python/inkbox/phone/client.py index 2bfaca1..3c884c9 100644 --- a/python/inkbox/phone/client.py +++ b/python/inkbox/phone/client.py @@ -10,7 +10,6 @@ from inkbox.phone.resources.numbers import PhoneNumbersResource from inkbox.phone.resources.calls import CallsResource from inkbox.phone.resources.transcripts import TranscriptsResource -from inkbox.phone.resources.webhooks import PhoneWebhooksResource _DEFAULT_BASE_URL = "https://api.inkbox.ai/api/v1/phone" @@ -28,11 +27,11 @@ class InkboxPhone: from inkbox.phone import InkboxPhone with InkboxPhone(api_key="ApiKey_...") as client: - number = client.numbers.provision(type="toll_free") + number = client.numbers.provision(agent_handle="sales-agent") call = client.calls.place( from_number=number.number, to_number="+15167251294", - stream_url="wss://your-agent.example.com/ws", + client_websocket_url="wss://your-agent.example.com/ws", ) print(call.status) """ @@ -48,7 +47,6 @@ def __init__( self.numbers = PhoneNumbersResource(self._http) self.calls = CallsResource(self._http) self.transcripts = TranscriptsResource(self._http) - self.webhooks = PhoneWebhooksResource(self._http) def close(self) -> None: """Close the underlying HTTP connection pool.""" diff --git a/python/inkbox/phone/resources/__init__.py b/python/inkbox/phone/resources/__init__.py index bc4f6c5..99c4857 100644 --- a/python/inkbox/phone/resources/__init__.py +++ b/python/inkbox/phone/resources/__init__.py @@ -1,11 +1,9 @@ from inkbox.phone.resources.numbers import PhoneNumbersResource from inkbox.phone.resources.calls import CallsResource from inkbox.phone.resources.transcripts import TranscriptsResource -from inkbox.phone.resources.webhooks import PhoneWebhooksResource __all__ = [ "PhoneNumbersResource", "CallsResource", "TranscriptsResource", - "PhoneWebhooksResource", ] diff --git a/python/inkbox/phone/resources/calls.py b/python/inkbox/phone/resources/calls.py index 8a085a0..82df99b 100644 --- a/python/inkbox/phone/resources/calls.py +++ b/python/inkbox/phone/resources/calls.py @@ -58,8 +58,7 @@ def place( *, from_number: str, to_number: str, - stream_url: str | None = None, - pipeline_mode: str | None = None, + client_websocket_url: str | None = None, webhook_url: str | None = None, ) -> PhoneCallWithRateLimit: """Place an outbound call. @@ -67,8 +66,8 @@ def place( Args: from_number: E.164 number to call from. Must belong to your org and be active. to_number: E.164 number to call. - stream_url: WebSocket URL (wss://) for audio bridging. - pipeline_mode: Pipeline mode override for this call. + client_websocket_url: WebSocket URL (wss://) for audio bridging. Falls back + to the phone number's ``client_websocket_url`` if not provided. webhook_url: Custom webhook URL for call lifecycle events. Returns: @@ -78,10 +77,8 @@ def place( "from_number": from_number, "to_number": to_number, } - if stream_url is not None: - body["stream_url"] = stream_url - if pipeline_mode is not None: - body["pipeline_mode"] = pipeline_mode + if client_websocket_url is not None: + body["client_websocket_url"] = client_websocket_url if webhook_url is not None: body["webhook_url"] = webhook_url data = self._http.post("/place-call", json=body) diff --git a/python/inkbox/phone/resources/numbers.py b/python/inkbox/phone/resources/numbers.py index 2c26ad0..8a6b924 100644 --- a/python/inkbox/phone/resources/numbers.py +++ b/python/inkbox/phone/resources/numbers.py @@ -15,6 +15,7 @@ from inkbox.phone._http import HttpTransport _BASE = "/numbers" +_UNSET = object() class PhoneNumbersResource: @@ -35,58 +36,81 @@ def update( self, phone_number_id: UUID | str, *, - incoming_call_action: str | None = None, - default_stream_url: str | None = None, - default_pipeline_mode: str | None = None, + incoming_call_action: str | None = _UNSET, # type: ignore[assignment] + client_websocket_url: str | None = _UNSET, # type: ignore[assignment] + incoming_call_webhook_url: str | None = _UNSET, # type: ignore[assignment] ) -> PhoneNumber: """Update phone number settings. Pass only the fields you want to change; omitted fields are left as-is. + Pass a field as ``None`` to clear it. Args: phone_number_id: UUID of the phone number. incoming_call_action: ``"auto_accept"``, ``"auto_reject"``, or ``"webhook"``. - default_stream_url: Default WebSocket URL (wss://) for audio bridging. - default_pipeline_mode: Default pipeline mode for calls on this number. + client_websocket_url: WebSocket URL (wss://) for audio bridging. + Required when ``incoming_call_action="auto_accept"``. + incoming_call_webhook_url: HTTPS URL to call on incoming calls. + Required when ``incoming_call_action="webhook"``. """ body: dict[str, Any] = {} - if incoming_call_action is not None: + if incoming_call_action is not _UNSET: body["incoming_call_action"] = incoming_call_action - if default_stream_url is not None: - body["default_stream_url"] = default_stream_url - if default_pipeline_mode is not None: - body["default_pipeline_mode"] = default_pipeline_mode + if client_websocket_url is not _UNSET: + body["client_websocket_url"] = client_websocket_url + if incoming_call_webhook_url is not _UNSET: + body["incoming_call_webhook_url"] = incoming_call_webhook_url data = self._http.patch(f"{_BASE}/{phone_number_id}", json=body) return PhoneNumber._from_dict(data) def provision( self, *, + agent_handle: str, type: str = "toll_free", state: str | None = None, + incoming_call_action: str = "auto_reject", + client_websocket_url: str | None = None, + incoming_call_webhook_url: str | None = None, ) -> PhoneNumber: - """Provision a new phone number. + """Provision a new phone number via Telnyx and assign it to an agent identity. Args: - type: ``"toll_free"`` or ``"local"``. + agent_handle: Handle of the agent identity to assign this number to + (e.g. ``"sales-agent"`` or ``"@sales-agent"``). + type: ``"toll_free"`` or ``"local"``. Defaults to ``"toll_free"``. state: US state abbreviation (e.g. ``"NY"``). Only valid for ``local`` numbers. + incoming_call_action: ``"auto_accept"``, ``"auto_reject"``, or ``"webhook"``. + Defaults to ``"auto_reject"``. + client_websocket_url: WebSocket URL (wss://) for audio bridging. + Required when ``incoming_call_action="auto_accept"``. + incoming_call_webhook_url: HTTPS URL to call on incoming calls. + Required when ``incoming_call_action="webhook"``. Returns: The provisioned phone number. """ - body: dict[str, Any] = {"type": type} + body: dict[str, Any] = { + "agent_handle": agent_handle, + "type": type, + "incoming_call_action": incoming_call_action, + } if state is not None: body["state"] = state - data = self._http.post(f"{_BASE}/provision", json=body) + if client_websocket_url is not None: + body["client_websocket_url"] = client_websocket_url + if incoming_call_webhook_url is not None: + body["incoming_call_webhook_url"] = incoming_call_webhook_url + data = self._http.post(_BASE, json=body) return PhoneNumber._from_dict(data) - def release(self, *, number: str) -> None: + def release(self, phone_number_id: UUID | str) -> None: """Release a phone number. Args: - number: E.164 phone number to release (e.g. ``"+18335794607"``). + phone_number_id: UUID of the phone number to release. """ - self._http.post(f"{_BASE}/release", json={"number": number}) + self._http.delete(f"{_BASE}/{phone_number_id}") def search_transcripts( self, diff --git a/python/inkbox/phone/resources/webhooks.py b/python/inkbox/phone/resources/webhooks.py deleted file mode 100644 index 313f77d..0000000 --- a/python/inkbox/phone/resources/webhooks.py +++ /dev/null @@ -1,80 +0,0 @@ -""" -inkbox/phone/resources/webhooks.py - -Phone webhook CRUD. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any -from uuid import UUID - -from inkbox.phone.types import PhoneWebhook, PhoneWebhookCreateResult - -if TYPE_CHECKING: - from inkbox.phone._http import HttpTransport - - -class PhoneWebhooksResource: - def __init__(self, http: HttpTransport) -> None: - self._http = http - - def create( - self, - phone_number_id: UUID | str, - *, - url: str, - event_types: list[str], - ) -> PhoneWebhookCreateResult: - """Register a webhook subscription for a phone number. - - Args: - phone_number_id: UUID of the phone number. - url: HTTPS endpoint that will receive webhook POST requests. - event_types: Events to subscribe to (e.g. ``["incoming_call"]``). - - Returns: - The created webhook. ``secret`` is the HMAC-SHA256 signing key — - save it immediately, as it will not be returned again. - """ - data = self._http.post( - f"/numbers/{phone_number_id}/webhooks", - json={"url": url, "event_types": event_types}, - ) - return PhoneWebhookCreateResult._from_dict(data) - - def list(self, phone_number_id: UUID | str) -> list[PhoneWebhook]: - """List all active webhooks for a phone number.""" - data = self._http.get(f"/numbers/{phone_number_id}/webhooks") - return [PhoneWebhook._from_dict(w) for w in data] - - def update( - self, - phone_number_id: UUID | str, - webhook_id: UUID | str, - *, - url: str | None = None, - event_types: list[str] | None = None, - ) -> PhoneWebhook: - """Update a webhook subscription. - - Pass only the fields you want to change; omitted fields are left as-is. - """ - body: dict[str, Any] = {} - if url is not None: - body["url"] = url - if event_types is not None: - body["event_types"] = event_types - data = self._http.patch( - f"/numbers/{phone_number_id}/webhooks/{webhook_id}", - json=body, - ) - return PhoneWebhook._from_dict(data) - - def delete( - self, - phone_number_id: UUID | str, - webhook_id: UUID | str, - ) -> None: - """Delete a webhook subscription.""" - self._http.delete(f"/numbers/{phone_number_id}/webhooks/{webhook_id}") diff --git a/python/inkbox/phone/types.py b/python/inkbox/phone/types.py index 1ca07be..8cda2fd 100644 --- a/python/inkbox/phone/types.py +++ b/python/inkbox/phone/types.py @@ -25,8 +25,8 @@ class PhoneNumber: type: str status: str incoming_call_action: str - default_stream_url: str | None - default_pipeline_mode: str + client_websocket_url: str | None + incoming_call_webhook_url: str | None created_at: datetime updated_at: datetime @@ -38,8 +38,8 @@ def _from_dict(cls, d: dict[str, Any]) -> PhoneNumber: type=d["type"], status=d["status"], incoming_call_action=d["incoming_call_action"], - default_stream_url=d.get("default_stream_url"), - default_pipeline_mode=d.get("default_pipeline_mode", "client_llm_only"), + client_websocket_url=d.get("client_websocket_url"), + incoming_call_webhook_url=d.get("incoming_call_webhook_url"), created_at=datetime.fromisoformat(d["created_at"]), updated_at=datetime.fromisoformat(d["updated_at"]), ) @@ -54,8 +54,10 @@ class PhoneCall: remote_phone_number: str direction: str status: str - pipeline_mode: str | None - stream_url: str | None + client_websocket_url: str | None + use_inkbox_tts: bool | None + use_inkbox_stt: bool | None + hangup_reason: str | None started_at: datetime | None ended_at: datetime | None created_at: datetime @@ -69,8 +71,10 @@ def _from_dict(cls, d: dict[str, Any]) -> PhoneCall: remote_phone_number=d["remote_phone_number"], direction=d["direction"], status=d["status"], - pipeline_mode=d.get("pipeline_mode"), - stream_url=d.get("stream_url"), + client_websocket_url=d.get("client_websocket_url"), + use_inkbox_tts=d.get("use_inkbox_tts"), + use_inkbox_stt=d.get("use_inkbox_stt"), + hangup_reason=d.get("hangup_reason"), started_at=_dt(d.get("started_at")), ended_at=_dt(d.get("ended_at")), created_at=datetime.fromisoformat(d["created_at"]), @@ -144,38 +148,3 @@ def _from_dict(cls, d: dict[str, Any]) -> PhoneTranscript: ) -@dataclass -class PhoneWebhook: - """A webhook subscription for a phone number.""" - - id: UUID - source_id: UUID - source_type: str - url: str - event_types: list[str] - status: str - created_at: datetime - - @classmethod - def _from_dict(cls, d: dict[str, Any]) -> PhoneWebhook: - return cls( - id=UUID(d["id"]), - source_id=UUID(d["source_id"]), - source_type=d["source_type"], - url=d["url"], - event_types=d["event_types"], - status=d["status"], - created_at=datetime.fromisoformat(d["created_at"]), - ) - - -@dataclass -class PhoneWebhookCreateResult(PhoneWebhook): - """Returned only on webhook creation. Includes the one-time HMAC signing secret.""" - - secret: str = "" - - @classmethod - def _from_dict(cls, d: dict[str, Any]) -> PhoneWebhookCreateResult: # type: ignore[override] - base = PhoneWebhook._from_dict(d) - return cls(**base.__dict__, secret=d["secret"]) diff --git a/typescript/src/client.ts b/typescript/src/client.ts index 99f3fe2..3c6a534 100644 --- a/typescript/src/client.ts +++ b/typescript/src/client.ts @@ -7,8 +7,8 @@ import { HttpTransport } from "./_http.js"; import { MailboxesResource } from "./resources/mailboxes.js"; import { MessagesResource } from "./resources/messages.js"; +import { SigningKeysResource } from "./resources/signing-keys.js"; import { ThreadsResource } from "./resources/threads.js"; -import { WebhooksResource } from "./resources/webhooks.js"; const DEFAULT_BASE_URL = "https://api.inkbox.ai/api/v1/mail"; @@ -30,15 +30,15 @@ export interface InkboxMailOptions { * * const client = new InkboxMail({ apiKey: "ApiKey_..." }); * - * const mailbox = await client.mailboxes.create({ displayName: "Agent 01" }); + * const mailbox = await client.mailboxes.create({ agentHandle: "sales-agent" }); * - * await client.messages.send(mailbox.id, { + * await client.messages.send(mailbox.emailAddress, { * to: ["user@example.com"], * subject: "Hello from Inkbox", * bodyText: "Hi there!", * }); * - * for await (const msg of client.messages.list(mailbox.id)) { + * for await (const msg of client.messages.list(mailbox.emailAddress)) { * console.log(msg.subject, msg.fromAddress); * } * ``` @@ -47,19 +47,20 @@ export class InkboxMail { readonly mailboxes: MailboxesResource; readonly messages: MessagesResource; readonly threads: ThreadsResource; - readonly webhooks: WebhooksResource; + readonly signingKeys: SigningKeysResource; private readonly http: HttpTransport; + private readonly apiHttp: HttpTransport; constructor(options: InkboxMailOptions) { - this.http = new HttpTransport( - options.apiKey, - options.baseUrl ?? DEFAULT_BASE_URL, - options.timeoutMs ?? 30_000, - ); + const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL; + this.http = new HttpTransport(options.apiKey, baseUrl, options.timeoutMs ?? 30_000); + // Signing keys live at the API root (one level up from /mail) + const apiRoot = baseUrl.replace(/\/mail\/?$/, ""); + this.apiHttp = new HttpTransport(options.apiKey, apiRoot, options.timeoutMs ?? 30_000); this.mailboxes = new MailboxesResource(this.http); this.messages = new MessagesResource(this.http); this.threads = new ThreadsResource(this.http); - this.webhooks = new WebhooksResource(this.http); + this.signingKeys = new SigningKeysResource(this.apiHttp); } } diff --git a/typescript/src/index.ts b/typescript/src/index.ts index 3f4443f..a3ae9b5 100644 --- a/typescript/src/index.ts +++ b/typescript/src/index.ts @@ -4,8 +4,7 @@ export type { Mailbox, Message, MessageDetail, + SigningKey, Thread, ThreadDetail, - Webhook, - WebhookCreateResult, } from "./types.js"; diff --git a/typescript/src/phone/client.ts b/typescript/src/phone/client.ts index 2dd29c7..aa95d84 100644 --- a/typescript/src/phone/client.ts +++ b/typescript/src/phone/client.ts @@ -8,7 +8,6 @@ import { HttpTransport } from "../_http.js"; import { PhoneNumbersResource } from "./resources/numbers.js"; import { CallsResource } from "./resources/calls.js"; import { TranscriptsResource } from "./resources/transcripts.js"; -import { PhoneWebhooksResource } from "./resources/webhooks.js"; const DEFAULT_BASE_URL = "https://api.inkbox.ai/api/v1/phone"; @@ -30,12 +29,12 @@ export interface InkboxPhoneOptions { * * const client = new InkboxPhone({ apiKey: "ApiKey_..." }); * - * const number = await client.numbers.provision(); + * const number = await client.numbers.provision({ agentHandle: "sales-agent" }); * * const call = await client.calls.place({ * fromNumber: number.number, * toNumber: "+15167251294", - * streamUrl: "wss://your-agent.example.com/ws", + * clientWebsocketUrl: "wss://your-agent.example.com/ws", * }); * * console.log(call.status); @@ -45,7 +44,6 @@ export class InkboxPhone { readonly numbers: PhoneNumbersResource; readonly calls: CallsResource; readonly transcripts: TranscriptsResource; - readonly webhooks: PhoneWebhooksResource; private readonly http: HttpTransport; @@ -58,6 +56,5 @@ export class InkboxPhone { this.numbers = new PhoneNumbersResource(this.http); this.calls = new CallsResource(this.http); this.transcripts = new TranscriptsResource(this.http); - this.webhooks = new PhoneWebhooksResource(this.http); } } diff --git a/typescript/src/phone/index.ts b/typescript/src/phone/index.ts index e395dd7..e6e38f2 100644 --- a/typescript/src/phone/index.ts +++ b/typescript/src/phone/index.ts @@ -5,6 +5,4 @@ export type { PhoneCallWithRateLimit, RateLimitInfo, PhoneTranscript, - PhoneWebhook, - PhoneWebhookCreateResult, } from "./types.js"; diff --git a/typescript/src/phone/resources/numbers.ts b/typescript/src/phone/resources/numbers.ts index 0be8b50..f5f348e 100644 --- a/typescript/src/phone/resources/numbers.ts +++ b/typescript/src/phone/resources/numbers.ts @@ -35,25 +35,31 @@ export class PhoneNumbersResource { /** * Update phone number settings. Only provided fields are updated. + * Pass a field as `null` to clear it. * * @param phoneNumberId - UUID of the phone number. * @param options.incomingCallAction - `"auto_accept"`, `"auto_reject"`, or `"webhook"`. * @param options.clientWebsocketUrl - WebSocket URL (wss://) for audio bridging on `auto_accept`. + * @param options.incomingCallWebhookUrl - HTTPS URL called on incoming calls when action is `webhook`. */ async update( phoneNumberId: string, options: { incomingCallAction?: string; - clientWebsocketUrl?: string; + clientWebsocketUrl?: string | null; + incomingCallWebhookUrl?: string | null; }, ): Promise { const body: Record = {}; if (options.incomingCallAction !== undefined) { body["incoming_call_action"] = options.incomingCallAction; } - if (options.clientWebsocketUrl !== undefined) { + if ("clientWebsocketUrl" in options) { body["client_websocket_url"] = options.clientWebsocketUrl; } + if ("incomingCallWebhookUrl" in options) { + body["incoming_call_webhook_url"] = options.incomingCallWebhookUrl; + } const data = await this.http.patch( `${BASE}/${phoneNumberId}`, body, @@ -68,19 +74,32 @@ export class PhoneNumbersResource { * (e.g. `"sales-agent"` or `"@sales-agent"`). * @param options.type - `"toll_free"` or `"local"`. Defaults to `"toll_free"`. * @param options.state - US state abbreviation (e.g. `"NY"`). Only valid for `local` numbers. + * @param options.incomingCallAction - `"auto_accept"`, `"auto_reject"`, or `"webhook"`. Defaults to `"auto_reject"`. + * @param options.clientWebsocketUrl - WebSocket URL (wss://) for audio bridging. Required when `incomingCallAction="auto_accept"`. + * @param options.incomingCallWebhookUrl - HTTPS URL called on incoming calls. Required when `incomingCallAction="webhook"`. */ async provision(options: { agentHandle: string; type?: string; state?: string; + incomingCallAction?: string; + clientWebsocketUrl?: string; + incomingCallWebhookUrl?: string; }): Promise { const body: Record = { agent_handle: options.agentHandle, type: options.type ?? "toll_free", + incoming_call_action: options.incomingCallAction ?? "auto_reject", }; if (options.state !== undefined) { body["state"] = options.state; } + if (options.clientWebsocketUrl !== undefined) { + body["client_websocket_url"] = options.clientWebsocketUrl; + } + if (options.incomingCallWebhookUrl !== undefined) { + body["incoming_call_webhook_url"] = options.incomingCallWebhookUrl; + } const data = await this.http.post(BASE, body); return parsePhoneNumber(data); } diff --git a/typescript/src/phone/resources/webhooks.ts b/typescript/src/phone/resources/webhooks.ts deleted file mode 100644 index 4c94b6d..0000000 --- a/typescript/src/phone/resources/webhooks.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * inkbox-phone/resources/webhooks.ts - * - * Phone webhook CRUD. - */ - -import { HttpTransport } from "../../_http.js"; -import { - PhoneWebhook, - PhoneWebhookCreateResult, - RawPhoneWebhook, - RawPhoneWebhookCreateResult, - parsePhoneWebhook, - parsePhoneWebhookCreateResult, -} from "../types.js"; - -export class PhoneWebhooksResource { - constructor(private readonly http: HttpTransport) {} - - /** - * Register a webhook subscription for a phone number. - * - * @param phoneNumberId - UUID of the phone number. - * @param options.url - HTTPS endpoint that will receive webhook POST requests. - * @param options.eventTypes - Events to subscribe to (e.g. `["incoming_call"]`). - * @returns The created webhook. `secret` is the one-time HMAC-SHA256 signing - * key — save it immediately, as it will not be returned again. - */ - async create( - phoneNumberId: string, - options: { url: string; eventTypes: string[] }, - ): Promise { - const data = await this.http.post( - `/numbers/${phoneNumberId}/webhooks`, - { url: options.url, event_types: options.eventTypes }, - ); - return parsePhoneWebhookCreateResult(data); - } - - /** List all active webhooks for a phone number. */ - async list(phoneNumberId: string): Promise { - const data = await this.http.get( - `/numbers/${phoneNumberId}/webhooks`, - ); - return data.map(parsePhoneWebhook); - } - - /** - * Update a webhook subscription. Only provided fields are updated. - * - * @param phoneNumberId - UUID of the phone number. - * @param webhookId - UUID of the webhook. - * @param options.url - New destination URL. - * @param options.eventTypes - New event subscriptions. - */ - async update( - phoneNumberId: string, - webhookId: string, - options: { url?: string; eventTypes?: string[] }, - ): Promise { - const body: Record = {}; - if (options.url !== undefined) { - body["url"] = options.url; - } - if (options.eventTypes !== undefined) { - body["event_types"] = options.eventTypes; - } - const data = await this.http.patch( - `/numbers/${phoneNumberId}/webhooks/${webhookId}`, - body, - ); - return parsePhoneWebhook(data); - } - - /** Delete a webhook subscription. */ - async delete(phoneNumberId: string, webhookId: string): Promise { - await this.http.delete( - `/numbers/${phoneNumberId}/webhooks/${webhookId}`, - ); - } -} diff --git a/typescript/src/phone/types.ts b/typescript/src/phone/types.ts index 4f6d4b7..91dd336 100644 --- a/typescript/src/phone/types.ts +++ b/typescript/src/phone/types.ts @@ -12,6 +12,7 @@ export interface PhoneNumber { /** "auto_accept" | "auto_reject" | "webhook" */ incomingCallAction: string; clientWebsocketUrl: string | null; + incomingCallWebhookUrl: string | null; createdAt: Date; updatedAt: Date; } @@ -59,22 +60,6 @@ export interface PhoneTranscript { createdAt: Date; } -export interface PhoneWebhook { - id: string; - sourceId: string; - sourceType: string; - url: string; - eventTypes: string[]; - /** "active" | "paused" | "deleted" */ - status: string; - createdAt: Date; -} - -export interface PhoneWebhookCreateResult extends PhoneWebhook { - /** One-time HMAC-SHA256 signing secret. Save immediately — not returned again. */ - secret: string; -} - // ---- internal raw API shapes (snake_case from JSON) ---- export interface RawPhoneNumber { @@ -84,6 +69,7 @@ export interface RawPhoneNumber { status: string; incoming_call_action: string; client_websocket_url: string | null; + incoming_call_webhook_url: string | null; created_at: string; updated_at: string; } @@ -127,20 +113,6 @@ export interface RawPhoneTranscript { created_at: string; } -export interface RawPhoneWebhook { - id: string; - source_id: string; - source_type: string; - url: string; - event_types: string[]; - status: string; - created_at: string; -} - -export interface RawPhoneWebhookCreateResult extends RawPhoneWebhook { - secret: string; -} - // ---- parsers ---- export function parsePhoneNumber(r: RawPhoneNumber): PhoneNumber { @@ -151,6 +123,7 @@ export function parsePhoneNumber(r: RawPhoneNumber): PhoneNumber { status: r.status, incomingCallAction: r.incoming_call_action, clientWebsocketUrl: r.client_websocket_url, + incomingCallWebhookUrl: r.incoming_call_webhook_url, createdAt: new Date(r.created_at), updatedAt: new Date(r.updated_at), }; @@ -206,20 +179,3 @@ export function parsePhoneTranscript(r: RawPhoneTranscript): PhoneTranscript { }; } -export function parsePhoneWebhook(r: RawPhoneWebhook): PhoneWebhook { - return { - id: r.id, - sourceId: r.source_id, - sourceType: r.source_type, - url: r.url, - eventTypes: r.event_types, - status: r.status, - createdAt: new Date(r.created_at), - }; -} - -export function parsePhoneWebhookCreateResult( - r: RawPhoneWebhookCreateResult, -): PhoneWebhookCreateResult { - return { ...parsePhoneWebhook(r), secret: r.secret }; -} diff --git a/typescript/src/resources/mailboxes.ts b/typescript/src/resources/mailboxes.ts index 8f92b45..efdab16 100644 --- a/typescript/src/resources/mailboxes.ts +++ b/typescript/src/resources/mailboxes.ts @@ -58,15 +58,23 @@ export class MailboxesResource { * Update mutable mailbox fields. * * Only provided fields are applied; omitted fields are left unchanged. + * Pass `webhookUrl: null` to unsubscribe from webhooks. * * @param emailAddress - Full email address of the mailbox to update. * @param options.displayName - New human-readable sender name. + * @param options.webhookUrl - HTTPS URL to receive webhook events, or `null` to unsubscribe. */ - async update(emailAddress: string, options: { displayName?: string }): Promise { + async update( + emailAddress: string, + options: { displayName?: string; webhookUrl?: string | null }, + ): Promise { const body: Record = {}; if (options.displayName !== undefined) { body["display_name"] = options.displayName; } + if ("webhookUrl" in options) { + body["webhook_url"] = options.webhookUrl; + } const data = await this.http.patch(`${BASE}/${emailAddress}`, body); return parseMailbox(data); } diff --git a/typescript/src/resources/messages.ts b/typescript/src/resources/messages.ts index 11bd2fb..d8caacd 100644 --- a/typescript/src/resources/messages.ts +++ b/typescript/src/resources/messages.ts @@ -33,15 +33,17 @@ export class MessagesResource { */ async *list( emailAddress: string, - options?: { pageSize?: number }, + options?: { pageSize?: number; direction?: "inbound" | "outbound" }, ): AsyncGenerator { const limit = options?.pageSize ?? DEFAULT_PAGE_SIZE; let cursor: string | undefined; while (true) { + const params: Record = { limit, cursor }; + if (options?.direction !== undefined) params["direction"] = options.direction; const page = await this.http.get>( `/mailboxes/${emailAddress}/messages`, - { limit, cursor }, + params, ); for (const item of page.items) { yield parseMessage(item); @@ -170,4 +172,25 @@ export class MessagesResource { async delete(emailAddress: string, messageId: string): Promise { await this.http.delete(`/mailboxes/${emailAddress}/messages/${messageId}`); } + + /** + * Get a presigned URL for a message attachment. + * + * @param emailAddress - Full email address of the owning mailbox. + * @param messageId - UUID of the message. + * @param filename - Attachment filename. + * @param options.redirect - If `true`, follows the redirect. If `false` (default), + * returns `{ url, filename, expiresIn }`. + */ + async getAttachment( + emailAddress: string, + messageId: string, + filename: string, + options?: { redirect?: boolean }, + ): Promise<{ url: string; filename: string; expiresIn: number }> { + return this.http.get( + `/mailboxes/${emailAddress}/messages/${messageId}/attachments/${filename}`, + { redirect: options?.redirect ? "true" : "false" }, + ); + } } diff --git a/typescript/src/resources/webhooks.ts b/typescript/src/resources/webhooks.ts deleted file mode 100644 index 8d3a7a9..0000000 --- a/typescript/src/resources/webhooks.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * inkbox-mail/resources/webhooks.ts - * - * Webhook CRUD. - */ - -import { HttpTransport } from "../_http.js"; -import { - RawWebhook, - RawWebhookCreateResult, - Webhook, - WebhookCreateResult, - parseWebhook, - parseWebhookCreateResult, -} from "../types.js"; - -export class WebhooksResource { - constructor(private readonly http: HttpTransport) {} - - /** - * Register a webhook subscription for a mailbox. - * - * @param emailAddress - Full email address of the mailbox to subscribe to. - * @param options.url - HTTPS endpoint that will receive webhook POST requests. - * @param options.eventTypes - Events to subscribe to. - * Valid values: `"message.received"`, `"message.sent"`. - * @returns The created webhook. `secret` is the one-time HMAC-SHA256 signing - * key — save it immediately, as it will not be returned again. - */ - async create( - emailAddress: string, - options: { url: string; eventTypes: string[] }, - ): Promise { - const data = await this.http.post( - `/mailboxes/${emailAddress}/webhooks`, - { url: options.url, event_types: options.eventTypes }, - ); - return parseWebhookCreateResult(data); - } - - /** List all active webhooks for a mailbox. */ - async list(emailAddress: string): Promise { - const data = await this.http.get( - `/mailboxes/${emailAddress}/webhooks`, - ); - return data.map(parseWebhook); - } - - /** Delete a webhook subscription. */ - async delete(emailAddress: string, webhookId: string): Promise { - await this.http.delete(`/mailboxes/${emailAddress}/webhooks/${webhookId}`); - } -} diff --git a/typescript/src/types.ts b/typescript/src/types.ts index 1590931..20cfc64 100644 --- a/typescript/src/types.ts +++ b/typescript/src/types.ts @@ -6,6 +6,7 @@ export interface Mailbox { id: string; emailAddress: string; displayName: string | null; + webhookUrl: string | null; /** "active" | "paused" | "deleted" */ status: string; createdAt: Date; @@ -61,32 +62,29 @@ export interface ThreadDetail extends Thread { messages: Message[]; } -export interface Webhook { - id: string; - mailboxId: string; - url: string; - eventTypes: string[]; - /** "active" | "paused" | "deleted" */ - status: string; +export interface SigningKey { + /** Plaintext signing key — returned once on creation/rotation. Store securely. */ + signingKey: string; createdAt: Date; } -export interface WebhookCreateResult extends Webhook { - /** One-time HMAC-SHA256 signing secret. Save immediately — not returned again. */ - secret: string; -} - // ---- internal raw API shapes (snake_case from JSON) ---- export interface RawMailbox { id: string; email_address: string; display_name: string | null; + webhook_url: string | null; status: string; created_at: string; updated_at: string; } +export interface RawSigningKey { + signing_key: string; + created_at: string; +} + export interface RawMessage { id: string; mailbox_id: string; @@ -125,20 +123,6 @@ export interface RawThread { messages?: RawMessage[]; } -export interface RawWebhook { - id: string; - mailbox_id: string; - url: string; - event_types: string[]; - status: string; - created_at: string; -} - -export interface RawWebhookCreateResult extends RawWebhook { - /** One-time HMAC-SHA256 signing secret. Save immediately — not returned again. */ - secret: string; -} - export interface RawCursorPage { items: T[]; next_cursor: string | null; @@ -152,12 +136,20 @@ export function parseMailbox(r: RawMailbox): Mailbox { id: r.id, emailAddress: r.email_address, displayName: r.display_name, + webhookUrl: r.webhook_url, status: r.status, createdAt: new Date(r.created_at), updatedAt: new Date(r.updated_at), }; } +export function parseSigningKey(r: RawSigningKey): SigningKey { + return { + signingKey: r.signing_key, + createdAt: new Date(r.created_at), + }; +} + export function parseMessage(r: RawMessage): Message { return { id: r.id, @@ -211,17 +203,3 @@ export function parseThreadDetail(r: RawThread): ThreadDetail { }; } -export function parseWebhook(r: RawWebhook): Webhook { - return { - id: r.id, - mailboxId: r.mailbox_id, - url: r.url, - eventTypes: r.event_types, - status: r.status, - createdAt: new Date(r.created_at), - }; -} - -export function parseWebhookCreateResult(r: RawWebhookCreateResult): WebhookCreateResult { - return { ...parseWebhook(r), secret: r.secret }; -} From 8bbcd8d87b8aa68ed3d73ac3ac077f443daa44f2 Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:53:53 -0400 Subject: [PATCH 19/56] update signing keys --- python/inkbox/mail/__init__.py | 2 +- python/inkbox/mail/client.py | 2 +- python/inkbox/mail/resources/__init__.py | 2 - python/inkbox/mail/types.py | 16 -------- python/inkbox/phone/__init__.py | 2 + python/inkbox/phone/client.py | 8 +++- python/inkbox/signing_keys.py | 50 ++++++++++++++++++++++++ typescript/src/index.ts | 2 +- typescript/src/phone/client.ts | 14 ++++--- typescript/src/phone/index.ts | 1 + typescript/src/resources/signing-keys.ts | 46 ++++++++++++++++++++++ typescript/src/types.ts | 18 --------- 12 files changed, 118 insertions(+), 45 deletions(-) create mode 100644 python/inkbox/signing_keys.py create mode 100644 typescript/src/resources/signing-keys.ts diff --git a/python/inkbox/mail/__init__.py b/python/inkbox/mail/__init__.py index 195931a..0840072 100644 --- a/python/inkbox/mail/__init__.py +++ b/python/inkbox/mail/__init__.py @@ -8,10 +8,10 @@ Mailbox, Message, MessageDetail, - SigningKey, Thread, ThreadDetail, ) +from inkbox.signing_keys import SigningKey __all__ = [ "InkboxMail", diff --git a/python/inkbox/mail/client.py b/python/inkbox/mail/client.py index c2e9872..f109214 100644 --- a/python/inkbox/mail/client.py +++ b/python/inkbox/mail/client.py @@ -9,8 +9,8 @@ from inkbox.mail._http import HttpTransport from inkbox.mail.resources.mailboxes import MailboxesResource from inkbox.mail.resources.messages import MessagesResource -from inkbox.mail.resources.signing_keys import SigningKeysResource from inkbox.mail.resources.threads import ThreadsResource +from inkbox.signing_keys import SigningKeysResource _DEFAULT_BASE_URL = "https://api.inkbox.ai/api/v1/mail" diff --git a/python/inkbox/mail/resources/__init__.py b/python/inkbox/mail/resources/__init__.py index 64b86c4..ae8610a 100644 --- a/python/inkbox/mail/resources/__init__.py +++ b/python/inkbox/mail/resources/__init__.py @@ -1,11 +1,9 @@ from inkbox.mail.resources.mailboxes import MailboxesResource from inkbox.mail.resources.messages import MessagesResource -from inkbox.mail.resources.signing_keys import SigningKeysResource from inkbox.mail.resources.threads import ThreadsResource __all__ = [ "MailboxesResource", "MessagesResource", - "SigningKeysResource", "ThreadsResource", ] diff --git a/python/inkbox/mail/types.py b/python/inkbox/mail/types.py index 8df4ec4..a32f471 100644 --- a/python/inkbox/mail/types.py +++ b/python/inkbox/mail/types.py @@ -155,19 +155,3 @@ def _from_dict(cls, d: dict[str, Any]) -> ThreadDetail: # type: ignore[override ) -@dataclass -class SigningKey: - """Org-level webhook signing key. - - Returned once on creation/rotation — store ``signing_key`` securely. - """ - - signing_key: str - created_at: datetime - - @classmethod - def _from_dict(cls, d: dict[str, Any]) -> SigningKey: - return cls( - signing_key=d["signing_key"], - created_at=datetime.fromisoformat(d["created_at"]), - ) diff --git a/python/inkbox/phone/__init__.py b/python/inkbox/phone/__init__.py index 5824788..6141bdb 100644 --- a/python/inkbox/phone/__init__.py +++ b/python/inkbox/phone/__init__.py @@ -11,6 +11,7 @@ PhoneTranscript, RateLimitInfo, ) +from inkbox.signing_keys import SigningKey __all__ = [ "InkboxPhone", @@ -21,4 +22,5 @@ "PhoneNumber", "PhoneTranscript", "RateLimitInfo", + "SigningKey", ] diff --git a/python/inkbox/phone/client.py b/python/inkbox/phone/client.py index 3c884c9..fe53d06 100644 --- a/python/inkbox/phone/client.py +++ b/python/inkbox/phone/client.py @@ -10,6 +10,7 @@ from inkbox.phone.resources.numbers import PhoneNumbersResource from inkbox.phone.resources.calls import CallsResource from inkbox.phone.resources.transcripts import TranscriptsResource +from inkbox.signing_keys import SigningKeysResource _DEFAULT_BASE_URL = "https://api.inkbox.ai/api/v1/phone" @@ -44,13 +45,18 @@ def __init__( timeout: float = 30.0, ) -> None: self._http = HttpTransport(api_key=api_key, base_url=base_url, timeout=timeout) + # Signing keys live at the API root (one level up from /phone) + _api_root = base_url.rstrip("/").removesuffix("/phone") + self._api_http = HttpTransport(api_key=api_key, base_url=_api_root, timeout=timeout) self.numbers = PhoneNumbersResource(self._http) self.calls = CallsResource(self._http) self.transcripts = TranscriptsResource(self._http) + self.signing_keys = SigningKeysResource(self._api_http) def close(self) -> None: - """Close the underlying HTTP connection pool.""" + """Close the underlying HTTP connection pools.""" self._http.close() + self._api_http.close() def __enter__(self) -> InkboxPhone: return self diff --git a/python/inkbox/signing_keys.py b/python/inkbox/signing_keys.py new file mode 100644 index 0000000..dcaa7d8 --- /dev/null +++ b/python/inkbox/signing_keys.py @@ -0,0 +1,50 @@ +""" +inkbox/signing_keys.py + +Org-level webhook signing key management — shared across all Inkbox clients. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Any + + +@dataclass +class SigningKey: + """Org-level webhook signing key. + + Returned once on creation/rotation — store ``signing_key`` securely. + """ + + signing_key: str + created_at: datetime + + @classmethod + def _from_dict(cls, d: dict[str, Any]) -> SigningKey: + return cls( + signing_key=d["signing_key"], + created_at=datetime.fromisoformat(d["created_at"]), + ) + + +class SigningKeysResource: + def __init__(self, http: Any) -> None: + self._http = http + + def create_or_rotate(self) -> SigningKey: + """Create or rotate the webhook signing key for your organisation. + + The first call creates a new key; subsequent calls rotate (replace) the + existing key. The plaintext ``signing_key`` is returned **once** — + store it securely as it cannot be retrieved again. + + Use the returned key to verify ``X-Inkbox-Signature`` headers on + incoming webhook requests. + + Returns: + The newly created/rotated signing key with its creation timestamp. + """ + data = self._http.post("/signing-keys", json={}) + return SigningKey._from_dict(data) diff --git a/typescript/src/index.ts b/typescript/src/index.ts index a3ae9b5..c387c5e 100644 --- a/typescript/src/index.ts +++ b/typescript/src/index.ts @@ -1,10 +1,10 @@ export { InkboxMail } from "./client.js"; export { InkboxAPIError } from "./_http.js"; +export type { SigningKey } from "./resources/signing-keys.js"; export type { Mailbox, Message, MessageDetail, - SigningKey, Thread, ThreadDetail, } from "./types.js"; diff --git a/typescript/src/phone/client.ts b/typescript/src/phone/client.ts index aa95d84..65e40ea 100644 --- a/typescript/src/phone/client.ts +++ b/typescript/src/phone/client.ts @@ -8,6 +8,7 @@ import { HttpTransport } from "../_http.js"; import { PhoneNumbersResource } from "./resources/numbers.js"; import { CallsResource } from "./resources/calls.js"; import { TranscriptsResource } from "./resources/transcripts.js"; +import { SigningKeysResource } from "../resources/signing-keys.js"; const DEFAULT_BASE_URL = "https://api.inkbox.ai/api/v1/phone"; @@ -44,17 +45,20 @@ export class InkboxPhone { readonly numbers: PhoneNumbersResource; readonly calls: CallsResource; readonly transcripts: TranscriptsResource; + readonly signingKeys: SigningKeysResource; private readonly http: HttpTransport; + private readonly apiHttp: HttpTransport; constructor(options: InkboxPhoneOptions) { - this.http = new HttpTransport( - options.apiKey, - options.baseUrl ?? DEFAULT_BASE_URL, - options.timeoutMs ?? 30_000, - ); + const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL; + this.http = new HttpTransport(options.apiKey, baseUrl, options.timeoutMs ?? 30_000); + // Signing keys live at the API root (one level up from /phone) + const apiRoot = baseUrl.replace(/\/phone\/?$/, ""); + this.apiHttp = new HttpTransport(options.apiKey, apiRoot, options.timeoutMs ?? 30_000); this.numbers = new PhoneNumbersResource(this.http); this.calls = new CallsResource(this.http); this.transcripts = new TranscriptsResource(this.http); + this.signingKeys = new SigningKeysResource(this.apiHttp); } } diff --git a/typescript/src/phone/index.ts b/typescript/src/phone/index.ts index e6e38f2..1c7a73e 100644 --- a/typescript/src/phone/index.ts +++ b/typescript/src/phone/index.ts @@ -1,4 +1,5 @@ export { InkboxPhone } from "./client.js"; +export type { SigningKey } from "../resources/signing-keys.js"; export type { PhoneNumber, PhoneCall, diff --git a/typescript/src/resources/signing-keys.ts b/typescript/src/resources/signing-keys.ts new file mode 100644 index 0000000..f902a16 --- /dev/null +++ b/typescript/src/resources/signing-keys.ts @@ -0,0 +1,46 @@ +/** + * Org-level webhook signing key management. + * + * Shared across all Inkbox clients (mail, phone, etc.). + */ + +import { HttpTransport } from "../_http.js"; + +const PATH = "/signing-keys"; + +export interface SigningKey { + /** Plaintext signing key — returned once on creation/rotation. Store securely. */ + signingKey: string; + createdAt: Date; +} + +interface RawSigningKey { + signing_key: string; + created_at: string; +} + +function parseSigningKey(r: RawSigningKey): SigningKey { + return { + signingKey: r.signing_key, + createdAt: new Date(r.created_at), + }; +} + +export class SigningKeysResource { + constructor(private readonly http: HttpTransport) {} + + /** + * Create or rotate the webhook signing key for your organisation. + * + * The first call creates a new key; subsequent calls rotate (replace) the + * existing key. The plaintext `signingKey` is returned **once** — + * store it securely as it cannot be retrieved again. + * + * Use the returned key to verify `X-Inkbox-Signature` headers on + * incoming webhook requests. + */ + async createOrRotate(): Promise { + const data = await this.http.post(PATH, {}); + return parseSigningKey(data); + } +} diff --git a/typescript/src/types.ts b/typescript/src/types.ts index 20cfc64..3d36c09 100644 --- a/typescript/src/types.ts +++ b/typescript/src/types.ts @@ -62,12 +62,6 @@ export interface ThreadDetail extends Thread { messages: Message[]; } -export interface SigningKey { - /** Plaintext signing key — returned once on creation/rotation. Store securely. */ - signingKey: string; - createdAt: Date; -} - // ---- internal raw API shapes (snake_case from JSON) ---- export interface RawMailbox { @@ -80,11 +74,6 @@ export interface RawMailbox { updated_at: string; } -export interface RawSigningKey { - signing_key: string; - created_at: string; -} - export interface RawMessage { id: string; mailbox_id: string; @@ -143,13 +132,6 @@ export function parseMailbox(r: RawMailbox): Mailbox { }; } -export function parseSigningKey(r: RawSigningKey): SigningKey { - return { - signingKey: r.signing_key, - createdAt: new Date(r.created_at), - }; -} - export function parseMessage(r: RawMessage): Message { return { id: r.id, From ddf11f9c8fb164d6f178f225b224f59654027666 Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:03:17 -0400 Subject: [PATCH 20/56] fix tests --- python/tests/conftest.py | 3 ++- python/tests/test_client.py | 4 +-- python/tests/test_mail_client.py | 4 +-- python/tests/test_signing_keys.py | 37 +++++++++++++++++++++++++++ typescript/tests/sampleData.ts | 7 +++++ typescript/tests/signing-keys.test.ts | 35 +++++++++++++++++++++++++ 6 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 python/tests/test_signing_keys.py create mode 100644 typescript/tests/signing-keys.test.ts diff --git a/python/tests/conftest.py b/python/tests/conftest.py index 31a3024..3e98557 100644 --- a/python/tests/conftest.py +++ b/python/tests/conftest.py @@ -29,8 +29,9 @@ def transport() -> FakeHttpTransport: def client(transport: FakeHttpTransport) -> InkboxPhone: c = InkboxPhone(api_key="sk-test") c._http = transport # type: ignore[attr-defined] + c._api_http = transport # type: ignore[attr-defined] c.numbers._http = transport c.calls._http = transport c.transcripts._http = transport - c.webhooks._http = transport + c.signing_keys._http = transport return c diff --git a/python/tests/test_client.py b/python/tests/test_client.py index 89b412f..19ad03a 100644 --- a/python/tests/test_client.py +++ b/python/tests/test_client.py @@ -4,7 +4,7 @@ from inkbox.phone.resources.numbers import PhoneNumbersResource from inkbox.phone.resources.calls import CallsResource from inkbox.phone.resources.transcripts import TranscriptsResource -from inkbox.phone.resources.webhooks import PhoneWebhooksResource +from inkbox.signing_keys import SigningKeysResource class TestInkboxPhoneClient: @@ -14,7 +14,7 @@ def test_creates_resource_instances(self): assert isinstance(client.numbers, PhoneNumbersResource) assert isinstance(client.calls, CallsResource) assert isinstance(client.transcripts, TranscriptsResource) - assert isinstance(client.webhooks, PhoneWebhooksResource) + assert isinstance(client.signing_keys, SigningKeysResource) def test_context_manager(self): with InkboxPhone(api_key="sk-test") as client: diff --git a/python/tests/test_mail_client.py b/python/tests/test_mail_client.py index 6215b78..557cf54 100644 --- a/python/tests/test_mail_client.py +++ b/python/tests/test_mail_client.py @@ -4,7 +4,7 @@ from inkbox.mail.resources.mailboxes import MailboxesResource from inkbox.mail.resources.messages import MessagesResource from inkbox.mail.resources.threads import ThreadsResource -from inkbox.mail.resources.webhooks import WebhooksResource +from inkbox.signing_keys import SigningKeysResource class TestInkboxMailClient: @@ -14,7 +14,7 @@ def test_creates_resource_instances(self): assert isinstance(client.mailboxes, MailboxesResource) assert isinstance(client.messages, MessagesResource) assert isinstance(client.threads, ThreadsResource) - assert isinstance(client.webhooks, WebhooksResource) + assert isinstance(client.signing_keys, SigningKeysResource) client.close() def test_context_manager(self): diff --git a/python/tests/test_signing_keys.py b/python/tests/test_signing_keys.py new file mode 100644 index 0000000..a2fc275 --- /dev/null +++ b/python/tests/test_signing_keys.py @@ -0,0 +1,37 @@ +"""Tests for SigningKeysResource.""" + +from datetime import datetime, timezone +from unittest.mock import MagicMock + +from inkbox.signing_keys import SigningKey, SigningKeysResource + + +SIGNING_KEY_DICT = { + "signing_key": "sk-test-hmac-secret-abc123", + "created_at": "2026-03-09T00:00:00Z", +} + + +def _resource(): + http = MagicMock() + return SigningKeysResource(http), http + + +class TestSigningKeysCreateOrRotate: + def test_calls_correct_endpoint(self): + res, http = _resource() + http.post.return_value = SIGNING_KEY_DICT + + res.create_or_rotate() + + http.post.assert_called_once_with("/signing-keys", json={}) + + def test_returns_signing_key(self): + res, http = _resource() + http.post.return_value = SIGNING_KEY_DICT + + key = res.create_or_rotate() + + assert isinstance(key, SigningKey) + assert key.signing_key == "sk-test-hmac-secret-abc123" + assert key.created_at == datetime(2026, 3, 9, 0, 0, 0, tzinfo=timezone.utc) diff --git a/typescript/tests/sampleData.ts b/typescript/tests/sampleData.ts index 7c6d174..a9b5f20 100644 --- a/typescript/tests/sampleData.ts +++ b/typescript/tests/sampleData.ts @@ -192,3 +192,10 @@ export const RAW_IDENTITY_DETAIL = { mailbox: RAW_IDENTITY_MAILBOX, phone_number: RAW_IDENTITY_PHONE, }; + +// ---- Signing Keys ---- + +export const RAW_SIGNING_KEY = { + signing_key: "sk-test-hmac-secret-abc123", + created_at: "2026-03-09T00:00:00Z", +}; diff --git a/typescript/tests/signing-keys.test.ts b/typescript/tests/signing-keys.test.ts new file mode 100644 index 0000000..8372fc0 --- /dev/null +++ b/typescript/tests/signing-keys.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, vi } from "vitest"; +import { SigningKeysResource } from "../src/resources/signing-keys.js"; +import { HttpTransport } from "../src/_http.js"; +import { RAW_SIGNING_KEY } from "./sampleData.js"; + +function makeResource() { + const http = { post: vi.fn() } as unknown as HttpTransport; + const resource = new SigningKeysResource(http); + return { resource, http: http as { post: ReturnType } }; +} + +describe("SigningKeysResource", () => { + describe("createOrRotate", () => { + it("calls POST /signing-keys with empty body", async () => { + const { resource, http } = makeResource(); + http.post.mockResolvedValue(RAW_SIGNING_KEY); + + await resource.createOrRotate(); + + expect(http.post).toHaveBeenCalledOnce(); + expect(http.post).toHaveBeenCalledWith("/signing-keys", {}); + }); + + it("parses signingKey and createdAt from response", async () => { + const { resource, http } = makeResource(); + http.post.mockResolvedValue(RAW_SIGNING_KEY); + + const key = await resource.createOrRotate(); + + expect(key.signingKey).toBe("sk-test-hmac-secret-abc123"); + expect(key.createdAt).toBeInstanceOf(Date); + expect(key.createdAt.toISOString()).toBe("2026-03-09T00:00:00.000Z"); + }); + }); +}); From a90b3452e444facb047d298023238a3499b0f0cb Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:19:17 -0400 Subject: [PATCH 21/56] fix more tests --- python/inkbox/mail/resources/webhooks.py | 60 ++++++++++++++++ python/inkbox/mail/types.py | 37 ++++++++++ python/inkbox/phone/types.py | 57 ++++++++++++--- typescript/src/phone/resources/numbers.ts | 4 +- typescript/src/phone/resources/webhooks.ts | 81 ++++++++++++++++++++++ typescript/src/phone/types.ts | 47 +++++++++++++ typescript/src/resources/webhooks.ts | 50 +++++++++++++ typescript/src/types.ts | 44 ++++++++++++ 8 files changed, 369 insertions(+), 11 deletions(-) create mode 100644 python/inkbox/mail/resources/webhooks.py create mode 100644 typescript/src/phone/resources/webhooks.ts create mode 100644 typescript/src/resources/webhooks.ts diff --git a/python/inkbox/mail/resources/webhooks.py b/python/inkbox/mail/resources/webhooks.py new file mode 100644 index 0000000..37bd3ce --- /dev/null +++ b/python/inkbox/mail/resources/webhooks.py @@ -0,0 +1,60 @@ +""" +inkbox/mail/resources/webhooks.py + +Webhook CRUD for mailboxes. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from inkbox.mail.types import Webhook, WebhookCreateResult + +if TYPE_CHECKING: + from inkbox.mail._http import HttpTransport + + +class WebhooksResource: + def __init__(self, http: HttpTransport) -> None: + self._http = http + + def create( + self, + mailbox_id: str, + *, + url: str, + event_types: list[str], + ) -> WebhookCreateResult: + """Create a webhook subscription for a mailbox. + + Args: + mailbox_id: UUID of the mailbox. + url: HTTPS URL to receive webhook events. + event_types: List of event types to subscribe to. + + Returns: + The created webhook, including the signing secret. + """ + data = self._http.post( + f"/mailboxes/{mailbox_id}/webhooks", + json={"url": url, "event_types": event_types}, + ) + return WebhookCreateResult._from_dict(data) + + def list(self, mailbox_id: str) -> list[Webhook]: + """List all webhooks for a mailbox. + + Args: + mailbox_id: UUID of the mailbox. + """ + data = self._http.get(f"/mailboxes/{mailbox_id}/webhooks") + return [Webhook._from_dict(w) for w in data] + + def delete(self, mailbox_id: str, webhook_id: str) -> None: + """Delete a webhook subscription. + + Args: + mailbox_id: UUID of the mailbox. + webhook_id: UUID of the webhook to delete. + """ + self._http.delete(f"/mailboxes/{mailbox_id}/webhooks/{webhook_id}") diff --git a/python/inkbox/mail/types.py b/python/inkbox/mail/types.py index a32f471..0f4a44d 100644 --- a/python/inkbox/mail/types.py +++ b/python/inkbox/mail/types.py @@ -155,3 +155,40 @@ def _from_dict(cls, d: dict[str, Any]) -> ThreadDetail: # type: ignore[override ) +@dataclass +class Webhook: + """A webhook subscription for mail events.""" + + id: UUID + mailbox_id: UUID + url: str + event_types: list[str] + status: str + created_at: datetime + + @classmethod + def _from_dict(cls, d: dict[str, Any]) -> Webhook: + return cls( + id=UUID(d["id"]), + mailbox_id=UUID(d["mailbox_id"]), + url=d["url"], + event_types=d["event_types"], + status=d["status"], + created_at=datetime.fromisoformat(d["created_at"]), + ) + + +@dataclass +class WebhookCreateResult(Webhook): + """Result of creating a mail webhook, includes the signing secret.""" + + secret: str = "" + + @classmethod + def _from_dict(cls, d: dict[str, Any]) -> WebhookCreateResult: # type: ignore[override] + base = Webhook._from_dict(d) + return cls( + **base.__dict__, + secret=d["secret"], + ) + diff --git a/python/inkbox/phone/types.py b/python/inkbox/phone/types.py index 8cda2fd..c6fea23 100644 --- a/python/inkbox/phone/types.py +++ b/python/inkbox/phone/types.py @@ -25,8 +25,8 @@ class PhoneNumber: type: str status: str incoming_call_action: str - client_websocket_url: str | None - incoming_call_webhook_url: str | None + default_stream_url: str | None + default_pipeline_mode: str created_at: datetime updated_at: datetime @@ -38,8 +38,8 @@ def _from_dict(cls, d: dict[str, Any]) -> PhoneNumber: type=d["type"], status=d["status"], incoming_call_action=d["incoming_call_action"], - client_websocket_url=d.get("client_websocket_url"), - incoming_call_webhook_url=d.get("incoming_call_webhook_url"), + default_stream_url=d.get("default_stream_url"), + default_pipeline_mode=d.get("default_pipeline_mode", "client_llm_only"), created_at=datetime.fromisoformat(d["created_at"]), updated_at=datetime.fromisoformat(d["updated_at"]), ) @@ -54,9 +54,8 @@ class PhoneCall: remote_phone_number: str direction: str status: str - client_websocket_url: str | None - use_inkbox_tts: bool | None - use_inkbox_stt: bool | None + pipeline_mode: str | None + stream_url: str | None hangup_reason: str | None started_at: datetime | None ended_at: datetime | None @@ -71,9 +70,8 @@ def _from_dict(cls, d: dict[str, Any]) -> PhoneCall: remote_phone_number=d["remote_phone_number"], direction=d["direction"], status=d["status"], - client_websocket_url=d.get("client_websocket_url"), - use_inkbox_tts=d.get("use_inkbox_tts"), - use_inkbox_stt=d.get("use_inkbox_stt"), + pipeline_mode=d.get("pipeline_mode"), + stream_url=d.get("stream_url"), hangup_reason=d.get("hangup_reason"), started_at=_dt(d.get("started_at")), ended_at=_dt(d.get("ended_at")), @@ -148,3 +146,42 @@ def _from_dict(cls, d: dict[str, Any]) -> PhoneTranscript: ) +@dataclass +class PhoneWebhook: + """A webhook subscription for phone events.""" + + id: UUID + source_id: UUID + source_type: str + url: str + event_types: list[str] + status: str + created_at: datetime + + @classmethod + def _from_dict(cls, d: dict[str, Any]) -> PhoneWebhook: + return cls( + id=UUID(d["id"]), + source_id=UUID(d["source_id"]), + source_type=d["source_type"], + url=d["url"], + event_types=d["event_types"], + status=d["status"], + created_at=datetime.fromisoformat(d["created_at"]), + ) + + +@dataclass +class PhoneWebhookCreateResult(PhoneWebhook): + """Result of creating a phone webhook, includes the signing secret.""" + + secret: str = "" + + @classmethod + def _from_dict(cls, d: dict[str, Any]) -> PhoneWebhookCreateResult: # type: ignore[override] + base = PhoneWebhook._from_dict(d) + return cls( + **base.__dict__, + secret=d["secret"], + ) + diff --git a/typescript/src/phone/resources/numbers.ts b/typescript/src/phone/resources/numbers.ts index f5f348e..33c0d1d 100644 --- a/typescript/src/phone/resources/numbers.ts +++ b/typescript/src/phone/resources/numbers.ts @@ -89,8 +89,10 @@ export class PhoneNumbersResource { const body: Record = { agent_handle: options.agentHandle, type: options.type ?? "toll_free", - incoming_call_action: options.incomingCallAction ?? "auto_reject", }; + if (options.incomingCallAction !== undefined) { + body["incoming_call_action"] = options.incomingCallAction; + } if (options.state !== undefined) { body["state"] = options.state; } diff --git a/typescript/src/phone/resources/webhooks.ts b/typescript/src/phone/resources/webhooks.ts new file mode 100644 index 0000000..9a7a0fc --- /dev/null +++ b/typescript/src/phone/resources/webhooks.ts @@ -0,0 +1,81 @@ +/** + * inkbox-phone/resources/webhooks.ts + * + * Webhook CRUD for phone numbers. + */ + +import { HttpTransport } from "../../_http.js"; +import { + PhoneWebhook, + PhoneWebhookCreateResult, + RawPhoneWebhook, + RawPhoneWebhookCreateResult, + parsePhoneWebhook, + parsePhoneWebhookCreateResult, +} from "../types.js"; + +const BASE = "/numbers"; + +export class PhoneWebhooksResource { + constructor(private readonly http: HttpTransport) {} + + /** + * Create a webhook subscription for a phone number. + * + * @param phoneNumberId - UUID of the phone number. + * @param options.url - HTTPS URL to receive webhook events. + * @param options.eventTypes - List of event types to subscribe to. + */ + async create( + phoneNumberId: string, + options: { url: string; eventTypes: string[] }, + ): Promise { + const data = await this.http.post( + `${BASE}/${phoneNumberId}/webhooks`, + { url: options.url, event_types: options.eventTypes }, + ); + return parsePhoneWebhookCreateResult(data); + } + + /** List all webhooks for a phone number. */ + async list(phoneNumberId: string): Promise { + const data = await this.http.get( + `${BASE}/${phoneNumberId}/webhooks`, + ); + return data.map(parsePhoneWebhook); + } + + /** + * Update a webhook subscription. Only provided fields are updated. + * + * @param phoneNumberId - UUID of the phone number. + * @param webhookId - UUID of the webhook. + * @param options.url - New HTTPS URL. + * @param options.eventTypes - New list of event types. + */ + async update( + phoneNumberId: string, + webhookId: string, + options: { url?: string; eventTypes?: string[] }, + ): Promise { + const body: Record = {}; + if (options.url !== undefined) { + body["url"] = options.url; + } + if (options.eventTypes !== undefined) { + body["event_types"] = options.eventTypes; + } + const data = await this.http.patch( + `${BASE}/${phoneNumberId}/webhooks/${webhookId}`, + body, + ); + return parsePhoneWebhook(data); + } + + /** Delete a webhook subscription. */ + async delete(phoneNumberId: string, webhookId: string): Promise { + await this.http.delete( + `${BASE}/${phoneNumberId}/webhooks/${webhookId}`, + ); + } +} diff --git a/typescript/src/phone/types.ts b/typescript/src/phone/types.ts index 91dd336..fd6c885 100644 --- a/typescript/src/phone/types.ts +++ b/typescript/src/phone/types.ts @@ -60,6 +60,20 @@ export interface PhoneTranscript { createdAt: Date; } +export interface PhoneWebhook { + id: string; + sourceId: string; + sourceType: string; + url: string; + eventTypes: string[]; + status: string; + createdAt: Date; +} + +export interface PhoneWebhookCreateResult extends PhoneWebhook { + secret: string; +} + // ---- internal raw API shapes (snake_case from JSON) ---- export interface RawPhoneNumber { @@ -113,6 +127,20 @@ export interface RawPhoneTranscript { created_at: string; } +export interface RawPhoneWebhook { + id: string; + source_id: string; + source_type: string; + url: string; + event_types: string[]; + status: string; + created_at: string; +} + +export interface RawPhoneWebhookCreateResult extends RawPhoneWebhook { + secret: string; +} + // ---- parsers ---- export function parsePhoneNumber(r: RawPhoneNumber): PhoneNumber { @@ -179,3 +207,22 @@ export function parsePhoneTranscript(r: RawPhoneTranscript): PhoneTranscript { }; } +export function parsePhoneWebhook(r: RawPhoneWebhook): PhoneWebhook { + return { + id: r.id, + sourceId: r.source_id, + sourceType: r.source_type, + url: r.url, + eventTypes: r.event_types, + status: r.status, + createdAt: new Date(r.created_at), + }; +} + +export function parsePhoneWebhookCreateResult(r: RawPhoneWebhookCreateResult): PhoneWebhookCreateResult { + return { + ...parsePhoneWebhook(r), + secret: r.secret, + }; +} + diff --git a/typescript/src/resources/webhooks.ts b/typescript/src/resources/webhooks.ts new file mode 100644 index 0000000..4cf782a --- /dev/null +++ b/typescript/src/resources/webhooks.ts @@ -0,0 +1,50 @@ +/** + * inkbox-mail/resources/webhooks.ts + * + * Webhook CRUD for mailboxes. + */ + +import { HttpTransport } from "../_http.js"; +import { + Webhook, + WebhookCreateResult, + RawWebhook, + RawWebhookCreateResult, + parseWebhook, + parseWebhookCreateResult, +} from "../types.js"; + +export class WebhooksResource { + constructor(private readonly http: HttpTransport) {} + + /** + * Create a webhook subscription for a mailbox. + * + * @param mailboxId - Email address or UUID of the mailbox. + * @param options.url - HTTPS URL to receive webhook events. + * @param options.eventTypes - List of event types to subscribe to. + */ + async create( + mailboxId: string, + options: { url: string; eventTypes: string[] }, + ): Promise { + const data = await this.http.post( + `/mailboxes/${mailboxId}/webhooks`, + { url: options.url, event_types: options.eventTypes }, + ); + return parseWebhookCreateResult(data); + } + + /** List all webhooks for a mailbox. */ + async list(mailboxId: string): Promise { + const data = await this.http.get( + `/mailboxes/${mailboxId}/webhooks`, + ); + return data.map(parseWebhook); + } + + /** Delete a webhook subscription. */ + async delete(mailboxId: string, webhookId: string): Promise { + await this.http.delete(`/mailboxes/${mailboxId}/webhooks/${webhookId}`); + } +} diff --git a/typescript/src/types.ts b/typescript/src/types.ts index 3d36c09..6c90124 100644 --- a/typescript/src/types.ts +++ b/typescript/src/types.ts @@ -62,6 +62,19 @@ export interface ThreadDetail extends Thread { messages: Message[]; } +export interface Webhook { + id: string; + mailboxId: string; + url: string; + eventTypes: string[]; + status: string; + createdAt: Date; +} + +export interface WebhookCreateResult extends Webhook { + secret: string; +} + // ---- internal raw API shapes (snake_case from JSON) ---- export interface RawMailbox { @@ -112,6 +125,19 @@ export interface RawThread { messages?: RawMessage[]; } +export interface RawWebhook { + id: string; + mailbox_id: string; + url: string; + event_types: string[]; + status: string; + created_at: string; +} + +export interface RawWebhookCreateResult extends RawWebhook { + secret: string; +} + export interface RawCursorPage { items: T[]; next_cursor: string | null; @@ -185,3 +211,21 @@ export function parseThreadDetail(r: RawThread): ThreadDetail { }; } +export function parseWebhook(r: RawWebhook): Webhook { + return { + id: r.id, + mailboxId: r.mailbox_id, + url: r.url, + eventTypes: r.event_types, + status: r.status, + createdAt: new Date(r.created_at), + }; +} + +export function parseWebhookCreateResult(r: RawWebhookCreateResult): WebhookCreateResult { + return { + ...parseWebhook(r), + secret: r.secret, + }; +} + From a7f4a7e6125d7a79158bfad72160cd9cc2177163 Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:24:42 -0400 Subject: [PATCH 22/56] fix tests --- python/inkbox/mail/resources/mailboxes.py | 7 +- python/inkbox/phone/client.py | 2 + python/inkbox/phone/resources/__init__.py | 2 + python/inkbox/phone/resources/calls.py | 13 ++-- python/inkbox/phone/resources/numbers.py | 50 ++++--------- python/inkbox/phone/resources/webhooks.py | 88 +++++++++++++++++++++++ 6 files changed, 116 insertions(+), 46 deletions(-) create mode 100644 python/inkbox/phone/resources/webhooks.py diff --git a/python/inkbox/mail/resources/mailboxes.py b/python/inkbox/mail/resources/mailboxes.py index 8661bd1..559cbce 100644 --- a/python/inkbox/mail/resources/mailboxes.py +++ b/python/inkbox/mail/resources/mailboxes.py @@ -24,22 +24,19 @@ def __init__(self, http: HttpTransport) -> None: def create( self, *, - agent_handle: str, display_name: str | None = None, ) -> Mailbox: - """Create a new mailbox and assign it to an agent identity. + """Create a new mailbox. The email address is automatically generated by the server. Args: - agent_handle: Handle of the agent identity to assign this mailbox to - (e.g. ``"sales-agent"`` or ``"@sales-agent"``). display_name: Optional human-readable name shown as the sender. Returns: The created mailbox. """ - body: dict[str, Any] = {"agent_handle": agent_handle} + body: dict[str, Any] = {} if display_name is not None: body["display_name"] = display_name data = self._http.post(_BASE, json=body) diff --git a/python/inkbox/phone/client.py b/python/inkbox/phone/client.py index fe53d06..c09b84e 100644 --- a/python/inkbox/phone/client.py +++ b/python/inkbox/phone/client.py @@ -10,6 +10,7 @@ from inkbox.phone.resources.numbers import PhoneNumbersResource from inkbox.phone.resources.calls import CallsResource from inkbox.phone.resources.transcripts import TranscriptsResource +from inkbox.phone.resources.webhooks import PhoneWebhooksResource from inkbox.signing_keys import SigningKeysResource _DEFAULT_BASE_URL = "https://api.inkbox.ai/api/v1/phone" @@ -51,6 +52,7 @@ def __init__( self.numbers = PhoneNumbersResource(self._http) self.calls = CallsResource(self._http) self.transcripts = TranscriptsResource(self._http) + self.webhooks = PhoneWebhooksResource(self._http) self.signing_keys = SigningKeysResource(self._api_http) def close(self) -> None: diff --git a/python/inkbox/phone/resources/__init__.py b/python/inkbox/phone/resources/__init__.py index 99c4857..bc4f6c5 100644 --- a/python/inkbox/phone/resources/__init__.py +++ b/python/inkbox/phone/resources/__init__.py @@ -1,9 +1,11 @@ from inkbox.phone.resources.numbers import PhoneNumbersResource from inkbox.phone.resources.calls import CallsResource from inkbox.phone.resources.transcripts import TranscriptsResource +from inkbox.phone.resources.webhooks import PhoneWebhooksResource __all__ = [ "PhoneNumbersResource", "CallsResource", "TranscriptsResource", + "PhoneWebhooksResource", ] diff --git a/python/inkbox/phone/resources/calls.py b/python/inkbox/phone/resources/calls.py index 82df99b..8a085a0 100644 --- a/python/inkbox/phone/resources/calls.py +++ b/python/inkbox/phone/resources/calls.py @@ -58,7 +58,8 @@ def place( *, from_number: str, to_number: str, - client_websocket_url: str | None = None, + stream_url: str | None = None, + pipeline_mode: str | None = None, webhook_url: str | None = None, ) -> PhoneCallWithRateLimit: """Place an outbound call. @@ -66,8 +67,8 @@ def place( Args: from_number: E.164 number to call from. Must belong to your org and be active. to_number: E.164 number to call. - client_websocket_url: WebSocket URL (wss://) for audio bridging. Falls back - to the phone number's ``client_websocket_url`` if not provided. + stream_url: WebSocket URL (wss://) for audio bridging. + pipeline_mode: Pipeline mode override for this call. webhook_url: Custom webhook URL for call lifecycle events. Returns: @@ -77,8 +78,10 @@ def place( "from_number": from_number, "to_number": to_number, } - if client_websocket_url is not None: - body["client_websocket_url"] = client_websocket_url + if stream_url is not None: + body["stream_url"] = stream_url + if pipeline_mode is not None: + body["pipeline_mode"] = pipeline_mode if webhook_url is not None: body["webhook_url"] = webhook_url data = self._http.post("/place-call", json=body) diff --git a/python/inkbox/phone/resources/numbers.py b/python/inkbox/phone/resources/numbers.py index 8a6b924..8ed72f4 100644 --- a/python/inkbox/phone/resources/numbers.py +++ b/python/inkbox/phone/resources/numbers.py @@ -37,8 +37,8 @@ def update( phone_number_id: UUID | str, *, incoming_call_action: str | None = _UNSET, # type: ignore[assignment] - client_websocket_url: str | None = _UNSET, # type: ignore[assignment] - incoming_call_webhook_url: str | None = _UNSET, # type: ignore[assignment] + default_stream_url: str | None = _UNSET, # type: ignore[assignment] + default_pipeline_mode: str | None = _UNSET, # type: ignore[assignment] ) -> PhoneNumber: """Update phone number settings. @@ -48,69 +48,47 @@ def update( Args: phone_number_id: UUID of the phone number. incoming_call_action: ``"auto_accept"``, ``"auto_reject"``, or ``"webhook"``. - client_websocket_url: WebSocket URL (wss://) for audio bridging. - Required when ``incoming_call_action="auto_accept"``. - incoming_call_webhook_url: HTTPS URL to call on incoming calls. - Required when ``incoming_call_action="webhook"``. + default_stream_url: WebSocket URL (wss://) for audio bridging. + default_pipeline_mode: Default pipeline mode for incoming calls. """ body: dict[str, Any] = {} if incoming_call_action is not _UNSET: body["incoming_call_action"] = incoming_call_action - if client_websocket_url is not _UNSET: - body["client_websocket_url"] = client_websocket_url - if incoming_call_webhook_url is not _UNSET: - body["incoming_call_webhook_url"] = incoming_call_webhook_url + if default_stream_url is not _UNSET: + body["default_stream_url"] = default_stream_url + if default_pipeline_mode is not _UNSET: + body["default_pipeline_mode"] = default_pipeline_mode data = self._http.patch(f"{_BASE}/{phone_number_id}", json=body) return PhoneNumber._from_dict(data) def provision( self, *, - agent_handle: str, type: str = "toll_free", state: str | None = None, - incoming_call_action: str = "auto_reject", - client_websocket_url: str | None = None, - incoming_call_webhook_url: str | None = None, ) -> PhoneNumber: - """Provision a new phone number via Telnyx and assign it to an agent identity. + """Provision a new phone number. Args: - agent_handle: Handle of the agent identity to assign this number to - (e.g. ``"sales-agent"`` or ``"@sales-agent"``). type: ``"toll_free"`` or ``"local"``. Defaults to ``"toll_free"``. state: US state abbreviation (e.g. ``"NY"``). Only valid for ``local`` numbers. - incoming_call_action: ``"auto_accept"``, ``"auto_reject"``, or ``"webhook"``. - Defaults to ``"auto_reject"``. - client_websocket_url: WebSocket URL (wss://) for audio bridging. - Required when ``incoming_call_action="auto_accept"``. - incoming_call_webhook_url: HTTPS URL to call on incoming calls. - Required when ``incoming_call_action="webhook"``. Returns: The provisioned phone number. """ - body: dict[str, Any] = { - "agent_handle": agent_handle, - "type": type, - "incoming_call_action": incoming_call_action, - } + body: dict[str, Any] = {"type": type} if state is not None: body["state"] = state - if client_websocket_url is not None: - body["client_websocket_url"] = client_websocket_url - if incoming_call_webhook_url is not None: - body["incoming_call_webhook_url"] = incoming_call_webhook_url - data = self._http.post(_BASE, json=body) + data = self._http.post(f"{_BASE}/provision", json=body) return PhoneNumber._from_dict(data) - def release(self, phone_number_id: UUID | str) -> None: + def release(self, *, number: str) -> None: """Release a phone number. Args: - phone_number_id: UUID of the phone number to release. + number: E.164 phone number to release. """ - self._http.delete(f"{_BASE}/{phone_number_id}") + self._http.post(f"{_BASE}/release", json={"number": number}) def search_transcripts( self, diff --git a/python/inkbox/phone/resources/webhooks.py b/python/inkbox/phone/resources/webhooks.py new file mode 100644 index 0000000..fa019ab --- /dev/null +++ b/python/inkbox/phone/resources/webhooks.py @@ -0,0 +1,88 @@ +""" +inkbox/phone/resources/webhooks.py + +Webhook CRUD for phone numbers. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from uuid import UUID + +from inkbox.phone.types import PhoneWebhook, PhoneWebhookCreateResult + +if TYPE_CHECKING: + from inkbox.phone._http import HttpTransport + +_UNSET = object() + + +class PhoneWebhooksResource: + def __init__(self, http: HttpTransport) -> None: + self._http = http + + def create( + self, + phone_number_id: UUID | str, + *, + url: str, + event_types: list[str], + ) -> PhoneWebhookCreateResult: + """Create a webhook for a phone number. + + Args: + phone_number_id: UUID of the phone number. + url: HTTPS URL to receive webhook events. + event_types: List of event types to subscribe to. + + Returns: + The created webhook including the signing secret. + """ + data = self._http.post( + f"/numbers/{phone_number_id}/webhooks", + json={"url": url, "event_types": event_types}, + ) + return PhoneWebhookCreateResult._from_dict(data) + + def list(self, phone_number_id: UUID | str) -> list[PhoneWebhook]: + """List webhooks for a phone number.""" + data = self._http.get(f"/numbers/{phone_number_id}/webhooks") + return [PhoneWebhook._from_dict(w) for w in data] + + def update( + self, + phone_number_id: UUID | str, + webhook_id: UUID | str, + *, + url: str | None = _UNSET, # type: ignore[assignment] + event_types: list[str] | None = _UNSET, # type: ignore[assignment] + ) -> PhoneWebhook: + """Update a webhook. + + Pass only the fields you want to change; omitted fields are left as-is. + + Args: + phone_number_id: UUID of the phone number. + webhook_id: UUID of the webhook. + url: New HTTPS URL for the webhook. + event_types: New list of event types. + """ + body: dict[str, Any] = {} + if url is not _UNSET: + body["url"] = url + if event_types is not _UNSET: + body["event_types"] = event_types + data = self._http.patch( + f"/numbers/{phone_number_id}/webhooks/{webhook_id}", + json=body, + ) + return PhoneWebhook._from_dict(data) + + def delete(self, phone_number_id: UUID | str, webhook_id: UUID | str) -> None: + """Delete a webhook. + + Args: + phone_number_id: UUID of the phone number. + webhook_id: UUID of the webhook to delete. + """ + self._http.delete(f"/numbers/{phone_number_id}/webhooks/{webhook_id}") From 1fb0f957d3d80411e0ba41dde851b76244a072ce Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:25:46 -0400 Subject: [PATCH 23/56] fix ts tests --- typescript/src/resources/messages.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/typescript/src/resources/messages.ts b/typescript/src/resources/messages.ts index d8caacd..20e9036 100644 --- a/typescript/src/resources/messages.ts +++ b/typescript/src/resources/messages.ts @@ -39,7 +39,7 @@ export class MessagesResource { let cursor: string | undefined; while (true) { - const params: Record = { limit, cursor }; + const params: Record = { limit, cursor }; if (options?.direction !== undefined) params["direction"] = options.direction; const page = await this.http.get>( `/mailboxes/${emailAddress}/messages`, From 03fe84cb0ca27899120df2b3fbbf344e79b20d74 Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:33:38 -0400 Subject: [PATCH 24/56] fix more tests --- python/tests/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python/tests/conftest.py b/python/tests/conftest.py index 3e98557..a1dcfdc 100644 --- a/python/tests/conftest.py +++ b/python/tests/conftest.py @@ -33,5 +33,6 @@ def client(transport: FakeHttpTransport) -> InkboxPhone: c.numbers._http = transport c.calls._http = transport c.transcripts._http = transport + c.webhooks._http = transport c.signing_keys._http = transport return c From 8583a9355f641d9f91a29186ab16d32bc55c17ab Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:14:42 -0400 Subject: [PATCH 25/56] update readmes --- README.md | 22 ++- examples/python/README.md | 0 python/README.md | 282 +++++++++++++++++++++++++++++++++----- typescript/README.md | 270 ++++++++++++++++++++++++++++++++++++ 4 files changed, 525 insertions(+), 49 deletions(-) create mode 100644 examples/python/README.md create mode 100644 typescript/README.md diff --git a/README.md b/README.md index c294779..b9c7f90 100644 --- a/README.md +++ b/README.md @@ -75,11 +75,8 @@ from inkbox.mail import InkboxMail with InkboxMail(api_key="ApiKey_...") as client: - # Create a mailbox (agent identity must already exist) - mailbox = client.mailboxes.create( - agent_handle="sales-agent", - display_name="Sales Agent", - ) + # Create a mailbox + mailbox = client.mailboxes.create(agent_handle="sales-agent", display_name="Sales Agent") # Send an email client.messages.send( @@ -168,24 +165,21 @@ from inkbox.phone import InkboxPhone with InkboxPhone(api_key="ApiKey_...") as client: - # Provision a phone number (agent identity must already exist) - number = client.numbers.provision( - agent_handle="sales-agent", - type="toll_free", - ) + # Provision a phone number + number = client.numbers.provision(type="toll_free") # Update settings client.numbers.update( number.id, incoming_call_action="auto_accept", - client_websocket_url="wss://your-agent.example.com/ws", + default_stream_url="wss://your-agent.example.com/ws", ) # Place an outbound call call = client.calls.place( from_number=number.number, to_number="+15167251294", - client_websocket_url="wss://your-agent.example.com/ws", + stream_url="wss://your-agent.example.com/ws", ) print(call.status) print(call.rate_limit.calls_remaining) @@ -206,7 +200,7 @@ with InkboxPhone(api_key="ApiKey_...") as client: print(hook.secret) # save this # Release a number - client.numbers.release(number.id) + client.numbers.release(number=number.number) ``` ### TypeScript @@ -216,7 +210,7 @@ import { InkboxPhone } from "@inkbox/sdk/phone"; const client = new InkboxPhone({ apiKey: "ApiKey_..." }); -// Provision a phone number (agent identity must already exist) +// Provision a phone number const number = await client.numbers.provision({ agentHandle: "sales-agent", type: "toll_free", diff --git a/examples/python/README.md b/examples/python/README.md new file mode 100644 index 0000000..e69de29 diff --git a/python/README.md b/python/README.md index 0217912..fbe635e 100644 --- a/python/README.md +++ b/python/README.md @@ -1,6 +1,6 @@ # inkbox -Python SDK for the [Inkbox Mail API](https://inkbox.ai) — API-first email for AI agents. +Python SDK for the [Inkbox API](https://inkbox.ai) — API-first communication infrastructure for AI agents (email, phone, identities). ## Install @@ -8,53 +8,265 @@ Python SDK for the [Inkbox Mail API](https://inkbox.ai) — API-first email for pip install inkbox ``` -## Usage +Requires Python ≥ 3.11. + +## Authentication + +Pass your API key when constructing a client, or load it from an environment variable: + +```python +import os +from inkbox.mail import InkboxMail + +client = InkboxMail(api_key=os.environ["INKBOX_API_KEY"]) +``` + +All three clients (`InkboxMail`, `InkboxPhone`, `InkboxIdentities`) accept the same constructor arguments: + +| Argument | Type | Default | Description | +|---|---|---|---| +| `api_key` | `str` | required | Your `ApiKey_...` token | +| `base_url` | `str` | API default | Override for self-hosting or testing | +| `timeout` | `float` | `30.0` | Request timeout in seconds | + +## Context manager + +Using clients as context managers is recommended — it ensures HTTP connections are closed cleanly: ```python from inkbox.mail import InkboxMail -client = InkboxMail(api_key="ApiKey_...") +with InkboxMail(api_key="ApiKey_...") as client: + mailboxes = client.mailboxes.list() +``` + +You can also call `client.close()` manually when not using `with`. + +--- + +## Identities + +Agent identities are the central concept — a named agent (e.g. `"sales-agent"`) that owns a mailbox and/or phone number. + +```python +from inkbox.identities import InkboxIdentities + +with InkboxIdentities(api_key="ApiKey_...") as client: + + # Create an identity + identity = client.identities.create(agent_handle="sales-agent") + print(f"Registered: {identity.agent_handle} id={identity.id}") + + # Assign communication channels (mailbox / phone number must already exist) + with_mailbox = client.identities.assign_mailbox( + "sales-agent", mailbox_id="" + ) + print(with_mailbox.mailbox.email_address) + + with_phone = client.identities.assign_phone_number( + "sales-agent", phone_number_id="" + ) + print(with_phone.phone_number.number) + + # List all identities + all_identities = client.identities.list() + for ident in all_identities: + print(ident.agent_handle, ident.status) + + # Get, update, delete + detail = client.identities.get("sales-agent") + client.identities.update("sales-agent", status="paused") + client.identities.delete("sales-agent") +``` + +--- + +## Mail + +### Mailboxes + +```python +from inkbox.mail import InkboxMail + +with InkboxMail(api_key="ApiKey_...") as client: + + # Create a mailbox (agent identity must already exist) + mailbox = client.mailboxes.create(agent_handle="sales-agent", display_name="Sales Agent") + print(mailbox.email_address) + + # List all mailboxes + all_mailboxes = client.mailboxes.list() + for m in all_mailboxes: + print(m.email_address, m.status) + + # Update display name + updated = client.mailboxes.update(mailbox.email_address, display_name="Sales Agent (updated)") + + # Full-text search across messages + results = client.mailboxes.search(mailbox.email_address, q="invoice") + + # Delete + client.mailboxes.delete(mailbox.email_address) +``` + +### Sending email -# Create a mailbox -mailbox = client.mailboxes.create(display_name="Agent 01") +```python + # Send an outbound email + sent = client.messages.send( + mailbox.email_address, + to=["recipient@example.com"], + subject="Hello from your AI agent", + body_text="Hi there!", + body_html="

Hi there!

", + ) + + # Reply in a thread (pass the original message's RFC Message-ID) + reply = client.messages.send( + mailbox.email_address, + to=["recipient@example.com"], + subject=f"Re: {sent.subject}", + body_text="Following up.", + in_reply_to_message_id=str(sent.id), + ) +``` + +### Reading messages and threads + +```python + # Paginate through all messages (pagination handled automatically) + for msg in client.messages.list(mailbox.email_address): + print(msg.subject, msg.from_address, msg.direction) + + # Fetch full message body + detail = client.messages.get(mailbox.email_address, msg.id) + print(detail.body_text) -# Send an email -client.messages.send( - mailbox.id, - to=["user@example.com"], - subject="Hello from Inkbox", - body_text="Hi there!", -) + # List threads + for thread in client.threads.list(mailbox.email_address): + print(thread.subject, thread.message_count) -# Iterate over all messages (pagination handled automatically) -for msg in client.messages.list(mailbox.id): - print(msg.subject, msg.from_address) + # Fetch full thread with all messages + thread_detail = client.threads.get(mailbox.email_address, thread.id) + for msg in thread_detail.messages: + print(f"[{msg.direction}] {msg.from_address}: {msg.snippet}") +``` -# Reply to a message -detail = client.messages.get(mailbox.id, msg.id) -client.messages.send( - mailbox.id, - to=detail.to_addresses, - subject=f"Re: {detail.subject}", - body_text="Got it, thanks!", - in_reply_to_message_id=detail.message_id, -) +### Webhooks + +```python + # Register a webhook (secret is one-time — save it immediately) + hook = client.webhooks.create( + mailbox.email_address, + url="https://yourapp.com/hooks/mail", + event_types=["message.received", "message.sent"], + ) + print(hook.secret) # save this — it will not be shown again -# Search -results = client.mailboxes.search(mailbox.id, q="invoice") + # List active webhooks + hooks = client.webhooks.list(mailbox.email_address) -# Webhooks (secret is one-time — save it immediately) -hook = client.webhooks.create( - mailbox.id, - url="https://yourapp.com/hooks/mail", - event_types=["message.received"], -) -print(hook.secret) # save this + # Delete a webhook + client.webhooks.delete(mailbox.email_address, hook.id) ``` -## Requirements +--- + +## Phone + +### Provisioning numbers + +```python +from inkbox.phone import InkboxPhone + +with InkboxPhone(api_key="ApiKey_...") as client: + + # Provision a toll-free number + number = client.numbers.provision(type="toll_free") + print(number.number, number.status) + + # Provision a local number in a specific state + number = client.numbers.provision(type="local", state="NY") + + # List all numbers + all_numbers = client.numbers.list() + for n in all_numbers: + print(n.number, n.type, n.status) + + # Update settings + updated = client.numbers.update( + number.id, + incoming_call_action="auto_accept", + default_stream_url="wss://your-agent.example.com/ws", + ) + + # Release a number + client.numbers.release(number=number.number) +``` + +### Placing calls + +```python + # Place an outbound call + call = client.calls.place( + from_number=number.number, + to_number="+15167251294", + stream_url="wss://your-agent.example.com/ws", + ) + print(call.status) + print(call.rate_limit.calls_remaining) +``` + +### Reading calls and transcripts + +```python + # List recent calls for a number + calls = client.calls.list(number.id, limit=10) + for call in calls: + print(call.id, call.direction, call.remote_phone_number, call.status) + + # Read transcript for a call + transcripts = client.transcripts.list(number.id, call.id) + for t in transcripts: + print(f"[{t.party}] {t.text}") + + # Full-text search across all transcripts for a number + results = client.numbers.search_transcripts(number.id, q="appointment") +``` + +### Webhooks + +```python + # Register a webhook (secret is one-time — save it immediately) + hook = client.webhooks.create( + number.id, + url="https://yourapp.com/hooks/phone", + event_types=["call.completed"], + ) + print(hook.secret) # save this — it will not be shown again + + # List and delete + hooks = client.webhooks.list(number.id) + client.webhooks.delete(number.id, hook.id) +``` + +--- + +## Examples + +Runnable example scripts are available in the [examples/python](https://github.com/vectorlyapp/inkbox/tree/main/inkbox/examples/python) directory: -- Python ≥ 3.11 +| Script | What it demonstrates | +|---|---| +| `register_agent_identity.py` | Create an identity and assign mailbox / phone number | +| `create_agent_mailbox.py` | Create, update, search, and delete a mailbox | +| `agent_send_email.py` | Send an email and a threaded reply | +| `read_agent_messages.py` | List messages and read full threads | +| `create_agent_phone_number.py` | Provision, update, and release a number | +| `list_agent_phone_numbers.py` | List all provisioned numbers | +| `read_agent_calls.py` | List calls and print transcripts | +| `receive_agent_email_webhook.py` | Register, list, and delete email webhooks | +| `receive_agent_call_webhook.py` | Register, list, and delete phone webhooks | ## License diff --git a/typescript/README.md b/typescript/README.md new file mode 100644 index 0000000..44254d8 --- /dev/null +++ b/typescript/README.md @@ -0,0 +1,270 @@ +# @inkbox/sdk + +TypeScript SDK for the [Inkbox API](https://inkbox.ai) — API-first communication infrastructure for AI agents (email, phone, identities). + +## Install + +```bash +npm install @inkbox/sdk +``` + +Requires Node.js ≥ 18. + +## Authentication + +Pass your API key when constructing a client, or load it from an environment variable: + +```ts +import { InkboxMail } from "@inkbox/sdk"; + +const client = new InkboxMail({ apiKey: process.env.INKBOX_API_KEY! }); +``` + +All three clients accept the same options: + +| Option | Type | Default | Description | +|---|---|---|---| +| `apiKey` | `string` | required | Your `ApiKey_...` token | +| `baseUrl` | `string` | API default | Override for self-hosting or testing | +| `timeoutMs` | `number` | `30000` | Request timeout in milliseconds | + +--- + +## Identities + +Agent identities are the central concept — a named agent (e.g. `"sales-agent"`) that owns a mailbox and/or phone number. + +```ts +import { InkboxIdentities } from "@inkbox/sdk/identities"; + +const client = new InkboxIdentities({ apiKey: "ApiKey_..." }); + +// Create an identity +const identity = await client.identities.create({ agentHandle: "sales-agent" }); +console.log(identity.agentHandle, identity.id); + +// Assign communication channels (mailbox / phone number must already exist) +const withMailbox = await client.identities.assignMailbox("sales-agent", { + mailboxId: "", +}); +console.log(withMailbox.mailbox?.emailAddress); + +const withPhone = await client.identities.assignPhoneNumber("sales-agent", { + phoneNumberId: "", +}); +console.log(withPhone.phoneNumber?.number); + +// List all identities +const all = await client.identities.list(); +for (const ident of all) { + console.log(ident.agentHandle, ident.status); +} + +// Get, update, delete +const detail = await client.identities.get("sales-agent"); +await client.identities.update("sales-agent", { status: "paused" }); +await client.identities.delete("sales-agent"); +``` + +--- + +## Mail + +```ts +import { InkboxMail } from "@inkbox/sdk"; +``` + +### Mailboxes + +```ts +// Create a mailbox (agent identity must already exist) +const mailbox = await client.mailboxes.create({ + agentHandle: "sales-agent", + displayName: "Sales Agent", +}); +console.log(mailbox.emailAddress); + +// List all mailboxes +const all = await client.mailboxes.list(); +for (const m of all) { + console.log(m.emailAddress, m.status); +} + +// Update display name +await client.mailboxes.update(mailbox.emailAddress, { displayName: "Sales Agent (updated)" }); + +// Full-text search across messages +const results = await client.mailboxes.search(mailbox.emailAddress, { q: "invoice" }); + +// Delete +await client.mailboxes.delete(mailbox.emailAddress); +``` + +### Sending email + +```ts +// Send an outbound email +const sent = await client.messages.send(mailbox.emailAddress, { + to: ["recipient@example.com"], + subject: "Hello from your AI agent", + bodyText: "Hi there!", + bodyHtml: "

Hi there!

", +}); + +// Reply in a thread (pass the original message's RFC Message-ID) +const reply = await client.messages.send(mailbox.emailAddress, { + to: ["recipient@example.com"], + subject: `Re: ${sent.subject}`, + bodyText: "Following up.", + inReplyToMessageId: sent.messageId, +}); +``` + +### Reading messages and threads + +```ts +// Iterate over all messages (pagination handled automatically) +for await (const msg of client.messages.list(mailbox.emailAddress)) { + console.log(msg.subject, msg.fromAddress, msg.direction); +} + +// Fetch full message body +const detail = await client.messages.get(mailbox.emailAddress, msg.id); +console.log(detail.bodyText); + +// List threads +for await (const thread of client.threads.list(mailbox.emailAddress)) { + console.log(thread.subject, thread.messageCount); +} + +// Fetch full thread with all messages +const threadDetail = await client.threads.get(mailbox.emailAddress, thread.id); +for (const msg of threadDetail.messages) { + console.log(`[${msg.direction}] ${msg.fromAddress}: ${msg.snippet}`); +} +``` + +### Webhooks + +```ts +// Register a webhook (secret is one-time — save it immediately) +const hook = await client.webhooks.create(mailbox.emailAddress, { + url: "https://yourapp.com/hooks/mail", + eventTypes: ["message.received", "message.sent"], +}); +console.log(hook.secret); // save this — it will not be shown again + +// List and delete +const hooks = await client.webhooks.list(mailbox.emailAddress); +await client.webhooks.delete(mailbox.emailAddress, hook.id); +``` + +--- + +## Phone + +```ts +import { InkboxPhone } from "@inkbox/sdk/phone"; +``` + +### Provisioning numbers + +```ts +// Provision a toll-free number +const number = await client.numbers.provision({ + agentHandle: "sales-agent", + type: "toll_free", +}); +console.log(number.number, number.status); + +// Provision a local number in a specific state +const local = await client.numbers.provision({ + agentHandle: "sales-agent", + type: "local", + state: "NY", +}); + +// List all numbers +const all = await client.numbers.list(); +for (const n of all) { + console.log(n.number, n.type, n.status); +} + +// Update settings +await client.numbers.update(number.id, { + incomingCallAction: "auto_accept", + clientWebsocketUrl: "wss://your-agent.example.com/ws", +}); + +// Release a number +await client.numbers.release(number.id); +``` + +### Placing calls + +```ts +// Place an outbound call +const call = await client.calls.place({ + fromNumber: number.number, + toNumber: "+15167251294", + clientWebsocketUrl: "wss://your-agent.example.com/ws", +}); +console.log(call.status); +console.log(call.rateLimit.callsRemaining); +``` + +### Reading calls and transcripts + +```ts +// List recent calls for a number +const calls = await client.calls.list(number.id, { limit: 10 }); +for (const call of calls) { + console.log(call.id, call.direction, call.remotePhoneNumber, call.status); +} + +// Read transcript for a call +const transcripts = await client.transcripts.list(number.id, call.id); +for (const t of transcripts) { + console.log(`[${t.party}] ${t.text}`); +} + +// Full-text search across all transcripts for a number +const results = await client.numbers.searchTranscripts(number.id, { q: "appointment" }); +``` + +### Webhooks + +```ts +// Register a webhook (secret is one-time — save it immediately) +const hook = await client.webhooks.create(number.id, { + url: "https://yourapp.com/hooks/phone", + eventTypes: ["call.completed"], +}); +console.log(hook.secret); // save this — it will not be shown again + +// List and delete +const hooks = await client.webhooks.list(number.id); +await client.webhooks.delete(number.id, hook.id); +``` + +--- + +## Examples + +Runnable example scripts are available in the [examples/typescript](https://github.com/vectorlyapp/inkbox/tree/main/inkbox/examples/typescript) directory: + +| Script | What it demonstrates | +|---|---| +| `register-agent-identity.ts` | Create an identity and assign mailbox / phone number | +| `create-agent-mailbox.ts` | Create, update, search, and delete a mailbox | +| `agent-send-email.ts` | Send an email and a threaded reply | +| `read-agent-messages.ts` | List messages and read full threads | +| `create-agent-phone-number.ts` | Provision, update, and release a number | +| `list-agent-phone-numbers.ts` | List all provisioned numbers | +| `read-agent-calls.ts` | List calls and print transcripts | +| `receive-agent-email-webhook.ts` | Register, list, and delete email webhooks | +| `receive-agent-call-webhook.ts` | Register, list, and delete phone webhooks | + +## License + +MIT From 8c3a54fe4c28a56f58b8f887a65e73e3c049a386 Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:49:11 -0400 Subject: [PATCH 26/56] unified python sdk --- examples/python/agent_send_email.py | 8 +- examples/python/create_agent_mailbox.py | 52 ++-- examples/python/create_agent_phone_number.py | 12 +- examples/python/list_agent_phone_numbers.py | 6 +- examples/python/read_agent_calls.py | 8 +- examples/python/read_agent_messages.py | 10 +- examples/python/receive_agent_call_webhook.py | 10 +- .../python/receive_agent_email_webhook.py | 10 +- examples/python/register_agent_identity.py | 30 +- python/inkbox/__init__.py | 75 ++++- python/inkbox/agent.py | 275 ++++++++++++++++++ python/inkbox/client.py | 166 +++++++++++ python/inkbox/identities/__init__.py | 18 +- python/inkbox/identities/client.py | 54 ---- python/inkbox/mail/__init__.py | 8 +- python/inkbox/mail/client.py | 77 ----- python/inkbox/phone/__init__.py | 8 +- python/inkbox/phone/client.py | 67 ----- python/tests/conftest.py | 12 +- python/tests/test_client.py | 22 +- python/tests/test_identities_client.py | 24 +- python/tests/test_mail_client.py | 23 +- python/tests/test_webhooks.py | 14 +- 23 files changed, 662 insertions(+), 327 deletions(-) create mode 100644 python/inkbox/agent.py create mode 100644 python/inkbox/client.py delete mode 100644 python/inkbox/identities/client.py delete mode 100644 python/inkbox/mail/client.py delete mode 100644 python/inkbox/phone/client.py diff --git a/examples/python/agent_send_email.py b/examples/python/agent_send_email.py index 8663c09..1bd04ce 100644 --- a/examples/python/agent_send_email.py +++ b/examples/python/agent_send_email.py @@ -6,13 +6,13 @@ """ import os -from inkbox.mail import InkboxMail +from inkbox import Inkbox -client = InkboxMail(api_key=os.environ["INKBOX_API_KEY"]) +inkbox = Inkbox(api_key=os.environ["INKBOX_API_KEY"]) mailbox_address = os.environ["MAILBOX_ADDRESS"] # Agent sends outbound email -sent = client.messages.send( +sent = inkbox.messages.send( mailbox_address, to=["recipient@example.com"], subject="Hello from your AI sales agent", @@ -22,7 +22,7 @@ print(f"Sent message {sent.id} subject={sent.subject!r}") # Agent sends threaded reply -reply = client.messages.send( +reply = inkbox.messages.send( mailbox_address, to=["recipient@example.com"], subject=f"Re: {sent.subject}", diff --git a/examples/python/create_agent_mailbox.py b/examples/python/create_agent_mailbox.py index 0da8a89..5604214 100644 --- a/examples/python/create_agent_mailbox.py +++ b/examples/python/create_agent_mailbox.py @@ -2,33 +2,31 @@ Create, update, search, and delete a mailbox. Usage: - INKBOX_API_KEY=ApiKey_... AGENT_HANDLE=sales-agent python create_agent_mailbox.py + INKBOX_API_KEY=ApiKey_... python create_agent_mailbox.py """ import os -from inkbox.mail import InkboxMail - -client = InkboxMail(api_key=os.environ["INKBOX_API_KEY"]) -agent_handle = os.environ.get("AGENT_HANDLE", "sales-agent") - -# Create agent mailbox -mailbox = client.mailboxes.create(agent_handle=agent_handle, display_name="Sales Agent") -print(f"Agent mailbox created: {mailbox.email_address} display_name={mailbox.display_name!r}") - -# List all mailboxes -all_mailboxes = client.mailboxes.list() -print(f"\nAll agent mailboxes ({len(all_mailboxes)}):") -for m in all_mailboxes: - print(f" {m.email_address} status={m.status}") - -# Update display name -updated = client.mailboxes.update(mailbox.email_address, display_name="Sales Agent (updated)") -print(f"\nUpdated display_name: {updated.display_name}") - -# Full-text search -results = client.mailboxes.search(mailbox.email_address, q="hello") -print(f'\nSearch results for "hello": {len(results)} messages') - -# Delete agent mailbox -client.mailboxes.delete(mailbox.email_address) -print("Agent mailbox deleted.") +from inkbox import Inkbox + +with Inkbox(api_key=os.environ["INKBOX_API_KEY"]) as inkbox: + # Create a mailbox + mailbox = inkbox.mailboxes.create(display_name="Sales Agent") + print(f"Mailbox created: {mailbox.email_address} display_name={mailbox.display_name!r}") + + # List all mailboxes + all_mailboxes = inkbox.mailboxes.list() + print(f"\nAll mailboxes ({len(all_mailboxes)}):") + for m in all_mailboxes: + print(f" {m.email_address} status={m.status}") + + # Update display name + updated = inkbox.mailboxes.update(mailbox.email_address, display_name="Sales Agent (updated)") + print(f"\nUpdated display_name: {updated.display_name}") + + # Full-text search + results = inkbox.mailboxes.search(mailbox.email_address, q="hello") + print(f'\nSearch results for "hello": {len(results)} messages') + + # Delete + inkbox.mailboxes.delete(mailbox.email_address) + print("Mailbox deleted.") diff --git a/examples/python/create_agent_phone_number.py b/examples/python/create_agent_phone_number.py index cd78d86..9cc15da 100644 --- a/examples/python/create_agent_phone_number.py +++ b/examples/python/create_agent_phone_number.py @@ -7,9 +7,9 @@ """ import os -from inkbox.phone import InkboxPhone +from inkbox import Inkbox -client = InkboxPhone(api_key=os.environ["INKBOX_API_KEY"]) +inkbox = Inkbox(api_key=os.environ["INKBOX_API_KEY"]) number_type = os.environ.get("NUMBER_TYPE", "toll_free") state = os.environ.get("STATE") @@ -17,19 +17,19 @@ kwargs = {"type": number_type} if state: kwargs["state"] = state -number = client.numbers.provision(**kwargs) +number = inkbox.numbers.provision(**kwargs) print(f"Agent phone number provisioned: {number.number} type={number.type} status={number.status}") # List all numbers -all_numbers = client.numbers.list() +all_numbers = inkbox.numbers.list() print(f"\nAll agent phone numbers ({len(all_numbers)}):") for n in all_numbers: print(f" {n.number} type={n.type} status={n.status}") # Update incoming call action -updated = client.numbers.update(number.id, incoming_call_action="auto_accept") +updated = inkbox.numbers.update(number.id, incoming_call_action="auto_accept") print(f"\nUpdated incoming_call_action: {updated.incoming_call_action}") # Release agent phone number -client.numbers.release(number=number.number) +inkbox.numbers.release(number=number.number) print("Agent phone number released.") diff --git a/examples/python/list_agent_phone_numbers.py b/examples/python/list_agent_phone_numbers.py index 0b38454..e1e1f22 100644 --- a/examples/python/list_agent_phone_numbers.py +++ b/examples/python/list_agent_phone_numbers.py @@ -6,11 +6,11 @@ """ import os -from inkbox.phone import InkboxPhone +from inkbox import Inkbox -client = InkboxPhone(api_key=os.environ["INKBOX_API_KEY"]) +inkbox = Inkbox(api_key=os.environ["INKBOX_API_KEY"]) -numbers = client.numbers.list() +numbers = inkbox.numbers.list() for n in numbers: print(f"{n.number} type={n.type} status={n.status}") diff --git a/examples/python/read_agent_calls.py b/examples/python/read_agent_calls.py index 50b986c..45a37fc 100644 --- a/examples/python/read_agent_calls.py +++ b/examples/python/read_agent_calls.py @@ -6,16 +6,16 @@ """ import os -from inkbox.phone import InkboxPhone +from inkbox import Inkbox -client = InkboxPhone(api_key=os.environ["INKBOX_API_KEY"]) +inkbox = Inkbox(api_key=os.environ["INKBOX_API_KEY"]) phone_number_id = os.environ["PHONE_NUMBER_ID"] -calls = client.calls.list(phone_number_id, limit=10) +calls = inkbox.calls.list(phone_number_id, limit=10) for call in calls: print(f"\n{call.id} {call.direction} {call.remote_phone_number} status={call.status}") - transcripts = client.transcripts.list(phone_number_id, call.id) + transcripts = inkbox.transcripts.list(phone_number_id, call.id) for t in transcripts: print(f" [{t.party}] {t.text}") diff --git a/examples/python/read_agent_messages.py b/examples/python/read_agent_messages.py index ad243ff..41bc655 100644 --- a/examples/python/read_agent_messages.py +++ b/examples/python/read_agent_messages.py @@ -6,14 +6,14 @@ """ import os -from inkbox.mail import InkboxMail +from inkbox import Inkbox -client = InkboxMail(api_key=os.environ["INKBOX_API_KEY"]) +inkbox = Inkbox(api_key=os.environ["INKBOX_API_KEY"]) mailbox_address = os.environ["MAILBOX_ADDRESS"] # List the 5 most recent messages print("=== Agent inbox ===") -for i, msg in enumerate(client.messages.list(mailbox_address)): +for i, msg in enumerate(inkbox.messages.list(mailbox_address)): print(f"{msg.id} {msg.subject} from={msg.from_address} read={msg.is_read}") if i >= 4: break @@ -21,13 +21,13 @@ # List threads and fetch the first one in full print("\n=== Agent threads ===") first_thread_id = None -for thread in client.threads.list(mailbox_address): +for thread in inkbox.threads.list(mailbox_address): print(f"{thread.id} {thread.subject!r} messages={thread.message_count}") if first_thread_id is None: first_thread_id = thread.id if first_thread_id: - thread = client.threads.get(mailbox_address, first_thread_id) + thread = inkbox.threads.get(mailbox_address, first_thread_id) print(f"\nAgent conversation: {thread.subject!r} ({len(thread.messages)} messages)") for msg in thread.messages: print(f" [{msg.from_address}] {msg.subject}") diff --git a/examples/python/receive_agent_call_webhook.py b/examples/python/receive_agent_call_webhook.py index 2c2c2cc..ef87b93 100644 --- a/examples/python/receive_agent_call_webhook.py +++ b/examples/python/receive_agent_call_webhook.py @@ -6,13 +6,13 @@ """ import os -from inkbox.phone import InkboxPhone +from inkbox import Inkbox -client = InkboxPhone(api_key=os.environ["INKBOX_API_KEY"]) +inkbox = Inkbox(api_key=os.environ["INKBOX_API_KEY"]) phone_number_id = os.environ["PHONE_NUMBER_ID"] # Register webhook for agent phone number -hook = client.webhooks.create( +hook = inkbox.phone_webhooks.create( phone_number_id, url="https://example.com/webhook", event_types=["incoming_call"], @@ -20,7 +20,7 @@ print(f"Registered agent phone webhook {hook.id} secret={hook.secret}") # Update agent phone webhook -updated = client.webhooks.update( +updated = inkbox.phone_webhooks.update( phone_number_id, hook.id, url="https://example.com/webhook-v2", @@ -28,5 +28,5 @@ print(f"Updated URL: {updated.url}") # Remove agent phone webhook -client.webhooks.delete(phone_number_id, hook.id) +inkbox.phone_webhooks.delete(phone_number_id, hook.id) print("Agent phone webhook removed.") diff --git a/examples/python/receive_agent_email_webhook.py b/examples/python/receive_agent_email_webhook.py index 42c6ea4..658f515 100644 --- a/examples/python/receive_agent_email_webhook.py +++ b/examples/python/receive_agent_email_webhook.py @@ -6,13 +6,13 @@ """ import os -from inkbox.mail import InkboxMail +from inkbox import Inkbox -client = InkboxMail(api_key=os.environ["INKBOX_API_KEY"]) +inkbox = Inkbox(api_key=os.environ["INKBOX_API_KEY"]) mailbox_address = os.environ["MAILBOX_ADDRESS"] # Register webhook for agent mailbox -hook = client.webhooks.create( +hook = inkbox.mail_webhooks.create( mailbox_address, url="https://example.com/webhook", event_types=["message.received", "message.sent"], @@ -20,11 +20,11 @@ print(f"Registered agent mailbox webhook {hook.id} secret={hook.secret}") # List -all_hooks = client.webhooks.list(mailbox_address) +all_hooks = inkbox.mail_webhooks.list(mailbox_address) print(f"Active agent mailbox webhooks: {len(all_hooks)}") for w in all_hooks: print(f" {w.id} url={w.url} events={', '.join(w.event_types)}") # Remove agent mailbox webhook -client.webhooks.delete(mailbox_address, hook.id) +inkbox.mail_webhooks.delete(mailbox_address, hook.id) print("Agent mailbox webhook removed.") diff --git a/examples/python/register_agent_identity.py b/examples/python/register_agent_identity.py index c2a8a0c..e2f052e 100644 --- a/examples/python/register_agent_identity.py +++ b/examples/python/register_agent_identity.py @@ -2,32 +2,30 @@ Create an agent identity and assign communication channels to it. Usage: - INKBOX_API_KEY=ApiKey_... MAILBOX_ID= PHONE_NUMBER_ID= python register_agent_identity.py + INKBOX_API_KEY=ApiKey_... python register_agent_identity.py """ import os -from inkbox.identities import InkboxIdentities +from inkbox import Inkbox -with InkboxIdentities(api_key=os.environ["INKBOX_API_KEY"]) as client: - # Register agent identity - identity = client.identities.create(agent_handle="sales-agent") - print(f"Registered agent: {identity.agent_handle} (id={identity.id})") +with Inkbox(api_key=os.environ["INKBOX_API_KEY"]) as inkbox: + # Create agent identity — returns an Agent object + agent = inkbox.identities.create(agent_handle="sales-agent") + print(f"Registered agent: {agent.agent_handle} (id={agent.id})") - # Assign channels - if mailbox_id := os.environ.get("MAILBOX_ID"): - with_mailbox = client.identities.assign_mailbox("sales-agent", mailbox_id=mailbox_id) - print(f"Assigned mailbox: {with_mailbox.mailbox.email_address}") + # Provision and assign channels in one call each + mailbox = agent.assign_mailbox(display_name="Sales Agent") + print(f"Assigned mailbox: {mailbox.email_address}") - if phone_number_id := os.environ.get("PHONE_NUMBER_ID"): - with_phone = client.identities.assign_phone_number("sales-agent", phone_number_id=phone_number_id) - print(f"Assigned phone: {with_phone.phone_number.number}") + phone = agent.assign_phone_number(type="toll_free") + print(f"Assigned phone: {phone.number}") # List all identities - all_identities = client.identities.list() + all_identities = inkbox.identities.list() print(f"\nAll identities ({len(all_identities)}):") for ident in all_identities: print(f" {ident.agent_handle} status={ident.status}") - # Unregister agent - client.identities.delete("sales-agent") + # Unregister agent (unlinks channels without deleting them) + agent.delete() print("\nUnregistered agent sales-agent.") diff --git a/python/inkbox/__init__.py b/python/inkbox/__init__.py index b04400a..e83e133 100644 --- a/python/inkbox/__init__.py +++ b/python/inkbox/__init__.py @@ -1 +1,74 @@ -# inkbox namespace package +""" +inkbox — Python SDK for the Inkbox APIs. +""" + +from inkbox.client import Inkbox +from inkbox.agent import Agent + +# Exceptions (canonical source: mail; identical in all submodules) +from inkbox.mail.exceptions import InkboxAPIError, InkboxError + +# Mail types +from inkbox.mail.types import ( + Mailbox, + Message, + MessageDetail, + Thread, + ThreadDetail, + Webhook as MailWebhook, + WebhookCreateResult as MailWebhookCreateResult, +) + +# Phone types +from inkbox.phone.types import ( + PhoneCall, + PhoneCallWithRateLimit, + PhoneNumber, + PhoneTranscript, + PhoneWebhook, + PhoneWebhookCreateResult, + RateLimitInfo, +) + +# Identity types +from inkbox.identities.types import ( + AgentIdentity, + AgentIdentityDetail, + IdentityMailbox, + IdentityPhoneNumber, +) + +# Signing key +from inkbox.signing_keys import SigningKey + +__all__ = [ + # Entry points + "Inkbox", + "Agent", + # Exceptions + "InkboxError", + "InkboxAPIError", + # Mail types + "Mailbox", + "Message", + "MessageDetail", + "Thread", + "ThreadDetail", + "MailWebhook", + "MailWebhookCreateResult", + # Phone types + "PhoneCall", + "PhoneCallWithRateLimit", + "PhoneNumber", + "PhoneTranscript", + "PhoneWebhook", + "PhoneWebhookCreateResult", + "RateLimitInfo", + # Identity types + "AgentIdentity", + "AgentIdentityDetail", + "IdentityMailbox", + "IdentityPhoneNumber", + # Signing key + "SigningKey", +] diff --git a/python/inkbox/agent.py b/python/inkbox/agent.py new file mode 100644 index 0000000..9ec23a3 --- /dev/null +++ b/python/inkbox/agent.py @@ -0,0 +1,275 @@ +""" +inkbox/agent.py + +Agent — a domain object representing one agent identity. +Returned by inkbox.identities.create() and inkbox.identities.get(). + +Convenience methods (send_email, place_call, etc.) are scoped to this +agent's assigned channels so callers never need to pass an email address +or phone number ID explicitly. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Iterator + +from inkbox.identities.types import AgentIdentityDetail, IdentityMailbox, IdentityPhoneNumber +from inkbox.mail.exceptions import InkboxError +from inkbox.mail.types import Message +from inkbox.phone.types import PhoneCallWithRateLimit, PhoneTranscript + +if TYPE_CHECKING: + from inkbox.client import Inkbox + + +class Agent: + """An agent identity with convenience methods for its assigned channels. + + Obtain an instance via:: + + agent = inkbox.identities.create(agent_handle="support-bot") + # or + agent = inkbox.identities.get("support-bot") + + After assigning channels you can communicate directly:: + + agent.assign_mailbox(display_name="Support Bot") + agent.assign_phone_number(type="toll_free") + + agent.send_email(to=["user@example.com"], subject="Hi", body_text="Hello") + agent.place_call(to_number="+15555550100", stream_url="wss://my-app.com/ws") + + for msg in agent.messages(): + print(msg.subject) + """ + + def __init__(self, identity: AgentIdentityDetail, inkbox: Inkbox) -> None: + self._identity = identity + self._inkbox = inkbox + self._mailbox: IdentityMailbox | None = identity.mailbox + self._phone_number: IdentityPhoneNumber | None = identity.phone_number + + # ------------------------------------------------------------------ + # Identity properties + # ------------------------------------------------------------------ + + @property + def agent_handle(self) -> str: + return self._identity.agent_handle + + @property + def id(self): + return self._identity.id + + @property + def status(self) -> str: + return self._identity.status + + @property + def mailbox(self) -> IdentityMailbox | None: + return self._mailbox + + @property + def phone_number(self) -> IdentityPhoneNumber | None: + return self._phone_number + + # ------------------------------------------------------------------ + # Channel assignment + # Combines resource creation/provisioning + identity linking in one call. + # ------------------------------------------------------------------ + + def assign_mailbox(self, *, display_name: str | None = None) -> IdentityMailbox: + """Create a new mailbox and assign it to this agent. + + Args: + display_name: Optional human-readable sender name. + + Returns: + The assigned mailbox. + """ + mailbox = self._inkbox.mailboxes.create(display_name=display_name) + detail = self._inkbox._ids_resource.assign_mailbox( + self.agent_handle, mailbox_id=mailbox.id + ) + self._mailbox = detail.mailbox + self._identity = detail + return self._mailbox # type: ignore[return-value] + + def assign_phone_number( + self, *, type: str = "toll_free", state: str | None = None + ) -> IdentityPhoneNumber: + """Provision a new phone number and assign it to this agent. + + Args: + type: ``"toll_free"`` (default) or ``"local"``. + state: US state abbreviation (e.g. ``"NY"``), valid for local numbers only. + + Returns: + The assigned phone number. + """ + number = self._inkbox.numbers.provision(type=type, state=state) + detail = self._inkbox._ids_resource.assign_phone_number( + self.agent_handle, phone_number_id=number.id + ) + self._phone_number = detail.phone_number + self._identity = detail + return self._phone_number # type: ignore[return-value] + + # ------------------------------------------------------------------ + # Mail helpers + # ------------------------------------------------------------------ + + def send_email( + self, + *, + to: list[str], + subject: str, + body_text: str | None = None, + body_html: str | None = None, + cc: list[str] | None = None, + bcc: list[str] | None = None, + in_reply_to_message_id: str | None = None, + attachments: list[dict] | None = None, + ) -> Message: + """Send an email from this agent's mailbox. + + Args: + to: Primary recipient addresses (at least one required). + subject: Email subject line. + body_text: Plain-text body. + body_html: HTML body. + cc: Carbon-copy recipients. + bcc: Blind carbon-copy recipients. + in_reply_to_message_id: RFC 5322 Message-ID to thread a reply. + attachments: List of file attachment dicts with ``filename``, + ``content_type``, and ``content_base64`` keys. + """ + self._require_mailbox() + return self._inkbox.messages.send( + self._mailbox.email_address, # type: ignore[union-attr] + to=to, + subject=subject, + body_text=body_text, + body_html=body_html, + cc=cc, + bcc=bcc, + in_reply_to_message_id=in_reply_to_message_id, + attachments=attachments, + ) + + def messages( + self, + *, + page_size: int = 50, + direction: str | None = None, + ) -> Iterator[Message]: + """Iterate over messages in this agent's inbox, newest first. + + Pagination is handled automatically. + + Args: + page_size: Messages fetched per API call (1–100). + direction: Filter by ``"inbound"`` or ``"outbound"``. + """ + self._require_mailbox() + return self._inkbox.messages.list( + self._mailbox.email_address, # type: ignore[union-attr] + page_size=page_size, + direction=direction, + ) + + # ------------------------------------------------------------------ + # Phone helpers + # ------------------------------------------------------------------ + + def place_call( + self, + *, + to_number: str, + stream_url: str | None = None, + pipeline_mode: str | None = None, + webhook_url: str | None = None, + ) -> PhoneCallWithRateLimit: + """Place an outbound call from this agent's phone number. + + Args: + to_number: E.164 destination number. + stream_url: WebSocket URL (wss://) for audio bridging. + pipeline_mode: Pipeline mode override for this call. + webhook_url: Custom webhook URL for call lifecycle events. + """ + self._require_phone() + return self._inkbox.calls.place( + from_number=self._phone_number.number, # type: ignore[union-attr] + to_number=to_number, + stream_url=stream_url, + pipeline_mode=pipeline_mode, + webhook_url=webhook_url, + ) + + def search_transcripts( + self, + *, + q: str, + party: str | None = None, + limit: int = 50, + ) -> list[PhoneTranscript]: + """Full-text search across call transcripts for this agent's number. + + Args: + q: Search query string. + party: Filter by speaker: ``"local"`` or ``"remote"``. + limit: Maximum number of results (1–200). + """ + self._require_phone() + return self._inkbox.numbers.search_transcripts( + self._phone_number.id, # type: ignore[union-attr] + q=q, + party=party, + limit=limit, + ) + + # ------------------------------------------------------------------ + # Misc + # ------------------------------------------------------------------ + + def refresh(self) -> Agent: + """Re-fetch this agent's identity from the API and update cached channels. + + Returns: + ``self`` for chaining. + """ + detail = self._inkbox._ids_resource.get(self.agent_handle) + self._identity = detail + self._mailbox = detail.mailbox + self._phone_number = detail.phone_number + return self + + def delete(self) -> None: + """Soft-delete this identity (unlinks channels without deleting them).""" + self._inkbox._ids_resource.delete(self.agent_handle) + + # ------------------------------------------------------------------ + # Internal guards + # ------------------------------------------------------------------ + + def _require_mailbox(self) -> None: + if not self._mailbox: + raise InkboxError( + f"Agent '{self.agent_handle}' has no mailbox assigned. " + "Call agent.assign_mailbox() first." + ) + + def _require_phone(self) -> None: + if not self._phone_number: + raise InkboxError( + f"Agent '{self.agent_handle}' has no phone number assigned. " + "Call agent.assign_phone_number() first." + ) + + def __repr__(self) -> str: + return ( + f"Agent(agent_handle={self.agent_handle!r}, " + f"mailbox={self._mailbox.email_address if self._mailbox else None!r}, " + f"phone={self._phone_number.number if self._phone_number else None!r})" + ) diff --git a/python/inkbox/client.py b/python/inkbox/client.py new file mode 100644 index 0000000..a58eac4 --- /dev/null +++ b/python/inkbox/client.py @@ -0,0 +1,166 @@ +""" +inkbox/client.py + +Unified Inkbox client — single entry point for all Inkbox APIs. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from inkbox.mail._http import HttpTransport as MailHttpTransport +from inkbox.mail.resources.mailboxes import MailboxesResource +from inkbox.mail.resources.messages import MessagesResource +from inkbox.mail.resources.threads import ThreadsResource +from inkbox.mail.resources.webhooks import WebhooksResource as MailWebhooksResource +from inkbox.phone._http import HttpTransport as PhoneHttpTransport +from inkbox.phone.resources.calls import CallsResource +from inkbox.phone.resources.numbers import PhoneNumbersResource +from inkbox.phone.resources.transcripts import TranscriptsResource +from inkbox.phone.resources.webhooks import PhoneWebhooksResource +from inkbox.identities._http import HttpTransport as IdsHttpTransport +from inkbox.identities.resources.identities import IdentitiesResource +from inkbox.signing_keys import SigningKeysResource + +if TYPE_CHECKING: + from inkbox.agent import Agent + +_DEFAULT_BASE_URL = "https://api.inkbox.ai" + + +class Inkbox: + """Unified client for all Inkbox APIs. + + Args: + api_key: Your Inkbox API key (``X-Service-Token``). + base_url: Override the API base URL (useful for self-hosting or testing). + timeout: Request timeout in seconds (default 30). + + Example:: + + from inkbox import Inkbox + + with Inkbox(api_key="ApiKey_...") as inkbox: + agent = inkbox.identities.create(agent_handle="support-bot") + agent.assign_mailbox(display_name="Support Bot") + agent.send_email( + to=["customer@example.com"], + subject="Hello!", + body_text="Hi there", + ) + """ + + def __init__( + self, + api_key: str, + *, + base_url: str = _DEFAULT_BASE_URL, + timeout: float = 30.0, + ) -> None: + _api_root = f"{base_url.rstrip('/')}/api/v1" + + self._mail_http = MailHttpTransport( + api_key=api_key, base_url=f"{_api_root}/mail", timeout=timeout + ) + self._phone_http = PhoneHttpTransport( + api_key=api_key, base_url=f"{_api_root}/phone", timeout=timeout + ) + self._ids_http = IdsHttpTransport( + api_key=api_key, base_url=f"{_api_root}/identities", timeout=timeout + ) + # Signing keys live at the API root + self._api_http = MailHttpTransport( + api_key=api_key, base_url=_api_root, timeout=timeout + ) + + # Mail resources + self.mailboxes = MailboxesResource(self._mail_http) + self.messages = MessagesResource(self._mail_http) + self.threads = ThreadsResource(self._mail_http) + self.mail_webhooks = MailWebhooksResource(self._mail_http) + + # Phone resources + self.calls = CallsResource(self._phone_http) + self.numbers = PhoneNumbersResource(self._phone_http) + self.transcripts = TranscriptsResource(self._phone_http) + self.phone_webhooks = PhoneWebhooksResource(self._phone_http) + + # Shared + self.signing_keys = SigningKeysResource(self._api_http) + + # Identities — internal resource used by Agent, wrapped namespace for users + self._ids_resource = IdentitiesResource(self._ids_http) + self.identities = _IdentitiesNamespace(self._ids_resource, self) + + def close(self) -> None: + """Close all underlying HTTP connection pools.""" + self._mail_http.close() + self._phone_http.close() + self._ids_http.close() + self._api_http.close() + + def __enter__(self) -> Inkbox: + return self + + def __exit__(self, *_: object) -> None: + self.close() + + +class _IdentitiesNamespace: + """Thin wrapper around IdentitiesResource that returns Agent objects.""" + + def __init__(self, resource: IdentitiesResource, inkbox: Inkbox) -> None: + self._r = resource + self._inkbox = inkbox + + def create(self, *, agent_handle: str) -> Agent: + """Create a new agent identity and return it as an Agent object. + + Args: + agent_handle: Unique handle for this agent (e.g. ``"support-bot"``). + """ + from inkbox.agent import Agent + + self._r.create(agent_handle=agent_handle) + # POST /identities returns AgentIdentity (no channel fields); + # fetch the detail to get a fully-populated AgentIdentityDetail. + detail = self._r.get(agent_handle) + return Agent(detail, self._inkbox) + + def get(self, agent_handle: str) -> Agent: + """Get an agent identity by handle. + + Args: + agent_handle: Handle of the identity to fetch. + """ + from inkbox.agent import Agent + + return Agent(self._r.get(agent_handle), self._inkbox) + + def list(self): + """List all agent identities for your organisation.""" + return self._r.list() + + def update(self, *args, **kwargs): + """Update an identity's handle or status.""" + return self._r.update(*args, **kwargs) + + def delete(self, *args, **kwargs): + """Delete an identity.""" + return self._r.delete(*args, **kwargs) + + def assign_mailbox(self, *args, **kwargs): + """Assign an existing mailbox to an identity by ID.""" + return self._r.assign_mailbox(*args, **kwargs) + + def unlink_mailbox(self, *args, **kwargs): + """Unlink a mailbox from an identity.""" + return self._r.unlink_mailbox(*args, **kwargs) + + def assign_phone_number(self, *args, **kwargs): + """Assign an existing phone number to an identity by ID.""" + return self._r.assign_phone_number(*args, **kwargs) + + def unlink_phone_number(self, *args, **kwargs): + """Unlink a phone number from an identity.""" + return self._r.unlink_phone_number(*args, **kwargs) diff --git a/python/inkbox/identities/__init__.py b/python/inkbox/identities/__init__.py index d2f8a46..128c519 100644 --- a/python/inkbox/identities/__init__.py +++ b/python/inkbox/identities/__init__.py @@ -1 +1,17 @@ -# inkbox identities namespace +""" +inkbox.identities — identity types. +""" + +from inkbox.identities.types import ( + AgentIdentity, + AgentIdentityDetail, + IdentityMailbox, + IdentityPhoneNumber, +) + +__all__ = [ + "AgentIdentity", + "AgentIdentityDetail", + "IdentityMailbox", + "IdentityPhoneNumber", +] diff --git a/python/inkbox/identities/client.py b/python/inkbox/identities/client.py deleted file mode 100644 index 440214b..0000000 --- a/python/inkbox/identities/client.py +++ /dev/null @@ -1,54 +0,0 @@ -""" -inkbox/identities/client.py - -Top-level InkboxIdentities client. -""" - -from __future__ import annotations - -from inkbox.identities._http import HttpTransport -from inkbox.identities.resources.identities import IdentitiesResource - -_DEFAULT_BASE_URL = "https://api.inkbox.ai/api/v1/identities" - - -class InkboxIdentities: - """Client for the Inkbox Identities API. - - Args: - api_key: Your Inkbox API key (``X-Service-Token``). - base_url: Override the API base URL (useful for self-hosting or testing). - timeout: Request timeout in seconds (default 30). - - Example:: - - from inkbox.identities import InkboxIdentities - - with InkboxIdentities(api_key="ApiKey_...") as client: - identity = client.identities.create(agent_handle="sales-agent") - detail = client.identities.assign_mailbox( - "sales-agent", - mailbox_id="", - ) - print(detail.mailbox.email_address) - """ - - def __init__( - self, - api_key: str, - *, - base_url: str = _DEFAULT_BASE_URL, - timeout: float = 30.0, - ) -> None: - self._http = HttpTransport(api_key=api_key, base_url=base_url, timeout=timeout) - self.identities = IdentitiesResource(self._http) - - def close(self) -> None: - """Close the underlying HTTP connection pool.""" - self._http.close() - - def __enter__(self) -> InkboxIdentities: - return self - - def __exit__(self, *_: object) -> None: - self.close() diff --git a/python/inkbox/mail/__init__.py b/python/inkbox/mail/__init__.py index 0840072..ae5a76c 100644 --- a/python/inkbox/mail/__init__.py +++ b/python/inkbox/mail/__init__.py @@ -1,8 +1,7 @@ """ -inkbox.mail — Python SDK for the Inkbox Mail API. +inkbox.mail — mail types and exceptions. """ -from inkbox.mail.client import InkboxMail from inkbox.mail.exceptions import InkboxAPIError, InkboxError from inkbox.mail.types import ( Mailbox, @@ -10,11 +9,12 @@ MessageDetail, Thread, ThreadDetail, + Webhook, + WebhookCreateResult, ) from inkbox.signing_keys import SigningKey __all__ = [ - "InkboxMail", "InkboxError", "InkboxAPIError", "Mailbox", @@ -23,4 +23,6 @@ "SigningKey", "Thread", "ThreadDetail", + "Webhook", + "WebhookCreateResult", ] diff --git a/python/inkbox/mail/client.py b/python/inkbox/mail/client.py deleted file mode 100644 index f109214..0000000 --- a/python/inkbox/mail/client.py +++ /dev/null @@ -1,77 +0,0 @@ -""" -inkbox/mail/client.py - -Top-level InkboxMail client. -""" - -from __future__ import annotations - -from inkbox.mail._http import HttpTransport -from inkbox.mail.resources.mailboxes import MailboxesResource -from inkbox.mail.resources.messages import MessagesResource -from inkbox.mail.resources.threads import ThreadsResource -from inkbox.signing_keys import SigningKeysResource - -_DEFAULT_BASE_URL = "https://api.inkbox.ai/api/v1/mail" - - -class InkboxMail: - """Client for the Inkbox Mail API. - - Args: - api_key: Your Inkbox API key (``X-Service-Token``). - base_url: Override the API base URL (useful for self-hosting or testing). - timeout: Request timeout in seconds (default 30). - - Example:: - - from inkbox.mail import InkboxMail - - client = InkboxMail(api_key="ApiKey_...") - - mailbox = client.mailboxes.create(agent_handle="sales-agent") - - client.messages.send( - mailbox.email_address, - to=["user@example.com"], - subject="Hello from Inkbox", - body_text="Hi there!", - ) - - for msg in client.messages.list(mailbox.email_address): - print(msg.subject, msg.from_address) - - client.close() - - The client can also be used as a context manager:: - - with InkboxMail(api_key="ApiKey_...") as client: - mailboxes = client.mailboxes.list() - """ - - def __init__( - self, - api_key: str, - *, - base_url: str = _DEFAULT_BASE_URL, - timeout: float = 30.0, - ) -> None: - self._http = HttpTransport(api_key=api_key, base_url=base_url, timeout=timeout) - # Signing keys live at the API root (one level up from /mail) - _api_root = base_url.rstrip("/").removesuffix("/mail") - self._api_http = HttpTransport(api_key=api_key, base_url=_api_root, timeout=timeout) - self.mailboxes = MailboxesResource(self._http) - self.messages = MessagesResource(self._http) - self.threads = ThreadsResource(self._http) - self.signing_keys = SigningKeysResource(self._api_http) - - def close(self) -> None: - """Close the underlying HTTP connection pools.""" - self._http.close() - self._api_http.close() - - def __enter__(self) -> InkboxMail: - return self - - def __exit__(self, *_: object) -> None: - self.close() diff --git a/python/inkbox/phone/__init__.py b/python/inkbox/phone/__init__.py index 6141bdb..7734c86 100644 --- a/python/inkbox/phone/__init__.py +++ b/python/inkbox/phone/__init__.py @@ -1,26 +1,28 @@ """ -inkbox.phone — Python SDK for the Inkbox Phone API. +inkbox.phone — phone types and exceptions. """ -from inkbox.phone.client import InkboxPhone from inkbox.phone.exceptions import InkboxAPIError, InkboxError from inkbox.phone.types import ( PhoneCall, PhoneCallWithRateLimit, PhoneNumber, PhoneTranscript, + PhoneWebhook, + PhoneWebhookCreateResult, RateLimitInfo, ) from inkbox.signing_keys import SigningKey __all__ = [ - "InkboxPhone", "InkboxError", "InkboxAPIError", "PhoneCall", "PhoneCallWithRateLimit", "PhoneNumber", "PhoneTranscript", + "PhoneWebhook", + "PhoneWebhookCreateResult", "RateLimitInfo", "SigningKey", ] diff --git a/python/inkbox/phone/client.py b/python/inkbox/phone/client.py deleted file mode 100644 index c09b84e..0000000 --- a/python/inkbox/phone/client.py +++ /dev/null @@ -1,67 +0,0 @@ -""" -inkbox/phone/client.py - -Top-level InkboxPhone client. -""" - -from __future__ import annotations - -from inkbox.phone._http import HttpTransport -from inkbox.phone.resources.numbers import PhoneNumbersResource -from inkbox.phone.resources.calls import CallsResource -from inkbox.phone.resources.transcripts import TranscriptsResource -from inkbox.phone.resources.webhooks import PhoneWebhooksResource -from inkbox.signing_keys import SigningKeysResource - -_DEFAULT_BASE_URL = "https://api.inkbox.ai/api/v1/phone" - - -class InkboxPhone: - """Client for the Inkbox Phone API. - - Args: - api_key: Your Inkbox API key (``X-Service-Token``). - base_url: Override the API base URL (useful for self-hosting or testing). - timeout: Request timeout in seconds (default 30). - - Example:: - - from inkbox.phone import InkboxPhone - - with InkboxPhone(api_key="ApiKey_...") as client: - number = client.numbers.provision(agent_handle="sales-agent") - call = client.calls.place( - from_number=number.number, - to_number="+15167251294", - client_websocket_url="wss://your-agent.example.com/ws", - ) - print(call.status) - """ - - def __init__( - self, - api_key: str, - *, - base_url: str = _DEFAULT_BASE_URL, - timeout: float = 30.0, - ) -> None: - self._http = HttpTransport(api_key=api_key, base_url=base_url, timeout=timeout) - # Signing keys live at the API root (one level up from /phone) - _api_root = base_url.rstrip("/").removesuffix("/phone") - self._api_http = HttpTransport(api_key=api_key, base_url=_api_root, timeout=timeout) - self.numbers = PhoneNumbersResource(self._http) - self.calls = CallsResource(self._http) - self.transcripts = TranscriptsResource(self._http) - self.webhooks = PhoneWebhooksResource(self._http) - self.signing_keys = SigningKeysResource(self._api_http) - - def close(self) -> None: - """Close the underlying HTTP connection pools.""" - self._http.close() - self._api_http.close() - - def __enter__(self) -> InkboxPhone: - return self - - def __exit__(self, *_: object) -> None: - self.close() diff --git a/python/tests/conftest.py b/python/tests/conftest.py index a1dcfdc..f4b4b8d 100644 --- a/python/tests/conftest.py +++ b/python/tests/conftest.py @@ -1,4 +1,4 @@ -"""Shared fixtures for Inkbox Phone SDK tests.""" +"""Shared fixtures for Inkbox SDK tests.""" from __future__ import annotations @@ -6,7 +6,7 @@ import pytest -from inkbox.phone import InkboxPhone +from inkbox import Inkbox class FakeHttpTransport: @@ -26,13 +26,13 @@ def transport() -> FakeHttpTransport: @pytest.fixture -def client(transport: FakeHttpTransport) -> InkboxPhone: - c = InkboxPhone(api_key="sk-test") - c._http = transport # type: ignore[attr-defined] +def client(transport: FakeHttpTransport) -> Inkbox: + c = Inkbox(api_key="sk-test") + c._phone_http = transport # type: ignore[attr-defined] c._api_http = transport # type: ignore[attr-defined] c.numbers._http = transport c.calls._http = transport c.transcripts._http = transport - c.webhooks._http = transport + c.phone_webhooks._http = transport c.signing_keys._http = transport return c diff --git a/python/tests/test_client.py b/python/tests/test_client.py index 19ad03a..fac5ddc 100644 --- a/python/tests/test_client.py +++ b/python/tests/test_client.py @@ -1,26 +1,26 @@ -"""Tests for InkboxPhone client.""" +"""Tests for Inkbox unified client — phone resources.""" -from inkbox.phone import InkboxPhone +from inkbox import Inkbox from inkbox.phone.resources.numbers import PhoneNumbersResource from inkbox.phone.resources.calls import CallsResource from inkbox.phone.resources.transcripts import TranscriptsResource +from inkbox.phone.resources.webhooks import PhoneWebhooksResource from inkbox.signing_keys import SigningKeysResource -class TestInkboxPhoneClient: - def test_creates_resource_instances(self): - client = InkboxPhone(api_key="sk-test") +class TestInkboxPhoneResources: + def test_creates_phone_resource_instances(self): + client = Inkbox(api_key="sk-test") assert isinstance(client.numbers, PhoneNumbersResource) assert isinstance(client.calls, CallsResource) assert isinstance(client.transcripts, TranscriptsResource) + assert isinstance(client.phone_webhooks, PhoneWebhooksResource) assert isinstance(client.signing_keys, SigningKeysResource) - def test_context_manager(self): - with InkboxPhone(api_key="sk-test") as client: - assert isinstance(client, InkboxPhone) + client.close() - def test_custom_base_url(self): - client = InkboxPhone(api_key="sk-test", base_url="http://localhost:8000") - assert client._http._client.base_url == "http://localhost:8000" + def test_phone_http_base_url(self): + client = Inkbox(api_key="sk-test", base_url="http://localhost:8000") + assert str(client._phone_http._client.base_url) == "http://localhost:8000/api/v1/phone/" client.close() diff --git a/python/tests/test_identities_client.py b/python/tests/test_identities_client.py index ef0d53a..820ff08 100644 --- a/python/tests/test_identities_client.py +++ b/python/tests/test_identities_client.py @@ -1,20 +1,20 @@ -"""Tests for InkboxIdentities client.""" +"""Tests for Inkbox unified client — identities namespace.""" -from inkbox.identities.client import InkboxIdentities +from inkbox import Inkbox +from inkbox.client import _IdentitiesNamespace from inkbox.identities.resources.identities import IdentitiesResource -class TestInkboxIdentitiesClient: - def test_creates_resource_instances(self): - client = InkboxIdentities(api_key="sk-test") +class TestInkboxIdentitiesResources: + def test_creates_identities_namespace(self): + client = Inkbox(api_key="sk-test") - assert isinstance(client.identities, IdentitiesResource) + assert isinstance(client.identities, _IdentitiesNamespace) + assert isinstance(client._ids_resource, IdentitiesResource) - def test_context_manager(self): - with InkboxIdentities(api_key="sk-test") as client: - assert isinstance(client, InkboxIdentities) + client.close() - def test_custom_base_url(self): - client = InkboxIdentities(api_key="sk-test", base_url="http://localhost:8000") - assert client._http._client.base_url == "http://localhost:8000" + def test_ids_http_base_url(self): + client = Inkbox(api_key="sk-test", base_url="http://localhost:8000") + assert str(client._ids_http._client.base_url) == "http://localhost:8000/api/v1/identities/" client.close() diff --git a/python/tests/test_mail_client.py b/python/tests/test_mail_client.py index 557cf54..e6c07c9 100644 --- a/python/tests/test_mail_client.py +++ b/python/tests/test_mail_client.py @@ -1,27 +1,30 @@ -"""Tests for InkboxMail client.""" +"""Tests for Inkbox unified client — mail resources.""" -from inkbox.mail import InkboxMail +from inkbox import Inkbox from inkbox.mail.resources.mailboxes import MailboxesResource from inkbox.mail.resources.messages import MessagesResource from inkbox.mail.resources.threads import ThreadsResource +from inkbox.mail.resources.webhooks import WebhooksResource from inkbox.signing_keys import SigningKeysResource -class TestInkboxMailClient: - def test_creates_resource_instances(self): - client = InkboxMail(api_key="sk-test") +class TestInkboxMailResources: + def test_creates_mail_resource_instances(self): + client = Inkbox(api_key="sk-test") assert isinstance(client.mailboxes, MailboxesResource) assert isinstance(client.messages, MessagesResource) assert isinstance(client.threads, ThreadsResource) + assert isinstance(client.mail_webhooks, WebhooksResource) assert isinstance(client.signing_keys, SigningKeysResource) + client.close() def test_context_manager(self): - with InkboxMail(api_key="sk-test") as client: - assert isinstance(client, InkboxMail) + with Inkbox(api_key="sk-test") as client: + assert isinstance(client, Inkbox) - def test_custom_base_url(self): - client = InkboxMail(api_key="sk-test", base_url="http://localhost:8000") - assert client._http._client.base_url == "http://localhost:8000" + def test_mail_http_base_url(self): + client = Inkbox(api_key="sk-test", base_url="http://localhost:8000") + assert str(client._mail_http._client.base_url) == "http://localhost:8000/api/v1/mail/" client.close() diff --git a/python/tests/test_webhooks.py b/python/tests/test_webhooks.py index 965c8d9..a9fcb84 100644 --- a/python/tests/test_webhooks.py +++ b/python/tests/test_webhooks.py @@ -13,7 +13,7 @@ class TestWebhooksCreate: def test_creates_webhook_with_secret(self, client, transport): transport.post.return_value = PHONE_WEBHOOK_CREATE_DICT - hook = client.webhooks.create( + hook = client.phone_webhooks.create( NUM_ID, url="https://example.com/webhooks/phone", event_types=["incoming_call"], @@ -36,7 +36,7 @@ class TestWebhooksList: def test_returns_webhooks(self, client, transport): transport.get.return_value = [PHONE_WEBHOOK_DICT] - webhooks = client.webhooks.list(NUM_ID) + webhooks = client.phone_webhooks.list(NUM_ID) transport.get.assert_called_once_with(f"/numbers/{NUM_ID}/webhooks") assert len(webhooks) == 1 @@ -46,7 +46,7 @@ def test_returns_webhooks(self, client, transport): def test_empty_list(self, client, transport): transport.get.return_value = [] - webhooks = client.webhooks.list(NUM_ID) + webhooks = client.phone_webhooks.list(NUM_ID) assert webhooks == [] @@ -56,7 +56,7 @@ def test_update_url(self, client, transport): updated = {**PHONE_WEBHOOK_DICT, "url": "https://new.example.com/hook"} transport.patch.return_value = updated - result = client.webhooks.update( + result = client.phone_webhooks.update( NUM_ID, WH_ID, url="https://new.example.com/hook" ) @@ -70,7 +70,7 @@ def test_update_event_types(self, client, transport): updated = {**PHONE_WEBHOOK_DICT, "event_types": ["incoming_call", "message.received"]} transport.patch.return_value = updated - result = client.webhooks.update( + result = client.phone_webhooks.update( NUM_ID, WH_ID, event_types=["incoming_call", "message.received"] ) @@ -81,7 +81,7 @@ def test_update_event_types(self, client, transport): def test_omitted_fields_not_sent(self, client, transport): transport.patch.return_value = PHONE_WEBHOOK_DICT - client.webhooks.update(NUM_ID, WH_ID, url="https://example.com/hook") + client.phone_webhooks.update(NUM_ID, WH_ID, url="https://example.com/hook") _, kwargs = transport.patch.call_args assert "event_types" not in kwargs["json"] @@ -89,7 +89,7 @@ def test_omitted_fields_not_sent(self, client, transport): class TestWebhooksDelete: def test_deletes_webhook(self, client, transport): - client.webhooks.delete(NUM_ID, WH_ID) + client.phone_webhooks.delete(NUM_ID, WH_ID) transport.delete.assert_called_once_with( f"/numbers/{NUM_ID}/webhooks/{WH_ID}" From 279c72d7237024b2c5fbe9a0fe9c0a024f0bed57 Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:50:56 -0400 Subject: [PATCH 27/56] update readme --- README.md | 73 +++++------ python/README.md | 312 +++++++++++++++++++++-------------------------- 2 files changed, 171 insertions(+), 214 deletions(-) diff --git a/README.md b/README.md index b9c7f90..78531aa 100644 --- a/README.md +++ b/README.md @@ -16,29 +16,24 @@ Agent identities are the central concept — a named agent (e.g. `"sales-agent"` ### Python ```python -from inkbox.identities import InkboxIdentities +from inkbox import Inkbox -with InkboxIdentities(api_key="ApiKey_...") as client: +with Inkbox(api_key="ApiKey_...") as inkbox: + # Create an identity — returns an Agent object + agent = inkbox.identities.create(agent_handle="sales-agent") - # Create an identity - identity = client.identities.create(agent_handle="sales-agent") + # Provision and link channels in one call each + agent.assign_mailbox(display_name="Sales Agent") + agent.assign_phone_number(type="toll_free") - # Assign channels (mailbox / phone number must already exist) - detail = client.identities.assign_mailbox( - "sales-agent", mailbox_id="" - ) - detail = client.identities.assign_phone_number( - "sales-agent", phone_number_id="" - ) - - print(detail.mailbox.email_address) - print(detail.phone_number.number) + print(agent.mailbox.email_address) + print(agent.phone_number.number) # List, get, update, delete - identities = client.identities.list() - detail = client.identities.get("sales-agent") - client.identities.update("sales-agent", status="paused") - client.identities.delete("sales-agent") + identities = inkbox.identities.list() + agent = inkbox.identities.get("sales-agent") + inkbox.identities.update("sales-agent", status="paused") + agent.delete() ``` ### TypeScript @@ -71,15 +66,14 @@ await client.identities.delete("sales-agent"); ### Python ```python -from inkbox.mail import InkboxMail - -with InkboxMail(api_key="ApiKey_...") as client: +from inkbox import Inkbox +with Inkbox(api_key="ApiKey_...") as inkbox: # Create a mailbox - mailbox = client.mailboxes.create(agent_handle="sales-agent", display_name="Sales Agent") + mailbox = inkbox.mailboxes.create(display_name="Sales Agent") # Send an email - client.messages.send( + inkbox.messages.send( mailbox.email_address, to=["user@example.com"], subject="Hello from Inkbox", @@ -87,12 +81,12 @@ with InkboxMail(api_key="ApiKey_...") as client: ) # Iterate over all messages (pagination handled automatically) - for msg in client.messages.list(mailbox.email_address): + for msg in inkbox.messages.list(mailbox.email_address): print(msg.subject, msg.from_address) # Reply to a message - detail = client.messages.get(mailbox.email_address, msg.id) - client.messages.send( + detail = inkbox.messages.get(mailbox.email_address, msg.id) + inkbox.messages.send( mailbox.email_address, to=detail.to_addresses, subject=f"Re: {detail.subject}", @@ -101,13 +95,13 @@ with InkboxMail(api_key="ApiKey_...") as client: ) # Update mailbox display name - client.mailboxes.update(mailbox.email_address, display_name="Support Agent") + inkbox.mailboxes.update(mailbox.email_address, display_name="Support Agent") # Search - results = client.mailboxes.search(mailbox.email_address, q="invoice") + results = inkbox.mailboxes.search(mailbox.email_address, q="invoice") # Webhooks (secret is one-time — save it immediately) - hook = client.webhooks.create( + hook = inkbox.mail_webhooks.create( mailbox.email_address, url="https://yourapp.com/hooks/mail", event_types=["message.received"], @@ -161,22 +155,21 @@ console.log(hook.secret); // save this ### Python ```python -from inkbox.phone import InkboxPhone - -with InkboxPhone(api_key="ApiKey_...") as client: +from inkbox import Inkbox +with Inkbox(api_key="ApiKey_...") as inkbox: # Provision a phone number - number = client.numbers.provision(type="toll_free") + number = inkbox.numbers.provision(type="toll_free") # Update settings - client.numbers.update( + inkbox.numbers.update( number.id, incoming_call_action="auto_accept", default_stream_url="wss://your-agent.example.com/ws", ) # Place an outbound call - call = client.calls.place( + call = inkbox.calls.place( from_number=number.number, to_number="+15167251294", stream_url="wss://your-agent.example.com/ws", @@ -185,14 +178,14 @@ with InkboxPhone(api_key="ApiKey_...") as client: print(call.rate_limit.calls_remaining) # List calls and transcripts - calls = client.calls.list(number.id) - transcripts = client.transcripts.list(number.id, calls[0].id) + calls = inkbox.calls.list(number.id) + transcripts = inkbox.transcripts.list(number.id, calls[0].id) # Search transcripts - results = client.numbers.search_transcripts(number.id, q="appointment") + results = inkbox.numbers.search_transcripts(number.id, q="appointment") # Webhooks - hook = client.webhooks.create( + hook = inkbox.phone_webhooks.create( number.id, url="https://yourapp.com/hooks/phone", event_types=["call.completed"], @@ -200,7 +193,7 @@ with InkboxPhone(api_key="ApiKey_...") as client: print(hook.secret) # save this # Release a number - client.numbers.release(number=number.number) + inkbox.numbers.release(number=number.number) ``` ### TypeScript diff --git a/python/README.md b/python/README.md index fbe635e..291f365 100644 --- a/python/README.md +++ b/python/README.md @@ -10,18 +10,42 @@ pip install inkbox Requires Python ≥ 3.11. -## Authentication - -Pass your API key when constructing a client, or load it from an environment variable: +## Quick start ```python import os -from inkbox.mail import InkboxMail +from inkbox import Inkbox + +with Inkbox(api_key=os.environ["INKBOX_API_KEY"]) as inkbox: + # Create an agent identity + agent = inkbox.identities.create(agent_handle="support-bot") + + # Provision and link channels in one call each + agent.assign_mailbox(display_name="Support Bot") + agent.assign_phone_number(type="toll_free") -client = InkboxMail(api_key=os.environ["INKBOX_API_KEY"]) + # Send email directly from the agent + agent.send_email( + to=["customer@example.com"], + subject="Your order has shipped", + body_text="Tracking number: 1Z999AA10123456784", + ) + + # Place an outbound call + agent.place_call( + to_number="+18005559999", + stream_url="wss://my-app.com/voice", + ) + + # Read inbox + for message in agent.messages(): + print(message.subject) + + # Search transcripts + transcripts = agent.search_transcripts(q="refund") ``` -All three clients (`InkboxMail`, `InkboxPhone`, `InkboxIdentities`) accept the same constructor arguments: +## Authentication | Argument | Type | Default | Description | |---|---|---|---| @@ -29,145 +53,100 @@ All three clients (`InkboxMail`, `InkboxPhone`, `InkboxIdentities`) accept the s | `base_url` | `str` | API default | Override for self-hosting or testing | | `timeout` | `float` | `30.0` | Request timeout in seconds | -## Context manager - -Using clients as context managers is recommended — it ensures HTTP connections are closed cleanly: - -```python -from inkbox.mail import InkboxMail - -with InkboxMail(api_key="ApiKey_...") as client: - mailboxes = client.mailboxes.list() -``` - -You can also call `client.close()` manually when not using `with`. +Use `with Inkbox(...) as inkbox:` (recommended) or call `inkbox.close()` manually to clean up HTTP connections. --- -## Identities +## Identities & Agent object -Agent identities are the central concept — a named agent (e.g. `"sales-agent"`) that owns a mailbox and/or phone number. +`inkbox.identities.create()` and `inkbox.identities.get()` return an `Agent` object that holds the agent's channels and exposes convenience methods scoped to those channels. ```python -from inkbox.identities import InkboxIdentities - -with InkboxIdentities(api_key="ApiKey_...") as client: - - # Create an identity - identity = client.identities.create(agent_handle="sales-agent") - print(f"Registered: {identity.agent_handle} id={identity.id}") - - # Assign communication channels (mailbox / phone number must already exist) - with_mailbox = client.identities.assign_mailbox( - "sales-agent", mailbox_id="" - ) - print(with_mailbox.mailbox.email_address) - - with_phone = client.identities.assign_phone_number( - "sales-agent", phone_number_id="" - ) - print(with_phone.phone_number.number) - - # List all identities - all_identities = client.identities.list() - for ident in all_identities: - print(ident.agent_handle, ident.status) - - # Get, update, delete - detail = client.identities.get("sales-agent") - client.identities.update("sales-agent", status="paused") - client.identities.delete("sales-agent") +# Create and fully provision an agent +agent = inkbox.identities.create(agent_handle="sales-bot") +mailbox = agent.assign_mailbox(display_name="Sales Bot") # creates + links +phone = agent.assign_phone_number(type="toll_free") # provisions + links + +print(mailbox.email_address) +print(phone.number) + +# Get an existing agent +agent = inkbox.identities.get("sales-bot") +agent.refresh() # re-fetch channels from API + +# List / update / delete +all_identities = inkbox.identities.list() +inkbox.identities.update("sales-bot", status="paused") +agent.delete() ``` --- ## Mail -### Mailboxes +### Sending email ```python -from inkbox.mail import InkboxMail - -with InkboxMail(api_key="ApiKey_...") as client: - - # Create a mailbox (agent identity must already exist) - mailbox = client.mailboxes.create(agent_handle="sales-agent", display_name="Sales Agent") - print(mailbox.email_address) - - # List all mailboxes - all_mailboxes = client.mailboxes.list() - for m in all_mailboxes: - print(m.email_address, m.status) - - # Update display name - updated = client.mailboxes.update(mailbox.email_address, display_name="Sales Agent (updated)") - - # Full-text search across messages - results = client.mailboxes.search(mailbox.email_address, q="invoice") - - # Delete - client.mailboxes.delete(mailbox.email_address) +# Via agent (no email address needed) +agent.send_email( + to=["user@example.com"], + subject="Hello", + body_text="Hi there!", + body_html="

Hi there!

", +) + +# Via flat namespace (useful when you have a mailbox address directly) +inkbox.messages.send( + "agent@inkboxmail.com", + to=["user@example.com"], + subject="Hello", + body_text="Hi there!", +) ``` -### Sending email +### Reading messages and threads ```python - # Send an outbound email - sent = client.messages.send( - mailbox.email_address, - to=["recipient@example.com"], - subject="Hello from your AI agent", - body_text="Hi there!", - body_html="

Hi there!

", - ) +# Via agent — iterates inbox automatically (paginated) +for msg in agent.messages(): + print(msg.subject, msg.from_address) - # Reply in a thread (pass the original message's RFC Message-ID) - reply = client.messages.send( - mailbox.email_address, - to=["recipient@example.com"], - subject=f"Re: {sent.subject}", - body_text="Following up.", - in_reply_to_message_id=str(sent.id), - ) +# Full message body +detail = inkbox.messages.get(mailbox.email_address, msg.id) +print(detail.body_text) + +# Threads +for thread in inkbox.threads.list(mailbox.email_address): + print(thread.subject, thread.message_count) + +thread_detail = inkbox.threads.get(mailbox.email_address, thread.id) +for msg in thread_detail.messages: + print(f"[{msg.direction}] {msg.from_address}: {msg.snippet}") ``` -### Reading messages and threads +### Mailboxes ```python - # Paginate through all messages (pagination handled automatically) - for msg in client.messages.list(mailbox.email_address): - print(msg.subject, msg.from_address, msg.direction) - - # Fetch full message body - detail = client.messages.get(mailbox.email_address, msg.id) - print(detail.body_text) - - # List threads - for thread in client.threads.list(mailbox.email_address): - print(thread.subject, thread.message_count) - - # Fetch full thread with all messages - thread_detail = client.threads.get(mailbox.email_address, thread.id) - for msg in thread_detail.messages: - print(f"[{msg.direction}] {msg.from_address}: {msg.snippet}") +mailbox = inkbox.mailboxes.create(display_name="Sales Agent") +all_mailboxes = inkbox.mailboxes.list() +inkbox.mailboxes.update(mailbox.email_address, display_name="Sales Agent v2") +results = inkbox.mailboxes.search(mailbox.email_address, q="invoice") +inkbox.mailboxes.delete(mailbox.email_address) ``` ### Webhooks ```python - # Register a webhook (secret is one-time — save it immediately) - hook = client.webhooks.create( - mailbox.email_address, - url="https://yourapp.com/hooks/mail", - event_types=["message.received", "message.sent"], - ) - print(hook.secret) # save this — it will not be shown again - - # List active webhooks - hooks = client.webhooks.list(mailbox.email_address) - - # Delete a webhook - client.webhooks.delete(mailbox.email_address, hook.id) +# Secret is one-time — save it immediately +hook = inkbox.mail_webhooks.create( + mailbox.email_address, + url="https://yourapp.com/hooks/mail", + event_types=["message.received", "message.sent"], +) +print(hook.secret) + +hooks = inkbox.mail_webhooks.list(mailbox.email_address) +inkbox.mail_webhooks.delete(mailbox.email_address, hook.id) ``` --- @@ -177,77 +156,62 @@ with InkboxMail(api_key="ApiKey_...") as client: ### Provisioning numbers ```python -from inkbox.phone import InkboxPhone - -with InkboxPhone(api_key="ApiKey_...") as client: - - # Provision a toll-free number - number = client.numbers.provision(type="toll_free") - print(number.number, number.status) - - # Provision a local number in a specific state - number = client.numbers.provision(type="local", state="NY") - - # List all numbers - all_numbers = client.numbers.list() - for n in all_numbers: - print(n.number, n.type, n.status) - - # Update settings - updated = client.numbers.update( - number.id, - incoming_call_action="auto_accept", - default_stream_url="wss://your-agent.example.com/ws", - ) - - # Release a number - client.numbers.release(number=number.number) +number = inkbox.numbers.provision(type="toll_free") +number = inkbox.numbers.provision(type="local", state="NY") + +all_numbers = inkbox.numbers.list() +inkbox.numbers.update( + number.id, + incoming_call_action="auto_accept", + default_stream_url="wss://your-agent.example.com/ws", +) +inkbox.numbers.release(number=number.number) ``` ### Placing calls ```python - # Place an outbound call - call = client.calls.place( - from_number=number.number, - to_number="+15167251294", - stream_url="wss://your-agent.example.com/ws", - ) - print(call.status) - print(call.rate_limit.calls_remaining) +# Via agent (from_number is automatic) +call = agent.place_call( + to_number="+15167251294", + stream_url="wss://your-agent.example.com/ws", +) + +# Via flat namespace +call = inkbox.calls.place( + from_number=number.number, + to_number="+15167251294", + stream_url="wss://your-agent.example.com/ws", +) +print(call.status, call.rate_limit.calls_remaining) ``` ### Reading calls and transcripts ```python - # List recent calls for a number - calls = client.calls.list(number.id, limit=10) - for call in calls: - print(call.id, call.direction, call.remote_phone_number, call.status) - - # Read transcript for a call - transcripts = client.transcripts.list(number.id, call.id) - for t in transcripts: - print(f"[{t.party}] {t.text}") - - # Full-text search across all transcripts for a number - results = client.numbers.search_transcripts(number.id, q="appointment") +calls = inkbox.calls.list(number.id, limit=10) +transcripts = inkbox.transcripts.list(number.id, calls[0].id) + +# Full-text search via agent +results = agent.search_transcripts(q="appointment") + +# Or flat namespace +results = inkbox.numbers.search_transcripts(number.id, q="appointment") ``` ### Webhooks ```python - # Register a webhook (secret is one-time — save it immediately) - hook = client.webhooks.create( - number.id, - url="https://yourapp.com/hooks/phone", - event_types=["call.completed"], - ) - print(hook.secret) # save this — it will not be shown again - - # List and delete - hooks = client.webhooks.list(number.id) - client.webhooks.delete(number.id, hook.id) +hook = inkbox.phone_webhooks.create( + number.id, + url="https://yourapp.com/hooks/phone", + event_types=["call.completed"], +) +print(hook.secret) + +hooks = inkbox.phone_webhooks.list(number.id) +inkbox.phone_webhooks.update(number.id, hook.id, url="https://yourapp.com/hooks/phone-v2") +inkbox.phone_webhooks.delete(number.id, hook.id) ``` --- @@ -258,7 +222,7 @@ Runnable example scripts are available in the [examples/python](https://github.c | Script | What it demonstrates | |---|---| -| `register_agent_identity.py` | Create an identity and assign mailbox / phone number | +| `register_agent_identity.py` | Create an identity, assign mailbox + phone number via Agent | | `create_agent_mailbox.py` | Create, update, search, and delete a mailbox | | `agent_send_email.py` | Send an email and a threaded reply | | `read_agent_messages.py` | List messages and read full threads | From 3ca58156b751e025474b411816a6987b753a85c0 Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Thu, 12 Mar 2026 19:09:15 -0400 Subject: [PATCH 28/56] unified ts sdk --- README.md | 92 ++++---- typescript/README.md | 249 ++++++++++------------ typescript/src/index.ts | 3 + typescript/src/phone/client.ts | 3 + typescript/src/phone/resources/calls.ts | 13 +- typescript/src/phone/resources/numbers.ts | 49 ++--- typescript/src/resources/mailboxes.ts | 8 +- 7 files changed, 188 insertions(+), 229 deletions(-) diff --git a/README.md b/README.md index 78531aa..dc2f480 100644 --- a/README.md +++ b/README.md @@ -23,11 +23,11 @@ with Inkbox(api_key="ApiKey_...") as inkbox: agent = inkbox.identities.create(agent_handle="sales-agent") # Provision and link channels in one call each - agent.assign_mailbox(display_name="Sales Agent") - agent.assign_phone_number(type="toll_free") + mailbox = agent.assign_mailbox(display_name="Sales Agent") + phone = agent.assign_phone_number(type="toll_free") - print(agent.mailbox.email_address) - print(agent.phone_number.number) + print(mailbox.email_address) + print(phone.number) # List, get, update, delete identities = inkbox.identities.list() @@ -39,24 +39,25 @@ with Inkbox(api_key="ApiKey_...") as inkbox: ### TypeScript ```ts -import { InkboxIdentities } from "@inkbox/sdk/identities"; +import { Inkbox } from "@inkbox/sdk"; -const client = new InkboxIdentities({ apiKey: "ApiKey_..." }); +const inkbox = new Inkbox({ apiKey: "ApiKey_..." }); -// Create an identity -const identity = await client.identities.create({ agentHandle: "sales-agent" }); +// Create an identity — returns an Agent object +const agent = await inkbox.identities.create({ agentHandle: "sales-agent" }); -// Assign channels -const detail = await client.identities.assignMailbox("sales-agent", { - mailboxId: "", -}); -console.log(detail.mailbox?.emailAddress); +// Provision and link channels in one call each +const mailbox = await agent.assignMailbox({ displayName: "Sales Agent" }); +const phone = await agent.assignPhoneNumber({ type: "toll_free" }); + +console.log(mailbox.emailAddress); +console.log(phone.number); // List, get, update, delete -const identities = await client.identities.list(); -const d = await client.identities.get("sales-agent"); -await client.identities.update("sales-agent", { status: "paused" }); -await client.identities.delete("sales-agent"); +const identities = await inkbox.identities.list(); +const a = await inkbox.identities.get("sales-agent"); +await inkbox.identities.update("sales-agent", { status: "paused" }); +await a.delete(); ``` --- @@ -112,36 +113,42 @@ with Inkbox(api_key="ApiKey_...") as inkbox: ### TypeScript ```ts -import { InkboxMail } from "@inkbox/sdk"; +import { Inkbox } from "@inkbox/sdk"; -const client = new InkboxMail({ apiKey: "ApiKey_..." }); +const inkbox = new Inkbox({ apiKey: "ApiKey_..." }); -// Create a mailbox (agent identity must already exist) -const mailbox = await client.mailboxes.create({ - agentHandle: "sales-agent", - displayName: "Sales Agent", -}); +// Create a mailbox +const mailbox = await inkbox.mailboxes.create({ displayName: "Sales Agent" }); // Send an email -await client.messages.send(mailbox.emailAddress, { +await inkbox.messages.send(mailbox.emailAddress, { to: ["user@example.com"], subject: "Hello from Inkbox", bodyText: "Hi there!", }); // Iterate over all messages (pagination handled automatically) -for await (const msg of client.messages.list(mailbox.emailAddress)) { +for await (const msg of inkbox.messages.list(mailbox.emailAddress)) { console.log(msg.subject, msg.fromAddress); } +// Reply to a message +const detail = await inkbox.messages.get(mailbox.emailAddress, msg.id); +await inkbox.messages.send(mailbox.emailAddress, { + to: detail.toAddresses, + subject: `Re: ${detail.subject}`, + bodyText: "Got it, thanks!", + inReplyToMessageId: detail.messageId, +}); + // Update mailbox display name -await client.mailboxes.update(mailbox.emailAddress, { displayName: "Support Agent" }); +await inkbox.mailboxes.update(mailbox.emailAddress, { displayName: "Support Agent" }); // Search -const results = await client.mailboxes.search(mailbox.emailAddress, { q: "invoice" }); +const results = await inkbox.mailboxes.search(mailbox.emailAddress, { q: "invoice" }); // Webhooks (secret is one-time — save it immediately) -const hook = await client.webhooks.create(mailbox.emailAddress, { +const hook = await inkbox.mailWebhooks.create(mailbox.emailAddress, { url: "https://yourapp.com/hooks/mail", eventTypes: ["message.received"], }); @@ -199,47 +206,44 @@ with Inkbox(api_key="ApiKey_...") as inkbox: ### TypeScript ```ts -import { InkboxPhone } from "@inkbox/sdk/phone"; +import { Inkbox } from "@inkbox/sdk"; -const client = new InkboxPhone({ apiKey: "ApiKey_..." }); +const inkbox = new Inkbox({ apiKey: "ApiKey_..." }); // Provision a phone number -const number = await client.numbers.provision({ - agentHandle: "sales-agent", - type: "toll_free", -}); +const number = await inkbox.numbers.provision({ type: "toll_free" }); // Update settings -await client.numbers.update(number.id, { +await inkbox.numbers.update(number.id, { incomingCallAction: "auto_accept", - clientWebsocketUrl: "wss://your-agent.example.com/ws", + defaultStreamUrl: "wss://your-agent.example.com/ws", }); // Place an outbound call -const call = await client.calls.place({ +const call = await inkbox.calls.place({ fromNumber: number.number, toNumber: "+15167251294", - clientWebsocketUrl: "wss://your-agent.example.com/ws", + streamUrl: "wss://your-agent.example.com/ws", }); console.log(call.status); console.log(call.rateLimit.callsRemaining); // List calls and transcripts -const calls = await client.calls.list(number.id); -const transcripts = await client.transcripts.list(number.id, calls[0].id); +const calls = await inkbox.calls.list(number.id); +const transcripts = await inkbox.transcripts.list(number.id, calls[0].id); // Search transcripts -const results = await client.numbers.searchTranscripts(number.id, { q: "appointment" }); +const results = await inkbox.numbers.searchTranscripts(number.id, { q: "appointment" }); // Webhooks -const hook = await client.webhooks.create(number.id, { +const hook = await inkbox.phoneWebhooks.create(number.id, { url: "https://yourapp.com/hooks/phone", eventTypes: ["call.completed"], }); console.log(hook.secret); // save this // Release a number -await client.numbers.release(number.id); +await inkbox.numbers.release({ number: number.number }); ``` --- diff --git a/typescript/README.md b/typescript/README.md index 44254d8..d31b80e 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -10,17 +10,43 @@ npm install @inkbox/sdk Requires Node.js ≥ 18. -## Authentication - -Pass your API key when constructing a client, or load it from an environment variable: +## Quick start ```ts -import { InkboxMail } from "@inkbox/sdk"; +import { Inkbox } from "@inkbox/sdk"; + +const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); + +// Create an agent identity +const agent = await inkbox.identities.create({ agentHandle: "support-bot" }); + +// Provision and link channels in one call each +const mailbox = await agent.assignMailbox({ displayName: "Support Bot" }); +const phone = await agent.assignPhoneNumber({ type: "toll_free" }); + +// Send email directly from the agent +await agent.sendEmail({ + to: ["customer@example.com"], + subject: "Your order has shipped", + bodyText: "Tracking number: 1Z999AA10123456784", +}); -const client = new InkboxMail({ apiKey: process.env.INKBOX_API_KEY! }); +// Place an outbound call +await agent.placeCall({ + toNumber: "+18005559999", + streamUrl: "wss://my-app.com/voice", +}); + +// Read inbox +for await (const message of agent.messages()) { + console.log(message.subject); +} + +// Search transcripts +const transcripts = await agent.searchTranscripts({ q: "refund" }); ``` -All three clients accept the same options: +## Authentication | Option | Type | Default | Description | |---|---|---|---| @@ -30,221 +56,160 @@ All three clients accept the same options: --- -## Identities +## Identities & Agent object -Agent identities are the central concept — a named agent (e.g. `"sales-agent"`) that owns a mailbox and/or phone number. +`inkbox.identities.create()` and `inkbox.identities.get()` return an `Agent` object that holds the agent's channels and exposes convenience methods scoped to those channels. ```ts -import { InkboxIdentities } from "@inkbox/sdk/identities"; - -const client = new InkboxIdentities({ apiKey: "ApiKey_..." }); +// Create and fully provision an agent +const agent = await inkbox.identities.create({ agentHandle: "sales-bot" }); +const mailbox = await agent.assignMailbox({ displayName: "Sales Bot" }); // creates + links +const phone = await agent.assignPhoneNumber({ type: "toll_free" }); // provisions + links -// Create an identity -const identity = await client.identities.create({ agentHandle: "sales-agent" }); -console.log(identity.agentHandle, identity.id); - -// Assign communication channels (mailbox / phone number must already exist) -const withMailbox = await client.identities.assignMailbox("sales-agent", { - mailboxId: "", -}); -console.log(withMailbox.mailbox?.emailAddress); - -const withPhone = await client.identities.assignPhoneNumber("sales-agent", { - phoneNumberId: "", -}); -console.log(withPhone.phoneNumber?.number); +console.log(mailbox.emailAddress); +console.log(phone.number); -// List all identities -const all = await client.identities.list(); -for (const ident of all) { - console.log(ident.agentHandle, ident.status); -} +// Get an existing agent +const agent2 = await inkbox.identities.get("sales-bot"); +await agent2.refresh(); // re-fetch channels from API -// Get, update, delete -const detail = await client.identities.get("sales-agent"); -await client.identities.update("sales-agent", { status: "paused" }); -await client.identities.delete("sales-agent"); +// List / update / delete +const allIdentities = await inkbox.identities.list(); +await inkbox.identities.update("sales-bot", { status: "paused" }); +await agent.delete(); ``` --- ## Mail -```ts -import { InkboxMail } from "@inkbox/sdk"; -``` - -### Mailboxes - -```ts -// Create a mailbox (agent identity must already exist) -const mailbox = await client.mailboxes.create({ - agentHandle: "sales-agent", - displayName: "Sales Agent", -}); -console.log(mailbox.emailAddress); - -// List all mailboxes -const all = await client.mailboxes.list(); -for (const m of all) { - console.log(m.emailAddress, m.status); -} - -// Update display name -await client.mailboxes.update(mailbox.emailAddress, { displayName: "Sales Agent (updated)" }); - -// Full-text search across messages -const results = await client.mailboxes.search(mailbox.emailAddress, { q: "invoice" }); - -// Delete -await client.mailboxes.delete(mailbox.emailAddress); -``` - ### Sending email ```ts -// Send an outbound email -const sent = await client.messages.send(mailbox.emailAddress, { - to: ["recipient@example.com"], - subject: "Hello from your AI agent", +// Via agent (no email address needed) +await agent.sendEmail({ + to: ["user@example.com"], + subject: "Hello", bodyText: "Hi there!", bodyHtml: "

Hi there!

", }); -// Reply in a thread (pass the original message's RFC Message-ID) -const reply = await client.messages.send(mailbox.emailAddress, { - to: ["recipient@example.com"], - subject: `Re: ${sent.subject}`, - bodyText: "Following up.", - inReplyToMessageId: sent.messageId, +// Via flat namespace (useful when you have a mailbox address directly) +await inkbox.messages.send("agent@inkboxmail.com", { + to: ["user@example.com"], + subject: "Hello", + bodyText: "Hi there!", }); ``` ### Reading messages and threads ```ts -// Iterate over all messages (pagination handled automatically) -for await (const msg of client.messages.list(mailbox.emailAddress)) { - console.log(msg.subject, msg.fromAddress, msg.direction); +// Via agent — iterates inbox automatically (paginated) +for await (const msg of agent.messages()) { + console.log(msg.subject, msg.fromAddress); } -// Fetch full message body -const detail = await client.messages.get(mailbox.emailAddress, msg.id); +// Full message body +const detail = await inkbox.messages.get(mailbox.emailAddress, msg.id); console.log(detail.bodyText); -// List threads -for await (const thread of client.threads.list(mailbox.emailAddress)) { +// Threads +for await (const thread of inkbox.threads.list(mailbox.emailAddress)) { console.log(thread.subject, thread.messageCount); } -// Fetch full thread with all messages -const threadDetail = await client.threads.get(mailbox.emailAddress, thread.id); +const threadDetail = await inkbox.threads.get(mailbox.emailAddress, thread.id); for (const msg of threadDetail.messages) { console.log(`[${msg.direction}] ${msg.fromAddress}: ${msg.snippet}`); } ``` +### Mailboxes + +```ts +const mailbox = await inkbox.mailboxes.create({ displayName: "Sales Agent" }); +const allMailboxes = await inkbox.mailboxes.list(); +await inkbox.mailboxes.update(mailbox.emailAddress, { displayName: "Sales Agent v2" }); +const results = await inkbox.mailboxes.search(mailbox.emailAddress, { q: "invoice" }); +await inkbox.mailboxes.delete(mailbox.emailAddress); +``` + ### Webhooks ```ts -// Register a webhook (secret is one-time — save it immediately) -const hook = await client.webhooks.create(mailbox.emailAddress, { +// Secret is one-time — save it immediately +const hook = await inkbox.mailWebhooks.create(mailbox.emailAddress, { url: "https://yourapp.com/hooks/mail", eventTypes: ["message.received", "message.sent"], }); -console.log(hook.secret); // save this — it will not be shown again +console.log(hook.secret); -// List and delete -const hooks = await client.webhooks.list(mailbox.emailAddress); -await client.webhooks.delete(mailbox.emailAddress, hook.id); +const hooks = await inkbox.mailWebhooks.list(mailbox.emailAddress); +await inkbox.mailWebhooks.delete(mailbox.emailAddress, hook.id); ``` --- ## Phone -```ts -import { InkboxPhone } from "@inkbox/sdk/phone"; -``` - ### Provisioning numbers ```ts -// Provision a toll-free number -const number = await client.numbers.provision({ - agentHandle: "sales-agent", - type: "toll_free", -}); -console.log(number.number, number.status); - -// Provision a local number in a specific state -const local = await client.numbers.provision({ - agentHandle: "sales-agent", - type: "local", - state: "NY", -}); - -// List all numbers -const all = await client.numbers.list(); -for (const n of all) { - console.log(n.number, n.type, n.status); -} +const number = await inkbox.numbers.provision({ type: "toll_free" }); +const local = await inkbox.numbers.provision({ type: "local", state: "NY" }); -// Update settings -await client.numbers.update(number.id, { +const allNumbers = await inkbox.numbers.list(); +await inkbox.numbers.update(number.id, { incomingCallAction: "auto_accept", - clientWebsocketUrl: "wss://your-agent.example.com/ws", + defaultStreamUrl: "wss://your-agent.example.com/ws", }); - -// Release a number -await client.numbers.release(number.id); +await inkbox.numbers.release({ number: number.number }); ``` ### Placing calls ```ts -// Place an outbound call -const call = await client.calls.place({ +// Via agent (fromNumber is automatic) +const call = await agent.placeCall({ + toNumber: "+15167251294", + streamUrl: "wss://your-agent.example.com/ws", +}); + +// Via flat namespace +const call2 = await inkbox.calls.place({ fromNumber: number.number, toNumber: "+15167251294", - clientWebsocketUrl: "wss://your-agent.example.com/ws", + streamUrl: "wss://your-agent.example.com/ws", }); -console.log(call.status); -console.log(call.rateLimit.callsRemaining); +console.log(call2.status, call2.rateLimit.callsRemaining); ``` ### Reading calls and transcripts ```ts -// List recent calls for a number -const calls = await client.calls.list(number.id, { limit: 10 }); -for (const call of calls) { - console.log(call.id, call.direction, call.remotePhoneNumber, call.status); -} +const calls = await inkbox.calls.list(number.id, { limit: 10 }); +const transcripts = await inkbox.transcripts.list(number.id, calls[0].id); -// Read transcript for a call -const transcripts = await client.transcripts.list(number.id, call.id); -for (const t of transcripts) { - console.log(`[${t.party}] ${t.text}`); -} +// Full-text search via agent +const results = await agent.searchTranscripts({ q: "appointment" }); -// Full-text search across all transcripts for a number -const results = await client.numbers.searchTranscripts(number.id, { q: "appointment" }); +// Or flat namespace +const results2 = await inkbox.numbers.searchTranscripts(number.id, { q: "appointment" }); ``` ### Webhooks ```ts -// Register a webhook (secret is one-time — save it immediately) -const hook = await client.webhooks.create(number.id, { +const hook = await inkbox.phoneWebhooks.create(number.id, { url: "https://yourapp.com/hooks/phone", eventTypes: ["call.completed"], }); -console.log(hook.secret); // save this — it will not be shown again +console.log(hook.secret); -// List and delete -const hooks = await client.webhooks.list(number.id); -await client.webhooks.delete(number.id, hook.id); +const hooks = await inkbox.phoneWebhooks.list(number.id); +await inkbox.phoneWebhooks.update(number.id, hook.id, { url: "https://yourapp.com/hooks/phone-v2" }); +await inkbox.phoneWebhooks.delete(number.id, hook.id); ``` --- @@ -255,7 +220,7 @@ Runnable example scripts are available in the [examples/typescript](https://gith | Script | What it demonstrates | |---|---| -| `register-agent-identity.ts` | Create an identity and assign mailbox / phone number | +| `register-agent-identity.ts` | Create an identity, assign mailbox + phone number via Agent | | `create-agent-mailbox.ts` | Create, update, search, and delete a mailbox | | `agent-send-email.ts` | Send an email and a threaded reply | | `read-agent-messages.ts` | List messages and read full threads | diff --git a/typescript/src/index.ts b/typescript/src/index.ts index c387c5e..84b16de 100644 --- a/typescript/src/index.ts +++ b/typescript/src/index.ts @@ -1,3 +1,6 @@ +export { Inkbox } from "./inkbox.js"; +export { Agent } from "./agent.js"; +export type { InkboxOptions } from "./inkbox.js"; export { InkboxMail } from "./client.js"; export { InkboxAPIError } from "./_http.js"; export type { SigningKey } from "./resources/signing-keys.js"; diff --git a/typescript/src/phone/client.ts b/typescript/src/phone/client.ts index 65e40ea..db0702b 100644 --- a/typescript/src/phone/client.ts +++ b/typescript/src/phone/client.ts @@ -8,6 +8,7 @@ import { HttpTransport } from "../_http.js"; import { PhoneNumbersResource } from "./resources/numbers.js"; import { CallsResource } from "./resources/calls.js"; import { TranscriptsResource } from "./resources/transcripts.js"; +import { PhoneWebhooksResource } from "./resources/webhooks.js"; import { SigningKeysResource } from "../resources/signing-keys.js"; const DEFAULT_BASE_URL = "https://api.inkbox.ai/api/v1/phone"; @@ -45,6 +46,7 @@ export class InkboxPhone { readonly numbers: PhoneNumbersResource; readonly calls: CallsResource; readonly transcripts: TranscriptsResource; + readonly webhooks: PhoneWebhooksResource; readonly signingKeys: SigningKeysResource; private readonly http: HttpTransport; @@ -59,6 +61,7 @@ export class InkboxPhone { this.numbers = new PhoneNumbersResource(this.http); this.calls = new CallsResource(this.http); this.transcripts = new TranscriptsResource(this.http); + this.webhooks = new PhoneWebhooksResource(this.http); this.signingKeys = new SigningKeysResource(this.apiHttp); } } diff --git a/typescript/src/phone/resources/calls.ts b/typescript/src/phone/resources/calls.ts index 40ff86e..22599a6 100644 --- a/typescript/src/phone/resources/calls.ts +++ b/typescript/src/phone/resources/calls.ts @@ -53,22 +53,27 @@ export class CallsResource { * * @param options.fromNumber - E.164 number to call from. Must belong to your org and be active. * @param options.toNumber - E.164 number to call. - * @param options.clientWebsocketUrl - WebSocket URL (wss://) for audio bridging. Falls back to the phone number's `clientWebsocketUrl`. + * @param options.streamUrl - WebSocket URL (wss://) for audio bridging. Falls back to the phone number's default stream URL. + * @param options.pipelineMode - Pipeline mode override for this call. * @param options.webhookUrl - Custom webhook URL for call lifecycle events. * @returns The created call record with current rate limit info. */ async place(options: { fromNumber: string; toNumber: string; - clientWebsocketUrl?: string; + streamUrl?: string; + pipelineMode?: string; webhookUrl?: string; }): Promise { const body: Record = { from_number: options.fromNumber, to_number: options.toNumber, }; - if (options.clientWebsocketUrl !== undefined) { - body["client_websocket_url"] = options.clientWebsocketUrl; + if (options.streamUrl !== undefined) { + body["stream_url"] = options.streamUrl; + } + if (options.pipelineMode !== undefined) { + body["pipeline_mode"] = options.pipelineMode; } if (options.webhookUrl !== undefined) { body["webhook_url"] = options.webhookUrl; diff --git a/typescript/src/phone/resources/numbers.ts b/typescript/src/phone/resources/numbers.ts index 33c0d1d..c5aa8d5 100644 --- a/typescript/src/phone/resources/numbers.ts +++ b/typescript/src/phone/resources/numbers.ts @@ -39,26 +39,26 @@ export class PhoneNumbersResource { * * @param phoneNumberId - UUID of the phone number. * @param options.incomingCallAction - `"auto_accept"`, `"auto_reject"`, or `"webhook"`. - * @param options.clientWebsocketUrl - WebSocket URL (wss://) for audio bridging on `auto_accept`. - * @param options.incomingCallWebhookUrl - HTTPS URL called on incoming calls when action is `webhook`. + * @param options.defaultStreamUrl - WebSocket URL (wss://) for audio bridging. + * @param options.defaultPipelineMode - Default pipeline mode for incoming calls. */ async update( phoneNumberId: string, options: { incomingCallAction?: string; - clientWebsocketUrl?: string | null; - incomingCallWebhookUrl?: string | null; + defaultStreamUrl?: string | null; + defaultPipelineMode?: string | null; }, ): Promise { const body: Record = {}; if (options.incomingCallAction !== undefined) { body["incoming_call_action"] = options.incomingCallAction; } - if ("clientWebsocketUrl" in options) { - body["client_websocket_url"] = options.clientWebsocketUrl; + if ("defaultStreamUrl" in options) { + body["default_stream_url"] = options.defaultStreamUrl; } - if ("incomingCallWebhookUrl" in options) { - body["incoming_call_webhook_url"] = options.incomingCallWebhookUrl; + if ("defaultPipelineMode" in options) { + body["default_pipeline_mode"] = options.defaultPipelineMode; } const data = await this.http.patch( `${BASE}/${phoneNumberId}`, @@ -68,51 +68,32 @@ export class PhoneNumbersResource { } /** - * Provision a new phone number via Telnyx and assign it to an agent identity. + * Provision a new phone number. * - * @param options.agentHandle - Handle of the agent identity to assign this number to - * (e.g. `"sales-agent"` or `"@sales-agent"`). * @param options.type - `"toll_free"` or `"local"`. Defaults to `"toll_free"`. * @param options.state - US state abbreviation (e.g. `"NY"`). Only valid for `local` numbers. - * @param options.incomingCallAction - `"auto_accept"`, `"auto_reject"`, or `"webhook"`. Defaults to `"auto_reject"`. - * @param options.clientWebsocketUrl - WebSocket URL (wss://) for audio bridging. Required when `incomingCallAction="auto_accept"`. - * @param options.incomingCallWebhookUrl - HTTPS URL called on incoming calls. Required when `incomingCallAction="webhook"`. */ async provision(options: { - agentHandle: string; type?: string; state?: string; - incomingCallAction?: string; - clientWebsocketUrl?: string; - incomingCallWebhookUrl?: string; - }): Promise { + } = {}): Promise { const body: Record = { - agent_handle: options.agentHandle, type: options.type ?? "toll_free", }; - if (options.incomingCallAction !== undefined) { - body["incoming_call_action"] = options.incomingCallAction; - } if (options.state !== undefined) { body["state"] = options.state; } - if (options.clientWebsocketUrl !== undefined) { - body["client_websocket_url"] = options.clientWebsocketUrl; - } - if (options.incomingCallWebhookUrl !== undefined) { - body["incoming_call_webhook_url"] = options.incomingCallWebhookUrl; - } - const data = await this.http.post(BASE, body); + const data = await this.http.post(`${BASE}/provision`, body); return parsePhoneNumber(data); } /** - * Release (delete) a phone number by ID. + * Release a phone number. * - * @param phoneNumberId - UUID of the phone number to release. + * @param options.number - E.164 phone number to release. */ - async release(phoneNumberId: string): Promise { - await this.http.delete(`${BASE}/${phoneNumberId}`); + async release(options: { number: string }): Promise { + await this.http.post(`${BASE}/release`, { number: options.number }); } /** diff --git a/typescript/src/resources/mailboxes.ts b/typescript/src/resources/mailboxes.ts index efdab16..6f0c599 100644 --- a/typescript/src/resources/mailboxes.ts +++ b/typescript/src/resources/mailboxes.ts @@ -21,16 +21,14 @@ export class MailboxesResource { constructor(private readonly http: HttpTransport) {} /** - * Create a new mailbox and assign it to an agent identity. + * Create a new mailbox. * * The email address is automatically generated by the server. * - * @param options.agentHandle - Handle of the agent identity to assign this mailbox to - * (e.g. `"sales-agent"` or `"@sales-agent"`). * @param options.displayName - Optional human-readable name shown as the sender. */ - async create(options: { agentHandle: string; displayName?: string }): Promise { - const body: Record = { agent_handle: options.agentHandle }; + async create(options: { displayName?: string } = {}): Promise { + const body: Record = {}; if (options.displayName !== undefined) { body["display_name"] = options.displayName; } From dd0dc48ab7f0dc841339f78c16c297f46101aa8d Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Thu, 12 Mar 2026 19:09:28 -0400 Subject: [PATCH 29/56] unified ts sdk --- typescript/src/agent.ts | 219 +++++++++++++++++++++++++++++++++++++++ typescript/src/inkbox.ts | 159 ++++++++++++++++++++++++++++ 2 files changed, 378 insertions(+) create mode 100644 typescript/src/agent.ts create mode 100644 typescript/src/inkbox.ts diff --git a/typescript/src/agent.ts b/typescript/src/agent.ts new file mode 100644 index 0000000..f6b75ef --- /dev/null +++ b/typescript/src/agent.ts @@ -0,0 +1,219 @@ +/** + * inkbox/src/agent.ts + * + * Agent — a domain object representing one agent identity. + * Returned by inkbox.identities.create() and inkbox.identities.get(). + * + * Convenience methods (sendEmail, placeCall, etc.) are scoped to this + * agent's assigned channels so callers never need to pass an email address + * or phone number ID explicitly. + */ + +import { InkboxAPIError } from "./_http.js"; +import type { Message } from "./types.js"; +import type { PhoneCallWithRateLimit, PhoneTranscript } from "./phone/types.js"; +import type { + AgentIdentityDetail, + IdentityMailbox, + IdentityPhoneNumber, +} from "./identities/types.js"; +import type { Inkbox } from "./inkbox.js"; + +export class Agent { + private _identity: AgentIdentityDetail; + private readonly _inkbox: Inkbox; + private _mailbox: IdentityMailbox | null; + private _phoneNumber: IdentityPhoneNumber | null; + + constructor(identity: AgentIdentityDetail, inkbox: Inkbox) { + this._identity = identity; + this._inkbox = inkbox; + this._mailbox = identity.mailbox; + this._phoneNumber = identity.phoneNumber; + } + + // ------------------------------------------------------------------ + // Identity properties + // ------------------------------------------------------------------ + + get agentHandle(): string { return this._identity.agentHandle; } + get id(): string { return this._identity.id; } + get status(): string { return this._identity.status; } + + /** The mailbox currently assigned to this agent, or `null` if none. */ + get mailbox(): IdentityMailbox | null { return this._mailbox; } + + /** The phone number currently assigned to this agent, or `null` if none. */ + get phoneNumber(): IdentityPhoneNumber | null { return this._phoneNumber; } + + // ------------------------------------------------------------------ + // Channel assignment + // Combines resource creation/provisioning + identity linking in one call. + // ------------------------------------------------------------------ + + /** + * Create a new mailbox and assign it to this agent. + * + * @param options.displayName - Optional human-readable sender name. + * @returns The assigned {@link IdentityMailbox}. + */ + async assignMailbox(options: { displayName?: string } = {}): Promise { + const mailbox = await this._inkbox.mailboxes.create(options); + const detail = await this._inkbox._idsResource.assignMailbox(this.agentHandle, { + mailboxId: mailbox.id, + }); + this._mailbox = detail.mailbox; + this._identity = detail; + return this._mailbox!; + } + + /** + * Provision a new phone number and assign it to this agent. + * + * @param options.type - `"toll_free"` (default) or `"local"`. + * @param options.state - US state abbreviation (e.g. `"NY"`), valid for local numbers only. + * @returns The assigned {@link IdentityPhoneNumber}. + */ + async assignPhoneNumber( + options: { type?: string; state?: string } = {}, + ): Promise { + const number = await this._inkbox.numbers.provision(options); + const detail = await this._inkbox._idsResource.assignPhoneNumber(this.agentHandle, { + phoneNumberId: number.id, + }); + this._phoneNumber = detail.phoneNumber; + this._identity = detail; + return this._phoneNumber!; + } + + // ------------------------------------------------------------------ + // Mail helpers + // ------------------------------------------------------------------ + + /** + * Send an email from this agent's mailbox. + * + * @param options.to - Primary recipient addresses (at least one required). + * @param options.subject - Email subject line. + * @param options.bodyText - Plain-text body. + * @param options.bodyHtml - HTML body. + * @param options.cc - Carbon-copy recipients. + * @param options.bcc - Blind carbon-copy recipients. + * @param options.inReplyToMessageId - RFC 5322 Message-ID to thread a reply. + * @param options.attachments - File attachments. + */ + async sendEmail(options: { + to: string[]; + subject: string; + bodyText?: string; + bodyHtml?: string; + cc?: string[]; + bcc?: string[]; + inReplyToMessageId?: string; + attachments?: Array<{ filename: string; contentType: string; contentBase64: string }>; + }): Promise { + this._requireMailbox(); + return this._inkbox.messages.send(this._mailbox!.emailAddress, options); + } + + /** + * Iterate over messages in this agent's inbox, newest first. + * + * Pagination is handled automatically. + * + * @param options.pageSize - Messages fetched per API call (1–100). Defaults to 50. + * @param options.direction - Filter by `"inbound"` or `"outbound"`. + */ + messages(options: { pageSize?: number; direction?: "inbound" | "outbound" } = {}): AsyncGenerator { + this._requireMailbox(); + return this._inkbox.messages.list(this._mailbox!.emailAddress, options); + } + + // ------------------------------------------------------------------ + // Phone helpers + // ------------------------------------------------------------------ + + /** + * Place an outbound call from this agent's phone number. + * + * @param options.toNumber - E.164 destination number. + * @param options.streamUrl - WebSocket URL (wss://) for audio bridging. + * @param options.pipelineMode - Pipeline mode override for this call. + * @param options.webhookUrl - Custom webhook URL for call lifecycle events. + */ + async placeCall(options: { + toNumber: string; + streamUrl?: string; + pipelineMode?: string; + webhookUrl?: string; + }): Promise { + this._requirePhone(); + return this._inkbox.calls.place({ + fromNumber: this._phoneNumber!.number, + toNumber: options.toNumber, + streamUrl: options.streamUrl, + pipelineMode: options.pipelineMode, + webhookUrl: options.webhookUrl, + }); + } + + /** + * Full-text search across call transcripts for this agent's number. + * + * @param options.q - Search query string. + * @param options.party - Filter by speaker: `"local"` or `"remote"`. + * @param options.limit - Maximum number of results (1–200). Defaults to 50. + */ + async searchTranscripts(options: { + q: string; + party?: string; + limit?: number; + }): Promise { + this._requirePhone(); + return this._inkbox.numbers.searchTranscripts(this._phoneNumber!.id, options); + } + + // ------------------------------------------------------------------ + // Misc + // ------------------------------------------------------------------ + + /** + * Re-fetch this agent's identity from the API and update cached channels. + * + * @returns `this` for chaining. + */ + async refresh(): Promise { + const detail = await this._inkbox._idsResource.get(this.agentHandle); + this._identity = detail; + this._mailbox = detail.mailbox; + this._phoneNumber = detail.phoneNumber; + return this; + } + + /** Soft-delete this identity (unlinks channels without deleting them). */ + async delete(): Promise { + await this._inkbox._idsResource.delete(this.agentHandle); + } + + // ------------------------------------------------------------------ + // Internal guards + // ------------------------------------------------------------------ + + private _requireMailbox(): void { + if (!this._mailbox) { + throw new InkboxAPIError( + 0, + `Agent '${this.agentHandle}' has no mailbox assigned. Call agent.assignMailbox() first.`, + ); + } + } + + private _requirePhone(): void { + if (!this._phoneNumber) { + throw new InkboxAPIError( + 0, + `Agent '${this.agentHandle}' has no phone number assigned. Call agent.assignPhoneNumber() first.`, + ); + } + } +} diff --git a/typescript/src/inkbox.ts b/typescript/src/inkbox.ts new file mode 100644 index 0000000..4a05737 --- /dev/null +++ b/typescript/src/inkbox.ts @@ -0,0 +1,159 @@ +/** + * inkbox/src/inkbox.ts + * + * Unified Inkbox client — single entry point for all Inkbox APIs. + */ + +import { HttpTransport } from "./_http.js"; +import { MailboxesResource } from "./resources/mailboxes.js"; +import { MessagesResource } from "./resources/messages.js"; +import { ThreadsResource } from "./resources/threads.js"; +import { WebhooksResource } from "./resources/webhooks.js"; +import { SigningKeysResource } from "./resources/signing-keys.js"; +import { PhoneNumbersResource } from "./phone/resources/numbers.js"; +import { CallsResource } from "./phone/resources/calls.js"; +import { TranscriptsResource } from "./phone/resources/transcripts.js"; +import { PhoneWebhooksResource } from "./phone/resources/webhooks.js"; +import { IdentitiesResource } from "./identities/resources/identities.js"; +import { Agent } from "./agent.js"; +import type { AgentIdentity, AgentIdentityDetail } from "./identities/types.js"; + +const DEFAULT_BASE_URL = "https://api.inkbox.ai"; + +export interface InkboxOptions { + /** Your Inkbox API key (sent as `X-Service-Token`). */ + apiKey: string; + /** Override the API base URL (useful for self-hosting or testing). */ + baseUrl?: string; + /** Request timeout in milliseconds. Defaults to 30 000. */ + timeoutMs?: number; +} + +/** + * Unified client for all Inkbox APIs. + * + * @example + * ```ts + * import { Inkbox } from "@inkbox/sdk"; + * + * const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); + * + * // Create an agent identity — returns an Agent object + * const agent = await inkbox.identities.create({ agentHandle: "support-bot" }); + * + * // Provision and link channels in one call each + * const mailbox = await agent.assignMailbox({ displayName: "Support Bot" }); + * const phone = await agent.assignPhoneNumber({ type: "toll_free" }); + * + * // Send email directly from the agent + * await agent.sendEmail({ + * to: ["customer@example.com"], + * subject: "Your order has shipped", + * bodyText: "Tracking number: 1Z999AA10123456784", + * }); + * ``` + */ +export class Inkbox { + // Mail + readonly mailboxes: MailboxesResource; + readonly messages: MessagesResource; + readonly threads: ThreadsResource; + readonly mailWebhooks: WebhooksResource; + readonly signingKeys: SigningKeysResource; + + // Phone + readonly numbers: PhoneNumbersResource; + readonly calls: CallsResource; + readonly transcripts: TranscriptsResource; + readonly phoneWebhooks: PhoneWebhooksResource; + + // Identities — returns Agent objects from create/get + readonly identities: IdentitiesNamespace; + + /** @internal — used by Agent to link channels without going through the namespace */ + readonly _idsResource: IdentitiesResource; + + constructor(options: InkboxOptions) { + const apiRoot = `${(options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "")}/api/v1`; + const ms = options.timeoutMs ?? 30_000; + + const mailHttp = new HttpTransport(options.apiKey, `${apiRoot}/mail`, ms); + const phoneHttp = new HttpTransport(options.apiKey, `${apiRoot}/phone`, ms); + const idsHttp = new HttpTransport(options.apiKey, `${apiRoot}/identities`, ms); + const apiHttp = new HttpTransport(options.apiKey, apiRoot, ms); + + this.mailboxes = new MailboxesResource(mailHttp); + this.messages = new MessagesResource(mailHttp); + this.threads = new ThreadsResource(mailHttp); + this.mailWebhooks = new WebhooksResource(mailHttp); + this.signingKeys = new SigningKeysResource(apiHttp); + + this.numbers = new PhoneNumbersResource(phoneHttp); + this.calls = new CallsResource(phoneHttp); + this.transcripts = new TranscriptsResource(phoneHttp); + this.phoneWebhooks = new PhoneWebhooksResource(phoneHttp); + + this._idsResource = new IdentitiesResource(idsHttp); + this.identities = new IdentitiesNamespace(this._idsResource, this); + } +} + +/** + * Thin wrapper around IdentitiesResource that returns Agent objects from + * create() and get(), while delegating everything else directly. + */ +class IdentitiesNamespace { + constructor( + private readonly _r: IdentitiesResource, + private readonly _inkbox: Inkbox, + ) {} + + /** Create a new agent identity and return it as an {@link Agent} object. */ + async create(options: { agentHandle: string }): Promise { + await this._r.create(options); + // POST /identities returns AgentIdentity (no channel fields); + // fetch the detail so the Agent has a fully-populated AgentIdentityDetail. + const detail = await this._r.get(options.agentHandle); + return new Agent(detail, this._inkbox); + } + + /** Get an existing agent identity by handle, returned as an {@link Agent} object. */ + async get(agentHandle: string): Promise { + return new Agent(await this._r.get(agentHandle), this._inkbox); + } + + /** List all agent identities for your organisation. */ + list(): Promise { + return this._r.list(); + } + + /** Update an identity's handle or status. */ + update(...args: Parameters) { + return this._r.update(...args); + } + + /** Soft-delete an identity (unlinks channels without deleting them). */ + delete(...args: Parameters) { + return this._r.delete(...args); + } + + /** Assign an existing mailbox to an identity by mailbox UUID. */ + assignMailbox(...args: Parameters): Promise { + return this._r.assignMailbox(...args); + } + + /** Unlink a mailbox from an identity. */ + unlinkMailbox(...args: Parameters) { + return this._r.unlinkMailbox(...args); + } + + /** Assign an existing phone number to an identity by phone number UUID. */ + assignPhoneNumber(...args: Parameters): Promise { + return this._r.assignPhoneNumber(...args); + } + + /** Unlink a phone number from an identity. */ + unlinkPhoneNumber(...args: Parameters) { + return this._r.unlinkPhoneNumber(...args); + } +} From 83c0adec8d305799029702e2470946882f54bbfb Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Thu, 12 Mar 2026 19:14:14 -0400 Subject: [PATCH 30/56] update examples --- examples/typescript/agent-send-email.ts | 8 ++-- examples/typescript/create-agent-mailbox.ts | 18 ++++----- .../typescript/create-agent-phone-number.ts | 12 +++--- .../typescript/list-agent-phone-numbers.ts | 6 +-- examples/typescript/read-agent-calls.ts | 8 ++-- examples/typescript/read-agent-messages.ts | 10 ++--- .../typescript/receive-agent-call-webhook.ts | 10 ++--- .../typescript/receive-agent-email-webhook.ts | 10 ++--- .../typescript/register-agent-identity.ts | 37 ++++++++----------- 9 files changed, 55 insertions(+), 64 deletions(-) diff --git a/examples/typescript/agent-send-email.ts b/examples/typescript/agent-send-email.ts index fd9976b..d177909 100644 --- a/examples/typescript/agent-send-email.ts +++ b/examples/typescript/agent-send-email.ts @@ -5,13 +5,13 @@ * INKBOX_API_KEY=ApiKey_... MAILBOX_ADDRESS=agent@inkboxmail.com npx ts-node agent-send-email.ts */ -import { InkboxMail } from "../../typescript/src/client.js"; +import { Inkbox } from "../../typescript/src/inkbox.js"; -const client = new InkboxMail({ apiKey: process.env.INKBOX_API_KEY! }); +const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); const mailboxAddress = process.env.MAILBOX_ADDRESS!; // Agent sends outbound email -const sent = await client.messages.send(mailboxAddress, { +const sent = await inkbox.messages.send(mailboxAddress, { to: ["recipient@example.com"], subject: "Hello from your AI sales agent", bodyText: "Hi there! I'm your AI sales agent reaching out via Inkbox.", @@ -20,7 +20,7 @@ const sent = await client.messages.send(mailboxAddress, { console.log(`Sent message ${sent.id} subject="${sent.subject}"`); // Agent sends threaded reply -const reply = await client.messages.send(mailboxAddress, { +const reply = await inkbox.messages.send(mailboxAddress, { to: ["recipient@example.com"], subject: `Re: ${sent.subject}`, bodyText: "Following up as your AI sales agent.", diff --git a/examples/typescript/create-agent-mailbox.ts b/examples/typescript/create-agent-mailbox.ts index 3e2a197..8aaf4ac 100644 --- a/examples/typescript/create-agent-mailbox.ts +++ b/examples/typescript/create-agent-mailbox.ts @@ -2,38 +2,36 @@ * Create, update, search, and delete a mailbox. * * Usage: - * INKBOX_API_KEY=ApiKey_... AGENT_HANDLE=sales-agent npx ts-node create-agent-mailbox.ts + * INKBOX_API_KEY=ApiKey_... npx ts-node create-agent-mailbox.ts */ -import { InkboxMail } from "../../typescript/src/client.js"; +import { Inkbox } from "../../typescript/src/inkbox.js"; -const client = new InkboxMail({ apiKey: process.env.INKBOX_API_KEY! }); -const agentHandle = process.env.AGENT_HANDLE ?? "sales-agent"; +const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); // Create agent mailbox -const mailbox = await client.mailboxes.create({ - agentHandle, +const mailbox = await inkbox.mailboxes.create({ displayName: "Sales Agent", }); console.log(`Agent mailbox created: ${mailbox.emailAddress} displayName="${mailbox.displayName}"`); // List all mailboxes -const all = await client.mailboxes.list(); +const all = await inkbox.mailboxes.list(); console.log(`\nAll agent mailboxes (${all.length}):`); for (const m of all) { console.log(` ${m.emailAddress} status=${m.status}`); } // Update display name -const updated = await client.mailboxes.update(mailbox.emailAddress, { +const updated = await inkbox.mailboxes.update(mailbox.emailAddress, { displayName: "Sales Agent (updated)", }); console.log(`\nUpdated displayName: ${updated.displayName}`); // Full-text search -const results = await client.mailboxes.search(mailbox.emailAddress, { q: "hello" }); +const results = await inkbox.mailboxes.search(mailbox.emailAddress, { q: "hello" }); console.log(`\nSearch results for "hello": ${results.length} messages`); // Delete agent mailbox -await client.mailboxes.delete(mailbox.emailAddress); +await inkbox.mailboxes.delete(mailbox.emailAddress); console.log("Agent mailbox deleted."); diff --git a/examples/typescript/create-agent-phone-number.ts b/examples/typescript/create-agent-phone-number.ts index 5259e75..a068564 100644 --- a/examples/typescript/create-agent-phone-number.ts +++ b/examples/typescript/create-agent-phone-number.ts @@ -6,32 +6,32 @@ * INKBOX_API_KEY=ApiKey_... NUMBER_TYPE=local STATE=NY npx ts-node create-agent-phone-number.ts */ -import { InkboxPhone } from "../../typescript/src/phone/index.js"; +import { Inkbox } from "../../typescript/src/inkbox.js"; -const client = new InkboxPhone({ apiKey: process.env.INKBOX_API_KEY! }); +const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); const numberType = process.env.NUMBER_TYPE ?? "toll_free"; const state = process.env.STATE; // Provision agent phone number -const number = await client.numbers.provision({ +const number = await inkbox.numbers.provision({ type: numberType, ...(state ? { state } : {}), }); console.log(`Agent phone number provisioned: ${number.number} type=${number.type} status=${number.status}`); // List all numbers -const all = await client.numbers.list(); +const all = await inkbox.numbers.list(); console.log(`\nAll agent phone numbers (${all.length}):`); for (const n of all) { console.log(` ${n.number} type=${n.type} status=${n.status}`); } // Update incoming call action -const updated = await client.numbers.update(number.id, { +const updated = await inkbox.numbers.update(number.id, { incomingCallAction: "auto_accept", }); console.log(`\nUpdated incomingCallAction: ${updated.incomingCallAction}`); // Release agent phone number -await client.numbers.release(number.id); +await inkbox.numbers.release({ number: number.number }); console.log("Agent phone number released."); diff --git a/examples/typescript/list-agent-phone-numbers.ts b/examples/typescript/list-agent-phone-numbers.ts index 282ca70..c7caf1b 100644 --- a/examples/typescript/list-agent-phone-numbers.ts +++ b/examples/typescript/list-agent-phone-numbers.ts @@ -5,11 +5,11 @@ * INKBOX_API_KEY=ApiKey_... npx ts-node list-agent-phone-numbers.ts */ -import { InkboxPhone } from "../../typescript/src/phone/index.js"; +import { Inkbox } from "../../typescript/src/inkbox.js"; -const client = new InkboxPhone({ apiKey: process.env.INKBOX_API_KEY! }); +const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); -const numbers = await client.numbers.list(); +const numbers = await inkbox.numbers.list(); for (const n of numbers) { console.log(`${n.number} type=${n.type} status=${n.status}`); diff --git a/examples/typescript/read-agent-calls.ts b/examples/typescript/read-agent-calls.ts index 3fbbc7b..c8037f6 100644 --- a/examples/typescript/read-agent-calls.ts +++ b/examples/typescript/read-agent-calls.ts @@ -5,17 +5,17 @@ * INKBOX_API_KEY=ApiKey_... PHONE_NUMBER_ID= npx ts-node read-agent-calls.ts */ -import { InkboxPhone } from "../../typescript/src/phone/index.js"; +import { Inkbox } from "../../typescript/src/inkbox.js"; -const client = new InkboxPhone({ apiKey: process.env.INKBOX_API_KEY! }); +const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); const phoneNumberId = process.env.PHONE_NUMBER_ID!; -const calls = await client.calls.list(phoneNumberId, { limit: 10 }); +const calls = await inkbox.calls.list(phoneNumberId, { limit: 10 }); for (const call of calls) { console.log(`\n${call.id} ${call.direction} ${call.remotePhoneNumber} status=${call.status}`); - const transcripts = await client.transcripts.list(phoneNumberId, call.id); + const transcripts = await inkbox.transcripts.list(phoneNumberId, call.id); for (const t of transcripts) { console.log(` [${t.party}] ${t.text}`); } diff --git a/examples/typescript/read-agent-messages.ts b/examples/typescript/read-agent-messages.ts index 9c7b2ba..6dc891a 100644 --- a/examples/typescript/read-agent-messages.ts +++ b/examples/typescript/read-agent-messages.ts @@ -5,15 +5,15 @@ * INKBOX_API_KEY=ApiKey_... MAILBOX_ADDRESS=agent@inkboxmail.com npx ts-node read-agent-messages.ts */ -import { InkboxMail } from "../../typescript/src/client.js"; +import { Inkbox } from "../../typescript/src/inkbox.js"; -const client = new InkboxMail({ apiKey: process.env.INKBOX_API_KEY! }); +const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); const mailboxAddress = process.env.MAILBOX_ADDRESS!; // List the 5 most recent messages console.log("=== Agent inbox ==="); let count = 0; -for await (const msg of client.messages.list(mailboxAddress)) { +for await (const msg of inkbox.messages.list(mailboxAddress)) { console.log(`${msg.id} ${msg.subject} from=${msg.fromAddress} read=${msg.isRead}`); if (++count >= 5) break; } @@ -21,13 +21,13 @@ for await (const msg of client.messages.list(mailboxAddress)) { // List threads and fetch the first one in full console.log("\n=== Agent threads ==="); let firstThreadId: string | undefined; -for await (const thread of client.threads.list(mailboxAddress)) { +for await (const thread of inkbox.threads.list(mailboxAddress)) { console.log(`${thread.id} "${thread.subject}" messages=${thread.messageCount}`); firstThreadId ??= thread.id; } if (firstThreadId) { - const thread = await client.threads.get(mailboxAddress, firstThreadId); + const thread = await inkbox.threads.get(mailboxAddress, firstThreadId); console.log(`\nAgent conversation: "${thread.subject}" (${thread.messages.length} messages)`); for (const msg of thread.messages) { console.log(` [${msg.fromAddress}] ${msg.subject}`); diff --git a/examples/typescript/receive-agent-call-webhook.ts b/examples/typescript/receive-agent-call-webhook.ts index 4022f5f..5fd47a6 100644 --- a/examples/typescript/receive-agent-call-webhook.ts +++ b/examples/typescript/receive-agent-call-webhook.ts @@ -5,24 +5,24 @@ * INKBOX_API_KEY=ApiKey_... PHONE_NUMBER_ID= npx ts-node receive-agent-call-webhook.ts */ -import { InkboxPhone } from "../../typescript/src/phone/index.js"; +import { Inkbox } from "../../typescript/src/inkbox.js"; -const client = new InkboxPhone({ apiKey: process.env.INKBOX_API_KEY! }); +const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); const phoneNumberId = process.env.PHONE_NUMBER_ID!; // Register webhook for agent phone number -const hook = await client.webhooks.create(phoneNumberId, { +const hook = await inkbox.phoneWebhooks.create(phoneNumberId, { url: "https://example.com/webhook", eventTypes: ["incoming_call"], }); console.log(`Registered agent phone webhook ${hook.id} secret=${hook.secret}`); // Update agent phone webhook -const updated = await client.webhooks.update(phoneNumberId, hook.id, { +const updated = await inkbox.phoneWebhooks.update(phoneNumberId, hook.id, { url: "https://example.com/webhook-v2", }); console.log(`Updated URL: ${updated.url}`); // Remove agent phone webhook -await client.webhooks.delete(phoneNumberId, hook.id); +await inkbox.phoneWebhooks.delete(phoneNumberId, hook.id); console.log("Agent phone webhook removed."); diff --git a/examples/typescript/receive-agent-email-webhook.ts b/examples/typescript/receive-agent-email-webhook.ts index 5c1a404..608849c 100644 --- a/examples/typescript/receive-agent-email-webhook.ts +++ b/examples/typescript/receive-agent-email-webhook.ts @@ -5,25 +5,25 @@ * INKBOX_API_KEY=ApiKey_... MAILBOX_ADDRESS=agent@inkboxmail.com npx ts-node receive-agent-email-webhook.ts */ -import { InkboxMail } from "../../typescript/src/client.js"; +import { Inkbox } from "../../typescript/src/inkbox.js"; -const client = new InkboxMail({ apiKey: process.env.INKBOX_API_KEY! }); +const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); const mailboxAddress = process.env.MAILBOX_ADDRESS!; // Register webhook for agent mailbox -const hook = await client.webhooks.create(mailboxAddress, { +const hook = await inkbox.mailWebhooks.create(mailboxAddress, { url: "https://example.com/webhook", eventTypes: ["message.received", "message.sent"], }); console.log(`Registered agent mailbox webhook ${hook.id} secret=${hook.secret}`); // List -const all = await client.webhooks.list(mailboxAddress); +const all = await inkbox.mailWebhooks.list(mailboxAddress); console.log(`Active agent mailbox webhooks: ${all.length}`); for (const w of all) { console.log(` ${w.id} url=${w.url} events=${w.eventTypes.join(", ")}`); } // Remove agent mailbox webhook -await client.webhooks.delete(mailboxAddress, hook.id); +await inkbox.mailWebhooks.delete(mailboxAddress, hook.id); console.log("Agent mailbox webhook removed."); diff --git a/examples/typescript/register-agent-identity.ts b/examples/typescript/register-agent-identity.ts index b17bcc1..1ac4d43 100644 --- a/examples/typescript/register-agent-identity.ts +++ b/examples/typescript/register-agent-identity.ts @@ -1,40 +1,33 @@ /** - * Create an agent identity and assign communication channels to it. + * Create an agent identity and provision communication channels for it. * * Usage: - * INKBOX_API_KEY=ApiKey_... MAILBOX_ID= PHONE_NUMBER_ID= npx ts-node register-agent-identity.ts + * INKBOX_API_KEY=ApiKey_... npx ts-node register-agent-identity.ts */ -import { InkboxIdentities } from "../../typescript/src/identities/index.js"; +import { Inkbox } from "../../typescript/src/inkbox.js"; -const client = new InkboxIdentities({ apiKey: process.env.INKBOX_API_KEY! }); +const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); -// Register agent identity -const identity = await client.identities.create({ agentHandle: "sales-agent" }); -console.log(`Registered agent: ${identity.agentHandle} (id=${identity.id})`); +// Register agent identity — returns an Agent object +const agent = await inkbox.identities.create({ agentHandle: "sales-agent" }); +console.log(`Registered agent: ${agent.agentHandle} (id=${agent.id})`); -// Assign channels -if (process.env.MAILBOX_ID) { - const withMailbox = await client.identities.assignMailbox("sales-agent", { - mailboxId: process.env.MAILBOX_ID, - }); - console.log(`Assigned mailbox: ${withMailbox.mailbox?.emailAddress}`); -} +// Provision and link a mailbox +const mailbox = await agent.assignMailbox({ displayName: "Sales Agent" }); +console.log(`Assigned mailbox: ${mailbox.emailAddress}`); -if (process.env.PHONE_NUMBER_ID) { - const withPhone = await client.identities.assignPhoneNumber("sales-agent", { - phoneNumberId: process.env.PHONE_NUMBER_ID, - }); - console.log(`Assigned phone: ${withPhone.phoneNumber?.number}`); -} +// Provision and link a phone number +const phone = await agent.assignPhoneNumber({ type: "toll_free" }); +console.log(`Assigned phone: ${phone.number}`); // List all identities -const all = await client.identities.list(); +const all = await inkbox.identities.list(); console.log(`\nAll identities (${all.length}):`); for (const id of all) { console.log(` ${id.agentHandle} status=${id.status}`); } // Unregister agent -await client.identities.delete("sales-agent"); +await agent.delete(); console.log("\nUnregistered agent sales-agent."); From 28df4d308f1cbd427d17b5c6e50d3b34f35707a2 Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Thu, 12 Mar 2026 19:18:16 -0400 Subject: [PATCH 31/56] rm unsed file --- examples/python/README.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 examples/python/README.md diff --git a/examples/python/README.md b/examples/python/README.md deleted file mode 100644 index e69de29..0000000 From 90ae2d98e3afb301b7afa6ddbc5188632996c139 Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Thu, 12 Mar 2026 19:32:08 -0400 Subject: [PATCH 32/56] built-in sig verification --- README.md | 34 +++++++++ python/README.md | 36 ++++++++++ python/inkbox/__init__.py | 7 +- python/inkbox/signing_keys.py | 31 ++++++++ python/tests/test_signing_keys.py | 90 +++++++++++++++++++++++- typescript/README.md | 21 ++++++ typescript/src/index.ts | 1 + typescript/src/resources/signing-keys.ts | 33 +++++++++ typescript/tests/signing-keys.test.ts | 56 ++++++++++++++- 9 files changed, 303 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index dc2f480..8cfe892 100644 --- a/README.md +++ b/README.md @@ -248,6 +248,40 @@ await inkbox.numbers.release({ number: number.number }); --- +## Verifying Webhook Signatures + +Use `verify_webhook` / `verifyWebhook` to confirm that an incoming request was sent by Inkbox. The function checks the HMAC-SHA256 signature over `{request_id}.{timestamp}.{body}`. + +### Python + +```python +from inkbox import verify_webhook + +is_valid = verify_webhook( + payload=raw_body, # bytes + signature=request.headers["X-Inkbox-Signature"], + request_id=request.headers["X-Inkbox-Request-ID"], + timestamp=request.headers["X-Inkbox-Timestamp"], + secret="whsec_...", # from POST /signing-keys +) +``` + +### TypeScript + +```ts +import { verifyWebhook } from "@inkbox/sdk"; + +const valid = verifyWebhook({ + payload: req.body, // Buffer or string + signature: req.headers["x-inkbox-signature"] as string, + requestId: req.headers["x-inkbox-request-id"] as string, + timestamp: req.headers["x-inkbox-timestamp"] as string, + secret: "whsec_...", // from POST /signing-keys +}); +``` + +--- + ## License MIT diff --git a/python/README.md b/python/README.md index 291f365..ddd44a2 100644 --- a/python/README.md +++ b/python/README.md @@ -214,6 +214,42 @@ inkbox.phone_webhooks.update(number.id, hook.id, url="https://yourapp.com/hooks/ inkbox.phone_webhooks.delete(number.id, hook.id) ``` +### Verifying webhook signatures + +Use `verify_webhook` to confirm that an incoming request was sent by Inkbox. + +```python +from inkbox import verify_webhook + +# FastAPI +@app.post("/hooks/mail") +async def mail_hook(request: Request): + raw_body = await request.body() + if not verify_webhook( + payload=raw_body, + signature=request.headers["X-Inkbox-Signature"], + request_id=request.headers["X-Inkbox-Request-ID"], + timestamp=request.headers["X-Inkbox-Timestamp"], + secret="whsec_...", + ): + raise HTTPException(status_code=403) + ... + +# Flask +@app.post("/hooks/mail") +def mail_hook(): + raw_body = request.get_data() + if not verify_webhook( + payload=raw_body, + signature=request.headers["X-Inkbox-Signature"], + request_id=request.headers["X-Inkbox-Request-ID"], + timestamp=request.headers["X-Inkbox-Timestamp"], + secret="whsec_...", + ): + abort(403) + ... +``` + --- ## Examples diff --git a/python/inkbox/__init__.py b/python/inkbox/__init__.py index e83e133..29f3a52 100644 --- a/python/inkbox/__init__.py +++ b/python/inkbox/__init__.py @@ -38,8 +38,8 @@ IdentityPhoneNumber, ) -# Signing key -from inkbox.signing_keys import SigningKey +# Signing key + webhook verification +from inkbox.signing_keys import SigningKey, verify_webhook __all__ = [ # Entry points @@ -69,6 +69,7 @@ "AgentIdentityDetail", "IdentityMailbox", "IdentityPhoneNumber", - # Signing key + # Signing key + webhook verification "SigningKey", + "verify_webhook", ] diff --git a/python/inkbox/signing_keys.py b/python/inkbox/signing_keys.py index dcaa7d8..7540c47 100644 --- a/python/inkbox/signing_keys.py +++ b/python/inkbox/signing_keys.py @@ -6,6 +6,8 @@ from __future__ import annotations +import hashlib +import hmac from dataclasses import dataclass from datetime import datetime from typing import Any @@ -29,6 +31,35 @@ def _from_dict(cls, d: dict[str, Any]) -> SigningKey: ) +def verify_webhook( + *, + payload: bytes, + signature: str, + request_id: str, + timestamp: str, + secret: str, +) -> bool: + """Verify that an incoming webhook request was sent by Inkbox. + + Args: + payload: Raw request body bytes (do not parse/re-serialize). + signature: Value of the ``X-Inkbox-Signature`` header. + request_id: Value of the ``X-Inkbox-Request-ID`` header. + timestamp: Value of the ``X-Inkbox-Timestamp`` header. + secret: Your signing key, with or without a ``whsec_`` prefix. + + Returns: + True if the signature is valid, False otherwise. + """ + if not signature.startswith("sha256="): + return False + key = secret.removeprefix("whsec_") + message = f"{request_id}.{timestamp}.".encode() + payload + expected = hmac.new(key.encode(), message, hashlib.sha256).hexdigest() + received = signature.removeprefix("sha256=") + return hmac.compare_digest(expected, received) + + class SigningKeysResource: def __init__(self, http: Any) -> None: self._http = http diff --git a/python/tests/test_signing_keys.py b/python/tests/test_signing_keys.py index a2fc275..18c04c6 100644 --- a/python/tests/test_signing_keys.py +++ b/python/tests/test_signing_keys.py @@ -1,9 +1,11 @@ -"""Tests for SigningKeysResource.""" +"""Tests for SigningKeysResource and verify_webhook.""" +import hashlib +import hmac from datetime import datetime, timezone from unittest.mock import MagicMock -from inkbox.signing_keys import SigningKey, SigningKeysResource +from inkbox.signing_keys import SigningKey, SigningKeysResource, verify_webhook SIGNING_KEY_DICT = { @@ -35,3 +37,87 @@ def test_returns_signing_key(self): assert isinstance(key, SigningKey) assert key.signing_key == "sk-test-hmac-secret-abc123" assert key.created_at == datetime(2026, 3, 9, 0, 0, 0, tzinfo=timezone.utc) + + +def _make_signature(key: str, request_id: str, timestamp: str, body: bytes) -> str: + message = f"{request_id}.{timestamp}.".encode() + body + digest = hmac.new(key.encode(), message, hashlib.sha256).hexdigest() + return f"sha256={digest}" + + +class TestVerifyWebhook: + KEY = "test-signing-key" + REQUEST_ID = "req-abc-123" + TIMESTAMP = "1741737600" + BODY = b'{"event":"message.received"}' + + def test_valid_signature(self): + sig = _make_signature(self.KEY, self.REQUEST_ID, self.TIMESTAMP, self.BODY) + assert verify_webhook( + payload=self.BODY, + signature=sig, + request_id=self.REQUEST_ID, + timestamp=self.TIMESTAMP, + secret=self.KEY, + ) + + def test_valid_signature_with_whsec_prefix(self): + sig = _make_signature(self.KEY, self.REQUEST_ID, self.TIMESTAMP, self.BODY) + assert verify_webhook( + payload=self.BODY, + signature=sig, + request_id=self.REQUEST_ID, + timestamp=self.TIMESTAMP, + secret=f"whsec_{self.KEY}", + ) + + def test_wrong_key_returns_false(self): + sig = _make_signature("wrong-key", self.REQUEST_ID, self.TIMESTAMP, self.BODY) + assert not verify_webhook( + payload=self.BODY, + signature=sig, + request_id=self.REQUEST_ID, + timestamp=self.TIMESTAMP, + secret=self.KEY, + ) + + def test_tampered_body_returns_false(self): + sig = _make_signature(self.KEY, self.REQUEST_ID, self.TIMESTAMP, self.BODY) + assert not verify_webhook( + payload=b'{"event":"message.sent"}', + signature=sig, + request_id=self.REQUEST_ID, + timestamp=self.TIMESTAMP, + secret=self.KEY, + ) + + def test_wrong_request_id_returns_false(self): + sig = _make_signature(self.KEY, self.REQUEST_ID, self.TIMESTAMP, self.BODY) + assert not verify_webhook( + payload=self.BODY, + signature=sig, + request_id="different-id", + timestamp=self.TIMESTAMP, + secret=self.KEY, + ) + + def test_wrong_timestamp_returns_false(self): + sig = _make_signature(self.KEY, self.REQUEST_ID, self.TIMESTAMP, self.BODY) + assert not verify_webhook( + payload=self.BODY, + signature=sig, + request_id=self.REQUEST_ID, + timestamp="9999999999", + secret=self.KEY, + ) + + def test_missing_sha256_prefix_returns_false(self): + sig = _make_signature(self.KEY, self.REQUEST_ID, self.TIMESTAMP, self.BODY) + bare_hex = sig.removeprefix("sha256=") + assert not verify_webhook( + payload=self.BODY, + signature=bare_hex, + request_id=self.REQUEST_ID, + timestamp=self.TIMESTAMP, + secret=self.KEY, + ) diff --git a/typescript/README.md b/typescript/README.md index d31b80e..035eaa8 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -212,6 +212,27 @@ await inkbox.phoneWebhooks.update(number.id, hook.id, { url: "https://yourapp.co await inkbox.phoneWebhooks.delete(number.id, hook.id); ``` +### Verifying webhook signatures + +Use `verifyWebhook` to confirm that an incoming request was sent by Inkbox. + +```typescript +import { verifyWebhook } from "@inkbox/sdk"; + +// Express — use express.raw() to get the raw body Buffer +app.post("/hooks/mail", express.raw({ type: "*/*" }), (req, res) => { + const valid = verifyWebhook({ + payload: req.body, + signature: req.headers["x-inkbox-signature"] as string, + requestId: req.headers["x-inkbox-request-id"] as string, + timestamp: req.headers["x-inkbox-timestamp"] as string, + secret: "whsec_...", + }); + if (!valid) return res.status(403).end(); + // handle event ... +}); +``` + --- ## Examples diff --git a/typescript/src/index.ts b/typescript/src/index.ts index 84b16de..7732db9 100644 --- a/typescript/src/index.ts +++ b/typescript/src/index.ts @@ -4,6 +4,7 @@ export type { InkboxOptions } from "./inkbox.js"; export { InkboxMail } from "./client.js"; export { InkboxAPIError } from "./_http.js"; export type { SigningKey } from "./resources/signing-keys.js"; +export { verifyWebhook } from "./resources/signing-keys.js"; export type { Mailbox, Message, diff --git a/typescript/src/resources/signing-keys.ts b/typescript/src/resources/signing-keys.ts index f902a16..fd2a496 100644 --- a/typescript/src/resources/signing-keys.ts +++ b/typescript/src/resources/signing-keys.ts @@ -4,6 +4,7 @@ * Shared across all Inkbox clients (mail, phone, etc.). */ +import { createHmac, timingSafeEqual } from "crypto"; import { HttpTransport } from "../_http.js"; const PATH = "/signing-keys"; @@ -26,6 +27,38 @@ function parseSigningKey(r: RawSigningKey): SigningKey { }; } +/** + * Verify that an incoming webhook request was sent by Inkbox. + * + * @param payload - Raw request body as a Buffer or string. + * @param signature - Value of the `X-Inkbox-Signature` header. + * @param requestId - Value of the `X-Inkbox-Request-ID` header. + * @param timestamp - Value of the `X-Inkbox-Timestamp` header. + * @param secret - Your signing key, with or without a `whsec_` prefix. + * @returns True if the signature is valid. + */ +export function verifyWebhook({ + payload, + signature, + requestId, + timestamp, + secret, +}: { + payload: Buffer | string; + signature: string; + requestId: string; + timestamp: string; + secret: string; +}): boolean { + if (!signature.startsWith("sha256=")) return false; + const key = secret.startsWith("whsec_") ? secret.slice("whsec_".length) : secret; + const body = typeof payload === "string" ? Buffer.from(payload) : payload; + const message = Buffer.concat([Buffer.from(`${requestId}.${timestamp}.`), body]); + const expected = createHmac("sha256", key).update(message).digest("hex"); + const received = signature.slice("sha256=".length); + return timingSafeEqual(Buffer.from(expected), Buffer.from(received)); +} + export class SigningKeysResource { constructor(private readonly http: HttpTransport) {} diff --git a/typescript/tests/signing-keys.test.ts b/typescript/tests/signing-keys.test.ts index 8372fc0..fc5b20d 100644 --- a/typescript/tests/signing-keys.test.ts +++ b/typescript/tests/signing-keys.test.ts @@ -1,14 +1,68 @@ +import { createHmac } from "crypto"; import { describe, it, expect, vi } from "vitest"; -import { SigningKeysResource } from "../src/resources/signing-keys.js"; +import { SigningKeysResource, verifyWebhook } from "../src/resources/signing-keys.js"; import { HttpTransport } from "../src/_http.js"; import { RAW_SIGNING_KEY } from "./sampleData.js"; +const TEST_KEY = "test-signing-key"; +const TEST_REQUEST_ID = "req-abc-123"; +const TEST_TIMESTAMP = "1741737600"; +const TEST_BODY = Buffer.from('{"event":"message.received"}'); + +function makeSignature(key: string, requestId: string, timestamp: string, body: Buffer): string { + const message = Buffer.concat([Buffer.from(`${requestId}.${timestamp}.`), body]); + const digest = createHmac("sha256", key).update(message).digest("hex"); + return `sha256=${digest}`; +} + function makeResource() { const http = { post: vi.fn() } as unknown as HttpTransport; const resource = new SigningKeysResource(http); return { resource, http: http as { post: ReturnType } }; } +describe("verifyWebhook", () => { + it("returns true for a valid signature", () => { + const sig = makeSignature(TEST_KEY, TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY); + expect(verifyWebhook({ payload: TEST_BODY, signature: sig, requestId: TEST_REQUEST_ID, timestamp: TEST_TIMESTAMP, secret: TEST_KEY })).toBe(true); + }); + + it("accepts whsec_ prefixed secret", () => { + const sig = makeSignature(TEST_KEY, TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY); + expect(verifyWebhook({ payload: TEST_BODY, signature: sig, requestId: TEST_REQUEST_ID, timestamp: TEST_TIMESTAMP, secret: `whsec_${TEST_KEY}` })).toBe(true); + }); + + it("accepts string payload", () => { + const sig = makeSignature(TEST_KEY, TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY); + expect(verifyWebhook({ payload: TEST_BODY.toString(), signature: sig, requestId: TEST_REQUEST_ID, timestamp: TEST_TIMESTAMP, secret: TEST_KEY })).toBe(true); + }); + + it("returns false for wrong key", () => { + const sig = makeSignature("wrong-key", TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY); + expect(verifyWebhook({ payload: TEST_BODY, signature: sig, requestId: TEST_REQUEST_ID, timestamp: TEST_TIMESTAMP, secret: TEST_KEY })).toBe(false); + }); + + it("returns false for tampered body", () => { + const sig = makeSignature(TEST_KEY, TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY); + expect(verifyWebhook({ payload: Buffer.from('{"event":"message.sent"}'), signature: sig, requestId: TEST_REQUEST_ID, timestamp: TEST_TIMESTAMP, secret: TEST_KEY })).toBe(false); + }); + + it("returns false for wrong requestId", () => { + const sig = makeSignature(TEST_KEY, TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY); + expect(verifyWebhook({ payload: TEST_BODY, signature: sig, requestId: "different-id", timestamp: TEST_TIMESTAMP, secret: TEST_KEY })).toBe(false); + }); + + it("returns false for wrong timestamp", () => { + const sig = makeSignature(TEST_KEY, TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY); + expect(verifyWebhook({ payload: TEST_BODY, signature: sig, requestId: TEST_REQUEST_ID, timestamp: "9999999999", secret: TEST_KEY })).toBe(false); + }); + + it("returns false when sha256= prefix is missing", () => { + const sig = makeSignature(TEST_KEY, TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY).slice("sha256=".length); + expect(verifyWebhook({ payload: TEST_BODY, signature: sig, requestId: TEST_REQUEST_ID, timestamp: TEST_TIMESTAMP, secret: TEST_KEY })).toBe(false); + }); +}); + describe("SigningKeysResource", () => { describe("createOrRotate", () => { it("calls POST /signing-keys with empty body", async () => { From f2e2268b082825760a227bccaabe4debd43c097d Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Thu, 12 Mar 2026 19:35:10 -0400 Subject: [PATCH 33/56] update ts config --- typescript/package.json | 5 ++++- typescript/tsconfig.json | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/typescript/package.json b/typescript/package.json index 707e129..f739890 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -16,7 +16,9 @@ "types": "./dist/phone/index.d.ts" } }, - "files": ["dist"], + "files": [ + "dist" + ], "scripts": { "build": "tsc", "test": "vitest run", @@ -24,6 +26,7 @@ "prepublishOnly": "npm run build" }, "devDependencies": { + "@types/node": "^25.5.0", "@vitest/coverage-v8": "^2.0.0", "typescript": "^5.4.0", "vitest": "^2.0.0" diff --git a/typescript/tsconfig.json b/typescript/tsconfig.json index 09485ae..a1b642d 100644 --- a/typescript/tsconfig.json +++ b/typescript/tsconfig.json @@ -4,6 +4,7 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "lib": ["ES2022", "DOM"], + "types": ["node"], "declaration": true, "declarationMap": true, "sourceMap": true, From a0e81f2e07bc9336d3150922f4d3b4ff71f3bb25 Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Fri, 13 Mar 2026 10:18:35 -0400 Subject: [PATCH 34/56] feat: unified ts sdk client --- typescript/package.json | 4 ---- typescript/src/index.ts | 18 +++++++++++++++++- typescript/src/phone/index.ts | 1 - 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/typescript/package.json b/typescript/package.json index f739890..eba371a 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -10,10 +10,6 @@ ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" - }, - "./phone": { - "import": "./dist/phone/index.js", - "types": "./dist/phone/index.d.ts" } }, "files": [ diff --git a/typescript/src/index.ts b/typescript/src/index.ts index 7732db9..79fc837 100644 --- a/typescript/src/index.ts +++ b/typescript/src/index.ts @@ -1,7 +1,6 @@ export { Inkbox } from "./inkbox.js"; export { Agent } from "./agent.js"; export type { InkboxOptions } from "./inkbox.js"; -export { InkboxMail } from "./client.js"; export { InkboxAPIError } from "./_http.js"; export type { SigningKey } from "./resources/signing-keys.js"; export { verifyWebhook } from "./resources/signing-keys.js"; @@ -11,4 +10,21 @@ export type { MessageDetail, Thread, ThreadDetail, + Webhook as MailWebhook, + WebhookCreateResult as MailWebhookCreateResult, } from "./types.js"; +export type { + PhoneNumber, + PhoneCall, + PhoneCallWithRateLimit, + RateLimitInfo, + PhoneTranscript, + PhoneWebhook, + PhoneWebhookCreateResult, +} from "./phone/types.js"; +export type { + AgentIdentity, + AgentIdentityDetail, + IdentityMailbox, + IdentityPhoneNumber, +} from "./identities/types.js"; diff --git a/typescript/src/phone/index.ts b/typescript/src/phone/index.ts index 1c7a73e..e9b5d3c 100644 --- a/typescript/src/phone/index.ts +++ b/typescript/src/phone/index.ts @@ -1,4 +1,3 @@ -export { InkboxPhone } from "./client.js"; export type { SigningKey } from "../resources/signing-keys.js"; export type { PhoneNumber, From 42e822d807d155a81511f9899024fc85e733aa69 Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:24:56 -0400 Subject: [PATCH 35/56] rm unused code --- typescript/src/client.ts | 66 ---------------------------- typescript/src/identities/client.ts | 52 ---------------------- typescript/src/identities/index.ts | 7 --- typescript/src/phone/client.ts | 67 ----------------------------- typescript/src/phone/index.ts | 8 ---- 5 files changed, 200 deletions(-) delete mode 100644 typescript/src/client.ts delete mode 100644 typescript/src/identities/client.ts delete mode 100644 typescript/src/identities/index.ts delete mode 100644 typescript/src/phone/client.ts delete mode 100644 typescript/src/phone/index.ts diff --git a/typescript/src/client.ts b/typescript/src/client.ts deleted file mode 100644 index 3c6a534..0000000 --- a/typescript/src/client.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * inkbox-mail/client.ts - * - * Top-level InkboxMail client. - */ - -import { HttpTransport } from "./_http.js"; -import { MailboxesResource } from "./resources/mailboxes.js"; -import { MessagesResource } from "./resources/messages.js"; -import { SigningKeysResource } from "./resources/signing-keys.js"; -import { ThreadsResource } from "./resources/threads.js"; - -const DEFAULT_BASE_URL = "https://api.inkbox.ai/api/v1/mail"; - -export interface InkboxMailOptions { - /** Your Inkbox API key (sent as `X-Service-Token`). */ - apiKey: string; - /** Override the API base URL (useful for self-hosting or testing). */ - baseUrl?: string; - /** Request timeout in milliseconds. Defaults to 30 000. */ - timeoutMs?: number; -} - -/** - * Async client for the Inkbox Mail API. - * - * @example - * ```ts - * import { InkboxMail } from "@inkbox/mail"; - * - * const client = new InkboxMail({ apiKey: "ApiKey_..." }); - * - * const mailbox = await client.mailboxes.create({ agentHandle: "sales-agent" }); - * - * await client.messages.send(mailbox.emailAddress, { - * to: ["user@example.com"], - * subject: "Hello from Inkbox", - * bodyText: "Hi there!", - * }); - * - * for await (const msg of client.messages.list(mailbox.emailAddress)) { - * console.log(msg.subject, msg.fromAddress); - * } - * ``` - */ -export class InkboxMail { - readonly mailboxes: MailboxesResource; - readonly messages: MessagesResource; - readonly threads: ThreadsResource; - readonly signingKeys: SigningKeysResource; - - private readonly http: HttpTransport; - private readonly apiHttp: HttpTransport; - - constructor(options: InkboxMailOptions) { - const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL; - this.http = new HttpTransport(options.apiKey, baseUrl, options.timeoutMs ?? 30_000); - // Signing keys live at the API root (one level up from /mail) - const apiRoot = baseUrl.replace(/\/mail\/?$/, ""); - this.apiHttp = new HttpTransport(options.apiKey, apiRoot, options.timeoutMs ?? 30_000); - this.mailboxes = new MailboxesResource(this.http); - this.messages = new MessagesResource(this.http); - this.threads = new ThreadsResource(this.http); - this.signingKeys = new SigningKeysResource(this.apiHttp); - } -} diff --git a/typescript/src/identities/client.ts b/typescript/src/identities/client.ts deleted file mode 100644 index 39140b5..0000000 --- a/typescript/src/identities/client.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * inkbox-identities/client.ts - * - * Top-level InkboxIdentities client. - */ - -import { HttpTransport } from "../_http.js"; -import { IdentitiesResource } from "./resources/identities.js"; - -const DEFAULT_BASE_URL = "https://api.inkbox.ai/api/v1/identities"; - -export interface InkboxIdentitiesOptions { - /** Your Inkbox API key (sent as `X-Service-Token`). */ - apiKey: string; - /** Override the API base URL (useful for self-hosting or testing). */ - baseUrl?: string; - /** Request timeout in milliseconds. Defaults to 30 000. */ - timeoutMs?: number; -} - -/** - * Client for the Inkbox Identities API. - * - * @example - * ```ts - * import { InkboxIdentities } from "@inkbox/sdk/identities"; - * - * const client = new InkboxIdentities({ apiKey: "ApiKey_..." }); - * - * const identity = await client.identities.create({ agentHandle: "sales-agent" }); - * - * const detail = await client.identities.assignMailbox("sales-agent", { - * mailboxId: "", - * }); - * - * console.log(detail.mailbox?.emailAddress); - * ``` - */ -export class InkboxIdentities { - readonly identities: IdentitiesResource; - - private readonly http: HttpTransport; - - constructor(options: InkboxIdentitiesOptions) { - this.http = new HttpTransport( - options.apiKey, - options.baseUrl ?? DEFAULT_BASE_URL, - options.timeoutMs ?? 30_000, - ); - this.identities = new IdentitiesResource(this.http); - } -} diff --git a/typescript/src/identities/index.ts b/typescript/src/identities/index.ts deleted file mode 100644 index 7ee11c0..0000000 --- a/typescript/src/identities/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { InkboxIdentities } from "./client.js"; -export type { - AgentIdentity, - AgentIdentityDetail, - IdentityMailbox, - IdentityPhoneNumber, -} from "./types.js"; diff --git a/typescript/src/phone/client.ts b/typescript/src/phone/client.ts deleted file mode 100644 index db0702b..0000000 --- a/typescript/src/phone/client.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * inkbox-phone/client.ts - * - * Top-level InkboxPhone client. - */ - -import { HttpTransport } from "../_http.js"; -import { PhoneNumbersResource } from "./resources/numbers.js"; -import { CallsResource } from "./resources/calls.js"; -import { TranscriptsResource } from "./resources/transcripts.js"; -import { PhoneWebhooksResource } from "./resources/webhooks.js"; -import { SigningKeysResource } from "../resources/signing-keys.js"; - -const DEFAULT_BASE_URL = "https://api.inkbox.ai/api/v1/phone"; - -export interface InkboxPhoneOptions { - /** Your Inkbox API key (sent as `X-Service-Token`). */ - apiKey: string; - /** Override the API base URL (useful for self-hosting or testing). */ - baseUrl?: string; - /** Request timeout in milliseconds. Defaults to 30 000. */ - timeoutMs?: number; -} - -/** - * Client for the Inkbox Phone API. - * - * @example - * ```ts - * import { InkboxPhone } from "@inkbox/sdk/phone"; - * - * const client = new InkboxPhone({ apiKey: "ApiKey_..." }); - * - * const number = await client.numbers.provision({ agentHandle: "sales-agent" }); - * - * const call = await client.calls.place({ - * fromNumber: number.number, - * toNumber: "+15167251294", - * clientWebsocketUrl: "wss://your-agent.example.com/ws", - * }); - * - * console.log(call.status); - * ``` - */ -export class InkboxPhone { - readonly numbers: PhoneNumbersResource; - readonly calls: CallsResource; - readonly transcripts: TranscriptsResource; - readonly webhooks: PhoneWebhooksResource; - readonly signingKeys: SigningKeysResource; - - private readonly http: HttpTransport; - private readonly apiHttp: HttpTransport; - - constructor(options: InkboxPhoneOptions) { - const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL; - this.http = new HttpTransport(options.apiKey, baseUrl, options.timeoutMs ?? 30_000); - // Signing keys live at the API root (one level up from /phone) - const apiRoot = baseUrl.replace(/\/phone\/?$/, ""); - this.apiHttp = new HttpTransport(options.apiKey, apiRoot, options.timeoutMs ?? 30_000); - this.numbers = new PhoneNumbersResource(this.http); - this.calls = new CallsResource(this.http); - this.transcripts = new TranscriptsResource(this.http); - this.webhooks = new PhoneWebhooksResource(this.http); - this.signingKeys = new SigningKeysResource(this.apiHttp); - } -} diff --git a/typescript/src/phone/index.ts b/typescript/src/phone/index.ts deleted file mode 100644 index e9b5d3c..0000000 --- a/typescript/src/phone/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type { SigningKey } from "../resources/signing-keys.js"; -export type { - PhoneNumber, - PhoneCall, - PhoneCallWithRateLimit, - RateLimitInfo, - PhoneTranscript, -} from "./types.js"; From 5cfd6c9c327bdfb781360cbae9ca6027bebf0852 Mon Sep 17 00:00:00 2001 From: "Ruizhi (Ray) Liao" <17989965+rayruizhiliao@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:35:12 -0400 Subject: [PATCH 36/56] Update typescript/README.md Co-authored-by: Dima Vremenko <90374336+dimavrem22@users.noreply.github.com> --- typescript/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/typescript/README.md b/typescript/README.md index 035eaa8..839c861 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -69,9 +69,11 @@ const phone = await agent.assignPhoneNumber({ type: "toll_free" }); // pro console.log(mailbox.emailAddress); console.log(phone.number); -// Get an existing agent +// Get an existing agent (returned with current channel state) const agent2 = await inkbox.identities.get("sales-bot"); -await agent2.refresh(); // re-fetch channels from API + +// If the agent's channels may have changed since you fetched it, re-sync: +await agent2.refresh(); // List / update / delete const allIdentities = await inkbox.identities.list(); From 1417dc31b1b4824bfc30ea17db1d87a0de8bbe12 Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Fri, 13 Mar 2026 12:52:54 -0400 Subject: [PATCH 37/56] improve abstraction --- README.md | 188 ++++++------------ python/inkbox/__init__.py | 10 +- python/inkbox/{agent.py => agent_identity.py} | 136 ++++++++----- python/inkbox/client.py | 135 ++++++------- python/inkbox/identities/__init__.py | 8 +- .../inkbox/identities/resources/identities.py | 26 +-- python/inkbox/identities/types.py | 13 +- python/tests/conftest.py | 10 +- python/tests/test_calls.py | 14 +- python/tests/test_client.py | 10 +- python/tests/test_identities.py | 10 +- python/tests/test_identities_client.py | 6 +- python/tests/test_identities_types.py | 14 +- python/tests/test_mail_client.py | 10 +- python/tests/test_numbers.py | 24 +-- python/tests/test_transcripts.py | 4 +- python/tests/test_webhooks.py | 14 +- typescript/src/agent.ts | 128 +++++++----- .../src/identities/resources/identities.ts | 48 ++--- typescript/src/identities/types.ts | 16 +- typescript/src/index.ts | 5 +- typescript/src/inkbox.ts | 170 ++++++++-------- typescript/tests/identities/types.test.ts | 14 +- 23 files changed, 494 insertions(+), 519 deletions(-) rename python/inkbox/{agent.py => agent_identity.py} (62%) diff --git a/README.md b/README.md index 8cfe892..ee0a56b 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Official SDKs for the [Inkbox API](https://inkbox.ai) — API-first communicatio ## Identities -Agent identities are the central concept — a named agent (e.g. `"sales-agent"`) that owns a mailbox and/or phone number. +Agent identities are the central concept — a named identity (e.g. `"sales-agent"`) that owns a mailbox and/or phone number. Use `Inkbox` as the org-level entry point to create and retrieve identities. ### Python @@ -19,21 +19,21 @@ Agent identities are the central concept — a named agent (e.g. `"sales-agent"` from inkbox import Inkbox with Inkbox(api_key="ApiKey_...") as inkbox: - # Create an identity — returns an Agent object - agent = inkbox.identities.create(agent_handle="sales-agent") + # Create an identity — returns an AgentIdentity object + identity = inkbox.create_identity("sales-agent") # Provision and link channels in one call each - mailbox = agent.assign_mailbox(display_name="Sales Agent") - phone = agent.assign_phone_number(type="toll_free") + mailbox = identity.assign_mailbox(display_name="Sales Agent") + phone = identity.assign_phone_number(type="toll_free") print(mailbox.email_address) print(phone.number) # List, get, update, delete - identities = inkbox.identities.list() - agent = inkbox.identities.get("sales-agent") - inkbox.identities.update("sales-agent", status="paused") - agent.delete() + identities = inkbox.list_identities() + identity = inkbox.get_identity("sales-agent") + identity.update(status="paused") + identity.delete() ``` ### TypeScript @@ -43,21 +43,21 @@ import { Inkbox } from "@inkbox/sdk"; const inkbox = new Inkbox({ apiKey: "ApiKey_..." }); -// Create an identity — returns an Agent object -const agent = await inkbox.identities.create({ agentHandle: "sales-agent" }); +// Create an identity — returns an AgentIdentity object +const identity = await inkbox.createIdentity("sales-agent"); // Provision and link channels in one call each -const mailbox = await agent.assignMailbox({ displayName: "Sales Agent" }); -const phone = await agent.assignPhoneNumber({ type: "toll_free" }); +const mailbox = await identity.assignMailbox({ displayName: "Sales Agent" }); +const phone = await identity.assignPhoneNumber({ type: "toll_free" }); console.log(mailbox.emailAddress); console.log(phone.number); // List, get, update, delete -const identities = await inkbox.identities.list(); -const a = await inkbox.identities.get("sales-agent"); -await inkbox.identities.update("sales-agent", { status: "paused" }); -await a.delete(); +const identities = await inkbox.listIdentities(); +const i = await inkbox.getIdentity("sales-agent"); +await i.update({ status: "paused" }); +await i.delete(); ``` --- @@ -70,44 +70,19 @@ await a.delete(); from inkbox import Inkbox with Inkbox(api_key="ApiKey_...") as inkbox: - # Create a mailbox - mailbox = inkbox.mailboxes.create(display_name="Sales Agent") + identity = inkbox.create_identity("sales-agent") + identity.assign_mailbox(display_name="Sales Agent") # Send an email - inkbox.messages.send( - mailbox.email_address, + identity.send_email( to=["user@example.com"], subject="Hello from Inkbox", body_text="Hi there!", ) # Iterate over all messages (pagination handled automatically) - for msg in inkbox.messages.list(mailbox.email_address): + for msg in identity.messages(): print(msg.subject, msg.from_address) - - # Reply to a message - detail = inkbox.messages.get(mailbox.email_address, msg.id) - inkbox.messages.send( - mailbox.email_address, - to=detail.to_addresses, - subject=f"Re: {detail.subject}", - body_text="Got it, thanks!", - in_reply_to_message_id=detail.message_id, - ) - - # Update mailbox display name - inkbox.mailboxes.update(mailbox.email_address, display_name="Support Agent") - - # Search - results = inkbox.mailboxes.search(mailbox.email_address, q="invoice") - - # Webhooks (secret is one-time — save it immediately) - hook = inkbox.mail_webhooks.create( - mailbox.email_address, - url="https://yourapp.com/hooks/mail", - event_types=["message.received"], - ) - print(hook.secret) # save this ``` ### TypeScript @@ -116,43 +91,20 @@ with Inkbox(api_key="ApiKey_...") as inkbox: import { Inkbox } from "@inkbox/sdk"; const inkbox = new Inkbox({ apiKey: "ApiKey_..." }); - -// Create a mailbox -const mailbox = await inkbox.mailboxes.create({ displayName: "Sales Agent" }); +const identity = await inkbox.createIdentity("sales-agent"); +await identity.assignMailbox({ displayName: "Sales Agent" }); // Send an email -await inkbox.messages.send(mailbox.emailAddress, { +await identity.sendEmail({ to: ["user@example.com"], subject: "Hello from Inkbox", bodyText: "Hi there!", }); // Iterate over all messages (pagination handled automatically) -for await (const msg of inkbox.messages.list(mailbox.emailAddress)) { +for await (const msg of identity.messages()) { console.log(msg.subject, msg.fromAddress); } - -// Reply to a message -const detail = await inkbox.messages.get(mailbox.emailAddress, msg.id); -await inkbox.messages.send(mailbox.emailAddress, { - to: detail.toAddresses, - subject: `Re: ${detail.subject}`, - bodyText: "Got it, thanks!", - inReplyToMessageId: detail.messageId, -}); - -// Update mailbox display name -await inkbox.mailboxes.update(mailbox.emailAddress, { displayName: "Support Agent" }); - -// Search -const results = await inkbox.mailboxes.search(mailbox.emailAddress, { q: "invoice" }); - -// Webhooks (secret is one-time — save it immediately) -const hook = await inkbox.mailWebhooks.create(mailbox.emailAddress, { - url: "https://yourapp.com/hooks/mail", - eventTypes: ["message.received"], -}); -console.log(hook.secret); // save this ``` --- @@ -165,42 +117,19 @@ console.log(hook.secret); // save this from inkbox import Inkbox with Inkbox(api_key="ApiKey_...") as inkbox: - # Provision a phone number - number = inkbox.numbers.provision(type="toll_free") - - # Update settings - inkbox.numbers.update( - number.id, - incoming_call_action="auto_accept", - default_stream_url="wss://your-agent.example.com/ws", - ) + identity = inkbox.create_identity("sales-agent") + identity.assign_phone_number(type="toll_free") # Place an outbound call - call = inkbox.calls.place( - from_number=number.number, + call = identity.place_call( to_number="+15167251294", stream_url="wss://your-agent.example.com/ws", ) print(call.status) print(call.rate_limit.calls_remaining) - # List calls and transcripts - calls = inkbox.calls.list(number.id) - transcripts = inkbox.transcripts.list(number.id, calls[0].id) - # Search transcripts - results = inkbox.numbers.search_transcripts(number.id, q="appointment") - - # Webhooks - hook = inkbox.phone_webhooks.create( - number.id, - url="https://yourapp.com/hooks/phone", - event_types=["call.completed"], - ) - print(hook.secret) # save this - - # Release a number - inkbox.numbers.release(number=number.number) + results = identity.search_transcripts(q="appointment") ``` ### TypeScript @@ -209,41 +138,48 @@ with Inkbox(api_key="ApiKey_...") as inkbox: import { Inkbox } from "@inkbox/sdk"; const inkbox = new Inkbox({ apiKey: "ApiKey_..." }); - -// Provision a phone number -const number = await inkbox.numbers.provision({ type: "toll_free" }); - -// Update settings -await inkbox.numbers.update(number.id, { - incomingCallAction: "auto_accept", - defaultStreamUrl: "wss://your-agent.example.com/ws", -}); +const identity = await inkbox.createIdentity("sales-agent"); +await identity.assignPhoneNumber({ type: "toll_free" }); // Place an outbound call -const call = await inkbox.calls.place({ - fromNumber: number.number, +const call = await identity.placeCall({ toNumber: "+15167251294", streamUrl: "wss://your-agent.example.com/ws", }); console.log(call.status); console.log(call.rateLimit.callsRemaining); -// List calls and transcripts -const calls = await inkbox.calls.list(number.id); -const transcripts = await inkbox.transcripts.list(number.id, calls[0].id); - // Search transcripts -const results = await inkbox.numbers.searchTranscripts(number.id, { q: "appointment" }); +const results = await identity.searchTranscripts({ q: "appointment" }); +``` -// Webhooks -const hook = await inkbox.phoneWebhooks.create(number.id, { - url: "https://yourapp.com/hooks/phone", - eventTypes: ["call.completed"], -}); -console.log(hook.secret); // save this +--- + +## Signing Keys + +Org-level webhook signing keys are managed through the `Inkbox` client. + +### Python + +```python +from inkbox import Inkbox + +with Inkbox(api_key="ApiKey_...") as inkbox: + # Create or rotate the org-level signing key (plaintext returned once) + key = inkbox.create_signing_key() + print(key.signing_key) # save this +``` + +### TypeScript + +```ts +import { Inkbox } from "@inkbox/sdk"; + +const inkbox = new Inkbox({ apiKey: "ApiKey_..." }); -// Release a number -await inkbox.numbers.release({ number: number.number }); +// Create or rotate the org-level signing key (plaintext returned once) +const key = await inkbox.createSigningKey(); +console.log(key.signingKey); // save this ``` --- @@ -262,7 +198,7 @@ is_valid = verify_webhook( signature=request.headers["X-Inkbox-Signature"], request_id=request.headers["X-Inkbox-Request-ID"], timestamp=request.headers["X-Inkbox-Timestamp"], - secret="whsec_...", # from POST /signing-keys + secret="whsec_...", # from create_signing_key() ) ``` @@ -276,7 +212,7 @@ const valid = verifyWebhook({ signature: req.headers["x-inkbox-signature"] as string, requestId: req.headers["x-inkbox-request-id"] as string, timestamp: req.headers["x-inkbox-timestamp"] as string, - secret: "whsec_...", // from POST /signing-keys + secret: "whsec_...", // from createSigningKey() }); ``` diff --git a/python/inkbox/__init__.py b/python/inkbox/__init__.py index 29f3a52..6aefed4 100644 --- a/python/inkbox/__init__.py +++ b/python/inkbox/__init__.py @@ -3,7 +3,7 @@ """ from inkbox.client import Inkbox -from inkbox.agent import Agent +from inkbox.agent_identity import AgentIdentity # Exceptions (canonical source: mail; identical in all submodules) from inkbox.mail.exceptions import InkboxAPIError, InkboxError @@ -32,8 +32,7 @@ # Identity types from inkbox.identities.types import ( - AgentIdentity, - AgentIdentityDetail, + AgentIdentitySummary, IdentityMailbox, IdentityPhoneNumber, ) @@ -44,7 +43,7 @@ __all__ = [ # Entry points "Inkbox", - "Agent", + "AgentIdentity", # Exceptions "InkboxError", "InkboxAPIError", @@ -65,8 +64,7 @@ "PhoneWebhookCreateResult", "RateLimitInfo", # Identity types - "AgentIdentity", - "AgentIdentityDetail", + "AgentIdentitySummary", "IdentityMailbox", "IdentityPhoneNumber", # Signing key + webhook verification diff --git a/python/inkbox/agent.py b/python/inkbox/agent_identity.py similarity index 62% rename from python/inkbox/agent.py rename to python/inkbox/agent_identity.py index 9ec23a3..6a63bd9 100644 --- a/python/inkbox/agent.py +++ b/python/inkbox/agent_identity.py @@ -1,8 +1,8 @@ """ -inkbox/agent.py +inkbox/agent_identity.py -Agent — a domain object representing one agent identity. -Returned by inkbox.identities.create() and inkbox.identities.get(). +AgentIdentity — a domain object representing one agent identity. +Returned by inkbox.create_identity() and inkbox.get_identity(). Convenience methods (send_email, place_call, etc.) are scoped to this agent's assigned channels so callers never need to pass an email address @@ -13,7 +13,7 @@ from typing import TYPE_CHECKING, Iterator -from inkbox.identities.types import AgentIdentityDetail, IdentityMailbox, IdentityPhoneNumber +from inkbox.identities.types import _AgentIdentityData, IdentityMailbox, IdentityPhoneNumber from inkbox.mail.exceptions import InkboxError from inkbox.mail.types import Message from inkbox.phone.types import PhoneCallWithRateLimit, PhoneTranscript @@ -22,32 +22,32 @@ from inkbox.client import Inkbox -class Agent: +class AgentIdentity: """An agent identity with convenience methods for its assigned channels. Obtain an instance via:: - agent = inkbox.identities.create(agent_handle="support-bot") + identity = inkbox.create_identity("support-bot") # or - agent = inkbox.identities.get("support-bot") + identity = inkbox.get_identity("support-bot") After assigning channels you can communicate directly:: - agent.assign_mailbox(display_name="Support Bot") - agent.assign_phone_number(type="toll_free") + identity.assign_mailbox(display_name="Support Bot") + identity.assign_phone_number(type="toll_free") - agent.send_email(to=["user@example.com"], subject="Hi", body_text="Hello") - agent.place_call(to_number="+15555550100", stream_url="wss://my-app.com/ws") + identity.send_email(to=["user@example.com"], subject="Hi", body_text="Hello") + identity.place_call(to_number="+15555550100", stream_url="wss://my-app.com/ws") - for msg in agent.messages(): + for msg in identity.messages(): print(msg.subject) """ - def __init__(self, identity: AgentIdentityDetail, inkbox: Inkbox) -> None: - self._identity = identity + def __init__(self, data: _AgentIdentityData, inkbox: Inkbox) -> None: + self._data = data self._inkbox = inkbox - self._mailbox: IdentityMailbox | None = identity.mailbox - self._phone_number: IdentityPhoneNumber | None = identity.phone_number + self._mailbox: IdentityMailbox | None = data.mailbox + self._phone_number: IdentityPhoneNumber | None = data.phone_number # ------------------------------------------------------------------ # Identity properties @@ -55,15 +55,15 @@ def __init__(self, identity: AgentIdentityDetail, inkbox: Inkbox) -> None: @property def agent_handle(self) -> str: - return self._identity.agent_handle + return self._data.agent_handle @property def id(self): - return self._identity.id + return self._data.id @property def status(self) -> str: - return self._identity.status + return self._data.status @property def mailbox(self) -> IdentityMailbox | None: @@ -79,7 +79,7 @@ def phone_number(self) -> IdentityPhoneNumber | None: # ------------------------------------------------------------------ def assign_mailbox(self, *, display_name: str | None = None) -> IdentityMailbox: - """Create a new mailbox and assign it to this agent. + """Create a new mailbox and assign it to this identity. Args: display_name: Optional human-readable sender name. @@ -87,18 +87,24 @@ def assign_mailbox(self, *, display_name: str | None = None) -> IdentityMailbox: Returns: The assigned mailbox. """ - mailbox = self._inkbox.mailboxes.create(display_name=display_name) - detail = self._inkbox._ids_resource.assign_mailbox( + mailbox = self._inkbox._mailboxes.create(display_name=display_name) + data = self._inkbox._ids_resource.assign_mailbox( self.agent_handle, mailbox_id=mailbox.id ) - self._mailbox = detail.mailbox - self._identity = detail + self._mailbox = data.mailbox + self._data = data return self._mailbox # type: ignore[return-value] + def unlink_mailbox(self) -> None: + """Unlink this identity's mailbox (does not delete the mailbox).""" + self._require_mailbox() + self._inkbox._ids_resource.unlink_mailbox(self.agent_handle) + self._mailbox = None + def assign_phone_number( self, *, type: str = "toll_free", state: str | None = None ) -> IdentityPhoneNumber: - """Provision a new phone number and assign it to this agent. + """Provision a new phone number and assign it to this identity. Args: type: ``"toll_free"`` (default) or ``"local"``. @@ -107,14 +113,20 @@ def assign_phone_number( Returns: The assigned phone number. """ - number = self._inkbox.numbers.provision(type=type, state=state) - detail = self._inkbox._ids_resource.assign_phone_number( + number = self._inkbox._numbers.provision(type=type, state=state) + data = self._inkbox._ids_resource.assign_phone_number( self.agent_handle, phone_number_id=number.id ) - self._phone_number = detail.phone_number - self._identity = detail + self._phone_number = data.phone_number + self._data = data return self._phone_number # type: ignore[return-value] + def unlink_phone_number(self) -> None: + """Unlink this identity's phone number (does not release the number).""" + self._require_phone() + self._inkbox._ids_resource.unlink_phone_number(self.agent_handle) + self._phone_number = None + # ------------------------------------------------------------------ # Mail helpers # ------------------------------------------------------------------ @@ -131,7 +143,7 @@ def send_email( in_reply_to_message_id: str | None = None, attachments: list[dict] | None = None, ) -> Message: - """Send an email from this agent's mailbox. + """Send an email from this identity's mailbox. Args: to: Primary recipient addresses (at least one required). @@ -145,7 +157,7 @@ def send_email( ``content_type``, and ``content_base64`` keys. """ self._require_mailbox() - return self._inkbox.messages.send( + return self._inkbox._messages.send( self._mailbox.email_address, # type: ignore[union-attr] to=to, subject=subject, @@ -163,7 +175,7 @@ def messages( page_size: int = 50, direction: str | None = None, ) -> Iterator[Message]: - """Iterate over messages in this agent's inbox, newest first. + """Iterate over messages in this identity's inbox, newest first. Pagination is handled automatically. @@ -172,7 +184,7 @@ def messages( direction: Filter by ``"inbound"`` or ``"outbound"``. """ self._require_mailbox() - return self._inkbox.messages.list( + return self._inkbox._messages.list( self._mailbox.email_address, # type: ignore[union-attr] page_size=page_size, direction=direction, @@ -190,7 +202,7 @@ def place_call( pipeline_mode: str | None = None, webhook_url: str | None = None, ) -> PhoneCallWithRateLimit: - """Place an outbound call from this agent's phone number. + """Place an outbound call from this identity's phone number. Args: to_number: E.164 destination number. @@ -199,7 +211,7 @@ def place_call( webhook_url: Custom webhook URL for call lifecycle events. """ self._require_phone() - return self._inkbox.calls.place( + return self._inkbox._calls.place( from_number=self._phone_number.number, # type: ignore[union-attr] to_number=to_number, stream_url=stream_url, @@ -214,7 +226,7 @@ def search_transcripts( party: str | None = None, limit: int = 50, ) -> list[PhoneTranscript]: - """Full-text search across call transcripts for this agent's number. + """Full-text search across call transcripts for this identity's number. Args: q: Search query string. @@ -222,7 +234,7 @@ def search_transcripts( limit: Maximum number of results (1–200). """ self._require_phone() - return self._inkbox.numbers.search_transcripts( + return self._inkbox._numbers.search_transcripts( self._phone_number.id, # type: ignore[union-attr] q=q, party=party, @@ -230,19 +242,45 @@ def search_transcripts( ) # ------------------------------------------------------------------ - # Misc + # Identity management # ------------------------------------------------------------------ - def refresh(self) -> Agent: - """Re-fetch this agent's identity from the API and update cached channels. + def update( + self, + *, + new_handle: str | None = None, + status: str | None = None, + ) -> None: + """Update this identity's handle or status. + + Args: + new_handle: New agent handle. + status: New lifecycle status: ``"active"`` or ``"paused"``. + """ + result = self._inkbox._ids_resource.update( + self.agent_handle, new_handle=new_handle, status=status + ) + self._data = _AgentIdentityData( + id=result.id, + organization_id=result.organization_id, + agent_handle=result.agent_handle, + status=result.status, + created_at=result.created_at, + updated_at=result.updated_at, + mailbox=self._mailbox, + phone_number=self._phone_number, + ) + + def refresh(self) -> AgentIdentity: + """Re-fetch this identity from the API and update cached channels. Returns: ``self`` for chaining. """ - detail = self._inkbox._ids_resource.get(self.agent_handle) - self._identity = detail - self._mailbox = detail.mailbox - self._phone_number = detail.phone_number + data = self._inkbox._ids_resource.get(self.agent_handle) + self._data = data + self._mailbox = data.mailbox + self._phone_number = data.phone_number return self def delete(self) -> None: @@ -256,20 +294,20 @@ def delete(self) -> None: def _require_mailbox(self) -> None: if not self._mailbox: raise InkboxError( - f"Agent '{self.agent_handle}' has no mailbox assigned. " - "Call agent.assign_mailbox() first." + f"Identity '{self.agent_handle}' has no mailbox assigned. " + "Call identity.assign_mailbox() first." ) def _require_phone(self) -> None: if not self._phone_number: raise InkboxError( - f"Agent '{self.agent_handle}' has no phone number assigned. " - "Call agent.assign_phone_number() first." + f"Identity '{self.agent_handle}' has no phone number assigned. " + "Call identity.assign_phone_number() first." ) def __repr__(self) -> str: return ( - f"Agent(agent_handle={self.agent_handle!r}, " + f"AgentIdentity(agent_handle={self.agent_handle!r}, " f"mailbox={self._mailbox.email_address if self._mailbox else None!r}, " f"phone={self._phone_number.number if self._phone_number else None!r})" ) diff --git a/python/inkbox/client.py b/python/inkbox/client.py index a58eac4..a6faaed 100644 --- a/python/inkbox/client.py +++ b/python/inkbox/client.py @@ -1,13 +1,11 @@ """ inkbox/client.py -Unified Inkbox client — single entry point for all Inkbox APIs. +Inkbox — org-level entry point for all Inkbox APIs. """ from __future__ import annotations -from typing import TYPE_CHECKING - from inkbox.mail._http import HttpTransport as MailHttpTransport from inkbox.mail.resources.mailboxes import MailboxesResource from inkbox.mail.resources.messages import MessagesResource @@ -20,16 +18,14 @@ from inkbox.phone.resources.webhooks import PhoneWebhooksResource from inkbox.identities._http import HttpTransport as IdsHttpTransport from inkbox.identities.resources.identities import IdentitiesResource -from inkbox.signing_keys import SigningKeysResource - -if TYPE_CHECKING: - from inkbox.agent import Agent +from inkbox.identities.types import AgentIdentitySummary +from inkbox.signing_keys import SigningKey, SigningKeysResource _DEFAULT_BASE_URL = "https://api.inkbox.ai" class Inkbox: - """Unified client for all Inkbox APIs. + """Org-level entry point for all Inkbox APIs. Args: api_key: Your Inkbox API key (``X-Service-Token``). @@ -41,9 +37,9 @@ class Inkbox: from inkbox import Inkbox with Inkbox(api_key="ApiKey_...") as inkbox: - agent = inkbox.identities.create(agent_handle="support-bot") - agent.assign_mailbox(display_name="Support Bot") - agent.send_email( + identity = inkbox.create_identity("support-bot") + identity.assign_mailbox(display_name="Support Bot") + identity.send_email( to=["customer@example.com"], subject="Hello!", body_text="Hi there", @@ -68,99 +64,80 @@ def __init__( self._ids_http = IdsHttpTransport( api_key=api_key, base_url=f"{_api_root}/identities", timeout=timeout ) - # Signing keys live at the API root self._api_http = MailHttpTransport( api_key=api_key, base_url=_api_root, timeout=timeout ) - # Mail resources - self.mailboxes = MailboxesResource(self._mail_http) - self.messages = MessagesResource(self._mail_http) - self.threads = ThreadsResource(self._mail_http) - self.mail_webhooks = MailWebhooksResource(self._mail_http) + # Internal resources — used by AgentIdentity + self._mailboxes = MailboxesResource(self._mail_http) + self._messages = MessagesResource(self._mail_http) + self._threads = ThreadsResource(self._mail_http) + self._mail_webhooks = MailWebhooksResource(self._mail_http) - # Phone resources - self.calls = CallsResource(self._phone_http) - self.numbers = PhoneNumbersResource(self._phone_http) - self.transcripts = TranscriptsResource(self._phone_http) - self.phone_webhooks = PhoneWebhooksResource(self._phone_http) + self._calls = CallsResource(self._phone_http) + self._numbers = PhoneNumbersResource(self._phone_http) + self._transcripts = TranscriptsResource(self._phone_http) + self._phone_webhooks = PhoneWebhooksResource(self._phone_http) - # Shared - self.signing_keys = SigningKeysResource(self._api_http) - - # Identities — internal resource used by Agent, wrapped namespace for users + self._signing_keys = SigningKeysResource(self._api_http) self._ids_resource = IdentitiesResource(self._ids_http) - self.identities = _IdentitiesNamespace(self._ids_resource, self) - - def close(self) -> None: - """Close all underlying HTTP connection pools.""" - self._mail_http.close() - self._phone_http.close() - self._ids_http.close() - self._api_http.close() - - def __enter__(self) -> Inkbox: - return self - - def __exit__(self, *_: object) -> None: - self.close() + # ------------------------------------------------------------------ + # Org-level operations + # ------------------------------------------------------------------ -class _IdentitiesNamespace: - """Thin wrapper around IdentitiesResource that returns Agent objects.""" - - def __init__(self, resource: IdentitiesResource, inkbox: Inkbox) -> None: - self._r = resource - self._inkbox = inkbox - - def create(self, *, agent_handle: str) -> Agent: - """Create a new agent identity and return it as an Agent object. + def create_identity(self, agent_handle: str) -> AgentIdentity: + """Create a new agent identity. Args: - agent_handle: Unique handle for this agent (e.g. ``"support-bot"``). + agent_handle: Unique handle for this identity (e.g. ``"sales-bot"``). + + Returns: + The created :class:`AgentIdentity`. """ - from inkbox.agent import Agent + from inkbox.agent_identity import AgentIdentity - self._r.create(agent_handle=agent_handle) - # POST /identities returns AgentIdentity (no channel fields); - # fetch the detail to get a fully-populated AgentIdentityDetail. - detail = self._r.get(agent_handle) - return Agent(detail, self._inkbox) + self._ids_resource.create(agent_handle=agent_handle) + data = self._ids_resource.get(agent_handle) + return AgentIdentity(data, self) - def get(self, agent_handle: str) -> Agent: + def get_identity(self, agent_handle: str) -> AgentIdentity: """Get an agent identity by handle. Args: agent_handle: Handle of the identity to fetch. + + Returns: + The :class:`AgentIdentity`. """ - from inkbox.agent import Agent + from inkbox.agent_identity import AgentIdentity - return Agent(self._r.get(agent_handle), self._inkbox) + return AgentIdentity(self._ids_resource.get(agent_handle), self) - def list(self): + def list_identities(self) -> list[AgentIdentitySummary]: """List all agent identities for your organisation.""" - return self._r.list() + return self._ids_resource.list() - def update(self, *args, **kwargs): - """Update an identity's handle or status.""" - return self._r.update(*args, **kwargs) + def create_signing_key(self) -> SigningKey: + """Create or rotate the org-level webhook signing key. - def delete(self, *args, **kwargs): - """Delete an identity.""" - return self._r.delete(*args, **kwargs) + The plaintext key is returned once — save it immediately. + """ + return self._signing_keys.create_or_rotate() - def assign_mailbox(self, *args, **kwargs): - """Assign an existing mailbox to an identity by ID.""" - return self._r.assign_mailbox(*args, **kwargs) + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ - def unlink_mailbox(self, *args, **kwargs): - """Unlink a mailbox from an identity.""" - return self._r.unlink_mailbox(*args, **kwargs) + def close(self) -> None: + """Close all underlying HTTP connection pools.""" + self._mail_http.close() + self._phone_http.close() + self._ids_http.close() + self._api_http.close() - def assign_phone_number(self, *args, **kwargs): - """Assign an existing phone number to an identity by ID.""" - return self._r.assign_phone_number(*args, **kwargs) + def __enter__(self) -> Inkbox: + return self - def unlink_phone_number(self, *args, **kwargs): - """Unlink a phone number from an identity.""" - return self._r.unlink_phone_number(*args, **kwargs) + def __exit__(self, *_: object) -> None: + self.close() diff --git a/python/inkbox/identities/__init__.py b/python/inkbox/identities/__init__.py index 128c519..f2000e8 100644 --- a/python/inkbox/identities/__init__.py +++ b/python/inkbox/identities/__init__.py @@ -3,15 +3,15 @@ """ from inkbox.identities.types import ( - AgentIdentity, - AgentIdentityDetail, + AgentIdentitySummary, + _AgentIdentityData, IdentityMailbox, IdentityPhoneNumber, ) __all__ = [ - "AgentIdentity", - "AgentIdentityDetail", + "AgentIdentitySummary", + "_AgentIdentityData", "IdentityMailbox", "IdentityPhoneNumber", ] diff --git a/python/inkbox/identities/resources/identities.py b/python/inkbox/identities/resources/identities.py index 30a3a1c..06343a0 100644 --- a/python/inkbox/identities/resources/identities.py +++ b/python/inkbox/identities/resources/identities.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Any from uuid import UUID -from inkbox.identities.types import AgentIdentity, AgentIdentityDetail +from inkbox.identities.types import AgentIdentitySummary, _AgentIdentityData if TYPE_CHECKING: from inkbox.identities._http import HttpTransport @@ -19,7 +19,7 @@ class IdentitiesResource: def __init__(self, http: HttpTransport) -> None: self._http = http - def create(self, *, agent_handle: str) -> AgentIdentity: + def create(self, *, agent_handle: str) -> AgentIdentitySummary: """Create a new agent identity. Args: @@ -30,21 +30,21 @@ def create(self, *, agent_handle: str) -> AgentIdentity: The created identity. """ data = self._http.post("/", json={"agent_handle": agent_handle}) - return AgentIdentity._from_dict(data) + return AgentIdentitySummary._from_dict(data) - def list(self) -> list[AgentIdentity]: + def list(self) -> list[AgentIdentitySummary]: """List all identities for your organisation.""" data = self._http.get("/") - return [AgentIdentity._from_dict(i) for i in data] + return [AgentIdentitySummary._from_dict(i) for i in data] - def get(self, agent_handle: str) -> AgentIdentityDetail: + def get(self, agent_handle: str) -> _AgentIdentityData: """Get an identity with its linked channels (mailbox, phone number). Args: agent_handle: Handle of the identity to fetch. """ data = self._http.get(f"/{agent_handle}") - return AgentIdentityDetail._from_dict(data) + return _AgentIdentityData._from_dict(data) def update( self, @@ -52,7 +52,7 @@ def update( *, new_handle: str | None = None, status: str | None = None, - ) -> AgentIdentity: + ) -> AgentIdentitySummary: """Update an identity's handle or status. Only provided fields are applied; omitted fields are left unchanged. @@ -68,7 +68,7 @@ def update( if status is not None: body["status"] = status data = self._http.patch(f"/{agent_handle}", json=body) - return AgentIdentity._from_dict(data) + return AgentIdentitySummary._from_dict(data) def delete(self, agent_handle: str) -> None: """Soft-delete an identity. @@ -85,7 +85,7 @@ def assign_mailbox( agent_handle: str, *, mailbox_id: UUID | str, - ) -> AgentIdentityDetail: + ) -> _AgentIdentityData: """Assign a mailbox to an identity. Args: @@ -96,7 +96,7 @@ def assign_mailbox( f"/{agent_handle}/mailbox", json={"mailbox_id": str(mailbox_id)}, ) - return AgentIdentityDetail._from_dict(data) + return _AgentIdentityData._from_dict(data) def unlink_mailbox(self, agent_handle: str) -> None: """Unlink the mailbox from an identity (does not delete the mailbox). @@ -111,7 +111,7 @@ def assign_phone_number( agent_handle: str, *, phone_number_id: UUID | str, - ) -> AgentIdentityDetail: + ) -> _AgentIdentityData: """Assign a phone number to an identity. Args: @@ -122,7 +122,7 @@ def assign_phone_number( f"/{agent_handle}/phone_number", json={"phone_number_id": str(phone_number_id)}, ) - return AgentIdentityDetail._from_dict(data) + return _AgentIdentityData._from_dict(data) def unlink_phone_number(self, agent_handle: str) -> None: """Unlink the phone number from an identity (does not delete the number). diff --git a/python/inkbox/identities/types.py b/python/inkbox/identities/types.py index 0222cc7..6ebaa36 100644 --- a/python/inkbox/identities/types.py +++ b/python/inkbox/identities/types.py @@ -67,8 +67,8 @@ def _from_dict(cls, d: dict[str, Any]) -> IdentityPhoneNumber: @dataclass -class AgentIdentity: - """An agent identity returned by create, list, and update endpoints.""" +class AgentIdentitySummary: + """Lightweight agent identity returned by list and update endpoints.""" id: UUID organization_id: str @@ -78,7 +78,7 @@ class AgentIdentity: updated_at: datetime @classmethod - def _from_dict(cls, d: dict[str, Any]) -> AgentIdentity: + def _from_dict(cls, d: dict[str, Any]) -> AgentIdentitySummary: return cls( id=UUID(d["id"]), organization_id=d["organization_id"], @@ -90,18 +90,19 @@ def _from_dict(cls, d: dict[str, Any]) -> AgentIdentity: @dataclass -class AgentIdentityDetail(AgentIdentity): +class _AgentIdentityData(AgentIdentitySummary): """Agent identity with linked communication channels. Returned by get, assign-mailbox, and assign-phone-number endpoints. + Internal — users interact with AgentIdentity (the domain class) instead. """ mailbox: IdentityMailbox | None = field(default=None) phone_number: IdentityPhoneNumber | None = field(default=None) @classmethod - def _from_dict(cls, d: dict[str, Any]) -> AgentIdentityDetail: # type: ignore[override] - base = AgentIdentity._from_dict(d) + def _from_dict(cls, d: dict[str, Any]) -> _AgentIdentityData: # type: ignore[override] + base = AgentIdentitySummary._from_dict(d) mailbox_data = d.get("mailbox") phone_data = d.get("phone_number") return cls( diff --git a/python/tests/conftest.py b/python/tests/conftest.py index f4b4b8d..f59fe4a 100644 --- a/python/tests/conftest.py +++ b/python/tests/conftest.py @@ -30,9 +30,9 @@ def client(transport: FakeHttpTransport) -> Inkbox: c = Inkbox(api_key="sk-test") c._phone_http = transport # type: ignore[attr-defined] c._api_http = transport # type: ignore[attr-defined] - c.numbers._http = transport - c.calls._http = transport - c.transcripts._http = transport - c.phone_webhooks._http = transport - c.signing_keys._http = transport + c._numbers._http = transport + c._calls._http = transport + c._transcripts._http = transport + c._phone_webhooks._http = transport + c._signing_keys._http = transport return c diff --git a/python/tests/test_calls.py b/python/tests/test_calls.py index 389fddb..d392ad3 100644 --- a/python/tests/test_calls.py +++ b/python/tests/test_calls.py @@ -13,7 +13,7 @@ class TestCallsList: def test_returns_calls(self, client, transport): transport.get.return_value = [PHONE_CALL_DICT] - calls = client.calls.list(NUM_ID, limit=5) + calls = client._calls.list(NUM_ID, limit=5) transport.get.assert_called_once_with( f"/numbers/{NUM_ID}/calls", @@ -26,7 +26,7 @@ def test_returns_calls(self, client, transport): def test_default_limit_and_offset(self, client, transport): transport.get.return_value = [] - client.calls.list(NUM_ID) + client._calls.list(NUM_ID) transport.get.assert_called_once_with( f"/numbers/{NUM_ID}/calls", @@ -36,7 +36,7 @@ def test_default_limit_and_offset(self, client, transport): def test_custom_offset(self, client, transport): transport.get.return_value = [] - client.calls.list(NUM_ID, limit=10, offset=20) + client._calls.list(NUM_ID, limit=10, offset=20) transport.get.assert_called_once_with( f"/numbers/{NUM_ID}/calls", @@ -48,7 +48,7 @@ class TestCallsGet: def test_returns_call(self, client, transport): transport.get.return_value = PHONE_CALL_DICT - call = client.calls.get(NUM_ID, CALL_ID) + call = client._calls.get(NUM_ID, CALL_ID) transport.get.assert_called_once_with(f"/numbers/{NUM_ID}/calls/{CALL_ID}") assert call.id == UUID(CALL_ID) @@ -68,7 +68,7 @@ def test_place_outbound_call(self, client, transport): "ended_at": None, } - call = client.calls.place( + call = client._calls.place( from_number="+18335794607", to_number="+15167251294", stream_url="wss://agent.example.com/ws", @@ -87,7 +87,7 @@ def test_place_outbound_call(self, client, transport): def test_place_with_pipeline_mode_and_webhook(self, client, transport): transport.post.return_value = PHONE_CALL_DICT - client.calls.place( + client._calls.place( from_number="+18335794607", to_number="+15167251294", stream_url="wss://agent.example.com/ws", @@ -102,7 +102,7 @@ def test_place_with_pipeline_mode_and_webhook(self, client, transport): def test_optional_fields_omitted_when_none(self, client, transport): transport.post.return_value = PHONE_CALL_DICT - client.calls.place( + client._calls.place( from_number="+18335794607", to_number="+15167251294", ) diff --git a/python/tests/test_client.py b/python/tests/test_client.py index fac5ddc..fdd3802 100644 --- a/python/tests/test_client.py +++ b/python/tests/test_client.py @@ -12,11 +12,11 @@ class TestInkboxPhoneResources: def test_creates_phone_resource_instances(self): client = Inkbox(api_key="sk-test") - assert isinstance(client.numbers, PhoneNumbersResource) - assert isinstance(client.calls, CallsResource) - assert isinstance(client.transcripts, TranscriptsResource) - assert isinstance(client.phone_webhooks, PhoneWebhooksResource) - assert isinstance(client.signing_keys, SigningKeysResource) + assert isinstance(client._numbers, PhoneNumbersResource) + assert isinstance(client._calls, CallsResource) + assert isinstance(client._transcripts, TranscriptsResource) + assert isinstance(client._phone_webhooks, PhoneWebhooksResource) + assert isinstance(client._signing_keys, SigningKeysResource) client.close() diff --git a/python/tests/test_identities.py b/python/tests/test_identities.py index d780fdc..40a4f66 100644 --- a/python/tests/test_identities.py +++ b/python/tests/test_identities.py @@ -4,7 +4,7 @@ from sample_data_identities import IDENTITY_DICT, IDENTITY_DETAIL_DICT from inkbox.identities.resources.identities import IdentitiesResource -from inkbox.identities.types import AgentIdentity, AgentIdentityDetail +from inkbox.identities.types import AgentIdentitySummary, _AgentIdentityData def _resource(): @@ -23,7 +23,7 @@ def test_creates_identity(self): identity = res.create(agent_handle=HANDLE) http.post.assert_called_once_with("/", json={"agent_handle": HANDLE}) - assert isinstance(identity, AgentIdentity) + assert isinstance(identity, AgentIdentitySummary) assert identity.agent_handle == HANDLE @@ -53,7 +53,7 @@ def test_returns_detail(self): detail = res.get(HANDLE) http.get.assert_called_once_with(f"/{HANDLE}") - assert isinstance(detail, AgentIdentityDetail) + assert isinstance(detail, _AgentIdentityData) assert detail.mailbox.email_address == "sales-agent@inkbox.ai" assert detail.phone_number.number == "+18335794607" @@ -109,7 +109,7 @@ def test_assigns_mailbox(self): http.post.assert_called_once_with( f"/{HANDLE}/mailbox", json={"mailbox_id": mailbox_id} ) - assert isinstance(detail, AgentIdentityDetail) + assert isinstance(detail, _AgentIdentityData) class TestIdentitiesUnlinkMailbox: @@ -132,7 +132,7 @@ def test_assigns_phone_number(self): http.post.assert_called_once_with( f"/{HANDLE}/phone_number", json={"phone_number_id": phone_id} ) - assert isinstance(detail, AgentIdentityDetail) + assert isinstance(detail, _AgentIdentityData) class TestIdentitiesUnlinkPhoneNumber: diff --git a/python/tests/test_identities_client.py b/python/tests/test_identities_client.py index 820ff08..ad7052a 100644 --- a/python/tests/test_identities_client.py +++ b/python/tests/test_identities_client.py @@ -1,15 +1,13 @@ -"""Tests for Inkbox unified client — identities namespace.""" +"""Tests for Inkbox unified client — identities.""" from inkbox import Inkbox -from inkbox.client import _IdentitiesNamespace from inkbox.identities.resources.identities import IdentitiesResource class TestInkboxIdentitiesResources: - def test_creates_identities_namespace(self): + def test_creates_identities_resource(self): client = Inkbox(api_key="sk-test") - assert isinstance(client.identities, _IdentitiesNamespace) assert isinstance(client._ids_resource, IdentitiesResource) client.close() diff --git a/python/tests/test_identities_types.py b/python/tests/test_identities_types.py index 55b8f3d..a29b87d 100644 --- a/python/tests/test_identities_types.py +++ b/python/tests/test_identities_types.py @@ -10,16 +10,16 @@ IDENTITY_PHONE_DICT, ) from inkbox.identities.types import ( - AgentIdentity, - AgentIdentityDetail, + AgentIdentitySummary, + _AgentIdentityData, IdentityMailbox, IdentityPhoneNumber, ) -class TestAgentIdentityParsing: +class TestAgentIdentitySummaryParsing: def test_from_dict(self): - i = AgentIdentity._from_dict(IDENTITY_DICT) + i = AgentIdentitySummary._from_dict(IDENTITY_DICT) assert isinstance(i.id, UUID) assert i.organization_id == "org-abc123" @@ -29,9 +29,9 @@ def test_from_dict(self): assert isinstance(i.updated_at, datetime) -class TestAgentIdentityDetailParsing: +class TestAgentIdentityDataParsing: def test_with_channels(self): - d = AgentIdentityDetail._from_dict(IDENTITY_DETAIL_DICT) + d = _AgentIdentityData._from_dict(IDENTITY_DETAIL_DICT) assert isinstance(d.id, UUID) assert d.agent_handle == "sales-agent" @@ -41,7 +41,7 @@ def test_with_channels(self): assert d.phone_number.number == "+18335794607" def test_no_channels(self): - d = AgentIdentityDetail._from_dict(IDENTITY_DICT) + d = _AgentIdentityData._from_dict(IDENTITY_DICT) assert d.mailbox is None assert d.phone_number is None diff --git a/python/tests/test_mail_client.py b/python/tests/test_mail_client.py index e6c07c9..0b5ab47 100644 --- a/python/tests/test_mail_client.py +++ b/python/tests/test_mail_client.py @@ -12,11 +12,11 @@ class TestInkboxMailResources: def test_creates_mail_resource_instances(self): client = Inkbox(api_key="sk-test") - assert isinstance(client.mailboxes, MailboxesResource) - assert isinstance(client.messages, MessagesResource) - assert isinstance(client.threads, ThreadsResource) - assert isinstance(client.mail_webhooks, WebhooksResource) - assert isinstance(client.signing_keys, SigningKeysResource) + assert isinstance(client._mailboxes, MailboxesResource) + assert isinstance(client._messages, MessagesResource) + assert isinstance(client._threads, ThreadsResource) + assert isinstance(client._mail_webhooks, WebhooksResource) + assert isinstance(client._signing_keys, SigningKeysResource) client.close() diff --git a/python/tests/test_numbers.py b/python/tests/test_numbers.py index 78df43d..9cd55a5 100644 --- a/python/tests/test_numbers.py +++ b/python/tests/test_numbers.py @@ -9,7 +9,7 @@ class TestNumbersList: def test_returns_list_of_phone_numbers(self, client, transport): transport.get.return_value = [PHONE_NUMBER_DICT] - numbers = client.numbers.list() + numbers = client._numbers.list() transport.get.assert_called_once_with("/numbers") assert len(numbers) == 1 @@ -21,7 +21,7 @@ def test_returns_list_of_phone_numbers(self, client, transport): def test_empty_list(self, client, transport): transport.get.return_value = [] - numbers = client.numbers.list() + numbers = client._numbers.list() assert numbers == [] @@ -31,7 +31,7 @@ def test_returns_phone_number(self, client, transport): transport.get.return_value = PHONE_NUMBER_DICT uid = "aaaa1111-0000-0000-0000-000000000001" - number = client.numbers.get(uid) + number = client._numbers.get(uid) transport.get.assert_called_once_with(f"/numbers/{uid}") assert number.id == UUID(uid) @@ -45,7 +45,7 @@ def test_update_incoming_call_action(self, client, transport): transport.patch.return_value = updated uid = "aaaa1111-0000-0000-0000-000000000001" - result = client.numbers.update(uid, incoming_call_action="webhook") + result = client._numbers.update(uid, incoming_call_action="webhook") transport.patch.assert_called_once_with( f"/numbers/{uid}", @@ -63,7 +63,7 @@ def test_update_multiple_fields(self, client, transport): transport.patch.return_value = updated uid = "aaaa1111-0000-0000-0000-000000000001" - result = client.numbers.update( + result = client._numbers.update( uid, incoming_call_action="auto_accept", default_stream_url="wss://agent.example.com/ws", @@ -85,7 +85,7 @@ def test_omitted_fields_not_sent(self, client, transport): transport.patch.return_value = PHONE_NUMBER_DICT uid = "aaaa1111-0000-0000-0000-000000000001" - client.numbers.update(uid, incoming_call_action="auto_reject") + client._numbers.update(uid, incoming_call_action="auto_reject") _, kwargs = transport.patch.call_args assert "default_stream_url" not in kwargs["json"] @@ -96,7 +96,7 @@ class TestNumbersProvision: def test_provision_toll_free(self, client, transport): transport.post.return_value = PHONE_NUMBER_DICT - number = client.numbers.provision(type="toll_free") + number = client._numbers.provision(type="toll_free") transport.post.assert_called_once_with( "/numbers/provision", @@ -108,7 +108,7 @@ def test_provision_local_with_state(self, client, transport): local = {**PHONE_NUMBER_DICT, "type": "local", "number": "+12125551234"} transport.post.return_value = local - number = client.numbers.provision(type="local", state="NY") + number = client._numbers.provision(type="local", state="NY") transport.post.assert_called_once_with( "/numbers/provision", @@ -119,7 +119,7 @@ def test_provision_local_with_state(self, client, transport): def test_provision_defaults_to_toll_free(self, client, transport): transport.post.return_value = PHONE_NUMBER_DICT - client.numbers.provision() + client._numbers.provision() _, kwargs = transport.post.call_args assert kwargs["json"]["type"] == "toll_free" @@ -129,7 +129,7 @@ class TestNumbersRelease: def test_release_posts_number(self, client, transport): transport.post.return_value = None - client.numbers.release(number="+18335794607") + client._numbers.release(number="+18335794607") transport.post.assert_called_once_with( "/numbers/release", @@ -142,7 +142,7 @@ def test_search_with_query(self, client, transport): transport.get.return_value = [PHONE_TRANSCRIPT_DICT] uid = "aaaa1111-0000-0000-0000-000000000001" - results = client.numbers.search_transcripts(uid, q="hello") + results = client._numbers.search_transcripts(uid, q="hello") transport.get.assert_called_once_with( f"/numbers/{uid}/search", @@ -155,7 +155,7 @@ def test_search_with_party_and_limit(self, client, transport): transport.get.return_value = [] uid = "aaaa1111-0000-0000-0000-000000000001" - results = client.numbers.search_transcripts( + results = client._numbers.search_transcripts( uid, q="test", party="remote", limit=10 ) diff --git a/python/tests/test_transcripts.py b/python/tests/test_transcripts.py index db4f479..fcccc40 100644 --- a/python/tests/test_transcripts.py +++ b/python/tests/test_transcripts.py @@ -21,7 +21,7 @@ def test_returns_transcripts(self, client, transport): } transport.get.return_value = [PHONE_TRANSCRIPT_DICT, second] - transcripts = client.transcripts.list(NUM_ID, CALL_ID) + transcripts = client._transcripts.list(NUM_ID, CALL_ID) transport.get.assert_called_once_with( f"/numbers/{NUM_ID}/calls/{CALL_ID}/transcripts", @@ -38,6 +38,6 @@ def test_returns_transcripts(self, client, transport): def test_empty_transcripts(self, client, transport): transport.get.return_value = [] - transcripts = client.transcripts.list(NUM_ID, CALL_ID) + transcripts = client._transcripts.list(NUM_ID, CALL_ID) assert transcripts == [] diff --git a/python/tests/test_webhooks.py b/python/tests/test_webhooks.py index a9fcb84..8ebe33c 100644 --- a/python/tests/test_webhooks.py +++ b/python/tests/test_webhooks.py @@ -13,7 +13,7 @@ class TestWebhooksCreate: def test_creates_webhook_with_secret(self, client, transport): transport.post.return_value = PHONE_WEBHOOK_CREATE_DICT - hook = client.phone_webhooks.create( + hook = client._phone_webhooks.create( NUM_ID, url="https://example.com/webhooks/phone", event_types=["incoming_call"], @@ -36,7 +36,7 @@ class TestWebhooksList: def test_returns_webhooks(self, client, transport): transport.get.return_value = [PHONE_WEBHOOK_DICT] - webhooks = client.phone_webhooks.list(NUM_ID) + webhooks = client._phone_webhooks.list(NUM_ID) transport.get.assert_called_once_with(f"/numbers/{NUM_ID}/webhooks") assert len(webhooks) == 1 @@ -46,7 +46,7 @@ def test_returns_webhooks(self, client, transport): def test_empty_list(self, client, transport): transport.get.return_value = [] - webhooks = client.phone_webhooks.list(NUM_ID) + webhooks = client._phone_webhooks.list(NUM_ID) assert webhooks == [] @@ -56,7 +56,7 @@ def test_update_url(self, client, transport): updated = {**PHONE_WEBHOOK_DICT, "url": "https://new.example.com/hook"} transport.patch.return_value = updated - result = client.phone_webhooks.update( + result = client._phone_webhooks.update( NUM_ID, WH_ID, url="https://new.example.com/hook" ) @@ -70,7 +70,7 @@ def test_update_event_types(self, client, transport): updated = {**PHONE_WEBHOOK_DICT, "event_types": ["incoming_call", "message.received"]} transport.patch.return_value = updated - result = client.phone_webhooks.update( + result = client._phone_webhooks.update( NUM_ID, WH_ID, event_types=["incoming_call", "message.received"] ) @@ -81,7 +81,7 @@ def test_update_event_types(self, client, transport): def test_omitted_fields_not_sent(self, client, transport): transport.patch.return_value = PHONE_WEBHOOK_DICT - client.phone_webhooks.update(NUM_ID, WH_ID, url="https://example.com/hook") + client._phone_webhooks.update(NUM_ID, WH_ID, url="https://example.com/hook") _, kwargs = transport.patch.call_args assert "event_types" not in kwargs["json"] @@ -89,7 +89,7 @@ def test_omitted_fields_not_sent(self, client, transport): class TestWebhooksDelete: def test_deletes_webhook(self, client, transport): - client.phone_webhooks.delete(NUM_ID, WH_ID) + client._phone_webhooks.delete(NUM_ID, WH_ID) transport.delete.assert_called_once_with( f"/numbers/{NUM_ID}/webhooks/{WH_ID}" diff --git a/typescript/src/agent.ts b/typescript/src/agent.ts index f6b75ef..bd751f8 100644 --- a/typescript/src/agent.ts +++ b/typescript/src/agent.ts @@ -1,49 +1,50 @@ /** * inkbox/src/agent.ts * - * Agent — a domain object representing one agent identity. - * Returned by inkbox.identities.create() and inkbox.identities.get(). + * AgentIdentity — a domain object representing one agent identity. + * Returned by inkbox.createIdentity() and inkbox.getIdentity(). * * Convenience methods (sendEmail, placeCall, etc.) are scoped to this - * agent's assigned channels so callers never need to pass an email address - * or phone number ID explicitly. + * identity's assigned channels so callers never need to pass an email + * address or phone number ID explicitly. */ import { InkboxAPIError } from "./_http.js"; import type { Message } from "./types.js"; import type { PhoneCallWithRateLimit, PhoneTranscript } from "./phone/types.js"; import type { - AgentIdentityDetail, + AgentIdentitySummary, + _AgentIdentityData, IdentityMailbox, IdentityPhoneNumber, } from "./identities/types.js"; import type { Inkbox } from "./inkbox.js"; -export class Agent { - private _identity: AgentIdentityDetail; +export class AgentIdentity { + private _data: _AgentIdentityData; private readonly _inkbox: Inkbox; private _mailbox: IdentityMailbox | null; private _phoneNumber: IdentityPhoneNumber | null; - constructor(identity: AgentIdentityDetail, inkbox: Inkbox) { - this._identity = identity; + constructor(data: _AgentIdentityData, inkbox: Inkbox) { + this._data = data; this._inkbox = inkbox; - this._mailbox = identity.mailbox; - this._phoneNumber = identity.phoneNumber; + this._mailbox = data.mailbox; + this._phoneNumber = data.phoneNumber; } // ------------------------------------------------------------------ // Identity properties // ------------------------------------------------------------------ - get agentHandle(): string { return this._identity.agentHandle; } - get id(): string { return this._identity.id; } - get status(): string { return this._identity.status; } + get agentHandle(): string { return this._data.agentHandle; } + get id(): string { return this._data.id; } + get status(): string { return this._data.status; } - /** The mailbox currently assigned to this agent, or `null` if none. */ + /** The mailbox currently assigned to this identity, or `null` if none. */ get mailbox(): IdentityMailbox | null { return this._mailbox; } - /** The phone number currently assigned to this agent, or `null` if none. */ + /** The phone number currently assigned to this identity, or `null` if none. */ get phoneNumber(): IdentityPhoneNumber | null { return this._phoneNumber; } // ------------------------------------------------------------------ @@ -52,23 +53,32 @@ export class Agent { // ------------------------------------------------------------------ /** - * Create a new mailbox and assign it to this agent. + * Create a new mailbox and assign it to this identity. * * @param options.displayName - Optional human-readable sender name. * @returns The assigned {@link IdentityMailbox}. */ async assignMailbox(options: { displayName?: string } = {}): Promise { - const mailbox = await this._inkbox.mailboxes.create(options); - const detail = await this._inkbox._idsResource.assignMailbox(this.agentHandle, { + const mailbox = await this._inkbox._mailboxes.create(options); + const data = await this._inkbox._idsResource.assignMailbox(this.agentHandle, { mailboxId: mailbox.id, }); - this._mailbox = detail.mailbox; - this._identity = detail; + this._mailbox = data.mailbox; + this._data = data; return this._mailbox!; } /** - * Provision a new phone number and assign it to this agent. + * Unlink this identity's mailbox (does not delete the mailbox). + */ + async unlinkMailbox(): Promise { + this._requireMailbox(); + await this._inkbox._idsResource.unlinkMailbox(this.agentHandle); + this._mailbox = null; + } + + /** + * Provision a new phone number and assign it to this identity. * * @param options.type - `"toll_free"` (default) or `"local"`. * @param options.state - US state abbreviation (e.g. `"NY"`), valid for local numbers only. @@ -77,21 +87,30 @@ export class Agent { async assignPhoneNumber( options: { type?: string; state?: string } = {}, ): Promise { - const number = await this._inkbox.numbers.provision(options); - const detail = await this._inkbox._idsResource.assignPhoneNumber(this.agentHandle, { + const number = await this._inkbox._numbers.provision(options); + const data = await this._inkbox._idsResource.assignPhoneNumber(this.agentHandle, { phoneNumberId: number.id, }); - this._phoneNumber = detail.phoneNumber; - this._identity = detail; + this._phoneNumber = data.phoneNumber; + this._data = data; return this._phoneNumber!; } + /** + * Unlink this identity's phone number (does not release the number). + */ + async unlinkPhoneNumber(): Promise { + this._requirePhone(); + await this._inkbox._idsResource.unlinkPhoneNumber(this.agentHandle); + this._phoneNumber = null; + } + // ------------------------------------------------------------------ // Mail helpers // ------------------------------------------------------------------ /** - * Send an email from this agent's mailbox. + * Send an email from this identity's mailbox. * * @param options.to - Primary recipient addresses (at least one required). * @param options.subject - Email subject line. @@ -113,11 +132,11 @@ export class Agent { attachments?: Array<{ filename: string; contentType: string; contentBase64: string }>; }): Promise { this._requireMailbox(); - return this._inkbox.messages.send(this._mailbox!.emailAddress, options); + return this._inkbox._messages.send(this._mailbox!.emailAddress, options); } /** - * Iterate over messages in this agent's inbox, newest first. + * Iterate over messages in this identity's inbox, newest first. * * Pagination is handled automatically. * @@ -126,7 +145,7 @@ export class Agent { */ messages(options: { pageSize?: number; direction?: "inbound" | "outbound" } = {}): AsyncGenerator { this._requireMailbox(); - return this._inkbox.messages.list(this._mailbox!.emailAddress, options); + return this._inkbox._messages.list(this._mailbox!.emailAddress, options); } // ------------------------------------------------------------------ @@ -134,7 +153,7 @@ export class Agent { // ------------------------------------------------------------------ /** - * Place an outbound call from this agent's phone number. + * Place an outbound call from this identity's phone number. * * @param options.toNumber - E.164 destination number. * @param options.streamUrl - WebSocket URL (wss://) for audio bridging. @@ -148,17 +167,17 @@ export class Agent { webhookUrl?: string; }): Promise { this._requirePhone(); - return this._inkbox.calls.place({ - fromNumber: this._phoneNumber!.number, - toNumber: options.toNumber, - streamUrl: options.streamUrl, + return this._inkbox._calls.place({ + fromNumber: this._phoneNumber!.number, + toNumber: options.toNumber, + streamUrl: options.streamUrl, pipelineMode: options.pipelineMode, - webhookUrl: options.webhookUrl, + webhookUrl: options.webhookUrl, }); } /** - * Full-text search across call transcripts for this agent's number. + * Full-text search across call transcripts for this identity's number. * * @param options.q - Search query string. * @param options.party - Filter by speaker: `"local"` or `"remote"`. @@ -170,23 +189,38 @@ export class Agent { limit?: number; }): Promise { this._requirePhone(); - return this._inkbox.numbers.searchTranscripts(this._phoneNumber!.id, options); + return this._inkbox._numbers.searchTranscripts(this._phoneNumber!.id, options); } // ------------------------------------------------------------------ - // Misc + // Identity management // ------------------------------------------------------------------ /** - * Re-fetch this agent's identity from the API and update cached channels. + * Update this identity's handle or status. + * + * @param options.newHandle - New agent handle. + * @param options.status - New lifecycle status: `"active"` or `"paused"`. + */ + async update(options: { newHandle?: string; status?: string }): Promise { + const result = await this._inkbox._idsResource.update(this.agentHandle, options); + this._data = { + ...result, + mailbox: this._mailbox, + phoneNumber: this._phoneNumber, + }; + } + + /** + * Re-fetch this identity from the API and update cached channels. * * @returns `this` for chaining. */ - async refresh(): Promise { - const detail = await this._inkbox._idsResource.get(this.agentHandle); - this._identity = detail; - this._mailbox = detail.mailbox; - this._phoneNumber = detail.phoneNumber; + async refresh(): Promise { + const data = await this._inkbox._idsResource.get(this.agentHandle); + this._data = data; + this._mailbox = data.mailbox; + this._phoneNumber = data.phoneNumber; return this; } @@ -203,7 +237,7 @@ export class Agent { if (!this._mailbox) { throw new InkboxAPIError( 0, - `Agent '${this.agentHandle}' has no mailbox assigned. Call agent.assignMailbox() first.`, + `Identity '${this.agentHandle}' has no mailbox assigned. Call identity.assignMailbox() first.`, ); } } @@ -212,7 +246,7 @@ export class Agent { if (!this._phoneNumber) { throw new InkboxAPIError( 0, - `Agent '${this.agentHandle}' has no phone number assigned. Call agent.assignPhoneNumber() first.`, + `Identity '${this.agentHandle}' has no phone number assigned. Call identity.assignPhoneNumber() first.`, ); } } diff --git a/typescript/src/identities/resources/identities.ts b/typescript/src/identities/resources/identities.ts index 4d3644c..3b38507 100644 --- a/typescript/src/identities/resources/identities.ts +++ b/typescript/src/identities/resources/identities.ts @@ -6,12 +6,12 @@ import { HttpTransport } from "../../_http.js"; import { - AgentIdentity, - AgentIdentityDetail, - RawAgentIdentity, - RawAgentIdentityDetail, - parseAgentIdentity, - parseAgentIdentityDetail, + AgentIdentitySummary, + _AgentIdentityData, + RawAgentIdentitySummary, + RawAgentIdentityData, + parseAgentIdentitySummary, + parseAgentIdentityData, } from "../types.js"; export class IdentitiesResource { @@ -23,17 +23,17 @@ export class IdentitiesResource { * @param options.agentHandle - Unique handle for this identity within your organisation * (e.g. `"sales-agent"` or `"@sales-agent"`). */ - async create(options: { agentHandle: string }): Promise { - const data = await this.http.post("/", { + async create(options: { agentHandle: string }): Promise { + const data = await this.http.post("/", { agent_handle: options.agentHandle, }); - return parseAgentIdentity(data); + return parseAgentIdentitySummary(data); } /** List all identities for your organisation. */ - async list(): Promise { - const data = await this.http.get("/"); - return data.map(parseAgentIdentity); + async list(): Promise { + const data = await this.http.get("/"); + return data.map(parseAgentIdentitySummary); } /** @@ -41,9 +41,9 @@ export class IdentitiesResource { * * @param agentHandle - Handle of the identity to fetch. */ - async get(agentHandle: string): Promise { - const data = await this.http.get(`/${agentHandle}`); - return parseAgentIdentityDetail(data); + async get(agentHandle: string): Promise<_AgentIdentityData> { + const data = await this.http.get(`/${agentHandle}`); + return parseAgentIdentityData(data); } /** @@ -58,12 +58,12 @@ export class IdentitiesResource { async update( agentHandle: string, options: { newHandle?: string; status?: string }, - ): Promise { + ): Promise { const body: Record = {}; if (options.newHandle !== undefined) body["agent_handle"] = options.newHandle; if (options.status !== undefined) body["status"] = options.status; - const data = await this.http.patch(`/${agentHandle}`, body); - return parseAgentIdentity(data); + const data = await this.http.patch(`/${agentHandle}`, body); + return parseAgentIdentitySummary(data); } /** @@ -86,12 +86,12 @@ export class IdentitiesResource { async assignMailbox( agentHandle: string, options: { mailboxId: string }, - ): Promise { - const data = await this.http.post( + ): Promise<_AgentIdentityData> { + const data = await this.http.post( `/${agentHandle}/mailbox`, { mailbox_id: options.mailboxId }, ); - return parseAgentIdentityDetail(data); + return parseAgentIdentityData(data); } /** @@ -112,12 +112,12 @@ export class IdentitiesResource { async assignPhoneNumber( agentHandle: string, options: { phoneNumberId: string }, - ): Promise { - const data = await this.http.post( + ): Promise<_AgentIdentityData> { + const data = await this.http.post( `/${agentHandle}/phone_number`, { phone_number_id: options.phoneNumberId }, ); - return parseAgentIdentityDetail(data); + return parseAgentIdentityData(data); } /** diff --git a/typescript/src/identities/types.ts b/typescript/src/identities/types.ts index 89ca6f4..400e4b7 100644 --- a/typescript/src/identities/types.ts +++ b/typescript/src/identities/types.ts @@ -26,7 +26,8 @@ export interface IdentityPhoneNumber { updatedAt: Date; } -export interface AgentIdentity { +/** Lightweight identity returned by list and update endpoints. */ +export interface AgentIdentitySummary { id: string; organizationId: string; agentHandle: string; @@ -36,7 +37,8 @@ export interface AgentIdentity { updatedAt: Date; } -export interface AgentIdentityDetail extends AgentIdentity { +/** @internal Full identity data with channels — users interact with AgentIdentity (the class) instead. */ +export interface _AgentIdentityData extends AgentIdentitySummary { /** Mailbox assigned to this identity, or null if unlinked. */ mailbox: IdentityMailbox | null; /** Phone number assigned to this identity, or null if unlinked. */ @@ -65,7 +67,7 @@ export interface RawIdentityPhoneNumber { updated_at: string; } -export interface RawAgentIdentity { +export interface RawAgentIdentitySummary { id: string; organization_id: string; agent_handle: string; @@ -74,7 +76,7 @@ export interface RawAgentIdentity { updated_at: string; } -export interface RawAgentIdentityDetail extends RawAgentIdentity { +export interface RawAgentIdentityData extends RawAgentIdentitySummary { mailbox: RawIdentityMailbox | null; phone_number: RawIdentityPhoneNumber | null; } @@ -105,7 +107,7 @@ export function parseIdentityPhoneNumber(r: RawIdentityPhoneNumber): IdentityPho }; } -export function parseAgentIdentity(r: RawAgentIdentity): AgentIdentity { +export function parseAgentIdentitySummary(r: RawAgentIdentitySummary): AgentIdentitySummary { return { id: r.id, organizationId: r.organization_id, @@ -116,9 +118,9 @@ export function parseAgentIdentity(r: RawAgentIdentity): AgentIdentity { }; } -export function parseAgentIdentityDetail(r: RawAgentIdentityDetail): AgentIdentityDetail { +export function parseAgentIdentityData(r: RawAgentIdentityData): _AgentIdentityData { return { - ...parseAgentIdentity(r), + ...parseAgentIdentitySummary(r), mailbox: r.mailbox ? parseIdentityMailbox(r.mailbox) : null, phoneNumber: r.phone_number ? parseIdentityPhoneNumber(r.phone_number) : null, }; diff --git a/typescript/src/index.ts b/typescript/src/index.ts index 79fc837..7989575 100644 --- a/typescript/src/index.ts +++ b/typescript/src/index.ts @@ -1,5 +1,5 @@ export { Inkbox } from "./inkbox.js"; -export { Agent } from "./agent.js"; +export { AgentIdentity } from "./agent.js"; export type { InkboxOptions } from "./inkbox.js"; export { InkboxAPIError } from "./_http.js"; export type { SigningKey } from "./resources/signing-keys.js"; @@ -23,8 +23,7 @@ export type { PhoneWebhookCreateResult, } from "./phone/types.js"; export type { - AgentIdentity, - AgentIdentityDetail, + AgentIdentitySummary, IdentityMailbox, IdentityPhoneNumber, } from "./identities/types.js"; diff --git a/typescript/src/inkbox.ts b/typescript/src/inkbox.ts index 4a05737..7ca709c 100644 --- a/typescript/src/inkbox.ts +++ b/typescript/src/inkbox.ts @@ -1,7 +1,7 @@ /** * inkbox/src/inkbox.ts * - * Unified Inkbox client — single entry point for all Inkbox APIs. + * Inkbox — org-level entry point for all Inkbox APIs. */ import { HttpTransport } from "./_http.js"; @@ -10,13 +10,14 @@ import { MessagesResource } from "./resources/messages.js"; import { ThreadsResource } from "./resources/threads.js"; import { WebhooksResource } from "./resources/webhooks.js"; import { SigningKeysResource } from "./resources/signing-keys.js"; +import type { SigningKey } from "./resources/signing-keys.js"; import { PhoneNumbersResource } from "./phone/resources/numbers.js"; import { CallsResource } from "./phone/resources/calls.js"; import { TranscriptsResource } from "./phone/resources/transcripts.js"; import { PhoneWebhooksResource } from "./phone/resources/webhooks.js"; import { IdentitiesResource } from "./identities/resources/identities.js"; -import { Agent } from "./agent.js"; -import type { AgentIdentity, AgentIdentityDetail } from "./identities/types.js"; +import { AgentIdentity } from "./agent.js"; +import type { AgentIdentitySummary } from "./identities/types.js"; const DEFAULT_BASE_URL = "https://api.inkbox.ai"; @@ -30,7 +31,7 @@ export interface InkboxOptions { } /** - * Unified client for all Inkbox APIs. + * Org-level entry point for all Inkbox APIs. * * @example * ```ts @@ -38,15 +39,15 @@ export interface InkboxOptions { * * const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); * - * // Create an agent identity — returns an Agent object - * const agent = await inkbox.identities.create({ agentHandle: "support-bot" }); + * // Create an agent identity + * const identity = await inkbox.createIdentity("support-bot"); * * // Provision and link channels in one call each - * const mailbox = await agent.assignMailbox({ displayName: "Support Bot" }); - * const phone = await agent.assignPhoneNumber({ type: "toll_free" }); + * const mailbox = await identity.assignMailbox({ displayName: "Support Bot" }); + * const phone = await identity.assignPhoneNumber({ type: "toll_free" }); * - * // Send email directly from the agent - * await agent.sendEmail({ + * // Send email directly from the identity + * await identity.sendEmail({ * to: ["customer@example.com"], * subject: "Your order has shipped", * bodyText: "Tracking number: 1Z999AA10123456784", @@ -54,23 +55,27 @@ export interface InkboxOptions { * ``` */ export class Inkbox { - // Mail - readonly mailboxes: MailboxesResource; - readonly messages: MessagesResource; - readonly threads: ThreadsResource; - readonly mailWebhooks: WebhooksResource; - readonly signingKeys: SigningKeysResource; - - // Phone - readonly numbers: PhoneNumbersResource; - readonly calls: CallsResource; - readonly transcripts: TranscriptsResource; - readonly phoneWebhooks: PhoneWebhooksResource; - - // Identities — returns Agent objects from create/get - readonly identities: IdentitiesNamespace; - - /** @internal — used by Agent to link channels without going through the namespace */ + /** @internal — used by AgentIdentity */ + readonly _mailboxes: MailboxesResource; + /** @internal — used by AgentIdentity */ + readonly _messages: MessagesResource; + /** @internal */ + readonly _threads: ThreadsResource; + /** @internal */ + readonly _mailWebhooks: WebhooksResource; + /** @internal */ + readonly _signingKeys: SigningKeysResource; + + /** @internal — used by AgentIdentity */ + readonly _numbers: PhoneNumbersResource; + /** @internal — used by AgentIdentity */ + readonly _calls: CallsResource; + /** @internal */ + readonly _transcripts: TranscriptsResource; + /** @internal */ + readonly _phoneWebhooks: PhoneWebhooksResource; + + /** @internal — used by AgentIdentity to link channels */ readonly _idsResource: IdentitiesResource; constructor(options: InkboxOptions) { @@ -82,78 +87,65 @@ export class Inkbox { const idsHttp = new HttpTransport(options.apiKey, `${apiRoot}/identities`, ms); const apiHttp = new HttpTransport(options.apiKey, apiRoot, ms); - this.mailboxes = new MailboxesResource(mailHttp); - this.messages = new MessagesResource(mailHttp); - this.threads = new ThreadsResource(mailHttp); - this.mailWebhooks = new WebhooksResource(mailHttp); - this.signingKeys = new SigningKeysResource(apiHttp); + this._mailboxes = new MailboxesResource(mailHttp); + this._messages = new MessagesResource(mailHttp); + this._threads = new ThreadsResource(mailHttp); + this._mailWebhooks = new WebhooksResource(mailHttp); + this._signingKeys = new SigningKeysResource(apiHttp); - this.numbers = new PhoneNumbersResource(phoneHttp); - this.calls = new CallsResource(phoneHttp); - this.transcripts = new TranscriptsResource(phoneHttp); - this.phoneWebhooks = new PhoneWebhooksResource(phoneHttp); + this._numbers = new PhoneNumbersResource(phoneHttp); + this._calls = new CallsResource(phoneHttp); + this._transcripts = new TranscriptsResource(phoneHttp); + this._phoneWebhooks = new PhoneWebhooksResource(phoneHttp); this._idsResource = new IdentitiesResource(idsHttp); - this.identities = new IdentitiesNamespace(this._idsResource, this); - } -} - -/** - * Thin wrapper around IdentitiesResource that returns Agent objects from - * create() and get(), while delegating everything else directly. - */ -class IdentitiesNamespace { - constructor( - private readonly _r: IdentitiesResource, - private readonly _inkbox: Inkbox, - ) {} - - /** Create a new agent identity and return it as an {@link Agent} object. */ - async create(options: { agentHandle: string }): Promise { - await this._r.create(options); - // POST /identities returns AgentIdentity (no channel fields); - // fetch the detail so the Agent has a fully-populated AgentIdentityDetail. - const detail = await this._r.get(options.agentHandle); - return new Agent(detail, this._inkbox); - } - - /** Get an existing agent identity by handle, returned as an {@link Agent} object. */ - async get(agentHandle: string): Promise { - return new Agent(await this._r.get(agentHandle), this._inkbox); - } - - /** List all agent identities for your organisation. */ - list(): Promise { - return this._r.list(); - } - - /** Update an identity's handle or status. */ - update(...args: Parameters) { - return this._r.update(...args); - } - - /** Soft-delete an identity (unlinks channels without deleting them). */ - delete(...args: Parameters) { - return this._r.delete(...args); } - /** Assign an existing mailbox to an identity by mailbox UUID. */ - assignMailbox(...args: Parameters): Promise { - return this._r.assignMailbox(...args); + // ------------------------------------------------------------------ + // Org-level operations + // ------------------------------------------------------------------ + + /** + * Create a new agent identity. + * + * @param agentHandle - Unique handle for this identity (e.g. `"sales-bot"`). + * @returns The created {@link AgentIdentity}. + */ + async createIdentity(agentHandle: string): Promise { + await this._idsResource.create({ agentHandle }); + // POST /identities returns summary (no channel fields); fetch detail so + // AgentIdentity has a fully-populated _AgentIdentityData. + const data = await this._idsResource.get(agentHandle); + return new AgentIdentity(data, this); } - /** Unlink a mailbox from an identity. */ - unlinkMailbox(...args: Parameters) { - return this._r.unlinkMailbox(...args); + /** + * Get an existing agent identity by handle. + * + * @param agentHandle - Handle of the identity to fetch. + * @returns The {@link AgentIdentity}. + */ + async getIdentity(agentHandle: string): Promise { + return new AgentIdentity(await this._idsResource.get(agentHandle), this); } - /** Assign an existing phone number to an identity by phone number UUID. */ - assignPhoneNumber(...args: Parameters): Promise { - return this._r.assignPhoneNumber(...args); + /** + * List all agent identities for your organisation. + * + * @returns Array of {@link AgentIdentitySummary}. + */ + async listIdentities(): Promise { + return this._idsResource.list(); } - /** Unlink a phone number from an identity. */ - unlinkPhoneNumber(...args: Parameters) { - return this._r.unlinkPhoneNumber(...args); + /** + * Create or rotate the org-level webhook signing key. + * + * The plaintext key is returned once — save it immediately. + * + * @returns The new {@link SigningKey}. + */ + async createSigningKey(): Promise { + return this._signingKeys.createOrRotate(); } } diff --git a/typescript/tests/identities/types.test.ts b/typescript/tests/identities/types.test.ts index 21fde4a..2287b5f 100644 --- a/typescript/tests/identities/types.test.ts +++ b/typescript/tests/identities/types.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import { - parseAgentIdentity, - parseAgentIdentityDetail, + parseAgentIdentitySummary, + parseAgentIdentityData, parseIdentityMailbox, parseIdentityPhoneNumber, } from "../../src/identities/types.js"; @@ -12,9 +12,9 @@ import { RAW_IDENTITY_PHONE, } from "../sampleData.js"; -describe("parseAgentIdentity", () => { +describe("parseAgentIdentitySummary", () => { it("converts all fields", () => { - const i = parseAgentIdentity(RAW_IDENTITY); + const i = parseAgentIdentitySummary(RAW_IDENTITY); expect(i.id).toBe(RAW_IDENTITY.id); expect(i.organizationId).toBe("org-abc123"); expect(i.agentHandle).toBe("sales-agent"); @@ -24,9 +24,9 @@ describe("parseAgentIdentity", () => { }); }); -describe("parseAgentIdentityDetail", () => { +describe("parseAgentIdentityData", () => { it("includes nested mailbox and phone number", () => { - const d = parseAgentIdentityDetail(RAW_IDENTITY_DETAIL); + const d = parseAgentIdentityData(RAW_IDENTITY_DETAIL); expect(d.agentHandle).toBe("sales-agent"); expect(d.mailbox).not.toBeNull(); expect(d.mailbox!.emailAddress).toBe("sales-agent@inkbox.ai"); @@ -35,7 +35,7 @@ describe("parseAgentIdentityDetail", () => { }); it("returns null for missing channels", () => { - const d = parseAgentIdentityDetail({ ...RAW_IDENTITY, mailbox: null, phone_number: null }); + const d = parseAgentIdentityData({ ...RAW_IDENTITY, mailbox: null, phone_number: null }); expect(d.mailbox).toBeNull(); expect(d.phoneNumber).toBeNull(); }); From 789d547cc399378f9e559ad185e1fff9dab326ed Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:09:13 -0400 Subject: [PATCH 38/56] update readme --- python/README.md | 186 +++++++++++++------------------------------ typescript/README.md | 183 ++++++++++++------------------------------ 2 files changed, 107 insertions(+), 262 deletions(-) diff --git a/python/README.md b/python/README.md index ddd44a2..1da936b 100644 --- a/python/README.md +++ b/python/README.md @@ -18,31 +18,31 @@ from inkbox import Inkbox with Inkbox(api_key=os.environ["INKBOX_API_KEY"]) as inkbox: # Create an agent identity - agent = inkbox.identities.create(agent_handle="support-bot") + identity = inkbox.create_identity("support-bot") # Provision and link channels in one call each - agent.assign_mailbox(display_name="Support Bot") - agent.assign_phone_number(type="toll_free") + identity.assign_mailbox(display_name="Support Bot") + identity.assign_phone_number(type="toll_free") - # Send email directly from the agent - agent.send_email( + # Send email directly from the identity + identity.send_email( to=["customer@example.com"], subject="Your order has shipped", body_text="Tracking number: 1Z999AA10123456784", ) # Place an outbound call - agent.place_call( + identity.place_call( to_number="+18005559999", stream_url="wss://my-app.com/voice", ) # Read inbox - for message in agent.messages(): + for message in identity.messages(): print(message.subject) # Search transcripts - transcripts = agent.search_transcripts(q="refund") + results = identity.search_transcripts(q="refund") ``` ## Authentication @@ -57,164 +57,90 @@ Use `with Inkbox(...) as inkbox:` (recommended) or call `inkbox.close()` manuall --- -## Identities & Agent object +## Identities -`inkbox.identities.create()` and `inkbox.identities.get()` return an `Agent` object that holds the agent's channels and exposes convenience methods scoped to those channels. +`inkbox.create_identity()` and `inkbox.get_identity()` return an `AgentIdentity` object that holds the identity's channels and exposes convenience methods scoped to those channels. ```python -# Create and fully provision an agent -agent = inkbox.identities.create(agent_handle="sales-bot") -mailbox = agent.assign_mailbox(display_name="Sales Bot") # creates + links -phone = agent.assign_phone_number(type="toll_free") # provisions + links +# Create and fully provision an identity +identity = inkbox.create_identity("sales-bot") +mailbox = identity.assign_mailbox(display_name="Sales Bot") # creates + links +phone = identity.assign_phone_number(type="toll_free") # provisions + links print(mailbox.email_address) print(phone.number) -# Get an existing agent -agent = inkbox.identities.get("sales-bot") -agent.refresh() # re-fetch channels from API +# Get an existing identity +identity = inkbox.get_identity("sales-bot") +identity.refresh() # re-fetch channels from API -# List / update / delete -all_identities = inkbox.identities.list() -inkbox.identities.update("sales-bot", status="paused") -agent.delete() +# List all identities for your org +all_identities = inkbox.list_identities() + +# Update status or handle +identity.update(status="paused") +identity.update(new_handle="sales-bot-v2") + +# Unlink channels (without deleting them) +identity.unlink_mailbox() +identity.unlink_phone_number() + +# Delete +identity.delete() ``` --- ## Mail -### Sending email - ```python -# Via agent (no email address needed) -agent.send_email( +# Send an email +identity.send_email( to=["user@example.com"], subject="Hello", body_text="Hi there!", body_html="

Hi there!

", ) -# Via flat namespace (useful when you have a mailbox address directly) -inkbox.messages.send( - "agent@inkboxmail.com", - to=["user@example.com"], - subject="Hello", - body_text="Hi there!", -) -``` - -### Reading messages and threads - -```python -# Via agent — iterates inbox automatically (paginated) -for msg in agent.messages(): +# Iterate inbox (paginated automatically) +for msg in identity.messages(): print(msg.subject, msg.from_address) -# Full message body -detail = inkbox.messages.get(mailbox.email_address, msg.id) -print(detail.body_text) - -# Threads -for thread in inkbox.threads.list(mailbox.email_address): - print(thread.subject, thread.message_count) - -thread_detail = inkbox.threads.get(mailbox.email_address, thread.id) -for msg in thread_detail.messages: - print(f"[{msg.direction}] {msg.from_address}: {msg.snippet}") -``` - -### Mailboxes - -```python -mailbox = inkbox.mailboxes.create(display_name="Sales Agent") -all_mailboxes = inkbox.mailboxes.list() -inkbox.mailboxes.update(mailbox.email_address, display_name="Sales Agent v2") -results = inkbox.mailboxes.search(mailbox.email_address, q="invoice") -inkbox.mailboxes.delete(mailbox.email_address) -``` - -### Webhooks - -```python -# Secret is one-time — save it immediately -hook = inkbox.mail_webhooks.create( - mailbox.email_address, - url="https://yourapp.com/hooks/mail", - event_types=["message.received", "message.sent"], -) -print(hook.secret) - -hooks = inkbox.mail_webhooks.list(mailbox.email_address) -inkbox.mail_webhooks.delete(mailbox.email_address, hook.id) +# Filter by direction +for msg in identity.messages(direction="inbound"): + print(msg.subject) ``` --- ## Phone -### Provisioning numbers - ```python -number = inkbox.numbers.provision(type="toll_free") -number = inkbox.numbers.provision(type="local", state="NY") - -all_numbers = inkbox.numbers.list() -inkbox.numbers.update( - number.id, - incoming_call_action="auto_accept", - default_stream_url="wss://your-agent.example.com/ws", -) -inkbox.numbers.release(number=number.number) -``` - -### Placing calls - -```python -# Via agent (from_number is automatic) -call = agent.place_call( - to_number="+15167251294", - stream_url="wss://your-agent.example.com/ws", -) - -# Via flat namespace -call = inkbox.calls.place( - from_number=number.number, +# Place an outbound call +call = identity.place_call( to_number="+15167251294", stream_url="wss://your-agent.example.com/ws", ) print(call.status, call.rate_limit.calls_remaining) -``` - -### Reading calls and transcripts - -```python -calls = inkbox.calls.list(number.id, limit=10) -transcripts = inkbox.transcripts.list(number.id, calls[0].id) - -# Full-text search via agent -results = agent.search_transcripts(q="appointment") -# Or flat namespace -results = inkbox.numbers.search_transcripts(number.id, q="appointment") +# Full-text search across transcripts +results = identity.search_transcripts(q="appointment") +results = identity.search_transcripts(q="refund", party="remote", limit=10) ``` -### Webhooks +--- -```python -hook = inkbox.phone_webhooks.create( - number.id, - url="https://yourapp.com/hooks/phone", - event_types=["call.completed"], -) -print(hook.secret) +## Signing Keys -hooks = inkbox.phone_webhooks.list(number.id) -inkbox.phone_webhooks.update(number.id, hook.id, url="https://yourapp.com/hooks/phone-v2") -inkbox.phone_webhooks.delete(number.id, hook.id) +```python +# Create or rotate the org-level webhook signing key (plaintext returned once) +key = inkbox.create_signing_key() +print(key.signing_key) # save this immediately ``` -### Verifying webhook signatures +--- + +## Verifying Webhook Signatures Use `verify_webhook` to confirm that an incoming request was sent by Inkbox. @@ -258,15 +184,11 @@ Runnable example scripts are available in the [examples/python](https://github.c | Script | What it demonstrates | |---|---| -| `register_agent_identity.py` | Create an identity, assign mailbox + phone number via Agent | -| `create_agent_mailbox.py` | Create, update, search, and delete a mailbox | +| `register_agent_identity.py` | Create an identity, assign mailbox + phone number | | `agent_send_email.py` | Send an email and a threaded reply | -| `read_agent_messages.py` | List messages and read full threads | -| `create_agent_phone_number.py` | Provision, update, and release a number | -| `list_agent_phone_numbers.py` | List all provisioned numbers | +| `read_agent_messages.py` | List messages | +| `create_agent_phone_number.py` | Provision and update a number | | `read_agent_calls.py` | List calls and print transcripts | -| `receive_agent_email_webhook.py` | Register, list, and delete email webhooks | -| `receive_agent_call_webhook.py` | Register, list, and delete phone webhooks | ## License diff --git a/typescript/README.md b/typescript/README.md index 839c861..652851c 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -18,32 +18,32 @@ import { Inkbox } from "@inkbox/sdk"; const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); // Create an agent identity -const agent = await inkbox.identities.create({ agentHandle: "support-bot" }); +const identity = await inkbox.createIdentity("support-bot"); // Provision and link channels in one call each -const mailbox = await agent.assignMailbox({ displayName: "Support Bot" }); -const phone = await agent.assignPhoneNumber({ type: "toll_free" }); +const mailbox = await identity.assignMailbox({ displayName: "Support Bot" }); +const phone = await identity.assignPhoneNumber({ type: "toll_free" }); -// Send email directly from the agent -await agent.sendEmail({ +// Send email directly from the identity +await identity.sendEmail({ to: ["customer@example.com"], subject: "Your order has shipped", bodyText: "Tracking number: 1Z999AA10123456784", }); // Place an outbound call -await agent.placeCall({ +await identity.placeCall({ toNumber: "+18005559999", streamUrl: "wss://my-app.com/voice", }); // Read inbox -for await (const message of agent.messages()) { +for await (const message of identity.messages()) { console.log(message.subject); } // Search transcripts -const transcripts = await agent.searchTranscripts({ q: "refund" }); +const transcripts = await identity.searchTranscripts({ q: "refund" }); ``` ## Authentication @@ -56,165 +56,92 @@ const transcripts = await agent.searchTranscripts({ q: "refund" }); --- -## Identities & Agent object +## Identities -`inkbox.identities.create()` and `inkbox.identities.get()` return an `Agent` object that holds the agent's channels and exposes convenience methods scoped to those channels. +`inkbox.createIdentity()` and `inkbox.getIdentity()` return an `AgentIdentity` object that holds the identity's channels and exposes convenience methods scoped to those channels. ```ts -// Create and fully provision an agent -const agent = await inkbox.identities.create({ agentHandle: "sales-bot" }); -const mailbox = await agent.assignMailbox({ displayName: "Sales Bot" }); // creates + links -const phone = await agent.assignPhoneNumber({ type: "toll_free" }); // provisions + links +// Create and fully provision an identity +const identity = await inkbox.createIdentity("sales-bot"); +const mailbox = await identity.assignMailbox({ displayName: "Sales Bot" }); // creates + links +const phone = await identity.assignPhoneNumber({ type: "toll_free" }); // provisions + links console.log(mailbox.emailAddress); console.log(phone.number); -// Get an existing agent (returned with current channel state) -const agent2 = await inkbox.identities.get("sales-bot"); +// Get an existing identity (returned with current channel state) +const identity2 = await inkbox.getIdentity("sales-bot"); +await identity2.refresh(); // re-fetch channels from API -// If the agent's channels may have changed since you fetched it, re-sync: -await agent2.refresh(); +// List all identities for your org +const allIdentities = await inkbox.listIdentities(); -// List / update / delete -const allIdentities = await inkbox.identities.list(); -await inkbox.identities.update("sales-bot", { status: "paused" }); -await agent.delete(); +// Update status or handle +await identity.update({ status: "paused" }); +await identity.update({ newHandle: "sales-bot-v2" }); + +// Unlink channels (without deleting them) +await identity.unlinkMailbox(); +await identity.unlinkPhoneNumber(); + +// Delete +await identity.delete(); ``` --- ## Mail -### Sending email - ```ts -// Via agent (no email address needed) -await agent.sendEmail({ +// Send an email +await identity.sendEmail({ to: ["user@example.com"], subject: "Hello", bodyText: "Hi there!", bodyHtml: "

Hi there!

", }); -// Via flat namespace (useful when you have a mailbox address directly) -await inkbox.messages.send("agent@inkboxmail.com", { - to: ["user@example.com"], - subject: "Hello", - bodyText: "Hi there!", -}); -``` - -### Reading messages and threads - -```ts -// Via agent — iterates inbox automatically (paginated) -for await (const msg of agent.messages()) { +// Iterate inbox (paginated automatically) +for await (const msg of identity.messages()) { console.log(msg.subject, msg.fromAddress); } -// Full message body -const detail = await inkbox.messages.get(mailbox.emailAddress, msg.id); -console.log(detail.bodyText); - -// Threads -for await (const thread of inkbox.threads.list(mailbox.emailAddress)) { - console.log(thread.subject, thread.messageCount); +// Filter by direction +for await (const msg of identity.messages({ direction: "inbound" })) { + console.log(msg.subject); } - -const threadDetail = await inkbox.threads.get(mailbox.emailAddress, thread.id); -for (const msg of threadDetail.messages) { - console.log(`[${msg.direction}] ${msg.fromAddress}: ${msg.snippet}`); -} -``` - -### Mailboxes - -```ts -const mailbox = await inkbox.mailboxes.create({ displayName: "Sales Agent" }); -const allMailboxes = await inkbox.mailboxes.list(); -await inkbox.mailboxes.update(mailbox.emailAddress, { displayName: "Sales Agent v2" }); -const results = await inkbox.mailboxes.search(mailbox.emailAddress, { q: "invoice" }); -await inkbox.mailboxes.delete(mailbox.emailAddress); -``` - -### Webhooks - -```ts -// Secret is one-time — save it immediately -const hook = await inkbox.mailWebhooks.create(mailbox.emailAddress, { - url: "https://yourapp.com/hooks/mail", - eventTypes: ["message.received", "message.sent"], -}); -console.log(hook.secret); - -const hooks = await inkbox.mailWebhooks.list(mailbox.emailAddress); -await inkbox.mailWebhooks.delete(mailbox.emailAddress, hook.id); ``` --- ## Phone -### Provisioning numbers - ```ts -const number = await inkbox.numbers.provision({ type: "toll_free" }); -const local = await inkbox.numbers.provision({ type: "local", state: "NY" }); - -const allNumbers = await inkbox.numbers.list(); -await inkbox.numbers.update(number.id, { - incomingCallAction: "auto_accept", - defaultStreamUrl: "wss://your-agent.example.com/ws", -}); -await inkbox.numbers.release({ number: number.number }); -``` - -### Placing calls - -```ts -// Via agent (fromNumber is automatic) -const call = await agent.placeCall({ +// Place an outbound call +const call = await identity.placeCall({ toNumber: "+15167251294", streamUrl: "wss://your-agent.example.com/ws", }); +console.log(call.status, call.rateLimit.callsRemaining); -// Via flat namespace -const call2 = await inkbox.calls.place({ - fromNumber: number.number, - toNumber: "+15167251294", - streamUrl: "wss://your-agent.example.com/ws", -}); -console.log(call2.status, call2.rateLimit.callsRemaining); +// Full-text search across transcripts +const results = await identity.searchTranscripts({ q: "appointment" }); +const filtered = await identity.searchTranscripts({ q: "refund", party: "remote", limit: 10 }); ``` -### Reading calls and transcripts - -```ts -const calls = await inkbox.calls.list(number.id, { limit: 10 }); -const transcripts = await inkbox.transcripts.list(number.id, calls[0].id); - -// Full-text search via agent -const results = await agent.searchTranscripts({ q: "appointment" }); - -// Or flat namespace -const results2 = await inkbox.numbers.searchTranscripts(number.id, { q: "appointment" }); -``` +--- -### Webhooks +## Signing Keys ```ts -const hook = await inkbox.phoneWebhooks.create(number.id, { - url: "https://yourapp.com/hooks/phone", - eventTypes: ["call.completed"], -}); -console.log(hook.secret); - -const hooks = await inkbox.phoneWebhooks.list(number.id); -await inkbox.phoneWebhooks.update(number.id, hook.id, { url: "https://yourapp.com/hooks/phone-v2" }); -await inkbox.phoneWebhooks.delete(number.id, hook.id); +// Create or rotate the org-level webhook signing key (plaintext returned once) +const key = await inkbox.createSigningKey(); +console.log(key.signingKey); // save this immediately ``` -### Verifying webhook signatures +--- + +## Verifying Webhook Signatures Use `verifyWebhook` to confirm that an incoming request was sent by Inkbox. @@ -243,15 +170,11 @@ Runnable example scripts are available in the [examples/typescript](https://gith | Script | What it demonstrates | |---|---| -| `register-agent-identity.ts` | Create an identity, assign mailbox + phone number via Agent | -| `create-agent-mailbox.ts` | Create, update, search, and delete a mailbox | +| `register-agent-identity.ts` | Create an identity, assign mailbox + phone number | | `agent-send-email.ts` | Send an email and a threaded reply | -| `read-agent-messages.ts` | List messages and read full threads | -| `create-agent-phone-number.ts` | Provision, update, and release a number | -| `list-agent-phone-numbers.ts` | List all provisioned numbers | +| `read-agent-messages.ts` | List messages | +| `create-agent-phone-number.ts` | Provision and update a number | | `read-agent-calls.ts` | List calls and print transcripts | -| `receive-agent-email-webhook.ts` | Register, list, and delete email webhooks | -| `receive-agent-call-webhook.ts` | Register, list, and delete phone webhooks | ## License From d680da8ce0d50cef18f0d5f2cf18e2f2ff525823 Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:41:24 -0400 Subject: [PATCH 39/56] restructure typescripts --- typescript/src/agent.ts | 2 +- typescript/src/index.ts | 6 +++--- typescript/src/inkbox.ts | 12 ++++++------ typescript/src/{ => mail}/resources/mailboxes.ts | 2 +- typescript/src/{ => mail}/resources/messages.ts | 2 +- typescript/src/{ => mail}/resources/threads.ts | 2 +- typescript/src/{ => mail}/resources/webhooks.ts | 2 +- typescript/src/{ => mail}/types.ts | 1 - .../{resources/signing-keys.ts => signing_keys.ts} | 2 +- typescript/tests/mail/mailboxes.test.ts | 2 +- typescript/tests/mail/messages.test.ts | 2 +- typescript/tests/mail/threads.test.ts | 2 +- typescript/tests/mail/types.test.ts | 2 +- typescript/tests/mail/webhooks.test.ts | 2 +- typescript/tests/signing-keys.test.ts | 2 +- 15 files changed, 21 insertions(+), 22 deletions(-) rename typescript/src/{ => mail}/resources/mailboxes.ts (98%) rename typescript/src/{ => mail}/resources/messages.ts (99%) rename typescript/src/{ => mail}/resources/threads.ts (97%) rename typescript/src/{ => mail}/resources/webhooks.ts (96%) rename typescript/src/{ => mail}/types.ts (99%) rename typescript/src/{resources/signing-keys.ts => signing_keys.ts} (98%) diff --git a/typescript/src/agent.ts b/typescript/src/agent.ts index bd751f8..d23a1b0 100644 --- a/typescript/src/agent.ts +++ b/typescript/src/agent.ts @@ -10,7 +10,7 @@ */ import { InkboxAPIError } from "./_http.js"; -import type { Message } from "./types.js"; +import type { Message } from "./mail/types.js"; import type { PhoneCallWithRateLimit, PhoneTranscript } from "./phone/types.js"; import type { AgentIdentitySummary, diff --git a/typescript/src/index.ts b/typescript/src/index.ts index 7989575..93ab27f 100644 --- a/typescript/src/index.ts +++ b/typescript/src/index.ts @@ -2,8 +2,8 @@ export { Inkbox } from "./inkbox.js"; export { AgentIdentity } from "./agent.js"; export type { InkboxOptions } from "./inkbox.js"; export { InkboxAPIError } from "./_http.js"; -export type { SigningKey } from "./resources/signing-keys.js"; -export { verifyWebhook } from "./resources/signing-keys.js"; +export type { SigningKey } from "./signing_keys.js"; +export { verifyWebhook } from "./signing_keys.js"; export type { Mailbox, Message, @@ -12,7 +12,7 @@ export type { ThreadDetail, Webhook as MailWebhook, WebhookCreateResult as MailWebhookCreateResult, -} from "./types.js"; +} from "./mail/types.js"; export type { PhoneNumber, PhoneCall, diff --git a/typescript/src/inkbox.ts b/typescript/src/inkbox.ts index 7ca709c..3602e41 100644 --- a/typescript/src/inkbox.ts +++ b/typescript/src/inkbox.ts @@ -5,12 +5,12 @@ */ import { HttpTransport } from "./_http.js"; -import { MailboxesResource } from "./resources/mailboxes.js"; -import { MessagesResource } from "./resources/messages.js"; -import { ThreadsResource } from "./resources/threads.js"; -import { WebhooksResource } from "./resources/webhooks.js"; -import { SigningKeysResource } from "./resources/signing-keys.js"; -import type { SigningKey } from "./resources/signing-keys.js"; +import { MailboxesResource } from "./mail/resources/mailboxes.js"; +import { MessagesResource } from "./mail/resources/messages.js"; +import { ThreadsResource } from "./mail/resources/threads.js"; +import { WebhooksResource } from "./mail/resources/webhooks.js"; +import { SigningKeysResource } from "./signing_keys.js"; +import type { SigningKey } from "./signing_keys.js"; import { PhoneNumbersResource } from "./phone/resources/numbers.js"; import { CallsResource } from "./phone/resources/calls.js"; import { TranscriptsResource } from "./phone/resources/transcripts.js"; diff --git a/typescript/src/resources/mailboxes.ts b/typescript/src/mail/resources/mailboxes.ts similarity index 98% rename from typescript/src/resources/mailboxes.ts rename to typescript/src/mail/resources/mailboxes.ts index 6f0c599..f7ea312 100644 --- a/typescript/src/resources/mailboxes.ts +++ b/typescript/src/mail/resources/mailboxes.ts @@ -4,7 +4,7 @@ * Mailbox CRUD and full-text search. */ -import { HttpTransport } from "../_http.js"; +import { HttpTransport } from "../../_http.js"; import { Mailbox, Message, diff --git a/typescript/src/resources/messages.ts b/typescript/src/mail/resources/messages.ts similarity index 99% rename from typescript/src/resources/messages.ts rename to typescript/src/mail/resources/messages.ts index 20e9036..7173dc6 100644 --- a/typescript/src/resources/messages.ts +++ b/typescript/src/mail/resources/messages.ts @@ -4,7 +4,7 @@ * Message operations: list (auto-paginated), get, send, flag updates, delete. */ -import { HttpTransport } from "../_http.js"; +import { HttpTransport } from "../../_http.js"; import { Message, MessageDetail, diff --git a/typescript/src/resources/threads.ts b/typescript/src/mail/resources/threads.ts similarity index 97% rename from typescript/src/resources/threads.ts rename to typescript/src/mail/resources/threads.ts index 0648b37..c2ba11b 100644 --- a/typescript/src/resources/threads.ts +++ b/typescript/src/mail/resources/threads.ts @@ -4,7 +4,7 @@ * Thread operations: list (auto-paginated), get with messages, delete. */ -import { HttpTransport } from "../_http.js"; +import { HttpTransport } from "../../_http.js"; import { RawCursorPage, RawThread, diff --git a/typescript/src/resources/webhooks.ts b/typescript/src/mail/resources/webhooks.ts similarity index 96% rename from typescript/src/resources/webhooks.ts rename to typescript/src/mail/resources/webhooks.ts index 4cf782a..f4805e1 100644 --- a/typescript/src/resources/webhooks.ts +++ b/typescript/src/mail/resources/webhooks.ts @@ -4,7 +4,7 @@ * Webhook CRUD for mailboxes. */ -import { HttpTransport } from "../_http.js"; +import { HttpTransport } from "../../_http.js"; import { Webhook, WebhookCreateResult, diff --git a/typescript/src/types.ts b/typescript/src/mail/types.ts similarity index 99% rename from typescript/src/types.ts rename to typescript/src/mail/types.ts index 6c90124..7accd6c 100644 --- a/typescript/src/types.ts +++ b/typescript/src/mail/types.ts @@ -228,4 +228,3 @@ export function parseWebhookCreateResult(r: RawWebhookCreateResult): WebhookCrea secret: r.secret, }; } - diff --git a/typescript/src/resources/signing-keys.ts b/typescript/src/signing_keys.ts similarity index 98% rename from typescript/src/resources/signing-keys.ts rename to typescript/src/signing_keys.ts index fd2a496..70b62a1 100644 --- a/typescript/src/resources/signing-keys.ts +++ b/typescript/src/signing_keys.ts @@ -5,7 +5,7 @@ */ import { createHmac, timingSafeEqual } from "crypto"; -import { HttpTransport } from "../_http.js"; +import { HttpTransport } from "./_http.js"; const PATH = "/signing-keys"; diff --git a/typescript/tests/mail/mailboxes.test.ts b/typescript/tests/mail/mailboxes.test.ts index efe75e3..b659fcf 100644 --- a/typescript/tests/mail/mailboxes.test.ts +++ b/typescript/tests/mail/mailboxes.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { MailboxesResource } from "../../src/resources/mailboxes.js"; +import { MailboxesResource } from "../../src/mail/resources/mailboxes.js"; import type { HttpTransport } from "../../src/_http.js"; import { RAW_MAILBOX, RAW_MESSAGE, CURSOR_PAGE_MESSAGES } from "../sampleData.js"; diff --git a/typescript/tests/mail/messages.test.ts b/typescript/tests/mail/messages.test.ts index 48b5975..d95ce4e 100644 --- a/typescript/tests/mail/messages.test.ts +++ b/typescript/tests/mail/messages.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { MessagesResource } from "../../src/resources/messages.js"; +import { MessagesResource } from "../../src/mail/resources/messages.js"; import type { HttpTransport } from "../../src/_http.js"; import { RAW_MESSAGE, diff --git a/typescript/tests/mail/threads.test.ts b/typescript/tests/mail/threads.test.ts index 8928b7b..f1bac61 100644 --- a/typescript/tests/mail/threads.test.ts +++ b/typescript/tests/mail/threads.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { ThreadsResource } from "../../src/resources/threads.js"; +import { ThreadsResource } from "../../src/mail/resources/threads.js"; import type { HttpTransport } from "../../src/_http.js"; import { RAW_THREAD, RAW_THREAD_DETAIL, CURSOR_PAGE_THREADS } from "../sampleData.js"; diff --git a/typescript/tests/mail/types.test.ts b/typescript/tests/mail/types.test.ts index 9849152..73a413e 100644 --- a/typescript/tests/mail/types.test.ts +++ b/typescript/tests/mail/types.test.ts @@ -7,7 +7,7 @@ import { parseThreadDetail, parseWebhook, parseWebhookCreateResult, -} from "../../src/types.js"; +} from "../../src/mail/types.js"; import { RAW_MAILBOX, RAW_MESSAGE, diff --git a/typescript/tests/mail/webhooks.test.ts b/typescript/tests/mail/webhooks.test.ts index 15d670f..2f2b663 100644 --- a/typescript/tests/mail/webhooks.test.ts +++ b/typescript/tests/mail/webhooks.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { WebhooksResource } from "../../src/resources/webhooks.js"; +import { WebhooksResource } from "../../src/mail/resources/webhooks.js"; import type { HttpTransport } from "../../src/_http.js"; import { RAW_WEBHOOK, RAW_WEBHOOK_CREATE } from "../sampleData.js"; diff --git a/typescript/tests/signing-keys.test.ts b/typescript/tests/signing-keys.test.ts index fc5b20d..0238838 100644 --- a/typescript/tests/signing-keys.test.ts +++ b/typescript/tests/signing-keys.test.ts @@ -1,6 +1,6 @@ import { createHmac } from "crypto"; import { describe, it, expect, vi } from "vitest"; -import { SigningKeysResource, verifyWebhook } from "../src/resources/signing-keys.js"; +import { SigningKeysResource, verifyWebhook } from "../src/signing_keys.js"; import { HttpTransport } from "../src/_http.js"; import { RAW_SIGNING_KEY } from "./sampleData.js"; From b2332f472313baf624b7e96ee427aaf87db04a5c Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:45:54 -0400 Subject: [PATCH 40/56] fix tests --- python/inkbox/client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python/inkbox/client.py b/python/inkbox/client.py index a6faaed..de72f93 100644 --- a/python/inkbox/client.py +++ b/python/inkbox/client.py @@ -18,6 +18,7 @@ from inkbox.phone.resources.webhooks import PhoneWebhooksResource from inkbox.identities._http import HttpTransport as IdsHttpTransport from inkbox.identities.resources.identities import IdentitiesResource +from inkbox.agent_identity import AgentIdentity from inkbox.identities.types import AgentIdentitySummary from inkbox.signing_keys import SigningKey, SigningKeysResource From 82dcae859cef28266241d989f8887988c8aa6667 Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:56:45 -0400 Subject: [PATCH 41/56] feat: call and transcripts methods --- README.md | 12 ++++++++++++ python/README.md | 7 +++++++ python/inkbox/agent_identity.py | 28 +++++++++++++++++++++++++++- typescript/README.md | 7 +++++++ typescript/src/agent.ts | 23 ++++++++++++++++++++++- 5 files changed, 75 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ee0a56b..9145955 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,12 @@ with Inkbox(api_key="ApiKey_...") as inkbox: # Search transcripts results = identity.search_transcripts(q="appointment") + + # List calls + calls = identity.calls() + + # Fetch transcript segments for a call + segments = identity.transcripts(calls[0].id) ``` ### TypeScript @@ -151,6 +157,12 @@ console.log(call.rateLimit.callsRemaining); // Search transcripts const results = await identity.searchTranscripts({ q: "appointment" }); + +// List calls +const calls = await identity.calls(); + +// Fetch transcript segments for a call +const segments = await identity.transcripts(calls[0].id); ``` --- diff --git a/python/README.md b/python/README.md index 1da936b..a1087c3 100644 --- a/python/README.md +++ b/python/README.md @@ -126,6 +126,13 @@ print(call.status, call.rate_limit.calls_remaining) # Full-text search across transcripts results = identity.search_transcripts(q="appointment") results = identity.search_transcripts(q="refund", party="remote", limit=10) + +# List calls +calls = identity.calls() +calls = identity.calls(limit=10, offset=0) + +# Fetch transcript segments for a call +segments = identity.transcripts(calls[0].id) ``` --- diff --git a/python/inkbox/agent_identity.py b/python/inkbox/agent_identity.py index 6a63bd9..e6d4f32 100644 --- a/python/inkbox/agent_identity.py +++ b/python/inkbox/agent_identity.py @@ -16,7 +16,7 @@ from inkbox.identities.types import _AgentIdentityData, IdentityMailbox, IdentityPhoneNumber from inkbox.mail.exceptions import InkboxError from inkbox.mail.types import Message -from inkbox.phone.types import PhoneCallWithRateLimit, PhoneTranscript +from inkbox.phone.types import PhoneCall, PhoneCallWithRateLimit, PhoneTranscript if TYPE_CHECKING: from inkbox.client import Inkbox @@ -241,6 +241,32 @@ def search_transcripts( limit=limit, ) + def calls(self, *, limit: int = 50, offset: int = 0) -> list[PhoneCall]: + """List calls made to/from this identity's phone number. + + Args: + limit: Maximum number of results (default 50). + offset: Pagination offset (default 0). + """ + self._require_phone() + return self._inkbox._calls.list( + self._phone_number.id, # type: ignore[union-attr] + limit=limit, + offset=offset, + ) + + def transcripts(self, call_id: str) -> list[PhoneTranscript]: + """Fetch transcript segments for a specific call. + + Args: + call_id: ID of the call to fetch transcripts for. + """ + self._require_phone() + return self._inkbox._transcripts.list( + self._phone_number.id, # type: ignore[union-attr] + call_id, + ) + # ------------------------------------------------------------------ # Identity management # ------------------------------------------------------------------ diff --git a/typescript/README.md b/typescript/README.md index 652851c..9ee48b9 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -127,6 +127,13 @@ console.log(call.status, call.rateLimit.callsRemaining); // Full-text search across transcripts const results = await identity.searchTranscripts({ q: "appointment" }); const filtered = await identity.searchTranscripts({ q: "refund", party: "remote", limit: 10 }); + +// List calls +const calls = await identity.calls(); +const paged = await identity.calls({ limit: 10, offset: 0 }); + +// Fetch transcript segments for a call +const segments = await identity.transcripts(calls[0].id); ``` --- diff --git a/typescript/src/agent.ts b/typescript/src/agent.ts index d23a1b0..6318c58 100644 --- a/typescript/src/agent.ts +++ b/typescript/src/agent.ts @@ -11,7 +11,7 @@ import { InkboxAPIError } from "./_http.js"; import type { Message } from "./mail/types.js"; -import type { PhoneCallWithRateLimit, PhoneTranscript } from "./phone/types.js"; +import type { PhoneCall, PhoneCallWithRateLimit, PhoneTranscript } from "./phone/types.js"; import type { AgentIdentitySummary, _AgentIdentityData, @@ -192,6 +192,27 @@ export class AgentIdentity { return this._inkbox._numbers.searchTranscripts(this._phoneNumber!.id, options); } + /** + * List calls made to/from this identity's phone number. + * + * @param options.limit - Maximum number of results. Defaults to 50. + * @param options.offset - Pagination offset. Defaults to 0. + */ + async calls(options: { limit?: number; offset?: number } = {}): Promise { + this._requirePhone(); + return this._inkbox._calls.list(this._phoneNumber!.id, options); + } + + /** + * Fetch transcript segments for a specific call. + * + * @param callId - ID of the call to fetch transcripts for. + */ + async transcripts(callId: string): Promise { + this._requirePhone(); + return this._inkbox._transcripts.list(this._phoneNumber!.id, callId); + } + // ------------------------------------------------------------------ // Identity management // ------------------------------------------------------------------ From bf463f973ef900cb4706fed96d976efb4dad1dfb Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:09:19 -0400 Subject: [PATCH 42/56] feat: update readme files --- examples/python/agent_send_email.py | 12 ++++----- examples/python/create_agent_mailbox.py | 24 ++++++++--------- examples/python/create_agent_phone_number.py | 27 ++++++++----------- examples/python/list_agent_phone_numbers.py | 2 +- examples/python/read_agent_calls.py | 10 +++---- examples/python/read_agent_messages.py | 12 ++++----- examples/python/receive_agent_call_webhook.py | 16 +++++------ .../python/receive_agent_email_webhook.py | 14 +++++----- examples/python/register_agent_identity.py | 6 ++--- examples/typescript/agent-send-email.ts | 10 +++---- examples/typescript/create-agent-mailbox.ts | 27 ++++++++----------- .../typescript/create-agent-phone-number.ts | 25 ++++++++--------- .../typescript/list-agent-phone-numbers.ts | 2 +- examples/typescript/read-agent-calls.ts | 10 +++---- examples/typescript/read-agent-messages.ts | 12 ++++----- .../typescript/receive-agent-call-webhook.ts | 12 ++++----- .../typescript/receive-agent-email-webhook.ts | 12 ++++----- .../typescript/register-agent-identity.ts | 6 ++--- python/README.md | 8 ++++-- typescript/README.md | 8 ++++-- 20 files changed, 123 insertions(+), 132 deletions(-) diff --git a/examples/python/agent_send_email.py b/examples/python/agent_send_email.py index 1bd04ce..c0453f7 100644 --- a/examples/python/agent_send_email.py +++ b/examples/python/agent_send_email.py @@ -1,19 +1,18 @@ """ -Send an email (and reply) from an Inkbox mailbox. +Send an email (and reply) from an Inkbox agent identity. Usage: - INKBOX_API_KEY=ApiKey_... MAILBOX_ADDRESS=agent@inkboxmail.com python agent_send_email.py + INKBOX_API_KEY=ApiKey_... AGENT_HANDLE=sales-agent python agent_send_email.py """ import os from inkbox import Inkbox inkbox = Inkbox(api_key=os.environ["INKBOX_API_KEY"]) -mailbox_address = os.environ["MAILBOX_ADDRESS"] +identity = inkbox.get_identity(os.environ["AGENT_HANDLE"]) # Agent sends outbound email -sent = inkbox.messages.send( - mailbox_address, +sent = identity.send_email( to=["recipient@example.com"], subject="Hello from your AI sales agent", body_text="Hi there! I'm your AI sales agent reaching out via Inkbox.", @@ -22,8 +21,7 @@ print(f"Sent message {sent.id} subject={sent.subject!r}") # Agent sends threaded reply -reply = inkbox.messages.send( - mailbox_address, +reply = identity.send_email( to=["recipient@example.com"], subject=f"Re: {sent.subject}", body_text="Following up as your AI sales agent.", diff --git a/examples/python/create_agent_mailbox.py b/examples/python/create_agent_mailbox.py index 5604214..4dc8d93 100644 --- a/examples/python/create_agent_mailbox.py +++ b/examples/python/create_agent_mailbox.py @@ -1,5 +1,5 @@ """ -Create, update, search, and delete a mailbox. +Create and manage a mailbox via an agent identity. Usage: INKBOX_API_KEY=ApiKey_... python create_agent_mailbox.py @@ -9,24 +9,22 @@ from inkbox import Inkbox with Inkbox(api_key=os.environ["INKBOX_API_KEY"]) as inkbox: - # Create a mailbox - mailbox = inkbox.mailboxes.create(display_name="Sales Agent") + # Create an identity and assign a mailbox in one call + agent = inkbox.create_identity("sales-agent") + mailbox = agent.assign_mailbox(display_name="Sales Agent") print(f"Mailbox created: {mailbox.email_address} display_name={mailbox.display_name!r}") - # List all mailboxes - all_mailboxes = inkbox.mailboxes.list() - print(f"\nAll mailboxes ({len(all_mailboxes)}):") - for m in all_mailboxes: - print(f" {m.email_address} status={m.status}") - # Update display name - updated = inkbox.mailboxes.update(mailbox.email_address, display_name="Sales Agent (updated)") + updated = inkbox._mailboxes.update(mailbox.email_address, display_name="Sales Agent (updated)") print(f"\nUpdated display_name: {updated.display_name}") # Full-text search - results = inkbox.mailboxes.search(mailbox.email_address, q="hello") + results = inkbox._mailboxes.search(mailbox.email_address, q="hello") print(f'\nSearch results for "hello": {len(results)} messages') - # Delete - inkbox.mailboxes.delete(mailbox.email_address) + # Unlink mailbox from identity, then delete it + agent.unlink_mailbox() + inkbox._mailboxes.delete(mailbox.email_address) print("Mailbox deleted.") + + agent.delete() diff --git a/examples/python/create_agent_phone_number.py b/examples/python/create_agent_phone_number.py index 9cc15da..40a3bb9 100644 --- a/examples/python/create_agent_phone_number.py +++ b/examples/python/create_agent_phone_number.py @@ -1,5 +1,5 @@ """ -Provision, update, and release a phone number. +Provision, update, and release a phone number via an agent identity. Usage: INKBOX_API_KEY=ApiKey_... python create_agent_phone_number.py @@ -13,23 +13,18 @@ number_type = os.environ.get("NUMBER_TYPE", "toll_free") state = os.environ.get("STATE") -# Provision agent phone number -kwargs = {"type": number_type} -if state: - kwargs["state"] = state -number = inkbox.numbers.provision(**kwargs) -print(f"Agent phone number provisioned: {number.number} type={number.type} status={number.status}") - -# List all numbers -all_numbers = inkbox.numbers.list() -print(f"\nAll agent phone numbers ({len(all_numbers)}):") -for n in all_numbers: - print(f" {n.number} type={n.type} status={n.status}") +# Create an identity and provision + assign a phone number in one call +agent = inkbox.create_identity("sales-agent") +phone = agent.assign_phone_number(type=number_type, state=state) +print(f"Agent phone number provisioned: {phone.number} type={phone.type} status={phone.status}") # Update incoming call action -updated = inkbox.numbers.update(number.id, incoming_call_action="auto_accept") +updated = inkbox._numbers.update(phone.id, incoming_call_action="auto_accept") print(f"\nUpdated incoming_call_action: {updated.incoming_call_action}") -# Release agent phone number -inkbox.numbers.release(number=number.number) +# Unlink phone number from identity, then release it +agent.unlink_phone_number() +inkbox._numbers.release(number=phone.number) print("Agent phone number released.") + +agent.delete() diff --git a/examples/python/list_agent_phone_numbers.py b/examples/python/list_agent_phone_numbers.py index e1e1f22..cb41116 100644 --- a/examples/python/list_agent_phone_numbers.py +++ b/examples/python/list_agent_phone_numbers.py @@ -10,7 +10,7 @@ inkbox = Inkbox(api_key=os.environ["INKBOX_API_KEY"]) -numbers = inkbox.numbers.list() +numbers = inkbox._numbers.list() for n in numbers: print(f"{n.number} type={n.type} status={n.status}") diff --git a/examples/python/read_agent_calls.py b/examples/python/read_agent_calls.py index 45a37fc..6385ca9 100644 --- a/examples/python/read_agent_calls.py +++ b/examples/python/read_agent_calls.py @@ -1,21 +1,21 @@ """ -List recent calls and their transcripts for a phone number. +List recent calls and their transcripts for an agent identity. Usage: - INKBOX_API_KEY=ApiKey_... PHONE_NUMBER_ID= python read_agent_calls.py + INKBOX_API_KEY=ApiKey_... AGENT_HANDLE=sales-agent python read_agent_calls.py """ import os from inkbox import Inkbox inkbox = Inkbox(api_key=os.environ["INKBOX_API_KEY"]) -phone_number_id = os.environ["PHONE_NUMBER_ID"] +identity = inkbox.get_identity(os.environ["AGENT_HANDLE"]) -calls = inkbox.calls.list(phone_number_id, limit=10) +calls = identity.calls(limit=10) for call in calls: print(f"\n{call.id} {call.direction} {call.remote_phone_number} status={call.status}") - transcripts = inkbox.transcripts.list(phone_number_id, call.id) + transcripts = identity.transcripts(call.id) for t in transcripts: print(f" [{t.party}] {t.text}") diff --git a/examples/python/read_agent_messages.py b/examples/python/read_agent_messages.py index 41bc655..343a5b2 100644 --- a/examples/python/read_agent_messages.py +++ b/examples/python/read_agent_messages.py @@ -1,19 +1,19 @@ """ -List messages and threads in a mailbox, and read a full thread. +List messages and threads in an agent's mailbox, and read a full thread. Usage: - INKBOX_API_KEY=ApiKey_... MAILBOX_ADDRESS=agent@inkboxmail.com python read_agent_messages.py + INKBOX_API_KEY=ApiKey_... AGENT_HANDLE=sales-agent python read_agent_messages.py """ import os from inkbox import Inkbox inkbox = Inkbox(api_key=os.environ["INKBOX_API_KEY"]) -mailbox_address = os.environ["MAILBOX_ADDRESS"] +identity = inkbox.get_identity(os.environ["AGENT_HANDLE"]) # List the 5 most recent messages print("=== Agent inbox ===") -for i, msg in enumerate(inkbox.messages.list(mailbox_address)): +for i, msg in enumerate(identity.messages()): print(f"{msg.id} {msg.subject} from={msg.from_address} read={msg.is_read}") if i >= 4: break @@ -21,13 +21,13 @@ # List threads and fetch the first one in full print("\n=== Agent threads ===") first_thread_id = None -for thread in inkbox.threads.list(mailbox_address): +for thread in inkbox._threads.list(identity.mailbox.email_address): print(f"{thread.id} {thread.subject!r} messages={thread.message_count}") if first_thread_id is None: first_thread_id = thread.id if first_thread_id: - thread = inkbox.threads.get(mailbox_address, first_thread_id) + thread = inkbox._threads.get(identity.mailbox.email_address, first_thread_id) print(f"\nAgent conversation: {thread.subject!r} ({len(thread.messages)} messages)") for msg in thread.messages: print(f" [{msg.from_address}] {msg.subject}") diff --git a/examples/python/receive_agent_call_webhook.py b/examples/python/receive_agent_call_webhook.py index ef87b93..dc08c95 100644 --- a/examples/python/receive_agent_call_webhook.py +++ b/examples/python/receive_agent_call_webhook.py @@ -1,32 +1,32 @@ """ -Create, update, and delete a webhook on a phone number. +Create, update, and delete a webhook on an agent's phone number. Usage: - INKBOX_API_KEY=ApiKey_... PHONE_NUMBER_ID= python receive_agent_call_webhook.py + INKBOX_API_KEY=ApiKey_... AGENT_HANDLE=sales-agent python receive_agent_call_webhook.py """ import os from inkbox import Inkbox inkbox = Inkbox(api_key=os.environ["INKBOX_API_KEY"]) -phone_number_id = os.environ["PHONE_NUMBER_ID"] +identity = inkbox.get_identity(os.environ["AGENT_HANDLE"]) # Register webhook for agent phone number -hook = inkbox.phone_webhooks.create( - phone_number_id, +hook = inkbox._phone_webhooks.create( + identity.phone_number.id, url="https://example.com/webhook", event_types=["incoming_call"], ) print(f"Registered agent phone webhook {hook.id} secret={hook.secret}") # Update agent phone webhook -updated = inkbox.phone_webhooks.update( - phone_number_id, +updated = inkbox._phone_webhooks.update( + identity.phone_number.id, hook.id, url="https://example.com/webhook-v2", ) print(f"Updated URL: {updated.url}") # Remove agent phone webhook -inkbox.phone_webhooks.delete(phone_number_id, hook.id) +inkbox._phone_webhooks.delete(identity.phone_number.id, hook.id) print("Agent phone webhook removed.") diff --git a/examples/python/receive_agent_email_webhook.py b/examples/python/receive_agent_email_webhook.py index 658f515..6849de3 100644 --- a/examples/python/receive_agent_email_webhook.py +++ b/examples/python/receive_agent_email_webhook.py @@ -1,30 +1,30 @@ """ -Register and delete a webhook on a mailbox. +Register and delete a webhook on an agent's mailbox. Usage: - INKBOX_API_KEY=ApiKey_... MAILBOX_ADDRESS=agent@inkboxmail.com python receive_agent_email_webhook.py + INKBOX_API_KEY=ApiKey_... AGENT_HANDLE=sales-agent python receive_agent_email_webhook.py """ import os from inkbox import Inkbox inkbox = Inkbox(api_key=os.environ["INKBOX_API_KEY"]) -mailbox_address = os.environ["MAILBOX_ADDRESS"] +identity = inkbox.get_identity(os.environ["AGENT_HANDLE"]) # Register webhook for agent mailbox -hook = inkbox.mail_webhooks.create( - mailbox_address, +hook = inkbox._mail_webhooks.create( + identity.mailbox.id, url="https://example.com/webhook", event_types=["message.received", "message.sent"], ) print(f"Registered agent mailbox webhook {hook.id} secret={hook.secret}") # List -all_hooks = inkbox.mail_webhooks.list(mailbox_address) +all_hooks = inkbox._mail_webhooks.list(identity.mailbox.id) print(f"Active agent mailbox webhooks: {len(all_hooks)}") for w in all_hooks: print(f" {w.id} url={w.url} events={', '.join(w.event_types)}") # Remove agent mailbox webhook -inkbox.mail_webhooks.delete(mailbox_address, hook.id) +inkbox._mail_webhooks.delete(identity.mailbox.id, hook.id) print("Agent mailbox webhook removed.") diff --git a/examples/python/register_agent_identity.py b/examples/python/register_agent_identity.py index e2f052e..d7de82f 100644 --- a/examples/python/register_agent_identity.py +++ b/examples/python/register_agent_identity.py @@ -9,8 +9,8 @@ from inkbox import Inkbox with Inkbox(api_key=os.environ["INKBOX_API_KEY"]) as inkbox: - # Create agent identity — returns an Agent object - agent = inkbox.identities.create(agent_handle="sales-agent") + # Create agent identity — returns an AgentIdentity object + agent = inkbox.create_identity("sales-agent") print(f"Registered agent: {agent.agent_handle} (id={agent.id})") # Provision and assign channels in one call each @@ -21,7 +21,7 @@ print(f"Assigned phone: {phone.number}") # List all identities - all_identities = inkbox.identities.list() + all_identities = inkbox.list_identities() print(f"\nAll identities ({len(all_identities)}):") for ident in all_identities: print(f" {ident.agent_handle} status={ident.status}") diff --git a/examples/typescript/agent-send-email.ts b/examples/typescript/agent-send-email.ts index d177909..4c211a1 100644 --- a/examples/typescript/agent-send-email.ts +++ b/examples/typescript/agent-send-email.ts @@ -1,17 +1,17 @@ /** - * Send an email (and reply) from an Inkbox mailbox. + * Send an email (and reply) from an Inkbox agent identity. * * Usage: - * INKBOX_API_KEY=ApiKey_... MAILBOX_ADDRESS=agent@inkboxmail.com npx ts-node agent-send-email.ts + * INKBOX_API_KEY=ApiKey_... AGENT_HANDLE=sales-agent npx ts-node agent-send-email.ts */ import { Inkbox } from "../../typescript/src/inkbox.js"; const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); -const mailboxAddress = process.env.MAILBOX_ADDRESS!; +const identity = await inkbox.getIdentity(process.env.AGENT_HANDLE!); // Agent sends outbound email -const sent = await inkbox.messages.send(mailboxAddress, { +const sent = await identity.sendEmail({ to: ["recipient@example.com"], subject: "Hello from your AI sales agent", bodyText: "Hi there! I'm your AI sales agent reaching out via Inkbox.", @@ -20,7 +20,7 @@ const sent = await inkbox.messages.send(mailboxAddress, { console.log(`Sent message ${sent.id} subject="${sent.subject}"`); // Agent sends threaded reply -const reply = await inkbox.messages.send(mailboxAddress, { +const reply = await identity.sendEmail({ to: ["recipient@example.com"], subject: `Re: ${sent.subject}`, bodyText: "Following up as your AI sales agent.", diff --git a/examples/typescript/create-agent-mailbox.ts b/examples/typescript/create-agent-mailbox.ts index 8aaf4ac..5152e0b 100644 --- a/examples/typescript/create-agent-mailbox.ts +++ b/examples/typescript/create-agent-mailbox.ts @@ -1,5 +1,5 @@ /** - * Create, update, search, and delete a mailbox. + * Create and manage a mailbox via an agent identity. * * Usage: * INKBOX_API_KEY=ApiKey_... npx ts-node create-agent-mailbox.ts @@ -9,29 +9,24 @@ import { Inkbox } from "../../typescript/src/inkbox.js"; const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); -// Create agent mailbox -const mailbox = await inkbox.mailboxes.create({ - displayName: "Sales Agent", -}); +// Create an identity and assign a mailbox in one call +const agent = await inkbox.createIdentity("sales-agent"); +const mailbox = await agent.assignMailbox({ displayName: "Sales Agent" }); console.log(`Agent mailbox created: ${mailbox.emailAddress} displayName="${mailbox.displayName}"`); -// List all mailboxes -const all = await inkbox.mailboxes.list(); -console.log(`\nAll agent mailboxes (${all.length}):`); -for (const m of all) { - console.log(` ${m.emailAddress} status=${m.status}`); -} - // Update display name -const updated = await inkbox.mailboxes.update(mailbox.emailAddress, { +const updated = await inkbox._mailboxes.update(mailbox.emailAddress, { displayName: "Sales Agent (updated)", }); console.log(`\nUpdated displayName: ${updated.displayName}`); // Full-text search -const results = await inkbox.mailboxes.search(mailbox.emailAddress, { q: "hello" }); +const results = await inkbox._mailboxes.search(mailbox.emailAddress, { q: "hello" }); console.log(`\nSearch results for "hello": ${results.length} messages`); -// Delete agent mailbox -await inkbox.mailboxes.delete(mailbox.emailAddress); +// Unlink mailbox from identity, then delete it +await agent.unlinkMailbox(); +await inkbox._mailboxes.delete(mailbox.emailAddress); console.log("Agent mailbox deleted."); + +await agent.delete(); diff --git a/examples/typescript/create-agent-phone-number.ts b/examples/typescript/create-agent-phone-number.ts index a068564..9a5a116 100644 --- a/examples/typescript/create-agent-phone-number.ts +++ b/examples/typescript/create-agent-phone-number.ts @@ -1,5 +1,5 @@ /** - * Provision, update, and release a phone number. + * Provision, update, and release a phone number via an agent identity. * * Usage: * INKBOX_API_KEY=ApiKey_... npx ts-node create-agent-phone-number.ts @@ -12,26 +12,23 @@ const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); const numberType = process.env.NUMBER_TYPE ?? "toll_free"; const state = process.env.STATE; -// Provision agent phone number -const number = await inkbox.numbers.provision({ +// Create an identity and provision + assign a phone number in one call +const agent = await inkbox.createIdentity("sales-agent"); +const phone = await agent.assignPhoneNumber({ type: numberType, ...(state ? { state } : {}), }); -console.log(`Agent phone number provisioned: ${number.number} type=${number.type} status=${number.status}`); - -// List all numbers -const all = await inkbox.numbers.list(); -console.log(`\nAll agent phone numbers (${all.length}):`); -for (const n of all) { - console.log(` ${n.number} type=${n.type} status=${n.status}`); -} +console.log(`Agent phone number provisioned: ${phone.number} type=${phone.type} status=${phone.status}`); // Update incoming call action -const updated = await inkbox.numbers.update(number.id, { +const updated = await inkbox._numbers.update(phone.id, { incomingCallAction: "auto_accept", }); console.log(`\nUpdated incomingCallAction: ${updated.incomingCallAction}`); -// Release agent phone number -await inkbox.numbers.release({ number: number.number }); +// Unlink phone number from identity, then release it +await agent.unlinkPhoneNumber(); +await inkbox._numbers.release({ number: phone.number }); console.log("Agent phone number released."); + +await agent.delete(); diff --git a/examples/typescript/list-agent-phone-numbers.ts b/examples/typescript/list-agent-phone-numbers.ts index c7caf1b..d835362 100644 --- a/examples/typescript/list-agent-phone-numbers.ts +++ b/examples/typescript/list-agent-phone-numbers.ts @@ -9,7 +9,7 @@ import { Inkbox } from "../../typescript/src/inkbox.js"; const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); -const numbers = await inkbox.numbers.list(); +const numbers = await inkbox._numbers.list(); for (const n of numbers) { console.log(`${n.number} type=${n.type} status=${n.status}`); diff --git a/examples/typescript/read-agent-calls.ts b/examples/typescript/read-agent-calls.ts index c8037f6..0eb3063 100644 --- a/examples/typescript/read-agent-calls.ts +++ b/examples/typescript/read-agent-calls.ts @@ -1,21 +1,21 @@ /** - * List recent calls and their transcripts for a phone number. + * List recent calls and their transcripts for an agent identity. * * Usage: - * INKBOX_API_KEY=ApiKey_... PHONE_NUMBER_ID= npx ts-node read-agent-calls.ts + * INKBOX_API_KEY=ApiKey_... AGENT_HANDLE=sales-agent npx ts-node read-agent-calls.ts */ import { Inkbox } from "../../typescript/src/inkbox.js"; const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); -const phoneNumberId = process.env.PHONE_NUMBER_ID!; +const identity = await inkbox.getIdentity(process.env.AGENT_HANDLE!); -const calls = await inkbox.calls.list(phoneNumberId, { limit: 10 }); +const calls = await identity.calls({ limit: 10 }); for (const call of calls) { console.log(`\n${call.id} ${call.direction} ${call.remotePhoneNumber} status=${call.status}`); - const transcripts = await inkbox.transcripts.list(phoneNumberId, call.id); + const transcripts = await identity.transcripts(call.id); for (const t of transcripts) { console.log(` [${t.party}] ${t.text}`); } diff --git a/examples/typescript/read-agent-messages.ts b/examples/typescript/read-agent-messages.ts index 6dc891a..fb2bb30 100644 --- a/examples/typescript/read-agent-messages.ts +++ b/examples/typescript/read-agent-messages.ts @@ -1,19 +1,19 @@ /** - * List messages and threads in a mailbox, and read a full thread. + * List messages and threads in an agent's mailbox, and read a full thread. * * Usage: - * INKBOX_API_KEY=ApiKey_... MAILBOX_ADDRESS=agent@inkboxmail.com npx ts-node read-agent-messages.ts + * INKBOX_API_KEY=ApiKey_... AGENT_HANDLE=sales-agent npx ts-node read-agent-messages.ts */ import { Inkbox } from "../../typescript/src/inkbox.js"; const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); -const mailboxAddress = process.env.MAILBOX_ADDRESS!; +const identity = await inkbox.getIdentity(process.env.AGENT_HANDLE!); // List the 5 most recent messages console.log("=== Agent inbox ==="); let count = 0; -for await (const msg of inkbox.messages.list(mailboxAddress)) { +for await (const msg of identity.messages()) { console.log(`${msg.id} ${msg.subject} from=${msg.fromAddress} read=${msg.isRead}`); if (++count >= 5) break; } @@ -21,13 +21,13 @@ for await (const msg of inkbox.messages.list(mailboxAddress)) { // List threads and fetch the first one in full console.log("\n=== Agent threads ==="); let firstThreadId: string | undefined; -for await (const thread of inkbox.threads.list(mailboxAddress)) { +for await (const thread of inkbox._threads.list(identity.mailbox!.emailAddress)) { console.log(`${thread.id} "${thread.subject}" messages=${thread.messageCount}`); firstThreadId ??= thread.id; } if (firstThreadId) { - const thread = await inkbox.threads.get(mailboxAddress, firstThreadId); + const thread = await inkbox._threads.get(identity.mailbox!.emailAddress, firstThreadId); console.log(`\nAgent conversation: "${thread.subject}" (${thread.messages.length} messages)`); for (const msg of thread.messages) { console.log(` [${msg.fromAddress}] ${msg.subject}`); diff --git a/examples/typescript/receive-agent-call-webhook.ts b/examples/typescript/receive-agent-call-webhook.ts index 5fd47a6..b0cc39d 100644 --- a/examples/typescript/receive-agent-call-webhook.ts +++ b/examples/typescript/receive-agent-call-webhook.ts @@ -1,28 +1,28 @@ /** - * Create, update, and delete a webhook on a phone number. + * Create, update, and delete a webhook on an agent's phone number. * * Usage: - * INKBOX_API_KEY=ApiKey_... PHONE_NUMBER_ID= npx ts-node receive-agent-call-webhook.ts + * INKBOX_API_KEY=ApiKey_... AGENT_HANDLE=sales-agent npx ts-node receive-agent-call-webhook.ts */ import { Inkbox } from "../../typescript/src/inkbox.js"; const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); -const phoneNumberId = process.env.PHONE_NUMBER_ID!; +const identity = await inkbox.getIdentity(process.env.AGENT_HANDLE!); // Register webhook for agent phone number -const hook = await inkbox.phoneWebhooks.create(phoneNumberId, { +const hook = await inkbox._phoneWebhooks.create(identity.phoneNumber!.id, { url: "https://example.com/webhook", eventTypes: ["incoming_call"], }); console.log(`Registered agent phone webhook ${hook.id} secret=${hook.secret}`); // Update agent phone webhook -const updated = await inkbox.phoneWebhooks.update(phoneNumberId, hook.id, { +const updated = await inkbox._phoneWebhooks.update(identity.phoneNumber!.id, hook.id, { url: "https://example.com/webhook-v2", }); console.log(`Updated URL: ${updated.url}`); // Remove agent phone webhook -await inkbox.phoneWebhooks.delete(phoneNumberId, hook.id); +await inkbox._phoneWebhooks.delete(identity.phoneNumber!.id, hook.id); console.log("Agent phone webhook removed."); diff --git a/examples/typescript/receive-agent-email-webhook.ts b/examples/typescript/receive-agent-email-webhook.ts index 608849c..895f020 100644 --- a/examples/typescript/receive-agent-email-webhook.ts +++ b/examples/typescript/receive-agent-email-webhook.ts @@ -1,29 +1,29 @@ /** - * Register and delete a webhook on a mailbox. + * Register and delete a webhook on an agent's mailbox. * * Usage: - * INKBOX_API_KEY=ApiKey_... MAILBOX_ADDRESS=agent@inkboxmail.com npx ts-node receive-agent-email-webhook.ts + * INKBOX_API_KEY=ApiKey_... AGENT_HANDLE=sales-agent npx ts-node receive-agent-email-webhook.ts */ import { Inkbox } from "../../typescript/src/inkbox.js"; const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); -const mailboxAddress = process.env.MAILBOX_ADDRESS!; +const identity = await inkbox.getIdentity(process.env.AGENT_HANDLE!); // Register webhook for agent mailbox -const hook = await inkbox.mailWebhooks.create(mailboxAddress, { +const hook = await inkbox._mailWebhooks.create(identity.mailbox!.id, { url: "https://example.com/webhook", eventTypes: ["message.received", "message.sent"], }); console.log(`Registered agent mailbox webhook ${hook.id} secret=${hook.secret}`); // List -const all = await inkbox.mailWebhooks.list(mailboxAddress); +const all = await inkbox._mailWebhooks.list(identity.mailbox!.id); console.log(`Active agent mailbox webhooks: ${all.length}`); for (const w of all) { console.log(` ${w.id} url=${w.url} events=${w.eventTypes.join(", ")}`); } // Remove agent mailbox webhook -await inkbox.mailWebhooks.delete(mailboxAddress, hook.id); +await inkbox._mailWebhooks.delete(identity.mailbox!.id, hook.id); console.log("Agent mailbox webhook removed."); diff --git a/examples/typescript/register-agent-identity.ts b/examples/typescript/register-agent-identity.ts index 1ac4d43..c0850b6 100644 --- a/examples/typescript/register-agent-identity.ts +++ b/examples/typescript/register-agent-identity.ts @@ -9,8 +9,8 @@ import { Inkbox } from "../../typescript/src/inkbox.js"; const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); -// Register agent identity — returns an Agent object -const agent = await inkbox.identities.create({ agentHandle: "sales-agent" }); +// Register agent identity — returns an AgentIdentity object +const agent = await inkbox.createIdentity("sales-agent"); console.log(`Registered agent: ${agent.agentHandle} (id=${agent.id})`); // Provision and link a mailbox @@ -22,7 +22,7 @@ const phone = await agent.assignPhoneNumber({ type: "toll_free" }); console.log(`Assigned phone: ${phone.number}`); // List all identities -const all = await inkbox.identities.list(); +const all = await inkbox.listIdentities(); console.log(`\nAll identities (${all.length}):`); for (const id of all) { console.log(` ${id.agentHandle} status=${id.status}`); diff --git a/python/README.md b/python/README.md index a1087c3..98062bd 100644 --- a/python/README.md +++ b/python/README.md @@ -193,9 +193,13 @@ Runnable example scripts are available in the [examples/python](https://github.c |---|---| | `register_agent_identity.py` | Create an identity, assign mailbox + phone number | | `agent_send_email.py` | Send an email and a threaded reply | -| `read_agent_messages.py` | List messages | -| `create_agent_phone_number.py` | Provision and update a number | +| `read_agent_messages.py` | List messages and threads | +| `create_agent_mailbox.py` | Create, update, search, and delete a mailbox | +| `create_agent_phone_number.py` | Provision, update, and release a number | +| `list_agent_phone_numbers.py` | List all phone numbers in the org | | `read_agent_calls.py` | List calls and print transcripts | +| `receive_agent_email_webhook.py` | Register and delete a mailbox webhook | +| `receive_agent_call_webhook.py` | Register, update, and delete a phone webhook | ## License diff --git a/typescript/README.md b/typescript/README.md index 9ee48b9..f76d56e 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -179,9 +179,13 @@ Runnable example scripts are available in the [examples/typescript](https://gith |---|---| | `register-agent-identity.ts` | Create an identity, assign mailbox + phone number | | `agent-send-email.ts` | Send an email and a threaded reply | -| `read-agent-messages.ts` | List messages | -| `create-agent-phone-number.ts` | Provision and update a number | +| `read-agent-messages.ts` | List messages and threads | +| `create-agent-mailbox.ts` | Create, update, search, and delete a mailbox | +| `create-agent-phone-number.ts` | Provision, update, and release a number | +| `list-agent-phone-numbers.ts` | List all phone numbers in the org | | `read-agent-calls.ts` | List calls and print transcripts | +| `receive-agent-email-webhook.ts` | Register and delete a mailbox webhook | +| `receive-agent-call-webhook.ts` | Register, update, and delete a phone webhook | ## License From cc7e8e1287ecded734599e5e5154a7a8de763696 Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:00:54 -0400 Subject: [PATCH 43/56] rm stale resources --- python/inkbox/__init__.py | 8 -- python/inkbox/agent_identity.py | 11 +-- python/inkbox/client.py | 4 - python/inkbox/mail/__init__.py | 4 - python/inkbox/mail/resources/webhooks.py | 60 -------------- python/inkbox/mail/types.py | 37 --------- python/inkbox/phone/__init__.py | 4 - python/inkbox/phone/resources/__init__.py | 2 - python/inkbox/phone/resources/calls.py | 12 +-- python/inkbox/phone/resources/numbers.py | 16 ++-- python/inkbox/phone/resources/webhooks.py | 88 -------------------- python/inkbox/phone/types.py | 57 +++---------- python/tests/conftest.py | 1 - python/tests/sample_data.py | 25 ++---- python/tests/sample_data_mail.py | 14 ---- python/tests/test_calls.py | 17 ++-- python/tests/test_client.py | 2 - python/tests/test_mail_client.py | 2 - python/tests/test_mail_types.py | 19 ----- python/tests/test_mail_webhooks.py | 58 ------------- python/tests/test_numbers.py | 28 +++---- python/tests/test_types.py | 39 ++------- python/tests/test_webhooks.py | 96 ---------------------- typescript/src/agent.ts | 15 ++-- typescript/src/index.ts | 4 - typescript/src/inkbox.ts | 22 ++--- typescript/src/mail/resources/webhooks.ts | 50 ----------- typescript/src/mail/types.ts | 43 ---------- typescript/src/phone/resources/calls.ts | 13 +-- typescript/src/phone/resources/numbers.ts | 16 ++-- typescript/src/phone/resources/webhooks.ts | 81 ------------------ typescript/src/phone/types.ts | 47 ----------- typescript/tests/mail/types.test.ts | 23 ------ typescript/tests/mail/webhooks.test.ts | 62 -------------- typescript/tests/phone/types.test.ts | 24 ------ typescript/tests/phone/webhooks.test.ts | 96 ---------------------- typescript/tests/sampleData.ts | 29 ------- 37 files changed, 83 insertions(+), 1046 deletions(-) delete mode 100644 python/inkbox/mail/resources/webhooks.py delete mode 100644 python/inkbox/phone/resources/webhooks.py delete mode 100644 python/tests/test_mail_webhooks.py delete mode 100644 python/tests/test_webhooks.py delete mode 100644 typescript/src/mail/resources/webhooks.ts delete mode 100644 typescript/src/phone/resources/webhooks.ts delete mode 100644 typescript/tests/mail/webhooks.test.ts delete mode 100644 typescript/tests/phone/webhooks.test.ts diff --git a/python/inkbox/__init__.py b/python/inkbox/__init__.py index 6aefed4..0d7b542 100644 --- a/python/inkbox/__init__.py +++ b/python/inkbox/__init__.py @@ -15,8 +15,6 @@ MessageDetail, Thread, ThreadDetail, - Webhook as MailWebhook, - WebhookCreateResult as MailWebhookCreateResult, ) # Phone types @@ -25,8 +23,6 @@ PhoneCallWithRateLimit, PhoneNumber, PhoneTranscript, - PhoneWebhook, - PhoneWebhookCreateResult, RateLimitInfo, ) @@ -53,15 +49,11 @@ "MessageDetail", "Thread", "ThreadDetail", - "MailWebhook", - "MailWebhookCreateResult", # Phone types "PhoneCall", "PhoneCallWithRateLimit", "PhoneNumber", "PhoneTranscript", - "PhoneWebhook", - "PhoneWebhookCreateResult", "RateLimitInfo", # Identity types "AgentIdentitySummary", diff --git a/python/inkbox/agent_identity.py b/python/inkbox/agent_identity.py index e6d4f32..b819786 100644 --- a/python/inkbox/agent_identity.py +++ b/python/inkbox/agent_identity.py @@ -37,7 +37,7 @@ class AgentIdentity: identity.assign_phone_number(type="toll_free") identity.send_email(to=["user@example.com"], subject="Hi", body_text="Hello") - identity.place_call(to_number="+15555550100", stream_url="wss://my-app.com/ws") + identity.place_call(to_number="+15555550100", client_websocket_url="wss://my-app.com/ws") for msg in identity.messages(): print(msg.subject) @@ -198,24 +198,21 @@ def place_call( self, *, to_number: str, - stream_url: str | None = None, - pipeline_mode: str | None = None, + client_websocket_url: str | None = None, webhook_url: str | None = None, ) -> PhoneCallWithRateLimit: """Place an outbound call from this identity's phone number. Args: to_number: E.164 destination number. - stream_url: WebSocket URL (wss://) for audio bridging. - pipeline_mode: Pipeline mode override for this call. + client_websocket_url: WebSocket URL (wss://) for audio bridging. webhook_url: Custom webhook URL for call lifecycle events. """ self._require_phone() return self._inkbox._calls.place( from_number=self._phone_number.number, # type: ignore[union-attr] to_number=to_number, - stream_url=stream_url, - pipeline_mode=pipeline_mode, + client_websocket_url=client_websocket_url, webhook_url=webhook_url, ) diff --git a/python/inkbox/client.py b/python/inkbox/client.py index de72f93..9d6cc3e 100644 --- a/python/inkbox/client.py +++ b/python/inkbox/client.py @@ -10,12 +10,10 @@ from inkbox.mail.resources.mailboxes import MailboxesResource from inkbox.mail.resources.messages import MessagesResource from inkbox.mail.resources.threads import ThreadsResource -from inkbox.mail.resources.webhooks import WebhooksResource as MailWebhooksResource from inkbox.phone._http import HttpTransport as PhoneHttpTransport from inkbox.phone.resources.calls import CallsResource from inkbox.phone.resources.numbers import PhoneNumbersResource from inkbox.phone.resources.transcripts import TranscriptsResource -from inkbox.phone.resources.webhooks import PhoneWebhooksResource from inkbox.identities._http import HttpTransport as IdsHttpTransport from inkbox.identities.resources.identities import IdentitiesResource from inkbox.agent_identity import AgentIdentity @@ -73,12 +71,10 @@ def __init__( self._mailboxes = MailboxesResource(self._mail_http) self._messages = MessagesResource(self._mail_http) self._threads = ThreadsResource(self._mail_http) - self._mail_webhooks = MailWebhooksResource(self._mail_http) self._calls = CallsResource(self._phone_http) self._numbers = PhoneNumbersResource(self._phone_http) self._transcripts = TranscriptsResource(self._phone_http) - self._phone_webhooks = PhoneWebhooksResource(self._phone_http) self._signing_keys = SigningKeysResource(self._api_http) self._ids_resource = IdentitiesResource(self._ids_http) diff --git a/python/inkbox/mail/__init__.py b/python/inkbox/mail/__init__.py index ae5a76c..da7a44f 100644 --- a/python/inkbox/mail/__init__.py +++ b/python/inkbox/mail/__init__.py @@ -9,8 +9,6 @@ MessageDetail, Thread, ThreadDetail, - Webhook, - WebhookCreateResult, ) from inkbox.signing_keys import SigningKey @@ -23,6 +21,4 @@ "SigningKey", "Thread", "ThreadDetail", - "Webhook", - "WebhookCreateResult", ] diff --git a/python/inkbox/mail/resources/webhooks.py b/python/inkbox/mail/resources/webhooks.py deleted file mode 100644 index 37bd3ce..0000000 --- a/python/inkbox/mail/resources/webhooks.py +++ /dev/null @@ -1,60 +0,0 @@ -""" -inkbox/mail/resources/webhooks.py - -Webhook CRUD for mailboxes. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from inkbox.mail.types import Webhook, WebhookCreateResult - -if TYPE_CHECKING: - from inkbox.mail._http import HttpTransport - - -class WebhooksResource: - def __init__(self, http: HttpTransport) -> None: - self._http = http - - def create( - self, - mailbox_id: str, - *, - url: str, - event_types: list[str], - ) -> WebhookCreateResult: - """Create a webhook subscription for a mailbox. - - Args: - mailbox_id: UUID of the mailbox. - url: HTTPS URL to receive webhook events. - event_types: List of event types to subscribe to. - - Returns: - The created webhook, including the signing secret. - """ - data = self._http.post( - f"/mailboxes/{mailbox_id}/webhooks", - json={"url": url, "event_types": event_types}, - ) - return WebhookCreateResult._from_dict(data) - - def list(self, mailbox_id: str) -> list[Webhook]: - """List all webhooks for a mailbox. - - Args: - mailbox_id: UUID of the mailbox. - """ - data = self._http.get(f"/mailboxes/{mailbox_id}/webhooks") - return [Webhook._from_dict(w) for w in data] - - def delete(self, mailbox_id: str, webhook_id: str) -> None: - """Delete a webhook subscription. - - Args: - mailbox_id: UUID of the mailbox. - webhook_id: UUID of the webhook to delete. - """ - self._http.delete(f"/mailboxes/{mailbox_id}/webhooks/{webhook_id}") diff --git a/python/inkbox/mail/types.py b/python/inkbox/mail/types.py index 0f4a44d..a32f471 100644 --- a/python/inkbox/mail/types.py +++ b/python/inkbox/mail/types.py @@ -155,40 +155,3 @@ def _from_dict(cls, d: dict[str, Any]) -> ThreadDetail: # type: ignore[override ) -@dataclass -class Webhook: - """A webhook subscription for mail events.""" - - id: UUID - mailbox_id: UUID - url: str - event_types: list[str] - status: str - created_at: datetime - - @classmethod - def _from_dict(cls, d: dict[str, Any]) -> Webhook: - return cls( - id=UUID(d["id"]), - mailbox_id=UUID(d["mailbox_id"]), - url=d["url"], - event_types=d["event_types"], - status=d["status"], - created_at=datetime.fromisoformat(d["created_at"]), - ) - - -@dataclass -class WebhookCreateResult(Webhook): - """Result of creating a mail webhook, includes the signing secret.""" - - secret: str = "" - - @classmethod - def _from_dict(cls, d: dict[str, Any]) -> WebhookCreateResult: # type: ignore[override] - base = Webhook._from_dict(d) - return cls( - **base.__dict__, - secret=d["secret"], - ) - diff --git a/python/inkbox/phone/__init__.py b/python/inkbox/phone/__init__.py index 7734c86..bd757ec 100644 --- a/python/inkbox/phone/__init__.py +++ b/python/inkbox/phone/__init__.py @@ -8,8 +8,6 @@ PhoneCallWithRateLimit, PhoneNumber, PhoneTranscript, - PhoneWebhook, - PhoneWebhookCreateResult, RateLimitInfo, ) from inkbox.signing_keys import SigningKey @@ -21,8 +19,6 @@ "PhoneCallWithRateLimit", "PhoneNumber", "PhoneTranscript", - "PhoneWebhook", - "PhoneWebhookCreateResult", "RateLimitInfo", "SigningKey", ] diff --git a/python/inkbox/phone/resources/__init__.py b/python/inkbox/phone/resources/__init__.py index bc4f6c5..99c4857 100644 --- a/python/inkbox/phone/resources/__init__.py +++ b/python/inkbox/phone/resources/__init__.py @@ -1,11 +1,9 @@ from inkbox.phone.resources.numbers import PhoneNumbersResource from inkbox.phone.resources.calls import CallsResource from inkbox.phone.resources.transcripts import TranscriptsResource -from inkbox.phone.resources.webhooks import PhoneWebhooksResource __all__ = [ "PhoneNumbersResource", "CallsResource", "TranscriptsResource", - "PhoneWebhooksResource", ] diff --git a/python/inkbox/phone/resources/calls.py b/python/inkbox/phone/resources/calls.py index 8a085a0..374f1b7 100644 --- a/python/inkbox/phone/resources/calls.py +++ b/python/inkbox/phone/resources/calls.py @@ -58,8 +58,7 @@ def place( *, from_number: str, to_number: str, - stream_url: str | None = None, - pipeline_mode: str | None = None, + client_websocket_url: str | None = None, webhook_url: str | None = None, ) -> PhoneCallWithRateLimit: """Place an outbound call. @@ -67,8 +66,7 @@ def place( Args: from_number: E.164 number to call from. Must belong to your org and be active. to_number: E.164 number to call. - stream_url: WebSocket URL (wss://) for audio bridging. - pipeline_mode: Pipeline mode override for this call. + client_websocket_url: WebSocket URL (wss://) for audio bridging. webhook_url: Custom webhook URL for call lifecycle events. Returns: @@ -78,10 +76,8 @@ def place( "from_number": from_number, "to_number": to_number, } - if stream_url is not None: - body["stream_url"] = stream_url - if pipeline_mode is not None: - body["pipeline_mode"] = pipeline_mode + if client_websocket_url is not None: + body["client_websocket_url"] = client_websocket_url if webhook_url is not None: body["webhook_url"] = webhook_url data = self._http.post("/place-call", json=body) diff --git a/python/inkbox/phone/resources/numbers.py b/python/inkbox/phone/resources/numbers.py index 8ed72f4..71ae90d 100644 --- a/python/inkbox/phone/resources/numbers.py +++ b/python/inkbox/phone/resources/numbers.py @@ -37,8 +37,8 @@ def update( phone_number_id: UUID | str, *, incoming_call_action: str | None = _UNSET, # type: ignore[assignment] - default_stream_url: str | None = _UNSET, # type: ignore[assignment] - default_pipeline_mode: str | None = _UNSET, # type: ignore[assignment] + client_websocket_url: str | None = _UNSET, # type: ignore[assignment] + incoming_call_webhook_url: str | None = _UNSET, # type: ignore[assignment] ) -> PhoneNumber: """Update phone number settings. @@ -48,16 +48,16 @@ def update( Args: phone_number_id: UUID of the phone number. incoming_call_action: ``"auto_accept"``, ``"auto_reject"``, or ``"webhook"``. - default_stream_url: WebSocket URL (wss://) for audio bridging. - default_pipeline_mode: Default pipeline mode for incoming calls. + client_websocket_url: WebSocket URL (wss://) for audio bridging. + incoming_call_webhook_url: Webhook URL called for incoming calls when action is ``"webhook"``. """ body: dict[str, Any] = {} if incoming_call_action is not _UNSET: body["incoming_call_action"] = incoming_call_action - if default_stream_url is not _UNSET: - body["default_stream_url"] = default_stream_url - if default_pipeline_mode is not _UNSET: - body["default_pipeline_mode"] = default_pipeline_mode + if client_websocket_url is not _UNSET: + body["client_websocket_url"] = client_websocket_url + if incoming_call_webhook_url is not _UNSET: + body["incoming_call_webhook_url"] = incoming_call_webhook_url data = self._http.patch(f"{_BASE}/{phone_number_id}", json=body) return PhoneNumber._from_dict(data) diff --git a/python/inkbox/phone/resources/webhooks.py b/python/inkbox/phone/resources/webhooks.py deleted file mode 100644 index fa019ab..0000000 --- a/python/inkbox/phone/resources/webhooks.py +++ /dev/null @@ -1,88 +0,0 @@ -""" -inkbox/phone/resources/webhooks.py - -Webhook CRUD for phone numbers. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any -from uuid import UUID - -from inkbox.phone.types import PhoneWebhook, PhoneWebhookCreateResult - -if TYPE_CHECKING: - from inkbox.phone._http import HttpTransport - -_UNSET = object() - - -class PhoneWebhooksResource: - def __init__(self, http: HttpTransport) -> None: - self._http = http - - def create( - self, - phone_number_id: UUID | str, - *, - url: str, - event_types: list[str], - ) -> PhoneWebhookCreateResult: - """Create a webhook for a phone number. - - Args: - phone_number_id: UUID of the phone number. - url: HTTPS URL to receive webhook events. - event_types: List of event types to subscribe to. - - Returns: - The created webhook including the signing secret. - """ - data = self._http.post( - f"/numbers/{phone_number_id}/webhooks", - json={"url": url, "event_types": event_types}, - ) - return PhoneWebhookCreateResult._from_dict(data) - - def list(self, phone_number_id: UUID | str) -> list[PhoneWebhook]: - """List webhooks for a phone number.""" - data = self._http.get(f"/numbers/{phone_number_id}/webhooks") - return [PhoneWebhook._from_dict(w) for w in data] - - def update( - self, - phone_number_id: UUID | str, - webhook_id: UUID | str, - *, - url: str | None = _UNSET, # type: ignore[assignment] - event_types: list[str] | None = _UNSET, # type: ignore[assignment] - ) -> PhoneWebhook: - """Update a webhook. - - Pass only the fields you want to change; omitted fields are left as-is. - - Args: - phone_number_id: UUID of the phone number. - webhook_id: UUID of the webhook. - url: New HTTPS URL for the webhook. - event_types: New list of event types. - """ - body: dict[str, Any] = {} - if url is not _UNSET: - body["url"] = url - if event_types is not _UNSET: - body["event_types"] = event_types - data = self._http.patch( - f"/numbers/{phone_number_id}/webhooks/{webhook_id}", - json=body, - ) - return PhoneWebhook._from_dict(data) - - def delete(self, phone_number_id: UUID | str, webhook_id: UUID | str) -> None: - """Delete a webhook. - - Args: - phone_number_id: UUID of the phone number. - webhook_id: UUID of the webhook to delete. - """ - self._http.delete(f"/numbers/{phone_number_id}/webhooks/{webhook_id}") diff --git a/python/inkbox/phone/types.py b/python/inkbox/phone/types.py index c6fea23..8cda2fd 100644 --- a/python/inkbox/phone/types.py +++ b/python/inkbox/phone/types.py @@ -25,8 +25,8 @@ class PhoneNumber: type: str status: str incoming_call_action: str - default_stream_url: str | None - default_pipeline_mode: str + client_websocket_url: str | None + incoming_call_webhook_url: str | None created_at: datetime updated_at: datetime @@ -38,8 +38,8 @@ def _from_dict(cls, d: dict[str, Any]) -> PhoneNumber: type=d["type"], status=d["status"], incoming_call_action=d["incoming_call_action"], - default_stream_url=d.get("default_stream_url"), - default_pipeline_mode=d.get("default_pipeline_mode", "client_llm_only"), + client_websocket_url=d.get("client_websocket_url"), + incoming_call_webhook_url=d.get("incoming_call_webhook_url"), created_at=datetime.fromisoformat(d["created_at"]), updated_at=datetime.fromisoformat(d["updated_at"]), ) @@ -54,8 +54,9 @@ class PhoneCall: remote_phone_number: str direction: str status: str - pipeline_mode: str | None - stream_url: str | None + client_websocket_url: str | None + use_inkbox_tts: bool | None + use_inkbox_stt: bool | None hangup_reason: str | None started_at: datetime | None ended_at: datetime | None @@ -70,8 +71,9 @@ def _from_dict(cls, d: dict[str, Any]) -> PhoneCall: remote_phone_number=d["remote_phone_number"], direction=d["direction"], status=d["status"], - pipeline_mode=d.get("pipeline_mode"), - stream_url=d.get("stream_url"), + client_websocket_url=d.get("client_websocket_url"), + use_inkbox_tts=d.get("use_inkbox_tts"), + use_inkbox_stt=d.get("use_inkbox_stt"), hangup_reason=d.get("hangup_reason"), started_at=_dt(d.get("started_at")), ended_at=_dt(d.get("ended_at")), @@ -146,42 +148,3 @@ def _from_dict(cls, d: dict[str, Any]) -> PhoneTranscript: ) -@dataclass -class PhoneWebhook: - """A webhook subscription for phone events.""" - - id: UUID - source_id: UUID - source_type: str - url: str - event_types: list[str] - status: str - created_at: datetime - - @classmethod - def _from_dict(cls, d: dict[str, Any]) -> PhoneWebhook: - return cls( - id=UUID(d["id"]), - source_id=UUID(d["source_id"]), - source_type=d["source_type"], - url=d["url"], - event_types=d["event_types"], - status=d["status"], - created_at=datetime.fromisoformat(d["created_at"]), - ) - - -@dataclass -class PhoneWebhookCreateResult(PhoneWebhook): - """Result of creating a phone webhook, includes the signing secret.""" - - secret: str = "" - - @classmethod - def _from_dict(cls, d: dict[str, Any]) -> PhoneWebhookCreateResult: # type: ignore[override] - base = PhoneWebhook._from_dict(d) - return cls( - **base.__dict__, - secret=d["secret"], - ) - diff --git a/python/tests/conftest.py b/python/tests/conftest.py index f59fe4a..47d35ec 100644 --- a/python/tests/conftest.py +++ b/python/tests/conftest.py @@ -33,6 +33,5 @@ def client(transport: FakeHttpTransport) -> Inkbox: c._numbers._http = transport c._calls._http = transport c._transcripts._http = transport - c._phone_webhooks._http = transport c._signing_keys._http = transport return c diff --git a/python/tests/sample_data.py b/python/tests/sample_data.py index 444c6ab..79da17d 100644 --- a/python/tests/sample_data.py +++ b/python/tests/sample_data.py @@ -6,8 +6,8 @@ "type": "toll_free", "status": "active", "incoming_call_action": "auto_reject", - "default_stream_url": None, - "default_pipeline_mode": "client_llm_only", + "client_websocket_url": None, + "incoming_call_webhook_url": None, "created_at": "2026-03-09T00:00:00Z", "updated_at": "2026-03-09T00:00:00Z", } @@ -18,8 +18,10 @@ "remote_phone_number": "+15167251294", "direction": "outbound", "status": "completed", - "pipeline_mode": "client_llm_only", - "stream_url": "wss://agent.example.com/ws", + "client_websocket_url": "wss://agent.example.com/ws", + "use_inkbox_tts": None, + "use_inkbox_stt": None, + "hangup_reason": None, "started_at": "2026-03-09T00:01:00Z", "ended_at": "2026-03-09T00:05:00Z", "created_at": "2026-03-09T00:00:00Z", @@ -35,18 +37,3 @@ "text": "Hello, how can I help you?", "created_at": "2026-03-09T00:01:01Z", } - -PHONE_WEBHOOK_DICT = { - "id": "dddd4444-0000-0000-0000-000000000001", - "source_id": "aaaa1111-0000-0000-0000-000000000001", - "source_type": "phone_number", - "url": "https://example.com/webhooks/phone", - "event_types": ["incoming_call"], - "status": "active", - "created_at": "2026-03-09T00:00:00Z", -} - -PHONE_WEBHOOK_CREATE_DICT = { - **PHONE_WEBHOOK_DICT, - "secret": "test-hmac-secret-abc123", -} diff --git a/python/tests/sample_data_mail.py b/python/tests/sample_data_mail.py index 0b1be7f..c4413ff 100644 --- a/python/tests/sample_data_mail.py +++ b/python/tests/sample_data_mail.py @@ -54,20 +54,6 @@ "messages": [MESSAGE_DICT], } -WEBHOOK_DICT = { - "id": "dddd4444-0000-0000-0000-000000000001", - "mailbox_id": "aaaa1111-0000-0000-0000-000000000001", - "url": "https://example.com/hooks/mail", - "event_types": ["message.received"], - "status": "active", - "created_at": "2026-03-09T00:00:00Z", -} - -WEBHOOK_CREATE_DICT = { - **WEBHOOK_DICT, - "secret": "test-hmac-secret-mail-abc123", -} - CURSOR_PAGE_MESSAGES = { "items": [MESSAGE_DICT], "next_cursor": None, diff --git a/python/tests/test_calls.py b/python/tests/test_calls.py index d392ad3..6afb5fb 100644 --- a/python/tests/test_calls.py +++ b/python/tests/test_calls.py @@ -53,8 +53,7 @@ def test_returns_call(self, client, transport): transport.get.assert_called_once_with(f"/numbers/{NUM_ID}/calls/{CALL_ID}") assert call.id == UUID(CALL_ID) assert call.status == "completed" - assert call.pipeline_mode == "client_llm_only" - assert call.stream_url == "wss://agent.example.com/ws" + assert call.client_websocket_url == "wss://agent.example.com/ws" assert call.started_at is not None assert call.ended_at is not None @@ -71,7 +70,7 @@ def test_place_outbound_call(self, client, transport): call = client._calls.place( from_number="+18335794607", to_number="+15167251294", - stream_url="wss://agent.example.com/ws", + client_websocket_url="wss://agent.example.com/ws", ) transport.post.assert_called_once_with( @@ -79,24 +78,23 @@ def test_place_outbound_call(self, client, transport): json={ "from_number": "+18335794607", "to_number": "+15167251294", - "stream_url": "wss://agent.example.com/ws", + "client_websocket_url": "wss://agent.example.com/ws", }, ) assert call.status == "ringing" - def test_place_with_pipeline_mode_and_webhook(self, client, transport): + def test_place_with_websocket_and_webhook(self, client, transport): transport.post.return_value = PHONE_CALL_DICT client._calls.place( from_number="+18335794607", to_number="+15167251294", - stream_url="wss://agent.example.com/ws", - pipeline_mode="client_llm_tts_stt", + client_websocket_url="wss://agent.example.com/ws", webhook_url="https://example.com/hook", ) _, kwargs = transport.post.call_args - assert kwargs["json"]["pipeline_mode"] == "client_llm_tts_stt" + assert kwargs["json"]["client_websocket_url"] == "wss://agent.example.com/ws" assert kwargs["json"]["webhook_url"] == "https://example.com/hook" def test_optional_fields_omitted_when_none(self, client, transport): @@ -108,6 +106,5 @@ def test_optional_fields_omitted_when_none(self, client, transport): ) _, kwargs = transport.post.call_args - assert "stream_url" not in kwargs["json"] - assert "pipeline_mode" not in kwargs["json"] + assert "client_websocket_url" not in kwargs["json"] assert "webhook_url" not in kwargs["json"] diff --git a/python/tests/test_client.py b/python/tests/test_client.py index fdd3802..91a12a1 100644 --- a/python/tests/test_client.py +++ b/python/tests/test_client.py @@ -4,7 +4,6 @@ from inkbox.phone.resources.numbers import PhoneNumbersResource from inkbox.phone.resources.calls import CallsResource from inkbox.phone.resources.transcripts import TranscriptsResource -from inkbox.phone.resources.webhooks import PhoneWebhooksResource from inkbox.signing_keys import SigningKeysResource @@ -15,7 +14,6 @@ def test_creates_phone_resource_instances(self): assert isinstance(client._numbers, PhoneNumbersResource) assert isinstance(client._calls, CallsResource) assert isinstance(client._transcripts, TranscriptsResource) - assert isinstance(client._phone_webhooks, PhoneWebhooksResource) assert isinstance(client._signing_keys, SigningKeysResource) client.close() diff --git a/python/tests/test_mail_client.py b/python/tests/test_mail_client.py index 0b5ab47..0a00d92 100644 --- a/python/tests/test_mail_client.py +++ b/python/tests/test_mail_client.py @@ -4,7 +4,6 @@ from inkbox.mail.resources.mailboxes import MailboxesResource from inkbox.mail.resources.messages import MessagesResource from inkbox.mail.resources.threads import ThreadsResource -from inkbox.mail.resources.webhooks import WebhooksResource from inkbox.signing_keys import SigningKeysResource @@ -15,7 +14,6 @@ def test_creates_mail_resource_instances(self): assert isinstance(client._mailboxes, MailboxesResource) assert isinstance(client._messages, MessagesResource) assert isinstance(client._threads, ThreadsResource) - assert isinstance(client._mail_webhooks, WebhooksResource) assert isinstance(client._signing_keys, SigningKeysResource) client.close() diff --git a/python/tests/test_mail_types.py b/python/tests/test_mail_types.py index 2498472..4a66c29 100644 --- a/python/tests/test_mail_types.py +++ b/python/tests/test_mail_types.py @@ -9,8 +9,6 @@ MESSAGE_DETAIL_DICT, THREAD_DICT, THREAD_DETAIL_DICT, - WEBHOOK_DICT, - WEBHOOK_CREATE_DICT, ) from inkbox.mail.types import ( Mailbox, @@ -18,8 +16,6 @@ MessageDetail, Thread, ThreadDetail, - Webhook, - WebhookCreateResult, ) @@ -104,18 +100,3 @@ def test_empty_messages(self): assert t.messages == [] -class TestWebhookParsing: - def test_from_dict(self): - w = Webhook._from_dict(WEBHOOK_DICT) - - assert isinstance(w.id, UUID) - assert isinstance(w.mailbox_id, UUID) - assert w.url == "https://example.com/hooks/mail" - assert w.event_types == ["message.received"] - assert w.status == "active" - - def test_create_result_includes_secret(self): - w = WebhookCreateResult._from_dict(WEBHOOK_CREATE_DICT) - - assert w.secret == "test-hmac-secret-mail-abc123" - assert w.url == "https://example.com/hooks/mail" diff --git a/python/tests/test_mail_webhooks.py b/python/tests/test_mail_webhooks.py deleted file mode 100644 index 21594b1..0000000 --- a/python/tests/test_mail_webhooks.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Tests for mail WebhooksResource.""" - -from unittest.mock import MagicMock -from uuid import UUID - -from sample_data_mail import WEBHOOK_DICT, WEBHOOK_CREATE_DICT -from inkbox.mail.resources.webhooks import WebhooksResource - - -MBOX = "aaaa1111-0000-0000-0000-000000000001" -WH_ID = "dddd4444-0000-0000-0000-000000000001" - - -def _resource(): - http = MagicMock() - return WebhooksResource(http), http - - -class TestWebhooksCreate: - def test_creates_webhook_with_secret(self): - res, http = _resource() - http.post.return_value = WEBHOOK_CREATE_DICT - - hook = res.create(MBOX, url="https://example.com/hooks/mail", event_types=["message.received"]) - - http.post.assert_called_once_with( - f"/mailboxes/{MBOX}/webhooks", - json={"url": "https://example.com/hooks/mail", "event_types": ["message.received"]}, - ) - assert hook.secret == "test-hmac-secret-mail-abc123" - assert hook.url == "https://example.com/hooks/mail" - - -class TestWebhooksList: - def test_returns_webhooks(self): - res, http = _resource() - http.get.return_value = [WEBHOOK_DICT] - - webhooks = res.list(MBOX) - - http.get.assert_called_once_with(f"/mailboxes/{MBOX}/webhooks") - assert len(webhooks) == 1 - assert webhooks[0].id == UUID(WH_ID) - - def test_empty_list(self): - res, http = _resource() - http.get.return_value = [] - - assert res.list(MBOX) == [] - - -class TestWebhooksDelete: - def test_deletes_webhook(self): - res, http = _resource() - - res.delete(MBOX, WH_ID) - - http.delete.assert_called_once_with(f"/mailboxes/{MBOX}/webhooks/{WH_ID}") diff --git a/python/tests/test_numbers.py b/python/tests/test_numbers.py index 9cd55a5..1fa3b0d 100644 --- a/python/tests/test_numbers.py +++ b/python/tests/test_numbers.py @@ -16,7 +16,7 @@ def test_returns_list_of_phone_numbers(self, client, transport): assert numbers[0].number == "+18335794607" assert numbers[0].type == "toll_free" assert numbers[0].status == "active" - assert numbers[0].default_pipeline_mode == "client_llm_only" + assert numbers[0].client_websocket_url is None def test_empty_list(self, client, transport): transport.get.return_value = [] @@ -56,30 +56,30 @@ def test_update_incoming_call_action(self, client, transport): def test_update_multiple_fields(self, client, transport): updated = { **PHONE_NUMBER_DICT, - "incoming_call_action": "auto_accept", - "default_stream_url": "wss://agent.example.com/ws", - "default_pipeline_mode": "client_llm_tts_stt", + "incoming_call_action": "webhook", + "client_websocket_url": "wss://agent.example.com/ws", + "incoming_call_webhook_url": "https://example.com/hook", } transport.patch.return_value = updated uid = "aaaa1111-0000-0000-0000-000000000001" result = client._numbers.update( uid, - incoming_call_action="auto_accept", - default_stream_url="wss://agent.example.com/ws", - default_pipeline_mode="client_llm_tts_stt", + incoming_call_action="webhook", + client_websocket_url="wss://agent.example.com/ws", + incoming_call_webhook_url="https://example.com/hook", ) transport.patch.assert_called_once_with( f"/numbers/{uid}", json={ - "incoming_call_action": "auto_accept", - "default_stream_url": "wss://agent.example.com/ws", - "default_pipeline_mode": "client_llm_tts_stt", + "incoming_call_action": "webhook", + "client_websocket_url": "wss://agent.example.com/ws", + "incoming_call_webhook_url": "https://example.com/hook", }, ) - assert result.default_stream_url == "wss://agent.example.com/ws" - assert result.default_pipeline_mode == "client_llm_tts_stt" + assert result.client_websocket_url == "wss://agent.example.com/ws" + assert result.incoming_call_webhook_url == "https://example.com/hook" def test_omitted_fields_not_sent(self, client, transport): transport.patch.return_value = PHONE_NUMBER_DICT @@ -88,8 +88,8 @@ def test_omitted_fields_not_sent(self, client, transport): client._numbers.update(uid, incoming_call_action="auto_reject") _, kwargs = transport.patch.call_args - assert "default_stream_url" not in kwargs["json"] - assert "default_pipeline_mode" not in kwargs["json"] + assert "client_websocket_url" not in kwargs["json"] + assert "incoming_call_webhook_url" not in kwargs["json"] class TestNumbersProvision: diff --git a/python/tests/test_types.py b/python/tests/test_types.py index c1453a5..699a016 100644 --- a/python/tests/test_types.py +++ b/python/tests/test_types.py @@ -7,15 +7,11 @@ PHONE_NUMBER_DICT, PHONE_CALL_DICT, PHONE_TRANSCRIPT_DICT, - PHONE_WEBHOOK_DICT, - PHONE_WEBHOOK_CREATE_DICT, ) from inkbox.phone.types import ( PhoneNumber, PhoneCall, PhoneTranscript, - PhoneWebhook, - PhoneWebhookCreateResult, ) @@ -28,19 +24,11 @@ def test_from_dict(self): assert n.type == "toll_free" assert n.status == "active" assert n.incoming_call_action == "auto_reject" - assert n.default_stream_url is None - assert n.default_pipeline_mode == "client_llm_only" + assert n.client_websocket_url is None + assert n.incoming_call_webhook_url is None assert isinstance(n.created_at, datetime) assert isinstance(n.updated_at, datetime) - def test_default_pipeline_mode_when_missing(self): - d = {**PHONE_NUMBER_DICT} - del d["default_pipeline_mode"] - - n = PhoneNumber._from_dict(d) - - assert n.default_pipeline_mode == "client_llm_only" - class TestPhoneCallParsing: def test_from_dict(self): @@ -51,8 +39,9 @@ def test_from_dict(self): assert c.remote_phone_number == "+15167251294" assert c.direction == "outbound" assert c.status == "completed" - assert c.pipeline_mode == "client_llm_only" - assert c.stream_url == "wss://agent.example.com/ws" + assert c.client_websocket_url == "wss://agent.example.com/ws" + assert c.use_inkbox_tts is None + assert c.use_inkbox_stt is None assert isinstance(c.started_at, datetime) assert isinstance(c.ended_at, datetime) @@ -76,21 +65,3 @@ def test_from_dict(self): assert t.party == "local" assert t.text == "Hello, how can I help you?" assert isinstance(t.created_at, datetime) - - -class TestPhoneWebhookParsing: - def test_from_dict(self): - w = PhoneWebhook._from_dict(PHONE_WEBHOOK_DICT) - - assert isinstance(w.id, UUID) - assert isinstance(w.source_id, UUID) - assert w.source_type == "phone_number" - assert w.url == "https://example.com/webhooks/phone" - assert w.event_types == ["incoming_call"] - assert w.status == "active" - - def test_create_result_includes_secret(self): - w = PhoneWebhookCreateResult._from_dict(PHONE_WEBHOOK_CREATE_DICT) - - assert w.secret == "test-hmac-secret-abc123" - assert w.url == "https://example.com/webhooks/phone" diff --git a/python/tests/test_webhooks.py b/python/tests/test_webhooks.py deleted file mode 100644 index 8ebe33c..0000000 --- a/python/tests/test_webhooks.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Tests for PhoneWebhooksResource.""" - -from uuid import UUID - -from sample_data import PHONE_WEBHOOK_DICT, PHONE_WEBHOOK_CREATE_DICT - - -NUM_ID = "aaaa1111-0000-0000-0000-000000000001" -WH_ID = "dddd4444-0000-0000-0000-000000000001" - - -class TestWebhooksCreate: - def test_creates_webhook_with_secret(self, client, transport): - transport.post.return_value = PHONE_WEBHOOK_CREATE_DICT - - hook = client._phone_webhooks.create( - NUM_ID, - url="https://example.com/webhooks/phone", - event_types=["incoming_call"], - ) - - transport.post.assert_called_once_with( - f"/numbers/{NUM_ID}/webhooks", - json={ - "url": "https://example.com/webhooks/phone", - "event_types": ["incoming_call"], - }, - ) - assert hook.secret == "test-hmac-secret-abc123" - assert hook.url == "https://example.com/webhooks/phone" - assert hook.source_type == "phone_number" - assert hook.event_types == ["incoming_call"] - - -class TestWebhooksList: - def test_returns_webhooks(self, client, transport): - transport.get.return_value = [PHONE_WEBHOOK_DICT] - - webhooks = client._phone_webhooks.list(NUM_ID) - - transport.get.assert_called_once_with(f"/numbers/{NUM_ID}/webhooks") - assert len(webhooks) == 1 - assert webhooks[0].id == UUID(WH_ID) - assert webhooks[0].status == "active" - - def test_empty_list(self, client, transport): - transport.get.return_value = [] - - webhooks = client._phone_webhooks.list(NUM_ID) - - assert webhooks == [] - - -class TestWebhooksUpdate: - def test_update_url(self, client, transport): - updated = {**PHONE_WEBHOOK_DICT, "url": "https://new.example.com/hook"} - transport.patch.return_value = updated - - result = client._phone_webhooks.update( - NUM_ID, WH_ID, url="https://new.example.com/hook" - ) - - transport.patch.assert_called_once_with( - f"/numbers/{NUM_ID}/webhooks/{WH_ID}", - json={"url": "https://new.example.com/hook"}, - ) - assert result.url == "https://new.example.com/hook" - - def test_update_event_types(self, client, transport): - updated = {**PHONE_WEBHOOK_DICT, "event_types": ["incoming_call", "message.received"]} - transport.patch.return_value = updated - - result = client._phone_webhooks.update( - NUM_ID, WH_ID, event_types=["incoming_call", "message.received"] - ) - - _, kwargs = transport.patch.call_args - assert kwargs["json"] == {"event_types": ["incoming_call", "message.received"]} - assert result.event_types == ["incoming_call", "message.received"] - - def test_omitted_fields_not_sent(self, client, transport): - transport.patch.return_value = PHONE_WEBHOOK_DICT - - client._phone_webhooks.update(NUM_ID, WH_ID, url="https://example.com/hook") - - _, kwargs = transport.patch.call_args - assert "event_types" not in kwargs["json"] - - -class TestWebhooksDelete: - def test_deletes_webhook(self, client, transport): - client._phone_webhooks.delete(NUM_ID, WH_ID) - - transport.delete.assert_called_once_with( - f"/numbers/{NUM_ID}/webhooks/{WH_ID}" - ) diff --git a/typescript/src/agent.ts b/typescript/src/agent.ts index 6318c58..a93a736 100644 --- a/typescript/src/agent.ts +++ b/typescript/src/agent.ts @@ -156,23 +156,20 @@ export class AgentIdentity { * Place an outbound call from this identity's phone number. * * @param options.toNumber - E.164 destination number. - * @param options.streamUrl - WebSocket URL (wss://) for audio bridging. - * @param options.pipelineMode - Pipeline mode override for this call. + * @param options.clientWebsocketUrl - WebSocket URL (wss://) for audio bridging. * @param options.webhookUrl - Custom webhook URL for call lifecycle events. */ async placeCall(options: { toNumber: string; - streamUrl?: string; - pipelineMode?: string; + clientWebsocketUrl?: string; webhookUrl?: string; }): Promise { this._requirePhone(); return this._inkbox._calls.place({ - fromNumber: this._phoneNumber!.number, - toNumber: options.toNumber, - streamUrl: options.streamUrl, - pipelineMode: options.pipelineMode, - webhookUrl: options.webhookUrl, + fromNumber: this._phoneNumber!.number, + toNumber: options.toNumber, + clientWebsocketUrl: options.clientWebsocketUrl, + webhookUrl: options.webhookUrl, }); } diff --git a/typescript/src/index.ts b/typescript/src/index.ts index 93ab27f..e4542e2 100644 --- a/typescript/src/index.ts +++ b/typescript/src/index.ts @@ -10,8 +10,6 @@ export type { MessageDetail, Thread, ThreadDetail, - Webhook as MailWebhook, - WebhookCreateResult as MailWebhookCreateResult, } from "./mail/types.js"; export type { PhoneNumber, @@ -19,8 +17,6 @@ export type { PhoneCallWithRateLimit, RateLimitInfo, PhoneTranscript, - PhoneWebhook, - PhoneWebhookCreateResult, } from "./phone/types.js"; export type { AgentIdentitySummary, diff --git a/typescript/src/inkbox.ts b/typescript/src/inkbox.ts index 3602e41..8898d8e 100644 --- a/typescript/src/inkbox.ts +++ b/typescript/src/inkbox.ts @@ -8,13 +8,11 @@ import { HttpTransport } from "./_http.js"; import { MailboxesResource } from "./mail/resources/mailboxes.js"; import { MessagesResource } from "./mail/resources/messages.js"; import { ThreadsResource } from "./mail/resources/threads.js"; -import { WebhooksResource } from "./mail/resources/webhooks.js"; import { SigningKeysResource } from "./signing_keys.js"; import type { SigningKey } from "./signing_keys.js"; import { PhoneNumbersResource } from "./phone/resources/numbers.js"; import { CallsResource } from "./phone/resources/calls.js"; import { TranscriptsResource } from "./phone/resources/transcripts.js"; -import { PhoneWebhooksResource } from "./phone/resources/webhooks.js"; import { IdentitiesResource } from "./identities/resources/identities.js"; import { AgentIdentity } from "./agent.js"; import type { AgentIdentitySummary } from "./identities/types.js"; @@ -62,8 +60,6 @@ export class Inkbox { /** @internal */ readonly _threads: ThreadsResource; /** @internal */ - readonly _mailWebhooks: WebhooksResource; - /** @internal */ readonly _signingKeys: SigningKeysResource; /** @internal — used by AgentIdentity */ @@ -72,8 +68,6 @@ export class Inkbox { readonly _calls: CallsResource; /** @internal */ readonly _transcripts: TranscriptsResource; - /** @internal */ - readonly _phoneWebhooks: PhoneWebhooksResource; /** @internal — used by AgentIdentity to link channels */ readonly _idsResource: IdentitiesResource; @@ -87,16 +81,14 @@ export class Inkbox { const idsHttp = new HttpTransport(options.apiKey, `${apiRoot}/identities`, ms); const apiHttp = new HttpTransport(options.apiKey, apiRoot, ms); - this._mailboxes = new MailboxesResource(mailHttp); - this._messages = new MessagesResource(mailHttp); - this._threads = new ThreadsResource(mailHttp); - this._mailWebhooks = new WebhooksResource(mailHttp); - this._signingKeys = new SigningKeysResource(apiHttp); + this._mailboxes = new MailboxesResource(mailHttp); + this._messages = new MessagesResource(mailHttp); + this._threads = new ThreadsResource(mailHttp); + this._signingKeys = new SigningKeysResource(apiHttp); - this._numbers = new PhoneNumbersResource(phoneHttp); - this._calls = new CallsResource(phoneHttp); - this._transcripts = new TranscriptsResource(phoneHttp); - this._phoneWebhooks = new PhoneWebhooksResource(phoneHttp); + this._numbers = new PhoneNumbersResource(phoneHttp); + this._calls = new CallsResource(phoneHttp); + this._transcripts = new TranscriptsResource(phoneHttp); this._idsResource = new IdentitiesResource(idsHttp); } diff --git a/typescript/src/mail/resources/webhooks.ts b/typescript/src/mail/resources/webhooks.ts deleted file mode 100644 index f4805e1..0000000 --- a/typescript/src/mail/resources/webhooks.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * inkbox-mail/resources/webhooks.ts - * - * Webhook CRUD for mailboxes. - */ - -import { HttpTransport } from "../../_http.js"; -import { - Webhook, - WebhookCreateResult, - RawWebhook, - RawWebhookCreateResult, - parseWebhook, - parseWebhookCreateResult, -} from "../types.js"; - -export class WebhooksResource { - constructor(private readonly http: HttpTransport) {} - - /** - * Create a webhook subscription for a mailbox. - * - * @param mailboxId - Email address or UUID of the mailbox. - * @param options.url - HTTPS URL to receive webhook events. - * @param options.eventTypes - List of event types to subscribe to. - */ - async create( - mailboxId: string, - options: { url: string; eventTypes: string[] }, - ): Promise { - const data = await this.http.post( - `/mailboxes/${mailboxId}/webhooks`, - { url: options.url, event_types: options.eventTypes }, - ); - return parseWebhookCreateResult(data); - } - - /** List all webhooks for a mailbox. */ - async list(mailboxId: string): Promise { - const data = await this.http.get( - `/mailboxes/${mailboxId}/webhooks`, - ); - return data.map(parseWebhook); - } - - /** Delete a webhook subscription. */ - async delete(mailboxId: string, webhookId: string): Promise { - await this.http.delete(`/mailboxes/${mailboxId}/webhooks/${webhookId}`); - } -} diff --git a/typescript/src/mail/types.ts b/typescript/src/mail/types.ts index 7accd6c..3d36c09 100644 --- a/typescript/src/mail/types.ts +++ b/typescript/src/mail/types.ts @@ -62,19 +62,6 @@ export interface ThreadDetail extends Thread { messages: Message[]; } -export interface Webhook { - id: string; - mailboxId: string; - url: string; - eventTypes: string[]; - status: string; - createdAt: Date; -} - -export interface WebhookCreateResult extends Webhook { - secret: string; -} - // ---- internal raw API shapes (snake_case from JSON) ---- export interface RawMailbox { @@ -125,19 +112,6 @@ export interface RawThread { messages?: RawMessage[]; } -export interface RawWebhook { - id: string; - mailbox_id: string; - url: string; - event_types: string[]; - status: string; - created_at: string; -} - -export interface RawWebhookCreateResult extends RawWebhook { - secret: string; -} - export interface RawCursorPage { items: T[]; next_cursor: string | null; @@ -211,20 +185,3 @@ export function parseThreadDetail(r: RawThread): ThreadDetail { }; } -export function parseWebhook(r: RawWebhook): Webhook { - return { - id: r.id, - mailboxId: r.mailbox_id, - url: r.url, - eventTypes: r.event_types, - status: r.status, - createdAt: new Date(r.created_at), - }; -} - -export function parseWebhookCreateResult(r: RawWebhookCreateResult): WebhookCreateResult { - return { - ...parseWebhook(r), - secret: r.secret, - }; -} diff --git a/typescript/src/phone/resources/calls.ts b/typescript/src/phone/resources/calls.ts index 22599a6..c39396f 100644 --- a/typescript/src/phone/resources/calls.ts +++ b/typescript/src/phone/resources/calls.ts @@ -53,27 +53,22 @@ export class CallsResource { * * @param options.fromNumber - E.164 number to call from. Must belong to your org and be active. * @param options.toNumber - E.164 number to call. - * @param options.streamUrl - WebSocket URL (wss://) for audio bridging. Falls back to the phone number's default stream URL. - * @param options.pipelineMode - Pipeline mode override for this call. + * @param options.clientWebsocketUrl - WebSocket URL (wss://) for audio bridging. * @param options.webhookUrl - Custom webhook URL for call lifecycle events. * @returns The created call record with current rate limit info. */ async place(options: { fromNumber: string; toNumber: string; - streamUrl?: string; - pipelineMode?: string; + clientWebsocketUrl?: string; webhookUrl?: string; }): Promise { const body: Record = { from_number: options.fromNumber, to_number: options.toNumber, }; - if (options.streamUrl !== undefined) { - body["stream_url"] = options.streamUrl; - } - if (options.pipelineMode !== undefined) { - body["pipeline_mode"] = options.pipelineMode; + if (options.clientWebsocketUrl !== undefined) { + body["client_websocket_url"] = options.clientWebsocketUrl; } if (options.webhookUrl !== undefined) { body["webhook_url"] = options.webhookUrl; diff --git a/typescript/src/phone/resources/numbers.ts b/typescript/src/phone/resources/numbers.ts index c5aa8d5..2a60851 100644 --- a/typescript/src/phone/resources/numbers.ts +++ b/typescript/src/phone/resources/numbers.ts @@ -39,26 +39,26 @@ export class PhoneNumbersResource { * * @param phoneNumberId - UUID of the phone number. * @param options.incomingCallAction - `"auto_accept"`, `"auto_reject"`, or `"webhook"`. - * @param options.defaultStreamUrl - WebSocket URL (wss://) for audio bridging. - * @param options.defaultPipelineMode - Default pipeline mode for incoming calls. + * @param options.clientWebsocketUrl - WebSocket URL (wss://) for audio bridging. + * @param options.incomingCallWebhookUrl - Webhook URL called for incoming calls when action is `"webhook"`. */ async update( phoneNumberId: string, options: { incomingCallAction?: string; - defaultStreamUrl?: string | null; - defaultPipelineMode?: string | null; + clientWebsocketUrl?: string | null; + incomingCallWebhookUrl?: string | null; }, ): Promise { const body: Record = {}; if (options.incomingCallAction !== undefined) { body["incoming_call_action"] = options.incomingCallAction; } - if ("defaultStreamUrl" in options) { - body["default_stream_url"] = options.defaultStreamUrl; + if ("clientWebsocketUrl" in options) { + body["client_websocket_url"] = options.clientWebsocketUrl; } - if ("defaultPipelineMode" in options) { - body["default_pipeline_mode"] = options.defaultPipelineMode; + if ("incomingCallWebhookUrl" in options) { + body["incoming_call_webhook_url"] = options.incomingCallWebhookUrl; } const data = await this.http.patch( `${BASE}/${phoneNumberId}`, diff --git a/typescript/src/phone/resources/webhooks.ts b/typescript/src/phone/resources/webhooks.ts deleted file mode 100644 index 9a7a0fc..0000000 --- a/typescript/src/phone/resources/webhooks.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * inkbox-phone/resources/webhooks.ts - * - * Webhook CRUD for phone numbers. - */ - -import { HttpTransport } from "../../_http.js"; -import { - PhoneWebhook, - PhoneWebhookCreateResult, - RawPhoneWebhook, - RawPhoneWebhookCreateResult, - parsePhoneWebhook, - parsePhoneWebhookCreateResult, -} from "../types.js"; - -const BASE = "/numbers"; - -export class PhoneWebhooksResource { - constructor(private readonly http: HttpTransport) {} - - /** - * Create a webhook subscription for a phone number. - * - * @param phoneNumberId - UUID of the phone number. - * @param options.url - HTTPS URL to receive webhook events. - * @param options.eventTypes - List of event types to subscribe to. - */ - async create( - phoneNumberId: string, - options: { url: string; eventTypes: string[] }, - ): Promise { - const data = await this.http.post( - `${BASE}/${phoneNumberId}/webhooks`, - { url: options.url, event_types: options.eventTypes }, - ); - return parsePhoneWebhookCreateResult(data); - } - - /** List all webhooks for a phone number. */ - async list(phoneNumberId: string): Promise { - const data = await this.http.get( - `${BASE}/${phoneNumberId}/webhooks`, - ); - return data.map(parsePhoneWebhook); - } - - /** - * Update a webhook subscription. Only provided fields are updated. - * - * @param phoneNumberId - UUID of the phone number. - * @param webhookId - UUID of the webhook. - * @param options.url - New HTTPS URL. - * @param options.eventTypes - New list of event types. - */ - async update( - phoneNumberId: string, - webhookId: string, - options: { url?: string; eventTypes?: string[] }, - ): Promise { - const body: Record = {}; - if (options.url !== undefined) { - body["url"] = options.url; - } - if (options.eventTypes !== undefined) { - body["event_types"] = options.eventTypes; - } - const data = await this.http.patch( - `${BASE}/${phoneNumberId}/webhooks/${webhookId}`, - body, - ); - return parsePhoneWebhook(data); - } - - /** Delete a webhook subscription. */ - async delete(phoneNumberId: string, webhookId: string): Promise { - await this.http.delete( - `${BASE}/${phoneNumberId}/webhooks/${webhookId}`, - ); - } -} diff --git a/typescript/src/phone/types.ts b/typescript/src/phone/types.ts index fd6c885..91dd336 100644 --- a/typescript/src/phone/types.ts +++ b/typescript/src/phone/types.ts @@ -60,20 +60,6 @@ export interface PhoneTranscript { createdAt: Date; } -export interface PhoneWebhook { - id: string; - sourceId: string; - sourceType: string; - url: string; - eventTypes: string[]; - status: string; - createdAt: Date; -} - -export interface PhoneWebhookCreateResult extends PhoneWebhook { - secret: string; -} - // ---- internal raw API shapes (snake_case from JSON) ---- export interface RawPhoneNumber { @@ -127,20 +113,6 @@ export interface RawPhoneTranscript { created_at: string; } -export interface RawPhoneWebhook { - id: string; - source_id: string; - source_type: string; - url: string; - event_types: string[]; - status: string; - created_at: string; -} - -export interface RawPhoneWebhookCreateResult extends RawPhoneWebhook { - secret: string; -} - // ---- parsers ---- export function parsePhoneNumber(r: RawPhoneNumber): PhoneNumber { @@ -207,22 +179,3 @@ export function parsePhoneTranscript(r: RawPhoneTranscript): PhoneTranscript { }; } -export function parsePhoneWebhook(r: RawPhoneWebhook): PhoneWebhook { - return { - id: r.id, - sourceId: r.source_id, - sourceType: r.source_type, - url: r.url, - eventTypes: r.event_types, - status: r.status, - createdAt: new Date(r.created_at), - }; -} - -export function parsePhoneWebhookCreateResult(r: RawPhoneWebhookCreateResult): PhoneWebhookCreateResult { - return { - ...parsePhoneWebhook(r), - secret: r.secret, - }; -} - diff --git a/typescript/tests/mail/types.test.ts b/typescript/tests/mail/types.test.ts index 73a413e..53c4871 100644 --- a/typescript/tests/mail/types.test.ts +++ b/typescript/tests/mail/types.test.ts @@ -5,8 +5,6 @@ import { parseMessageDetail, parseThread, parseThreadDetail, - parseWebhook, - parseWebhookCreateResult, } from "../../src/mail/types.js"; import { RAW_MAILBOX, @@ -14,8 +12,6 @@ import { RAW_MESSAGE_DETAIL, RAW_THREAD, RAW_THREAD_DETAIL, - RAW_WEBHOOK, - RAW_WEBHOOK_CREATE, } from "../sampleData.js"; describe("parseMailbox", () => { @@ -89,22 +85,3 @@ describe("parseThreadDetail", () => { }); }); -describe("parseWebhook", () => { - it("converts all fields", () => { - const w = parseWebhook(RAW_WEBHOOK); - expect(w.id).toBe(RAW_WEBHOOK.id); - expect(w.mailboxId).toBe(RAW_WEBHOOK.mailbox_id); - expect(w.url).toBe("https://example.com/hooks/mail"); - expect(w.eventTypes).toEqual(["message.received"]); - expect(w.status).toBe("active"); - expect(w.createdAt).toBeInstanceOf(Date); - }); -}); - -describe("parseWebhookCreateResult", () => { - it("includes secret", () => { - const w = parseWebhookCreateResult(RAW_WEBHOOK_CREATE); - expect(w.secret).toBe("test-hmac-secret-mail-abc123"); - expect(w.url).toBe("https://example.com/hooks/mail"); - }); -}); diff --git a/typescript/tests/mail/webhooks.test.ts b/typescript/tests/mail/webhooks.test.ts deleted file mode 100644 index 2f2b663..0000000 --- a/typescript/tests/mail/webhooks.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { WebhooksResource } from "../../src/mail/resources/webhooks.js"; -import type { HttpTransport } from "../../src/_http.js"; -import { RAW_WEBHOOK, RAW_WEBHOOK_CREATE } from "../sampleData.js"; - -function mockHttp() { - return { - get: vi.fn(), - post: vi.fn(), - patch: vi.fn(), - delete: vi.fn(), - } as unknown as HttpTransport; -} - -const ADDR = "agent01@inkbox.ai"; -const WEBHOOK_ID = "dddd4444-0000-0000-0000-000000000001"; - -describe("WebhooksResource.create", () => { - it("posts and returns WebhookCreateResult with secret", async () => { - const http = mockHttp(); - vi.mocked(http.post).mockResolvedValue(RAW_WEBHOOK_CREATE); - const res = new WebhooksResource(http); - - const result = await res.create(ADDR, { - url: "https://example.com/hooks/mail", - eventTypes: ["message.received"], - }); - - expect(http.post).toHaveBeenCalledWith(`/mailboxes/${ADDR}/webhooks`, { - url: "https://example.com/hooks/mail", - event_types: ["message.received"], - }); - expect(result.secret).toBe("test-hmac-secret-mail-abc123"); - expect(result.url).toBe("https://example.com/hooks/mail"); - }); -}); - -describe("WebhooksResource.list", () => { - it("returns list of webhooks", async () => { - const http = mockHttp(); - vi.mocked(http.get).mockResolvedValue([RAW_WEBHOOK]); - const res = new WebhooksResource(http); - - const webhooks = await res.list(ADDR); - - expect(http.get).toHaveBeenCalledWith(`/mailboxes/${ADDR}/webhooks`); - expect(webhooks).toHaveLength(1); - expect(webhooks[0].eventTypes).toEqual(["message.received"]); - }); -}); - -describe("WebhooksResource.delete", () => { - it("calls delete on the correct path", async () => { - const http = mockHttp(); - vi.mocked(http.delete).mockResolvedValue(undefined); - const res = new WebhooksResource(http); - - await res.delete(ADDR, WEBHOOK_ID); - - expect(http.delete).toHaveBeenCalledWith(`/mailboxes/${ADDR}/webhooks/${WEBHOOK_ID}`); - }); -}); diff --git a/typescript/tests/phone/types.test.ts b/typescript/tests/phone/types.test.ts index fee8bf2..1db4053 100644 --- a/typescript/tests/phone/types.test.ts +++ b/typescript/tests/phone/types.test.ts @@ -5,8 +5,6 @@ import { parseRateLimitInfo, parsePhoneCallWithRateLimit, parsePhoneTranscript, - parsePhoneWebhook, - parsePhoneWebhookCreateResult, } from "../../src/phone/types.js"; import { RAW_PHONE_NUMBER, @@ -14,8 +12,6 @@ import { RAW_RATE_LIMIT, RAW_PHONE_CALL_WITH_RATE_LIMIT, RAW_PHONE_TRANSCRIPT, - RAW_PHONE_WEBHOOK, - RAW_PHONE_WEBHOOK_CREATE, } from "../sampleData.js"; describe("parsePhoneNumber", () => { @@ -85,23 +81,3 @@ describe("parsePhoneTranscript", () => { }); }); -describe("parsePhoneWebhook", () => { - it("converts all fields", () => { - const w = parsePhoneWebhook(RAW_PHONE_WEBHOOK); - expect(w.id).toBe(RAW_PHONE_WEBHOOK.id); - expect(w.sourceId).toBe(RAW_PHONE_WEBHOOK.source_id); - expect(w.sourceType).toBe("phone_number"); - expect(w.url).toBe("https://example.com/webhooks/phone"); - expect(w.eventTypes).toEqual(["incoming_call"]); - expect(w.status).toBe("active"); - expect(w.createdAt).toBeInstanceOf(Date); - }); -}); - -describe("parsePhoneWebhookCreateResult", () => { - it("includes secret", () => { - const w = parsePhoneWebhookCreateResult(RAW_PHONE_WEBHOOK_CREATE); - expect(w.secret).toBe("test-hmac-secret-abc123"); - expect(w.url).toBe("https://example.com/webhooks/phone"); - }); -}); diff --git a/typescript/tests/phone/webhooks.test.ts b/typescript/tests/phone/webhooks.test.ts deleted file mode 100644 index 0a7bf4b..0000000 --- a/typescript/tests/phone/webhooks.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { PhoneWebhooksResource } from "../../src/phone/resources/webhooks.js"; -import type { HttpTransport } from "../../src/_http.js"; -import { RAW_PHONE_WEBHOOK, RAW_PHONE_WEBHOOK_CREATE } from "../sampleData.js"; - -function mockHttp() { - return { - get: vi.fn(), - post: vi.fn(), - patch: vi.fn(), - delete: vi.fn(), - } as unknown as HttpTransport; -} - -const NUM_ID = "aaaa1111-0000-0000-0000-000000000001"; -const WEBHOOK_ID = "dddd4444-0000-0000-0000-000000000001"; - -describe("PhoneWebhooksResource.create", () => { - it("posts and returns WebhookCreateResult with secret", async () => { - const http = mockHttp(); - vi.mocked(http.post).mockResolvedValue(RAW_PHONE_WEBHOOK_CREATE); - const res = new PhoneWebhooksResource(http); - - const result = await res.create(NUM_ID, { - url: "https://example.com/webhooks/phone", - eventTypes: ["incoming_call"], - }); - - expect(http.post).toHaveBeenCalledWith(`/numbers/${NUM_ID}/webhooks`, { - url: "https://example.com/webhooks/phone", - event_types: ["incoming_call"], - }); - expect(result.secret).toBe("test-hmac-secret-abc123"); - }); -}); - -describe("PhoneWebhooksResource.list", () => { - it("returns list of webhooks", async () => { - const http = mockHttp(); - vi.mocked(http.get).mockResolvedValue([RAW_PHONE_WEBHOOK]); - const res = new PhoneWebhooksResource(http); - - const webhooks = await res.list(NUM_ID); - - expect(http.get).toHaveBeenCalledWith(`/numbers/${NUM_ID}/webhooks`); - expect(webhooks).toHaveLength(1); - expect(webhooks[0].eventTypes).toEqual(["incoming_call"]); - }); -}); - -describe("PhoneWebhooksResource.update", () => { - it("sends url", async () => { - const http = mockHttp(); - vi.mocked(http.patch).mockResolvedValue(RAW_PHONE_WEBHOOK); - const res = new PhoneWebhooksResource(http); - - await res.update(NUM_ID, WEBHOOK_ID, { url: "https://new.example.com/hook" }); - - expect(http.patch).toHaveBeenCalledWith(`/numbers/${NUM_ID}/webhooks/${WEBHOOK_ID}`, { - url: "https://new.example.com/hook", - }); - }); - - it("sends eventTypes", async () => { - const http = mockHttp(); - vi.mocked(http.patch).mockResolvedValue(RAW_PHONE_WEBHOOK); - const res = new PhoneWebhooksResource(http); - - await res.update(NUM_ID, WEBHOOK_ID, { eventTypes: ["call.completed"] }); - - const [, body] = vi.mocked(http.patch).mock.calls[0] as [string, Record]; - expect(body["event_types"]).toEqual(["call.completed"]); - }); - - it("omits undefined fields", async () => { - const http = mockHttp(); - vi.mocked(http.patch).mockResolvedValue(RAW_PHONE_WEBHOOK); - const res = new PhoneWebhooksResource(http); - - await res.update(NUM_ID, WEBHOOK_ID, {}); - - expect(http.patch).toHaveBeenCalledWith(`/numbers/${NUM_ID}/webhooks/${WEBHOOK_ID}`, {}); - }); -}); - -describe("PhoneWebhooksResource.delete", () => { - it("calls delete on the correct path", async () => { - const http = mockHttp(); - vi.mocked(http.delete).mockResolvedValue(undefined); - const res = new PhoneWebhooksResource(http); - - await res.delete(NUM_ID, WEBHOOK_ID); - - expect(http.delete).toHaveBeenCalledWith(`/numbers/${NUM_ID}/webhooks/${WEBHOOK_ID}`); - }); -}); diff --git a/typescript/tests/sampleData.ts b/typescript/tests/sampleData.ts index a9b5f20..e469526 100644 --- a/typescript/tests/sampleData.ts +++ b/typescript/tests/sampleData.ts @@ -56,20 +56,6 @@ export const RAW_THREAD_DETAIL = { messages: [RAW_MESSAGE], }; -export const RAW_WEBHOOK = { - id: "dddd4444-0000-0000-0000-000000000001", - mailbox_id: "aaaa1111-0000-0000-0000-000000000001", - url: "https://example.com/hooks/mail", - event_types: ["message.received"], - status: "active", - created_at: "2026-03-09T00:00:00Z", -}; - -export const RAW_WEBHOOK_CREATE = { - ...RAW_WEBHOOK, - secret: "test-hmac-secret-mail-abc123", -}; - export const CURSOR_PAGE_MESSAGES = { items: [RAW_MESSAGE], next_cursor: null, @@ -141,21 +127,6 @@ export const RAW_PHONE_TRANSCRIPT = { created_at: "2026-03-09T00:01:01Z", }; -export const RAW_PHONE_WEBHOOK = { - id: "dddd4444-0000-0000-0000-000000000001", - source_id: "aaaa1111-0000-0000-0000-000000000001", - source_type: "phone_number", - url: "https://example.com/webhooks/phone", - event_types: ["incoming_call"], - status: "active", - created_at: "2026-03-09T00:00:00Z", -}; - -export const RAW_PHONE_WEBHOOK_CREATE = { - ...RAW_PHONE_WEBHOOK, - secret: "test-hmac-secret-abc123", -}; - // ---- Identities ---- export const RAW_IDENTITY_MAILBOX = { From 58c274329e0ead431dea749e0ffcf4b3291768fc Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:17:47 -0400 Subject: [PATCH 44/56] seperate create and assing --- README.md | 20 ++++++------ python/README.md | 10 +++--- python/inkbox/agent_identity.py | 57 ++++++++++++++++++++++++++------- python/inkbox/client.py | 17 ++++++++-- typescript/README.md | 10 +++--- typescript/src/agent.ts | 51 +++++++++++++++++++++++------ typescript/src/inkbox.ts | 26 +++++++-------- 7 files changed, 134 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 9145955..ff32962 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,9 @@ with Inkbox(api_key="ApiKey_...") as inkbox: # Create an identity — returns an AgentIdentity object identity = inkbox.create_identity("sales-agent") - # Provision and link channels in one call each - mailbox = identity.assign_mailbox(display_name="Sales Agent") - phone = identity.assign_phone_number(type="toll_free") + # Create and link new channels + mailbox = identity.create_mailbox(display_name="Sales Agent") + phone = identity.provision_phone_number(type="toll_free") print(mailbox.email_address) print(phone.number) @@ -46,9 +46,9 @@ const inkbox = new Inkbox({ apiKey: "ApiKey_..." }); // Create an identity — returns an AgentIdentity object const identity = await inkbox.createIdentity("sales-agent"); -// Provision and link channels in one call each -const mailbox = await identity.assignMailbox({ displayName: "Sales Agent" }); -const phone = await identity.assignPhoneNumber({ type: "toll_free" }); +// Create and link new channels +const mailbox = await identity.createMailbox({ displayName: "Sales Agent" }); +const phone = await identity.provisionPhoneNumber({ type: "toll_free" }); console.log(mailbox.emailAddress); console.log(phone.number); @@ -71,7 +71,7 @@ from inkbox import Inkbox with Inkbox(api_key="ApiKey_...") as inkbox: identity = inkbox.create_identity("sales-agent") - identity.assign_mailbox(display_name="Sales Agent") + identity.create_mailbox(display_name="Sales Agent") # Send an email identity.send_email( @@ -92,7 +92,7 @@ import { Inkbox } from "@inkbox/sdk"; const inkbox = new Inkbox({ apiKey: "ApiKey_..." }); const identity = await inkbox.createIdentity("sales-agent"); -await identity.assignMailbox({ displayName: "Sales Agent" }); +await identity.createMailbox({ displayName: "Sales Agent" }); // Send an email await identity.sendEmail({ @@ -118,7 +118,7 @@ from inkbox import Inkbox with Inkbox(api_key="ApiKey_...") as inkbox: identity = inkbox.create_identity("sales-agent") - identity.assign_phone_number(type="toll_free") + identity.provision_phone_number(type="toll_free") # Place an outbound call call = identity.place_call( @@ -145,7 +145,7 @@ import { Inkbox } from "@inkbox/sdk"; const inkbox = new Inkbox({ apiKey: "ApiKey_..." }); const identity = await inkbox.createIdentity("sales-agent"); -await identity.assignPhoneNumber({ type: "toll_free" }); +await identity.provisionPhoneNumber({ type: "toll_free" }); // Place an outbound call const call = await identity.placeCall({ diff --git a/python/README.md b/python/README.md index 98062bd..1344838 100644 --- a/python/README.md +++ b/python/README.md @@ -20,9 +20,9 @@ with Inkbox(api_key=os.environ["INKBOX_API_KEY"]) as inkbox: # Create an agent identity identity = inkbox.create_identity("support-bot") - # Provision and link channels in one call each - identity.assign_mailbox(display_name="Support Bot") - identity.assign_phone_number(type="toll_free") + # Create and link new channels + identity.create_mailbox(display_name="Support Bot") + identity.provision_phone_number(type="toll_free") # Send email directly from the identity identity.send_email( @@ -64,8 +64,8 @@ Use `with Inkbox(...) as inkbox:` (recommended) or call `inkbox.close()` manuall ```python # Create and fully provision an identity identity = inkbox.create_identity("sales-bot") -mailbox = identity.assign_mailbox(display_name="Sales Bot") # creates + links -phone = identity.assign_phone_number(type="toll_free") # provisions + links +mailbox = identity.create_mailbox(display_name="Sales Bot") # creates + links +phone = identity.provision_phone_number(type="toll_free") # provisions + links print(mailbox.email_address) print(phone.number) diff --git a/python/inkbox/agent_identity.py b/python/inkbox/agent_identity.py index b819786..5a0577b 100644 --- a/python/inkbox/agent_identity.py +++ b/python/inkbox/agent_identity.py @@ -33,8 +33,8 @@ class AgentIdentity: After assigning channels you can communicate directly:: - identity.assign_mailbox(display_name="Support Bot") - identity.assign_phone_number(type="toll_free") + identity.create_mailbox(display_name="Support Bot") + identity.provision_phone_number(type="toll_free") identity.send_email(to=["user@example.com"], subject="Hi", body_text="Hello") identity.place_call(to_number="+15555550100", client_websocket_url="wss://my-app.com/ws") @@ -74,18 +74,17 @@ def phone_number(self) -> IdentityPhoneNumber | None: return self._phone_number # ------------------------------------------------------------------ - # Channel assignment - # Combines resource creation/provisioning + identity linking in one call. + # Channel management # ------------------------------------------------------------------ - def assign_mailbox(self, *, display_name: str | None = None) -> IdentityMailbox: - """Create a new mailbox and assign it to this identity. + def create_mailbox(self, *, display_name: str | None = None) -> IdentityMailbox: + """Create a new mailbox and link it to this identity. Args: display_name: Optional human-readable sender name. Returns: - The assigned mailbox. + The newly created and linked mailbox. """ mailbox = self._inkbox._mailboxes.create(display_name=display_name) data = self._inkbox._ids_resource.assign_mailbox( @@ -95,23 +94,40 @@ def assign_mailbox(self, *, display_name: str | None = None) -> IdentityMailbox: self._data = data return self._mailbox # type: ignore[return-value] + def assign_mailbox(self, mailbox_id: str) -> IdentityMailbox: + """Link an existing mailbox to this identity. + + Args: + mailbox_id: UUID of the mailbox to link. Obtain via + ``inkbox.mailboxes.list()`` or ``inkbox.mailboxes.get()``. + + Returns: + The linked mailbox. + """ + data = self._inkbox._ids_resource.assign_mailbox( + self.agent_handle, mailbox_id=mailbox_id + ) + self._mailbox = data.mailbox + self._data = data + return self._mailbox # type: ignore[return-value] + def unlink_mailbox(self) -> None: """Unlink this identity's mailbox (does not delete the mailbox).""" self._require_mailbox() self._inkbox._ids_resource.unlink_mailbox(self.agent_handle) self._mailbox = None - def assign_phone_number( + def provision_phone_number( self, *, type: str = "toll_free", state: str | None = None ) -> IdentityPhoneNumber: - """Provision a new phone number and assign it to this identity. + """Provision a new phone number and link it to this identity. Args: type: ``"toll_free"`` (default) or ``"local"``. state: US state abbreviation (e.g. ``"NY"``), valid for local numbers only. Returns: - The assigned phone number. + The newly provisioned and linked phone number. """ number = self._inkbox._numbers.provision(type=type, state=state) data = self._inkbox._ids_resource.assign_phone_number( @@ -121,6 +137,23 @@ def assign_phone_number( self._data = data return self._phone_number # type: ignore[return-value] + def assign_phone_number(self, phone_number_id: str) -> IdentityPhoneNumber: + """Link an existing phone number to this identity. + + Args: + phone_number_id: UUID of the phone number to link. Obtain via + ``inkbox.phone_numbers.list()`` or ``inkbox.phone_numbers.get()``. + + Returns: + The linked phone number. + """ + data = self._inkbox._ids_resource.assign_phone_number( + self.agent_handle, phone_number_id=phone_number_id + ) + self._phone_number = data.phone_number + self._data = data + return self._phone_number # type: ignore[return-value] + def unlink_phone_number(self) -> None: """Unlink this identity's phone number (does not release the number).""" self._require_phone() @@ -318,14 +351,14 @@ def _require_mailbox(self) -> None: if not self._mailbox: raise InkboxError( f"Identity '{self.agent_handle}' has no mailbox assigned. " - "Call identity.assign_mailbox() first." + "Call identity.create_mailbox() or identity.assign_mailbox() first." ) def _require_phone(self) -> None: if not self._phone_number: raise InkboxError( f"Identity '{self.agent_handle}' has no phone number assigned. " - "Call identity.assign_phone_number() first." + "Call identity.provision_phone_number() or identity.assign_phone_number() first." ) def __repr__(self) -> str: diff --git a/python/inkbox/client.py b/python/inkbox/client.py index 9d6cc3e..1da0655 100644 --- a/python/inkbox/client.py +++ b/python/inkbox/client.py @@ -37,7 +37,7 @@ class Inkbox: with Inkbox(api_key="ApiKey_...") as inkbox: identity = inkbox.create_identity("support-bot") - identity.assign_mailbox(display_name="Support Bot") + identity.create_mailbox(display_name="Support Bot") identity.send_email( to=["customer@example.com"], subject="Hello!", @@ -67,7 +67,6 @@ def __init__( api_key=api_key, base_url=_api_root, timeout=timeout ) - # Internal resources — used by AgentIdentity self._mailboxes = MailboxesResource(self._mail_http) self._messages = MessagesResource(self._mail_http) self._threads = ThreadsResource(self._mail_http) @@ -79,6 +78,20 @@ def __init__( self._signing_keys = SigningKeysResource(self._api_http) self._ids_resource = IdentitiesResource(self._ids_http) + # ------------------------------------------------------------------ + # Public resource accessors + # ------------------------------------------------------------------ + + @property + def mailboxes(self) -> MailboxesResource: + """Access org-level mailbox operations (list, get, create, update, delete).""" + return self._mailboxes + + @property + def phone_numbers(self) -> PhoneNumbersResource: + """Access org-level phone number operations (list, get, provision, release).""" + return self._numbers + # ------------------------------------------------------------------ # Org-level operations # ------------------------------------------------------------------ diff --git a/typescript/README.md b/typescript/README.md index f76d56e..676f4bf 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -20,9 +20,9 @@ const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); // Create an agent identity const identity = await inkbox.createIdentity("support-bot"); -// Provision and link channels in one call each -const mailbox = await identity.assignMailbox({ displayName: "Support Bot" }); -const phone = await identity.assignPhoneNumber({ type: "toll_free" }); +// Create and link new channels +const mailbox = await identity.createMailbox({ displayName: "Support Bot" }); +const phone = await identity.provisionPhoneNumber({ type: "toll_free" }); // Send email directly from the identity await identity.sendEmail({ @@ -63,8 +63,8 @@ const transcripts = await identity.searchTranscripts({ q: "refund" }); ```ts // Create and fully provision an identity const identity = await inkbox.createIdentity("sales-bot"); -const mailbox = await identity.assignMailbox({ displayName: "Sales Bot" }); // creates + links -const phone = await identity.assignPhoneNumber({ type: "toll_free" }); // provisions + links +const mailbox = await identity.createMailbox({ displayName: "Sales Bot" }); // creates + links +const phone = await identity.provisionPhoneNumber({ type: "toll_free" }); // provisions + links console.log(mailbox.emailAddress); console.log(phone.number); diff --git a/typescript/src/agent.ts b/typescript/src/agent.ts index a93a736..7cd2c44 100644 --- a/typescript/src/agent.ts +++ b/typescript/src/agent.ts @@ -48,17 +48,16 @@ export class AgentIdentity { get phoneNumber(): IdentityPhoneNumber | null { return this._phoneNumber; } // ------------------------------------------------------------------ - // Channel assignment - // Combines resource creation/provisioning + identity linking in one call. + // Channel management // ------------------------------------------------------------------ /** - * Create a new mailbox and assign it to this identity. + * Create a new mailbox and link it to this identity. * * @param options.displayName - Optional human-readable sender name. - * @returns The assigned {@link IdentityMailbox}. + * @returns The newly created and linked {@link IdentityMailbox}. */ - async assignMailbox(options: { displayName?: string } = {}): Promise { + async createMailbox(options: { displayName?: string } = {}): Promise { const mailbox = await this._inkbox._mailboxes.create(options); const data = await this._inkbox._idsResource.assignMailbox(this.agentHandle, { mailboxId: mailbox.id, @@ -68,6 +67,22 @@ export class AgentIdentity { return this._mailbox!; } + /** + * Link an existing mailbox to this identity. + * + * @param mailboxId - UUID of the mailbox to link. Obtain via + * `inkbox.mailboxes.list()` or `inkbox.mailboxes.get()`. + * @returns The linked {@link IdentityMailbox}. + */ + async assignMailbox(mailboxId: string): Promise { + const data = await this._inkbox._idsResource.assignMailbox(this.agentHandle, { + mailboxId, + }); + this._mailbox = data.mailbox; + this._data = data; + return this._mailbox!; + } + /** * Unlink this identity's mailbox (does not delete the mailbox). */ @@ -78,13 +93,13 @@ export class AgentIdentity { } /** - * Provision a new phone number and assign it to this identity. + * Provision a new phone number and link it to this identity. * * @param options.type - `"toll_free"` (default) or `"local"`. * @param options.state - US state abbreviation (e.g. `"NY"`), valid for local numbers only. - * @returns The assigned {@link IdentityPhoneNumber}. + * @returns The newly provisioned and linked {@link IdentityPhoneNumber}. */ - async assignPhoneNumber( + async provisionPhoneNumber( options: { type?: string; state?: string } = {}, ): Promise { const number = await this._inkbox._numbers.provision(options); @@ -96,6 +111,22 @@ export class AgentIdentity { return this._phoneNumber!; } + /** + * Link an existing phone number to this identity. + * + * @param phoneNumberId - UUID of the phone number to link. Obtain via + * `inkbox.phoneNumbers.list()` or `inkbox.phoneNumbers.get()`. + * @returns The linked {@link IdentityPhoneNumber}. + */ + async assignPhoneNumber(phoneNumberId: string): Promise { + const data = await this._inkbox._idsResource.assignPhoneNumber(this.agentHandle, { + phoneNumberId, + }); + this._phoneNumber = data.phoneNumber; + this._data = data; + return this._phoneNumber!; + } + /** * Unlink this identity's phone number (does not release the number). */ @@ -255,7 +286,7 @@ export class AgentIdentity { if (!this._mailbox) { throw new InkboxAPIError( 0, - `Identity '${this.agentHandle}' has no mailbox assigned. Call identity.assignMailbox() first.`, + `Identity '${this.agentHandle}' has no mailbox assigned. Call identity.createMailbox() or identity.assignMailbox() first.`, ); } } @@ -264,7 +295,7 @@ export class AgentIdentity { if (!this._phoneNumber) { throw new InkboxAPIError( 0, - `Identity '${this.agentHandle}' has no phone number assigned. Call identity.assignPhoneNumber() first.`, + `Identity '${this.agentHandle}' has no phone number assigned. Call identity.provisionPhoneNumber() or identity.assignPhoneNumber() first.`, ); } } diff --git a/typescript/src/inkbox.ts b/typescript/src/inkbox.ts index 8898d8e..f6725f5 100644 --- a/typescript/src/inkbox.ts +++ b/typescript/src/inkbox.ts @@ -40,9 +40,9 @@ export interface InkboxOptions { * // Create an agent identity * const identity = await inkbox.createIdentity("support-bot"); * - * // Provision and link channels in one call each - * const mailbox = await identity.assignMailbox({ displayName: "Support Bot" }); - * const phone = await identity.assignPhoneNumber({ type: "toll_free" }); + * // Create and link new channels + * const mailbox = await identity.createMailbox({ displayName: "Support Bot" }); + * const phone = await identity.provisionPhoneNumber({ type: "toll_free" }); * * // Send email directly from the identity * await identity.sendEmail({ @@ -53,23 +53,13 @@ export interface InkboxOptions { * ``` */ export class Inkbox { - /** @internal — used by AgentIdentity */ readonly _mailboxes: MailboxesResource; - /** @internal — used by AgentIdentity */ readonly _messages: MessagesResource; - /** @internal */ readonly _threads: ThreadsResource; - /** @internal */ readonly _signingKeys: SigningKeysResource; - - /** @internal — used by AgentIdentity */ readonly _numbers: PhoneNumbersResource; - /** @internal — used by AgentIdentity */ readonly _calls: CallsResource; - /** @internal */ readonly _transcripts: TranscriptsResource; - - /** @internal — used by AgentIdentity to link channels */ readonly _idsResource: IdentitiesResource; constructor(options: InkboxOptions) { @@ -93,6 +83,16 @@ export class Inkbox { this._idsResource = new IdentitiesResource(idsHttp); } + // ------------------------------------------------------------------ + // Public resource accessors + // ------------------------------------------------------------------ + + /** Org-level mailbox operations (list, get, create, update, delete). */ + get mailboxes(): MailboxesResource { return this._mailboxes; } + + /** Org-level phone number operations (list, get, provision, release). */ + get phoneNumbers(): PhoneNumbersResource { return this._numbers; } + // ------------------------------------------------------------------ // Org-level operations // ------------------------------------------------------------------ From dcfc32aa51b8ca1050058211cc20a968d3fe5062 Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:49:52 -0400 Subject: [PATCH 45/56] update email methods --- README.md | 4 ++-- examples/python/read_agent_messages.py | 2 +- examples/typescript/read-agent-messages.ts | 2 +- python/README.md | 6 +++--- python/inkbox/agent_identity.py | 6 +++--- typescript/README.md | 6 +++--- typescript/src/agent.ts | 4 ++-- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index ff32962..35f7d9e 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ with Inkbox(api_key="ApiKey_...") as inkbox: ) # Iterate over all messages (pagination handled automatically) - for msg in identity.messages(): + for msg in identity.iter_emails(): print(msg.subject, msg.from_address) ``` @@ -102,7 +102,7 @@ await identity.sendEmail({ }); // Iterate over all messages (pagination handled automatically) -for await (const msg of identity.messages()) { +for await (const msg of identity.iterEmails()) { console.log(msg.subject, msg.fromAddress); } ``` diff --git a/examples/python/read_agent_messages.py b/examples/python/read_agent_messages.py index 343a5b2..ef4b7cd 100644 --- a/examples/python/read_agent_messages.py +++ b/examples/python/read_agent_messages.py @@ -13,7 +13,7 @@ # List the 5 most recent messages print("=== Agent inbox ===") -for i, msg in enumerate(identity.messages()): +for i, msg in enumerate(identity.iter_emails()): print(f"{msg.id} {msg.subject} from={msg.from_address} read={msg.is_read}") if i >= 4: break diff --git a/examples/typescript/read-agent-messages.ts b/examples/typescript/read-agent-messages.ts index fb2bb30..6351201 100644 --- a/examples/typescript/read-agent-messages.ts +++ b/examples/typescript/read-agent-messages.ts @@ -13,7 +13,7 @@ const identity = await inkbox.getIdentity(process.env.AGENT_HANDLE!); // List the 5 most recent messages console.log("=== Agent inbox ==="); let count = 0; -for await (const msg of identity.messages()) { +for await (const msg of identity.iterEmails()) { console.log(`${msg.id} ${msg.subject} from=${msg.fromAddress} read=${msg.isRead}`); if (++count >= 5) break; } diff --git a/python/README.md b/python/README.md index 1344838..ad59d7d 100644 --- a/python/README.md +++ b/python/README.md @@ -38,7 +38,7 @@ with Inkbox(api_key=os.environ["INKBOX_API_KEY"]) as inkbox: ) # Read inbox - for message in identity.messages(): + for message in identity.iter_emails(): print(message.subject) # Search transcripts @@ -103,11 +103,11 @@ identity.send_email( ) # Iterate inbox (paginated automatically) -for msg in identity.messages(): +for msg in identity.iter_emails(): print(msg.subject, msg.from_address) # Filter by direction -for msg in identity.messages(direction="inbound"): +for msg in identity.iter_emails(direction="inbound"): print(msg.subject) ``` diff --git a/python/inkbox/agent_identity.py b/python/inkbox/agent_identity.py index 5a0577b..fb4ef1b 100644 --- a/python/inkbox/agent_identity.py +++ b/python/inkbox/agent_identity.py @@ -39,7 +39,7 @@ class AgentIdentity: identity.send_email(to=["user@example.com"], subject="Hi", body_text="Hello") identity.place_call(to_number="+15555550100", client_websocket_url="wss://my-app.com/ws") - for msg in identity.messages(): + for msg in identity.iter_emails(): print(msg.subject) """ @@ -202,13 +202,13 @@ def send_email( attachments=attachments, ) - def messages( + def iter_emails( self, *, page_size: int = 50, direction: str | None = None, ) -> Iterator[Message]: - """Iterate over messages in this identity's inbox, newest first. + """Iterate over emails in this identity's inbox, newest first. Pagination is handled automatically. diff --git a/typescript/README.md b/typescript/README.md index 676f4bf..2538c0c 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -38,7 +38,7 @@ await identity.placeCall({ }); // Read inbox -for await (const message of identity.messages()) { +for await (const message of identity.iterEmails()) { console.log(message.subject); } @@ -102,12 +102,12 @@ await identity.sendEmail({ }); // Iterate inbox (paginated automatically) -for await (const msg of identity.messages()) { +for await (const msg of identity.iterEmails()) { console.log(msg.subject, msg.fromAddress); } // Filter by direction -for await (const msg of identity.messages({ direction: "inbound" })) { +for await (const msg of identity.iterEmails({ direction: "inbound" })) { console.log(msg.subject); } ``` diff --git a/typescript/src/agent.ts b/typescript/src/agent.ts index 7cd2c44..c9e3dad 100644 --- a/typescript/src/agent.ts +++ b/typescript/src/agent.ts @@ -167,14 +167,14 @@ export class AgentIdentity { } /** - * Iterate over messages in this identity's inbox, newest first. + * Iterate over emails in this identity's inbox, newest first. * * Pagination is handled automatically. * * @param options.pageSize - Messages fetched per API call (1–100). Defaults to 50. * @param options.direction - Filter by `"inbound"` or `"outbound"`. */ - messages(options: { pageSize?: number; direction?: "inbound" | "outbound" } = {}): AsyncGenerator { + iterEmails(options: { pageSize?: number; direction?: "inbound" | "outbound" } = {}): AsyncGenerator { this._requireMailbox(); return this._inkbox._messages.list(this._mailbox!.emailAddress, options); } From d728cc00f5f5dc8d97931f52c35508495bb1055b Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:56:56 -0400 Subject: [PATCH 46/56] rename agent to agent_identity --- typescript/src/{agent.ts => agent_identity.ts} | 0 typescript/src/index.ts | 2 +- typescript/src/inkbox.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename typescript/src/{agent.ts => agent_identity.ts} (100%) diff --git a/typescript/src/agent.ts b/typescript/src/agent_identity.ts similarity index 100% rename from typescript/src/agent.ts rename to typescript/src/agent_identity.ts diff --git a/typescript/src/index.ts b/typescript/src/index.ts index e4542e2..b1e927e 100644 --- a/typescript/src/index.ts +++ b/typescript/src/index.ts @@ -1,5 +1,5 @@ export { Inkbox } from "./inkbox.js"; -export { AgentIdentity } from "./agent.js"; +export { AgentIdentity } from "./agent_identity.js"; export type { InkboxOptions } from "./inkbox.js"; export { InkboxAPIError } from "./_http.js"; export type { SigningKey } from "./signing_keys.js"; diff --git a/typescript/src/inkbox.ts b/typescript/src/inkbox.ts index f6725f5..069d337 100644 --- a/typescript/src/inkbox.ts +++ b/typescript/src/inkbox.ts @@ -14,7 +14,7 @@ import { PhoneNumbersResource } from "./phone/resources/numbers.js"; import { CallsResource } from "./phone/resources/calls.js"; import { TranscriptsResource } from "./phone/resources/transcripts.js"; import { IdentitiesResource } from "./identities/resources/identities.js"; -import { AgentIdentity } from "./agent.js"; +import { AgentIdentity } from "./agent_identity.js"; import type { AgentIdentitySummary } from "./identities/types.js"; const DEFAULT_BASE_URL = "https://api.inkbox.ai"; From 94db06f671de480efc92432620d5ac85cfc9319d Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:06:37 -0400 Subject: [PATCH 47/56] feat: more email methods --- README.md | 17 +++++++++++++++++ python/README.md | 7 +++++++ python/inkbox/agent_identity.py | 30 ++++++++++++++++++++++++++++++ typescript/README.md | 10 ++++++++++ typescript/src/agent_identity.ts | 26 ++++++++++++++++++++++++++ 5 files changed, 90 insertions(+) diff --git a/README.md b/README.md index 35f7d9e..027cb55 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,13 @@ with Inkbox(api_key="ApiKey_...") as inkbox: # Iterate over all messages (pagination handled automatically) for msg in identity.iter_emails(): print(msg.subject, msg.from_address) + + # Iterate only unread messages + for msg in identity.iter_unread_emails(): + print(msg.subject) + + # Mark messages as read + identity.mark_emails_read([msg.id for msg in identity.iter_unread_emails()]) ``` ### TypeScript @@ -105,6 +112,16 @@ await identity.sendEmail({ for await (const msg of identity.iterEmails()) { console.log(msg.subject, msg.fromAddress); } + +// Iterate only unread messages +for await (const msg of identity.iterUnreadEmails()) { + console.log(msg.subject); +} + +// Mark messages as read +const unread: string[] = []; +for await (const msg of identity.iterUnreadEmails()) unread.push(msg.id); +await identity.markEmailsRead(unread); ``` --- diff --git a/python/README.md b/python/README.md index ad59d7d..416630c 100644 --- a/python/README.md +++ b/python/README.md @@ -109,6 +109,13 @@ for msg in identity.iter_emails(): # Filter by direction for msg in identity.iter_emails(direction="inbound"): print(msg.subject) + +# Iterate only unread emails +for msg in identity.iter_unread_emails(): + print(msg.subject) + +# Mark messages as read +identity.mark_emails_read([msg.id for msg in identity.iter_unread_emails()]) ``` --- diff --git a/python/inkbox/agent_identity.py b/python/inkbox/agent_identity.py index fb4ef1b..e0fe9ff 100644 --- a/python/inkbox/agent_identity.py +++ b/python/inkbox/agent_identity.py @@ -223,6 +223,36 @@ def iter_emails( direction=direction, ) + def iter_unread_emails( + self, + *, + page_size: int = 50, + direction: str | None = None, + ) -> Iterator[Message]: + """Iterate over unread emails in this identity's inbox, newest first. + + Fetches all messages and filters client-side. Pagination is handled + automatically. + + Args: + page_size: Messages fetched per API call (1–100). + direction: Filter by ``"inbound"`` or ``"outbound"``. + """ + return (msg for msg in self.iter_emails(page_size=page_size, direction=direction) if not msg.is_read) + + def mark_emails_read(self, message_ids: list[str]) -> None: + """Mark a list of messages as read. + + Args: + message_ids: IDs of the messages to mark as read. + """ + self._require_mailbox() + for mid in message_ids: + self._inkbox._messages.mark_read( + self._mailbox.email_address, # type: ignore[union-attr] + mid, + ) + # ------------------------------------------------------------------ # Phone helpers # ------------------------------------------------------------------ diff --git a/typescript/README.md b/typescript/README.md index 2538c0c..2c0f16a 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -110,6 +110,16 @@ for await (const msg of identity.iterEmails()) { for await (const msg of identity.iterEmails({ direction: "inbound" })) { console.log(msg.subject); } + +// Iterate only unread emails +for await (const msg of identity.iterUnreadEmails()) { + console.log(msg.subject); +} + +// Mark messages as read +const unread: string[] = []; +for await (const msg of identity.iterUnreadEmails()) unread.push(msg.id); +await identity.markEmailsRead(unread); ``` --- diff --git a/typescript/src/agent_identity.ts b/typescript/src/agent_identity.ts index c9e3dad..74ae9a0 100644 --- a/typescript/src/agent_identity.ts +++ b/typescript/src/agent_identity.ts @@ -179,6 +179,32 @@ export class AgentIdentity { return this._inkbox._messages.list(this._mailbox!.emailAddress, options); } + /** + * Iterate over unread emails in this identity's inbox, newest first. + * + * Fetches all messages and filters client-side. Pagination is handled automatically. + * + * @param options.pageSize - Messages fetched per API call (1–100). Defaults to 50. + * @param options.direction - Filter by `"inbound"` or `"outbound"`. + */ + async *iterUnreadEmails(options: { pageSize?: number; direction?: "inbound" | "outbound" } = {}): AsyncGenerator { + for await (const msg of this.iterEmails(options)) { + if (!msg.isRead) yield msg; + } + } + + /** + * Mark a list of messages as read. + * + * @param messageIds - IDs of the messages to mark as read. + */ + async markEmailsRead(messageIds: string[]): Promise { + this._requireMailbox(); + for (const id of messageIds) { + await this._inkbox._messages.markRead(this._mailbox!.emailAddress, id); + } + } + // ------------------------------------------------------------------ // Phone helpers // ------------------------------------------------------------------ From d2ec5fd56acf7e568589e5cb2e0691f4e8497e8a Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:22:54 -0400 Subject: [PATCH 48/56] feat: get thread --- python/README.md | 5 ++ python/inkbox/agent_identity.py | 15 +++++- python/tests/test_agent_identity.py | 47 +++++++++++++++++++ typescript/README.md | 6 +++ typescript/src/agent_identity.ts | 13 ++++- .../tests/identities/agent_identity.test.ts | 47 +++++++++++++++++++ 6 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 python/tests/test_agent_identity.py create mode 100644 typescript/tests/identities/agent_identity.test.ts diff --git a/python/README.md b/python/README.md index 416630c..b5f3d8a 100644 --- a/python/README.md +++ b/python/README.md @@ -116,6 +116,11 @@ for msg in identity.iter_unread_emails(): # Mark messages as read identity.mark_emails_read([msg.id for msg in identity.iter_unread_emails()]) + +# Get all emails in a thread (thread_id comes from msg.thread_id) +thread = identity.get_thread(msg.thread_id) +for m in thread.messages: + print(m.subject, m.from_address) ``` --- diff --git a/python/inkbox/agent_identity.py b/python/inkbox/agent_identity.py index e0fe9ff..dfc65db 100644 --- a/python/inkbox/agent_identity.py +++ b/python/inkbox/agent_identity.py @@ -15,7 +15,7 @@ from inkbox.identities.types import _AgentIdentityData, IdentityMailbox, IdentityPhoneNumber from inkbox.mail.exceptions import InkboxError -from inkbox.mail.types import Message +from inkbox.mail.types import Message, ThreadDetail from inkbox.phone.types import PhoneCall, PhoneCallWithRateLimit, PhoneTranscript if TYPE_CHECKING: @@ -253,6 +253,19 @@ def mark_emails_read(self, message_ids: list[str]) -> None: mid, ) + def get_thread(self, thread_id: str) -> ThreadDetail: + """Get a thread with all its messages inlined (oldest-first). + + Args: + thread_id: UUID of the thread to fetch. Obtain via ``msg.thread_id`` + on any :class:`~inkbox.mail.types.Message`. + """ + self._require_mailbox() + return self._inkbox._threads.get( + self._mailbox.email_address, # type: ignore[union-attr] + thread_id, + ) + # ------------------------------------------------------------------ # Phone helpers # ------------------------------------------------------------------ diff --git a/python/tests/test_agent_identity.py b/python/tests/test_agent_identity.py new file mode 100644 index 0000000..5c0a3c5 --- /dev/null +++ b/python/tests/test_agent_identity.py @@ -0,0 +1,47 @@ +"""Tests for AgentIdentity convenience methods.""" + +import pytest +from unittest.mock import MagicMock + +from sample_data_identities import IDENTITY_DETAIL_DICT +from sample_data_mail import THREAD_DETAIL_DICT + +from inkbox.agent_identity import AgentIdentity +from inkbox.identities.types import _AgentIdentityData +from inkbox.mail.exceptions import InkboxError +from inkbox.mail.types import ThreadDetail + + +def _identity_with_mailbox(): + """Return an AgentIdentity backed by a mock Inkbox client.""" + data = _AgentIdentityData._from_dict(IDENTITY_DETAIL_DICT) + inkbox = MagicMock() + return AgentIdentity(data, inkbox), inkbox + + +def _identity_without_mailbox(): + """Return an AgentIdentity with no mailbox assigned.""" + detail = {**IDENTITY_DETAIL_DICT, "mailbox": None} + data = _AgentIdentityData._from_dict(detail) + inkbox = MagicMock() + return AgentIdentity(data, inkbox), inkbox + + +class TestAgentIdentityGetThread: + def test_get_thread_returns_thread_detail(self): + identity, inkbox = _identity_with_mailbox() + thread_id = THREAD_DETAIL_DICT["id"] + inkbox._threads.get.return_value = ThreadDetail._from_dict(THREAD_DETAIL_DICT) + + result = identity.get_thread(thread_id) + + inkbox._threads.get.assert_called_once_with("sales-agent@inkbox.ai", thread_id) + assert isinstance(result, ThreadDetail) + assert str(result.id) == thread_id + assert len(result.messages) == 1 + + def test_get_thread_requires_mailbox(self): + identity, _ = _identity_without_mailbox() + + with pytest.raises(InkboxError, match="no mailbox assigned"): + identity.get_thread("eeee5555-0000-0000-0000-000000000001") diff --git a/typescript/README.md b/typescript/README.md index 2c0f16a..59743d1 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -120,6 +120,12 @@ for await (const msg of identity.iterUnreadEmails()) { const unread: string[] = []; for await (const msg of identity.iterUnreadEmails()) unread.push(msg.id); await identity.markEmailsRead(unread); + +// Get all emails in a thread (threadId comes from msg.threadId) +const thread = await identity.getThread(msg.threadId!); +for (const m of thread.messages) { + console.log(m.subject, m.fromAddress); +} ``` --- diff --git a/typescript/src/agent_identity.ts b/typescript/src/agent_identity.ts index 74ae9a0..40863ae 100644 --- a/typescript/src/agent_identity.ts +++ b/typescript/src/agent_identity.ts @@ -10,7 +10,7 @@ */ import { InkboxAPIError } from "./_http.js"; -import type { Message } from "./mail/types.js"; +import type { Message, ThreadDetail } from "./mail/types.js"; import type { PhoneCall, PhoneCallWithRateLimit, PhoneTranscript } from "./phone/types.js"; import type { AgentIdentitySummary, @@ -205,6 +205,17 @@ export class AgentIdentity { } } + /** + * Get a thread with all its messages inlined (oldest-first). + * + * @param threadId - UUID of the thread to fetch. Obtain via `msg.threadId` + * on any {@link Message}. + */ + async getThread(threadId: string): Promise { + this._requireMailbox(); + return this._inkbox._threads.get(this._mailbox!.emailAddress, threadId); + } + // ------------------------------------------------------------------ // Phone helpers // ------------------------------------------------------------------ diff --git a/typescript/tests/identities/agent_identity.test.ts b/typescript/tests/identities/agent_identity.test.ts new file mode 100644 index 0000000..078e5a6 --- /dev/null +++ b/typescript/tests/identities/agent_identity.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, vi } from "vitest"; +import { AgentIdentity } from "../../src/agent_identity.js"; +import { parseAgentIdentityData } from "../../src/identities/types.js"; +import { parseThreadDetail } from "../../src/mail/types.js"; +import type { Inkbox } from "../../src/inkbox.js"; +import { InkboxAPIError } from "../../src/_http.js"; +import { RAW_IDENTITY_DETAIL, RAW_THREAD_DETAIL } from "../sampleData.js"; + +const THREAD_ID = RAW_THREAD_DETAIL.id; + +function mockInkbox() { + return { + _threads: { get: vi.fn() }, + } as unknown as Inkbox; +} + +function identityWithMailbox() { + const data = parseAgentIdentityData(RAW_IDENTITY_DETAIL); + const inkbox = mockInkbox(); + return { identity: new AgentIdentity(data, inkbox), inkbox }; +} + +function identityWithoutMailbox() { + const data = parseAgentIdentityData({ ...RAW_IDENTITY_DETAIL, mailbox: null }); + const inkbox = mockInkbox(); + return { identity: new AgentIdentity(data, inkbox), inkbox }; +} + +describe("AgentIdentity.getThread", () => { + it("fetches thread detail from identity mailbox", async () => { + const { identity, inkbox } = identityWithMailbox(); + const threadDetail = parseThreadDetail(RAW_THREAD_DETAIL); + vi.mocked(inkbox._threads.get).mockResolvedValue(threadDetail); + + const result = await identity.getThread(THREAD_ID); + + expect(inkbox._threads.get).toHaveBeenCalledWith("sales-agent@inkbox.ai", THREAD_ID); + expect(result.id).toBe(THREAD_ID); + expect(result.messages).toHaveLength(1); + }); + + it("throws when no mailbox is assigned", async () => { + const { identity } = identityWithoutMailbox(); + + await expect(identity.getThread(THREAD_ID)).rejects.toThrow(InkboxAPIError); + }); +}); From 581d790c2e95f913ee5e7892a856d335fba16489 Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:30:01 -0400 Subject: [PATCH 49/56] update phone methods --- README.md | 14 ++++--------- examples/python/read_agent_calls.py | 4 ++-- examples/typescript/read-agent-calls.ts | 4 ++-- python/README.md | 14 +++++-------- python/inkbox/agent_identity.py | 28 +++---------------------- typescript/README.md | 10 +++------ typescript/src/agent_identity.ts | 22 +++---------------- 7 files changed, 22 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 027cb55..df09674 100644 --- a/README.md +++ b/README.md @@ -145,14 +145,11 @@ with Inkbox(api_key="ApiKey_...") as inkbox: print(call.status) print(call.rate_limit.calls_remaining) - # Search transcripts - results = identity.search_transcripts(q="appointment") - # List calls - calls = identity.calls() + calls = identity.list_calls() # Fetch transcript segments for a call - segments = identity.transcripts(calls[0].id) + segments = identity.list_transcripts(calls[0].id) ``` ### TypeScript @@ -172,14 +169,11 @@ const call = await identity.placeCall({ console.log(call.status); console.log(call.rateLimit.callsRemaining); -// Search transcripts -const results = await identity.searchTranscripts({ q: "appointment" }); - // List calls -const calls = await identity.calls(); +const calls = await identity.listCalls(); // Fetch transcript segments for a call -const segments = await identity.transcripts(calls[0].id); +const segments = await identity.listTranscripts(calls[0].id); ``` --- diff --git a/examples/python/read_agent_calls.py b/examples/python/read_agent_calls.py index 6385ca9..e8274b6 100644 --- a/examples/python/read_agent_calls.py +++ b/examples/python/read_agent_calls.py @@ -11,11 +11,11 @@ inkbox = Inkbox(api_key=os.environ["INKBOX_API_KEY"]) identity = inkbox.get_identity(os.environ["AGENT_HANDLE"]) -calls = identity.calls(limit=10) +calls = identity.list_calls(limit=10) for call in calls: print(f"\n{call.id} {call.direction} {call.remote_phone_number} status={call.status}") - transcripts = identity.transcripts(call.id) + transcripts = identity.list_transcripts(call.id) for t in transcripts: print(f" [{t.party}] {t.text}") diff --git a/examples/typescript/read-agent-calls.ts b/examples/typescript/read-agent-calls.ts index 0eb3063..df01007 100644 --- a/examples/typescript/read-agent-calls.ts +++ b/examples/typescript/read-agent-calls.ts @@ -10,12 +10,12 @@ import { Inkbox } from "../../typescript/src/inkbox.js"; const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); const identity = await inkbox.getIdentity(process.env.AGENT_HANDLE!); -const calls = await identity.calls({ limit: 10 }); +const calls = await identity.listCalls({ limit: 10 }); for (const call of calls) { console.log(`\n${call.id} ${call.direction} ${call.remotePhoneNumber} status=${call.status}`); - const transcripts = await identity.transcripts(call.id); + const transcripts = await identity.listTranscripts(call.id); for (const t of transcripts) { console.log(` [${t.party}] ${t.text}`); } diff --git a/python/README.md b/python/README.md index b5f3d8a..8d5f3b0 100644 --- a/python/README.md +++ b/python/README.md @@ -41,8 +41,8 @@ with Inkbox(api_key=os.environ["INKBOX_API_KEY"]) as inkbox: for message in identity.iter_emails(): print(message.subject) - # Search transcripts - results = identity.search_transcripts(q="refund") + # List calls + calls = identity.list_calls() ``` ## Authentication @@ -135,16 +135,12 @@ call = identity.place_call( ) print(call.status, call.rate_limit.calls_remaining) -# Full-text search across transcripts -results = identity.search_transcripts(q="appointment") -results = identity.search_transcripts(q="refund", party="remote", limit=10) - # List calls -calls = identity.calls() -calls = identity.calls(limit=10, offset=0) +calls = identity.list_calls() +calls = identity.list_calls(limit=10, offset=0) # Fetch transcript segments for a call -segments = identity.transcripts(calls[0].id) +segments = identity.list_transcripts(calls[0].id) ``` --- diff --git a/python/inkbox/agent_identity.py b/python/inkbox/agent_identity.py index dfc65db..8d2a2a9 100644 --- a/python/inkbox/agent_identity.py +++ b/python/inkbox/agent_identity.py @@ -292,29 +292,7 @@ def place_call( webhook_url=webhook_url, ) - def search_transcripts( - self, - *, - q: str, - party: str | None = None, - limit: int = 50, - ) -> list[PhoneTranscript]: - """Full-text search across call transcripts for this identity's number. - - Args: - q: Search query string. - party: Filter by speaker: ``"local"`` or ``"remote"``. - limit: Maximum number of results (1–200). - """ - self._require_phone() - return self._inkbox._numbers.search_transcripts( - self._phone_number.id, # type: ignore[union-attr] - q=q, - party=party, - limit=limit, - ) - - def calls(self, *, limit: int = 50, offset: int = 0) -> list[PhoneCall]: + def list_calls(self, *, limit: int = 50, offset: int = 0) -> list[PhoneCall]: """List calls made to/from this identity's phone number. Args: @@ -328,8 +306,8 @@ def calls(self, *, limit: int = 50, offset: int = 0) -> list[PhoneCall]: offset=offset, ) - def transcripts(self, call_id: str) -> list[PhoneTranscript]: - """Fetch transcript segments for a specific call. + def list_transcripts(self, call_id: str) -> list[PhoneTranscript]: + """List transcript segments for a specific call. Args: call_id: ID of the call to fetch transcripts for. diff --git a/typescript/README.md b/typescript/README.md index 59743d1..4e7f327 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -140,16 +140,12 @@ const call = await identity.placeCall({ }); console.log(call.status, call.rateLimit.callsRemaining); -// Full-text search across transcripts -const results = await identity.searchTranscripts({ q: "appointment" }); -const filtered = await identity.searchTranscripts({ q: "refund", party: "remote", limit: 10 }); - // List calls -const calls = await identity.calls(); -const paged = await identity.calls({ limit: 10, offset: 0 }); +const calls = await identity.listCalls(); +const paged = await identity.listCalls({ limit: 10, offset: 0 }); // Fetch transcript segments for a call -const segments = await identity.transcripts(calls[0].id); +const segments = await identity.listTranscripts(calls[0].id); ``` --- diff --git a/typescript/src/agent_identity.ts b/typescript/src/agent_identity.ts index 40863ae..6e80109 100644 --- a/typescript/src/agent_identity.ts +++ b/typescript/src/agent_identity.ts @@ -241,39 +241,23 @@ export class AgentIdentity { }); } - /** - * Full-text search across call transcripts for this identity's number. - * - * @param options.q - Search query string. - * @param options.party - Filter by speaker: `"local"` or `"remote"`. - * @param options.limit - Maximum number of results (1–200). Defaults to 50. - */ - async searchTranscripts(options: { - q: string; - party?: string; - limit?: number; - }): Promise { - this._requirePhone(); - return this._inkbox._numbers.searchTranscripts(this._phoneNumber!.id, options); - } - /** * List calls made to/from this identity's phone number. * * @param options.limit - Maximum number of results. Defaults to 50. * @param options.offset - Pagination offset. Defaults to 0. */ - async calls(options: { limit?: number; offset?: number } = {}): Promise { + async listCalls(options: { limit?: number; offset?: number } = {}): Promise { this._requirePhone(); return this._inkbox._calls.list(this._phoneNumber!.id, options); } /** - * Fetch transcript segments for a specific call. + * List transcript segments for a specific call. * * @param callId - ID of the call to fetch transcripts for. */ - async transcripts(callId: string): Promise { + async listTranscripts(callId: string): Promise { this._requirePhone(); return this._inkbox._transcripts.list(this._phoneNumber!.id, callId); } From a13aee3b3ae2f3bbccc0c17f1ed7375b839a751d Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:39:37 -0400 Subject: [PATCH 50/56] simplify signing key verification --- README.md | 379 +++++++++++++++++++++++--- python/README.md | 8 +- python/inkbox/signing_keys.py | 18 +- python/tests/test_signing_keys.py | 77 ++---- typescript/README.md | 4 +- typescript/src/signing_keys.ts | 23 +- typescript/tests/signing-keys.test.ts | 38 ++- 7 files changed, 428 insertions(+), 119 deletions(-) diff --git a/README.md b/README.md index df09674..c19c7f0 100644 --- a/README.md +++ b/README.md @@ -29,11 +29,20 @@ with Inkbox(api_key="ApiKey_...") as inkbox: print(mailbox.email_address) print(phone.number) - # List, get, update, delete + # Link an existing mailbox or phone number instead of creating new ones + identity.assign_mailbox("mailbox-uuid-here") + identity.assign_phone_number("phone-number-uuid-here") + + # Unlink channels without deleting them + identity.unlink_mailbox() + identity.unlink_phone_number() + + # List, get, update, delete, refresh identities = inkbox.list_identities() - identity = inkbox.get_identity("sales-agent") - identity.update(status="paused") - identity.delete() + identity = inkbox.get_identity("sales-agent") + identity.update(status="paused") # or new_handle="new-name" + identity.refresh() # re-fetch from API, updates cached channels + identity.delete() # soft-delete; unlinks channels ``` ### TypeScript @@ -53,11 +62,20 @@ const phone = await identity.provisionPhoneNumber({ type: "toll_free" }); console.log(mailbox.emailAddress); console.log(phone.number); -// List, get, update, delete +// Link an existing mailbox or phone number instead of creating new ones +await identity.assignMailbox("mailbox-uuid-here"); +await identity.assignPhoneNumber("phone-number-uuid-here"); + +// Unlink channels without deleting them +await identity.unlinkMailbox(); +await identity.unlinkPhoneNumber(); + +// List, get, update, delete, refresh const identities = await inkbox.listIdentities(); const i = await inkbox.getIdentity("sales-agent"); -await i.update({ status: "paused" }); -await i.delete(); +await i.update({ status: "paused" }); // or newHandle: "new-name" +await i.refresh(); // re-fetch from API, updates cached channels +await i.delete(); // soft-delete; unlinks channels ``` --- @@ -70,26 +88,60 @@ await i.delete(); from inkbox import Inkbox with Inkbox(api_key="ApiKey_...") as inkbox: - identity = inkbox.create_identity("sales-agent") - identity.create_mailbox(display_name="Sales Agent") + identity = inkbox.get_identity("sales-agent") - # Send an email - identity.send_email( + # Send an email (plain text and/or HTML) + sent = identity.send_email( to=["user@example.com"], subject="Hello from Inkbox", body_text="Hi there!", + body_html="

Hi there!

", + cc=["manager@example.com"], + bcc=["archive@example.com"], + ) + + # Send a threaded reply + identity.send_email( + to=["user@example.com"], + subject=f"Re: {sent.subject}", + body_text="Following up!", + in_reply_to_message_id=sent.id, + ) + + # Send with attachments + identity.send_email( + to=["user@example.com"], + subject="See attached", + body_text="Please find the file attached.", + attachments=[{ + "filename": "report.pdf", + "content_type": "application/pdf", + "content_base64": "", + }], ) # Iterate over all messages (pagination handled automatically) for msg in identity.iter_emails(): - print(msg.subject, msg.from_address) + print(msg.subject, msg.from_address, msg.is_read) + + # Filter by direction: "inbound" or "outbound" + for msg in identity.iter_emails(direction="inbound"): + print(msg.subject) # Iterate only unread messages for msg in identity.iter_unread_emails(): print(msg.subject) # Mark messages as read - identity.mark_emails_read([msg.id for msg in identity.iter_unread_emails()]) + unread_ids = [msg.id for msg in identity.iter_unread_emails()] + identity.mark_emails_read(unread_ids) + + # Get a full thread (all messages, oldest-first) + for msg in identity.iter_emails(): + thread = identity.get_thread(msg.thread_id) + for m in thread.messages: + print(f"[{m.from_address}] {m.subject}") + break ``` ### TypeScript @@ -98,19 +150,46 @@ with Inkbox(api_key="ApiKey_...") as inkbox: import { Inkbox } from "@inkbox/sdk"; const inkbox = new Inkbox({ apiKey: "ApiKey_..." }); -const identity = await inkbox.createIdentity("sales-agent"); -await identity.createMailbox({ displayName: "Sales Agent" }); +const identity = await inkbox.getIdentity("sales-agent"); -// Send an email -await identity.sendEmail({ +// Send an email (plain text and/or HTML) +const sent = await identity.sendEmail({ to: ["user@example.com"], subject: "Hello from Inkbox", bodyText: "Hi there!", + bodyHtml: "

Hi there!

", + cc: ["manager@example.com"], + bcc: ["archive@example.com"], +}); + +// Send a threaded reply +await identity.sendEmail({ + to: ["user@example.com"], + subject: `Re: ${sent.subject}`, + bodyText: "Following up!", + inReplyToMessageId: sent.id, +}); + +// Send with attachments +await identity.sendEmail({ + to: ["user@example.com"], + subject: "See attached", + bodyText: "Please find the file attached.", + attachments: [{ + filename: "report.pdf", + contentType: "application/pdf", + contentBase64: "", + }], }); // Iterate over all messages (pagination handled automatically) for await (const msg of identity.iterEmails()) { - console.log(msg.subject, msg.fromAddress); + console.log(msg.subject, msg.fromAddress, msg.isRead); +} + +// Filter by direction: "inbound" or "outbound" +for await (const msg of identity.iterEmails({ direction: "inbound" })) { + console.log(msg.subject); } // Iterate only unread messages @@ -119,9 +198,18 @@ for await (const msg of identity.iterUnreadEmails()) { } // Mark messages as read -const unread: string[] = []; -for await (const msg of identity.iterUnreadEmails()) unread.push(msg.id); -await identity.markEmailsRead(unread); +const unreadIds: string[] = []; +for await (const msg of identity.iterUnreadEmails()) unreadIds.push(msg.id); +await identity.markEmailsRead(unreadIds); + +// Get a full thread (all messages, oldest-first) +for await (const msg of identity.iterEmails()) { + const thread = await identity.getThread(msg.threadId); + for (const m of thread.messages) { + console.log(`[${m.fromAddress}] ${m.subject}`); + } + break; +} ``` --- @@ -134,22 +222,31 @@ await identity.markEmailsRead(unread); from inkbox import Inkbox with Inkbox(api_key="ApiKey_...") as inkbox: - identity = inkbox.create_identity("sales-agent") - identity.provision_phone_number(type="toll_free") + identity = inkbox.get_identity("sales-agent") - # Place an outbound call + # Place an outbound call — stream audio over WebSocket call = identity.place_call( to_number="+15167251294", - stream_url="wss://your-agent.example.com/ws", + client_websocket_url="wss://your-agent.example.com/ws", ) print(call.status) print(call.rate_limit.calls_remaining) - # List calls - calls = identity.list_calls() + # Or receive call events via webhook instead + call = identity.place_call( + to_number="+15167251294", + webhook_url="https://your-agent.example.com/call-events", + ) + + # List calls (paginated) + calls = identity.list_calls(limit=10, offset=0) + for call in calls: + print(call.id, call.direction, call.remote_phone_number, call.status) # Fetch transcript segments for a call segments = identity.list_transcripts(calls[0].id) + for t in segments: + print(f"[{t.party}] {t.text}") # party: "local" or "remote" ``` ### TypeScript @@ -158,22 +255,238 @@ with Inkbox(api_key="ApiKey_...") as inkbox: import { Inkbox } from "@inkbox/sdk"; const inkbox = new Inkbox({ apiKey: "ApiKey_..." }); -const identity = await inkbox.createIdentity("sales-agent"); -await identity.provisionPhoneNumber({ type: "toll_free" }); +const identity = await inkbox.getIdentity("sales-agent"); -// Place an outbound call +// Place an outbound call — stream audio over WebSocket const call = await identity.placeCall({ toNumber: "+15167251294", - streamUrl: "wss://your-agent.example.com/ws", + clientWebsocketUrl: "wss://your-agent.example.com/ws", }); console.log(call.status); console.log(call.rateLimit.callsRemaining); -// List calls -const calls = await identity.listCalls(); +// Or receive call events via webhook instead +const call2 = await identity.placeCall({ + toNumber: "+15167251294", + webhookUrl: "https://your-agent.example.com/call-events", +}); + +// List calls (paginated) +const calls = await identity.listCalls({ limit: 10, offset: 0 }); +for (const c of calls) { + console.log(c.id, c.direction, c.remotePhoneNumber, c.status); +} // Fetch transcript segments for a call const segments = await identity.listTranscripts(calls[0].id); +for (const t of segments) { + console.log(`[${t.party}] ${t.text}`); // party: "local" or "remote" +} +``` + +--- + +## Org-level mailboxes + +Manage mailboxes directly without going through an identity. Access via `inkbox.mailboxes`. + +### Python + +```python +from inkbox import Inkbox + +with Inkbox(api_key="ApiKey_...") as inkbox: + # List all mailboxes in the organisation + mailboxes = inkbox.mailboxes.list() + + # Get a specific mailbox + mailbox = inkbox.mailboxes.get("abc-xyz@inkboxmail.com") + + # Create a standalone mailbox + mailbox = inkbox.mailboxes.create(display_name="Support Inbox") + print(mailbox.email_address) + + # Update display name or webhook URL + inkbox.mailboxes.update(mailbox.email_address, display_name="New Name") + inkbox.mailboxes.update(mailbox.email_address, webhook_url="https://example.com/hook") + inkbox.mailboxes.update(mailbox.email_address, webhook_url=None) # remove webhook + + # Full-text search across messages in a mailbox + results = inkbox.mailboxes.search(mailbox.email_address, q="invoice", limit=20) + for msg in results: + print(msg.subject, msg.from_address) + + # Delete a mailbox + inkbox.mailboxes.delete(mailbox.email_address) +``` + +### TypeScript + +```ts +import { Inkbox } from "@inkbox/sdk"; + +const inkbox = new Inkbox({ apiKey: "ApiKey_..." }); + +// List all mailboxes in the organisation +const mailboxes = await inkbox.mailboxes.list(); + +// Get a specific mailbox +const mailbox = await inkbox.mailboxes.get("abc-xyz@inkboxmail.com"); + +// Create a standalone mailbox +const mb = await inkbox.mailboxes.create({ displayName: "Support Inbox" }); +console.log(mb.emailAddress); + +// Update display name or webhook URL +await inkbox.mailboxes.update(mb.emailAddress, { displayName: "New Name" }); +await inkbox.mailboxes.update(mb.emailAddress, { webhookUrl: "https://example.com/hook" }); +await inkbox.mailboxes.update(mb.emailAddress, { webhookUrl: null }); // remove webhook + +// Full-text search across messages in a mailbox +const results = await inkbox.mailboxes.search(mb.emailAddress, { q: "invoice", limit: 20 }); +for (const msg of results) { + console.log(msg.subject, msg.fromAddress); +} + +// Delete a mailbox +await inkbox.mailboxes.delete(mb.emailAddress); +``` + +--- + +## Org-level phone numbers + +Manage phone numbers directly without going through an identity. Access via `inkbox.phone_numbers` (Python) / `inkbox.phoneNumbers` (TypeScript). + +### Python + +```python +from inkbox import Inkbox + +with Inkbox(api_key="ApiKey_...") as inkbox: + # List all phone numbers in the organisation + numbers = inkbox.phone_numbers.list() + + # Get a specific phone number by ID + number = inkbox.phone_numbers.get("phone-number-uuid") + + # Provision a new number + number = inkbox.phone_numbers.provision(type="toll_free") + local = inkbox.phone_numbers.provision(type="local", state="NY") + + # Update incoming call behaviour + inkbox.phone_numbers.update( + number.id, + incoming_call_action="webhook", + incoming_call_webhook_url="https://example.com/calls", + ) + inkbox.phone_numbers.update( + number.id, + incoming_call_action="auto_accept", + client_websocket_url="wss://example.com/ws", + ) + + # Full-text search across transcripts + hits = inkbox.phone_numbers.search_transcripts(number.id, q="refund", party="remote") + for t in hits: + print(f"[{t.party}] {t.text}") + + # Release a number + inkbox.phone_numbers.release(number=number.number) +``` + +### TypeScript + +```ts +import { Inkbox } from "@inkbox/sdk"; + +const inkbox = new Inkbox({ apiKey: "ApiKey_..." }); + +// List all phone numbers in the organisation +const numbers = await inkbox.phoneNumbers.list(); + +// Get a specific phone number by ID +const number = await inkbox.phoneNumbers.get("phone-number-uuid"); + +// Provision a new number +const num = await inkbox.phoneNumbers.provision({ type: "toll_free" }); +const local = await inkbox.phoneNumbers.provision({ type: "local", state: "NY" }); + +// Update incoming call behaviour +await inkbox.phoneNumbers.update(num.id, { + incomingCallAction: "webhook", + incomingCallWebhookUrl: "https://example.com/calls", +}); +await inkbox.phoneNumbers.update(num.id, { + incomingCallAction: "auto_accept", + clientWebsocketUrl: "wss://example.com/ws", +}); + +// Full-text search across transcripts +const hits = await inkbox.phoneNumbers.searchTranscripts(num.id, { q: "refund", party: "remote" }); +for (const t of hits) { + console.log(`[${t.party}] ${t.text}`); +} + +// Release a number +await inkbox.phoneNumbers.release({ number: num.number }); +``` + +--- + +## Webhooks + +Webhooks are configured on the mailbox or phone number resource — no separate registration step. + +### Mailbox webhooks + +Set a URL on a mailbox to receive `message.received` and `message.sent` events. + +```python +# Python +inkbox.mailboxes.update("abc@inkboxmail.com", webhook_url="https://example.com/hook") +# Remove: +inkbox.mailboxes.update("abc@inkboxmail.com", webhook_url=None) +``` + +```ts +// TypeScript +await inkbox.mailboxes.update("abc@inkboxmail.com", { webhookUrl: "https://example.com/hook" }); +// Remove: +await inkbox.mailboxes.update("abc@inkboxmail.com", { webhookUrl: null }); +``` + +### Phone webhooks + +Set an incoming call webhook URL and action on a phone number. + +```python +# Python — route incoming calls to a webhook +inkbox.phone_numbers.update( + number.id, + incoming_call_action="webhook", + incoming_call_webhook_url="https://example.com/calls", +) +``` + +```ts +// TypeScript — route incoming calls to a webhook +await inkbox.phoneNumbers.update(number.id, { + incomingCallAction: "webhook", + incomingCallWebhookUrl: "https://example.com/calls", +}); +``` + +You can also supply a per-call webhook URL when placing a call: + +```python +# Python +identity.place_call(to_number="+15005550006", webhook_url="https://example.com/call-events") +``` + +```ts +// TypeScript +await identity.placeCall({ toNumber: "+15005550006", webhookUrl: "https://example.com/call-events" }); ``` --- diff --git a/python/README.md b/python/README.md index 8d5f3b0..b6bc1d7 100644 --- a/python/README.md +++ b/python/README.md @@ -168,9 +168,7 @@ async def mail_hook(request: Request): raw_body = await request.body() if not verify_webhook( payload=raw_body, - signature=request.headers["X-Inkbox-Signature"], - request_id=request.headers["X-Inkbox-Request-ID"], - timestamp=request.headers["X-Inkbox-Timestamp"], + headers=request.headers, secret="whsec_...", ): raise HTTPException(status_code=403) @@ -182,9 +180,7 @@ def mail_hook(): raw_body = request.get_data() if not verify_webhook( payload=raw_body, - signature=request.headers["X-Inkbox-Signature"], - request_id=request.headers["X-Inkbox-Request-ID"], - timestamp=request.headers["X-Inkbox-Timestamp"], + headers=request.headers, secret="whsec_...", ): abort(403) diff --git a/python/inkbox/signing_keys.py b/python/inkbox/signing_keys.py index 7540c47..df57927 100644 --- a/python/inkbox/signing_keys.py +++ b/python/inkbox/signing_keys.py @@ -10,7 +10,7 @@ import hmac from dataclasses import dataclass from datetime import datetime -from typing import Any +from typing import Any, Mapping @dataclass @@ -34,23 +34,23 @@ def _from_dict(cls, d: dict[str, Any]) -> SigningKey: def verify_webhook( *, payload: bytes, - signature: str, - request_id: str, - timestamp: str, + headers: Mapping[str, str], secret: str, ) -> bool: """Verify that an incoming webhook request was sent by Inkbox. Args: - payload: Raw request body bytes (do not parse/re-serialize). - signature: Value of the ``X-Inkbox-Signature`` header. - request_id: Value of the ``X-Inkbox-Request-ID`` header. - timestamp: Value of the ``X-Inkbox-Timestamp`` header. - secret: Your signing key, with or without a ``whsec_`` prefix. + payload: Raw request body bytes (do not parse/re-serialize). + headers: Request headers mapping (keys are lowercased internally). + secret: Your signing key, with or without a ``whsec_`` prefix. Returns: True if the signature is valid, False otherwise. """ + h = {k.lower(): v for k, v in headers.items()} + signature = h.get("x-inkbox-signature", "") + request_id = h.get("x-inkbox-request-id", "") + timestamp = h.get("x-inkbox-timestamp", "") if not signature.startswith("sha256="): return False key = secret.removeprefix("whsec_") diff --git a/python/tests/test_signing_keys.py b/python/tests/test_signing_keys.py index 18c04c6..d8aba59 100644 --- a/python/tests/test_signing_keys.py +++ b/python/tests/test_signing_keys.py @@ -51,73 +51,52 @@ class TestVerifyWebhook: TIMESTAMP = "1741737600" BODY = b'{"event":"message.received"}' + def _headers(self, sig: str) -> dict[str, str]: + return { + "X-Inkbox-Signature": sig, + "X-Inkbox-Request-ID": self.REQUEST_ID, + "X-Inkbox-Timestamp": self.TIMESTAMP, + } + def test_valid_signature(self): sig = _make_signature(self.KEY, self.REQUEST_ID, self.TIMESTAMP, self.BODY) - assert verify_webhook( - payload=self.BODY, - signature=sig, - request_id=self.REQUEST_ID, - timestamp=self.TIMESTAMP, - secret=self.KEY, - ) + assert verify_webhook(payload=self.BODY, headers=self._headers(sig), secret=self.KEY) def test_valid_signature_with_whsec_prefix(self): sig = _make_signature(self.KEY, self.REQUEST_ID, self.TIMESTAMP, self.BODY) - assert verify_webhook( - payload=self.BODY, - signature=sig, - request_id=self.REQUEST_ID, - timestamp=self.TIMESTAMP, - secret=f"whsec_{self.KEY}", - ) + assert verify_webhook(payload=self.BODY, headers=self._headers(sig), secret=f"whsec_{self.KEY}") + + def test_headers_are_case_insensitive(self): + sig = _make_signature(self.KEY, self.REQUEST_ID, self.TIMESTAMP, self.BODY) + lowercase_headers = { + "x-inkbox-signature": sig, + "x-inkbox-request-id": self.REQUEST_ID, + "x-inkbox-timestamp": self.TIMESTAMP, + } + assert verify_webhook(payload=self.BODY, headers=lowercase_headers, secret=self.KEY) def test_wrong_key_returns_false(self): sig = _make_signature("wrong-key", self.REQUEST_ID, self.TIMESTAMP, self.BODY) - assert not verify_webhook( - payload=self.BODY, - signature=sig, - request_id=self.REQUEST_ID, - timestamp=self.TIMESTAMP, - secret=self.KEY, - ) + assert not verify_webhook(payload=self.BODY, headers=self._headers(sig), secret=self.KEY) def test_tampered_body_returns_false(self): sig = _make_signature(self.KEY, self.REQUEST_ID, self.TIMESTAMP, self.BODY) - assert not verify_webhook( - payload=b'{"event":"message.sent"}', - signature=sig, - request_id=self.REQUEST_ID, - timestamp=self.TIMESTAMP, - secret=self.KEY, - ) + assert not verify_webhook(payload=b'{"event":"message.sent"}', headers=self._headers(sig), secret=self.KEY) def test_wrong_request_id_returns_false(self): sig = _make_signature(self.KEY, self.REQUEST_ID, self.TIMESTAMP, self.BODY) - assert not verify_webhook( - payload=self.BODY, - signature=sig, - request_id="different-id", - timestamp=self.TIMESTAMP, - secret=self.KEY, - ) + headers = {**self._headers(sig), "X-Inkbox-Request-ID": "different-id"} + assert not verify_webhook(payload=self.BODY, headers=headers, secret=self.KEY) def test_wrong_timestamp_returns_false(self): sig = _make_signature(self.KEY, self.REQUEST_ID, self.TIMESTAMP, self.BODY) - assert not verify_webhook( - payload=self.BODY, - signature=sig, - request_id=self.REQUEST_ID, - timestamp="9999999999", - secret=self.KEY, - ) + headers = {**self._headers(sig), "X-Inkbox-Timestamp": "9999999999"} + assert not verify_webhook(payload=self.BODY, headers=headers, secret=self.KEY) def test_missing_sha256_prefix_returns_false(self): sig = _make_signature(self.KEY, self.REQUEST_ID, self.TIMESTAMP, self.BODY) bare_hex = sig.removeprefix("sha256=") - assert not verify_webhook( - payload=self.BODY, - signature=bare_hex, - request_id=self.REQUEST_ID, - timestamp=self.TIMESTAMP, - secret=self.KEY, - ) + assert not verify_webhook(payload=self.BODY, headers=self._headers(bare_hex), secret=self.KEY) + + def test_missing_headers_returns_false(self): + assert not verify_webhook(payload=self.BODY, headers={}, secret=self.KEY) diff --git a/typescript/README.md b/typescript/README.md index 4e7f327..16bc219 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -171,9 +171,7 @@ import { verifyWebhook } from "@inkbox/sdk"; app.post("/hooks/mail", express.raw({ type: "*/*" }), (req, res) => { const valid = verifyWebhook({ payload: req.body, - signature: req.headers["x-inkbox-signature"] as string, - requestId: req.headers["x-inkbox-request-id"] as string, - timestamp: req.headers["x-inkbox-timestamp"] as string, + headers: req.headers, secret: "whsec_...", }); if (!valid) return res.status(403).end(); diff --git a/typescript/src/signing_keys.ts b/typescript/src/signing_keys.ts index 70b62a1..430c817 100644 --- a/typescript/src/signing_keys.ts +++ b/typescript/src/signing_keys.ts @@ -30,26 +30,27 @@ function parseSigningKey(r: RawSigningKey): SigningKey { /** * Verify that an incoming webhook request was sent by Inkbox. * - * @param payload - Raw request body as a Buffer or string. - * @param signature - Value of the `X-Inkbox-Signature` header. - * @param requestId - Value of the `X-Inkbox-Request-ID` header. - * @param timestamp - Value of the `X-Inkbox-Timestamp` header. - * @param secret - Your signing key, with or without a `whsec_` prefix. + * @param payload - Raw request body as a Buffer or string. + * @param headers - Request headers object (keys are lowercased internally). + * @param secret - Your signing key, with or without a `whsec_` prefix. * @returns True if the signature is valid. */ export function verifyWebhook({ payload, - signature, - requestId, - timestamp, + headers, secret, }: { payload: Buffer | string; - signature: string; - requestId: string; - timestamp: string; + headers: Record; secret: string; }): boolean { + const h: Record = {}; + for (const [k, v] of Object.entries(headers)) { + h[k.toLowerCase()] = Array.isArray(v) ? v[0] : v; + } + const signature = h["x-inkbox-signature"] ?? ""; + const requestId = h["x-inkbox-request-id"] ?? ""; + const timestamp = h["x-inkbox-timestamp"] ?? ""; if (!signature.startsWith("sha256=")) return false; const key = secret.startsWith("whsec_") ? secret.slice("whsec_".length) : secret; const body = typeof payload === "string" ? Buffer.from(payload) : payload; diff --git a/typescript/tests/signing-keys.test.ts b/typescript/tests/signing-keys.test.ts index 0238838..863795f 100644 --- a/typescript/tests/signing-keys.test.ts +++ b/typescript/tests/signing-keys.test.ts @@ -21,45 +21,67 @@ function makeResource() { return { resource, http: http as { post: ReturnType } }; } +function makeHeaders(sig: string): Record { + return { + "x-inkbox-signature": sig, + "x-inkbox-request-id": TEST_REQUEST_ID, + "x-inkbox-timestamp": TEST_TIMESTAMP, + }; +} + describe("verifyWebhook", () => { it("returns true for a valid signature", () => { const sig = makeSignature(TEST_KEY, TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY); - expect(verifyWebhook({ payload: TEST_BODY, signature: sig, requestId: TEST_REQUEST_ID, timestamp: TEST_TIMESTAMP, secret: TEST_KEY })).toBe(true); + expect(verifyWebhook({ payload: TEST_BODY, headers: makeHeaders(sig), secret: TEST_KEY })).toBe(true); }); it("accepts whsec_ prefixed secret", () => { const sig = makeSignature(TEST_KEY, TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY); - expect(verifyWebhook({ payload: TEST_BODY, signature: sig, requestId: TEST_REQUEST_ID, timestamp: TEST_TIMESTAMP, secret: `whsec_${TEST_KEY}` })).toBe(true); + expect(verifyWebhook({ payload: TEST_BODY, headers: makeHeaders(sig), secret: `whsec_${TEST_KEY}` })).toBe(true); }); it("accepts string payload", () => { const sig = makeSignature(TEST_KEY, TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY); - expect(verifyWebhook({ payload: TEST_BODY.toString(), signature: sig, requestId: TEST_REQUEST_ID, timestamp: TEST_TIMESTAMP, secret: TEST_KEY })).toBe(true); + expect(verifyWebhook({ payload: TEST_BODY.toString(), headers: makeHeaders(sig), secret: TEST_KEY })).toBe(true); + }); + + it("normalizes header casing", () => { + const sig = makeSignature(TEST_KEY, TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY); + const uppercaseHeaders = { + "X-Inkbox-Signature": sig, + "X-Inkbox-Request-ID": TEST_REQUEST_ID, + "X-Inkbox-Timestamp": TEST_TIMESTAMP, + }; + expect(verifyWebhook({ payload: TEST_BODY, headers: uppercaseHeaders, secret: TEST_KEY })).toBe(true); }); it("returns false for wrong key", () => { const sig = makeSignature("wrong-key", TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY); - expect(verifyWebhook({ payload: TEST_BODY, signature: sig, requestId: TEST_REQUEST_ID, timestamp: TEST_TIMESTAMP, secret: TEST_KEY })).toBe(false); + expect(verifyWebhook({ payload: TEST_BODY, headers: makeHeaders(sig), secret: TEST_KEY })).toBe(false); }); it("returns false for tampered body", () => { const sig = makeSignature(TEST_KEY, TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY); - expect(verifyWebhook({ payload: Buffer.from('{"event":"message.sent"}'), signature: sig, requestId: TEST_REQUEST_ID, timestamp: TEST_TIMESTAMP, secret: TEST_KEY })).toBe(false); + expect(verifyWebhook({ payload: Buffer.from('{"event":"message.sent"}'), headers: makeHeaders(sig), secret: TEST_KEY })).toBe(false); }); it("returns false for wrong requestId", () => { const sig = makeSignature(TEST_KEY, TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY); - expect(verifyWebhook({ payload: TEST_BODY, signature: sig, requestId: "different-id", timestamp: TEST_TIMESTAMP, secret: TEST_KEY })).toBe(false); + expect(verifyWebhook({ payload: TEST_BODY, headers: { ...makeHeaders(sig), "x-inkbox-request-id": "different-id" }, secret: TEST_KEY })).toBe(false); }); it("returns false for wrong timestamp", () => { const sig = makeSignature(TEST_KEY, TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY); - expect(verifyWebhook({ payload: TEST_BODY, signature: sig, requestId: TEST_REQUEST_ID, timestamp: "9999999999", secret: TEST_KEY })).toBe(false); + expect(verifyWebhook({ payload: TEST_BODY, headers: { ...makeHeaders(sig), "x-inkbox-timestamp": "9999999999" }, secret: TEST_KEY })).toBe(false); }); it("returns false when sha256= prefix is missing", () => { const sig = makeSignature(TEST_KEY, TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY).slice("sha256=".length); - expect(verifyWebhook({ payload: TEST_BODY, signature: sig, requestId: TEST_REQUEST_ID, timestamp: TEST_TIMESTAMP, secret: TEST_KEY })).toBe(false); + expect(verifyWebhook({ payload: TEST_BODY, headers: makeHeaders(sig), secret: TEST_KEY })).toBe(false); + }); + + it("returns false when headers are missing", () => { + expect(verifyWebhook({ payload: TEST_BODY, headers: {}, secret: TEST_KEY })).toBe(false); }); }); From 4eee438fac97cedb7e4c35b7ba08f70d4bae505f Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:40:25 -0400 Subject: [PATCH 51/56] rm examples --- examples/python/agent_send_email.py | 30 ---------------- examples/python/create_agent_mailbox.py | 30 ---------------- examples/python/create_agent_phone_number.py | 30 ---------------- examples/python/list_agent_phone_numbers.py | 16 --------- examples/python/read_agent_calls.py | 21 ----------- examples/python/read_agent_messages.py | 33 ----------------- examples/python/receive_agent_call_webhook.py | 32 ----------------- .../python/receive_agent_email_webhook.py | 30 ---------------- examples/python/register_agent_identity.py | 31 ---------------- examples/typescript/agent-send-email.ts | 29 --------------- examples/typescript/create-agent-mailbox.ts | 32 ----------------- .../typescript/create-agent-phone-number.ts | 34 ------------------ .../typescript/list-agent-phone-numbers.ts | 16 --------- examples/typescript/read-agent-calls.ts | 22 ------------ examples/typescript/read-agent-messages.ts | 35 ------------------- .../typescript/receive-agent-call-webhook.ts | 28 --------------- .../typescript/receive-agent-email-webhook.ts | 29 --------------- .../typescript/register-agent-identity.ts | 33 ----------------- 18 files changed, 511 deletions(-) delete mode 100644 examples/python/agent_send_email.py delete mode 100644 examples/python/create_agent_mailbox.py delete mode 100644 examples/python/create_agent_phone_number.py delete mode 100644 examples/python/list_agent_phone_numbers.py delete mode 100644 examples/python/read_agent_calls.py delete mode 100644 examples/python/read_agent_messages.py delete mode 100644 examples/python/receive_agent_call_webhook.py delete mode 100644 examples/python/receive_agent_email_webhook.py delete mode 100644 examples/python/register_agent_identity.py delete mode 100644 examples/typescript/agent-send-email.ts delete mode 100644 examples/typescript/create-agent-mailbox.ts delete mode 100644 examples/typescript/create-agent-phone-number.ts delete mode 100644 examples/typescript/list-agent-phone-numbers.ts delete mode 100644 examples/typescript/read-agent-calls.ts delete mode 100644 examples/typescript/read-agent-messages.ts delete mode 100644 examples/typescript/receive-agent-call-webhook.ts delete mode 100644 examples/typescript/receive-agent-email-webhook.ts delete mode 100644 examples/typescript/register-agent-identity.ts diff --git a/examples/python/agent_send_email.py b/examples/python/agent_send_email.py deleted file mode 100644 index c0453f7..0000000 --- a/examples/python/agent_send_email.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -Send an email (and reply) from an Inkbox agent identity. - -Usage: - INKBOX_API_KEY=ApiKey_... AGENT_HANDLE=sales-agent python agent_send_email.py -""" - -import os -from inkbox import Inkbox - -inkbox = Inkbox(api_key=os.environ["INKBOX_API_KEY"]) -identity = inkbox.get_identity(os.environ["AGENT_HANDLE"]) - -# Agent sends outbound email -sent = identity.send_email( - to=["recipient@example.com"], - subject="Hello from your AI sales agent", - body_text="Hi there! I'm your AI sales agent reaching out via Inkbox.", - body_html="

Hi there! I'm your AI sales agent reaching out via Inkbox.

", -) -print(f"Sent message {sent.id} subject={sent.subject!r}") - -# Agent sends threaded reply -reply = identity.send_email( - to=["recipient@example.com"], - subject=f"Re: {sent.subject}", - body_text="Following up as your AI sales agent.", - in_reply_to_message_id=str(sent.id), -) -print(f"Sent reply {reply.id}") diff --git a/examples/python/create_agent_mailbox.py b/examples/python/create_agent_mailbox.py deleted file mode 100644 index 4dc8d93..0000000 --- a/examples/python/create_agent_mailbox.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -Create and manage a mailbox via an agent identity. - -Usage: - INKBOX_API_KEY=ApiKey_... python create_agent_mailbox.py -""" - -import os -from inkbox import Inkbox - -with Inkbox(api_key=os.environ["INKBOX_API_KEY"]) as inkbox: - # Create an identity and assign a mailbox in one call - agent = inkbox.create_identity("sales-agent") - mailbox = agent.assign_mailbox(display_name="Sales Agent") - print(f"Mailbox created: {mailbox.email_address} display_name={mailbox.display_name!r}") - - # Update display name - updated = inkbox._mailboxes.update(mailbox.email_address, display_name="Sales Agent (updated)") - print(f"\nUpdated display_name: {updated.display_name}") - - # Full-text search - results = inkbox._mailboxes.search(mailbox.email_address, q="hello") - print(f'\nSearch results for "hello": {len(results)} messages') - - # Unlink mailbox from identity, then delete it - agent.unlink_mailbox() - inkbox._mailboxes.delete(mailbox.email_address) - print("Mailbox deleted.") - - agent.delete() diff --git a/examples/python/create_agent_phone_number.py b/examples/python/create_agent_phone_number.py deleted file mode 100644 index 40a3bb9..0000000 --- a/examples/python/create_agent_phone_number.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -Provision, update, and release a phone number via an agent identity. - -Usage: - INKBOX_API_KEY=ApiKey_... python create_agent_phone_number.py - INKBOX_API_KEY=ApiKey_... NUMBER_TYPE=local STATE=NY python create_agent_phone_number.py -""" - -import os -from inkbox import Inkbox - -inkbox = Inkbox(api_key=os.environ["INKBOX_API_KEY"]) -number_type = os.environ.get("NUMBER_TYPE", "toll_free") -state = os.environ.get("STATE") - -# Create an identity and provision + assign a phone number in one call -agent = inkbox.create_identity("sales-agent") -phone = agent.assign_phone_number(type=number_type, state=state) -print(f"Agent phone number provisioned: {phone.number} type={phone.type} status={phone.status}") - -# Update incoming call action -updated = inkbox._numbers.update(phone.id, incoming_call_action="auto_accept") -print(f"\nUpdated incoming_call_action: {updated.incoming_call_action}") - -# Unlink phone number from identity, then release it -agent.unlink_phone_number() -inkbox._numbers.release(number=phone.number) -print("Agent phone number released.") - -agent.delete() diff --git a/examples/python/list_agent_phone_numbers.py b/examples/python/list_agent_phone_numbers.py deleted file mode 100644 index cb41116..0000000 --- a/examples/python/list_agent_phone_numbers.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -List all phone numbers attached to your Inkbox account. - -Usage: - INKBOX_API_KEY=ApiKey_... python list_agent_phone_numbers.py -""" - -import os -from inkbox import Inkbox - -inkbox = Inkbox(api_key=os.environ["INKBOX_API_KEY"]) - -numbers = inkbox._numbers.list() - -for n in numbers: - print(f"{n.number} type={n.type} status={n.status}") diff --git a/examples/python/read_agent_calls.py b/examples/python/read_agent_calls.py deleted file mode 100644 index e8274b6..0000000 --- a/examples/python/read_agent_calls.py +++ /dev/null @@ -1,21 +0,0 @@ -""" -List recent calls and their transcripts for an agent identity. - -Usage: - INKBOX_API_KEY=ApiKey_... AGENT_HANDLE=sales-agent python read_agent_calls.py -""" - -import os -from inkbox import Inkbox - -inkbox = Inkbox(api_key=os.environ["INKBOX_API_KEY"]) -identity = inkbox.get_identity(os.environ["AGENT_HANDLE"]) - -calls = identity.list_calls(limit=10) - -for call in calls: - print(f"\n{call.id} {call.direction} {call.remote_phone_number} status={call.status}") - - transcripts = identity.list_transcripts(call.id) - for t in transcripts: - print(f" [{t.party}] {t.text}") diff --git a/examples/python/read_agent_messages.py b/examples/python/read_agent_messages.py deleted file mode 100644 index ef4b7cd..0000000 --- a/examples/python/read_agent_messages.py +++ /dev/null @@ -1,33 +0,0 @@ -""" -List messages and threads in an agent's mailbox, and read a full thread. - -Usage: - INKBOX_API_KEY=ApiKey_... AGENT_HANDLE=sales-agent python read_agent_messages.py -""" - -import os -from inkbox import Inkbox - -inkbox = Inkbox(api_key=os.environ["INKBOX_API_KEY"]) -identity = inkbox.get_identity(os.environ["AGENT_HANDLE"]) - -# List the 5 most recent messages -print("=== Agent inbox ===") -for i, msg in enumerate(identity.iter_emails()): - print(f"{msg.id} {msg.subject} from={msg.from_address} read={msg.is_read}") - if i >= 4: - break - -# List threads and fetch the first one in full -print("\n=== Agent threads ===") -first_thread_id = None -for thread in inkbox._threads.list(identity.mailbox.email_address): - print(f"{thread.id} {thread.subject!r} messages={thread.message_count}") - if first_thread_id is None: - first_thread_id = thread.id - -if first_thread_id: - thread = inkbox._threads.get(identity.mailbox.email_address, first_thread_id) - print(f"\nAgent conversation: {thread.subject!r} ({len(thread.messages)} messages)") - for msg in thread.messages: - print(f" [{msg.from_address}] {msg.subject}") diff --git a/examples/python/receive_agent_call_webhook.py b/examples/python/receive_agent_call_webhook.py deleted file mode 100644 index dc08c95..0000000 --- a/examples/python/receive_agent_call_webhook.py +++ /dev/null @@ -1,32 +0,0 @@ -""" -Create, update, and delete a webhook on an agent's phone number. - -Usage: - INKBOX_API_KEY=ApiKey_... AGENT_HANDLE=sales-agent python receive_agent_call_webhook.py -""" - -import os -from inkbox import Inkbox - -inkbox = Inkbox(api_key=os.environ["INKBOX_API_KEY"]) -identity = inkbox.get_identity(os.environ["AGENT_HANDLE"]) - -# Register webhook for agent phone number -hook = inkbox._phone_webhooks.create( - identity.phone_number.id, - url="https://example.com/webhook", - event_types=["incoming_call"], -) -print(f"Registered agent phone webhook {hook.id} secret={hook.secret}") - -# Update agent phone webhook -updated = inkbox._phone_webhooks.update( - identity.phone_number.id, - hook.id, - url="https://example.com/webhook-v2", -) -print(f"Updated URL: {updated.url}") - -# Remove agent phone webhook -inkbox._phone_webhooks.delete(identity.phone_number.id, hook.id) -print("Agent phone webhook removed.") diff --git a/examples/python/receive_agent_email_webhook.py b/examples/python/receive_agent_email_webhook.py deleted file mode 100644 index 6849de3..0000000 --- a/examples/python/receive_agent_email_webhook.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -Register and delete a webhook on an agent's mailbox. - -Usage: - INKBOX_API_KEY=ApiKey_... AGENT_HANDLE=sales-agent python receive_agent_email_webhook.py -""" - -import os -from inkbox import Inkbox - -inkbox = Inkbox(api_key=os.environ["INKBOX_API_KEY"]) -identity = inkbox.get_identity(os.environ["AGENT_HANDLE"]) - -# Register webhook for agent mailbox -hook = inkbox._mail_webhooks.create( - identity.mailbox.id, - url="https://example.com/webhook", - event_types=["message.received", "message.sent"], -) -print(f"Registered agent mailbox webhook {hook.id} secret={hook.secret}") - -# List -all_hooks = inkbox._mail_webhooks.list(identity.mailbox.id) -print(f"Active agent mailbox webhooks: {len(all_hooks)}") -for w in all_hooks: - print(f" {w.id} url={w.url} events={', '.join(w.event_types)}") - -# Remove agent mailbox webhook -inkbox._mail_webhooks.delete(identity.mailbox.id, hook.id) -print("Agent mailbox webhook removed.") diff --git a/examples/python/register_agent_identity.py b/examples/python/register_agent_identity.py deleted file mode 100644 index d7de82f..0000000 --- a/examples/python/register_agent_identity.py +++ /dev/null @@ -1,31 +0,0 @@ -""" -Create an agent identity and assign communication channels to it. - -Usage: - INKBOX_API_KEY=ApiKey_... python register_agent_identity.py -""" - -import os -from inkbox import Inkbox - -with Inkbox(api_key=os.environ["INKBOX_API_KEY"]) as inkbox: - # Create agent identity — returns an AgentIdentity object - agent = inkbox.create_identity("sales-agent") - print(f"Registered agent: {agent.agent_handle} (id={agent.id})") - - # Provision and assign channels in one call each - mailbox = agent.assign_mailbox(display_name="Sales Agent") - print(f"Assigned mailbox: {mailbox.email_address}") - - phone = agent.assign_phone_number(type="toll_free") - print(f"Assigned phone: {phone.number}") - - # List all identities - all_identities = inkbox.list_identities() - print(f"\nAll identities ({len(all_identities)}):") - for ident in all_identities: - print(f" {ident.agent_handle} status={ident.status}") - - # Unregister agent (unlinks channels without deleting them) - agent.delete() - print("\nUnregistered agent sales-agent.") diff --git a/examples/typescript/agent-send-email.ts b/examples/typescript/agent-send-email.ts deleted file mode 100644 index 4c211a1..0000000 --- a/examples/typescript/agent-send-email.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Send an email (and reply) from an Inkbox agent identity. - * - * Usage: - * INKBOX_API_KEY=ApiKey_... AGENT_HANDLE=sales-agent npx ts-node agent-send-email.ts - */ - -import { Inkbox } from "../../typescript/src/inkbox.js"; - -const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); -const identity = await inkbox.getIdentity(process.env.AGENT_HANDLE!); - -// Agent sends outbound email -const sent = await identity.sendEmail({ - to: ["recipient@example.com"], - subject: "Hello from your AI sales agent", - bodyText: "Hi there! I'm your AI sales agent reaching out via Inkbox.", - bodyHtml: "

Hi there! I'm your AI sales agent reaching out via Inkbox.

", -}); -console.log(`Sent message ${sent.id} subject="${sent.subject}"`); - -// Agent sends threaded reply -const reply = await identity.sendEmail({ - to: ["recipient@example.com"], - subject: `Re: ${sent.subject}`, - bodyText: "Following up as your AI sales agent.", - inReplyToMessageId: sent.id, -}); -console.log(`Sent reply ${reply.id}`); diff --git a/examples/typescript/create-agent-mailbox.ts b/examples/typescript/create-agent-mailbox.ts deleted file mode 100644 index 5152e0b..0000000 --- a/examples/typescript/create-agent-mailbox.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Create and manage a mailbox via an agent identity. - * - * Usage: - * INKBOX_API_KEY=ApiKey_... npx ts-node create-agent-mailbox.ts - */ - -import { Inkbox } from "../../typescript/src/inkbox.js"; - -const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); - -// Create an identity and assign a mailbox in one call -const agent = await inkbox.createIdentity("sales-agent"); -const mailbox = await agent.assignMailbox({ displayName: "Sales Agent" }); -console.log(`Agent mailbox created: ${mailbox.emailAddress} displayName="${mailbox.displayName}"`); - -// Update display name -const updated = await inkbox._mailboxes.update(mailbox.emailAddress, { - displayName: "Sales Agent (updated)", -}); -console.log(`\nUpdated displayName: ${updated.displayName}`); - -// Full-text search -const results = await inkbox._mailboxes.search(mailbox.emailAddress, { q: "hello" }); -console.log(`\nSearch results for "hello": ${results.length} messages`); - -// Unlink mailbox from identity, then delete it -await agent.unlinkMailbox(); -await inkbox._mailboxes.delete(mailbox.emailAddress); -console.log("Agent mailbox deleted."); - -await agent.delete(); diff --git a/examples/typescript/create-agent-phone-number.ts b/examples/typescript/create-agent-phone-number.ts deleted file mode 100644 index 9a5a116..0000000 --- a/examples/typescript/create-agent-phone-number.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Provision, update, and release a phone number via an agent identity. - * - * Usage: - * INKBOX_API_KEY=ApiKey_... npx ts-node create-agent-phone-number.ts - * INKBOX_API_KEY=ApiKey_... NUMBER_TYPE=local STATE=NY npx ts-node create-agent-phone-number.ts - */ - -import { Inkbox } from "../../typescript/src/inkbox.js"; - -const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); -const numberType = process.env.NUMBER_TYPE ?? "toll_free"; -const state = process.env.STATE; - -// Create an identity and provision + assign a phone number in one call -const agent = await inkbox.createIdentity("sales-agent"); -const phone = await agent.assignPhoneNumber({ - type: numberType, - ...(state ? { state } : {}), -}); -console.log(`Agent phone number provisioned: ${phone.number} type=${phone.type} status=${phone.status}`); - -// Update incoming call action -const updated = await inkbox._numbers.update(phone.id, { - incomingCallAction: "auto_accept", -}); -console.log(`\nUpdated incomingCallAction: ${updated.incomingCallAction}`); - -// Unlink phone number from identity, then release it -await agent.unlinkPhoneNumber(); -await inkbox._numbers.release({ number: phone.number }); -console.log("Agent phone number released."); - -await agent.delete(); diff --git a/examples/typescript/list-agent-phone-numbers.ts b/examples/typescript/list-agent-phone-numbers.ts deleted file mode 100644 index d835362..0000000 --- a/examples/typescript/list-agent-phone-numbers.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * List all phone numbers attached to your Inkbox account. - * - * Usage: - * INKBOX_API_KEY=ApiKey_... npx ts-node list-agent-phone-numbers.ts - */ - -import { Inkbox } from "../../typescript/src/inkbox.js"; - -const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); - -const numbers = await inkbox._numbers.list(); - -for (const n of numbers) { - console.log(`${n.number} type=${n.type} status=${n.status}`); -} diff --git a/examples/typescript/read-agent-calls.ts b/examples/typescript/read-agent-calls.ts deleted file mode 100644 index df01007..0000000 --- a/examples/typescript/read-agent-calls.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * List recent calls and their transcripts for an agent identity. - * - * Usage: - * INKBOX_API_KEY=ApiKey_... AGENT_HANDLE=sales-agent npx ts-node read-agent-calls.ts - */ - -import { Inkbox } from "../../typescript/src/inkbox.js"; - -const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); -const identity = await inkbox.getIdentity(process.env.AGENT_HANDLE!); - -const calls = await identity.listCalls({ limit: 10 }); - -for (const call of calls) { - console.log(`\n${call.id} ${call.direction} ${call.remotePhoneNumber} status=${call.status}`); - - const transcripts = await identity.listTranscripts(call.id); - for (const t of transcripts) { - console.log(` [${t.party}] ${t.text}`); - } -} diff --git a/examples/typescript/read-agent-messages.ts b/examples/typescript/read-agent-messages.ts deleted file mode 100644 index 6351201..0000000 --- a/examples/typescript/read-agent-messages.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * List messages and threads in an agent's mailbox, and read a full thread. - * - * Usage: - * INKBOX_API_KEY=ApiKey_... AGENT_HANDLE=sales-agent npx ts-node read-agent-messages.ts - */ - -import { Inkbox } from "../../typescript/src/inkbox.js"; - -const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); -const identity = await inkbox.getIdentity(process.env.AGENT_HANDLE!); - -// List the 5 most recent messages -console.log("=== Agent inbox ==="); -let count = 0; -for await (const msg of identity.iterEmails()) { - console.log(`${msg.id} ${msg.subject} from=${msg.fromAddress} read=${msg.isRead}`); - if (++count >= 5) break; -} - -// List threads and fetch the first one in full -console.log("\n=== Agent threads ==="); -let firstThreadId: string | undefined; -for await (const thread of inkbox._threads.list(identity.mailbox!.emailAddress)) { - console.log(`${thread.id} "${thread.subject}" messages=${thread.messageCount}`); - firstThreadId ??= thread.id; -} - -if (firstThreadId) { - const thread = await inkbox._threads.get(identity.mailbox!.emailAddress, firstThreadId); - console.log(`\nAgent conversation: "${thread.subject}" (${thread.messages.length} messages)`); - for (const msg of thread.messages) { - console.log(` [${msg.fromAddress}] ${msg.subject}`); - } -} diff --git a/examples/typescript/receive-agent-call-webhook.ts b/examples/typescript/receive-agent-call-webhook.ts deleted file mode 100644 index b0cc39d..0000000 --- a/examples/typescript/receive-agent-call-webhook.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Create, update, and delete a webhook on an agent's phone number. - * - * Usage: - * INKBOX_API_KEY=ApiKey_... AGENT_HANDLE=sales-agent npx ts-node receive-agent-call-webhook.ts - */ - -import { Inkbox } from "../../typescript/src/inkbox.js"; - -const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); -const identity = await inkbox.getIdentity(process.env.AGENT_HANDLE!); - -// Register webhook for agent phone number -const hook = await inkbox._phoneWebhooks.create(identity.phoneNumber!.id, { - url: "https://example.com/webhook", - eventTypes: ["incoming_call"], -}); -console.log(`Registered agent phone webhook ${hook.id} secret=${hook.secret}`); - -// Update agent phone webhook -const updated = await inkbox._phoneWebhooks.update(identity.phoneNumber!.id, hook.id, { - url: "https://example.com/webhook-v2", -}); -console.log(`Updated URL: ${updated.url}`); - -// Remove agent phone webhook -await inkbox._phoneWebhooks.delete(identity.phoneNumber!.id, hook.id); -console.log("Agent phone webhook removed."); diff --git a/examples/typescript/receive-agent-email-webhook.ts b/examples/typescript/receive-agent-email-webhook.ts deleted file mode 100644 index 895f020..0000000 --- a/examples/typescript/receive-agent-email-webhook.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Register and delete a webhook on an agent's mailbox. - * - * Usage: - * INKBOX_API_KEY=ApiKey_... AGENT_HANDLE=sales-agent npx ts-node receive-agent-email-webhook.ts - */ - -import { Inkbox } from "../../typescript/src/inkbox.js"; - -const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); -const identity = await inkbox.getIdentity(process.env.AGENT_HANDLE!); - -// Register webhook for agent mailbox -const hook = await inkbox._mailWebhooks.create(identity.mailbox!.id, { - url: "https://example.com/webhook", - eventTypes: ["message.received", "message.sent"], -}); -console.log(`Registered agent mailbox webhook ${hook.id} secret=${hook.secret}`); - -// List -const all = await inkbox._mailWebhooks.list(identity.mailbox!.id); -console.log(`Active agent mailbox webhooks: ${all.length}`); -for (const w of all) { - console.log(` ${w.id} url=${w.url} events=${w.eventTypes.join(", ")}`); -} - -// Remove agent mailbox webhook -await inkbox._mailWebhooks.delete(identity.mailbox!.id, hook.id); -console.log("Agent mailbox webhook removed."); diff --git a/examples/typescript/register-agent-identity.ts b/examples/typescript/register-agent-identity.ts deleted file mode 100644 index c0850b6..0000000 --- a/examples/typescript/register-agent-identity.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Create an agent identity and provision communication channels for it. - * - * Usage: - * INKBOX_API_KEY=ApiKey_... npx ts-node register-agent-identity.ts - */ - -import { Inkbox } from "../../typescript/src/inkbox.js"; - -const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); - -// Register agent identity — returns an AgentIdentity object -const agent = await inkbox.createIdentity("sales-agent"); -console.log(`Registered agent: ${agent.agentHandle} (id=${agent.id})`); - -// Provision and link a mailbox -const mailbox = await agent.assignMailbox({ displayName: "Sales Agent" }); -console.log(`Assigned mailbox: ${mailbox.emailAddress}`); - -// Provision and link a phone number -const phone = await agent.assignPhoneNumber({ type: "toll_free" }); -console.log(`Assigned phone: ${phone.number}`); - -// List all identities -const all = await inkbox.listIdentities(); -console.log(`\nAll identities (${all.length}):`); -for (const id of all) { - console.log(` ${id.agentHandle} status=${id.status}`); -} - -// Unregister agent -await agent.delete(); -console.log("\nUnregistered agent sales-agent."); From 6aaf350b517a4f3c7bf125e528d3b2c485fe3227 Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:42:22 -0400 Subject: [PATCH 52/56] update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c19c7f0..2767a85 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Inkbox SDK -Official SDKs for the [Inkbox API](https://inkbox.ai) — API-first communication infrastructure for AI agents (email, phone, identities). +Official SDKs for the [Inkbox API](https://inkbox.ai) — API-first communication infrastructure for AI agents (identities, email, phone). | Package | Language | Install | |---|---|---| From 05f890f424477ec2a404cd6da50cd77d6105eba7 Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:56:43 -0400 Subject: [PATCH 53/56] update readme --- python/README.md | 180 ++++++++++++++++++++++++++++++++++++++--- typescript/README.md | 189 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 346 insertions(+), 23 deletions(-) diff --git a/python/README.md b/python/README.md index b6bc1d7..ae857ee 100644 --- a/python/README.md +++ b/python/README.md @@ -34,7 +34,7 @@ with Inkbox(api_key=os.environ["INKBOX_API_KEY"]) as inkbox: # Place an outbound call identity.place_call( to_number="+18005559999", - stream_url="wss://my-app.com/voice", + client_websocket_url="wss://my-app.com/voice", ) # Read inbox @@ -70,6 +70,10 @@ phone = identity.provision_phone_number(type="toll_free") # provisions + print(mailbox.email_address) print(phone.number) +# Link an existing mailbox or phone number instead of creating new ones +identity.assign_mailbox("mailbox-uuid-here") +identity.assign_phone_number("phone-number-uuid-here") + # Get an existing identity identity = inkbox.get_identity("sales-bot") identity.refresh() # re-fetch channels from API @@ -94,19 +98,41 @@ identity.delete() ## Mail ```python -# Send an email -identity.send_email( +# Send an email (plain text and/or HTML) +sent = identity.send_email( to=["user@example.com"], - subject="Hello", + subject="Hello from Inkbox", body_text="Hi there!", body_html="

Hi there!

", + cc=["manager@example.com"], + bcc=["archive@example.com"], +) + +# Send a threaded reply +identity.send_email( + to=["user@example.com"], + subject=f"Re: {sent.subject}", + body_text="Following up!", + in_reply_to_message_id=sent.id, +) + +# Send with attachments +identity.send_email( + to=["user@example.com"], + subject="See attached", + body_text="Please find the file attached.", + attachments=[{ + "filename": "report.pdf", + "content_type": "application/pdf", + "content_base64": "", + }], ) # Iterate inbox (paginated automatically) for msg in identity.iter_emails(): - print(msg.subject, msg.from_address) + print(msg.subject, msg.from_address, msg.is_read) -# Filter by direction +# Filter by direction: "inbound" or "outbound" for msg in identity.iter_emails(direction="inbound"): print(msg.subject) @@ -128,19 +154,153 @@ for m in thread.messages: ## Phone ```python -# Place an outbound call +# Place an outbound call — stream audio over WebSocket call = identity.place_call( to_number="+15167251294", - stream_url="wss://your-agent.example.com/ws", + client_websocket_url="wss://your-agent.example.com/ws", ) print(call.status, call.rate_limit.calls_remaining) -# List calls -calls = identity.list_calls() +# Or receive call events via webhook instead +call = identity.place_call( + to_number="+15167251294", + webhook_url="https://your-agent.example.com/call-events", +) + +# List calls (paginated) calls = identity.list_calls(limit=10, offset=0) +for call in calls: + print(call.id, call.direction, call.remote_phone_number, call.status) # Fetch transcript segments for a call segments = identity.list_transcripts(calls[0].id) +for t in segments: + print(f"[{t.party}] {t.text}") # party: "local" or "remote" + +# Read transcripts across all recent calls +for call in identity.list_calls(limit=10): + segments = identity.list_transcripts(call.id) + if not segments: + continue + print(f"\n--- Call {call.id} ({call.direction}) ---") + for t in segments: + print(f" [{t.party:6}] {t.text}") + +# Filter to only the remote party's speech +for t in identity.list_transcripts(calls[0].id): + if t.party == "remote": + print(t.text) + +# Search transcripts across a phone number (org-level) +hits = inkbox.phone_numbers.search_transcripts(phone.id, q="refund", party="remote") +for t in hits: + print(f"[{t.party}] {t.text}") +``` + +--- + +## Org-level Mailboxes + +Manage mailboxes directly without going through an identity. Access via `inkbox.mailboxes`. + +```python +# List all mailboxes in the organisation +mailboxes = inkbox.mailboxes.list() + +# Get a specific mailbox +mailbox = inkbox.mailboxes.get("abc-xyz@inkboxmail.com") + +# Create a standalone mailbox +mailbox = inkbox.mailboxes.create(display_name="Support Inbox") +print(mailbox.email_address) + +# Update display name or webhook URL +inkbox.mailboxes.update(mailbox.email_address, display_name="New Name") +inkbox.mailboxes.update(mailbox.email_address, webhook_url="https://example.com/hook") +inkbox.mailboxes.update(mailbox.email_address, webhook_url=None) # remove webhook + +# Full-text search across messages in a mailbox +results = inkbox.mailboxes.search(mailbox.email_address, q="invoice", limit=20) +for msg in results: + print(msg.subject, msg.from_address) + +# Delete a mailbox +inkbox.mailboxes.delete(mailbox.email_address) +``` + +--- + +## Org-level Phone Numbers + +Manage phone numbers directly without going through an identity. Access via `inkbox.phone_numbers`. + +```python +# List all phone numbers in the organisation +numbers = inkbox.phone_numbers.list() + +# Get a specific phone number by ID +number = inkbox.phone_numbers.get("phone-number-uuid") + +# Provision a new number +number = inkbox.phone_numbers.provision(type="toll_free") +local = inkbox.phone_numbers.provision(type="local", state="NY") + +# Update incoming call behaviour +inkbox.phone_numbers.update( + number.id, + incoming_call_action="webhook", + incoming_call_webhook_url="https://example.com/calls", +) +inkbox.phone_numbers.update( + number.id, + incoming_call_action="auto_accept", + client_websocket_url="wss://example.com/ws", +) + +# Full-text search across transcripts +hits = inkbox.phone_numbers.search_transcripts(number.id, q="refund", party="remote") +for t in hits: + print(f"[{t.party}] {t.text}") + +# Release a number +inkbox.phone_numbers.release(number=number.number) +``` + +--- + +## Webhooks + +Webhooks are configured on the mailbox or phone number resource — no separate registration step. + +### Mailbox webhooks + +Set a URL on a mailbox to receive `message.received` and `message.sent` events. + +```python +# Set webhook +inkbox.mailboxes.update("abc@inkboxmail.com", webhook_url="https://example.com/hook") + +# Remove webhook +inkbox.mailboxes.update("abc@inkboxmail.com", webhook_url=None) +``` + +### Phone webhooks + +Set an incoming call webhook URL and action on a phone number. + +```python +# Route incoming calls to a webhook +inkbox.phone_numbers.update( + number.id, + incoming_call_action="webhook", + incoming_call_webhook_url="https://example.com/calls", +) +``` + +You can also supply a per-call webhook URL when placing a call: + +```python +identity.place_call(to_number="+15005550006", webhook_url="https://example.com/call-events") ``` --- diff --git a/typescript/README.md b/typescript/README.md index 16bc219..cc7d095 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -34,7 +34,7 @@ await identity.sendEmail({ // Place an outbound call await identity.placeCall({ toNumber: "+18005559999", - streamUrl: "wss://my-app.com/voice", + clientWebsocketUrl: "wss://my-app.com/voice", }); // Read inbox @@ -42,8 +42,8 @@ for await (const message of identity.iterEmails()) { console.log(message.subject); } -// Search transcripts -const transcripts = await identity.searchTranscripts({ q: "refund" }); +// List calls +const calls = await identity.listCalls(); ``` ## Authentication @@ -69,6 +69,10 @@ const phone = await identity.provisionPhoneNumber({ type: "toll_free" }); console.log(mailbox.emailAddress); console.log(phone.number); +// Link an existing mailbox or phone number instead of creating new ones +await identity.assignMailbox("mailbox-uuid-here"); +await identity.assignPhoneNumber("phone-number-uuid-here"); + // Get an existing identity (returned with current channel state) const identity2 = await inkbox.getIdentity("sales-bot"); await identity2.refresh(); // re-fetch channels from API @@ -93,20 +97,42 @@ await identity.delete(); ## Mail ```ts -// Send an email -await identity.sendEmail({ +// Send an email (plain text and/or HTML) +const sent = await identity.sendEmail({ to: ["user@example.com"], - subject: "Hello", + subject: "Hello from Inkbox", bodyText: "Hi there!", bodyHtml: "

Hi there!

", + cc: ["manager@example.com"], + bcc: ["archive@example.com"], +}); + +// Send a threaded reply +await identity.sendEmail({ + to: ["user@example.com"], + subject: `Re: ${sent.subject}`, + bodyText: "Following up!", + inReplyToMessageId: sent.id, +}); + +// Send with attachments +await identity.sendEmail({ + to: ["user@example.com"], + subject: "See attached", + bodyText: "Please find the file attached.", + attachments: [{ + filename: "report.pdf", + contentType: "application/pdf", + contentBase64: "", + }], }); // Iterate inbox (paginated automatically) for await (const msg of identity.iterEmails()) { - console.log(msg.subject, msg.fromAddress); + console.log(msg.subject, msg.fromAddress, msg.isRead); } -// Filter by direction +// Filter by direction: "inbound" or "outbound" for await (const msg of identity.iterEmails({ direction: "inbound" })) { console.log(msg.subject); } @@ -133,19 +159,156 @@ for (const m of thread.messages) { ## Phone ```ts -// Place an outbound call +// Place an outbound call — stream audio over WebSocket const call = await identity.placeCall({ toNumber: "+15167251294", - streamUrl: "wss://your-agent.example.com/ws", + clientWebsocketUrl: "wss://your-agent.example.com/ws", }); console.log(call.status, call.rateLimit.callsRemaining); -// List calls -const calls = await identity.listCalls(); -const paged = await identity.listCalls({ limit: 10, offset: 0 }); +// Or receive call events via webhook instead +const call2 = await identity.placeCall({ + toNumber: "+15167251294", + webhookUrl: "https://your-agent.example.com/call-events", +}); + +// List calls (paginated) +const calls = await identity.listCalls({ limit: 10, offset: 0 }); +for (const c of calls) { + console.log(c.id, c.direction, c.remotePhoneNumber, c.status); +} // Fetch transcript segments for a call const segments = await identity.listTranscripts(calls[0].id); +for (const t of segments) { + console.log(`[${t.party}] ${t.text}`); // party: "local" or "remote" +} + +// Read transcripts across all recent calls +const recentCalls = await identity.listCalls({ limit: 10 }); +for (const call of recentCalls) { + const segs = await identity.listTranscripts(call.id); + if (!segs.length) continue; + console.log(`\n--- Call ${call.id} (${call.direction}) ---`); + for (const t of segs) { + console.log(` [${t.party.padEnd(6)}] ${t.text}`); + } +} + +// Filter to only the remote party's speech +const remoteOnly = segments.filter(t => t.party === "remote"); +for (const t of remoteOnly) console.log(t.text); + +// Search transcripts across a phone number (org-level) +const hits = await inkbox.phoneNumbers.searchTranscripts(phone.id, { q: "refund", party: "remote" }); +for (const t of hits) { + console.log(`[${t.party}] ${t.text}`); +} +``` + +--- + +## Org-level Mailboxes + +Manage mailboxes directly without going through an identity. Access via `inkbox.mailboxes`. + +```ts +// List all mailboxes in the organisation +const mailboxes = await inkbox.mailboxes.list(); + +// Get a specific mailbox +const mailbox = await inkbox.mailboxes.get("abc-xyz@inkboxmail.com"); + +// Create a standalone mailbox +const mb = await inkbox.mailboxes.create({ displayName: "Support Inbox" }); +console.log(mb.emailAddress); + +// Update display name or webhook URL +await inkbox.mailboxes.update(mb.emailAddress, { displayName: "New Name" }); +await inkbox.mailboxes.update(mb.emailAddress, { webhookUrl: "https://example.com/hook" }); +await inkbox.mailboxes.update(mb.emailAddress, { webhookUrl: null }); // remove webhook + +// Full-text search across messages in a mailbox +const results = await inkbox.mailboxes.search(mb.emailAddress, { q: "invoice", limit: 20 }); +for (const msg of results) { + console.log(msg.subject, msg.fromAddress); +} + +// Delete a mailbox +await inkbox.mailboxes.delete(mb.emailAddress); +``` + +--- + +## Org-level Phone Numbers + +Manage phone numbers directly without going through an identity. Access via `inkbox.phoneNumbers`. + +```ts +// List all phone numbers in the organisation +const numbers = await inkbox.phoneNumbers.list(); + +// Get a specific phone number by ID +const number = await inkbox.phoneNumbers.get("phone-number-uuid"); + +// Provision a new number +const num = await inkbox.phoneNumbers.provision({ type: "toll_free" }); +const local = await inkbox.phoneNumbers.provision({ type: "local", state: "NY" }); + +// Update incoming call behaviour +await inkbox.phoneNumbers.update(num.id, { + incomingCallAction: "webhook", + incomingCallWebhookUrl: "https://example.com/calls", +}); +await inkbox.phoneNumbers.update(num.id, { + incomingCallAction: "auto_accept", + clientWebsocketUrl: "wss://example.com/ws", +}); + +// Full-text search across transcripts +const hits = await inkbox.phoneNumbers.searchTranscripts(num.id, { q: "refund", party: "remote" }); +for (const t of hits) { + console.log(`[${t.party}] ${t.text}`); +} + +// Release a number +await inkbox.phoneNumbers.release({ number: num.number }); +``` + +--- + +## Webhooks + +Webhooks are configured on the mailbox or phone number resource — no separate registration step. + +### Mailbox webhooks + +Set a URL on a mailbox to receive `message.received` and `message.sent` events. + +```ts +// Set webhook +await inkbox.mailboxes.update("abc@inkboxmail.com", { webhookUrl: "https://example.com/hook" }); + +// Remove webhook +await inkbox.mailboxes.update("abc@inkboxmail.com", { webhookUrl: null }); +``` + +### Phone webhooks + +Set an incoming call webhook URL and action on a phone number. + +```ts +// Route incoming calls to a webhook +await inkbox.phoneNumbers.update(number.id, { + incomingCallAction: "webhook", + incomingCallWebhookUrl: "https://example.com/calls", +}); +``` + +You can also supply a per-call webhook URL when placing a call: + +```ts +await identity.placeCall({ toNumber: "+15005550006", webhookUrl: "https://example.com/call-events" }); ``` --- From 9c5746327b6a47fed6be09b8a301a9f0de77b74d Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:01:42 -0400 Subject: [PATCH 54/56] fix: inconsistency with api --- python/inkbox/agent_identity.py | 6 ++---- python/inkbox/phone/resources/numbers.py | 14 ++++++------ python/tests/test_numbers.py | 26 +++++++++++------------ typescript/src/agent_identity.ts | 6 ++---- typescript/src/phone/resources/numbers.ts | 15 +++++++------ 5 files changed, 33 insertions(+), 34 deletions(-) diff --git a/python/inkbox/agent_identity.py b/python/inkbox/agent_identity.py index 8d2a2a9..52fcf93 100644 --- a/python/inkbox/agent_identity.py +++ b/python/inkbox/agent_identity.py @@ -129,10 +129,8 @@ def provision_phone_number( Returns: The newly provisioned and linked phone number. """ - number = self._inkbox._numbers.provision(type=type, state=state) - data = self._inkbox._ids_resource.assign_phone_number( - self.agent_handle, phone_number_id=number.id - ) + self._inkbox._numbers.provision(agent_handle=self.agent_handle, type=type, state=state) + data = self._inkbox._ids_resource.get(self.agent_handle) self._phone_number = data.phone_number self._data = data return self._phone_number # type: ignore[return-value] diff --git a/python/inkbox/phone/resources/numbers.py b/python/inkbox/phone/resources/numbers.py index 71ae90d..744f366 100644 --- a/python/inkbox/phone/resources/numbers.py +++ b/python/inkbox/phone/resources/numbers.py @@ -64,31 +64,33 @@ def update( def provision( self, *, + agent_handle: str, type: str = "toll_free", state: str | None = None, ) -> PhoneNumber: - """Provision a new phone number. + """Provision a new phone number and link it to an agent identity. Args: + agent_handle: Handle of the agent identity to assign this number to. type: ``"toll_free"`` or ``"local"``. Defaults to ``"toll_free"``. state: US state abbreviation (e.g. ``"NY"``). Only valid for ``local`` numbers. Returns: The provisioned phone number. """ - body: dict[str, Any] = {"type": type} + body: dict[str, Any] = {"agent_handle": agent_handle, "type": type} if state is not None: body["state"] = state - data = self._http.post(f"{_BASE}/provision", json=body) + data = self._http.post(_BASE, json=body) return PhoneNumber._from_dict(data) - def release(self, *, number: str) -> None: + def release(self, phone_number_id: UUID | str) -> None: """Release a phone number. Args: - number: E.164 phone number to release. + phone_number_id: UUID of the phone number to release. """ - self._http.post(f"{_BASE}/release", json={"number": number}) + self._http.delete(f"{_BASE}/{phone_number_id}") def search_transcripts( self, diff --git a/python/tests/test_numbers.py b/python/tests/test_numbers.py index 1fa3b0d..504d443 100644 --- a/python/tests/test_numbers.py +++ b/python/tests/test_numbers.py @@ -96,11 +96,11 @@ class TestNumbersProvision: def test_provision_toll_free(self, client, transport): transport.post.return_value = PHONE_NUMBER_DICT - number = client._numbers.provision(type="toll_free") + number = client._numbers.provision(agent_handle="sales-bot", type="toll_free") transport.post.assert_called_once_with( - "/numbers/provision", - json={"type": "toll_free"}, + "/numbers", + json={"agent_handle": "sales-bot", "type": "toll_free"}, ) assert number.type == "toll_free" @@ -108,33 +108,31 @@ def test_provision_local_with_state(self, client, transport): local = {**PHONE_NUMBER_DICT, "type": "local", "number": "+12125551234"} transport.post.return_value = local - number = client._numbers.provision(type="local", state="NY") + number = client._numbers.provision(agent_handle="sales-bot", type="local", state="NY") transport.post.assert_called_once_with( - "/numbers/provision", - json={"type": "local", "state": "NY"}, + "/numbers", + json={"agent_handle": "sales-bot", "type": "local", "state": "NY"}, ) assert number.type == "local" def test_provision_defaults_to_toll_free(self, client, transport): transport.post.return_value = PHONE_NUMBER_DICT - client._numbers.provision() + client._numbers.provision(agent_handle="sales-bot") _, kwargs = transport.post.call_args assert kwargs["json"]["type"] == "toll_free" + assert kwargs["json"]["agent_handle"] == "sales-bot" class TestNumbersRelease: - def test_release_posts_number(self, client, transport): - transport.post.return_value = None + def test_release_deletes_by_id(self, client, transport): + uid = "aaaa1111-0000-0000-0000-000000000001" - client._numbers.release(number="+18335794607") + client._numbers.release(uid) - transport.post.assert_called_once_with( - "/numbers/release", - json={"number": "+18335794607"}, - ) + transport.delete.assert_called_once_with(f"/numbers/{uid}") class TestNumbersSearchTranscripts: diff --git a/typescript/src/agent_identity.ts b/typescript/src/agent_identity.ts index 6e80109..4635712 100644 --- a/typescript/src/agent_identity.ts +++ b/typescript/src/agent_identity.ts @@ -102,10 +102,8 @@ export class AgentIdentity { async provisionPhoneNumber( options: { type?: string; state?: string } = {}, ): Promise { - const number = await this._inkbox._numbers.provision(options); - const data = await this._inkbox._idsResource.assignPhoneNumber(this.agentHandle, { - phoneNumberId: number.id, - }); + await this._inkbox._numbers.provision({ agentHandle: this.agentHandle, ...options }); + const data = await this._inkbox._idsResource.get(this.agentHandle); this._phoneNumber = data.phoneNumber; this._data = data; return this._phoneNumber!; diff --git a/typescript/src/phone/resources/numbers.ts b/typescript/src/phone/resources/numbers.ts index 2a60851..9792638 100644 --- a/typescript/src/phone/resources/numbers.ts +++ b/typescript/src/phone/resources/numbers.ts @@ -68,32 +68,35 @@ export class PhoneNumbersResource { } /** - * Provision a new phone number. + * Provision a new phone number and link it to an agent identity. * + * @param options.agentHandle - Handle of the agent identity to assign this number to. * @param options.type - `"toll_free"` or `"local"`. Defaults to `"toll_free"`. * @param options.state - US state abbreviation (e.g. `"NY"`). Only valid for `local` numbers. */ async provision(options: { + agentHandle: string; type?: string; state?: string; - } = {}): Promise { + }): Promise { const body: Record = { + agent_handle: options.agentHandle, type: options.type ?? "toll_free", }; if (options.state !== undefined) { body["state"] = options.state; } - const data = await this.http.post(`${BASE}/provision`, body); + const data = await this.http.post(BASE, body); return parsePhoneNumber(data); } /** * Release a phone number. * - * @param options.number - E.164 phone number to release. + * @param phoneNumberId - UUID of the phone number to release. */ - async release(options: { number: string }): Promise { - await this.http.post(`${BASE}/release`, { number: options.number }); + async release(phoneNumberId: string): Promise { + await this.http.delete(`${BASE}/${phoneNumberId}`); } /** From c80fa3a6f2761fb93119acdd899608945e534f5e Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:09:27 -0400 Subject: [PATCH 55/56] token readme --- README.md | 6 ++++++ python/README.md | 4 ++++ typescript/README.md | 4 ++++ 3 files changed, 14 insertions(+) diff --git a/README.md b/README.md index 2767a85..71fd013 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,12 @@ Official SDKs for the [Inkbox API](https://inkbox.ai) — API-first communicatio --- +## Authentication + +All SDK calls require an API key. You can obtain one from the [Inkbox Console](https://console.inkbox.ai/). + +--- + ## Identities Agent identities are the central concept — a named identity (e.g. `"sales-agent"`) that owns a mailbox and/or phone number. Use `Inkbox` as the org-level entry point to create and retrieve identities. diff --git a/python/README.md b/python/README.md index ae857ee..27ce728 100644 --- a/python/README.md +++ b/python/README.md @@ -10,6 +10,10 @@ pip install inkbox Requires Python ≥ 3.11. +## Authentication + +You'll need an API key to use this SDK. Get one at [console.inkbox.ai](https://console.inkbox.ai/). + ## Quick start ```python diff --git a/typescript/README.md b/typescript/README.md index cc7d095..0f8e112 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -10,6 +10,10 @@ npm install @inkbox/sdk Requires Node.js ≥ 18. +## Authentication + +You'll need an API key to use this SDK. Get one at [console.inkbox.ai](https://console.inkbox.ai/). + ## Quick start ```ts From 71b6ac07cdbe86e881fed7241ca5712a70fa4fba Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:26:32 -0400 Subject: [PATCH 56/56] update reamde --- README.md | 2 +- python/README.md | 2 +- typescript/README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 71fd013..547c4f8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Inkbox SDK -Official SDKs for the [Inkbox API](https://inkbox.ai) — API-first communication infrastructure for AI agents (identities, email, phone). +Official SDKs for the [Inkbox API](https://www.inkbox.ai/docs) — API-first communication infrastructure for AI agents (identities, email, phone). | Package | Language | Install | |---|---|---| diff --git a/python/README.md b/python/README.md index 27ce728..7c79ceb 100644 --- a/python/README.md +++ b/python/README.md @@ -1,6 +1,6 @@ # inkbox -Python SDK for the [Inkbox API](https://inkbox.ai) — API-first communication infrastructure for AI agents (email, phone, identities). +Python SDK for the [Inkbox API](https://www.inkbox.ai/docs) — API-first communication infrastructure for AI agents (email, phone, identities). ## Install diff --git a/typescript/README.md b/typescript/README.md index 0f8e112..fd929b3 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -1,6 +1,6 @@ # @inkbox/sdk -TypeScript SDK for the [Inkbox API](https://inkbox.ai) — API-first communication infrastructure for AI agents (email, phone, identities). +TypeScript SDK for the [Inkbox API](https://www.inkbox.ai/docs) — API-first communication infrastructure for AI agents (email, phone, identities). ## Install