callin 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- callin-0.1.0/.gitignore +5 -0
- callin-0.1.0/LICENSE +21 -0
- callin-0.1.0/PKG-INFO +72 -0
- callin-0.1.0/README.md +50 -0
- callin-0.1.0/pyproject.toml +27 -0
- callin-0.1.0/src/callin/__init__.py +5 -0
- callin-0.1.0/src/callin/client.py +238 -0
- callin-0.1.0/src/callin/crewai.py +48 -0
- callin-0.1.0/src/callin/langchain.py +44 -0
callin-0.1.0/.gitignore
ADDED
callin-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Replacing Humans LLC (Callin.io)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
callin-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: callin
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Give your AI agent a real phone line: Callin.io voice agents, calls and transcripts for Python, LangChain and CrewAI.
|
|
5
|
+
Project-URL: Homepage, https://callin.io
|
|
6
|
+
Project-URL: Documentation, https://support.callin.io/api-reference/introduction
|
|
7
|
+
Project-URL: Source, https://github.com/callin-io/callin-agents
|
|
8
|
+
Author-email: "Callin.io" <hello@callin.io>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai agents,callin,crewai,langchain,mcp,phone calls,telephony,voice ai
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Communications :: Telephony
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Requires-Dist: httpx>=0.24
|
|
17
|
+
Provides-Extra: crewai
|
|
18
|
+
Requires-Dist: crewai>=0.80; extra == 'crewai'
|
|
19
|
+
Provides-Extra: langchain
|
|
20
|
+
Requires-Dist: langchain-core>=0.2; extra == 'langchain'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# Callin.io for AI agents
|
|
24
|
+
|
|
25
|
+
**Give your AI agent a real phone line.** Callin.io places phone calls with an AI voice agent and returns what happened: status, summary and transcript. Calls in Italian, Spanish, Portuguese, English, French, German, Dutch and Hindi, GDPR compliant, and every call discloses that it is an AI.
|
|
26
|
+
|
|
27
|
+
| Use it from | How |
|
|
28
|
+
|---|---|
|
|
29
|
+
| Claude, ChatGPT, Cursor, VS Code, OpenAI Agents SDK | MCP server `https://claude-mcp.callin.io/mcp` (OAuth) — registry name `io.callin/callin` |
|
|
30
|
+
| Python | `pip install callin` |
|
|
31
|
+
| LangChain | `pip install "callin[langchain]"` → `from callin.langchain import get_callin_tools` |
|
|
32
|
+
| CrewAI | `pip install "callin[crewai]"` → `from callin.crewai import CallinPlaceCallTool` |
|
|
33
|
+
| Node.js | `npm i callin-sdk` |
|
|
34
|
+
| OpenClaw / Claude skills | [`skills/callin-phone/SKILL.md`](skills/callin-phone/SKILL.md) |
|
|
35
|
+
| n8n | community node `n8n-nodes-callin` |
|
|
36
|
+
| REST | [API reference](https://support.callin.io/api-reference/introduction) · [OpenAPI](https://support.callin.io/openapi.json) |
|
|
37
|
+
|
|
38
|
+
## Quickstart (Python)
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from callin import Callin # env: CALLIN_API_KEY, CALLIN_AGENT_ID
|
|
42
|
+
|
|
43
|
+
result = Callin().place_call(
|
|
44
|
+
"+390111234567",
|
|
45
|
+
objective="Book a table for 2 tomorrow at 20:30 under the name Rossi",
|
|
46
|
+
language="it",
|
|
47
|
+
wait=True,
|
|
48
|
+
)
|
|
49
|
+
print(result.status, result.summary)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Quickstart (Node)
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
import { Callin } from "callin-sdk";
|
|
56
|
+
const callin = new Callin(); // env: CALLIN_API_KEY, CALLIN_AGENT_ID
|
|
57
|
+
const { campaignId } = await callin.placeCall({ to: "+34911234567", objective: "Ask for the first free appointment next week", language: "es" });
|
|
58
|
+
console.log(await callin.waitForCall(campaignId));
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## How it works
|
|
62
|
+
1. Create a voice agent in [app.callin.io](https://app.callin.io) with a phone number, using the [Task Caller template](task-caller-agent/prompt.md). Its id is `CALLIN_AGENT_ID`.
|
|
63
|
+
2. `place_call` starts a one-contact campaign for that agent and passes `objective`, `context` and `language` as dynamic variables.
|
|
64
|
+
3. `get_call_result` / `wait_for_call` returns status, summary and transcript when the call ends.
|
|
65
|
+
|
|
66
|
+
## Responsible use
|
|
67
|
+
Call only when your user asks you to, and only numbers they give you or public business numbers. No unsolicited marketing, no emergency numbers, no payment card data over the phone. The agent always states that it is an AI.
|
|
68
|
+
|
|
69
|
+
## Pricing
|
|
70
|
+
$0.16 per minute, all inclusive (telephony, language model and voice), no monthly fee. See [callin.io/pricing](https://callin.io/pricing).
|
|
71
|
+
|
|
72
|
+
MIT licensed. © Replacing Humans LLC — Callin.io.
|
callin-0.1.0/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Callin.io for AI agents
|
|
2
|
+
|
|
3
|
+
**Give your AI agent a real phone line.** Callin.io places phone calls with an AI voice agent and returns what happened: status, summary and transcript. Calls in Italian, Spanish, Portuguese, English, French, German, Dutch and Hindi, GDPR compliant, and every call discloses that it is an AI.
|
|
4
|
+
|
|
5
|
+
| Use it from | How |
|
|
6
|
+
|---|---|
|
|
7
|
+
| Claude, ChatGPT, Cursor, VS Code, OpenAI Agents SDK | MCP server `https://claude-mcp.callin.io/mcp` (OAuth) — registry name `io.callin/callin` |
|
|
8
|
+
| Python | `pip install callin` |
|
|
9
|
+
| LangChain | `pip install "callin[langchain]"` → `from callin.langchain import get_callin_tools` |
|
|
10
|
+
| CrewAI | `pip install "callin[crewai]"` → `from callin.crewai import CallinPlaceCallTool` |
|
|
11
|
+
| Node.js | `npm i callin-sdk` |
|
|
12
|
+
| OpenClaw / Claude skills | [`skills/callin-phone/SKILL.md`](skills/callin-phone/SKILL.md) |
|
|
13
|
+
| n8n | community node `n8n-nodes-callin` |
|
|
14
|
+
| REST | [API reference](https://support.callin.io/api-reference/introduction) · [OpenAPI](https://support.callin.io/openapi.json) |
|
|
15
|
+
|
|
16
|
+
## Quickstart (Python)
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from callin import Callin # env: CALLIN_API_KEY, CALLIN_AGENT_ID
|
|
20
|
+
|
|
21
|
+
result = Callin().place_call(
|
|
22
|
+
"+390111234567",
|
|
23
|
+
objective="Book a table for 2 tomorrow at 20:30 under the name Rossi",
|
|
24
|
+
language="it",
|
|
25
|
+
wait=True,
|
|
26
|
+
)
|
|
27
|
+
print(result.status, result.summary)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quickstart (Node)
|
|
31
|
+
|
|
32
|
+
```js
|
|
33
|
+
import { Callin } from "callin-sdk";
|
|
34
|
+
const callin = new Callin(); // env: CALLIN_API_KEY, CALLIN_AGENT_ID
|
|
35
|
+
const { campaignId } = await callin.placeCall({ to: "+34911234567", objective: "Ask for the first free appointment next week", language: "es" });
|
|
36
|
+
console.log(await callin.waitForCall(campaignId));
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## How it works
|
|
40
|
+
1. Create a voice agent in [app.callin.io](https://app.callin.io) with a phone number, using the [Task Caller template](task-caller-agent/prompt.md). Its id is `CALLIN_AGENT_ID`.
|
|
41
|
+
2. `place_call` starts a one-contact campaign for that agent and passes `objective`, `context` and `language` as dynamic variables.
|
|
42
|
+
3. `get_call_result` / `wait_for_call` returns status, summary and transcript when the call ends.
|
|
43
|
+
|
|
44
|
+
## Responsible use
|
|
45
|
+
Call only when your user asks you to, and only numbers they give you or public business numbers. No unsolicited marketing, no emergency numbers, no payment card data over the phone. The agent always states that it is an AI.
|
|
46
|
+
|
|
47
|
+
## Pricing
|
|
48
|
+
$0.16 per minute, all inclusive (telephony, language model and voice), no monthly fee. See [callin.io/pricing](https://callin.io/pricing).
|
|
49
|
+
|
|
50
|
+
MIT licensed. © Replacing Humans LLC — Callin.io.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "callin"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Give your AI agent a real phone line: Callin.io voice agents, calls and transcripts for Python, LangChain and CrewAI."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Callin.io", email = "hello@callin.io" }]
|
|
13
|
+
keywords = ["ai agents", "phone calls", "voice ai", "telephony", "langchain", "crewai", "mcp", "callin"]
|
|
14
|
+
dependencies = ["httpx>=0.24"]
|
|
15
|
+
classifiers = ["Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", "Topic :: Communications :: Telephony"]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
langchain = ["langchain-core>=0.2"]
|
|
19
|
+
crewai = ["crewai>=0.80"]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://callin.io"
|
|
23
|
+
Documentation = "https://support.callin.io/api-reference/introduction"
|
|
24
|
+
Source = "https://github.com/callin-io/callin-agents"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
packages = ["src/callin"]
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Minimal client for the Callin.io public API (https://support.callin.io/api-reference/introduction).
|
|
2
|
+
|
|
3
|
+
Phone calls are placed through a Callin.io voice agent that has a phone number.
|
|
4
|
+
`place_call` creates a one-contact campaign for that agent and passes the call goal
|
|
5
|
+
as dynamic variables (`objective`, `context`, ...), which the agent's prompt uses.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
DEFAULT_BASE_URL = "https://api.callin.io/api"
|
|
18
|
+
FINAL_STATUSES = {"completed", "done", "ended", "failed", "no-answer", "no_answer", "busy", "voicemail", "canceled", "cancelled", "error"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CallinError(RuntimeError):
|
|
22
|
+
"""Raised when the Callin.io API returns an error."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, message: str, status: Optional[int] = None, details: Any = None):
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
self.status = status
|
|
27
|
+
self.details = details
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class CallResult:
|
|
32
|
+
"""Outcome of a phone call placed with `Callin.place_call`."""
|
|
33
|
+
|
|
34
|
+
campaign_id: str
|
|
35
|
+
status: str
|
|
36
|
+
finished: bool
|
|
37
|
+
to: str
|
|
38
|
+
call_id: Optional[str] = None
|
|
39
|
+
conversation_id: Optional[str] = None
|
|
40
|
+
duration_seconds: Optional[int] = None
|
|
41
|
+
summary: Optional[str] = None
|
|
42
|
+
transcript: Optional[str] = None
|
|
43
|
+
voicemail: Optional[bool] = None
|
|
44
|
+
error: Optional[str] = None
|
|
45
|
+
raw: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
46
|
+
|
|
47
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
48
|
+
d = {k: v for k, v in self.__dict__.items() if k != "raw"}
|
|
49
|
+
return d
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Callin:
|
|
53
|
+
"""Client for the Callin.io public API.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
api_key: Callin.io API key. Defaults to the ``CALLIN_API_KEY`` environment variable.
|
|
57
|
+
agent_id: Default voice agent used by :meth:`place_call`. Defaults to ``CALLIN_AGENT_ID``.
|
|
58
|
+
base_url: API base URL.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, api_key: Optional[str] = None, agent_id: Optional[str] = None,
|
|
62
|
+
base_url: str = DEFAULT_BASE_URL, timeout: float = 30.0):
|
|
63
|
+
self.api_key = api_key or os.environ.get("CALLIN_API_KEY")
|
|
64
|
+
if not self.api_key:
|
|
65
|
+
raise CallinError("Missing API key: pass api_key= or set CALLIN_API_KEY")
|
|
66
|
+
self.agent_id = agent_id or os.environ.get("CALLIN_AGENT_ID")
|
|
67
|
+
self._http = httpx.Client(base_url=base_url.rstrip("/"), timeout=timeout,
|
|
68
|
+
headers={"x-api-key": self.api_key, "user-agent": "callin-python/0.1.0"})
|
|
69
|
+
|
|
70
|
+
# ---- low level -------------------------------------------------------
|
|
71
|
+
def _request(self, method: str, path: str, **kw) -> Any:
|
|
72
|
+
r = self._http.request(method, path, **kw)
|
|
73
|
+
try:
|
|
74
|
+
body = r.json()
|
|
75
|
+
except ValueError:
|
|
76
|
+
raise CallinError(f"HTTP {r.status_code}: {r.text[:200]}", r.status_code)
|
|
77
|
+
if r.status_code >= 400 or body.get("success") is False:
|
|
78
|
+
err = body.get("error") or {}
|
|
79
|
+
msg = err.get("message") if isinstance(err, dict) else str(err)
|
|
80
|
+
raise CallinError(msg or f"HTTP {r.status_code}", r.status_code, body)
|
|
81
|
+
return body.get("data", body)
|
|
82
|
+
|
|
83
|
+
def close(self) -> None:
|
|
84
|
+
self._http.close()
|
|
85
|
+
|
|
86
|
+
def __enter__(self):
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
def __exit__(self, *exc):
|
|
90
|
+
self.close()
|
|
91
|
+
|
|
92
|
+
# ---- account & agents ------------------------------------------------
|
|
93
|
+
def me(self) -> Dict[str, Any]:
|
|
94
|
+
"""Return the user id and email of the API key's account."""
|
|
95
|
+
return self._request("GET", "/public/v1/me")
|
|
96
|
+
|
|
97
|
+
def list_agents(self, search: Optional[str] = None, page_size: int = 20) -> List[Dict[str, Any]]:
|
|
98
|
+
params = {"pageSize": page_size}
|
|
99
|
+
if search:
|
|
100
|
+
params["search"] = search
|
|
101
|
+
return self._request("GET", "/public/v1/agents", params=params)
|
|
102
|
+
|
|
103
|
+
def get_agent(self, agent_id: str) -> Dict[str, Any]:
|
|
104
|
+
return self._request("GET", f"/public/v1/agents/{agent_id}")
|
|
105
|
+
|
|
106
|
+
# ---- conversations ---------------------------------------------------
|
|
107
|
+
def list_conversations(self, agent_id: Optional[str] = None, page_size: int = 20,
|
|
108
|
+
start_after_unix: Optional[int] = None, status: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
109
|
+
params: Dict[str, Any] = {"pageSize": page_size}
|
|
110
|
+
if agent_id:
|
|
111
|
+
params["agentId"] = agent_id
|
|
112
|
+
if start_after_unix:
|
|
113
|
+
params["startAfterUnix"] = start_after_unix
|
|
114
|
+
if status:
|
|
115
|
+
params["status"] = status
|
|
116
|
+
return self._request("GET", "/public/v1/conversations", params=params)
|
|
117
|
+
|
|
118
|
+
def get_conversation(self, conversation_id: str) -> Dict[str, Any]:
|
|
119
|
+
return self._request("GET", f"/public/v1/conversations/{conversation_id}")
|
|
120
|
+
|
|
121
|
+
# ---- phone calls -----------------------------------------------------
|
|
122
|
+
def place_call(self, to: str, objective: str, *, context: str = "", language: str = "",
|
|
123
|
+
agent_id: Optional[str] = None, contact_name: str = "",
|
|
124
|
+
variables: Optional[Dict[str, Any]] = None, wait: bool = False,
|
|
125
|
+
timeout_seconds: int = 600, poll_seconds: int = 10) -> CallResult:
|
|
126
|
+
"""Place a phone call to ``to`` (E.164, e.g. "+390111234567") with a goal.
|
|
127
|
+
|
|
128
|
+
The goal and context are passed to the voice agent as dynamic variables
|
|
129
|
+
``objective``, ``context`` and ``language``. Use an agent whose prompt
|
|
130
|
+
includes ``{{objective}}`` and ``{{context}}`` (see the "Task Caller" template).
|
|
131
|
+
|
|
132
|
+
With ``wait=True`` the method blocks until the call ends and returns the
|
|
133
|
+
summary and transcript; otherwise call :meth:`get_call_result` later.
|
|
134
|
+
"""
|
|
135
|
+
agent = agent_id or self.agent_id
|
|
136
|
+
if not agent:
|
|
137
|
+
raise CallinError("Missing agent: pass agent_id= or set CALLIN_AGENT_ID")
|
|
138
|
+
if not to.startswith("+"):
|
|
139
|
+
raise CallinError("Phone number must be in E.164 format, e.g. +390111234567")
|
|
140
|
+
contact: Dict[str, Any] = {"phoneNumber": to, "objective": objective}
|
|
141
|
+
if contact_name:
|
|
142
|
+
contact["name"] = contact_name
|
|
143
|
+
if context:
|
|
144
|
+
contact["context"] = context
|
|
145
|
+
if language:
|
|
146
|
+
contact["language"] = language
|
|
147
|
+
for k, v in (variables or {}).items():
|
|
148
|
+
contact[k] = v
|
|
149
|
+
name = f"agent-call {time.strftime('%Y-%m-%d %H:%M:%S')} {uuid.uuid4().hex[:6]}"
|
|
150
|
+
camp = self._request("POST", "/public/v1/campaigns",
|
|
151
|
+
json={"name": name, "agentId": agent, "contacts": [contact], "launch": True})
|
|
152
|
+
result = CallResult(campaign_id=camp.get("campaignId"), status=camp.get("status") or "queued",
|
|
153
|
+
finished=False, to=to, raw={"campaign": camp})
|
|
154
|
+
if wait:
|
|
155
|
+
return self.wait_for_call(result.campaign_id, timeout_seconds=timeout_seconds, poll_seconds=poll_seconds)
|
|
156
|
+
return result
|
|
157
|
+
|
|
158
|
+
def get_call_result(self, campaign_id: str) -> CallResult:
|
|
159
|
+
"""Return the current status and, once finished, the summary and transcript of a call."""
|
|
160
|
+
camp = self._request("GET", f"/public/v1/campaigns/{campaign_id}")
|
|
161
|
+
calls = self._request("GET", f"/public/v1/campaigns/{campaign_id}/calls", params={"pageSize": 5})
|
|
162
|
+
to = ""
|
|
163
|
+
contacts = camp.get("contacts") or []
|
|
164
|
+
if contacts:
|
|
165
|
+
to = contacts[0].get("phoneNumber", "")
|
|
166
|
+
if not calls:
|
|
167
|
+
cstatus = (camp.get("status") or "queued").lower()
|
|
168
|
+
res = CallResult(campaign_id=campaign_id, status=cstatus, finished=cstatus in FINAL_STATUSES,
|
|
169
|
+
to=to, raw={"campaign": camp})
|
|
170
|
+
if res.finished:
|
|
171
|
+
created = (camp.get("metadata") or {}).get("createdAt")
|
|
172
|
+
conv = self._find_conversation(camp.get("agentId"), {"contactNumber": to, "startedAt": created})
|
|
173
|
+
if conv:
|
|
174
|
+
self._fill(res, conv)
|
|
175
|
+
return res
|
|
176
|
+
call = calls[0]
|
|
177
|
+
status = (call.get("status") or "").lower() or "in_progress"
|
|
178
|
+
res = CallResult(campaign_id=campaign_id, status=status, finished=status in FINAL_STATUSES, to=to,
|
|
179
|
+
call_id=call.get("callId"), duration_seconds=call.get("durationSeconds"),
|
|
180
|
+
error=call.get("errorMessage") or None, raw={"campaign": camp, "call": call})
|
|
181
|
+
if res.finished:
|
|
182
|
+
conv = self._find_conversation(camp.get("agentId"), call)
|
|
183
|
+
if conv:
|
|
184
|
+
self._fill(res, conv)
|
|
185
|
+
return res
|
|
186
|
+
|
|
187
|
+
@staticmethod
|
|
188
|
+
def _fill(res: "CallResult", conv: Dict[str, Any]) -> None:
|
|
189
|
+
res.conversation_id = conv.get("conversationId")
|
|
190
|
+
res.summary = conv.get("summary")
|
|
191
|
+
res.transcript = conv.get("transcript")
|
|
192
|
+
res.voicemail = conv.get("voiceMailDetected")
|
|
193
|
+
res.duration_seconds = res.duration_seconds or conv.get("conversationDuration")
|
|
194
|
+
res.raw["conversation"] = conv
|
|
195
|
+
|
|
196
|
+
def wait_for_call(self, campaign_id: str, timeout_seconds: int = 600, poll_seconds: int = 10) -> CallResult:
|
|
197
|
+
deadline = time.time() + timeout_seconds
|
|
198
|
+
res = self.get_call_result(campaign_id)
|
|
199
|
+
while not res.finished and time.time() < deadline:
|
|
200
|
+
time.sleep(poll_seconds)
|
|
201
|
+
res = self.get_call_result(campaign_id)
|
|
202
|
+
return res
|
|
203
|
+
|
|
204
|
+
def _find_conversation(self, agent_id: Optional[str], call: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
205
|
+
call_id = call.get("callId")
|
|
206
|
+
if call_id:
|
|
207
|
+
try:
|
|
208
|
+
conv = self.get_conversation(call_id)
|
|
209
|
+
conv.setdefault("conversationId", call_id)
|
|
210
|
+
return conv
|
|
211
|
+
except CallinError:
|
|
212
|
+
pass
|
|
213
|
+
started = call.get("startedAt")
|
|
214
|
+
after = None
|
|
215
|
+
if started:
|
|
216
|
+
try:
|
|
217
|
+
from datetime import datetime
|
|
218
|
+
after = int(datetime.fromisoformat(started.replace("Z", "+00:00")).timestamp()) - 120
|
|
219
|
+
except ValueError:
|
|
220
|
+
after = None
|
|
221
|
+
number = (call.get("contactNumber") or "").replace(" ", "")
|
|
222
|
+
convs = self.list_conversations(agent_id=agent_id, page_size=20, start_after_unix=after)
|
|
223
|
+
match = None
|
|
224
|
+
if number:
|
|
225
|
+
match = next((c for c in convs if number in (c.get("contactNumber") or "") or number in (c.get("callerNumber") or "")), None)
|
|
226
|
+
if match is None and after:
|
|
227
|
+
# A dedicated "task caller" agent places one call per campaign: take the first outbound call after launch.
|
|
228
|
+
outbound = [c for c in convs if (c.get("direction") or "outbound") == "outbound" and (c.get("startTimeUnix") or 0) >= after]
|
|
229
|
+
outbound.sort(key=lambda c: c.get("startTimeUnix") or 0)
|
|
230
|
+
match = outbound[0] if outbound else None
|
|
231
|
+
if match is None:
|
|
232
|
+
return None
|
|
233
|
+
try:
|
|
234
|
+
conv = self.get_conversation(match["conversationId"])
|
|
235
|
+
conv.setdefault("conversationId", match["conversationId"])
|
|
236
|
+
return conv
|
|
237
|
+
except CallinError:
|
|
238
|
+
return match
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""CrewAI tools for Callin.io. Install with: pip install "callin[crewai]"."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from typing import Optional, Type
|
|
6
|
+
|
|
7
|
+
from crewai.tools import BaseTool
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from .client import Callin
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _PlaceCallInput(BaseModel):
|
|
14
|
+
to: str = Field(..., description="Phone number in E.164 format, e.g. +390111234567")
|
|
15
|
+
objective: str = Field(..., description="What the call must achieve, in plain language")
|
|
16
|
+
context: str = Field("", description="Facts the caller may share")
|
|
17
|
+
language: str = Field("", description='Language code for the call, e.g. "it", "es", "pt", "en"')
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CallinPlaceCallTool(BaseTool):
|
|
21
|
+
name: str = "callin_place_call"
|
|
22
|
+
description: str = ("Place a real phone call with an AI voice agent that discloses it is an AI. "
|
|
23
|
+
"Returns campaign_id; use callin_get_call_result to read the outcome.")
|
|
24
|
+
args_schema: Type[BaseModel] = _PlaceCallInput
|
|
25
|
+
client: Optional[Callin] = None
|
|
26
|
+
|
|
27
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
28
|
+
|
|
29
|
+
def _run(self, to: str, objective: str, context: str = "", language: str = "") -> str:
|
|
30
|
+
c = self.client or Callin()
|
|
31
|
+
return json.dumps(c.place_call(to=to, objective=objective, context=context, language=language).to_dict())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _ResultInput(BaseModel):
|
|
35
|
+
campaign_id: str = Field(..., description="campaign_id returned by callin_place_call")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CallinGetCallResultTool(BaseTool):
|
|
39
|
+
name: str = "callin_get_call_result"
|
|
40
|
+
description: str = "Get the status, summary and transcript of a call placed with callin_place_call."
|
|
41
|
+
args_schema: Type[BaseModel] = _ResultInput
|
|
42
|
+
client: Optional[Callin] = None
|
|
43
|
+
|
|
44
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
45
|
+
|
|
46
|
+
def _run(self, campaign_id: str) -> str:
|
|
47
|
+
c = self.client or Callin()
|
|
48
|
+
return json.dumps(c.get_call_result(campaign_id).to_dict())
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""LangChain tools for Callin.io. Install with: pip install "callin[langchain]"."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
from langchain_core.tools import BaseTool, tool
|
|
8
|
+
|
|
9
|
+
from .client import Callin
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_callin_tools(client: Optional[Callin] = None) -> List[BaseTool]:
|
|
13
|
+
"""Return LangChain tools that let an agent place phone calls and read results."""
|
|
14
|
+
c = client or Callin()
|
|
15
|
+
|
|
16
|
+
@tool("callin_place_call")
|
|
17
|
+
def place_call(to: str, objective: str, context: str = "", language: str = "") -> str:
|
|
18
|
+
"""Place a real phone call with an AI voice agent.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
to: Phone number in E.164 format, e.g. +390111234567.
|
|
22
|
+
objective: What the call must achieve, in plain language
|
|
23
|
+
(e.g. "Book a table for 2 tomorrow at 20:30 under the name Rossi").
|
|
24
|
+
context: Facts the caller may share (names, order numbers, callback number).
|
|
25
|
+
language: Language code for the call, e.g. "it", "es", "pt", "en".
|
|
26
|
+
Returns a JSON string with campaign_id and status. The call is asynchronous:
|
|
27
|
+
use callin_get_call_result with the campaign_id to read the outcome.
|
|
28
|
+
The voice agent always discloses that it is an AI assistant.
|
|
29
|
+
"""
|
|
30
|
+
r = c.place_call(to=to, objective=objective, context=context, language=language)
|
|
31
|
+
return json.dumps(r.to_dict())
|
|
32
|
+
|
|
33
|
+
@tool("callin_get_call_result")
|
|
34
|
+
def get_call_result(campaign_id: str) -> str:
|
|
35
|
+
"""Get the status of a phone call placed with callin_place_call and, when it has
|
|
36
|
+
finished, its summary and transcript. Returns a JSON string."""
|
|
37
|
+
return json.dumps(c.get_call_result(campaign_id).to_dict())
|
|
38
|
+
|
|
39
|
+
@tool("callin_list_recent_calls")
|
|
40
|
+
def list_recent_calls(limit: int = 10) -> str:
|
|
41
|
+
"""List recent calls handled by your Callin.io voice agents, with summaries. Returns JSON."""
|
|
42
|
+
return json.dumps(c.list_conversations(page_size=limit))
|
|
43
|
+
|
|
44
|
+
return [place_call, get_call_result, list_recent_calls]
|