1105 lines
36 KiB
Python
1105 lines
36 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
ClawdTalk AI Missions API client using only Python standard library.
|
|
Proxies all requests through ClawdTalk instead of calling Telnyx directly.
|
|
Usage: python telnyx_api.py <command> [args...]
|
|
|
|
Env vars: CLAWDTALK_API_KEY, CLAWDTALK_API_URL
|
|
Endpoints: https://clawdtalk.com/v1
|
|
Reads: skill-config.json
|
|
Writes: .missions_state.json
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import urllib.request
|
|
import urllib.error
|
|
import urllib.parse
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
SKILL_DIR = Path(__file__).resolve().parent.parent
|
|
SKILL_CONFIG = SKILL_DIR / "skill-config.json"
|
|
STATE_FILE = Path(".missions_state.json")
|
|
|
|
def _load_skill_config():
|
|
"""Load skill-config.json if it exists."""
|
|
try:
|
|
if SKILL_CONFIG.exists():
|
|
return json.loads(SKILL_CONFIG.read_text())
|
|
except Exception:
|
|
pass
|
|
return {}
|
|
|
|
_skill_config = _load_skill_config()
|
|
|
|
def _get_base_url():
|
|
"""Get API base URL from env var, skill-config.json, or default."""
|
|
url = os.environ.get("CLAWDTALK_API_URL")
|
|
if url:
|
|
return url
|
|
server = _skill_config.get("server")
|
|
if server:
|
|
return server.rstrip("/") + "/v1"
|
|
return "https://clawdtalk.com/v1"
|
|
|
|
BASE_URL = _get_base_url()
|
|
|
|
def get_api_key():
|
|
"""Get API key from env var or skill-config.json."""
|
|
key = os.environ.get("CLAWDTALK_API_KEY")
|
|
if key:
|
|
return key
|
|
key = _skill_config.get("api_key")
|
|
if key:
|
|
return key
|
|
print("ERROR: No API key found. Set CLAWDTALK_API_KEY or run setup.sh", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def api_request(method: str, endpoint: str, data: dict = None) -> dict:
|
|
"""Make an API request to ClawdTalk."""
|
|
url = f"{BASE_URL}{endpoint}"
|
|
headers = {
|
|
"Authorization": f"Bearer {get_api_key()}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
body = json.dumps(data).encode("utf-8") if data else None
|
|
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=60) as response:
|
|
response_body = response.read().decode("utf-8")
|
|
if response_body:
|
|
return json.loads(response_body)
|
|
return {}
|
|
except urllib.error.HTTPError as e:
|
|
error_body = e.read().decode("utf-8") if e.fp else ""
|
|
print(f"HTTP Error {e.code}: {e.reason}", file=sys.stderr)
|
|
if error_body:
|
|
try:
|
|
error_json = json.loads(error_body)
|
|
print(f"Details: {json.dumps(error_json, indent=2)}", file=sys.stderr)
|
|
except json.JSONDecodeError:
|
|
print(f"Details: {error_body}", file=sys.stderr)
|
|
sys.exit(1)
|
|
except urllib.error.URLError as e:
|
|
print(f"Connection Error: {e.reason}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def slugify(name: str) -> str:
|
|
"""Convert name to URL-safe slug."""
|
|
import re
|
|
|
|
slug = name.lower()
|
|
slug = re.sub(r"[^a-z0-9]", "-", slug)
|
|
slug = re.sub(r"-+", "-", slug)
|
|
return slug.strip("-")
|
|
|
|
|
|
def load_state() -> dict:
|
|
"""Load state from file."""
|
|
if STATE_FILE.exists():
|
|
return json.loads(STATE_FILE.read_text())
|
|
return {}
|
|
|
|
|
|
def save_state(state: dict):
|
|
"""Save state to file."""
|
|
STATE_FILE.write_text(json.dumps(state, indent=2))
|
|
|
|
|
|
def get_mission_state(slug: str) -> dict:
|
|
"""Get state for a specific mission."""
|
|
state = load_state()
|
|
return state.get(slug, {})
|
|
|
|
|
|
def update_mission_state(slug: str, updates: dict):
|
|
"""Update state for a specific mission."""
|
|
state = load_state()
|
|
if slug not in state:
|
|
state[slug] = {}
|
|
state[slug].update(updates)
|
|
save_state(state)
|
|
|
|
|
|
def remove_mission_state(slug: str):
|
|
"""Remove a mission from state."""
|
|
state = load_state()
|
|
if slug in state:
|
|
del state[slug]
|
|
save_state(state)
|
|
|
|
|
|
def save_memory(slug: str, key: str, value):
|
|
"""Save a piece of memory/data to the mission state."""
|
|
state = load_state()
|
|
if slug not in state:
|
|
state[slug] = {}
|
|
if "memory" not in state[slug]:
|
|
state[slug]["memory"] = {}
|
|
state[slug]["memory"][key] = value
|
|
state[slug]["last_updated"] = (
|
|
datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
)
|
|
save_state(state)
|
|
print(f"Saved memory '{key}' for mission '{slug}'")
|
|
|
|
|
|
def get_memory(slug: str, key: str = None):
|
|
"""Get memory from mission state. If key is None, returns all memory."""
|
|
state = get_mission_state(slug)
|
|
memory = state.get("memory", {})
|
|
if key:
|
|
return memory.get(key)
|
|
return memory
|
|
|
|
|
|
def append_memory(slug: str, key: str, item):
|
|
"""Append an item to a list in memory. Creates list if doesn't exist."""
|
|
state = load_state()
|
|
if slug not in state:
|
|
state[slug] = {}
|
|
if "memory" not in state[slug]:
|
|
state[slug]["memory"] = {}
|
|
if key not in state[slug]["memory"]:
|
|
state[slug]["memory"][key] = []
|
|
if not isinstance(state[slug]["memory"][key], list):
|
|
state[slug]["memory"][key] = [state[slug]["memory"][key]]
|
|
state[slug]["memory"][key].append(item)
|
|
state[slug]["last_updated"] = (
|
|
datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
)
|
|
save_state(state)
|
|
print(
|
|
f"Appended to memory '{key}' for mission '{slug}' (now {len(state[slug]['memory'][key])} items)"
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# MISSIONS API
|
|
# ============================================================================
|
|
|
|
|
|
def create_mission(name: str, instructions: str) -> str:
|
|
"""Create a new mission. Returns mission_id."""
|
|
result = api_request(
|
|
"POST", "/missions", {"name": name, "instructions": instructions}
|
|
)
|
|
mission = result.get("mission", result.get("data", result))
|
|
mission_id = mission.get("id") or mission.get("mission_id")
|
|
print(f"Created mission: {mission_id}")
|
|
return mission_id
|
|
|
|
|
|
def get_mission(mission_id: str) -> dict:
|
|
"""Get mission details."""
|
|
result = api_request("GET", f"/missions/{mission_id}")
|
|
mission = result.get("mission", result.get("data", result))
|
|
print(json.dumps(mission, indent=2))
|
|
return mission
|
|
|
|
|
|
def list_missions(page_size: int = 20) -> list:
|
|
"""List all missions."""
|
|
result = api_request("GET", f"/missions?page[size]={page_size}")
|
|
missions = result.get("missions", result.get("data", []))
|
|
for m in missions:
|
|
mid = m.get("id") or m.get("mission_id")
|
|
print(f"{mid}: {m.get('name', 'unnamed')}")
|
|
return missions
|
|
|
|
|
|
# ============================================================================
|
|
# RUNS API
|
|
# ============================================================================
|
|
|
|
|
|
def create_run(mission_id: str, input_data: dict) -> str:
|
|
"""Create a new run. Returns run_id."""
|
|
result = api_request(
|
|
"POST", f"/missions/{mission_id}/runs", {"input": input_data}
|
|
)
|
|
run = result.get("data", result)
|
|
run_id = run.get("run_id") or run.get("id")
|
|
print(f"Created run: {run_id}")
|
|
return run_id
|
|
|
|
|
|
def get_run(mission_id: str, run_id: str) -> dict:
|
|
"""Get run details."""
|
|
result = api_request("GET", f"/missions/{mission_id}/runs/{run_id}")
|
|
run = result.get("data", result)
|
|
print(json.dumps(run, indent=2))
|
|
return run
|
|
|
|
|
|
def update_run(
|
|
mission_id: str,
|
|
run_id: str,
|
|
status: str = None,
|
|
result_summary: str = None,
|
|
result_payload: dict = None,
|
|
) -> dict:
|
|
"""Update a run's status or results."""
|
|
data = {}
|
|
if status:
|
|
data["status"] = status
|
|
if result_summary:
|
|
data["result_summary"] = result_summary
|
|
if result_payload:
|
|
data["result_payload"] = result_payload
|
|
|
|
result = api_request("PATCH", f"/missions/{mission_id}/runs/{run_id}", data)
|
|
print(f"Updated run {run_id}: {status or 'updated'}")
|
|
return result.get("data", {})
|
|
|
|
|
|
def list_runs(mission_id: str) -> list:
|
|
"""List runs for a mission."""
|
|
result = api_request("GET", f"/missions/{mission_id}/runs")
|
|
runs = result.get("data", [])
|
|
for r in runs:
|
|
print(f"{r['run_id']}: {r['status']}")
|
|
return runs
|
|
|
|
|
|
# ============================================================================
|
|
# PLAN API
|
|
# ============================================================================
|
|
|
|
|
|
def create_plan(mission_id: str, run_id: str, steps: list) -> dict:
|
|
"""Create a plan with steps."""
|
|
result = api_request(
|
|
"POST", f"/missions/{mission_id}/runs/{run_id}/plan", {"steps": steps}
|
|
)
|
|
print(f"Created plan with {len(steps)} steps")
|
|
return result.get("data", {})
|
|
|
|
|
|
def get_plan(mission_id: str, run_id: str) -> dict:
|
|
"""Get the plan for a run."""
|
|
result = api_request("GET", f"/missions/{mission_id}/runs/{run_id}/plan")
|
|
print(json.dumps(result.get("data", {}), indent=2))
|
|
return result.get("data", {})
|
|
|
|
|
|
def update_plan_step(mission_id: str, run_id: str, step_id: str, status: str) -> dict:
|
|
"""Update a plan step's status. Valid statuses: pending, in_progress, completed, skipped, failed."""
|
|
result = api_request(
|
|
"PATCH",
|
|
f"/missions/{mission_id}/runs/{run_id}/plan/steps/{step_id}",
|
|
{"status": status},
|
|
)
|
|
print(f"Updated step '{step_id}': {status}")
|
|
return result.get("data", {})
|
|
|
|
|
|
# ============================================================================
|
|
# EVENTS API
|
|
# ============================================================================
|
|
|
|
|
|
def log_event(
|
|
mission_id: str,
|
|
run_id: str,
|
|
event_type: str,
|
|
summary: str,
|
|
agent_id: str = "claude-code",
|
|
step_id: str = None,
|
|
payload: dict = None,
|
|
) -> dict:
|
|
"""Log an event to the run."""
|
|
data = {
|
|
"type": event_type,
|
|
"summary": summary,
|
|
}
|
|
if agent_id:
|
|
data["agent_id"] = agent_id
|
|
if step_id:
|
|
data["step_id"] = step_id
|
|
if payload:
|
|
data["payload"] = payload
|
|
|
|
result = api_request(
|
|
"POST", f"/missions/{mission_id}/runs/{run_id}/events", data
|
|
)
|
|
print(f"Logged event: {summary}")
|
|
return result.get("data", {})
|
|
|
|
|
|
def list_events(mission_id: str, run_id: str) -> list:
|
|
"""List events for a run."""
|
|
result = api_request("GET", f"/missions/{mission_id}/runs/{run_id}/events")
|
|
events = result.get("data", [])
|
|
for e in events:
|
|
print(f"[{e.get('type')}] {e.get('summary')}")
|
|
return events
|
|
|
|
|
|
# ============================================================================
|
|
# ASSISTANTS API
|
|
# ============================================================================
|
|
|
|
|
|
def create_assistant(
|
|
name: str,
|
|
instructions: str,
|
|
greeting: str,
|
|
features: list = None, # defaults to ["telephony"]
|
|
model: str = "openai/gpt-4o",
|
|
tools: list = None,
|
|
description: str = None,
|
|
) -> str:
|
|
"""Create an AI assistant. Returns assistant_id.
|
|
|
|
Voice settings, transcription, and telephony defaults are applied
|
|
server-side. Pass extra Telnyx-specific config via extra_config if needed.
|
|
|
|
Tools can include any combination of:
|
|
- {"type": "hangup", "hangup": {"description": "..."}}
|
|
- {"type": "webhook", "webhook": {"name": "...", "description": "...", "url": "...", ...}}
|
|
- {"type": "transfer", "transfer": {"targets": [{"name": "...", "to": "..."}], "from": "...", ...}}
|
|
- {"type": "handoff", "handoff": {"ai_assistants": [{"name": "...", "id": "..."}], ...}}
|
|
- {"type": "retrieval", "retrieval": {"bucket_ids": ["..."], ...}}
|
|
- {"type": "refer", "refer": {"targets": [{"name": "...", "sip_address": "..."}], ...}}
|
|
- {"type": "send_dtmf", "send_dtmf": {}}
|
|
- {"type": "send_message", "send_message": {}}
|
|
|
|
If tools is None, defaults to a hangup tool.
|
|
"""
|
|
if tools is None:
|
|
tools = [
|
|
{
|
|
"type": "hangup",
|
|
"hangup": {
|
|
"description": "To be used whenever the conversation has ended and it would be appropriate to hangup the call."
|
|
},
|
|
},
|
|
{
|
|
"type": "send_dtmf",
|
|
"send_dtmf": {},
|
|
},
|
|
]
|
|
|
|
data = {
|
|
"name": name,
|
|
"model": model,
|
|
"instructions": instructions,
|
|
"greeting": greeting,
|
|
"tools": tools,
|
|
"enabled_features": features or ["telephony", "messaging"],
|
|
}
|
|
if description:
|
|
data["description"] = description
|
|
|
|
result = api_request("POST", "/assistants", data)
|
|
assistant = result.get("assistant", result)
|
|
assistant_id = assistant.get("id")
|
|
print(f"Created assistant: {assistant_id}")
|
|
return assistant_id
|
|
|
|
|
|
def list_assistants(
|
|
page_size: int = 25, page_number: int = 1, name_filter: str = None
|
|
) -> list:
|
|
"""List assistants with optional name filter and pagination.
|
|
|
|
Args:
|
|
name_filter: Filter assistants by name (substring match).
|
|
page_number: Page number (1-indexed).
|
|
page_size: Results per page.
|
|
"""
|
|
params = f"pageSize={page_size}&page={page_number}"
|
|
if name_filter:
|
|
params += f"&name={urllib.parse.quote(name_filter)}"
|
|
|
|
result = api_request("GET", f"/assistants?{params}")
|
|
assistants = result.get("assistants", result.get("data", []))
|
|
|
|
meta = result.get("meta", {})
|
|
total = meta.get("total_results") or meta.get("total") or len(assistants)
|
|
total_pages = meta.get("total_pages", 1)
|
|
|
|
for a in assistants:
|
|
aid = a.get("id") or a.get("assistant_id")
|
|
name = a.get("name", "unnamed")
|
|
model = a.get("model", "")
|
|
features = a.get("enabled_features", [])
|
|
print(f"{aid}: {name} (model={model}, features={features})")
|
|
|
|
print(f"\nPage {page_number}/{total_pages} ({total} total)", file=sys.stderr)
|
|
return assistants
|
|
|
|
|
|
def get_assistant(assistant_id: str) -> dict:
|
|
"""Get assistant details."""
|
|
result = api_request("GET", f"/assistants/{assistant_id}")
|
|
assistant = result.get("assistant", result)
|
|
print(json.dumps(assistant, indent=2))
|
|
return assistant
|
|
|
|
|
|
def update_assistant(assistant_id: str, updates: dict) -> dict:
|
|
"""Update an assistant. Accepts any fields from create_assistant."""
|
|
result = api_request("PATCH", f"/assistants/{assistant_id}", updates)
|
|
assistant = result.get("assistant", result)
|
|
print(f"Updated assistant {assistant_id}")
|
|
return assistant
|
|
|
|
|
|
def get_assistant_connection_id(assistant_id: str, feature: str = "telephony") -> str:
|
|
"""Get the connection ID for an assistant (texml_app_id or messaging_profile_id)."""
|
|
result = api_request(
|
|
"GET", f"/assistants/{assistant_id}/connection-id?feature={feature}"
|
|
)
|
|
|
|
conn_id = result.get("connection_id")
|
|
if conn_id:
|
|
print(f"Connection ID: {conn_id}")
|
|
else:
|
|
print(f"No connection ID found for {feature}", file=sys.stderr)
|
|
return conn_id
|
|
|
|
|
|
# ============================================================================
|
|
# PHONE NUMBERS API
|
|
# ============================================================================
|
|
|
|
|
|
def list_phone_numbers() -> list:
|
|
"""List phone numbers."""
|
|
result = api_request("GET", "/numbers/account-phones")
|
|
numbers = result.get("data", [])
|
|
|
|
for n in numbers:
|
|
status = "available" if not n.get("connection_id") else "assigned"
|
|
print(f"{n['id']}: {n['phone_number']} [{status}]")
|
|
|
|
return numbers
|
|
|
|
|
|
def get_available_phone_number(prefer_hd_voice: bool = True) -> tuple:
|
|
"""Get first available phone number. Returns (id, phone_number) or (None, None).
|
|
|
|
When prefer_hd_voice is True, prioritizes numbers that support HD voice,
|
|
but falls back to non-HD numbers if none are available.
|
|
"""
|
|
result = api_request("GET", "/numbers/account-phones/available")
|
|
phone = result.get("phone")
|
|
|
|
if not phone:
|
|
print("ERROR: No available phone numbers found", file=sys.stderr)
|
|
return None, None
|
|
|
|
print(f"Found available: {phone['phone_number']}")
|
|
return phone["id"], phone["phone_number"]
|
|
|
|
|
|
def assign_phone_number(
|
|
phone_number_id: str,
|
|
connection_id: str,
|
|
connection_type: str = "voice",
|
|
) -> dict:
|
|
"""Assign a phone number to a connection."""
|
|
data = {
|
|
"connection_id": connection_id,
|
|
"type": connection_type,
|
|
}
|
|
|
|
result = api_request("PATCH", f"/numbers/account-phones/{phone_number_id}", data)
|
|
print(f"Assigned phone number {phone_number_id}")
|
|
return result.get("data", {})
|
|
|
|
|
|
# ============================================================================
|
|
# SCHEDULED EVENTS API
|
|
# ============================================================================
|
|
|
|
|
|
def schedule_call(
|
|
assistant_id: str,
|
|
end_user_phone: str,
|
|
agent_phone: str,
|
|
scheduled_time: str,
|
|
mission_id: str,
|
|
mission_run_id: str,
|
|
step_id: str = None,
|
|
) -> str:
|
|
"""Schedule a phone call. Returns event id.
|
|
|
|
When step_id is provided, the server automatically updates the corresponding
|
|
plan step to 'completed' when the call finishes, and marks the mission as
|
|
'succeeded' once all scheduled events reach a terminal state.
|
|
"""
|
|
data = {
|
|
"channel": "call",
|
|
"to": end_user_phone,
|
|
"from": agent_phone,
|
|
"scheduled_at": scheduled_time,
|
|
"mission_id": mission_id,
|
|
"run_id": mission_run_id,
|
|
}
|
|
if step_id:
|
|
data["step_id"] = step_id
|
|
|
|
result = api_request(
|
|
"POST", f"/assistants/{assistant_id}/events", data
|
|
)
|
|
event = result.get("event", result)
|
|
event_id = event.get("id")
|
|
print(f"Scheduled call: {event_id}")
|
|
return event_id
|
|
|
|
|
|
def schedule_sms(
|
|
assistant_id: str,
|
|
end_user_phone: str,
|
|
agent_phone: str,
|
|
scheduled_time: str,
|
|
text: str,
|
|
mission_id: str = None,
|
|
mission_run_id: str = None,
|
|
step_id: str = None,
|
|
) -> str:
|
|
"""Schedule an SMS. Returns event id.
|
|
|
|
When mission_id/run_id/step_id are provided, the server automatically
|
|
updates the plan step and mission status when the SMS completes.
|
|
"""
|
|
data = {
|
|
"channel": "sms",
|
|
"to": end_user_phone,
|
|
"from": agent_phone,
|
|
"scheduled_at": scheduled_time,
|
|
"text_body": text,
|
|
}
|
|
if mission_id:
|
|
data["mission_id"] = mission_id
|
|
if mission_run_id:
|
|
data["run_id"] = mission_run_id
|
|
if step_id:
|
|
data["step_id"] = step_id
|
|
|
|
result = api_request(
|
|
"POST", f"/assistants/{assistant_id}/events", data
|
|
)
|
|
event = result.get("event", result)
|
|
event_id = event.get("id")
|
|
print(f"Scheduled SMS: {event_id}")
|
|
return event_id
|
|
|
|
|
|
def get_scheduled_event(assistant_id: str, event_id: str) -> dict:
|
|
"""Get scheduled event status including call_status for phone calls."""
|
|
result = api_request(
|
|
"GET", f"/assistants/{assistant_id}/events/{event_id}"
|
|
)
|
|
event = result.get("event", result)
|
|
status = event.get("status", "unknown")
|
|
call_status = event.get("call_status")
|
|
conv_id = event.get("conversation_id")
|
|
retry_count = event.get("retry_count", 0)
|
|
parts = [f"Status: {status}"]
|
|
if call_status:
|
|
parts.append(f"call_status: {call_status}")
|
|
if conv_id:
|
|
parts.append(f"conversation_id: {conv_id}")
|
|
if retry_count > 0:
|
|
parts.append(f"retry_count: {retry_count}")
|
|
print(", ".join(parts))
|
|
return event
|
|
|
|
|
|
def delete_scheduled_event(assistant_id: str, event_id: str):
|
|
"""Delete a scheduled event."""
|
|
api_request(
|
|
"DELETE", f"/assistants/{assistant_id}/events/{event_id}"
|
|
)
|
|
print(f"Deleted scheduled event: {event_id}")
|
|
|
|
|
|
def list_scheduled_events(assistant_id: str) -> list:
|
|
"""List scheduled events for an assistant."""
|
|
result = api_request("GET", f"/assistants/{assistant_id}/events")
|
|
events = result.get("events", result.get("data", []))
|
|
for e in events:
|
|
parts = [f"{e.get('id')}: {e.get('status')}"]
|
|
call_status = e.get("call_status")
|
|
if call_status:
|
|
parts.append(f"call_status={call_status}")
|
|
print(", ".join(parts))
|
|
return events
|
|
|
|
|
|
# ============================================================================
|
|
# MISSION RUN AGENTS API (Linking Telnyx agents to mission runs)
|
|
# ============================================================================
|
|
|
|
|
|
def link_telnyx_agent(mission_id: str, run_id: str, telnyx_agent_id: str) -> dict:
|
|
"""Link a Telnyx agent (assistant) to a mission run."""
|
|
data = {"telnyx_agent_id": telnyx_agent_id}
|
|
result = api_request(
|
|
"POST", f"/missions/{mission_id}/runs/{run_id}/agents", data
|
|
)
|
|
print(f"Linked agent {telnyx_agent_id} to run {run_id}")
|
|
return result.get("data", {})
|
|
|
|
|
|
def list_linked_agents(mission_id: str, run_id: str) -> list:
|
|
"""List Telnyx agents linked to a mission run."""
|
|
result = api_request(
|
|
"GET", f"/missions/{mission_id}/runs/{run_id}/agents"
|
|
)
|
|
agents = result.get("data", [])
|
|
for a in agents:
|
|
print(f"Agent: {a.get('telnyx_agent_id')}")
|
|
return agents
|
|
|
|
|
|
def unlink_telnyx_agent(mission_id: str, run_id: str, telnyx_agent_id: str):
|
|
"""Unlink a Telnyx agent from a mission run."""
|
|
api_request(
|
|
"DELETE",
|
|
f"/missions/{mission_id}/runs/{run_id}/agents/{telnyx_agent_id}",
|
|
)
|
|
print(f"Unlinked agent {telnyx_agent_id} from run {run_id}")
|
|
|
|
|
|
# ============================================================================
|
|
# CONVERSATION INSIGHTS API
|
|
# ============================================================================
|
|
|
|
|
|
def get_conversation_insights(conversation_id: str) -> list:
|
|
"""Get insights from a conversation."""
|
|
result = api_request(
|
|
"GET", f"/missions/conversations/{conversation_id}/insights"
|
|
)
|
|
insights = result.get("data", [])
|
|
|
|
for insight in insights:
|
|
if insight.get("status") == "completed":
|
|
for ci in insight.get("conversation_insights", []):
|
|
print(f"Insight: {ci.get('result')}")
|
|
|
|
return insights
|
|
|
|
|
|
# ============================================================================
|
|
# HIGH-LEVEL WORKFLOW FUNCTIONS
|
|
# ============================================================================
|
|
|
|
|
|
def init_mission(
|
|
name: str, instructions: str, user_request: str, plan_steps: list = None
|
|
) -> tuple:
|
|
"""
|
|
Initialize a mission with run and optional plan.
|
|
Returns (mission_id, run_id).
|
|
Resumes existing mission if found.
|
|
"""
|
|
slug = slugify(name)
|
|
existing = get_mission_state(slug)
|
|
|
|
if existing.get("mission_id") and existing.get("run_id"):
|
|
print(f"Resuming existing mission: {slug}")
|
|
return existing["mission_id"], existing["run_id"]
|
|
|
|
# Create new mission
|
|
mission_id = create_mission(name, instructions)
|
|
update_mission_state(
|
|
slug,
|
|
{
|
|
"mission_name": name,
|
|
"mission_id": mission_id,
|
|
"created_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
},
|
|
)
|
|
|
|
# Create run
|
|
run_id = create_run(mission_id, {"original_request": user_request})
|
|
update_mission_state(slug, {"run_id": run_id})
|
|
|
|
# Create plan if provided
|
|
if plan_steps:
|
|
create_plan(mission_id, run_id, plan_steps)
|
|
|
|
# Set running
|
|
update_run(mission_id, run_id, status="running")
|
|
|
|
return mission_id, run_id
|
|
|
|
|
|
def setup_voice_agent(
|
|
mission_slug: str,
|
|
name: str,
|
|
instructions: str,
|
|
greeting: str,
|
|
tools: list = None,
|
|
model: str = "openai/gpt-4o",
|
|
) -> tuple:
|
|
"""
|
|
Create voice assistant, assign phone number, and link to mission run.
|
|
Returns (assistant_id, phone_number).
|
|
|
|
Voice settings, transcription, and telephony defaults are applied server-side.
|
|
"""
|
|
existing = get_mission_state(mission_slug)
|
|
|
|
if existing.get("assistant_id") and existing.get("agent_phone"):
|
|
print(f"Using existing assistant: {existing['assistant_id']}")
|
|
return existing["assistant_id"], existing["agent_phone"]
|
|
|
|
# Create assistant
|
|
kwargs = {}
|
|
if tools is not None:
|
|
kwargs["tools"] = tools
|
|
assistant_id = create_assistant(
|
|
name, instructions, greeting, ["telephony", "messaging"], model=model, **kwargs
|
|
)
|
|
update_mission_state(mission_slug, {"assistant_id": assistant_id})
|
|
|
|
# Link agent to mission run if IDs are available
|
|
mission_id = existing.get("mission_id")
|
|
run_id = existing.get("run_id")
|
|
if mission_id and run_id:
|
|
link_telnyx_agent(mission_id, run_id, assistant_id)
|
|
|
|
# Get available phone number
|
|
phone_id, phone_number = get_available_phone_number()
|
|
if not phone_id:
|
|
return assistant_id, None
|
|
|
|
# Get connection ID and assign
|
|
connection_id = get_assistant_connection_id(assistant_id, "telephony")
|
|
if connection_id:
|
|
assign_phone_number(phone_id, connection_id, "voice")
|
|
update_mission_state(
|
|
mission_slug, {"agent_phone": phone_number, "phone_number_id": phone_id}
|
|
)
|
|
|
|
return assistant_id, phone_number
|
|
|
|
|
|
def complete_mission(
|
|
mission_slug: str, mission_id: str, run_id: str, summary: str, payload: dict = None
|
|
):
|
|
"""Complete a mission and clean up state."""
|
|
update_run(
|
|
mission_id,
|
|
run_id,
|
|
status="succeeded",
|
|
result_summary=summary,
|
|
result_payload=payload,
|
|
)
|
|
remove_mission_state(mission_slug)
|
|
print(f"Mission '{mission_slug}' completed successfully")
|
|
|
|
|
|
# ============================================================================
|
|
# CLI INTERFACE
|
|
# ============================================================================
|
|
|
|
|
|
def print_usage():
|
|
"""Print usage information."""
|
|
print("""
|
|
ClawdTalk AI Missions API Client
|
|
|
|
Usage: python telnyx_api.py <command> [args...]
|
|
|
|
Commands:
|
|
check-key Verify API key is set
|
|
|
|
Missions:
|
|
create-mission <name> <instructions>
|
|
get-mission <mission_id>
|
|
list-missions
|
|
|
|
Runs:
|
|
create-run <mission_id> <input_json>
|
|
get-run <mission_id> <run_id>
|
|
update-run <mission_id> <run_id> <status>
|
|
list-runs <mission_id>
|
|
|
|
Plan:
|
|
create-plan <mission_id> <run_id> <steps_json>
|
|
get-plan <mission_id> <run_id>
|
|
update-step <mission_id> <run_id> <step_id> <status>
|
|
status: pending, in_progress, completed, skipped, failed
|
|
|
|
Events:
|
|
log-event <mission_id> <run_id> <type> <summary> <step_id> [payload_json]
|
|
list-events <mission_id> <run_id>
|
|
|
|
Assistants:
|
|
create-assistant <name> <instructions> <greeting> [options_json]
|
|
options_json keys: features, model, tools, description
|
|
Voice/transcription/telephony defaults applied server-side.
|
|
Tool types: hangup, webhook, transfer, handoff, retrieval, refer,
|
|
send_dtmf, send_message
|
|
list-assistants [--name=<filter>] [--page=<n>] [--size=<n>]
|
|
get-assistant <assistant_id>
|
|
update-assistant <assistant_id> <updates_json>
|
|
get-connection-id <assistant_id> [telephony|messaging]
|
|
|
|
Phone Numbers:
|
|
list-phones
|
|
get-available-phone
|
|
assign-phone <phone_id> <connection_id> [voice|sms]
|
|
|
|
Scheduled Events:
|
|
schedule-call <assistant_id> <to_phone> <from_phone> <datetime> <mission_id> <run_id> [step_id]
|
|
step_id links to a plan step — server auto-syncs step status when call completes
|
|
schedule-sms <assistant_id> <to_phone> <from_phone> <datetime> <text> [mission_id] [run_id] [step_id]
|
|
mission_id/run_id/step_id enable auto-sync of plan steps and mission status
|
|
get-event <assistant_id> <event_id>
|
|
cancel-scheduled-event <assistant_id> <event_id>
|
|
list-events-assistant <assistant_id>
|
|
|
|
Insights (conversation results):
|
|
get-insights <conversation_id>
|
|
|
|
Mission Run Agents (linking agents to runs):
|
|
link-agent <mission_id> <run_id> <assistant_id>
|
|
list-linked-agents <mission_id> <run_id>
|
|
unlink-agent <mission_id> <run_id> <assistant_id>
|
|
|
|
State:
|
|
list-state List all missions in state file
|
|
get-state <slug> Get state for a mission
|
|
remove-state <slug> Remove mission from state
|
|
|
|
Memory (SAVE OFTEN!):
|
|
save-memory <slug> <key> <value_json> Save data to mission memory
|
|
get-memory <slug> [key] Get memory (all or specific key)
|
|
append-memory <slug> <key> <item_json> Append item to a list in memory
|
|
|
|
High-level:
|
|
init <name> <instructions> <request> [steps_json]
|
|
setup-agent <slug> <name> <instructions> <greeting>
|
|
complete <slug> <mission_id> <run_id> <summary> [payload_json]
|
|
|
|
Examples:
|
|
python telnyx_api.py create-mission "Find contractors" "Call and negotiate"
|
|
python telnyx_api.py create-run mis_abc123 '{"request": "Find window washers"}'
|
|
python telnyx_api.py schedule-call ast_xyz "+15551234567" "+15559876543" "2024-12-01T15:00:00Z" mis_abc run_def call_1
|
|
""")
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) < 2:
|
|
print_usage()
|
|
sys.exit(1)
|
|
|
|
cmd = sys.argv[1]
|
|
args = sys.argv[2:]
|
|
|
|
try:
|
|
# Basic
|
|
if cmd == "check-key":
|
|
key = get_api_key()
|
|
print(f"API key configured: {key[:8]}...{key[-4:]}")
|
|
|
|
# Missions
|
|
elif cmd == "create-mission":
|
|
create_mission(args[0], args[1])
|
|
elif cmd == "get-mission":
|
|
get_mission(args[0])
|
|
elif cmd == "list-missions":
|
|
list_missions()
|
|
|
|
# Runs
|
|
elif cmd == "create-run":
|
|
create_run(args[0], json.loads(args[1]))
|
|
elif cmd == "get-run":
|
|
get_run(args[0], args[1])
|
|
elif cmd == "update-run":
|
|
update_run(args[0], args[1], status=args[2])
|
|
elif cmd == "list-runs":
|
|
list_runs(args[0])
|
|
|
|
# Plan
|
|
elif cmd == "create-plan":
|
|
create_plan(args[0], args[1], json.loads(args[2]))
|
|
elif cmd == "get-plan":
|
|
get_plan(args[0], args[1])
|
|
elif cmd == "update-step":
|
|
update_plan_step(args[0], args[1], args[2], args[3])
|
|
|
|
# Events
|
|
elif cmd == "log-event":
|
|
# log-event <mission_id> <run_id> <type> <summary> <step_id> [payload_json]
|
|
# step_id is required - use "-" if event doesn't belong to a specific step
|
|
if len(args) < 5:
|
|
print("ERROR: log-event requires at least 5 arguments: <mission_id> <run_id> <type> <summary> <step_id>", file=sys.stderr)
|
|
print('Use "-" for step_id if event doesn\'t belong to a specific step', file=sys.stderr)
|
|
sys.exit(1)
|
|
if args[4].startswith("{") or args[4].startswith("["):
|
|
print("ERROR: step_id appears to be JSON. You likely forgot the step_id argument.", file=sys.stderr)
|
|
print("Expected: log-event <mission_id> <run_id> <type> <summary> <step_id> [payload_json]", file=sys.stderr)
|
|
print('Use "-" for step_id if event doesn\'t belong to a specific step', file=sys.stderr)
|
|
sys.exit(1)
|
|
step_id = args[4] if args[4] != "-" else None
|
|
payload = json.loads(args[5]) if len(args) > 5 else None
|
|
log_event(
|
|
args[0], args[1], args[2], args[3], step_id=step_id, payload=payload
|
|
)
|
|
elif cmd == "list-events":
|
|
list_events(args[0], args[1])
|
|
|
|
# Assistants
|
|
elif cmd == "create-assistant":
|
|
# create-assistant <name> <instructions> <greeting> [features_or_options_json]
|
|
opts = {}
|
|
if len(args) > 3:
|
|
parsed = json.loads(args[3])
|
|
if isinstance(parsed, dict):
|
|
opts = parsed
|
|
create_assistant(
|
|
args[0],
|
|
args[1],
|
|
args[2],
|
|
features=opts.pop("features", None) or opts.pop("enabled_features", None),
|
|
**opts,
|
|
)
|
|
elif cmd == "list-assistants":
|
|
# list-assistants [--name=<filter>] [--page=<n>] [--size=<n>]
|
|
name_filter = None
|
|
page_num = 1
|
|
page_size = 25
|
|
for a in args:
|
|
if a.startswith("--name="):
|
|
name_filter = a.split("=", 1)[1]
|
|
elif a.startswith("--page="):
|
|
page_num = int(a.split("=", 1)[1])
|
|
elif a.startswith("--size="):
|
|
page_size = int(a.split("=", 1)[1])
|
|
list_assistants(
|
|
page_size=page_size, page_number=page_num, name_filter=name_filter
|
|
)
|
|
elif cmd == "get-assistant":
|
|
get_assistant(args[0])
|
|
elif cmd == "update-assistant":
|
|
updates = json.loads(args[1])
|
|
update_assistant(args[0], updates)
|
|
elif cmd == "get-connection-id":
|
|
feature = args[1] if len(args) > 1 else "telephony"
|
|
get_assistant_connection_id(args[0], feature)
|
|
|
|
# Phone Numbers
|
|
elif cmd == "list-phones":
|
|
list_phone_numbers()
|
|
elif cmd == "get-available-phone":
|
|
get_available_phone_number()
|
|
elif cmd == "assign-phone":
|
|
conn_type = args[2] if len(args) > 2 else "voice"
|
|
assign_phone_number(args[0], args[1], conn_type)
|
|
|
|
# Scheduled Events
|
|
elif cmd == "schedule-call":
|
|
# schedule-call <assistant_id> <to> <from> <datetime> <mission_id> <run_id> [step_id]
|
|
step_id = args[6] if len(args) > 6 else None
|
|
schedule_call(args[0], args[1], args[2], args[3], args[4], args[5], step_id)
|
|
elif cmd == "schedule-sms":
|
|
# schedule-sms <assistant_id> <to> <from> <datetime> <text> [mission_id] [run_id] [step_id]
|
|
sms_mission_id = args[5] if len(args) > 5 else None
|
|
sms_run_id = args[6] if len(args) > 6 else None
|
|
sms_step_id = args[7] if len(args) > 7 else None
|
|
schedule_sms(args[0], args[1], args[2], args[3], args[4], sms_mission_id, sms_run_id, sms_step_id)
|
|
elif cmd == "get-event":
|
|
get_scheduled_event(args[0], args[1])
|
|
elif cmd == "cancel-scheduled-event":
|
|
delete_scheduled_event(args[0], args[1])
|
|
elif cmd == "list-events-assistant":
|
|
list_scheduled_events(args[0])
|
|
|
|
# Insights (conversation results)
|
|
elif cmd == "get-insights":
|
|
get_conversation_insights(args[0])
|
|
|
|
# Mission Run Agents (linking)
|
|
elif cmd == "link-agent":
|
|
link_telnyx_agent(args[0], args[1], args[2])
|
|
elif cmd == "list-linked-agents":
|
|
list_linked_agents(args[0], args[1])
|
|
elif cmd == "unlink-agent":
|
|
unlink_telnyx_agent(args[0], args[1], args[2])
|
|
|
|
# State management
|
|
elif cmd == "list-state":
|
|
state = load_state()
|
|
for slug, data in state.items():
|
|
print(
|
|
f"{slug}: {data.get('mission_name')} [{data.get('status', 'unknown')}]"
|
|
)
|
|
elif cmd == "get-state":
|
|
state = get_mission_state(args[0])
|
|
print(json.dumps(state, indent=2))
|
|
elif cmd == "remove-state":
|
|
remove_mission_state(args[0])
|
|
print(f"Removed {args[0]} from state")
|
|
|
|
# Memory
|
|
elif cmd == "save-memory":
|
|
value = (
|
|
json.loads(args[2])
|
|
if args[2].startswith("{") or args[2].startswith("[")
|
|
else args[2]
|
|
)
|
|
save_memory(args[0], args[1], value)
|
|
elif cmd == "get-memory":
|
|
key = args[1] if len(args) > 1 else None
|
|
memory = get_memory(args[0], key)
|
|
if memory is not None:
|
|
print(
|
|
json.dumps(memory, indent=2)
|
|
if isinstance(memory, (dict, list))
|
|
else memory
|
|
)
|
|
else:
|
|
print("No memory found" if key else "{}")
|
|
elif cmd == "append-memory":
|
|
item = (
|
|
json.loads(args[2])
|
|
if args[2].startswith("{") or args[2].startswith("[")
|
|
else args[2]
|
|
)
|
|
append_memory(args[0], args[1], item)
|
|
|
|
# High-level
|
|
elif cmd == "init":
|
|
steps = json.loads(args[3]) if len(args) > 3 else None
|
|
init_mission(args[0], args[1], args[2], steps)
|
|
elif cmd == "setup-agent":
|
|
# setup-agent <slug> <name> <instructions> <greeting> [options_json]
|
|
opts = json.loads(args[4]) if len(args) > 4 else {}
|
|
setup_voice_agent(args[0], args[1], args[2], args[3], **opts)
|
|
elif cmd == "complete":
|
|
payload = json.loads(args[4]) if len(args) > 4 else None
|
|
complete_mission(args[0], args[1], args[2], args[3], payload)
|
|
|
|
else:
|
|
print(f"Unknown command: {cmd}", file=sys.stderr)
|
|
print_usage()
|
|
sys.exit(1)
|
|
|
|
except IndexError:
|
|
print(f"Missing arguments for command: {cmd}", file=sys.stderr)
|
|
print_usage()
|
|
sys.exit(1)
|
|
except json.JSONDecodeError as e:
|
|
print(f"Invalid JSON: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|