diff --git a/CHANGELOG.md b/CHANGELOG.md index 1011f0a..fcd1272 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [0.4.0] — 2025-02-28 ### Added -- OpenClaw email skill — installable `commune-email` skill with companion CLI helpers and an automated install script. +- `openclaw-email-sms/` — Email & SMS skills for OpenClaw agents. Includes two installable skills (`commune-email`, `commune-sms`) with companion CLI helpers and an automated install script. +- `capabilities/phone-numbers/` — Full phone number management guide: provisioning, toll-free vs local, allow/block lists, MMS +- `capabilities/sms/` — SMS capability deep-dive: send, receive webhooks, two-way conversations ### Changed - Master README reorganized with three sections: Platforms, Use Cases, Capabilities @@ -29,11 +31,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - `use-cases/` section — five use case categories with working examples: - - `customer-support/` — email agent, omnichannel - - `hiring-and-recruiting/` — candidate outreach, interview scheduler - - `sales-and-marketing/` — cold email sequences, newsletter + - `customer-support/` — email agent, SMS bot, omnichannel + - `hiring-and-recruiting/` — SMS worker dispatch, candidate outreach, interview scheduler + - `sales-and-marketing/` — cold email sequences, SMS lead qualification, newsletter - `research/` — email research agent - - `notifications-and-alerts/` — incident alerts + - `notifications-and-alerts/` — incident alerts, transactional SMS - `capabilities/` section — six capability deep-dives: - `quickstart/`, `email-threading/`, `extraction/`, `search/`, `webhooks/` @@ -43,7 +45,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - `typescript/` — Webhook handler and multi-agent coordination in TypeScript -- `mcp-server/` — MCP server with Claude Desktop + Cursor configuration +- `sms/` — SMS alert agent and two-way SMS conversation handler +- `mcp-server/` — 13-tool MCP server with Claude Desktop + Cursor configuration --- @@ -51,7 +54,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - Initial release with examples for four platforms: - - `langchain/` — customer support, lead outreach + - `langchain/` — customer support, lead outreach, email+SMS tools - `crewai/` — support crew, outreach crew - `openai-agents/` — support agent, multi-agent handoff - `claude/` — support agent, extraction agent diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 94af6eb..df9941b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,11 +1,11 @@ # Contributing to email-for-agents -Thanks for contributing! This repo exists to help developers give their AI agents a real email address — the more examples and frameworks covered, the more useful it is for everyone. +Thanks for contributing! This repo exists to help developers give their AI agents a real email address and phone number — the more examples and frameworks covered, the more useful it is for everyone. ## What we welcome - **New platform integrations** — AutoGen, Pydantic AI, Haystack, Dify, n8n, Flowise, etc. -- **New use-case examples** — real problems AI agents solve using email +- **New use-case examples** — real problems AI agents solve using email and SMS - **Bug fixes** — if an example is broken, out of date, or uses the wrong API - **Capability demos** — showing off specific Commune features (threading, extraction, vector search, webhooks) - **Better documentation** — clearer READMEs, more helpful `.env.example` files @@ -24,7 +24,7 @@ Thanks for contributing! This repo exists to help developers give their AI agent - Python: `ruff` for linting (`pip install ruff && ruff check .`) - TypeScript: strict mode, `tsc --noEmit` should pass -- No real API keys or email addresses in committed files +- No real API keys, email addresses, or phone numbers in committed files - Every `.env.example` uses obvious placeholders (`your_api_key_here`, not `sk-abc123`) ## Example structure diff --git a/README.md b/README.md index e57d1db..854f7bc 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,11 @@ [![LangChain](https://img.shields.io/badge/LangChain-compatible-green)](langchain/) [![CrewAI](https://img.shields.io/badge/CrewAI-compatible-orange)](crewai/) [![MCP](https://img.shields.io/badge/MCP-server-purple)](mcp-server/) -[![OpenClaw](https://img.shields.io/badge/OpenClaw-skills-orange)](openclaw-email-quickstart/) +[![OpenClaw](https://img.shields.io/badge/OpenClaw-skills-orange)](openclaw-email-sms/) -# Email for AI Agents +# Email & SMS for AI Agents -**Give your AI agent a real email address. Production-ready examples for LangChain, CrewAI, OpenAI Agents SDK, Claude, MCP, OpenClaw, and agent-to-agent networks. Powered by [Commune](https://commune.email).** +**Give your AI agent a real email address and phone number. Production-ready examples for LangChain, CrewAI, OpenAI Agents SDK, Claude, MCP, OpenClaw, and agent-to-agent networks. Powered by [Commune](https://commune.email).** --- @@ -50,20 +50,21 @@ That's a real, deliverable inbox. Your agent can now send replies, search its th ## OpenClaw -> **OpenClaw users:** install the Commune email skill in 30 seconds. Your agent gets a real inbox it can use from WhatsApp, Telegram, or wherever it lives. +> **OpenClaw users:** install the Commune email + SMS skills in 30 seconds. Your agent gets a real inbox and phone number it can use from WhatsApp, Telegram, or wherever it lives. ```bash -git clone https://github.com/commune-dev/commune-openclaw-email-quickstart -cd commune-openclaw-email-quickstart +git clone https://github.com/shanjai-raj/commune-openclaw-email-sms-quickstart +cd commune-openclaw-email-sms-quickstart bash install.sh ``` -The installer copies the skill into `~/.openclaw/workspace/skills/`: +The installer copies two skills into `~/.openclaw/workspace/skills/`: - **`commune-email`** — send, receive, search, and manage email threads +- **`commune-sms`** — send SMS, read conversations, manage phone numbers -Once installed, your agent understands natural-language commands like "check my email" and "reply to that thread about the contract." +Once installed, your agent understands natural-language commands like "check my email", "reply to that thread about the contract", and "text Alex that I'm running late." -[→ Dedicated repo with use cases](https://github.com/commune-dev/commune-openclaw-email-quickstart) +[→ Full OpenClaw setup guide](openclaw-email-sms/) · [→ Dedicated repo with use cases](https://github.com/shanjai-raj/commune-openclaw-email-sms-quickstart) --- @@ -112,6 +113,7 @@ No shared database. No coordination layer. The thread IS the task context. | Customer Support Agent | [✅](langchain/) | [✅](crewai/) | [✅](openai-agents/) | [✅](claude/) | [✅](mcp-server/) | [✅](typescript/) | — | | Lead Outreach | [✅](langchain/) | [✅](crewai/) | — | [✅](claude/) | — | — | — | | Multi-Agent Coordination | — | [✅](crewai/) | — | — | — | [✅](typescript/) | [✅](agent-to-agent/) | +| SMS Notifications | — | — | — | — | [✅](mcp-server/) | [✅](typescript/) | — | | Structured Extraction | — | — | — | [✅](claude/) | [✅](mcp-server/) | — | — | | Webhook Handler | — | — | — | — | — | [✅](typescript/) | — | | Task Delegation | — | — | — | — | — | — | [✅](agent-to-agent/) | @@ -120,11 +122,12 @@ No shared database. No coordination layer. The thread IS the task context. ## Why email for agents? -Most agent frameworks are great at reasoning — but stop short when it comes to communicating with the outside world asynchronously. Email fills that gap: +Most agent frameworks are great at reasoning — but stop short when it comes to communicating with the outside world asynchronously. Email and SMS fill that gap: - **Agents are async by nature.** A task might take minutes or hours. Email is the right protocol for async handoffs — your agent sends, the user replies when ready, the thread stays intact. - **Email is the universal protocol.** Every system on the planet speaks SMTP. Your agent can talk to any user, any tool, any service — no integration required. - **Threading keeps context.** `In-Reply-To` and `References` headers (RFC 5322) tie every message to its thread. Your agent never loses the conversation history. +- **SMS adds the urgency channel.** Some things need immediate attention. Two-way SMS lets your agent escalate, notify, and confirm — all from a real phone number. - **Agent-to-agent is next.** Agents will increasingly communicate with other agents — delegating tasks, routing results, building mesh networks. Email is the right protocol: async, persistent, addressable, universally supported. ### What Commune adds on top of bare SMTP @@ -188,9 +191,16 @@ Every example is self-contained: install, set your key, run. ### OpenClaw -OpenClaw is the most popular open-source personal agent framework. Commune provides a first-party email skill — install once, and your OpenClaw agent can manage a real inbox from any chat interface. +OpenClaw is the most popular open-source personal agent framework. Commune provides first-party skills for email and SMS — install once, and your OpenClaw agent can manage a real inbox from any chat interface. -[→ Dedicated quickstart repo](https://github.com/commune-dev/commune-openclaw-email-quickstart) +| Example | Description | +|---------|-------------| +| [Personal Assistant](openclaw-email-sms/use-cases/personal-assistant/) | Agent manages your personal email — check, reply, summarize from WhatsApp | +| [Company Agent](openclaw-email-sms/use-cases/company-assistant/) | Agent handles customer email: triage, draft replies, SMS escalation | +| [Skill: commune-email](openclaw-email-sms/skills/) | Full email skill: create inboxes, read threads, send, reply, search | +| [Skill: commune-sms](openclaw-email-sms/skills/) | SMS skill: send, receive, list phone numbers | + +[→ See all OpenClaw examples](openclaw-email-sms/) · [→ Dedicated quickstart repo](https://github.com/shanjai-raj/commune-openclaw-email-sms-quickstart) --- @@ -210,7 +220,7 @@ Each agent gets its own inbox address. Agents delegate tasks by sending emails, ### LangChain -LangChain tools wrap Commune with the `@tool` decorator. Your chain gains `send_email`, `read_inbox`, and `search_threads` as first-class tools — callable by any LLM in the chain. +LangChain tools wrap Commune with the `@tool` decorator. Your chain gains `send_email`, `read_inbox`, `search_threads`, and `send_sms` as first-class tools — callable by any LLM in the chain. | Example | Description | |---------|-------------| @@ -268,6 +278,7 @@ Run `commune-mcp` as a local MCP server and connect it to Claude Desktop, Cursor | Example | Description | |---------|-------------| | [Customer Support via MCP](mcp-server/) | Full support workflow through Claude Desktop | +| [SMS Notifications via MCP](mcp-server/) | Provision a number and send SMS from within a chat session | | [Structured Extraction via MCP](mcp-server/) | Define schemas and extract structured data from inbound mail | [→ See all MCP examples](mcp-server/) @@ -276,12 +287,13 @@ Run `commune-mcp` as a local MCP server and connect it to Claude Desktop, Cursor ### TypeScript -Full end-to-end TypeScript examples: webhook handlers with HMAC verification and multi-agent coordination with typed payloads — all typed against the `commune-ai` SDK. +Full end-to-end TypeScript examples: webhook handlers with HMAC verification, multi-agent coordination with typed payloads, and SMS flows — all typed against the `commune-ai` SDK. | Example | Description | |---------|-------------| | [Customer Support Agent](typescript/) | Express webhook handler + Commune reply flow | | [Multi-Agent Coordination](typescript/) | Two agents hand off tasks over email with typed thread payloads | +| [SMS Notifications](typescript/) | Provision a number, send SMS, handle inbound replies | | [Webhook Handler](typescript/) | Reference implementation with `verifyCommuneWebhook` and retry-safe handling | [→ See all TypeScript examples](typescript/) @@ -295,9 +307,12 @@ Browse examples by what you want to build: | Use Case | Channel | Complexity | |----------|---------|------------| | [AI Email Support Agent](use-cases/customer-support/email-support-agent/) | Email | Beginner | +| [SMS Worker Dispatch](use-cases/hiring-and-recruiting/sms-worker-dispatch/) | SMS | Intermediate | | [Candidate Outreach Sequence](use-cases/hiring-and-recruiting/candidate-email-outreach/) | Email | Intermediate | | [Cold Email Outreach](use-cases/sales-and-marketing/cold-outreach-sequences/) | Email | Intermediate | -| [Incident Alert System](use-cases/notifications-and-alerts/incident-alerts/) | Email | Advanced | +| [SMS Lead Qualification](use-cases/sales-and-marketing/sms-lead-qualification/) | SMS | Intermediate | +| [Omnichannel Support](use-cases/customer-support/omnichannel-support/) | Email + SMS | Advanced | +| [Incident Alert System](use-cases/notifications-and-alerts/incident-alerts/) | Email + SMS | Advanced | | [Multi-Agent Coordination](typescript/multi-agent/) | Email | Advanced | | [Agent-to-Agent Task Delegation](agent-to-agent/) | Email | Advanced | @@ -311,16 +326,41 @@ Reference examples for every Commune feature: | Capability | What it does | Get started | |-----------|-------------|-------------| -| [Quickstart](capabilities/quickstart/) | Give your agent an email address | 3 lines of code | +| [Quickstart](capabilities/quickstart/) | Give your agent an email + phone | 3 lines of code | | [Email Threading](capabilities/email-threading/) | Reply in the same thread | RFC 5322 explained | | [Structured Extraction](capabilities/structured-extraction/) | Auto-parse email fields to JSON | Zero extra LLM calls | | [Semantic Search](capabilities/semantic-search/) | Natural language inbox search | Vector embeddings | | [Webhook Delivery](capabilities/webhook-delivery/) | Receive emails in real time | HMAC verified, 8 retries | +| [Phone Numbers](capabilities/phone-numbers/) | Agent phone number management | Provision + SMS + voice | +| [SMS](capabilities/sms/) | Send, receive, broadcast SMS | Quickstart → mass SMS | → [Browse all capabilities](capabilities/) --- +### SMS + +Your agent can also send and receive SMS. + +```python +# Provision a real phone number +phone = commune.phoneNumbers.provision() +print(phone.number) # → +14155552671 + +# Send an SMS +commune.sms.send( + to="+14155551234", + body="Your order has shipped.", + phone_number_id=phone.id, +) +``` + +Two-way conversations. Semantic search across SMS and email in a single unified index. Escalation from email thread to SMS with one method call. + +[→ See SMS examples](sms/) + +--- + ## Key capabilities
@@ -380,6 +420,8 @@ for thread in results: print(thread.subject, thread.score) ``` +SMS messages are indexed in the same vector store. One query surfaces relevant context regardless of channel. +
@@ -436,6 +478,9 @@ app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => { | `commune.threads.messages(thread_id)` | Get all messages in a thread | | `commune.search.threads({ query, inbox_id })` | Semantic search across threads | | `commune.threads.set_status(thread_id, status)` | Update thread status | +| `commune.sms.send({ to, body, phone_number_id })` | Send an SMS | +| `commune.phone_numbers.provision()` | Provision a real phone number | + ### TypeScript (`commune-ai`) | Method | Description | @@ -446,6 +491,9 @@ app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => { | `commune.threads.messages(threadId)` | Get all messages in a thread | | `commune.search.threads({ query, inboxId })` | Semantic search across threads | | `commune.threads.setStatus(threadId, status)` | Update thread status | +| `commune.sms.send({ to, body, phoneNumberId })` | Send an SMS | +| `commune.phoneNumbers.provision()` | Provision a real phone number | + --- ## Interactive Notebooks diff --git a/agent-to-agent/README.md b/agent-to-agent/README.md index c9f457b..50b528a 100644 --- a/agent-to-agent/README.md +++ b/agent-to-agent/README.md @@ -238,5 +238,5 @@ python orchestrator.py - [Full capability reference: extraction schemas](../capabilities/extraction/) - [Full capability reference: webhook delivery](../capabilities/webhooks/) -- [OpenClaw personal agent setup](https://github.com/commune-dev/commune-openclaw-email-quickstart) +- [OpenClaw personal agent setup](../openclaw-email-sms/) - [TypeScript multi-agent patterns](../typescript/) diff --git a/capabilities/README.md b/capabilities/README.md index 5b98ad0..7bc806c 100644 --- a/capabilities/README.md +++ b/capabilities/README.md @@ -11,6 +11,8 @@ Deep dives on each Commune capability. Start here to understand how features wor | [Structured Extraction](structured-extraction/) | Auto-parse email fields to JSON schema | Support tickets, orders, applications | | [Semantic Search](semantic-search/) | Natural language search across history | Retrieving context before replying | | [Webhook Delivery](webhook-delivery/) | Real-time delivery with 8-retry guarantee | Production agents needing reliability | +| [Phone Numbers](phone-numbers/) | Provision and manage real phone numbers | SMS-capable agents | +| [SMS](sms/) | Send, receive, broadcast SMS messages | Urgent notifications, lead qualification | ## Architecture overview @@ -22,10 +24,10 @@ Your AI Agent ├── Inbox Management (create, configure, delete) ├── Email Engine (inbound processing, outbound delivery) ├── Thread Store (RFC 5322 threading, history) - ├── Vector Index (semantic search across email) + ├── Vector Index (semantic search across email + SMS) ├── Extraction Engine (JSON schema parsing, zero extra LLM calls) ├── Webhook Dispatcher (HMAC-signed, 8 retries, circuit breaker) - └── Webhook Dispatcher (HMAC-signed, 8 retries, circuit breaker) + └── SMS Gateway (provision, send, receive, search) ``` ## Recommended path @@ -34,7 +36,7 @@ Your AI Agent quickstart/ → email-threading/ → structured-extraction/ → webhook-delivery/ ``` -1. **[quickstart/](quickstart/)** — provision an inbox, send your first message. Covers Python and TypeScript. Takes under 60 seconds. +1. **[quickstart/](quickstart/)** — provision an inbox and phone number, send your first message. Covers Python and TypeScript. Takes under 60 seconds. 2. **[email-threading/](email-threading/)** — learn how to keep replies in the correct thread. Covers `In-Reply-To` / `References` headers (RFC 5322) and the `thread_id` pattern. @@ -45,6 +47,8 @@ quickstart/ → email-threading/ → structured-extraction/ → webhook-delivery After those four, explore based on what you need: - **[semantic-search/](semantic-search/)** — natural language search across your agent's entire inbox history using vector embeddings. +- **[phone-numbers/](phone-numbers/)** — provision and manage real phone numbers programmatically. +- **[sms/](sms/)** — send, receive, and broadcast SMS from a real phone number. ## Security layers diff --git a/capabilities/phone-numbers/manage-numbers.py b/capabilities/phone-numbers/manage-numbers.py new file mode 100644 index 0000000..722f2af --- /dev/null +++ b/capabilities/phone-numbers/manage-numbers.py @@ -0,0 +1,78 @@ +""" +Phone number management — powered by Commune + +Shows how to list phone numbers, send an SMS, and read SMS conversations. + +Note: provisioning new phone numbers requires the Commune dashboard +or the TypeScript phoneNumbers.provision() method. See manage-numbers.ts. + +Usage: + export COMMUNE_API_KEY=comm_... + python manage-numbers.py +""" +import os +from commune import CommuneClient + +commune = CommuneClient(api_key=os.environ["COMMUNE_API_KEY"]) + +# ── List phone numbers ───────────────────────────────────────────────────────── + +print("Phone numbers on your account:") +numbers = commune.phone_numbers.list() + +if not numbers: + print(" No phone numbers found.") + print(" Provision one at https://commune.email/dashboard or use manage-numbers.ts") + raise SystemExit(0) + +for n in numbers: + sms_cap = "SMS" if n.capabilities.sms else "no SMS" + voice_cap = "Voice" if n.capabilities.voice else "no Voice" + print(f" {n.number} [{sms_cap}, {voice_cap}] id={n.id}") + +phone = numbers[0] +print() + +# ── Send an SMS ──────────────────────────────────────────────────────────────── + +# Replace with a real number you own for testing +recipient = "+14155551234" + +print(f"Sending SMS to {recipient} from {phone.number}...") +result = commune.sms.send( + to=recipient, + body="Hello from your Commune agent!", + phone_number_id=phone.id, +) +print(f" Sent — message_id: {result.message_id}") +print(f" thread_id: {result.thread_id}") +print(f" status: {result.status}") +print(f" credits: {result.credits_charged}") +print() + +# ── List conversations ───────────────────────────────────────────────────────── + +print(f"Conversations on {phone.number}:") +conversations = commune.sms.conversations(phone_number_id=phone.id) + +if not conversations: + print(" No conversations yet.") +else: + for convo in conversations: + print(f" {convo.remote_number} ({convo.message_count} messages)") + print(f" Last: {convo.last_message_preview}") + print(f" Thread: {convo.thread_id}") + print() + +# ── Read a specific conversation thread ─────────────────────────────────────── + +if conversations: + first = conversations[0] + print(f"Full thread with {first.remote_number}:") + messages = commune.sms.thread( + remote_number=first.remote_number, + phone_number_id=phone.id, + ) + for msg in messages: + direction = "OUT" if msg.direction == "outbound" else " IN" + print(f" [{direction}] {msg.created_at} {msg.content}") diff --git a/capabilities/phone-numbers/manage-numbers.ts b/capabilities/phone-numbers/manage-numbers.ts new file mode 100644 index 0000000..fdc3e42 --- /dev/null +++ b/capabilities/phone-numbers/manage-numbers.ts @@ -0,0 +1,119 @@ +/** + * Phone number management — powered by Commune + * + * Full TypeScript API: search available numbers, provision, list, configure + * webhooks, set auto-reply, and read SMS conversations. + * + * Usage: + * export COMMUNE_API_KEY=comm_... + * npx tsx manage-numbers.ts + */ +import { CommuneClient } from 'commune-ai'; + +const commune = new CommuneClient({ apiKey: process.env.COMMUNE_API_KEY! }); + +async function main() { + + // ── Search available numbers ─────────────────────────────────────────────── + console.log('Available local numbers in area code 415:'); + const available = await commune.phoneNumbers.available({ + type: 'Local', + area_code: '415', + }); + + for (const n of available.slice(0, 3)) { + const sms = n.capabilities.sms ? 'SMS' : ''; + const voice = n.capabilities.voice ? 'Voice' : ''; + const caps = [sms, voice].filter(Boolean).join(', '); + console.log(` ${n.phoneNumber} [${caps}]`); + } + console.log(); + + // ── Provision a number ───────────────────────────────────────────────────── + // Uncomment to actually provision: + // + // const provisioned = await commune.phoneNumbers.provision(available[0].phoneNumber); + // console.log(`Provisioned: ${provisioned.number} id=${provisioned.id}`); + + // ── List provisioned numbers ─────────────────────────────────────────────── + console.log('Numbers on your account:'); + const numbers = await commune.phoneNumbers.list(); + + if (numbers.length === 0) { + console.log(' No numbers yet. Provision one above or via the dashboard.'); + return; + } + + for (const n of numbers) { + const sms = n.capabilities?.sms ? 'SMS' : ''; + const voice = n.capabilities?.voice ? 'Voice' : ''; + const caps = [sms, voice].filter(Boolean).join(', '); + console.log(` ${n.number} [${caps}] id=${n.id}`); + } + console.log(); + + const phone = numbers[0]; + + // ── Configure webhook ────────────────────────────────────────────────────── + // Point inbound SMS events at your handler. + // The handler receives URL-encoded bodies — see capabilities/sms/two-way/ + // + // await commune.phoneNumbers.setWebhook(phone.id, { + // endpoint: 'https://your-app.railway.app/sms-webhook', + // events: ['sms.received'], + // }); + // console.log('Webhook configured'); + + // ── Set friendly name and auto-reply ────────────────────────────────────── + // Auto-reply fires when an inbound SMS arrives and no webhook is configured, + // or as an immediate acknowledgment before your agent processes. + // + // await commune.phoneNumbers.update(phone.id, { + // friendlyName: 'Support Line', + // autoReply: 'Thanks! Our agent will reply within 1 hour.', + // }); + // console.log('Auto-reply set'); + + // ── Send an SMS ──────────────────────────────────────────────────────────── + // Replace with a real number you own for testing. + const recipient = '+14155551234'; + + console.log(`Sending test SMS to ${recipient} from ${phone.number}...`); + const result = await commune.sms.send({ + to: recipient, + body: 'Hello from your Commune agent!', + phone_number_id: phone.id, + }); + console.log(` Sent — message_id: ${result.message_id}`); + console.log(` thread_id: ${result.thread_id}`); + console.log(` status: ${result.status}`); + console.log(); + + // ── List conversations ───────────────────────────────────────────────────── + console.log(`Conversations on ${phone.number}:`); + const convos = await commune.sms.conversations({ phone_number_id: phone.id }); + + if (convos.length === 0) { + console.log(' No conversations yet.'); + return; + } + + for (const c of convos) { + console.log(` ${c.remote_number} (${c.message_count} messages)`); + console.log(` Last: ${c.last_message_preview}`); + console.log(` Thread: ${c.thread_id}`); + } + console.log(); + + // ── Read a specific thread ───────────────────────────────────────────────── + const first = convos[0]; + console.log(`Full thread with ${first.remote_number}:`); + const msgs = await commune.sms.thread(first.remote_number, phone.id); + + for (const msg of msgs) { + const dir = msg.direction === 'outbound' ? 'OUT' : ' IN'; + console.log(` [${dir}] ${msg.created_at} ${msg.content}`); + } +} + +main().catch(console.error); diff --git a/capabilities/quickstart/README.md b/capabilities/quickstart/README.md index ab9c816..cc7419a 100644 --- a/capabilities/quickstart/README.md +++ b/capabilities/quickstart/README.md @@ -1,4 +1,4 @@ -# Quickstart — Give Your Agent an Email Address +# Quickstart — Give Your Agent an Email Address & Phone Number ```python # Install @@ -58,19 +58,49 @@ console.log(result.thread_id); --- +## Give your agent a phone number + +```python +# List your provisioned numbers +numbers = commune.phone_numbers.list() +print(numbers[0].number) # → +14155551234 + +# Send an SMS +commune.sms.send( + to="+14155550000", + body="Hello from your agent!", + phone_number_id=numbers[0].id, +) +``` + +```typescript +const numbers = await commune.phoneNumbers.list(); +await commune.sms.send({ + to: '+14155550000', + body: 'Hello from your agent!', + phone_number_id: numbers[0].id, +}); +``` + +Provision a new number at [commune.email/dashboard](https://commune.email/dashboard). + +--- + ## Files in this directory | File | What it does | |------|-------------| | `give-your-agent-email.py` | Create inbox, print address, send test email | +| `give-your-agent-phone-number.py` | List phone numbers, send test SMS | | `send-your-first-email.py` | Minimal example: create inbox → send email | -| `setup.py` | Onboarding: inbox + test email | +| `send-your-first-sms.py` | Minimal example: list phones → send SMS | +| `setup.py` | Full onboarding: inbox + test email + phone + test SMS | --- ## Next steps -Once you have an inbox, you're ready for the real use cases: +Once you have an inbox and phone number, you're ready for the real use cases: - **[use-cases/customer-support/](../../use-cases/customer-support/)** — email + SMS support agent with knowledge base and thread-aware replies - **[use-cases/notifications-and-alerts/](../../use-cases/notifications-and-alerts/)** — incident alerting with SMS escalation and email acknowledgment @@ -82,7 +112,9 @@ Once you have an inbox, you're ready for the real use cases: flowchart LR A[Get API key] --> B[Create inbox] B --> C[Send email] - C --> F[Build your use case] + B --> D[Provision phone number] + D --> E[Send SMS] + C & E --> F[Build your use case] F --> G[customer-support/] F --> H[notifications-and-alerts/] F --> I[mcp-server/] diff --git a/capabilities/quickstart/give-your-agent-phone-number.py b/capabilities/quickstart/give-your-agent-phone-number.py new file mode 100644 index 0000000..227e7f3 --- /dev/null +++ b/capabilities/quickstart/give-your-agent-phone-number.py @@ -0,0 +1,28 @@ +""" +Give your agent a phone number. +Lists provisioned phone numbers and sends a test SMS. +""" + +import os +from commune import CommuneClient + +commune = CommuneClient(api_key=os.environ["COMMUNE_API_KEY"]) + +# List your provisioned phone numbers +numbers = commune.phone_numbers.list() + +if not numbers: + print("No phone numbers found. Provision one at https://commune.email/dashboard") + raise SystemExit(1) + +print("Your phone numbers:") +for n in numbers: + print(f" {n.number} (id: {n.id})") + +# Send a test SMS from the first number +result = commune.sms.send( + to=os.environ["TEST_PHONE"], # e.g. "+14155550000" + body="Hello from your agent! SMS is working.", + phone_number_id=numbers[0].id, +) +print(f"\nTest SMS sent from {numbers[0].number}. SID: {result.id}") diff --git a/capabilities/quickstart/send-your-first-sms.py b/capabilities/quickstart/send-your-first-sms.py new file mode 100644 index 0000000..d0e3e47 --- /dev/null +++ b/capabilities/quickstart/send-your-first-sms.py @@ -0,0 +1,31 @@ +""" +Send your first SMS with Commune. +Lists available phone numbers and sends a test SMS. +""" + +import os +from commune import CommuneClient + +commune = CommuneClient(api_key=os.environ["COMMUNE_API_KEY"]) + +# Step 1: List your provisioned phone numbers +numbers = commune.phone_numbers.list() +if not numbers: + print("No phone numbers provisioned.") + print("Visit https://commune.email/dashboard to provision a number.") + raise SystemExit(1) + +phone = numbers[0] +print(f"Using number: {phone.number} (id: {phone.id})") + +# Step 2: Send an SMS +to_number = os.environ["TEST_PHONE"] # e.g. "+14155550000" +result = commune.sms.send( + to=to_number, + body="Hello! This is my first SMS sent via the Commune API.", + phone_number_id=phone.id, +) + +# Step 3: Confirm +print(f"SMS sent to {to_number}") +print(f"Message SID: {result.id}") diff --git a/capabilities/quickstart/setup.py b/capabilities/quickstart/setup.py index 69bf46b..926e709 100644 --- a/capabilities/quickstart/setup.py +++ b/capabilities/quickstart/setup.py @@ -1,10 +1,10 @@ """ Full Commune onboarding setup. -Creates an inbox and sends a test email. +Creates an inbox, sends a test email, lists phone numbers, sends a test SMS. Run this once to verify your account is set up correctly. Usage: - COMMUNE_API_KEY=comm_... TEST_EMAIL=you@example.com python setup.py + COMMUNE_API_KEY=comm_... TEST_EMAIL=you@example.com TEST_PHONE=+1... python setup.py """ import os @@ -15,6 +15,7 @@ commune = CommuneClient(api_key=os.environ["COMMUNE_API_KEY"]) TEST_EMAIL = os.environ.get("TEST_EMAIL") +TEST_PHONE = os.environ.get("TEST_PHONE") print("=" * 50) print("Commune Setup") @@ -23,7 +24,7 @@ # --------------------------------------------------------------------------- # 1. Create an inbox # --------------------------------------------------------------------------- -print("\n[1/2] Creating inbox...") +print("\n[1/4] Creating inbox...") inbox = commune.inboxes.create(local_part="setup-test") print(f" Address : {inbox.address}") print(f" Inbox ID: {inbox.id}") @@ -31,7 +32,7 @@ # --------------------------------------------------------------------------- # 2. Send a test email # --------------------------------------------------------------------------- -print("\n[2/2] Sending test email...") +print("\n[2/4] Sending test email...") if not TEST_EMAIL: print(" Skipping — set TEST_EMAIL env var to send a test email.") else: @@ -49,6 +50,36 @@ print(f" Sent to {TEST_EMAIL}") print(f" Thread ID: {result.thread_id}") +# --------------------------------------------------------------------------- +# 3. List phone numbers +# --------------------------------------------------------------------------- +print("\n[3/4] Listing phone numbers...") +numbers = commune.phone_numbers.list() +if not numbers: + print(" No phone numbers provisioned.") + print(" Visit https://commune.email/dashboard to provision one.") +else: + print(f" Found {len(numbers)} number(s):") + for n in numbers: + print(f" {n.number} (id: {n.id})") + +# --------------------------------------------------------------------------- +# 4. Send a test SMS +# --------------------------------------------------------------------------- +print("\n[4/4] Sending test SMS...") +if not TEST_PHONE: + print(" Skipping — set TEST_PHONE env var to send a test SMS.") +elif not numbers: + print(" Skipping — no phone numbers available.") +else: + sms = commune.sms.send( + to=TEST_PHONE, + body="Commune setup complete. SMS is working!", + phone_number_id=numbers[0].id, + ) + print(f" Sent to {TEST_PHONE} from {numbers[0].number}") + print(f" SID: {sms.id}") + # --------------------------------------------------------------------------- # Summary # --------------------------------------------------------------------------- @@ -56,6 +87,8 @@ print("Setup complete.") print(f" Inbox address : {inbox.address}") print(f" Inbox ID : {inbox.id}") +if numbers: + print(f" Phone number : {numbers[0].number}") print() print("Next steps:") print(" - Add a webhook: commune.email/dashboard > Inboxes > Webhooks") diff --git a/capabilities/sms/README.md b/capabilities/sms/README.md new file mode 100644 index 0000000..5eb2e6c --- /dev/null +++ b/capabilities/sms/README.md @@ -0,0 +1,54 @@ +# SMS — Send, Receive, and Broadcast + +Your agent can send and receive SMS from a real phone number. This section has three examples, ordered from simplest to most involved. + +--- + +## Examples + +| Example | What it shows | +|---------|---------------| +| [quickstart/](quickstart/) | Send your first SMS in under 2 minutes | +| [mass-sms/](mass-sms/) | Send personalized SMS to many recipients with rate limiting | +| [two-way/](two-way/) | Receive inbound SMS via webhook and reply with an AI agent | + +--- + +## How it fits together + +```mermaid +flowchart TD + A[Commune phone number] --> B{Direction} + B -->|Outbound| C[commune.sms.send] + B -->|Inbound| D[Webhook fired\nURL-encoded body] + D --> E[Your agent\nreads Body, From] + E --> C + C --> F[SMS delivered] + + G[Broadcast list] -->|loop + rate limit| C +``` + +--- + +## Prerequisites + +All SMS examples need a provisioned phone number. Get one via the Commune dashboard or: + +```typescript +// TypeScript — search and provision +const available = await commune.phoneNumbers.available({ type: 'Local', area_code: '415' }); +await commune.phoneNumbers.provision(available[0].phoneNumber); +``` + +Then set your key: + +```bash +export COMMUNE_API_KEY=comm_... +``` + +--- + +## See also + +- [Phone Numbers](../phone-numbers/) — manage numbers, set auto-reply, configure webhooks +- [Semantic Search](../semantic-search/) — search across SMS and email threads together diff --git a/capabilities/sms/mass-sms/README.md b/capabilities/sms/mass-sms/README.md new file mode 100644 index 0000000..640edda --- /dev/null +++ b/capabilities/sms/mass-sms/README.md @@ -0,0 +1,85 @@ +# Mass SMS — Send to Many Recipients + +Send personalized SMS to a contact list. Includes rate limiting, per-contact personalization via OpenAI, and a summary of sent vs failed. + +--- + +## How it works + +```mermaid +flowchart TD + A[contacts.json] --> B[Load contacts] + B --> C{For each contact} + C --> D{OpenAI API key set?} + D -->|yes| E[Personalize message\ngpt-4o-mini] + D -->|no| F[Use template as-is] + E & F --> G[commune.sms.send] + G --> H[200ms delay\n~5 msg/sec] + H --> C + C -->|done| I[Print summary\nSent / Failed] +``` + +--- + +## Usage + +```bash +pip install commune-mail openai + +export COMMUNE_API_KEY=comm_... +export OPENAI_API_KEY=sk_... # optional — skip for plain template sends + +python broadcast.py --message "Your order has shipped!" --contacts contacts.json +``` + +Output: + +``` +Sending to 5 contacts... + + +14155551001 — Your order has shipped, Sarah! + +14155551002 — Hi James, just letting you know your order is on its way. + +14155551003 — Your order has shipped! + ... + +Sent: 5 | Failed: 0 +``` + +--- + +## Rate limiting + +Carriers throttle senders that burst too fast. The broadcaster enforces a 200ms delay between sends (~5 messages/second). For large lists (1000+), consider batching over multiple hours and checking suppression lists first: + +```python +# Check who has opted out before sending +suppressions = commune.sms.suppressions(phone_number_id=phone_id) +opted_out = {s.phone_number for s in suppressions} +contacts = [c for c in contacts if c["phone"] not in opted_out] +``` + +--- + +## Suppression / opt-out + +If a recipient replies `STOP`, Commune adds them to the suppression list automatically. Re-add them only if they explicitly opt back in: + +```python +commune.sms.remove_suppression("+14155551234") +``` + +--- + +## Files + +| File | Description | +|------|-------------| +| [`broadcast.py`](broadcast.py) | Broadcaster with optional OpenAI personalization | +| [`contacts.json`](contacts.json) | Sample contact list | + +--- + +## Related + +- [SMS Quickstart](../quickstart/) — send a single SMS first +- [Phone Numbers](../../phone-numbers/) — manage numbers and check capabilities diff --git a/capabilities/sms/mass-sms/broadcast.py b/capabilities/sms/mass-sms/broadcast.py new file mode 100644 index 0000000..8123bd3 --- /dev/null +++ b/capabilities/sms/mass-sms/broadcast.py @@ -0,0 +1,109 @@ +""" +Mass SMS Broadcaster — powered by Commune + +Sends personalized SMS to a list of contacts. +Rate limited to avoid carrier throttling. + +If OPENAI_API_KEY is set, each message is personalized with gpt-4o-mini. +Otherwise the template is sent as-is. + +Usage: + python broadcast.py --message "Your order has shipped!" --contacts contacts.json +""" +import argparse +import json +import os +import time + +from commune import CommuneClient +from openai import OpenAI + +commune = CommuneClient(api_key=os.environ["COMMUNE_API_KEY"]) +openai_client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY", "")) + + +def personalize(template: str, contact: dict) -> str: + """Optionally personalize message with OpenAI. Falls back to template.""" + if not openai_client.api_key or "{" not in template: + return template + + try: + response = openai_client.chat.completions.create( + model="gpt-4o-mini", + messages=[ + { + "role": "user", + "content": ( + f"Personalize this SMS for {contact['name']}: '{template}'. " + "Keep it under 160 characters. Return only the message text, no quotes." + ), + } + ], + ) + return response.choices[0].message.content.strip()[:160] + except Exception: + # If OpenAI fails, send the original template rather than blocking + return template + + +def broadcast(message: str, contacts_file: str) -> None: + # Get first available phone number + numbers = commune.phone_numbers.list() + if not numbers: + raise ValueError( + "No phone numbers on this account. " + "Provision one at https://commune.email/dashboard" + ) + phone_id = numbers[0].id + phone_number = numbers[0].number + + # Load contacts + with open(contacts_file) as f: + contacts = json.load(f) + + # Filter out opted-out numbers before sending + suppressions = commune.sms.suppressions(phone_number_id=phone_id) + opted_out = {s.phone_number for s in suppressions} + skipped = [c for c in contacts if c["phone"] in opted_out] + contacts = [c for c in contacts if c["phone"] not in opted_out] + + if skipped: + print(f"Skipping {len(skipped)} opted-out number(s): {[c['phone'] for c in skipped]}") + + print(f"Sending to {len(contacts)} contacts from {phone_number}...\n") + + sent, failed = [], [] + + for contact in contacts: + try: + text = personalize(message, contact) + result = commune.sms.send( + to=contact["phone"], + body=text, + phone_number_id=phone_id, + ) + sent.append({"phone": contact["phone"], "message_id": result.message_id}) + print(f" {contact['phone']} — {text[:80]}") + + # ~5 messages/second — stay within carrier rate limits + time.sleep(0.2) + + except Exception as e: + failed.append({"phone": contact["phone"], "error": str(e)}) + print(f" FAILED {contact['phone']} — {e}") + + print(f"\nSent: {len(sent)} | Failed: {len(failed)}") + + if failed: + print("\nFailed numbers:") + for f in failed: + print(f" {f['phone']} — {f['error']}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Broadcast SMS to a contact list via Commune") + parser.add_argument("--message", required=True, help="Message template to send") + parser.add_argument("--contacts", default="contacts.json", help="Path to contacts JSON file") + args = parser.parse_args() + + broadcast(args.message, args.contacts) diff --git a/capabilities/sms/mass-sms/contacts.json b/capabilities/sms/mass-sms/contacts.json new file mode 100644 index 0000000..c334f4e --- /dev/null +++ b/capabilities/sms/mass-sms/contacts.json @@ -0,0 +1,7 @@ +[ + { "name": "Sarah Chen", "phone": "+14155551001" }, + { "name": "James Rivera", "phone": "+14155551002" }, + { "name": "Priya Patel", "phone": "+14155551003" }, + { "name": "Marcus Johnson","phone": "+14155551004" }, + { "name": "Elena Kowalski","phone": "+14155551005" } +] diff --git a/capabilities/sms/quickstart/README.md b/capabilities/sms/quickstart/README.md new file mode 100644 index 0000000..eb5c992 --- /dev/null +++ b/capabilities/sms/quickstart/README.md @@ -0,0 +1,86 @@ +# SMS Quickstart — Send Your First SMS + +**Python:** + +```bash +pip install commune-mail +``` + +```python +from commune import CommuneClient + +commune = CommuneClient(api_key="comm_...") + +numbers = commune.phone_numbers.list() +result = commune.sms.send( + to="+14155551234", # replace with your number + body="Hello from my AI agent!", + phone_number_id=numbers[0].id, +) +print(f"Sent! Message ID: {result.message_id}") +``` + +**TypeScript:** + +```bash +npm install commune-ai +``` + +```typescript +import { CommuneClient } from 'commune-ai'; + +const commune = new CommuneClient({ apiKey: 'comm_...' }); + +const numbers = await commune.phoneNumbers.list(); +const result = await commune.sms.send({ + to: '+14155551234', // replace with your number + body: 'Hello from my AI agent!', + phone_number_id: numbers[0].id, +}); +console.log(`Sent! Message ID: ${result.message_id}`); +``` + +--- + +## What you get back + +```python +result.message_id # "msg_abc123" +result.thread_id # "thd_xyz789" — stable ID for this conversation +result.status # "queued" | "sent" | "delivered" | "failed" +result.credits_charged # 1 (1 credit per 160-char SMS segment) +``` + +--- + +## Flow + +```mermaid +flowchart LR + A[Your agent] -->|commune.sms.send| B[Commune] + B -->|carrier delivery| C[Recipient's phone] + B -->|result| A + A --> D[message_id\nthread_id\nstatus] +``` + +--- + +## No phone numbers? + +Provision one in the [Commune dashboard](https://commune.email/dashboard) or see [Phone Numbers](../../phone-numbers/) for the full API. + +--- + +## What's next? + +- [Mass SMS](../mass-sms/) — send to many recipients with personalization and rate limiting +- [Two-Way SMS](../two-way/) — receive inbound SMS via webhook and reply with your agent + +--- + +## Files + +| File | Description | +|------|-------------| +| [`send-first-sms.py`](send-first-sms.py) | Python — 15 lines, just works | +| [`send-first-sms.ts`](send-first-sms.ts) | TypeScript equivalent | diff --git a/capabilities/sms/quickstart/send-first-sms.py b/capabilities/sms/quickstart/send-first-sms.py new file mode 100644 index 0000000..89615d6 --- /dev/null +++ b/capabilities/sms/quickstart/send-first-sms.py @@ -0,0 +1,25 @@ +""" +Send your first SMS — powered by Commune + +Usage: + export COMMUNE_API_KEY=comm_... + python send-first-sms.py +""" +import os +from commune import CommuneClient + +commune = CommuneClient(api_key=os.environ["COMMUNE_API_KEY"]) + +numbers = commune.phone_numbers.list() +if not numbers: + raise SystemExit("No phone numbers found. Provision one at https://commune.email/dashboard") + +result = commune.sms.send( + to="+14155551234", # replace with your number + body="Hello from my AI agent!", + phone_number_id=numbers[0].id, +) + +print(f"Sent! Message ID: {result.message_id}") +print(f" Thread ID: {result.thread_id}") +print(f" Status: {result.status}") diff --git a/capabilities/sms/quickstart/send-first-sms.ts b/capabilities/sms/quickstart/send-first-sms.ts new file mode 100644 index 0000000..16e9dd6 --- /dev/null +++ b/capabilities/sms/quickstart/send-first-sms.ts @@ -0,0 +1,29 @@ +/** + * Send your first SMS — powered by Commune + * + * Usage: + * export COMMUNE_API_KEY=comm_... + * npx tsx send-first-sms.ts + */ +import { CommuneClient } from 'commune-ai'; + +const commune = new CommuneClient({ apiKey: process.env.COMMUNE_API_KEY! }); + +async function main() { + const numbers = await commune.phoneNumbers.list(); + if (numbers.length === 0) { + throw new Error('No phone numbers found. Provision one at https://commune.email/dashboard'); + } + + const result = await commune.sms.send({ + to: '+14155551234', // replace with your number + body: 'Hello from my AI agent!', + phone_number_id: numbers[0].id, + }); + + console.log(`Sent! Message ID: ${result.message_id}`); + console.log(` Thread ID: ${result.thread_id}`); + console.log(` Status: ${result.status}`); +} + +main().catch(console.error); diff --git a/capabilities/sms/two-way/.env.example b/capabilities/sms/two-way/.env.example new file mode 100644 index 0000000..69b5095 --- /dev/null +++ b/capabilities/sms/two-way/.env.example @@ -0,0 +1,12 @@ +# Commune API key — get from https://commune.email/dashboard +COMMUNE_API_KEY=comm_... + +# The phone number that will receive inbound SMS +# Find the ID in the dashboard: Phone Numbers → your number → ID +COMMUNE_PHONE_NUMBER_ID=pn_... + +# OpenAI API key — used to generate replies +OPENAI_API_KEY=sk_... + +# Port for the webhook handler server (default: 3000) +PORT=3000 diff --git a/capabilities/sms/two-way/README.md b/capabilities/sms/two-way/README.md new file mode 100644 index 0000000..66ba2ae --- /dev/null +++ b/capabilities/sms/two-way/README.md @@ -0,0 +1,92 @@ +# Two-Way SMS — Receive and Reply to SMS Messages + +Provision a phone number, point it at a webhook, and your agent handles every inbound text — reading conversation history, generating a reply with OpenAI, and sending it back. + +--- + +## How it works + +```mermaid +flowchart LR + A[User sends SMS] --> B[Commune\nphone number] + B -->|POST URL-encoded\nFrom, To, Body, MessageSid| C[Your server\n/sms-webhook] + C --> D[Load conversation\nhistory] + D --> E[Build messages\narray] + E --> F[OpenAI\ngpt-4o-mini] + F --> G[Reply text\nunder 160 chars] + G -->|commune.sms.send| B + B --> A +``` + +**Webhook format:** Commune sends inbound SMS as URL-encoded POST bodies (Twilio-compatible): + +| Field | Example | Meaning | +|-------|---------|---------| +| `From` | `+14155551234` | Sender's number | +| `To` | `+14155557890` | Your Commune number | +| `Body` | `What time does my appointment start?` | Message text | +| `MessageSid` | `SM_abc123` | Unique message ID | + +--- + +## Setup + +**1. Configure webhook on your phone number** (once): + +```typescript +await commune.phoneNumbers.setWebhook(phoneNumberId, { + endpoint: 'https://your-app.railway.app/sms-webhook', + events: ['sms.received'], +}); +``` + +Or set `COMMUNE_PHONE_NUMBER_ID` in your `.env` and the server auto-configures on startup. + +**2. Fill in `.env`** (copy from `.env.example`): + +```bash +COMMUNE_API_KEY=comm_... +COMMUNE_PHONE_NUMBER_ID=pn_... +OPENAI_API_KEY=sk_... +``` + +**3. Install and run:** + +```bash +npm install +npm run dev +``` + +**4. Send a text to your Commune number.** The agent will reply. + +--- + +## Conversation history + +Every inbound message triggers a load of the full conversation history before the LLM call: + +```typescript +const history = await commune.sms.thread(from, phoneNumberId); +// history: SmsMessage[] sorted oldest → newest +// Each message: { direction: 'inbound' | 'outbound', content, created_at } +``` + +This gives the agent full context — it knows what was said before and can maintain continuity across sessions without a separate database. + +--- + +## Files + +| File | Description | +|------|-------------| +| [`src/index.ts`](src/index.ts) | Express webhook handler — parse, load history, reply | +| [`package.json`](package.json) | Dependencies | +| [`.env.example`](.env.example) | Required environment variables | + +--- + +## Related + +- [SMS Quickstart](../quickstart/) — send your first SMS first +- [Phone Numbers](../../phone-numbers/) — provision a number and configure auto-reply +- [Webhook Delivery](../../webhook-delivery/) — same pattern for email webhooks diff --git a/capabilities/sms/two-way/package.json b/capabilities/sms/two-way/package.json new file mode 100644 index 0000000..c5e5256 --- /dev/null +++ b/capabilities/sms/two-way/package.json @@ -0,0 +1,20 @@ +{ + "name": "commune-two-way-sms", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "tsx watch src/index.ts", + "start": "tsx src/index.ts" + }, + "dependencies": { + "commune-ai": "latest", + "express": "^4.18.2", + "openai": "^4.0.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.0.0", + "tsx": "^4.0.0", + "typescript": "^5.0.0" + } +} diff --git a/capabilities/sms/two-way/src/index.ts b/capabilities/sms/two-way/src/index.ts new file mode 100644 index 0000000..0661b6d --- /dev/null +++ b/capabilities/sms/two-way/src/index.ts @@ -0,0 +1,138 @@ +/** + * Two-Way SMS Handler — powered by Commune + * + * Receives inbound SMS via webhook, loads conversation history, + * generates a reply with OpenAI, and sends it back. + * + * Commune fires URL-encoded POST bodies (Twilio-compatible) with fields: + * From — sender's phone number + * To — your Commune phone number + * Body — message text + * MessageSid — unique message ID + * + * Usage: + * cp ../.env.example .env # fill in keys + * npm install + * npm run dev + */ +import express from 'express'; +import OpenAI from 'openai'; +import { CommuneClient } from 'commune-ai'; + +const app = express(); +const commune = new CommuneClient({ apiKey: process.env.COMMUNE_API_KEY! }); +const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! }); + +// Parse URL-encoded bodies — Commune SMS webhooks are Twilio-compatible format +app.use(express.urlencoded({ extended: false })); + +const PHONE_NUMBER_ID = process.env.COMMUNE_PHONE_NUMBER_ID!; +const MAX_SMS_LENGTH = 160; + +// ─── Webhook handler ────────────────────────────────────────────────────────── + +app.post('/sms-webhook', async (req, res) => { + const from: string = req.body.From; + const to: string = req.body.To; + const body: string = req.body.Body; + const messageSid: string = req.body.MessageSid; + + if (!from || !body) { + return res.sendStatus(400); + } + + // Acknowledge immediately — return 200 before the async work starts + res.sendStatus(200); + + console.log(`[${new Date().toISOString()}] SMS from ${from}: ${body}`); + + try { + await handleInboundSms({ from, to, body, messageSid }); + } catch (err) { + console.error('Error handling inbound SMS:', err); + } +}); + +// ─── Core logic ─────────────────────────────────────────────────────────────── + +async function handleInboundSms({ + from, + body, +}: { + from: string; + to: string; + body: string; + messageSid: string; +}) { + // 1. Load full conversation history with this number + const history = await commune.sms.thread(from, PHONE_NUMBER_ID); + // history is SmsMessage[] sorted oldest → newest + // Each message: { direction: 'inbound' | 'outbound', content, created_at } + + // 2. Build the messages array for OpenAI + // Map Commune direction to OpenAI role: + // 'inbound' → 'user' (message from the SMS user) + // 'outbound' → 'assistant' (previous replies from our agent) + const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [ + { + role: 'system', + content: [ + 'You are a helpful AI assistant responding to SMS messages.', + 'Keep replies concise — under 160 characters.', + 'Be friendly and direct. No markdown, no bullet points.', + 'If you cannot help with something, say so briefly.', + ].join(' '), + }, + ...history.map(msg => ({ + role: (msg.direction === 'inbound' ? 'user' : 'assistant') as 'user' | 'assistant', + content: msg.content, + })), + // The current inbound message is already in history, but include it + // explicitly in case the history fetch raced with the webhook delivery + { + role: 'user' as const, + content: body, + }, + ]; + + // 3. Generate reply + const completion = await openai.chat.completions.create({ + model: 'gpt-4o-mini', + messages, + max_tokens: 100, // keeps reply well under 160 chars + }); + + const reply = (completion.choices[0].message.content ?? '').trim().slice(0, MAX_SMS_LENGTH); + + if (!reply) { + console.warn('OpenAI returned empty reply — skipping send'); + return; + } + + // 4. Send reply back to the user + await commune.sms.send({ + to: from, + body: reply, + phone_number_id: PHONE_NUMBER_ID, + }); + + console.log(` Replied to ${from}: ${reply}`); +} + +// ─── Startup ────────────────────────────────────────────────────────────────── + +async function main() { + if (!PHONE_NUMBER_ID) { + throw new Error('COMMUNE_PHONE_NUMBER_ID is required. Set it in your .env file.'); + } + + const PORT = process.env.PORT || 3000; + + app.listen(PORT, () => { + console.log(`Two-way SMS handler listening on port ${PORT}`); + console.log(`POST /sms-webhook`); + console.log(`Phone number ID: ${PHONE_NUMBER_ID}`); + }); +} + +main().catch(console.error); diff --git a/crewai/outreach-crew/README.md b/crewai/outreach-crew/README.md index 0681557..10b245d 100644 --- a/crewai/outreach-crew/README.md +++ b/crewai/outreach-crew/README.md @@ -175,4 +175,4 @@ outreach-crew/ - **CRM integration** — replace `leads.csv` with a call to HubSpot, Salesforce, or any CRM API to pull live lead lists. - **Follow-up sequences** — use the saved `thread_id` values in a scheduled job that calls `send_followup_email` 3 and 7 days after the first send. - **Reply detection** — poll `commune.threads.list()` and check `last_direction` — if `inbound`, the lead replied. Route them to a qualification crew. -- **Follow-up via email** — if a lead doesn't reply within 7 days, send another follow-up email in the same thread. +- **SMS escalation** — if a lead doesn't reply to email within 7 days, use `commune.sms.send()` to send a brief SMS nudge. diff --git a/langchain/README.md b/langchain/README.md index b851992..fb3f941 100644 --- a/langchain/README.md +++ b/langchain/README.md @@ -1,4 +1,4 @@ -# LangChain + Commune — Email for Your Agents +# LangChain + Commune — Email & SMS for Your Agents Give your LangChain agents a real inbox. Email becomes a first-class tool in your chain — send, receive, search, and reply in thread using the `@tool` decorator. @@ -7,8 +7,8 @@ Give your LangChain agents a real inbox. Email becomes a first-class tool in you ``` LangChain Agent ↓ uses @tool -send_email() → Commune → recipient's inbox -read_inbox() ← Commune ← inbound webhook fires +send_email() → Commune → recipient's inbox +read_inbox() ← Commune ← inbound webhook fires search_threads() → vector search across history reply_in_thread() → RFC 5322 threaded reply ``` @@ -130,6 +130,7 @@ def handle_inbound_email(): |-----------|-------------| | [`customer-support/`](customer-support/) | Full support workflow: classify → search history → reply in thread | | [`lead-outreach/`](lead-outreach/) | Personalized outreach sequences with open tracking | +| [`email-sms-tools/`](email-sms-tools/) | Combined email + SMS tool definitions for LangChain agents | ## TypeScript version diff --git a/langchain/email-sms-tools/.env.example b/langchain/email-sms-tools/.env.example new file mode 100644 index 0000000..e27a9b5 --- /dev/null +++ b/langchain/email-sms-tools/.env.example @@ -0,0 +1,4 @@ +COMMUNE_API_KEY=comm_your_key_here +OPENAI_API_KEY=sk-your_key_here +# For the combined escalation example (example_usage.py example 3) +ON_CALL_NUMBER=+14155550100 diff --git a/langchain/email-sms-tools/README.md b/langchain/email-sms-tools/README.md new file mode 100644 index 0000000..324b412 --- /dev/null +++ b/langchain/email-sms-tools/README.md @@ -0,0 +1,135 @@ +# LangChain Email & SMS Tools for Commune + +A reusable, drop-in set of LangChain tools that wrap the full Commune Email and SMS API. Import `get_email_tools` or `get_sms_tools` into any LangChain agent — no glue code required. + +--- + +## Architecture + +```mermaid +flowchart TD + A([Your LangChain Agent]) --> B{Tool selector} + B --> C[Email Tools] + B --> D[SMS Tools] + + C --> C1[list_email_threads] + C --> C2[get_thread_messages] + C --> C3[send_email] + C --> C4[reply_to_thread] + C --> C5[search_emails] + + D --> D1[send_sms] + D --> D2[list_sms_conversations] + D --> D3[get_sms_thread] + + C1 & C2 & C3 & C4 & C5 --> E[(Commune\nEmail API)] + D1 & D2 & D3 --> F[(Commune\nSMS API)] +``` + +--- + +## Quick Start + +### 1. Install + +```bash +pip install -r requirements.txt +``` + +### 2. Set environment variables + +```bash +export COMMUNE_API_KEY=comm_your_key_here +export OPENAI_API_KEY=sk-your_key_here +``` + +### 3. Drop tools into your agent + +```python +from commune_tools import get_email_tools, get_sms_tools + +# Email tools — pass your Commune inbox_id +email_tools = get_email_tools(inbox_id="your_inbox_id") + +# SMS tools — pass your Commune phone_number_id +sms_tools = get_sms_tools(phone_number_id="your_phone_number_id") + +# Use with any LangChain agent +tools = email_tools + sms_tools +``` + +See `example_usage.py` for a complete working agent. + +--- + +## Email Tools Reference + +All functions returned by `get_email_tools(inbox_id, client=None)`: + +| Tool | Description | +|---|---| +| `list_email_threads(limit)` | List recent threads. Returns thread_id, subject, direction, message count. | +| `get_thread_messages(thread_id)` | Get full message history for a thread, including sender and content. | +| `send_email(to, subject, body)` | Send a new email (starts a new thread). | +| `reply_to_thread(thread_id, to, subject, body)` | Reply within an existing thread. Prefer this over `send_email` when replying. | +| `search_emails(query)` | Semantic search across all threads using natural language. | + +## SMS Tools Reference + +All functions returned by `get_sms_tools(phone_number_id, client=None)`: + +| Tool | Description | +|---|---| +| `send_sms(to, message)` | Send an SMS. `to` must be E.164 format (+14155551234). | +| `list_sms_conversations()` | List active SMS conversations with remote number and preview. | +| `get_sms_thread(remote_number)` | Get all messages with a specific phone number. | + +--- + +## Usage Patterns + +### Share a single client across email and SMS + +```python +from commune import CommuneClient +from commune_tools import get_email_tools, get_sms_tools + +client = CommuneClient(api_key="comm_...") +email_tools = get_email_tools(inbox_id="inb_123", client=client) +sms_tools = get_sms_tools(phone_number_id="phn_456", client=client) +``` + +### Use only email tools + +```python +tools = get_email_tools(inbox_id="inb_123") +# → [list_email_threads, get_thread_messages, send_email, reply_to_thread, search_emails] +``` + +### Use only SMS tools + +```python +tools = get_sms_tools(phone_number_id="phn_456") +# → [send_sms, list_sms_conversations, get_sms_thread] +``` + +### Pick specific tools + +```python +all_email_tools = get_email_tools(inbox_id="inb_123") +# Take only the tools you want +tools = [t for t in all_email_tools if t.name in ("send_email", "search_emails")] +``` + +--- + +## File Structure + +``` +email-sms-tools/ +├── commune_tools.py # All LangChain tool definitions — import this +├── example_usage.py # Complete working agent using the tools +├── requirements.txt +├── .env.example +└── README.md +``` diff --git a/langchain/email-sms-tools/commune_tools.py b/langchain/email-sms-tools/commune_tools.py new file mode 100644 index 0000000..a054f9e --- /dev/null +++ b/langchain/email-sms-tools/commune_tools.py @@ -0,0 +1,291 @@ +""" +Reusable LangChain tools for Commune Email & SMS API. + +Drop these into any LangChain agent: + from commune_tools import get_email_tools, get_sms_tools + +Example: + tools = get_email_tools(inbox_id="your_inbox_id") + # → [list_email_threads, get_thread_messages, send_email, reply_to_thread, search_emails] + + sms_tools = get_sms_tools(phone_number_id="your_phone_number_id") + # → [send_sms, list_sms_conversations, get_sms_thread] + +Both factory functions accept an optional `client` argument so you can share a +single CommuneClient instance across tool groups (avoids duplicate auth). +""" +import json +import os +from typing import Optional + +from commune import CommuneClient +from langchain_core.tools import tool + + +# --------------------------------------------------------------------------- +# Email tools +# --------------------------------------------------------------------------- + +def get_email_tools( + inbox_id: str, + client: Optional[CommuneClient] = None, +) -> list: + """ + Return a list of LangChain tools for Commune email operations. + + Args: + inbox_id: The Commune inbox ID to operate on. + client: Optional pre-constructed CommuneClient. If omitted, one is + created from the COMMUNE_API_KEY environment variable. + + Returns: + List of LangChain tool callables: + [list_email_threads, get_thread_messages, send_email, + reply_to_thread, search_emails] + """ + comm = client or CommuneClient(api_key=os.environ["COMMUNE_API_KEY"]) + + # ------------------------------------------------------------------ + # Each tool is defined as a closure so it captures `comm` and + # `inbox_id` without global state. The @tool decorator is applied + # inside the factory function — LangChain picks up the docstring + # for the tool description that the LLM sees. + # ------------------------------------------------------------------ + + @tool + def list_email_threads(limit: int = 20) -> str: + """ + List recent email threads in the inbox. + Returns a JSON array with thread_id, subject, last_direction + ('inbound'/'outbound'), and message_count. + + Use this first to discover what conversations exist before + reading a specific thread. + + Args: + limit: Maximum number of threads to return (default 20). + """ + result = comm.threads.list(inbox_id=inbox_id, limit=limit) + threads = [ + { + "thread_id": t.thread_id, + "subject": t.subject, + "last_direction": t.last_direction, + "message_count": t.message_count, + "last_message_at": str(t.last_message_at), + } + for t in result.data + ] + return json.dumps(threads, indent=2) + + @tool + def get_thread_messages(thread_id: str) -> str: + """ + Fetch all messages in an email thread. + Returns a JSON array with direction ('inbound'/'outbound'), + sender address, content, and created_at timestamp. + + Use this to read the full conversation before replying. + + Args: + thread_id: The thread ID from list_email_threads. + """ + messages = comm.threads.messages(thread_id) + return json.dumps( + [ + { + "direction": m.direction, + "sender": next( + (p.identity for p in m.participants if p.role == "sender"), + "unknown", + ), + "content": m.content, + "created_at": str(m.created_at), + } + for m in messages + ], + indent=2, + ) + + @tool + def send_email(to: str, subject: str, body: str) -> str: + """ + Send a new email, starting a fresh thread. + Returns JSON with status and message_id. + + Use reply_to_thread instead if you are responding to an existing + conversation — that keeps messages in the same thread for the recipient. + + Args: + to: Recipient email address. + subject: Email subject line. + body: Plain-text email body. + """ + result = comm.messages.send( + to=to, + subject=subject, + text=body, + inbox_id=inbox_id, + ) + return json.dumps({ + "status": "sent", + "message_id": getattr(result, "message_id", "ok"), + }) + + @tool + def reply_to_thread(thread_id: str, to: str, subject: str, body: str) -> str: + """ + Send a reply within an existing email thread. + Always prefer this over send_email when responding to a conversation — + it keeps the reply in the same thread so the recipient sees the history. + + Args: + thread_id: The thread ID to reply in (from list_email_threads). + to: Recipient email address. + subject: Reply subject (usually 'Re: '). + body: Plain-text reply body. + """ + result = comm.messages.send( + to=to, + subject=subject, + text=body, + inbox_id=inbox_id, + thread_id=thread_id, + ) + return json.dumps({ + "status": "sent", + "message_id": getattr(result, "message_id", "ok"), + }) + + @tool + def search_emails(query: str) -> str: + """ + Semantic search across all email threads in the inbox. + Returns the top matching threads with thread_id, subject, and + a relevance score (0–1). + + Use natural language queries — e.g. "billing refund request" or + "password reset help". + + Args: + query: Natural language search query. + """ + results = comm.search.threads(query=query, inbox_id=inbox_id, limit=5) + if not results: + return json.dumps({"message": "No matching threads found."}) + return json.dumps( + [ + { + "thread_id": r.thread_id, + "subject": r.subject, + "score": round(r.score, 3), + } + for r in results + ], + indent=2, + ) + + return [ + list_email_threads, + get_thread_messages, + send_email, + reply_to_thread, + search_emails, + ] + + +# --------------------------------------------------------------------------- +# SMS tools +# --------------------------------------------------------------------------- + +def get_sms_tools( + phone_number_id: str, + client: Optional[CommuneClient] = None, +) -> list: + """ + Return a list of LangChain tools for Commune SMS operations. + + Args: + phone_number_id: The Commune phone number ID to send from. + client: Optional pre-constructed CommuneClient. + + Returns: + List of LangChain tool callables: + [send_sms, list_sms_conversations, get_sms_thread] + """ + comm = client or CommuneClient(api_key=os.environ["COMMUNE_API_KEY"]) + + @tool + def send_sms(to: str, message: str) -> str: + """ + Send an SMS message to a phone number. + Returns JSON with status and message_id. + + Args: + to: Recipient phone number in E.164 format (e.g. +14155551234). + Always include the country code and leading '+'. + message: The text message content. Keep under 160 chars for a + single SMS segment; longer messages are split automatically. + """ + result = comm.sms.send( + to=to, + body=message, + phone_number_id=phone_number_id, + ) + return json.dumps({ + "status": "sent", + "message_id": getattr(result, "message_id", "ok"), + }) + + @tool + def list_sms_conversations() -> str: + """ + List all active SMS conversations on this phone number. + Returns JSON array with thread_id, remote_number, message_count, + and a preview of the last message. + + Use this to discover what conversations exist before reading a + specific thread. + """ + conversations = comm.sms.conversations(phone_number_id=phone_number_id) + return json.dumps( + [ + { + "thread_id": c.thread_id, + "remote_number": c.remote_number, + "message_count": c.message_count, + "last_message": getattr(c, "last_message_preview", ""), + } + for c in conversations + ], + indent=2, + ) + + @tool + def get_sms_thread(remote_number: str) -> str: + """ + Get the full SMS message history with a specific phone number. + Returns a JSON array with direction ('inbound'/'outbound'), + content, and created_at timestamp. + + Args: + remote_number: The remote phone number in E.164 format (+14155551234). + Use list_sms_conversations to discover numbers. + """ + messages = comm.sms.thread( + remote_number=remote_number, + phone_number_id=phone_number_id, + ) + return json.dumps( + [ + { + "direction": m.direction, + "content": m.content, + "created_at": str(m.created_at), + } + for m in messages + ], + indent=2, + ) + + return [send_sms, list_sms_conversations, get_sms_thread] diff --git a/langchain/email-sms-tools/example_usage.py b/langchain/email-sms-tools/example_usage.py new file mode 100644 index 0000000..b531b6c --- /dev/null +++ b/langchain/email-sms-tools/example_usage.py @@ -0,0 +1,194 @@ +""" +Example: using commune_tools with a LangChain agent. + +Demonstrates: + 1. Email-only agent — checks inbox and summarises unanswered emails + 2. SMS-only agent — sends a broadcast SMS to a list of numbers + 3. Combined email + SMS agent — escalates to SMS when an urgent email arrives + +Usage: + export COMMUNE_API_KEY=comm_... + export OPENAI_API_KEY=sk-... + python example_usage.py +""" +import os + +from commune import CommuneClient +from commune_tools import get_email_tools, get_sms_tools +from langchain.agents import AgentExecutor, create_tool_calling_agent +from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder +from langchain_openai import ChatOpenAI + +# --------------------------------------------------------------------------- +# Shared setup +# --------------------------------------------------------------------------- + +commune = CommuneClient(api_key=os.environ["COMMUNE_API_KEY"]) +llm = ChatOpenAI(model="gpt-4o-mini", temperature=0) + + +def make_agent(tools: list, system_prompt: str) -> AgentExecutor: + """Convenience factory: build an AgentExecutor with a system prompt.""" + prompt = ChatPromptTemplate.from_messages([ + ("system", system_prompt), + ("human", "{input}"), + MessagesPlaceholder("agent_scratchpad"), + ]) + agent = create_tool_calling_agent(llm, tools, prompt) + return AgentExecutor(agent=agent, tools=tools, verbose=True) + + +# --------------------------------------------------------------------------- +# Example 1: Email agent — summarise unanswered emails +# --------------------------------------------------------------------------- + +def example_email_agent(): + """ + Creates an email inbox, builds email tools, and asks the agent to + summarise any unanswered emails. + """ + print("\n" + "=" * 60) + print("Example 1: Email inbox summary agent") + print("=" * 60) + + # Get or create an inbox — reuse the client to avoid extra auth calls + inboxes = commune.inboxes.list() + inbox = next((ib for ib in inboxes if ib.local_part == "support"), None) + if inbox is None: + inbox = commune.inboxes.create(local_part="support") + + # Build tools, passing the shared client + email_tools = get_email_tools(inbox_id=inbox.id, client=commune) + + agent = make_agent( + tools=email_tools, + system_prompt=( + "You are a helpful assistant with access to a Commune email inbox. " + "When asked to summarise the inbox, use list_email_threads to see recent " + "conversations, then identify which ones are unanswered (last_direction is " + "'inbound'). For each unanswered thread, use get_thread_messages to read " + "the latest message. Provide a concise summary of what each customer needs." + ), + ) + + result = agent.invoke({ + "input": "Check the inbox and summarise any unanswered emails." + }) + print("\nAgent output:", result["output"]) + + +# --------------------------------------------------------------------------- +# Example 2: SMS agent — broadcast a message +# --------------------------------------------------------------------------- + +def example_sms_agent(): + """ + Lists available phone numbers and sends a broadcast SMS to a list of + recipients using the SMS tools. + """ + print("\n" + "=" * 60) + print("Example 2: SMS broadcast agent") + print("=" * 60) + + # Pick the first available phone number + numbers = commune.phone_numbers.list() + if not numbers: + print("No phone numbers configured on this Commune account — skipping.") + return + + phone = numbers[0] + sms_tools = get_sms_tools(phone_number_id=phone.id, client=commune) + + agent = make_agent( + tools=sms_tools, + system_prompt=( + "You are a messaging assistant. Send SMS messages as instructed. " + "Always use E.164 format for phone numbers. Keep messages concise." + ), + ) + + # Send a service update broadcast to two demo numbers + recipients = ["+14155550001", "+14155550002"] + result = agent.invoke({ + "input": ( + f"Send the following service update SMS to each of these numbers: " + f"{recipients}\n\n" + f"Message: 'Scheduled maintenance is complete. All systems are operational. " + f"Thank you for your patience.'" + ) + }) + print("\nAgent output:", result["output"]) + + +# --------------------------------------------------------------------------- +# Example 3: Combined email + SMS agent — urgent escalation +# --------------------------------------------------------------------------- + +def example_combined_agent(): + """ + Combines email and SMS tools in a single agent. The agent reads the inbox + and, for any email marked as urgent, both replies by email and sends an + SMS alert to an on-call number. + """ + print("\n" + "=" * 60) + print("Example 3: Combined email + SMS escalation agent") + print("=" * 60) + + # Email inbox + inboxes = commune.inboxes.list() + inbox = next((ib for ib in inboxes if ib.local_part == "support"), None) + if inbox is None: + inbox = commune.inboxes.create(local_part="support") + + # Phone number + numbers = commune.phone_numbers.list() + if not numbers: + print("No phone numbers configured — skipping SMS escalation example.") + # Fall back to email-only + tools = get_email_tools(inbox_id=inbox.id, client=commune) + else: + phone = numbers[0] + tools = ( + get_email_tools(inbox_id=inbox.id, client=commune) + + get_sms_tools(phone_number_id=phone.id, client=commune) + ) + + ON_CALL_NUMBER = os.environ.get("ON_CALL_NUMBER", "+14155550100") + + agent = make_agent( + tools=tools, + system_prompt=( + "You are an intelligent triage agent. " + "Check the email inbox for any inbound messages that appear urgent " + "(words like 'urgent', 'down', 'outage', 'broken', 'critical'). " + "For urgent emails:\n" + " 1. Reply to the sender acknowledging receipt and that the team is looking into it.\n" + f" 2. Send an SMS alert to the on-call engineer at {ON_CALL_NUMBER} " + "summarising the issue.\n" + "For non-urgent emails, just acknowledge them politely by email." + ), + ) + + result = agent.invoke({ + "input": ( + "Check the inbox for new inbound emails. " + "Triage and respond appropriately. " + "Escalate any urgent issues to the on-call engineer via SMS." + ) + }) + print("\nAgent output:", result["output"]) + + +# --------------------------------------------------------------------------- +# Run all examples +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + # Run example 1 — always works (just needs an inbox) + example_email_agent() + + # Run example 2 — requires at least one phone number in Commune + example_sms_agent() + + # Run example 3 — combines both + example_combined_agent() diff --git a/langchain/email-sms-tools/requirements.txt b/langchain/email-sms-tools/requirements.txt new file mode 100644 index 0000000..21b3dab --- /dev/null +++ b/langchain/email-sms-tools/requirements.txt @@ -0,0 +1,5 @@ +commune-mail>=0.3.0 +langchain>=0.2.0 +langchain-openai>=0.1.0 +openai>=1.30.0 +python-dotenv>=1.0.0 diff --git a/llms.txt b/llms.txt index 33c0fc7..5e71492 100644 --- a/llms.txt +++ b/llms.txt @@ -1,6 +1,6 @@ # email-for-agents -> Production-ready code examples for giving AI agents a real email address. Covers LangChain, CrewAI, OpenAI Agents SDK, Claude, MCP Server, TypeScript, OpenClaw, and agent-to-agent communication. Powered by Commune — email infrastructure built natively for AI agents. +> Production-ready code examples for giving AI agents a real email address and phone number. Covers LangChain, CrewAI, OpenAI Agents SDK, Claude, MCP Server, TypeScript, SMS, OpenClaw, and agent-to-agent communication. Powered by Commune — email + SMS infrastructure built natively for AI agents. All examples are runnable with a Commune API key. Copy, paste, run. @@ -28,28 +28,30 @@ Key primitives: ## OpenClaw -OpenClaw is an open-source personal agent framework. Commune provides a first-party email skill. Install: `bash install.sh` from the dedicated quickstart repo. Skill: `commune-email` (inbox, threads, send, reply, search). +OpenClaw is an open-source personal agent framework. Commune provides first-party skills. Install: `bash install.sh` from the openclaw-email-sms directory. Skills: `commune-email` (inbox, threads, send, reply, search) and `commune-sms` (send, receive, phone numbers). -- [Dedicated OpenClaw quickstart repo](https://github.com/commune-dev/commune-openclaw-email-quickstart) +- [OpenClaw skills and setup](openclaw-email-sms/README.md) +- [Dedicated OpenClaw quickstart repo](https://github.com/shanjai-raj/commune-openclaw-email-sms-quickstart) ## Platforms -- [LangChain examples](langchain/): Customer support agent, lead outreach +- [LangChain examples](langchain/): Customer support agent, lead outreach, email+SMS tools - [CrewAI examples](crewai/): Support crew, outreach crew with multi-agent coordination - [OpenAI Agents SDK examples](openai-agents/): Support agent, multi-agent handoff - [Claude (Anthropic) examples](claude/): Support agent, extraction agent -- [MCP Server](mcp-server/): MCP server for Claude Desktop and Cursor +- [MCP Server](mcp-server/): 13-tool MCP server for Claude Desktop and Cursor - [TypeScript examples](typescript/): Webhook handler, multi-agent coordination -- [OpenClaw integration](https://github.com/commune-dev/commune-openclaw-email-quickstart): Email skill for OpenClaw agents +- [SMS examples](sms/): Alert agent, two-way SMS conversation handler +- [OpenClaw integration](openclaw-email-sms/): Email & SMS skills for OpenClaw agents - [Agent-to-agent](agent-to-agent/): Typed task delegation, orchestrator/worker pattern, agent mesh ## Use Cases -- [Customer support](use-cases/customer-support/): Email agent, omnichannel -- [Hiring & recruiting](use-cases/hiring-and-recruiting/): Candidate outreach, interview scheduler -- [Sales & marketing](use-cases/sales-and-marketing/): Cold email sequences, newsletter +- [Customer support](use-cases/customer-support/): Email agent, SMS bot, omnichannel +- [Hiring & recruiting](use-cases/hiring-and-recruiting/): SMS worker dispatch, candidate outreach, interview scheduler +- [Sales & marketing](use-cases/sales-and-marketing/): Cold email sequences, SMS lead qualification, newsletter - [Research](use-cases/research/): Email research agent -- [Notifications & alerts](use-cases/notifications-and-alerts/): Incident alerts +- [Notifications & alerts](use-cases/notifications-and-alerts/): Incident alerts, transactional SMS ## Capabilities @@ -58,6 +60,8 @@ OpenClaw is an open-source personal agent framework. Commune provides a first-pa - [Extraction](capabilities/extraction/): Extract structured data from email content - [Search](capabilities/search/): Semantic vector search across email history - [Webhooks](capabilities/webhooks/): Receive and verify inbound email webhooks +- [Phone numbers](capabilities/phone-numbers/): Provision and manage phone numbers +- [SMS](capabilities/sms/): Send and receive SMS messages ## SDK @@ -71,6 +75,8 @@ Key methods (Python / TypeScript): - `commune.threads.messages(thread_id)` — full message chain with direction, participants, content - `commune.search.threads(query, inbox_id)` — semantic/vector search across all messages - `commune.threads.set_status(thread_id, status)` — open / needs_reply / waiting / closed +- `commune.sms.send(to, body, phone_number_id)` — send SMS from agent's phone number +- `commune.phone_numbers.list()` / `commune.phone_numbers.provision()` — manage agent phone numbers - `verifyCommuneWebhook(rawBody, timestamp, signature, secret)` — verify inbound webhook (TypeScript) ## API Reference diff --git a/mcp-server/README.md b/mcp-server/README.md index 00b353b..20aa12a 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -39,6 +39,7 @@ For Cursor, add to `.cursor/mcp.json`. For VS Code (GitHub Copilot), add to `.vs "Reply to John's thread saying we're processing his refund" "Search for all emails about the payment issue" "Create a new inbox called billing under example.com" +"Send an SMS to +14155551234 saying shipment is delayed" "Show me deliverability stats for the last 7 days" ``` @@ -56,18 +57,23 @@ Claude summarizes the result for you ## Tools reference -8 email tools: - -| Tool | Description | -|------|-------------| -| `commune_list_inboxes` | List all inboxes and their addresses | -| `commune_create_inbox` | Create a new inbox | -| `commune_list_threads` | List threads in an inbox, flagged by reply status | -| `commune_get_thread` | Fetch all messages in a thread | -| `commune_send_email` | Send a new email or reply in an existing thread | -| `commune_search_emails` | Semantic search across threads using natural language | -| `commune_set_thread_status` | Set status: open, needs_reply, waiting, or closed | -| `commune_tag_thread` | Add tags to a thread for triage and routing | +13 tools across email and SMS: + +| Tool | Category | Description | +|------|----------|-------------| +| `commune_list_inboxes` | Email | List all inboxes and their addresses | +| `commune_create_inbox` | Email | Create a new inbox | +| `commune_list_threads` | Email | List threads in an inbox, flagged by reply status | +| `commune_get_thread` | Email | Fetch all messages in a thread | +| `commune_send_email` | Email | Send a new email or reply in an existing thread | +| `commune_search_emails` | Email | Semantic search across threads using natural language | +| `commune_set_thread_status` | Email | Set status: open, needs_reply, waiting, or closed | +| `commune_tag_thread` | Email | Add tags to a thread for triage and routing | +| `commune_list_phone_numbers` | SMS | List provisioned phone numbers | +| `commune_send_sms` | SMS | Send an SMS to any E.164 number | +| `commune_list_sms_conversations` | SMS | List all SMS conversations | +| `commune_get_sms_thread` | SMS | Fetch full message history with a specific number | +| `commune_search_sms` | SMS | Semantic search across SMS messages | ## Compatibility @@ -91,7 +97,7 @@ Commune MCP Server (local process) ↓ HTTPS REST Commune API ↓ -Email infrastructure +Email + SMS infrastructure ``` ## Troubleshooting diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts index c090486..e75e0d0 100644 --- a/mcp-server/src/index.ts +++ b/mcp-server/src/index.ts @@ -2,7 +2,7 @@ /** * Commune MCP Server * - * Exposes Commune email as MCP tools for Claude Desktop, Cursor, + * Exposes Commune email & SMS as MCP tools for Claude Desktop, Cursor, * VS Code, and any MCP-compatible AI client. * * Install: @@ -163,6 +163,80 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ required: ['thread_id', 'tags'], }, }, + // ── SMS tools ──────────────────────────────────────────────────────── + { + name: 'commune_list_phone_numbers', + description: 'List provisioned phone numbers for SMS.', + inputSchema: { type: 'object', properties: {}, required: [] }, + }, + { + name: 'commune_send_sms', + description: 'Send an SMS message.', + inputSchema: { + type: 'object', + properties: { + to: { + type: 'string', + description: + 'Recipient phone number in E.164 format (+14155551234)', + }, + body: { type: 'string', description: 'SMS message text' }, + phone_number_id: { + type: 'string', + description: 'Your Commune phone number ID', + }, + }, + required: ['to', 'body', 'phone_number_id'], + }, + }, + { + name: 'commune_list_sms_conversations', + description: 'List SMS conversations.', + inputSchema: { + type: 'object', + properties: { + phone_number_id: { + type: 'string', + description: 'Phone number ID to list conversations for', + }, + }, + required: ['phone_number_id'], + }, + }, + { + name: 'commune_get_sms_thread', + description: 'Get all SMS messages with a specific phone number.', + inputSchema: { + type: 'object', + properties: { + remote_number: { + type: 'string', + description: 'The other party phone number (E.164)', + }, + phone_number_id: { + type: 'string', + description: 'Your Commune phone number ID', + }, + }, + required: ['remote_number', 'phone_number_id'], + }, + }, + { + name: 'commune_search_sms', + description: 'Semantic search across SMS messages.', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Natural language search query', + }, + phone_number_id: { type: 'string' }, + limit: { type: 'number' }, + }, + required: ['query'], + }, + }, ], })); @@ -329,6 +403,119 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }; } + // ── SMS ──────────────────────────────────────────────────────────── + + case 'commune_list_phone_numbers': { + const numbers = await commune.phoneNumbers.list(); + return { + content: [ + { + type: 'text', + text: JSON.stringify( + numbers.map((n) => ({ + id: n.id, + number: n.number, + type: n.numberType, + sms: n.capabilities.sms, + voice: n.capabilities.voice, + })), + null, + 2 + ), + }, + ], + }; + } + + case 'commune_send_sms': { + const result = await commune.sms.send({ + to: args!.to as string, + body: args!.body as string, + phone_number_id: args!.phone_number_id as string, + }); + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + status: result.status, + message_id: result.message_id, + }), + }, + ], + }; + } + + case 'commune_list_sms_conversations': { + const convos = await commune.sms.conversations({ + phone_number_id: args!.phone_number_id as string, + }); + return { + content: [ + { + type: 'text', + text: JSON.stringify( + convos.map((c) => ({ + thread_id: c.thread_id, + remote_number: c.remote_number, + message_count: c.message_count, + last_message: c.last_message_preview, + })), + null, + 2 + ), + }, + ], + }; + } + + case 'commune_get_sms_thread': { + const messages = await commune.sms.thread( + args!.remote_number as string, + args!.phone_number_id as string + ); + return { + content: [ + { + type: 'text', + text: JSON.stringify( + messages.map((m) => ({ + direction: m.direction, + content: m.content, + created_at: m.created_at, + })), + null, + 2 + ), + }, + ], + }; + } + + case 'commune_search_sms': { + const results = await commune.sms.search({ + q: args!.query as string, + phone_number_id: args!.phone_number_id as string | undefined, + limit: (args!.limit as number) || 5, + }); + return { + content: [ + { + type: 'text', + text: JSON.stringify( + results.map((m) => ({ + message_id: m.message_id, + content: m.content, + direction: m.direction, + })), + null, + 2 + ), + }, + ], + }; + } + default: throw new Error(`Unknown tool: ${name}`); } diff --git a/notebooks/README.md b/notebooks/README.md index f40d613..76af3c6 100644 --- a/notebooks/README.md +++ b/notebooks/README.md @@ -28,6 +28,7 @@ Set `COMMUNE_API_KEY` and `OPENAI_API_KEY` as environment variables or Colab sec | 05 | [`05_openai_agents_email.ipynb`](05_openai_agents_email.ipynb) | Email as tools in the OpenAI Agents SDK | OpenAI Agents | | 06 | [`06_langgraph_email_agent.ipynb`](06_langgraph_email_agent.ipynb) | Stateful email agent with `StateGraph`, `MemorySaver`, thread isolation | LangGraph | | 07 | [`07_async_streaming.ipynb`](07_async_streaming.ipynb) | `AsyncCommuneClient`, `asyncio.gather()`, semaphore, fire-and-forget | Async Python | +| 08 | [`08_sms_email_combined.ipynb`](08_sms_email_combined.ipynb) | Urgency classifier routing to email + SMS, suppression checks | Python | | 09 | [`09_langchain_production.ipynb`](09_langchain_production.ipynb) | Production LangChain patterns: idempotency, retries, observability | LangChain | | 10 | [`10_crewai_production.ipynb`](10_crewai_production.ipynb) | Production CrewAI patterns: multi-tenant isolation, prompt injection | CrewAI | | 11 | [`11_openai_agents_production.ipynb`](11_openai_agents_production.ipynb) | Production OpenAI Agents: async tools, webhook patterns | OpenAI Agents | @@ -44,4 +45,6 @@ Set `COMMUNE_API_KEY` and `OPENAI_API_KEY` as environment variables or Colab sec **Building async webhooks?** → `07_async_streaming.ipynb`. +**Need SMS + email together?** → `08_sms_email_combined.ipynb`. + **Going to production?** → `09`, `10`, or `11` for your framework's production patterns. diff --git a/openai-agents/README.md b/openai-agents/README.md index cbbeb84..eaefef1 100644 --- a/openai-agents/README.md +++ b/openai-agents/README.md @@ -1,6 +1,6 @@ # OpenAI Agents SDK + Commune — Email as Agent Tools -Add email to OpenAI Agents SDK agents using `@function_tool`. The agent decides when to read, search, and reply — you just wire the tools. +Add email and SMS to OpenAI Agents SDK agents using `@function_tool`. The agent decides when to read, search, and reply — you just wire the tools. ## Install @@ -113,7 +113,7 @@ async def handle_email(request: Request): | Directory | Description | |-----------|-------------| | [`support-agent/`](support-agent/) | Support agent with triage, search history, and human escalation handoff | -| [`tools/`](tools/) | Reusable `@function_tool` definitions for email | +| [`tools/`](tools/) | Reusable `@function_tool` definitions for email and SMS | ## Tips diff --git a/openclaw-email-sms/README.md b/openclaw-email-sms/README.md new file mode 100644 index 0000000..99ebc2d --- /dev/null +++ b/openclaw-email-sms/README.md @@ -0,0 +1,200 @@ +# Email & SMS for OpenClaw Agents + +[![OpenClaw](https://img.shields.io/badge/OpenClaw-compatible-orange)](https://openclaw.ai) +[![Commune](https://img.shields.io/badge/Commune-email%20%2B%20SMS-blue)](https://commune.email) +[![Skills](https://img.shields.io/badge/skills-3%20included-green)](#skills) + +**Give your OpenClaw agent its own email address and phone number. Ask it to check email, reply to threads, and send SMS — from WhatsApp, Telegram, iMessage, or wherever your agent lives.** + +--- + +## What This Unlocks + +| Without Commune | With Commune | +|----------------|-------------| +| Agent can chat | Agent has its own inbox: `assistant@yourdomain.commune.email` | +| No email context | Agent reads, threads, searches your email | +| No SMS | Agent sends/receives SMS from a real phone number | +| One-off messages | Full conversation threading with RFC 5322 compliance | +| No external comms | Agent emails people on your behalf, tracks replies | + +--- + +## Architecture + +### Personal Use + +```mermaid +flowchart LR + You["You\nWhatsApp / Telegram\n/ iMessage"] -->|"message"| OC["OpenClaw\n(Mac Mini / VPS)"] + OC --> LLM["Claude / GPT /\nLocal Model"] + LLM --> OC + OC <-->|"Commune SDK\n/ REST API"| Commune["Commune\nEmail + SMS"] + Commune <-->|"real email"| Email["Email\nInbox"] + Commune <-->|"real SMS"| SMS["SMS\nPhone Number"] + Email -->|"new email arrives"| OC + SMS -->|"new SMS arrives"| OC +``` + +### Company Use + +```mermaid +flowchart TD + Customers["Customers\n(email / SMS)"] <-->|"email + SMS"| Commune["Commune\nEmail + SMS Infrastructure"] + Commune <-->|"webhook / poll"| OC["OpenClaw Agent\n(company server)"] + OC --> LLM["Claude / GPT"] + OC -->|"auto-reply"| Customers + OC -->|"notify team"| Slack["Slack / Discord\n(via OpenClaw)"] + OC -->|"escalate"| OnCall["On-call Engineer\n(SMS alert)"] + Team["Your Team\n(WhatsApp / Slack)"] -->|"commands"| OC +``` + +--- + +## Two Ways to Use + +### 1. Personal Assistant + +OpenClaw manages your personal email and SMS inbox. You message your agent on WhatsApp, it checks email, replies, sends SMS on your behalf. You're in control — your agent is your proxy. + +- "Check my email and tell me what's urgent" +- "Reply to Mom's email and say I'll call Sunday" +- "Text the client that I'm running 10 minutes late" + +[Setup guide for personal use →](./use-cases/personal-assistant/README.md) + +### 2. Company Agent + +OpenClaw handles customer-facing email and SMS. Customers email `support@yourco.com`, OpenClaw triages, drafts replies, escalates urgent issues to Slack, and keeps your team informed — without a human touching the queue first. + +- Full inbox triage: classify, prioritize, respond +- SMS escalation to on-call engineers +- Daily digest emails to management + +[Setup guide for company use →](./use-cases/company-assistant/README.md) + +--- + +## Skills + +This starter includes two OpenClaw skills: + +| Skill | What it does | File | +|-------|-------------|------| +| `commune-email` | Send, receive, search, and manage email threads | `skills/commune-email/SKILL.md` | +| `commune-sms` | Send SMS, read conversations, list phone numbers | `skills/commune-sms/SKILL.md` | +| `commune-agent-network` | Agent-to-agent task delegation via email addresses | `skills/commune-agent-network/SKILL.md` | + +Each skill includes a companion Node.js CLI helper so your agent can shell out to perform operations without needing to construct raw HTTP requests. + +--- + +## Installation + +**Option 1: ClawHub (one command)** + +```bash +clawhub install shanjairaj7/commune +``` + +This installs all Commune skills — email, SMS, and agent network — directly into your OpenClaw workspace. + +**Option 2: Manual install** + +```bash +# Clone and copy skills +git clone https://github.com/shanjai-raj/commune-openclaw-email-sms-quickstart +cd commune-openclaw-email-sms-quickstart + +cp -r skills/commune-email ~/.openclaw/workspace/skills/ +cp -r skills/commune-sms ~/.openclaw/workspace/skills/ +cp -r skills/commune-agent-network ~/.openclaw/workspace/skills/ +``` + +Then talk to your agent: + +``` +You: Check my email inbox and summarize what's new +Agent: [reads Commune inbox, summarizes threads] + +You: Reply to Sarah's email about the contract and say we'll sign next week +Agent: [finds thread, sends reply via Commune] + +You: Text +14155551234 that the meeting is moved to 3pm +Agent: [sends SMS via Commune] +``` + +--- + +## Quick Example Prompts + +| Category | What you say | What the agent does | +|----------|-------------|---------------------| +| **Email triage** | "Summarize my unread emails" | Lists threads, extracts key points | +| **Email triage** | "Which emails need a reply?" | Finds threads with `last_direction: inbound` | +| **Email reading** | "Read the email from Alex about the contract" | Semantic search + full thread view | +| **Email sending** | "Email Sarah the meeting notes from today" | Composes and sends via Commune | +| **Email replying** | "Reply to Mom and say I'll call Sunday" | Finds thread, replies with `thread_id` | +| **Email search** | "Find anything about the lease agreement" | Vector search across inbox | +| **Email management** | "Mark the billing thread as resolved" | PUT `/v1/threads/:id/status` | +| **Email management** | "Tag this as urgent" | POST `/v1/threads/:id/tags` | +| **SMS sending** | "Text +14155551234 that I'm running 10 minutes late" | SMS via Commune phone number | +| **SMS reading** | "What did John text me yesterday?" | Reads SMS conversation by number | +| **SMS listing** | "Show me my recent texts" | Lists all SMS conversations | +| **Automation** | "Every morning summarize my inbox" | Recurring task via OpenClaw scheduler | +| **Automation** | "If I get an email from my bank, notify me on WhatsApp" | Event-driven agent behavior | +| **Company** | "How many support tickets came in today?" | Thread count from support inbox | +| **Company** | "Draft a reply to the refund request and show it to me first" | Draft workflow before sending | + +--- + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `COMMUNE_API_KEY` | Yes | Your Commune API key (`comm_...`) | +| `COMMUNE_INBOX_ID` | Recommended | Default inbox ID for email operations | +| `COMMUNE_INBOX_ADDRESS` | Recommended | Your full inbox address (e.g. `me@domain.commune.email`) | +| `COMMUNE_PHONE_ID` | Optional | Default phone number ID for SMS | + +--- + +## Full Setup Guide + +See [setup/README.md](./setup/README.md) for step-by-step installation, inbox creation, troubleshooting, and `USER.md`/`SOUL.md` templates. + +--- + +## About Commune + +[Commune](https://commune.email) provides real email inboxes and phone numbers via a REST API. Every inbox is RFC 5322 compliant — replies thread correctly in any email client. Built-in spam filtering and prompt injection detection protect your agent from malicious inbound content. + +**Base URL:** `https://api.commune.email` +**Auth:** `Authorization: Bearer $COMMUNE_API_KEY` + +## About OpenClaw + +[OpenClaw](https://openclaw.ai) is a self-hosted AI agent runner with 150k+ GitHub stars. It connects messaging apps (WhatsApp, Telegram, iMessage, Slack, Discord) to LLMs (Claude, GPT, local models) and runs on a Mac Mini, VPS, or Raspberry Pi. Skills are markdown files that teach your agent new capabilities — this repo provides two. + +--- + +## File Structure + +``` +openclaw-email-sms/ +├── README.md # This file +├── skills/ +│ ├── commune-email/ +│ │ ├── SKILL.md # Agent-readable email skill +│ │ └── commune.js # CLI helper for email operations +│ └── commune-sms/ +│ ├── SKILL.md # Agent-readable SMS skill +│ └── commune-sms.js # CLI helper for SMS operations +├── use-cases/ +│ ├── personal-assistant/ +│ │ └── README.md # Personal use guide + example prompts +│ └── company-assistant/ +│ └── README.md # Company deployment patterns + SOUL.md templates +└── setup/ + └── README.md # Step-by-step setup + troubleshooting +``` diff --git a/openclaw-email-sms/install.sh b/openclaw-email-sms/install.sh new file mode 100755 index 0000000..9e2dfe7 --- /dev/null +++ b/openclaw-email-sms/install.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# install.sh — Install Commune email & SMS skills for OpenClaw +# Usage: bash install.sh +set -euo pipefail + +SKILLS_DIR="${HOME}/.openclaw/workspace/skills" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# ── Colours ──────────────────────────────────────────────────────────────────── +GREEN="\033[0;32m" +YELLOW="\033[1;33m" +RED="\033[0;31m" +NC="\033[0m" + +info() { echo -e "${GREEN}✓${NC} $*"; } +warn() { echo -e "${YELLOW}⚠${NC} $*"; } +error() { echo -e "${RED}✗${NC} $*" >&2; } + +# ── Preflight checks ────────────────────────────────────────────────────────── +echo "" +echo "Installing Commune skills for OpenClaw..." +echo "" + +# Check OpenClaw is installed +if ! command -v openclaw &>/dev/null && [ ! -d "${HOME}/.openclaw" ]; then + error "OpenClaw not found. Install it first: https://github.com/openclaw/openclaw" + exit 1 +fi + +# Check Node.js is available (needed by commune.js CLI helpers) +if ! command -v node &>/dev/null; then + error "Node.js is required (https://nodejs.org). Install Node 18+ and re-run." + exit 1 +fi + +# ── Create skills directory ─────────────────────────────────────────────────── +mkdir -p "${SKILLS_DIR}" + +# ── Install commune-email skill ─────────────────────────────────────────────── +COMMUNE_EMAIL_DEST="${SKILLS_DIR}/commune-email" + +if [ -d "${COMMUNE_EMAIL_DEST}" ]; then + warn "commune-email skill already installed — updating..." + rm -rf "${COMMUNE_EMAIL_DEST}" +fi + +cp -r "${SCRIPT_DIR}/skills/commune-email" "${COMMUNE_EMAIL_DEST}" + +# Install npm dependencies for the CLI helper +if [ -f "${COMMUNE_EMAIL_DEST}/package.json" ]; then + (cd "${COMMUNE_EMAIL_DEST}" && npm install --silent) +fi + +chmod +x "${COMMUNE_EMAIL_DEST}/commune.js" +info "commune-email skill installed" + +# ── Install commune-sms skill ───────────────────────────────────────────────── +COMMUNE_SMS_DEST="${SKILLS_DIR}/commune-sms" + +if [ -d "${COMMUNE_SMS_DEST}" ]; then + warn "commune-sms skill already installed — updating..." + rm -rf "${COMMUNE_SMS_DEST}" +fi + +cp -r "${SCRIPT_DIR}/skills/commune-sms" "${COMMUNE_SMS_DEST}" + +if [ -f "${COMMUNE_SMS_DEST}/package.json" ]; then + (cd "${COMMUNE_SMS_DEST}" && npm install --silent) +fi + +chmod +x "${COMMUNE_SMS_DEST}/commune-sms.js" +info "commune-sms skill installed" + +# ── Environment variable check ──────────────────────────────────────────────── +echo "" +echo "Checking environment variables..." + +OPENCLAW_CONFIG="${HOME}/.openclaw/openclaw.json" +ENV_FILE="${HOME}/.openclaw/.env" + +missing_vars=() + +if [ ! -f "${ENV_FILE}" ]; then + warn "No .env found at ${ENV_FILE}" + missing_vars+=("COMMUNE_API_KEY" "COMMUNE_PHONE_NUMBER_ID") +else + # Source the .env and check for required vars + # shellcheck disable=SC1090 + set -a + source "${ENV_FILE}" 2>/dev/null || true + set +a + + if [ -z "${COMMUNE_API_KEY:-}" ]; then + missing_vars+=("COMMUNE_API_KEY") + fi +fi + +if [ ${#missing_vars[@]} -gt 0 ]; then + echo "" + warn "Missing environment variables. Add these to ${ENV_FILE}:" + echo "" + for var in "${missing_vars[@]}"; do + echo " export ${var}=your_value_here" + done + echo "" + echo " Get your API key at: https://commune.email/dashboard" + echo "" +else + info "Environment variables look good" +fi + +# ── Verify installation ─────────────────────────────────────────────────────── +echo "" +echo "Verifying installation..." + +if [ -f "${COMMUNE_EMAIL_DEST}/SKILL.md" ]; then + info "commune-email SKILL.md present" +else + error "commune-email SKILL.md missing — installation may have failed" + exit 1 +fi + +if [ -f "${COMMUNE_SMS_DEST}/SKILL.md" ]; then + info "commune-sms SKILL.md present" +else + error "commune-sms SKILL.md missing — installation may have failed" + exit 1 +fi + +# ── Done ────────────────────────────────────────────────────────────────────── +echo "" +echo -e "${GREEN}Installation complete!${NC}" +echo "" +echo "Next steps:" +echo " 1. Add COMMUNE_API_KEY to ${ENV_FILE}" +echo " 2. Restart OpenClaw: openclaw restart (or close and reopen)" +echo " 3. Try: 'Check my emails' or 'Send a text to +1 555 000 1234'" +echo "" +echo "Docs: https://github.com/shanjairaj7/email-for-agents/tree/main/openclaw-email-sms" diff --git a/openclaw-email-sms/setup/README.md b/openclaw-email-sms/setup/README.md new file mode 100644 index 0000000..58ebde2 --- /dev/null +++ b/openclaw-email-sms/setup/README.md @@ -0,0 +1,287 @@ +# Setup Guide — Commune + OpenClaw Integration + +Step-by-step instructions for connecting your OpenClaw agent to Commune email and SMS. + +--- + +## Step 1: Get Your Commune API Key + +1. Sign up at [commune.email](https://commune.email) +2. Go to your dashboard → API Keys +3. Create a new key — it will start with `comm_` +4. Copy it somewhere safe. You won't see it again after this screen. + +--- + +## Step 2: Install the Skills + +```bash +# Clone the email-for-agents repo +git clone https://github.com/commune-email/email-for-agents +cd email-for-agents/openclaw-email-sms + +# Copy skills into your OpenClaw workspace +cp -r skills/commune-email ~/.openclaw/workspace/skills/ +cp -r skills/commune-sms ~/.openclaw/workspace/skills/ + +# Make the helper scripts executable +chmod +x ~/.openclaw/workspace/skills/commune-email/commune.js +chmod +x ~/.openclaw/workspace/skills/commune-sms/commune-sms.js +``` + +Verify the skills appeared: + +```bash +openclaw skill list | grep commune +# commune-email +# commune-sms +``` + +--- + +## Step 3: Set Environment Variables + +Add these to your shell profile (`~/.zshrc` or `~/.bashrc`) or your server's environment config. Fill in the values as you complete subsequent steps. + +```bash +# Required +export COMMUNE_API_KEY=comm_your_key_here + +# Set after Step 4 (inbox creation) +export COMMUNE_INBOX_ID=inbox_xxx +export COMMUNE_INBOX_ADDRESS=assistant@yourdomain.commune.email + +# Set after Step 5 (phone number, optional) +export COMMUNE_PHONE_ID=pn_xxx +export COMMUNE_PHONE_NUMBER=+14155551234 +``` + +After editing your shell profile, reload it: + +```bash +source ~/.zshrc # or source ~/.bashrc +``` + +--- + +## Step 4: Create Your Inbox + +Either ask your agent directly: + +``` +You: Create a Commune inbox called "assistant" +Agent: Created inbox: assistant@yourdomain.commune.email | ID: inbox_xxx +``` + +Or via the CLI helper: + +```bash +node ~/.openclaw/workspace/skills/commune-email/commune.js create-inbox assistant +``` + +Output: +``` +Inbox created successfully. +Address: assistant@yourdomain.commune.email +Inbox ID: inbox_xxx + +Add these to your environment: + export COMMUNE_INBOX_ID=inbox_xxx + export COMMUNE_INBOX_ADDRESS=assistant@yourdomain.commune.email +``` + +Copy the values and add them to your environment (Step 3). + +**Note:** The inbox address is permanent. Choose a local part (`assistant`, `support`, `me`) that makes sense for your use case. + +--- + +## Step 5: Get a Phone Number (Optional) + +If you want SMS capability: + +1. Go to [commune.email/dashboard](https://commune.email/dashboard) → Phone Numbers → Provision +2. Choose a number (US, UK, or other regions available) +3. List your numbers to get the ID: + +```bash +node ~/.openclaw/workspace/skills/commune-sms/commune-sms.js list-numbers +# +14155551234 — ID: pn_xxx +``` + +Add to your environment: + +```bash +export COMMUNE_PHONE_ID=pn_xxx +export COMMUNE_PHONE_NUMBER=+14155551234 +``` + +--- + +## Step 6: Verify the Integration + +Test that everything is connected: + +```bash +# Test email — create a scratch inbox +node ~/.openclaw/workspace/skills/commune-email/commune.js create-inbox test + +# Test list threads (should return empty or your threads) +node ~/.openclaw/workspace/skills/commune-email/commune.js list-threads $COMMUNE_INBOX_ID + +# Test SMS list (if you have a phone number) +node ~/.openclaw/workspace/skills/commune-sms/commune-sms.js list-numbers +``` + +Send a test email to your new inbox from any email client, wait 30 seconds, then: + +```bash +node ~/.openclaw/workspace/skills/commune-email/commune.js list-threads +# [WAITING REPLY] thread_xxx | 1 msg | Test email +``` + +If you see your test email listed, the integration is working. + +--- + +## Step 7: Tell Your Agent + +Add your inbox details to `~/.openclaw/workspace/USER.md` so the agent knows about them: + +```markdown +## Communication + +### Email +My Commune inbox: assistant@yourdomain.commune.email +Inbox ID: inbox_xxx + +When checking email: +- Prioritize threads with last_direction: inbound (waiting for reply) +- Skip newsletter/automated notifications unless I ask +- Reply in my voice — casual but professional + +### SMS +My Commune phone number: +14155551234 +Phone number ID: pn_xxx + +Common contacts: +- [Name]: +1XXXXXXXXXX +``` + +For a company agent, update the agent's `SOUL.md` with its inbox and responsibilities. See [../use-cases/company-assistant/README.md](../use-cases/company-assistant/README.md) for a full template. + +--- + +## Step 8: Test With Your Agent + +Restart OpenClaw to pick up the new skills and environment variables, then test: + +``` +You: Check my Commune email inbox +Agent: [lists your threads] + +You: Send a test email to yourself@gmail.com with subject "Hello from my agent" +Agent: [sends via Commune] + +You: Text +14155551234 "this is a test" (use your own number to self-test) +Agent: [sends SMS] +``` + +--- + +## Troubleshooting + +### "Agent doesn't seem to use Commune" + +1. Verify the skill files are in the right place: + ```bash + ls ~/.openclaw/workspace/skills/commune-email/ + # SKILL.md commune.js + ``` +2. Check that `COMMUNE_API_KEY` is actually set in the environment OpenClaw runs in: + ```bash + echo $COMMUNE_API_KEY + ``` +3. Restart OpenClaw after setting environment variables — it reads env on startup. +4. Explicitly mention Commune in your request: "Use the Commune email skill to check my inbox." + +--- + +### "Authentication error (401)" + +Your API key is invalid or expired. + +1. Go to [commune.email/dashboard](https://commune.email/dashboard) → API Keys +2. Verify the key matches exactly what's in your environment (no trailing spaces) +3. If needed, rotate the key and update `COMMUNE_API_KEY` + +--- + +### "No threads showing" + +1. Check that `COMMUNE_INBOX_ID` is set and correct: + ```bash + echo $COMMUNE_INBOX_ID + ``` +2. Send a test email to your inbox address and wait 30 seconds +3. Confirm the inbox address is correct: + ```bash + node ~/.openclaw/workspace/skills/commune-email/commune.js create-inbox check + # If you get a "already exists" error, the address is correct + ``` +4. Try listing with the inbox ID explicitly: + ```bash + node ~/.openclaw/workspace/skills/commune-email/commune.js list-threads inbox_xxx + ``` + +--- + +### "SMS send failed" + +1. Check number format — must be E.164: `+14155551234` + - The CLI helper auto-normalizes US 10-digit numbers + - For non-US numbers, include the country code +2. Verify `COMMUNE_PHONE_ID` is set and matches a provisioned number: + ```bash + node ~/.openclaw/workspace/skills/commune-sms/commune-sms.js list-numbers + ``` +3. Check that your Commune account has SMS credits or an active plan + +--- + +### "Replies are starting new threads instead of replying" + +The `thread_id` is not being passed in the send request. Make sure the agent: +1. Reads the thread first to get the `thread_id` +2. Passes `thread_id` in the body of `POST /v1/messages/send` + +In SKILL.md this is documented as critical — if the agent is skipping it, add an explicit note to your `SOUL.md` or `USER.md`: "Always include thread_id when replying to email." + +--- + +### "Search isn't finding emails I know exist" + +1. Vector indexing takes a short time after new emails arrive — wait 1-2 minutes after a new email arrives before searching +2. Try rephrasing — semantic search responds to meaning, not just keywords +3. For exact matches, use `list-threads` and scan subject lines instead + +--- + +## Environment Variable Reference + +| Variable | Required | Description | Where to find it | +|----------|----------|-------------|-----------------| +| `COMMUNE_API_KEY` | Yes | Your Commune API key | commune.email/dashboard → API Keys | +| `COMMUNE_INBOX_ID` | Recommended | Default inbox ID | Response from `create-inbox` | +| `COMMUNE_INBOX_ADDRESS` | Recommended | Full inbox address | Response from `create-inbox` | +| `COMMUNE_PHONE_ID` | Optional | Default phone number ID | Response from `list-numbers` | +| `COMMUNE_PHONE_NUMBER` | Optional | Full phone number in E.164 | Response from `list-numbers` | + +--- + +## What's Next + +- [Personal assistant use case](../use-cases/personal-assistant/README.md) — prompts and setup for personal email management +- [Company agent use case](../use-cases/company-assistant/README.md) — deployment patterns for customer-facing agents +- [commune-email SKILL.md](../skills/commune-email/SKILL.md) — full email API reference for your agent +- [commune-sms SKILL.md](../skills/commune-sms/SKILL.md) — full SMS API reference for your agent diff --git a/openclaw-email-sms/skills/commune-agent-network/SKILL.md b/openclaw-email-sms/skills/commune-agent-network/SKILL.md new file mode 100644 index 0000000..9fd2df9 --- /dev/null +++ b/openclaw-email-sms/skills/commune-agent-network/SKILL.md @@ -0,0 +1,162 @@ +# Skill: commune-agent-network + +> Every OpenClaw agent can become a node in an agent network. Each agent gets a permanent email address. Agents send tasks to each other, receive results as email replies, and maintain full thread history — without shared state or coordination infrastructure. + +## Authentication + +Requires `COMMUNE_API_KEY` environment variable. Get one at commune.email. + +``` +Base URL: https://api.commune.email +Auth header: Authorization: Bearer $COMMUNE_API_KEY +``` + +--- + +## Core Concept + +``` +You (WhatsApp) → OpenClaw Agent (orchestrator@commune.email) + ↓ sends task email + Researcher Agent (researcher@commune.email) + ↓ replies with result + OpenClaw Agent reads full thread + ↓ + Reports result back to you +``` + +Each agent has one inbox address. Addresses are permanent. The email thread preserves the full task chain. + +--- + +## Operations + +### Provision a worker agent inbox + +``` +POST /v1/inboxes +Body: { "localPart": "researcher" } +Response: { "id": "inbox_xxx", "address": "researcher@org.commune.email" } +``` + +Save the `id` as `COMMUNE_WORKER_INBOX_ID` and `address` as `COMMUNE_WORKER_ADDRESS`. + +--- + +### Send a task to another agent + +``` +POST /v1/messages/send +Body: { + "to": "researcher@org.commune.email", + "subject": "Research task: cloud database pricing", + "text": "Please compare pricing for Neon, Supabase, and Railway Postgres. Return as a comparison table.", + "inboxId": "COMMUNE_ORCHESTRATOR_INBOX_ID", + "idempotencyKey": "research-db-pricing-2026" +} +Response: { "thread_id": "thread_xxx" } +``` + +Save `thread_id`. It is the unique identifier for this task. The idempotency key ensures the task is sent exactly once even if retried. + +--- + +### Read the full task chain + +``` +GET /v1/threads/THREAD_ID/messages +``` + +Returns every message in the thread in order: +- `direction: "outbound"` = sent by your agent +- `direction: "inbound"` = received from the worker + +--- + +### Reply in a task thread (worker side) + +``` +POST /v1/messages/send +Body: { + "to": "orchestrator@org.commune.email", + "subject": "Re: Research task: cloud database pricing", + "text": "Here is the comparison: ...", + "inboxId": "COMMUNE_WORKER_INBOX_ID", + "thread_id": "thread_xxx" +} +``` + +`thread_id` binds the reply to the original task. Without it, a new thread is created. + +--- + +### Search for past tasks (semantic deduplication) + +``` +GET /v1/search/threads?q=TASK_DESCRIPTION&inbox_id=COMMUNE_WORKER_INBOX_ID&limit=3 +``` + +Returns past tasks ranked by semantic similarity. If a similar task was already completed, the worker can return the cached result instead of repeating work. + +--- + +## Example Prompts for Your OpenClaw Agent + +- "Ask the researcher agent to compare Postgres pricing and report back" +- "Send a task to analyst@myteam.commune.email to summarize the Q1 sales threads" +- "Check if researcher replied to the database task" +- "Read the full thread for task thread_xxx" +- "What tasks has the researcher agent completed this week?" + +--- + +## Agent Mesh Pattern + +``` +orchestrator@org.commune.email + ↓ send task +researcher@org.commune.email + ↓ reply in thread + +orchestrator reads full chain: + GET /v1/threads/thread_xxx/messages + → [task, result] +``` + +For multi-step pipelines, pass the `thread_id` from stage to stage. Each agent can `GET /v1/threads/:id/messages` to see the full history before processing. + +--- + +## Shell Helper + +```bash +node ~/.openclaw/workspace/skills/commune-agent-network/commune-network.js [args] +``` + +| Command | Args | What it does | +|---------|------|--------------| +| `send-task` | `to subject body` | Sends task email to agent address, returns thread_id | +| `read-thread` | `thread_id` | Prints full task→result chain | +| `reply-in-thread` | `thread_id to body` | Replies in existing task thread | +| `search-past-tasks` | `query` | Semantic search for similar past tasks | +| `list-worker-threads` | `[inbox_id]` | Lists threads waiting for reply | + +--- + +## Installation + +```bash +cp -r skills/commune-agent-network ~/.openclaw/workspace/skills/ +``` + +Then add to your OpenClaw agent configuration: +```yaml +skills: + - commune-agent-network +``` + +Set environment variables: +```bash +COMMUNE_API_KEY=comm_... +COMMUNE_ORCHESTRATOR_INBOX_ID=inbox_... +``` diff --git a/openclaw-email-sms/skills/commune-email/SKILL.md b/openclaw-email-sms/skills/commune-email/SKILL.md new file mode 100644 index 0000000..2e51c7c --- /dev/null +++ b/openclaw-email-sms/skills/commune-email/SKILL.md @@ -0,0 +1,218 @@ +# Skill: Commune Email + +## What I Can Do + +I give you a real email inbox and let you send, receive, search, and manage email threads programmatically via the Commune API. Every inbox I create is RFC 5322 compliant — replies thread correctly in any email client, including Gmail, Apple Mail, and Outlook. + +## Authentication + +Requires `COMMUNE_API_KEY` environment variable. Get one at commune.email. + +``` +Base URL: https://api.commune.email +Auth header: Authorization: Bearer $COMMUNE_API_KEY +Content-Type: application/json +``` + +## My Inbox Address + +`COMMUNE_INBOX_ADDRESS` — set this environment variable to your full inbox address after creation (e.g. `assistant@yourdomain.commune.email`). Also set `COMMUNE_INBOX_ID` to the inbox's ID string. + +--- + +## Core Operations + +### Create Inbox + +``` +POST /v1/inboxes +Body: { "localPart": "assistant" } +Response: { "id": "inbox_xxx", "address": "assistant@yourdomain.commune.email" } +``` + +Save the returned `id` as `COMMUNE_INBOX_ID` and `address` as `COMMUNE_INBOX_ADDRESS`. + +--- + +### List Email Threads + +``` +GET /v1/threads?inbox_id=INBOX_ID&limit=20 +``` + +Response shape: +```json +{ + "data": [ + { + "thread_id": "thread_xxx", + "subject": "Re: Contract draft", + "last_direction": "inbound", + "message_count": 3, + "last_message_at": "2026-02-28T10:00:00Z" + } + ] +} +``` + +**Important:** `last_direction: "inbound"` means the last message came from the other person — this thread is waiting for a reply. `last_direction: "outbound"` means you replied last. + +--- + +### Read a Thread + +``` +GET /v1/threads/THREAD_ID/messages +``` + +Returns an array of messages with: +- `direction`: `"inbound"` or `"outbound"` +- `content`: the message body text +- `participants`: array of `{ role: "sender"|"recipient", identity: "email@address.com" }` +- `metadata.subject`: the email subject + +Always read the full thread before replying — context matters. + +--- + +### Send Email (New Thread) + +``` +POST /v1/messages/send +Body: { + "to": "recipient@example.com", + "subject": "Meeting notes from today", + "text": "Hi Sarah, here are the notes...", + "inboxId": "INBOX_ID" +} +``` + +Response includes `message_id` for tracking. + +--- + +### Reply in Existing Thread + +``` +POST /v1/messages/send +Body: { + "to": "sender@example.com", + "subject": "Re: Original Subject", + "text": "Thanks for your email...", + "inboxId": "INBOX_ID", + "thread_id": "thread_xxx" +} +``` + +**Critical:** Always include `thread_id` when replying to keep the conversation threaded. Without it, a new thread is created. Always prefix the subject with `Re: ` when replying. + +--- + +### Search Email (Semantic) + +``` +GET /v1/search/threads?q=QUERY&inbox_id=INBOX_ID +``` + +Uses vector/semantic search — natural language queries work well. Examples: +- `?q=contract negotiations` +- `?q=invoice overdue` +- `?q=meeting cancellation` + +Returns an array of threads with a `score` field (0–1, higher is more relevant). + +--- + +### Update Thread Status + +``` +PUT /v1/threads/THREAD_ID/status +Body: { "status": "closed" } +``` + +Status options: +- `open` — active, needs attention +- `needs_reply` — explicitly flagged for reply +- `waiting` — waiting on the other party +- `closed` — resolved, no further action needed + +--- + +### Add Tags to Thread + +``` +POST /v1/threads/THREAD_ID/tags +Body: { "tags": ["urgent", "billing", "vip"] } +``` + +Tags are freeform strings. Useful for classifying threads before reporting or routing. + +--- + +## Shell Helper + +A CLI helper is included at `~/.openclaw/workspace/skills/commune-email/commune.js`. Run it for quick operations without constructing raw HTTP requests: + +```bash +node ~/.openclaw/workspace/skills/commune-email/commune.js [args] +``` + +Available commands: +| Command | Args | What it does | +|---------|------|--------------| +| `list-threads` | `[inbox_id]` | Lists threads, flags ones waiting for reply | +| `read-thread` | `thread_id` | Prints full conversation | +| `send` | `to subject body [inbox_id]` | Sends a new email | +| `reply` | `thread_id to body [inbox_id]` | Replies in a thread | +| `search` | `query [inbox_id]` | Semantic search | +| `create-inbox` | `local_part` | Creates a new inbox | + +If `COMMUNE_INBOX_ID` is set, `inbox_id` arguments are optional. + +--- + +## Workflow Patterns + +### Checking for new email + +1. `GET /v1/threads?inbox_id=...&limit=20` +2. Filter for `last_direction: "inbound"` — those need replies +3. For each, `GET /v1/threads/:id/messages` to read the content +4. Summarize or act as instructed + +### Replying to a specific email + +1. Search for the thread: `GET /v1/search/threads?q=...` +2. Read the thread: `GET /v1/threads/:id/messages` +3. Identify the sender from `participants` where `role: "sender"` +4. Send the reply: `POST /v1/messages/send` with `thread_id` + +### Sending a new email + +1. Compose subject and body +2. `POST /v1/messages/send` with `to`, `subject`, `text`, `inboxId` +3. Confirm with the message_id from the response + +--- + +## Usage Examples + +- "Check my Commune inbox" → `GET /v1/threads?inbox_id=$COMMUNE_INBOX_ID` +- "Reply to the email from Alex about the contract" → search, read, reply with `thread_id` +- "Search for emails about the invoice" → `GET /v1/search/threads?q=invoice` +- "Send Sarah the meeting notes" → `POST /v1/messages/send` +- "Mark the billing thread as resolved" → `PUT /v1/threads/:id/status { "status": "closed" }` +- "Flag this as urgent" → `POST /v1/threads/:id/tags { "tags": ["urgent"] }` + +--- + +## Error Handling + +| HTTP Status | Meaning | Action | +|-------------|---------|--------| +| 401 | Invalid API key | Check `COMMUNE_API_KEY` | +| 404 | Inbox or thread not found | Verify `COMMUNE_INBOX_ID` | +| 429 | Rate limited | Wait and retry | +| 5xx | Server error | Retry after a short delay | + +Always check the response body for an `error` or `message` field when a request fails. diff --git a/openclaw-email-sms/skills/commune-email/commune.js b/openclaw-email-sms/skills/commune-email/commune.js new file mode 100644 index 0000000..0eb3f31 --- /dev/null +++ b/openclaw-email-sms/skills/commune-email/commune.js @@ -0,0 +1,225 @@ +#!/usr/bin/env node +/** + * Commune Email CLI helper for OpenClaw + * Usage: node commune.js [args] + * + * Commands: + * list-threads [inbox_id] — list threads, flags ones waiting for reply + * read-thread — print full conversation + * send [inbox_id] + * reply [inbox_id] + * search [inbox_id] + * create-inbox + * + * Environment variables: + * COMMUNE_API_KEY required + * COMMUNE_INBOX_ID optional default inbox + */ + +const BASE = 'https://api.commune.email'; +const KEY = process.env.COMMUNE_API_KEY; +const DEFAULT_INBOX = process.env.COMMUNE_INBOX_ID; + +if (!KEY) { + console.error('Error: COMMUNE_API_KEY environment variable is not set.'); + console.error('Get your key at https://commune.email/dashboard'); + process.exit(1); +} + +const headers = { + 'Authorization': `Bearer ${KEY}`, + 'Content-Type': 'application/json', +}; + +async function api(method, path, body) { + const url = `${BASE}${path}`; + const options = { method, headers }; + if (body) options.body = JSON.stringify(body); + + let res; + try { + res = await fetch(url, options); + } catch (err) { + console.error(`Network error: ${err.message}`); + process.exit(1); + } + + const text = await res.text(); + if (!res.ok) { + console.error(`HTTP ${res.status} from ${method} ${path}`); + console.error(text); + process.exit(1); + } + + try { + return JSON.parse(text); + } catch { + return text; + } +} + +const [, , cmd, ...args] = process.argv; + +switch (cmd) { + case 'list-threads': { + const inboxId = args[0] || DEFAULT_INBOX; + if (!inboxId) { + console.error('Error: inbox_id required (or set COMMUNE_INBOX_ID)'); + process.exit(1); + } + const data = await api('GET', `/v1/threads?inbox_id=${inboxId}&limit=20`); + const threads = data.data || data || []; + if (threads.length === 0) { + console.log('No threads found.'); + break; + } + threads.forEach(t => { + const status = t.last_direction === 'inbound' ? '[WAITING REPLY]' : '[replied] '; + const count = `${t.message_count || 1} msg${(t.message_count || 1) !== 1 ? 's' : ''}`; + console.log(`${status} ${t.thread_id} | ${count} | ${t.subject || '(no subject)'}`); + }); + break; + } + + case 'read-thread': { + const threadId = args[0]; + if (!threadId) { + console.error('Error: thread_id required'); + process.exit(1); + } + const msgs = await api('GET', `/v1/threads/${threadId}/messages`); + const messages = Array.isArray(msgs) ? msgs : msgs.data || []; + if (messages.length === 0) { + console.log('No messages in this thread.'); + break; + } + messages.forEach((m, i) => { + const sender = + (m.participants || []).find(p => p.role === 'sender')?.identity || + m.from || + 'unknown'; + const dir = (m.direction || 'unknown').toUpperCase(); + const subject = m.metadata?.subject || m.subject || ''; + console.log(`\n${'─'.repeat(60)}`); + console.log(`[${i + 1}] ${dir} — from: ${sender}${subject ? ` — subject: ${subject}` : ''}`); + console.log(`${'─'.repeat(60)}`); + console.log(m.content || m.text || m.body || '(no content)'); + }); + console.log(`\n${'─'.repeat(60)}`); + break; + } + + case 'send': { + const [to, subject, body, inboxId] = args; + if (!to || !subject || !body) { + console.error('Usage: commune.js send [inbox_id]'); + process.exit(1); + } + const effectiveInbox = inboxId || DEFAULT_INBOX; + if (!effectiveInbox) { + console.error('Error: inbox_id required (or set COMMUNE_INBOX_ID)'); + process.exit(1); + } + const r = await api('POST', '/v1/messages/send', { + to, + subject, + text: body, + inboxId: effectiveInbox, + }); + console.log('Sent successfully.'); + console.log('Message ID:', r.message_id || r.id || JSON.stringify(r)); + break; + } + + case 'reply': { + const [threadId, to, body, inboxId] = args; + if (!threadId || !to || !body) { + console.error('Usage: commune.js reply [inbox_id]'); + process.exit(1); + } + const effectiveInbox = inboxId || DEFAULT_INBOX; + if (!effectiveInbox) { + console.error('Error: inbox_id required (or set COMMUNE_INBOX_ID)'); + process.exit(1); + } + + // Fetch the thread to get the original subject + const msgs = await api('GET', `/v1/threads/${threadId}/messages`); + const messages = Array.isArray(msgs) ? msgs : msgs.data || []; + const firstMsg = messages[0]; + const originalSubject = + firstMsg?.metadata?.subject || firstMsg?.subject || ''; + const subject = originalSubject.startsWith('Re:') + ? originalSubject + : `Re: ${originalSubject}`; + + const r = await api('POST', '/v1/messages/send', { + to, + subject, + text: body, + inboxId: effectiveInbox, + thread_id: threadId, + }); + console.log('Reply sent successfully.'); + console.log('Message ID:', r.message_id || r.id || JSON.stringify(r)); + break; + } + + case 'search': { + const [query, inboxId] = args; + if (!query) { + console.error('Usage: commune.js search [inbox_id]'); + process.exit(1); + } + const effectiveInbox = inboxId || DEFAULT_INBOX; + const path = effectiveInbox + ? `/v1/search/threads?q=${encodeURIComponent(query)}&inbox_id=${effectiveInbox}` + : `/v1/search/threads?q=${encodeURIComponent(query)}`; + const r = await api('GET', path); + const results = Array.isArray(r) ? r : r.data || []; + if (results.length === 0) { + console.log('No results found.'); + break; + } + results.forEach(t => { + const score = t.score != null ? `[${t.score.toFixed(2)}] ` : ''; + console.log(`${score}${t.thread_id} | ${t.subject || '(no subject)'}`); + }); + break; + } + + case 'create-inbox': { + const localPart = args[0] || 'assistant'; + const r = await api('POST', '/v1/inboxes', { localPart }); + console.log('Inbox created successfully.'); + console.log('Address:', r.address); + console.log('Inbox ID:', r.id); + console.log(''); + console.log('Add these to your environment:'); + console.log(` export COMMUNE_INBOX_ID=${r.id}`); + console.log(` export COMMUNE_INBOX_ADDRESS=${r.address}`); + break; + } + + default: { + console.log('Commune Email CLI — OpenClaw helper'); + console.log(''); + console.log('Usage: node commune.js [args]'); + console.log(''); + console.log('Commands:'); + console.log(' list-threads [inbox_id] List threads (flags ones awaiting reply)'); + console.log(' read-thread Print full conversation'); + console.log(' send [inbox] Send a new email'); + console.log(' reply [inbox] Reply in an existing thread'); + console.log(' search [inbox_id] Semantic search across threads'); + console.log(' create-inbox Create a new inbox'); + console.log(''); + console.log('Environment variables:'); + console.log(' COMMUNE_API_KEY Your Commune API key (required)'); + console.log(' COMMUNE_INBOX_ID Default inbox ID (optional)'); + if (cmd && cmd !== 'help') { + console.error(`\nUnknown command: ${cmd}`); + process.exit(1); + } + } +} diff --git a/openclaw-email-sms/skills/commune-sms/SKILL.md b/openclaw-email-sms/skills/commune-sms/SKILL.md new file mode 100644 index 0000000..ad4090f --- /dev/null +++ b/openclaw-email-sms/skills/commune-sms/SKILL.md @@ -0,0 +1,192 @@ +# Skill: Commune SMS + +## What I Can Do + +I give you a real phone number and let you send and receive SMS messages programmatically via the Commune API. You can send texts to any number, read conversation threads, and list all your SMS conversations — all from within your OpenClaw agent. + +## Authentication + +Requires `COMMUNE_API_KEY` environment variable. Get one at commune.email. + +``` +Base URL: https://api.commune.email +Auth header: Authorization: Bearer $COMMUNE_API_KEY +Content-Type: application/json +``` + +## My Phone Number + +`COMMUNE_PHONE_NUMBER` — set this environment variable to your full phone number after provisioning (e.g. `+14155551234`). Also set `COMMUNE_PHONE_ID` to the phone number's ID string. + +--- + +## Core Operations + +### List Phone Numbers + +``` +GET /v1/phone-numbers +``` + +Response: +```json +[ + { + "id": "pn_xxx", + "number": "+14155551234", + "label": "Support Line", + "capabilities": ["sms"] + } +] +``` + +Use this to discover your available phone numbers and their IDs. Save the relevant `id` as `COMMUNE_PHONE_ID`. + +--- + +### Send SMS + +``` +POST /v1/sms/send +Body: { + "to": "+14155559876", + "body": "Your meeting is at 3pm today.", + "phone_number_id": "pn_xxx" +} +``` + +Response includes `message_id` for confirmation. + +**Number format:** Always use E.164 format (`+1XXXXXXXXXX` for US numbers). If the user gives a 10-digit number, prepend `+1`. + +--- + +### List SMS Conversations + +``` +GET /v1/sms/conversations?phone_number_id=PHONE_ID +``` + +Response: +```json +[ + { + "contact_number": "+14155559876", + "last_message": "Thanks, see you then!", + "last_message_at": "2026-02-28T09:30:00Z", + "message_count": 5, + "direction": "inbound" + } +] +``` + +`direction: "inbound"` means the last message came from the other person — this conversation may need a reply. + +--- + +### Read SMS Conversation with a Specific Number + +``` +GET /v1/sms/conversations/:number +``` + +Where `:number` is the contact's phone number in E.164 format (URL-encoded: `%2B14155559876`). + +Response: +```json +[ + { + "id": "sms_xxx", + "direction": "inbound", + "body": "Can we reschedule to 3pm?", + "from": "+14155559876", + "to": "+14155551234", + "created_at": "2026-02-28T09:00:00Z" + }, + { + "id": "sms_yyy", + "direction": "outbound", + "body": "Sure, 3pm works!", + "from": "+14155551234", + "to": "+14155559876", + "created_at": "2026-02-28T09:05:00Z" + } +] +``` + +--- + +## Shell Helper + +A CLI helper is included at `~/.openclaw/workspace/skills/commune-sms/commune-sms.js`. Run it for quick SMS operations: + +```bash +node ~/.openclaw/workspace/skills/commune-sms/commune-sms.js [args] +``` + +Available commands: +| Command | Args | What it does | +|---------|------|--------------| +| `list-numbers` | — | Lists your Commune phone numbers | +| `send` | `to body [phone_id]` | Sends an SMS | +| `list-convos` | `[phone_id]` | Lists all SMS conversations | +| `read-thread` | `contact_number [phone_id]` | Reads conversation with a number | + +If `COMMUNE_PHONE_ID` is set, `phone_id` arguments are optional. + +--- + +## Workflow Patterns + +### Sending a text message + +1. Get the recipient's number from context (or ask the user) +2. Normalize to E.164 format if needed +3. `POST /v1/sms/send` with `to`, `body`, `phone_number_id` +4. Confirm with the message_id + +### Checking recent texts + +1. `GET /v1/sms/conversations?phone_number_id=...` +2. Filter by `direction: "inbound"` for conversations needing a reply +3. For a specific contact, `GET /v1/sms/conversations/:number` + +### Replying to a text + +Sending a reply is the same as sending a new SMS — just use the same `to` number. There is no separate reply endpoint. The conversation history is maintained automatically. + +--- + +## Usage Examples + +- "Text +14155551234 that I'm running 10 minutes late" → `POST /v1/sms/send` +- "What did John text me?" → read conversation with John's number +- "Show me my recent texts" → `GET /v1/sms/conversations?phone_number_id=...` +- "List my Commune phone numbers" → `GET /v1/phone-numbers` +- "Text the client that their order has shipped" → `POST /v1/sms/send` +- "Has anyone texted me since this morning?" → list conversations, check `last_message_at` + +--- + +## Number Formatting + +When a user says a phone number, normalize it before sending: +- `415-555-1234` → `+14155551234` +- `(415) 555-1234` → `+14155551234` +- `4155551234` → `+14155551234` +- Already has `+1` → use as-is +- Non-US numbers: use the full E.164 format with country code + +--- + +## Error Handling + +| HTTP Status | Meaning | Action | +|-------------|---------|--------| +| 401 | Invalid API key | Check `COMMUNE_API_KEY` | +| 400 | Invalid number format | Ensure E.164 format with `+` prefix | +| 404 | Phone number not found | Verify `COMMUNE_PHONE_ID` with `list-numbers` | +| 429 | Rate limited | Wait and retry | +| 5xx | Server error | Retry after a short delay | + +Always check the response body for an `error` or `message` field when a request fails. diff --git a/openclaw-email-sms/skills/commune-sms/commune-sms.js b/openclaw-email-sms/skills/commune-sms/commune-sms.js new file mode 100644 index 0000000..ec97295 --- /dev/null +++ b/openclaw-email-sms/skills/commune-sms/commune-sms.js @@ -0,0 +1,196 @@ +#!/usr/bin/env node +/** + * Commune SMS CLI helper for OpenClaw + * Usage: node commune-sms.js [args] + * + * Commands: + * list-numbers — list your Commune phone numbers + * send [phone_id] — send an SMS + * list-convos [phone_id] — list all SMS conversations + * read-thread [phone_id] — read conversation with a number + * + * Environment variables: + * COMMUNE_API_KEY required + * COMMUNE_PHONE_ID optional default phone number ID + */ + +const BASE = 'https://api.commune.email'; +const KEY = process.env.COMMUNE_API_KEY; +const DEFAULT_PHONE_ID = process.env.COMMUNE_PHONE_ID; + +if (!KEY) { + console.error('Error: COMMUNE_API_KEY environment variable is not set.'); + console.error('Get your key at https://commune.email/dashboard'); + process.exit(1); +} + +const headers = { + 'Authorization': `Bearer ${KEY}`, + 'Content-Type': 'application/json', +}; + +async function api(method, path, body) { + const url = `${BASE}${path}`; + const options = { method, headers }; + if (body) options.body = JSON.stringify(body); + + let res; + try { + res = await fetch(url, options); + } catch (err) { + console.error(`Network error: ${err.message}`); + process.exit(1); + } + + const text = await res.text(); + if (!res.ok) { + console.error(`HTTP ${res.status} from ${method} ${path}`); + console.error(text); + process.exit(1); + } + + try { + return JSON.parse(text); + } catch { + return text; + } +} + +/** + * Normalize a phone number to E.164 format. + * Handles common US formats — adds +1 prefix if missing. + */ +function normalizeNumber(raw) { + // Strip all non-digit characters except leading + + const cleaned = raw.replace(/[^\d+]/g, ''); + // Already in E.164 + if (cleaned.startsWith('+')) return cleaned; + // 10-digit US number + if (cleaned.length === 10) return `+1${cleaned}`; + // 11-digit with leading 1 + if (cleaned.length === 11 && cleaned.startsWith('1')) return `+${cleaned}`; + // Return as-is for international numbers the user has already formatted + return cleaned.startsWith('+') ? cleaned : `+${cleaned}`; +} + +const [, , cmd, ...args] = process.argv; + +switch (cmd) { + case 'list-numbers': { + const numbers = await api('GET', '/v1/phone-numbers'); + const list = Array.isArray(numbers) ? numbers : numbers.data || []; + if (list.length === 0) { + console.log('No phone numbers found. Provision one at https://commune.email/dashboard'); + break; + } + list.forEach(n => { + const label = n.label ? ` (${n.label})` : ''; + const caps = n.capabilities ? ` [${n.capabilities.join(', ')}]` : ''; + console.log(`${n.number}${label}${caps} — ID: ${n.id}`); + }); + console.log(''); + console.log('To set a default, add to your environment:'); + console.log(` export COMMUNE_PHONE_ID=${list[0].id}`); + break; + } + + case 'send': { + const [to, body, phoneId] = args; + if (!to || !body) { + console.error('Usage: commune-sms.js send [phone_id]'); + console.error('Example: commune-sms.js send +14155551234 "Meeting moved to 3pm"'); + process.exit(1); + } + const effectivePhoneId = phoneId || DEFAULT_PHONE_ID; + if (!effectivePhoneId) { + console.error('Error: phone_id required (or set COMMUNE_PHONE_ID)'); + process.exit(1); + } + const normalizedTo = normalizeNumber(to); + const r = await api('POST', '/v1/sms/send', { + to: normalizedTo, + body, + phone_number_id: effectivePhoneId, + }); + console.log('SMS sent successfully.'); + console.log('To:', normalizedTo); + console.log('Message ID:', r.message_id || r.id || JSON.stringify(r)); + break; + } + + case 'list-convos': { + const [phoneId] = args; + const effectivePhoneId = phoneId || DEFAULT_PHONE_ID; + if (!effectivePhoneId) { + console.error('Error: phone_id required (or set COMMUNE_PHONE_ID)'); + process.exit(1); + } + const convos = await api('GET', `/v1/sms/conversations?phone_number_id=${effectivePhoneId}`); + const list = Array.isArray(convos) ? convos : convos.data || []; + if (list.length === 0) { + console.log('No SMS conversations found.'); + break; + } + list.forEach(c => { + const waitingFlag = c.direction === 'inbound' ? '[WAITING REPLY]' : '[replied] '; + const lastMsg = c.last_message + ? c.last_message.length > 60 + ? c.last_message.slice(0, 57) + '...' + : c.last_message + : '(no message)'; + const msgCount = c.message_count ? ` | ${c.message_count} msgs` : ''; + console.log(`${waitingFlag} ${c.contact_number}${msgCount} | "${lastMsg}"`); + }); + break; + } + + case 'read-thread': { + const [contactNumber, phoneId] = args; + if (!contactNumber) { + console.error('Usage: commune-sms.js read-thread [phone_id]'); + console.error('Example: commune-sms.js read-thread +14155551234'); + process.exit(1); + } + const normalized = normalizeNumber(contactNumber); + const encoded = encodeURIComponent(normalized); + const msgs = await api('GET', `/v1/sms/conversations/${encoded}`); + const messages = Array.isArray(msgs) ? msgs : msgs.data || []; + if (messages.length === 0) { + console.log(`No messages found with ${normalized}`); + break; + } + console.log(`\nSMS conversation with ${normalized}`); + console.log('='.repeat(50)); + messages.forEach(m => { + const dir = m.direction === 'outbound' ? 'You' : normalized; + const time = m.created_at ? new Date(m.created_at).toLocaleString() : ''; + console.log(`\n[${dir}]${time ? ` at ${time}` : ''}`); + console.log(m.body || m.content || m.text || '(no content)'); + }); + console.log('\n' + '='.repeat(50)); + break; + } + + default: { + console.log('Commune SMS CLI — OpenClaw helper'); + console.log(''); + console.log('Usage: node commune-sms.js [args]'); + console.log(''); + console.log('Commands:'); + console.log(' list-numbers List your Commune phone numbers'); + console.log(' send [phone_id] Send an SMS'); + console.log(' list-convos [phone_id] List all SMS conversations'); + console.log(' read-thread [phone_id] Read conversation with a number'); + console.log(''); + console.log('Environment variables:'); + console.log(' COMMUNE_API_KEY Your Commune API key (required)'); + console.log(' COMMUNE_PHONE_ID Default phone number ID (optional)'); + console.log(''); + console.log('Number format: E.164 preferred (+14155551234)'); + console.log(' US 10-digit numbers are auto-prefixed with +1'); + if (cmd && cmd !== 'help') { + console.error(`\nUnknown command: ${cmd}`); + process.exit(1); + } + } +} diff --git a/openclaw-email-sms/use-cases/company-assistant/README.md b/openclaw-email-sms/use-cases/company-assistant/README.md new file mode 100644 index 0000000..33eab5a --- /dev/null +++ b/openclaw-email-sms/use-cases/company-assistant/README.md @@ -0,0 +1,237 @@ +# Company AI Agent — Customer Email & SMS with Commune + +**Deploy OpenClaw as your company's AI agent. It handles customer support email, sends SMS updates, and keeps your team informed — all running on your own infrastructure.** + +--- + +## Architecture + +```mermaid +flowchart TD + subgraph External["External (Customers)"] + CE["Customer Email\nsupport@company.com"] + CS["Customer SMS\n+1 800 number"] + end + subgraph Commune["Commune Platform"] + CI["Support Inbox\n+ Spam Filter\n+ Injection Detection"] + CP["Phone Number\n+ SMS Routing"] + end + subgraph OpenClaw["OpenClaw Agent (your server)"] + OC["Agent Core\n+ Memory\n+ Skills"] + LLM["Claude / GPT\n(your keys)"] + COM["commune-email\n+ commune-sms\nskills"] + end + subgraph Team["Your Team"] + SLACK["Slack / Discord\n(via OpenClaw)"] + OC_TEAM["OpenClaw\n(team channel)"] + end + CE --> CI --> OC + CS --> CP --> OC + OC <--> LLM + OC --> COM + COM --> CI + COM --> CP + OC --> SLACK + OC --> OC_TEAM +``` + +--- + +## Deployment Architectures + +### Pattern 1 — Triage + Respond + +The agent reads all incoming emails, classifies them, responds to clear-cut cases immediately, and flags complex ones in Slack for human review. + +``` +Inbound email arrives + → Agent reads it + → Classifies: billing / bug / feature-request / cancellation / general + + If billing: → Answer from knowledge base, close thread + If bug: → Acknowledge receipt, escalate to #engineering Slack + If feature: → Thank sender, add to backlog tag, close thread + If cancel: → Offer pause option, escalate if they decline + If general: → Draft reply, post draft in Slack for 15-min human approval window + → If no response in 15 min, send the draft +``` + +**Best for:** Teams that want AI to handle volume but retain human oversight for edge cases. + +--- + +### Pattern 2 — Notify + Escalate + +The agent monitors the inbox and acts as a triage layer — alerting humans and routing intelligently, rather than responding directly. + +``` +Every 15 minutes: + → Agent checks inbox for new threads + → Urgent (contains: "down", "can't access", "production", "breach"): + → SMS to on-call engineer immediately + → Post full email in #incidents Slack channel + → Normal: + → Post summary in #support Slack channel + → Assign to relevant team member if identifiable + +Daily at 9am: + → Email digest to support-manager@yourco.com + → Include: thread count, response rate, top categories, unresolved > 24h +``` + +**Best for:** Teams where humans want to write all replies but need better visibility and routing. + +--- + +### Pattern 3 — Full Autonomy + +The agent handles the complete support queue end-to-end. Humans only see escalations and edge cases. + +``` +Continuous operation: + → New email arrives → classify → respond → close + → New SMS arrives → acknowledge → answer or escalate + → Any thread unanswered > 4 hours → SMS on-call, flag in Slack + → Any thread re-opened after close → escalate immediately + → Tone mismatch (angry customer) → route to senior support + notify manager + +Weekly Monday 8am: + → Email digest to CEO with: volume, resolution rate, top issues, CSAT trends +``` + +**Best for:** High-volume support where human-in-the-loop is impractical at scale. + +--- + +## Tell Your Agent Its Role + +Place this in your company agent's `SOUL.md` at `~/.openclaw/workspace/souls/support-agent/SOUL.md`: + +```markdown +# Company Support Agent + +I am the AI support agent for Acme Corp. I handle customer emails and SMS. + +## My contact info + +Email inbox: support@acme.commune.email (inbox_id: inbox_xxx) +Phone number: +18005551234 (phone_number_id: pn_xxx) + +## My responsibilities + +1. Read all inbound support emails every 30 minutes +2. Reply to billing questions immediately (use the pricing guide in my knowledge base) +3. For bugs: acknowledge within 1 hour, post full details to #engineering in Slack +4. For cancellations: offer a 30-day pause before confirming cancellation +5. For anything I'm unsure about: draft a reply and post it in #support for human approval +6. Send daily digest to ceo@acme.com every Monday at 9am + +## Escalation triggers + +Escalate via SMS to on-call (+14155559000) if: +- Subject or body contains: "breach", "hacked", "data leak", "down", "production outage" +- Customer has been waiting > 4 hours with no reply +- Customer explicitly asks to speak to a human + +## Tone and style + +Professional, empathetic, solution-focused. Never defensive. +Sign off as "Acme Support" +Do not use filler phrases like "I hope this email finds you well." +Be direct and clear. Customers are busy. + +## What I never do + +- I never promise refunds without checking the refund policy +- I never share internal system details, pricing exceptions, or other customers' information +- I never send a reply I'm not confident about — I draft it and wait for approval +``` + +--- + +## Multi-Agent Setup + +OpenClaw supports multiple agents with separate workspaces and inboxes. Use this to separate concerns across inboxes. + +``` +~/.openclaw/workspace/ +├── souls/ +│ ├── support-agent/ → handles support@ +│ │ ├── SOUL.md +│ │ └── AGENTS.md +│ ├── billing-agent/ → handles billing@ +│ │ ├── SOUL.md +│ │ └── AGENTS.md +│ └── sales-agent/ → handles sales@ +│ ├── SOUL.md +│ └── AGENTS.md +``` + +Each agent gets its own Commune inbox: +- `support@yourco.commune.email` → support-agent (inbox_id: inbox_aaa) +- `billing@yourco.commune.email` → billing-agent (inbox_id: inbox_bbb) +- `sales@yourco.commune.email` → sales-agent (inbox_id: inbox_ccc) + +Configure in each agent's `AGENTS.md`: + +```markdown +# support-agent AGENTS.md + +## My Commune config +Inbox: support@yourco.commune.email +Inbox ID: inbox_aaa +Phone: +18005551234 (phone_number_id: pn_xxx) + +## Routing rules +If email mentions billing/invoice/payment → forward to billing@yourco.commune.email +If email mentions a sales inquiry → forward to sales@yourco.commune.email +Otherwise → handle directly +``` + +The routing agent can forward by sending a new email from the correct inbox to the appropriate team inbox, or by posting a notification in the relevant Slack channel. + +--- + +## Example Prompts — Team Commands + +Your team can talk to the support agent from Slack or WhatsApp: + +| Team member says | Agent does | +|-----------------|-----------| +| "How many tickets are open right now?" | Count of threads with status `open` or `needs_reply` | +| "Show me tickets that have been waiting over 24 hours" | Filter threads by `last_message_at` | +| "Reply to ticket thread_xxx and say we're investigating" | Sends reply with `thread_id` | +| "Close all threads tagged 'resolved'" | Batch status update to `closed` | +| "Who's been waiting the longest?" | Sort inbound threads by `last_message_at` ascending | +| "Draft a reply to the angry customer in thread_yyy and show it to me first" | Drafts in Slack, waits for approval | +| "Send an SMS to +1415... that their issue is fixed" | `POST /v1/sms/send` | +| "Give me today's support summary" | Thread count, categories, average wait time | +| "Search for all threads about the login bug" | Semantic search across inbox | + +--- + +## Security Considerations + +Commune provides two layers of protection for inbound content: + +**Spam filtering** — junk and phishing emails are filtered before they reach your agent. Your agent's context window isn't wasted on spam, and your LLM costs stay predictable. + +**Prompt injection detection** — Commune scans inbound email content for prompt injection attempts (e.g., emails that try to hijack your agent by embedding hidden instructions in the email body). Detected attempts are flagged and optionally quarantined before the agent reads them. + +These protections matter especially in company deployments where attackers may specifically target your agent's email channel. Review Commune's security documentation for details on configuring these features. + +--- + +## Setup Checklist + +- [ ] `commune-email` and `commune-sms` skills installed in `~/.openclaw/workspace/skills/` +- [ ] `COMMUNE_API_KEY` set in server environment +- [ ] Commune inbox created for each agent (`support@`, `billing@`, etc.) +- [ ] `COMMUNE_INBOX_ID` set per-agent (in each agent's env or config) +- [ ] Commune phone number provisioned (if using SMS) +- [ ] `COMMUNE_PHONE_ID` set in environment +- [ ] Agent `SOUL.md` updated with inbox details and responsibilities +- [ ] Escalation contacts set (on-call SMS number, Slack channel) +- [ ] Tested with a real email to the support inbox + +For detailed setup steps, see [../../setup/README.md](../../setup/README.md). diff --git a/openclaw-email-sms/use-cases/personal-assistant/README.md b/openclaw-email-sms/use-cases/personal-assistant/README.md new file mode 100644 index 0000000..4d2ff93 --- /dev/null +++ b/openclaw-email-sms/use-cases/personal-assistant/README.md @@ -0,0 +1,274 @@ +# Personal AI Assistant — Email & SMS with Commune + +**Your OpenClaw agent, now with a real inbox and phone number. Ask it to check email while you're in a meeting. Have it text someone on your behalf. Get a morning briefing of unread threads — all from WhatsApp.** + +--- + +## How It Works + +```mermaid +flowchart LR + You["You\nWhatsApp / Telegram\n/ iMessage"] -->|"'Check my email'"| OC["OpenClaw\n(Mac Mini / VPS)"] + OC --> LLM["Your LLM\n(Claude / GPT /\nOllama local)"] + LLM --> OC + OC <-->|"commune-email\n+ commune-sms skills"| Commune["Commune\nEmail + SMS"] + Commune <-->|"real email"| Inbox["Your Inbox\nassistant@...\n.commune.email"] + Commune <-->|"real SMS"| Phone["Your Number\n+1 415..."] + Inbox -->|"incoming email"| OC + Phone -->|"incoming SMS"| OC +``` + +Your OpenClaw agent bridges your chat app and the real world. When you message it on WhatsApp, it reads your email, composes replies, and sends texts — as you. + +--- + +## What You Can Ask Your Agent + +### Email Management + +| What you say | What happens | +|-------------|-------------| +| "Summarize my unread emails" | Agent reads all threads with `last_direction: inbound`, extracts key points | +| "Which emails need a reply?" | Lists threads waiting on you, sorted by urgency | +| "Any urgent emails today?" | Agent scans for urgent language and flags them | +| "What's in my inbox?" | Full inbox summary with sender, subject, thread count | +| "Have I heard back from Sarah?" | Searches threads, checks last direction for Sarah's address | +| "How many unread threads do I have?" | Count of `inbound` threads from /v1/threads | + +### Reading Email + +| What you say | What happens | +|-------------|-------------| +| "Read me the email from Alex" | Semantic search for Alex's thread, reads full conversation | +| "What did the contractor say about the timeline?" | Searches inbox for contractor emails, reads relevant thread | +| "Show me the email about the contract" | Vector search: `q=contract` | +| "Read John's latest email" | Finds most recent thread from John | + +### Sending Email + +| What you say | What happens | +|-------------|-------------| +| "Email Sarah the meeting notes from today" | Composes and sends via Commune | +| "Send the invoice to client@acme.com" | Attaches context if you paste it, sends | +| "Email my landlord about the repair" | Composes appropriate message, sends from your inbox | +| "Send a follow-up to the job application I sent last week" | Searches for the original thread, replies with thread_id | + +### Replying to Email + +| What you say | What happens | +|-------------|-------------| +| "Reply to Mom's email and say I'll call Sunday" | Finds thread, sends reply keeping the thread intact | +| "Reply to the contract email and say we'll sign next week" | Finds thread, replies in your voice | +| "Tell the team I'm running 20 minutes late" | Finds the right thread, replies | +| "Decline the meeting invite from HR" | Finds thread, sends a polite decline | + +### Searching Email + +| What you say | What happens | +|-------------|-------------| +| "Find all emails about the lease agreement" | `GET /v1/search/threads?q=lease agreement` | +| "Search for anything about the contractor" | Semantic search, returns ranked results | +| "Did anyone email me about the invoice?" | Searches for invoice-related threads | +| "Find the email where Alex shared the doc link" | Natural language search works well | + +### Inbox Intelligence + +| What you say | What happens | +|-------------|-------------| +| "Mark the billing thread as resolved" | `PUT /v1/threads/:id/status { "status": "closed" }` | +| "Tag this as urgent" | `POST /v1/threads/:id/tags` | +| "Which threads have I not replied to in over a week?" | Lists inbound threads, filters by date | + +### SMS + +| What you say | What happens | +|-------------|-------------| +| "Text +14155551234 that I'm running 10 minutes late" | `POST /v1/sms/send` | +| "Send the client a text that their order shipped" | Sends SMS from your Commune number | +| "What did John text me?" | Reads SMS conversation with John's number | +| "Show me my recent texts" | Lists all SMS conversations | +| "Has anyone texted me today?" | Lists conversations, checks timestamps | +| "Text Mom happy birthday" | Sends if you've set Mom's number in USER.md | + +### Automation + +| What you say | What happens | +|-------------|-------------| +| "Every morning summarize my inbox" | Sets up recurring task (see Morning Briefing below) | +| "Let me know on WhatsApp if I get an email from my bank" | Event-driven monitoring | +| "Check my email every hour and text me if anything urgent arrives" | Polling + conditional SMS | + +--- + +## Setup + +### 1. Get Your Commune API Key + +Sign up at [commune.email](https://commune.email) and copy your API key from the dashboard. It starts with `comm_`. + +### 2. Install the Skills + +```bash +git clone https://github.com/commune-email/email-for-agents +cp -r email-for-agents/openclaw-email-sms/skills/commune-email ~/.openclaw/workspace/skills/ +cp -r email-for-agents/openclaw-email-sms/skills/commune-sms ~/.openclaw/workspace/skills/ +``` + +### 3. Set Environment Variables + +Add to your shell profile (`~/.zshrc`, `~/.bashrc`) or your OpenClaw env config: + +```bash +export COMMUNE_API_KEY=comm_your_key_here +export COMMUNE_INBOX_ID= # set after step 4 +export COMMUNE_INBOX_ADDRESS= # set after step 4 +export COMMUNE_PHONE_ID= # set after step 5 (optional) +export COMMUNE_PHONE_NUMBER= # set after step 5 (optional) +``` + +### 4. Create Your Personal Inbox + +Ask your agent directly: + +``` +You: Create me a Commune inbox called "personal" +Agent: Created inbox: personal@yourdomain.commune.email | ID: inbox_xxx + +You: Great. Now remember this as my default inbox. +Agent: Got it. I'll use personal@yourdomain.commune.email for all email operations. +``` + +Or via CLI: + +```bash +node ~/.openclaw/workspace/skills/commune-email/commune.js create-inbox personal +# → Created inbox: personal@yourdomain.commune.email | ID: inbox_xxx +export COMMUNE_INBOX_ID=inbox_xxx +export COMMUNE_INBOX_ADDRESS=personal@yourdomain.commune.email +``` + +### 5. Get a Phone Number (Optional) + +Provision a phone number at [commune.email/dashboard](https://commune.email/dashboard), then: + +```bash +node ~/.openclaw/workspace/skills/commune-sms/commune-sms.js list-numbers +# → +14155551234 — ID: pn_xxx +export COMMUNE_PHONE_ID=pn_xxx +export COMMUNE_PHONE_NUMBER=+14155551234 +``` + +### 6. Tell Your Agent About Yourself + +Add to `~/.openclaw/workspace/USER.md` (your agent reads this to understand you): + +```markdown +## Email + +My Commune inbox: personal@yourdomain.commune.email +Inbox ID: inbox_xxx + +When checking email: +- Lead with urgent items first +- Skip newsletters and automated notifications unless I ask +- Group replies by person when there are multiple threads + +Reply style: casual but professional. Use my first name to sign off. + +## SMS + +My Commune phone number: +14155551234 +Phone number ID: pn_xxx + +Common contacts: +- Mom: +15105550001 +- Sarah (work): +14085550002 +- Contractor (Mike): +16505550003 + +## Preferences + +If anything looks urgent, notify me immediately — don't wait for me to ask. +Check email when I ask, don't poll unless I set up a schedule. +``` + +--- + +## Teach Your Agent Its Role + +Add to your agent's `SOUL.md` to make email and SMS part of its core identity: + +```markdown +## Communication + +I have access to [Your Name]'s real email inbox and SMS line via Commune. + +My email address: personal@yourdomain.commune.email (inbox_id: inbox_xxx) +My SMS number: +14155551234 (phone_number_id: pn_xxx) + +When asked to check email, I: +1. List threads with last_direction: inbound (waiting for reply) +2. Read urgent ones in full +3. Summarize by sender and urgency + +When replying to email, I always: +- Include thread_id to keep conversations threaded +- Write in [Your Name]'s voice — see USER.md for style notes +- Confirm before sending if unsure of tone or content + +When sending SMS, I: +- Normalize numbers to E.164 format +- Keep messages concise and natural +``` + +--- + +## Advanced: Morning Briefing + +Set up your agent to run every morning and send a WhatsApp summary of your inbox. + +In OpenClaw's scheduler or `AGENTS.md`, add a recurring task: + +```markdown +## Scheduled Tasks + +### Morning Email Briefing +Schedule: every day at 8:00 AM local time +Action: Check Commune inbox, summarize unread threads by urgency, send summary to my WhatsApp. + +Format: +- Start with "Good morning. Here's your inbox:" +- List urgent items first (flag if sender is known contact or subject contains urgent/asap/deadline) +- Then: X other threads waiting for reply +- End with: "Reply count needed: X" +``` + +The agent will: +1. `GET /v1/threads?inbox_id=...&limit=50` +2. Filter for `last_direction: inbound` +3. Classify by urgency (read thread content if needed) +4. Send you a WhatsApp summary +5. Optionally: ask if you want it to draft replies for anything + +--- + +## Privacy Note + +All email content is processed through your chosen LLM. If you're using a local model (Ollama, LM Studio, or similar), your email content never leaves your device. If using a cloud LLM (Claude, GPT-4), email content is sent to that provider's API — the same privacy posture as using any AI assistant. + +Commune itself stores your emails on their infrastructure, similar to any email provider. Review [commune.email/privacy](https://commune.email/privacy) for details. + +--- + +## Troubleshooting + +**"Agent doesn't seem to know about my inbox"** +Add your inbox address and ID to `USER.md`. The agent reads this on every session start. + +**"Agent sent a reply as a new thread instead of a reply"** +The `thread_id` was missing from the send call. Make sure the agent reads the thread first and passes `thread_id` in the reply body. + +**"SMS send failed"** +Check that the number is in E.164 format (`+14155551234`) and that `COMMUNE_PHONE_ID` is set correctly. + +**"Search isn't finding my emails"** +Vector search needs a moment after new emails arrive. Also try rephrasing — natural language queries work better than keywords alone. diff --git a/sms/README.md b/sms/README.md new file mode 100644 index 0000000..883f61a --- /dev/null +++ b/sms/README.md @@ -0,0 +1,173 @@ +# SMS for AI Agents — Commune + +Give your agent a real phone number. Two-way SMS, unified search with email, instant escalation from email thread to SMS. + +## When to use SMS vs email + +| Scenario | Use | Why | +|----------|-----|-----| +| Urgent alerts | SMS | Immediate delivery, high open rate | +| Async conversation | Email | Threading, attachments, search | +| Appointment reminders | SMS | Short, time-sensitive | +| Customer onboarding | Email | Rich formatting, links | +| Critical incident | SMS first, email follow-up | SMS for urgency, email for details | +| Lead qualification | SMS | Higher response rate for cold outreach | + +## Install + +```bash +pip install commune-mail +export COMMUNE_API_KEY="comm_..." +``` + +## Provision a phone number + +```python +from commune import CommuneClient + +client = CommuneClient(api_key=os.environ["COMMUNE_API_KEY"]) + +# Get a real US phone number for your agent +phone = client.phone_numbers.provision() +print(phone.number) # → +14155552671 +print(phone.id) # → pn_abc123 (use in send calls) +``` + +## Send SMS + +```python +# Basic send +client.sms.send( + to="+14155551234", + body="Your order has shipped. Track it at: https://tracking.example.com/123", + phone_number_id=phone.id, +) + +# With idempotency key (prevent duplicate sends in retry loops) +client.sms.send( + to="+14155551234", + body=f"Order {order_id} shipped", + phone_number_id=phone.id, + idempotency_key=f"shipment-{order_id}", +) +``` + +## Receive SMS (webhook) + +```python +from flask import Flask, request + +app = Flask(__name__) + +@app.post("/webhook/sms") +def handle_inbound_sms(): + payload = request.json + + from_number = payload["from"] + body = payload["body"] + phone_number_id = payload["phoneNumberId"] + + # Run your agent + reply = agent.process(f"SMS from {from_number}: {body}") + + # Reply via SMS + client.sms.send( + to=from_number, + body=reply, + phone_number_id=phone_number_id, + ) + + return {"ok": True} +``` + +## Inbound webhook payload + +```python +{ + "from_number": "+15551234567", # E.164 — the person texting you + "to_number": "+18005551234", # your Commune phone number + "body": "YES, I'll take the shift", + "message_sid": "SM...", # carrier message ID + "thread_id": "abc123...", # stable across messages with same number + "num_segments": 1, + "credits_charged": 2 +} +``` + +Your webhook must return HTTP 200 within 10 seconds. Commune retries on failure with exponential backoff. + +## Escalation pattern (email → SMS) + +```python +import asyncio + +async def send_with_sms_escalation( + email_address: str, + phone_number: str, + subject: str, + email_body: str, + sms_body: str, + escalate_after_hours: int = 4, +): + # Send email first + msg = client.messages.send( + to=email_address, + subject=subject, + text=email_body, + inbox_id=INBOX_ID, + ) + + # Wait for reply + await asyncio.sleep(escalate_after_hours * 3600) + + # Check if replied + thread = client.threads.get(msg.thread_id) + if thread.message_count == 1: # no reply yet + # Escalate to SMS + client.sms.send( + to=phone_number, + body=sms_body, + phone_number_id=PHONE_NUMBER_ID, + ) +``` + +## Unified search (email + SMS) + +```python +# Search across BOTH email and SMS history with one query +results = client.search.threads( + query="customer asking about delivery", + inbox_id=INBOX_ID, +) +# Returns matching threads from both email and SMS, ranked by semantic similarity +``` + +## Capability reference + +| Capability | Method | Notes | +|-----------|--------|-------| +| Send SMS | `client.sms.send(to, body, phone_number_id)` | Returns message_id, thread_id, credits_charged | +| Receive webhook | `POST /webhook/sms` | See payload above | +| Conversation history | `client.sms.thread(remote_number, phone_number_id)` | Full history, ordered oldest-first | +| Semantic search | `client.sms.search(q, phone_number_id, limit)` | Vector search across all SMS content | +| Opt-out management | `client.sms.suppressions(phone_number_id)` | Returns numbers that sent STOP | +| MMS | `client.sms.send(..., media_url="https://...")` | Attach images or files | +| Auto-reply | `client.phone_numbers.update(id, auto_reply="...")` | Set a default auto-reply message | +| Allow / block lists | `client.phone_numbers.update(..., allow_list=[...], block_list=[...])` | Per-number access control | + +## Examples in this folder + +| Directory | Description | +|-----------|-------------| +| [`quickstart/`](quickstart/) | Provision a number and send your first SMS | +| [`two-way-sms/`](two-way-sms/) | Receive SMS, process with LLM, reply in thread | +| [`alert-agent/`](alert-agent/) | Send alerts and escalations via SMS | +| [`hiring-agent/`](hiring-agent/) | Shift dispatch and worker confirmation flows | +| [`personal-agent/`](personal-agent/) | Personal SMS assistant patterns | +| [`sms-marketing/`](sms-marketing/) | Send to a list with deduplication and opt-out handling | + +## Related + +- [Email examples](../langchain/) — email-first workflows +- [capabilities/sms/](../capabilities/sms/) — full API reference for all SMS methods +- [use-cases/hiring-and-recruiting/](../use-cases/hiring-and-recruiting/) — end-to-end hiring workflows diff --git a/sms/agent-with-phone-number/.env.example b/sms/agent-with-phone-number/.env.example new file mode 100644 index 0000000..1d06ab8 --- /dev/null +++ b/sms/agent-with-phone-number/.env.example @@ -0,0 +1,9 @@ +# Your Commune API key — get it at https://commune.sh +COMMUNE_API_KEY=comm_your_key_here + +# Your OpenAI API key — get it at https://platform.openai.com +OPENAI_API_KEY=sk-your_openai_key_here + +# The Commune phone number ID to use for sending replies (pn_...) +# Find this in your Commune dashboard under Phone Numbers +PHONE_NUMBER_ID=pn_your_phone_number_id_here diff --git a/sms/agent-with-phone-number/README.md b/sms/agent-with-phone-number/README.md new file mode 100644 index 0000000..b385008 --- /dev/null +++ b/sms/agent-with-phone-number/README.md @@ -0,0 +1,79 @@ +# Give Your AI Agent Its Own Phone Number + +A Flask webhook server that gives an AI agent a dedicated SMS line. When someone texts the agent's number, it fetches the full conversation history, passes it to GPT-4o-mini, and replies — all within a few seconds. + +``` +User texts +1 415 555 0100 + ↓ +Commune webhook → POST /webhook/sms + ↓ +commune.sms.thread() → conversation history + ↓ +GPT-4o-mini → generates reply + ↓ +commune.sms.send() → user receives reply +``` + +--- + +## Setup + +**1. Install dependencies** + +```bash +pip install -r requirements.txt +``` + +**2. Configure environment** + +```bash +cp .env.example .env +# Fill in COMMUNE_API_KEY, OPENAI_API_KEY, PHONE_NUMBER_ID +``` + +Get your Commune API key at [commune.sh](https://commune.sh). Find your `PHONE_NUMBER_ID` in the Commune dashboard under Phone Numbers. + +**3. Expose your server (local dev)** + +```bash +ngrok http 8000 +# Copy the https URL, e.g. https://abc123.ngrok.io +``` + +**4. Register the webhook in Commune dashboard** + +Set your webhook URL to: `https://abc123.ngrok.io/webhook/sms` + +**5. Run the agent** + +```bash +python agent.py +``` + +**6. Test it** + +Text your Commune phone number. The agent will reply within a few seconds. + +--- + +## .env.example contents + +``` +COMMUNE_API_KEY=comm_your_key_here +OPENAI_API_KEY=sk-your_openai_key_here +PHONE_NUMBER_ID=pn_your_phone_number_id_here +``` + +--- + +## What's happening + +1. Inbound SMS hits `POST /webhook/sms`. The payload includes `from_number`, `body`, and `thread_id`. +2. `commune.sms.thread()` fetches the full conversation history between this sender and your number. This gives the LLM real context — it can reference earlier messages. +3. The last 10 messages are formatted into an OpenAI `messages` array, with `inbound` mapped to `"user"` and `outbound` mapped to `"assistant"`. +4. GPT-4o-mini generates a reply. The system prompt tells it to keep responses under 160 characters (one SMS segment). +5. `commune.sms.send()` delivers the reply back to the sender. + +**Conversation history window:** The agent uses the last 10 messages to keep token usage predictable. Increase this if your use case requires longer memory. + +**Production note:** Add HMAC signature verification before deploying publicly. The code includes a stub comment showing exactly where to add it. diff --git a/sms/agent-with-phone-number/agent.py b/sms/agent-with-phone-number/agent.py new file mode 100644 index 0000000..0bdd9de --- /dev/null +++ b/sms/agent-with-phone-number/agent.py @@ -0,0 +1,130 @@ +""" +AI agent with its own phone number. + +Runs a Flask webhook server. When someone texts the agent's Commune phone number, +this server receives the inbound SMS, fetches conversation history, generates a +GPT-4o-mini reply, and sends it back via SMS. + +Usage: + python agent.py + +Requirements: + - COMMUNE_API_KEY: your Commune API key + - OPENAI_API_KEY: your OpenAI API key + - PHONE_NUMBER_ID: the Commune phone number ID (pn_...) to use for sending +""" + +import os +import sys +from flask import Flask, request, jsonify +from commune import CommuneClient +from openai import OpenAI +from dotenv import load_dotenv + +load_dotenv() + +# Validate all required environment variables at startup. +# Catching these early means a clear error message instead of a confusing 500 later. +required_vars = ["COMMUNE_API_KEY", "OPENAI_API_KEY", "PHONE_NUMBER_ID"] +for var in required_vars: + if not os.environ.get(var): + raise SystemExit(f"Missing env var: {var} — copy .env.example to .env and fill it in.") + +commune = CommuneClient(api_key=os.environ["COMMUNE_API_KEY"]) +openai_client = OpenAI(api_key=os.environ["OPENAI_API_KEY"]) +PHONE_NUMBER_ID = os.environ["PHONE_NUMBER_ID"] + +# The system prompt establishes the agent's persona and SMS-specific constraints. +# SMS has no markdown support and carriers charge per 160-char segment, so brevity matters. +SYSTEM_PROMPT = """You are a helpful AI assistant reachable via SMS. +Keep your replies concise — under 160 characters whenever possible. +Use plain text only — no markdown, bullet points, or formatting.""" + +app = Flask(__name__) + + +@app.route("/webhook/sms", methods=["POST"]) +def handle_inbound_sms(): + """ + Receive an inbound SMS from Commune, generate a reply, send it back. + + Commune POST body (JSON): + from_number — E.164 number of the sender + to_number — our Commune phone number + body — the text content of the incoming SMS + thread_id — stable conversation thread identifier + message_sid — carrier message ID + """ + + # TODO: Add HMAC signature verification here before going to production. + # Commune sends a 'commune-signature' header. Verify it against your webhook secret + # using the same pattern as typescript/webhook-handler/verifyCommuneWebhook. + # Example: hmac.compare_digest(expected_sig, request.headers.get("commune-signature", "")) + + data = request.get_json(silent=True) + if not data: + return jsonify({"error": "Invalid JSON payload"}), 400 + + from_number: str = data.get("from_number", "") + body: str = data.get("body", "").strip() + + if not from_number: + return jsonify({"error": "Missing from_number"}), 400 + + # Empty body is valid (e.g. MMS with no caption). Acknowledge and return early. + if not body: + return jsonify({"status": "ok", "note": "empty body — no reply sent"}) + + try: + # Fetch the full conversation history so the LLM has context for multi-turn dialogue. + # thread() returns messages ordered oldest-first. + history = commune.sms.thread( + remote_number=from_number, + phone_number_id=PHONE_NUMBER_ID, + ) + except Exception as e: + print(f"[warn] Could not fetch thread for {from_number}: {e}") + history = [] + + # Build the messages array for OpenAI. Map Commune's direction field to OpenAI roles: + # inbound → "user" (messages they sent to us) + # outbound → "assistant" (messages we sent to them) + messages = [{"role": "system", "content": SYSTEM_PROMPT}] + for msg in history[-10:]: # keep the last 10 messages to limit token usage + role = "user" if msg.direction == "inbound" else "assistant" + content = msg.content or "" + if content: # skip empty messages (e.g. MMS-only) + messages.append({"role": role, "content": content}) + + try: + response = openai_client.chat.completions.create( + model="gpt-4o-mini", + messages=messages, + ) + reply = response.choices[0].message.content or "Sorry, I couldn't generate a reply." + except Exception as e: + print(f"[error] OpenAI call failed: {e}") + reply = "Sorry, something went wrong. Please try again in a moment." + + try: + commune.sms.send( + to=from_number, + body=reply, + phone_number_id=PHONE_NUMBER_ID, + ) + except Exception as e: + print(f"[error] Failed to send reply to {from_number}: {e}") + return jsonify({"error": "Failed to send reply"}), 500 + + print(f"[sms] {from_number} → '{body[:60]}...' | reply='{reply[:60]}...'") + return jsonify({"status": "ok"}) + + +if __name__ == "__main__": + print(f"Agent starting on port 8000. Webhook: POST /webhook/sms") + print(f"Phone number ID: {PHONE_NUMBER_ID}") + try: + app.run(port=8000, debug=False) + except KeyboardInterrupt: + print("\nShutting down.") + sys.exit(0) diff --git a/sms/agent-with-phone-number/requirements.txt b/sms/agent-with-phone-number/requirements.txt new file mode 100644 index 0000000..d200388 --- /dev/null +++ b/sms/agent-with-phone-number/requirements.txt @@ -0,0 +1,4 @@ +flask>=3.0.0 +commune-mail>=0.3.0 +openai>=1.30.0 +python-dotenv>=1.0.0 diff --git a/sms/alert-agent/.env.example b/sms/alert-agent/.env.example new file mode 100644 index 0000000..e441a6a --- /dev/null +++ b/sms/alert-agent/.env.example @@ -0,0 +1,3 @@ +COMMUNE_API_KEY=comm_your_key_here +OPENAI_API_KEY=sk-your_key_here +ALERT_PHONE=+15551234567 diff --git a/sms/alert-agent/README.md b/sms/alert-agent/README.md new file mode 100644 index 0000000..713ce94 --- /dev/null +++ b/sms/alert-agent/README.md @@ -0,0 +1,72 @@ +# SMS Alert Agent — Email Monitoring with SMS Notifications + +Monitors an email inbox and sends SMS alerts when urgent emails arrive. Uses Commune's unified email + SMS API. + +## Architecture + +```mermaid +graph LR + A[Email arrives] --> B[Commune inbox] + B --> C[agent.py polls every 30s] + C --> D[GPT-4o-mini classifies urgency] + D -- low / medium --> E[Skip] + D -- high --> F[commune.sms.send] + F --> G[SMS to your phone] +``` + +## Setup + +**1. Install dependencies** + +```bash +pip install -r requirements.txt +``` + +**2. Configure environment** + +```bash +cp .env.example .env +# Fill in COMMUNE_API_KEY, OPENAI_API_KEY, ALERT_PHONE +``` + +Get a Commune API key at [commune.sh](https://commune.sh). Provision a phone number in the Commune dashboard before running. + +**3. Run** + +```bash +python agent.py +``` + +The agent creates (or reuses) a `monitoring` inbox, then polls every 30 seconds. Send a test email mentioning something urgent like "system is down" to trigger an SMS alert. + +## How it works + +The agent polls `commune.threads.list()` every 30 seconds. For each new inbound thread it hasn't seen before, it: + +1. Loads the most recent inbound message with `commune.threads.messages()` +2. Sends the subject and first 500 characters to GPT-4o-mini for urgency classification +3. If urgency is `"high"`, calls `commune.sms.send()` to deliver an SMS alert to your configured phone number + +The SMS body is truncated to 160 characters to ensure single-segment delivery. + +### Urgency classification + +GPT-4o-mini returns structured JSON: + +```json +{ + "urgency": "high", + "reason": "System outage reported by customer", + "summary": "Customer reports production API returning 500 errors" +} +``` + +The agent only sends SMS for `"high"` urgency. Adjust the prompt in `classify_urgency()` to tune the threshold for your use case. + +## Customisation + +- **Monitor multiple inboxes** — call `get_inbox()` with different names and poll each one in the loop. +- **Route by sender** — check `sender` before alerting to only notify for specific customers or domains. +- **Reply via SMS reply** — combine with `sms/two-way-sms/` to let you respond to the email by replying to the SMS alert. +- **Adjust polling interval** — change `time.sleep(30)` to poll more or less frequently. +- **Add email auto-reply** — call `commune.messages.send()` alongside the SMS to acknowledge receipt to the sender. diff --git a/sms/alert-agent/agent.py b/sms/alert-agent/agent.py new file mode 100644 index 0000000..b639e0b --- /dev/null +++ b/sms/alert-agent/agent.py @@ -0,0 +1,138 @@ +""" +SMS Alert Agent — powered by Commune + +Monitors an email inbox and sends SMS alerts for high-urgency emails. +Shows Commune's unified email + SMS API in one agent. + +Usage: + export COMMUNE_API_KEY=comm_... + export OPENAI_API_KEY=sk-... + export ALERT_PHONE=+14155551234 # your phone number for alerts + python agent.py +""" +import os, json, time +from dotenv import load_dotenv +from commune import CommuneClient +from openai import OpenAI + +load_dotenv() + +# Validate required environment variables at startup +_REQUIRED_ENV = ["COMMUNE_API_KEY", "OPENAI_API_KEY", "ALERT_PHONE"] +for _var in _REQUIRED_ENV: + if not os.getenv(_var): + raise SystemExit(f"Missing required environment variable: {_var}\n" + f"Copy .env.example to .env and fill in your values.") + +commune = CommuneClient(api_key=os.environ["COMMUNE_API_KEY"]) +openai_client = OpenAI(api_key=os.environ["OPENAI_API_KEY"]) +ALERT_PHONE = os.environ["ALERT_PHONE"] + +# ── Inbox setup ──────────────────────────────────────────────────────────── + +def get_inbox(name="monitoring"): + """Get an existing inbox by local part, or create one.""" + for ib in commune.inboxes.list(): + if ib.local_part == name: + return ib.id, ib.address + ib = commune.inboxes.create(local_part=name) + return ib.id, ib.address + +INBOX_ID, INBOX_ADDRESS = get_inbox() + +# ── SMS phone number setup ───────────────────────────────────────────────── + +def get_phone_number(): + """Get the first provisioned phone number.""" + numbers = commune.phone_numbers.list() + if not numbers: + raise ValueError( + "No phone numbers found. Provision one at commune.email/dashboard." + ) + return numbers[0].id, numbers[0].number + +PHONE_ID, PHONE_NUMBER = get_phone_number() + +# ── Email classification ─────────────────────────────────────────────────── + +def classify_urgency(subject: str, content: str) -> dict: + """Use LLM to classify email urgency.""" + response = openai_client.chat.completions.create( + model="gpt-4o-mini", + response_format={"type": "json_object"}, + messages=[{ + "role": "user", + "content": f"""Classify this email urgency. + +Subject: {subject} +Content: {content[:500]} + +Return JSON: {{"urgency": "low|medium|high", "reason": "brief reason", "summary": "one line summary"}}""" + }] + ) + return json.loads(response.choices[0].message.content) + +# ── SMS alert ────────────────────────────────────────────────────────────── + +def send_sms_alert(classification: dict, sender: str, subject: str): + """Send SMS alert for high-urgency email.""" + # SMS messages are capped at 160 characters for single-segment delivery + message = f"Urgent email from {sender}\n{subject}\n{classification['reason']}" + result = commune.sms.send( + to=ALERT_PHONE, + body=message[:160], + phone_number_id=PHONE_ID, + ) + print(f" SMS alert sent to {ALERT_PHONE}: {result.message_id}") + +# ── Main loop ────────────────────────────────────────────────────────────── + +def main(): + handled = set() + print(f"Alert agent monitoring: {INBOX_ADDRESS}") + print(f"SMS alerts -> {ALERT_PHONE}\n") + + try: + while True: + result = commune.threads.list(inbox_id=INBOX_ID, limit=10) + + for thread in result.data: + # Skip threads we've already processed or that were outbound + if thread.thread_id in handled or thread.last_direction != "inbound": + handled.add(thread.thread_id) + continue + + # Load the full message list and find the last inbound message + messages = commune.threads.messages(thread.thread_id) + last = next( + (m for m in reversed(messages) if m.direction == "inbound"), + None, + ) + if not last: + continue + + sender = next( + (p.identity for p in last.participants if p.role == "sender"), + "unknown", + ) + print(f"\nEmail from {sender}: {thread.subject}") + + # Classify urgency with LLM + classification = classify_urgency( + thread.subject or "", + last.content or "", + ) + print(f" Urgency: {classification['urgency']} — {classification['reason']}") + + # Only alert on high-urgency emails + if classification["urgency"] == "high": + send_sms_alert(classification, sender, thread.subject or "(no subject)") + + handled.add(thread.thread_id) + + time.sleep(30) + except KeyboardInterrupt: + print("\nShutting down gracefully...") + +if __name__ == "__main__": + main() diff --git a/sms/alert-agent/requirements.txt b/sms/alert-agent/requirements.txt new file mode 100644 index 0000000..f3cfa56 --- /dev/null +++ b/sms/alert-agent/requirements.txt @@ -0,0 +1,3 @@ +commune-mail>=0.3.0 +openai>=1.30.0 +python-dotenv>=1.0.0 diff --git a/sms/hiring-agent/.env.example b/sms/hiring-agent/.env.example new file mode 100644 index 0000000..5e53f14 --- /dev/null +++ b/sms/hiring-agent/.env.example @@ -0,0 +1,16 @@ +# Your Commune API key — get it at https://commune.sh +COMMUNE_API_KEY=comm_your_key_here + +# The Commune phone number ID used to send and receive shift SMS (pn_...) +# Find this in your Commune dashboard under Phone Numbers +PHONE_NUMBER_ID=pn_your_phone_number_id_here + +# The Commune inbox ID used to send the manager notification email +# Find this in your Commune dashboard under Inboxes +COMMUNE_INBOX_ID=inbox_your_inbox_id_here + +# Email address of the manager who receives the "all shifts filled" notification +MANAGER_EMAIL=manager@yourcompany.com + +# How many YES confirmations are needed before the manager is notified +REQUIRED_CONFIRMATIONS=2 diff --git a/sms/hiring-agent/README.md b/sms/hiring-agent/README.md new file mode 100644 index 0000000..2bc3710 --- /dev/null +++ b/sms/hiring-agent/README.md @@ -0,0 +1,87 @@ +# Phone Number for Your Hiring Agent: Dispatch Workers via SMS + +An AI hiring agent that texts workers about open shifts, collects YES/NO confirmations, and emails the manager when all shifts are filled. + +``` +python agent.py dispatch → texts all workers in shifts.json +python agent.py webhook → starts webhook server to receive replies +``` + +--- + +## How it works + +**Dispatch mode** reads `shifts.json`, checks the suppression list (workers who opted out via STOP), and sends a personalized SMS to every eligible worker. Responses are saved to `shift_status.json`. + +**Webhook mode** listens for inbound SMS replies. Workers respond YES or NO. The agent: +- Confirms the worker and saves their status +- Sends a follow-up if the reply is ambiguous +- Emails the manager via Commune when all required confirmations are received + +--- + +## Setup + +**1. Install dependencies** + +```bash +pip install -r requirements.txt +``` + +**2. Configure environment** + +```bash +cp .env.example .env +# Fill in all variables +``` + +**3. Edit shifts.json** + +Add your open shifts and workers. See the sample in this folder. + +**4. Expose your server (local dev)** + +```bash +ngrok http 8000 +# Set this URL as your webhook in the Commune dashboard +``` + +**5. Dispatch offers** + +```bash +python agent.py dispatch +``` + +**6. Start webhook server to receive replies** + +```bash +python agent.py webhook +``` + +Run both in separate terminals, or deploy the webhook mode to a server. + +--- + +## .env.example contents + +``` +COMMUNE_API_KEY=comm_your_key_here +PHONE_NUMBER_ID=pn_your_phone_number_id_here +COMMUNE_INBOX_ID=inbox_your_inbox_id_here +MANAGER_EMAIL=manager@yourcompany.com +REQUIRED_CONFIRMATIONS=2 +``` + +--- + +## What's happening + +**Suppression check:** Before every send, the agent calls `commune.sms.suppressions()` to get the current opt-out list. Workers who sent STOP are skipped automatically — this is both legally required and good practice. + +**Rate limiting:** A 0.5-second delay between sends prevents carrier rate limiting. For large batches (100+ workers), increase this to 1–2 seconds. + +**Status tracking:** `shift_status.json` persists the state across restarts. If the webhook server goes down and comes back up, it picks up where it left off. + +**Manager notification:** When confirmed workers reach `REQUIRED_CONFIRMATIONS`, the agent sends a summary email via `commune.messages.send()`. This closes the loop without requiring the manager to monitor the dashboard. + +**Opt-out safety:** Workers can always reply STOP to opt out. Commune handles the suppression automatically — this agent just respects that list on the send side. diff --git a/sms/hiring-agent/agent.py b/sms/hiring-agent/agent.py new file mode 100644 index 0000000..082cc51 --- /dev/null +++ b/sms/hiring-agent/agent.py @@ -0,0 +1,252 @@ +""" +Hiring agent: dispatch open shifts via SMS and collect YES/NO confirmations. + +Two modes: + python agent.py dispatch — send shift offer SMS to all eligible workers in shifts.json + python agent.py webhook — start Flask server to receive and process worker replies + +State is persisted in shift_status.json so the webhook server can restart without +losing track of who has confirmed. +""" + +import os +import sys +import json +import time +from flask import Flask, request, jsonify +from commune import CommuneClient +from dotenv import load_dotenv + +load_dotenv() + +# Validate required environment variables at startup. +required_vars = ["COMMUNE_API_KEY", "PHONE_NUMBER_ID", "COMMUNE_INBOX_ID", "MANAGER_EMAIL"] +for var in required_vars: + if not os.environ.get(var): + raise SystemExit(f"Missing env var: {var} — copy .env.example to .env and fill it in.") + +commune = CommuneClient(api_key=os.environ["COMMUNE_API_KEY"]) +PHONE_NUMBER_ID = os.environ["PHONE_NUMBER_ID"] +COMMUNE_INBOX_ID = os.environ["COMMUNE_INBOX_ID"] +MANAGER_EMAIL = os.environ["MANAGER_EMAIL"] + +# How many confirmed workers are needed before we notify the manager. +REQUIRED_CONFIRMATIONS = int(os.environ.get("REQUIRED_CONFIRMATIONS", "1")) + +SHIFTS_FILE = os.path.join(os.path.dirname(__file__), "shifts.json") +STATUS_FILE = os.path.join(os.path.dirname(__file__), "shift_status.json") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def load_status() -> dict: + """Load current shift confirmation state from disk, or return empty dict.""" + if os.path.exists(STATUS_FILE): + try: + with open(STATUS_FILE) as f: + return json.load(f) + except Exception as e: + print(f"[warn] Could not read status file: {e}") + return {} + return {} + + +def save_status(status: dict) -> None: + """Persist shift confirmation state to disk.""" + with open(STATUS_FILE, "w") as f: + json.dump(status, f, indent=2) + + +def notify_manager(confirmed_workers: list) -> None: + """ + Send the manager a summary email when all required shifts are filled. + Uses Commune email (commune.messages.send) so the hiring loop is fully handled + by the same Commune account — no separate email provider needed. + """ + worker_list = "\n".join(f" - {w}" for w in confirmed_workers) + try: + commune.messages.send( + to=MANAGER_EMAIL, + subject=f"All {len(confirmed_workers)} shifts confirmed!", + text=( + f"All required shifts are now filled.\n\n" + f"Confirmed workers:\n{worker_list}\n\n" + f"No further action needed." + ), + inbox_id=COMMUNE_INBOX_ID, + ) + print(f"[email] Manager notified at {MANAGER_EMAIL}") + except Exception as e: + print(f"[warn] Could not send manager email: {e}") + + +# --------------------------------------------------------------------------- +# Dispatch mode: send shift offers to workers +# --------------------------------------------------------------------------- + +def dispatch_shifts() -> None: + """ + Read shifts.json, check opt-out list, and send personalized SMS to each worker. + Skips workers who have opted out via STOP (suppressions list). + """ + with open(SHIFTS_FILE) as f: + shifts = json.load(f) + + # Fetch the current opt-out list. Commune tracks STOP replies automatically. + # We check this before every send rather than caching it — the list can change. + suppressed_raw = commune.sms.suppressions(phone_number_id=PHONE_NUMBER_ID) + suppressed = {s.phone_number for s in suppressed_raw} + print(f"Suppression list: {len(suppressed)} numbers opted out") + + sent = 0 + skipped = 0 + + for shift in shifts: + shift_desc = ( + f"{shift['role']} on {shift['date']} at {shift['location']}" + ) + for worker in shift.get("workers", []): + name = worker.get("name", "there") + phone = worker.get("phone", "") + + if not phone: + print(f" SKIP {name} — no phone number") + continue + + if phone in suppressed: + print(f" SKIP {name} ({phone}) — opted out") + skipped += 1 + continue + + msg = ( + f"Hi {name}! Shift available: {shift_desc}. " + f"Reply YES to confirm or NO to decline." + ) + + try: + result = commune.sms.send( + to=phone, + body=msg, + phone_number_id=PHONE_NUMBER_ID, + ) + print(f" SENT to {name} ({phone}): {result.message_id}") + sent += 1 + except Exception as e: + print(f" FAIL {name} ({phone}): {e}") + + # Respect carrier rate limits — don't blast too fast + time.sleep(0.5) + + print(f"\nDispatch complete. Sent: {sent}, Skipped (opted out): {skipped}") + print(f"Run 'python agent.py webhook' to receive replies.") + + +# --------------------------------------------------------------------------- +# Webhook mode: receive and process worker replies +# --------------------------------------------------------------------------- + +app = Flask(__name__) + + +@app.route("/webhook/sms", methods=["POST"]) +def handle_reply(): + """ + Process inbound SMS replies from workers. + + Workers reply YES or NO. Anything else gets a clarification prompt. + After each reply, we check if all required confirmations have been received + and notify the manager if so. + """ + data = request.get_json(silent=True) + if not data: + return jsonify({"error": "Invalid JSON"}), 400 + + from_number: str = data.get("from_number", "") + body: str = data.get("body", "").strip().upper() + + if not from_number: + return jsonify({"error": "Missing from_number"}), 400 + + status = load_status() + already_notified = status.get("__manager_notified", False) + + if body.startswith("YES"): + status[from_number] = "confirmed" + try: + commune.sms.send( + to=from_number, + body="Great! You're confirmed. We'll send details shortly.", + phone_number_id=PHONE_NUMBER_ID, + ) + except Exception as e: + print(f"[warn] Could not send confirmation to {from_number}: {e}") + print(f"[reply] {from_number} → CONFIRMED") + + elif body.startswith("NO"): + status[from_number] = "declined" + try: + commune.sms.send( + to=from_number, + body="No problem! We'll reach out if another shift opens up.", + phone_number_id=PHONE_NUMBER_ID, + ) + except Exception as e: + print(f"[warn] Could not send decline ack to {from_number}: {e}") + print(f"[reply] {from_number} → DECLINED") + + else: + # Ambiguous reply — ask them to clarify + try: + commune.sms.send( + to=from_number, + body="Please reply YES to confirm or NO to decline the shift.", + phone_number_id=PHONE_NUMBER_ID, + ) + except Exception as e: + print(f"[warn] Could not send clarification to {from_number}: {e}") + print(f"[reply] {from_number} → AMBIGUOUS: '{body[:40]}'") + + save_status(status) + + # Check if we've hit the required confirmation threshold + confirmed = [num for num, s in status.items() if s == "confirmed" and not num.startswith("__")] + print(f" Confirmed so far: {len(confirmed)}/{REQUIRED_CONFIRMATIONS}") + + if len(confirmed) >= REQUIRED_CONFIRMATIONS and not already_notified: + notify_manager(confirmed) + status["__manager_notified"] = True + save_status(status) + + return jsonify({"status": "ok"}) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + mode = sys.argv[1] if len(sys.argv) > 1 else "help" + + if mode == "dispatch": + try: + dispatch_shifts() + except KeyboardInterrupt: + print("\nCancelled.") + sys.exit(0) + + elif mode == "webhook": + print(f"Webhook server starting on port 8000. POST /webhook/sms") + print(f"Required confirmations: {REQUIRED_CONFIRMATIONS}") + try: + app.run(port=8000, debug=False) + except KeyboardInterrupt: + print("\nShutting down.") + sys.exit(0) + + else: + print("Usage:") + print(" python agent.py dispatch — send shift offer SMS to workers") + print(" python agent.py webhook — start server to receive replies") + sys.exit(1) diff --git a/sms/hiring-agent/requirements.txt b/sms/hiring-agent/requirements.txt new file mode 100644 index 0000000..946db78 --- /dev/null +++ b/sms/hiring-agent/requirements.txt @@ -0,0 +1,3 @@ +flask>=3.0.0 +commune-mail>=0.3.0 +python-dotenv>=1.0.0 diff --git a/sms/hiring-agent/shifts.json b/sms/hiring-agent/shifts.json new file mode 100644 index 0000000..142d2b7 --- /dev/null +++ b/sms/hiring-agent/shifts.json @@ -0,0 +1,22 @@ +[ + { + "id": "shift_001", + "role": "Customer Service Representative", + "date": "Monday March 3, 9am–5pm", + "location": "Downtown Office, 123 Main St", + "workers": [ + {"name": "Alex", "phone": "+15551234567"}, + {"name": "Sam", "phone": "+15559876543"} + ] + }, + { + "id": "shift_002", + "role": "Warehouse Associate", + "date": "Tuesday March 4, 7am–3pm", + "location": "Distribution Center, 456 Industrial Blvd", + "workers": [ + {"name": "Jordan", "phone": "+15552223333"}, + {"name": "Taylor", "phone": "+15554445555"} + ] + } +] diff --git a/sms/openclaw-sms/README.md b/sms/openclaw-sms/README.md new file mode 100644 index 0000000..a5c23c3 --- /dev/null +++ b/sms/openclaw-sms/README.md @@ -0,0 +1,81 @@ +# Give Your OpenClaw Agent Its Own Phone Number + +Add SMS to your OpenClaw agent in minutes using a pre-built Commune skill. + +--- + +## What is OpenClaw? + +OpenClaw is an open-source AI agent framework that runs locally on your machine. You give it tools (called skills), and it uses an LLM to decide when and how to use them. Out of the box it can browse the web, run code, and read files — but it can't send or receive SMS without a real phone number. + +--- + +## The problem + +Your OpenClaw agent can generate a message and know who to send it to — but without a phone number, it has no way to actually deliver an SMS or receive replies. Building that infrastructure from scratch means provisioning carrier numbers, handling webhooks, managing opt-outs, and storing conversation history. + +--- + +## The solution + +The `commune-sms` skill (in [`../../openclaw-email-sms/`](../../openclaw-email-sms/)) wraps the entire Commune SMS API as a set of OpenClaw-compatible tools. Install it once and your agent can send texts, read replies, search conversation history, and check who has opted out — all through natural language. + +--- + +## Installation + +**1. Install the skill** + +```bash +cd ../../openclaw-email-sms +./install.sh +``` + +Or follow the manual setup in [`../../openclaw-email-sms/README.md`](../../openclaw-email-sms/README.md). + +**2. Set environment variables** + +```bash +export COMMUNE_API_KEY=comm_your_key_here +export PHONE_NUMBER_ID=pn_your_phone_number_id_here +``` + +**3. Restart OpenClaw** + +The skill is auto-discovered on startup. You'll see `commune-sms` listed in the active tools. + +--- + +## What to say to your agent + +Once the skill is installed, talk to OpenClaw the same way you would talk to a person: + +- "Send a text to +1 555 000 1234 saying 'Your appointment is confirmed for tomorrow at 2pm.'" +- "What texts have I received in the last 24 hours?" +- "Reply to the last text from Sam saying 'I'll be there by noon.'" +- "Show me all conversations where someone asked about the refund policy." +- "Which contacts have opted out of SMS?" +- "Send the shift offer to everyone in my contacts list who hasn't opted out." + +The skill handles number lookup, conversation history, suppression checking, and sending — your agent just needs to describe what it wants to do. + +--- + +## What the skill provides + +| Tool | What it does | +|---|---| +| `sms_send` | Send an SMS to a phone number | +| `sms_thread` | Read the full conversation history with a contact | +| `sms_conversations` | List all active SMS threads | +| `sms_search` | Semantic search across all SMS content | +| `sms_suppressions` | Get the list of numbers that opted out | +| `phone_numbers_list` | List available Commune phone numbers | + +--- + +## Further reading + +- [openclaw-email-sms/](../../openclaw-email-sms/) — full skill documentation and SMS skill source +- [SMS capabilities reference](../../capabilities/sms/) — complete API reference for all SMS methods +- [two-way-sms/](../two-way-sms/) — if you want to build a custom SMS webhook server instead of using OpenClaw diff --git a/sms/personal-agent/.env.example b/sms/personal-agent/.env.example new file mode 100644 index 0000000..72a8417 --- /dev/null +++ b/sms/personal-agent/.env.example @@ -0,0 +1,19 @@ +# Your Commune API key — get it at https://commune.sh +COMMUNE_API_KEY=comm_your_key_here + +# Your OpenAI API key — get it at https://platform.openai.com +OPENAI_API_KEY=sk-your_openai_key_here + +# The Commune phone number ID for the agent's dedicated number (pn_...) +# Find this in your Commune dashboard under Phone Numbers +PHONE_NUMBER_ID=pn_your_phone_number_id_here + +# YOUR personal cell number — the agent only responds to texts from this number +MY_PHONE_NUMBER=+15551234567 + +# Your name — used in the system prompt and morning greeting +OWNER_NAME=Alex + +# Your timezone — used for the morning summary schedule +# Examples: US/Eastern, US/Pacific, Europe/London, Asia/Tokyo +TIMEZONE=US/Eastern diff --git a/sms/personal-agent/README.md b/sms/personal-agent/README.md new file mode 100644 index 0000000..dc2eaf3 --- /dev/null +++ b/sms/personal-agent/README.md @@ -0,0 +1,77 @@ +# Your Personal AI That Texts You Back + +A personal AI assistant with a real phone number. It texts you a morning summary every day at 9am, and replies to any text you send it within seconds. + +``` +You: "What should I work on today?" +Agent: "Top 3: finish the API docs, reply to Sarah's email, prep for 3pm standup." + +You: "Remind me to call mom at 6pm" +Agent: "Got it — I'll text you at 6pm: call mom." + +[9:00am, unprompted] +Agent: "Morning! Today: dentist at 2pm, team sync at 4pm. Reply with anything." +``` + +--- + +## Setup + +**1. Install dependencies** + +```bash +pip install -r requirements.txt +``` + +**2. Configure environment** + +```bash +cp .env.example .env +# Fill in all variables — especially MY_PHONE_NUMBER (your personal cell) +``` + +**3. Expose your server (local dev)** + +```bash +ngrok http 8000 +# Set this URL as your webhook in the Commune dashboard +``` + +**4. Register the webhook** + +In the Commune dashboard, set your phone number's webhook to `https://your-url.ngrok.io/webhook/sms`. + +**5. Run** + +```bash +python agent.py +``` + +The agent starts a background thread for the morning summary and a Flask server for responding to texts. Both run in the same process. + +--- + +## .env.example contents + +``` +COMMUNE_API_KEY=comm_your_key_here +OPENAI_API_KEY=sk-your_openai_key_here +PHONE_NUMBER_ID=pn_your_phone_number_id_here +MY_PHONE_NUMBER=+15551234567 +OWNER_NAME=Alex +TIMEZONE=US/Eastern +``` + +--- + +## What's happening + +**Security:** The webhook only responds to texts from `MY_PHONE_NUMBER`. Texts from anyone else are silently ignored. This means the agent is exclusively yours. + +**Conversation memory:** Every text you send and every reply the agent gives is stored in Commune's conversation thread. When you text again, the agent fetches the last 20 messages as context — so it remembers what you talked about. + +**Morning summary:** A background thread checks the time every 30 seconds. At 9:00am in your local time, it sends a morning check-in. You can customize this to pull from a calendar file, a todo list, or any API you have access to. + +**Proactive vs reactive:** The agent runs both modes in the same process. The background thread handles proactive outreach; the Flask server handles reactive replies. You can deploy this to a server (Railway, Fly, etc.) and leave it running permanently. + +**Keeping responses short:** The system prompt instructs GPT-4o-mini to keep replies under 160 characters when possible. You can remove this constraint if you're okay with multi-segment messages. diff --git a/sms/personal-agent/agent.py b/sms/personal-agent/agent.py new file mode 100644 index 0000000..e8d923e --- /dev/null +++ b/sms/personal-agent/agent.py @@ -0,0 +1,198 @@ +""" +Personal AI assistant with a real phone number. + +Two modes run simultaneously in one process: + - Proactive: background thread sends a morning summary at 9am every day + - Reactive: Flask webhook receives your texts and replies via GPT-4o-mini + +Only responds to texts from MY_PHONE_NUMBER — all other senders are ignored. + +Usage: + python agent.py +""" + +import os +import sys +import time +import threading +import datetime +import zoneinfo +from flask import Flask, request, jsonify +from commune import CommuneClient +from openai import OpenAI +from dotenv import load_dotenv + +load_dotenv() + +# Validate required environment variables at startup. +required_vars = [ + "COMMUNE_API_KEY", + "OPENAI_API_KEY", + "PHONE_NUMBER_ID", + "MY_PHONE_NUMBER", +] +for var in required_vars: + if not os.environ.get(var): + raise SystemExit(f"Missing env var: {var} — copy .env.example to .env and fill it in.") + +commune = CommuneClient(api_key=os.environ["COMMUNE_API_KEY"]) +openai_client = OpenAI(api_key=os.environ["OPENAI_API_KEY"]) + +MY_PHONE = os.environ["MY_PHONE_NUMBER"] # your personal cell number (E.164) +PHONE_ID = os.environ["PHONE_NUMBER_ID"] # Commune phone number used for this agent +OWNER_NAME = os.environ.get("OWNER_NAME", "there") +TIMEZONE = os.environ.get("TIMEZONE", "US/Eastern") + +# Validate the timezone string early so the error is obvious at startup. +try: + _TZ = zoneinfo.ZoneInfo(TIMEZONE) +except zoneinfo.ZoneInfoNotFoundError: + raise SystemExit(f"Invalid TIMEZONE: '{TIMEZONE}'. Use IANA names like 'US/Eastern' or 'America/New_York'.") + +SYSTEM_PROMPT = ( + f"You are {OWNER_NAME}'s personal AI assistant, reachable via SMS. " + f"You're helpful, concise, and friendly. " + f"Keep SMS responses under 160 characters whenever possible — use plain text only, no markdown. " + f"You have access to conversation history, so you can reference earlier messages." +) + + +# --------------------------------------------------------------------------- +# Proactive: morning summary +# --------------------------------------------------------------------------- + +def send_morning_summary() -> None: + """ + Background thread: send a morning summary at 9am every day. + + In a real deployment you'd pull from a calendar API, todo list, or database + here. This example sends a placeholder that you can customize. + """ + summary_sent_today: str | None = None # track which date we last sent + + while True: + # Use the owner's configured timezone so 9am fires at the right local time, + # not at 9am UTC (which would be wrong on any cloud server). + now = datetime.datetime.now(tz=_TZ) + today = now.strftime("%Y-%m-%d") + + # Send at 9:00am in the owner's timezone, once per day + if now.hour == 9 and now.minute == 0 and summary_sent_today != today: + message = ( + f"Good morning {OWNER_NAME}! " + f"Today is {now.strftime('%A %B %-d')}. " + f"Reply with anything — I'm here." + ) + # In production: pull from calendar/todos here and include in message + try: + commune.sms.send( + to=MY_PHONE, + body=message, + phone_number_id=PHONE_ID, + ) + print(f"[morning] Summary sent to {MY_PHONE}") + summary_sent_today = today + except Exception as e: + print(f"[warn] Could not send morning summary: {e}") + + # Sleep 61 seconds so we don't re-trigger in the same minute + time.sleep(61) + else: + # Check every 30 seconds — low overhead, fast enough response + time.sleep(30) + + +# --------------------------------------------------------------------------- +# Reactive: webhook server +# --------------------------------------------------------------------------- + +app = Flask(__name__) + + +@app.route("/webhook/sms", methods=["POST"]) +def handle_text(): + """ + Receive an inbound SMS from Commune and reply via GPT-4o-mini. + + Only processes messages from MY_PHONE_NUMBER — this keeps the agent + private so only you can use it. + """ + data = request.get_json(silent=True) + if not data: + return jsonify({"error": "Invalid JSON payload"}), 400 + + from_number: str = data.get("from_number", "") + body: str = data.get("body", "").strip() + + # Security: only respond to texts from the owner's number. + # Texts from anyone else are silently ignored, not replied to. + if from_number != MY_PHONE: + print(f"[ignored] Text from {from_number} — not the owner") + return jsonify({"status": "ignored"}) + + if not body: + return jsonify({"status": "ok", "note": "empty body"}) + + # Fetch the full conversation history so the LLM can reference earlier messages. + # Using a window of 20 messages gives good context without ballooning token usage. + try: + history = commune.sms.thread( + remote_number=MY_PHONE, + phone_number_id=PHONE_ID, + ) + except Exception as e: + print(f"[warn] Could not fetch thread: {e}") + history = [] + + messages = [{"role": "system", "content": SYSTEM_PROMPT}] + for msg in history[-20:]: + role = "user" if msg.direction == "inbound" else "assistant" + content = msg.content or "" + if content: + messages.append({"role": role, "content": content}) + + try: + response = openai_client.chat.completions.create( + model="gpt-4o-mini", + messages=messages, + ) + reply = response.choices[0].message.content or "I couldn't generate a reply — try again." + except Exception as e: + print(f"[error] OpenAI call failed: {e}") + reply = "Something went wrong on my end. Try again in a moment." + + try: + commune.sms.send( + to=MY_PHONE, + body=reply, + phone_number_id=PHONE_ID, + ) + except Exception as e: + print(f"[error] Failed to send reply: {e}") + return jsonify({"error": "Failed to send reply"}), 500 + + print(f"[reply] '{body[:50]}' → '{reply[:50]}'") + return jsonify({"status": "ok"}) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + print(f"Personal agent starting.") + print(f"Owner: {OWNER_NAME} ({MY_PHONE})") + print(f"Phone number ID: {PHONE_ID}") + print(f"Morning summaries: 9am daily") + + # Start the proactive morning summary thread. + # daemon=True means it exits automatically when the main process exits. + morning_thread = threading.Thread(target=send_morning_summary, daemon=True) + morning_thread.start() + + print(f"Webhook server on port 8000. Text {MY_PHONE} to chat.") + try: + app.run(port=8000, debug=False) + except KeyboardInterrupt: + print("\nShutting down.") + sys.exit(0) diff --git a/sms/personal-agent/requirements.txt b/sms/personal-agent/requirements.txt new file mode 100644 index 0000000..d200388 --- /dev/null +++ b/sms/personal-agent/requirements.txt @@ -0,0 +1,4 @@ +flask>=3.0.0 +commune-mail>=0.3.0 +openai>=1.30.0 +python-dotenv>=1.0.0 diff --git a/sms/quickstart/.env.example b/sms/quickstart/.env.example new file mode 100644 index 0000000..f56ff8e --- /dev/null +++ b/sms/quickstart/.env.example @@ -0,0 +1,5 @@ +# Your Commune API key — get it at https://commune.sh +COMMUNE_API_KEY=comm_your_key_here + +# The real mobile number you want to send the test SMS to (E.164 format) +TEST_PHONE_NUMBER=+15551234567 diff --git a/sms/quickstart/README.md b/sms/quickstart/README.md new file mode 100644 index 0000000..aa83797 --- /dev/null +++ b/sms/quickstart/README.md @@ -0,0 +1,61 @@ +# SMS Quickstart: Send Your First Text in 60 Seconds + +List your available phone numbers, send a test SMS, and print the delivery receipt. No server required. + +```python +python quickstart.py +# Phone number: +14155552671 +# SMS sent to +15551234567 +# message_id=SM7a3c... thread_id=thr_9x2... status=queued credits_charged=1 +``` + +--- + +## Setup + +**1. Install dependencies** + +```bash +pip install -r requirements.txt +``` + +**2. Configure environment** + +```bash +cp .env.example .env +# Edit .env — add your COMMUNE_API_KEY and TEST_PHONE_NUMBER +``` + +Get your API key at [commune.sh](https://commune.sh). Your test phone number is the real mobile number you want to text. + +**3. Run** + +```bash +python quickstart.py +``` + +For TypeScript: + +```bash +npm install +npx tsx quickstart.ts +``` + +--- + +## .env.example contents + +``` +COMMUNE_API_KEY=comm_your_key_here +TEST_PHONE_NUMBER=+15551234567 +``` + +--- + +## What's happening + +1. `commune.phone_numbers.list()` — fetches all phone numbers on your Commune account. The first SMS-capable number is used automatically. +2. `commune.sms.send()` — sends the message and returns a receipt with `message_id`, `thread_id`, `status`, and `credits_charged`. +3. The receipt is printed so you can verify delivery before wiring up a full agent. + +If you have no phone numbers yet, provision one from the [Commune dashboard](https://commune.sh) — it takes under a minute. diff --git a/sms/quickstart/package.json b/sms/quickstart/package.json new file mode 100644 index 0000000..389d461 --- /dev/null +++ b/sms/quickstart/package.json @@ -0,0 +1,18 @@ +{ + "name": "sms-quickstart", + "version": "1.0.0", + "description": "Send your first Commune SMS in 60 seconds", + "scripts": { + "start": "npx tsx quickstart.ts", + "dev": "npx tsx quickstart.ts" + }, + "dependencies": { + "commune-ai": "latest", + "dotenv": "^16.0.0" + }, + "devDependencies": { + "tsx": "^4.0.0", + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + } +} diff --git a/sms/quickstart/quickstart.py b/sms/quickstart/quickstart.py new file mode 100644 index 0000000..ee902fb --- /dev/null +++ b/sms/quickstart/quickstart.py @@ -0,0 +1,84 @@ +""" +SMS Quickstart — send your first text in 60 seconds. + +Steps: + 1. List phone numbers to find one with SMS capability + 2. Send a test SMS to TEST_PHONE_NUMBER + 3. Print the delivery receipt + +Usage: + python quickstart.py +""" + +import os +import sys +from dotenv import load_dotenv +from commune import CommuneClient + +load_dotenv() + +# Validate required environment variables before doing anything else. +# Failing fast here gives a clear error rather than a confusing API response. +required = ["COMMUNE_API_KEY", "TEST_PHONE_NUMBER"] +for var in required: + if not os.environ.get(var): + raise SystemExit(f"Missing env var: {var} — copy .env.example to .env and fill it in.") + +API_KEY = os.environ["COMMUNE_API_KEY"] +TEST_PHONE = os.environ["TEST_PHONE_NUMBER"] + +commune = CommuneClient(api_key=API_KEY) + + +def find_sms_capable_number(): + """Return the first phone number on the account that has SMS capability.""" + numbers = commune.phone_numbers.list() + + if not numbers: + raise SystemExit( + "No phone numbers found on your account. " + "Provision one at https://commune.sh before running this script." + ) + + for number in numbers: + # Skip numbers that don't have SMS enabled (e.g. voice-only lines) + if number.capabilities and number.capabilities.sms: + return number + + raise SystemExit( + "No SMS-capable phone numbers found. " + "Check your Commune dashboard and ensure at least one number has SMS enabled." + ) + + +def main() -> None: + print("Fetching phone numbers...") + phone = find_sms_capable_number() + print(f"Using phone number: {phone.number} (id={phone.id})") + + print(f"\nSending SMS to {TEST_PHONE}...") + try: + result = commune.sms.send( + to=TEST_PHONE, + body="Hello from Commune! Your SMS quickstart is working.", + phone_number_id=phone.id, + ) + except Exception as e: + raise SystemExit(f"SMS send failed: {e}") + + # Print the full delivery receipt so you can verify everything looks right. + print("\nDelivery receipt:") + print(f" message_id = {result.message_id}") + print(f" thread_id = {result.thread_id}") + print(f" status = {result.status}") + print(f" credits_charged = {result.credits_charged}") + print(f" segments = {result.segments}") + print("\nDone. Check your phone for the message.") + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\nCancelled.") + sys.exit(0) diff --git a/sms/quickstart/quickstart.ts b/sms/quickstart/quickstart.ts new file mode 100644 index 0000000..c92549e --- /dev/null +++ b/sms/quickstart/quickstart.ts @@ -0,0 +1,91 @@ +/** + * SMS Quickstart — send your first text in 60 seconds (TypeScript). + * + * Steps: + * 1. List phone numbers to find one with SMS capability + * 2. Send a test SMS to TEST_PHONE_NUMBER + * 3. Print the delivery receipt + * + * Usage: + * npx tsx quickstart.ts + */ + +import { CommuneClient } from "commune-ai"; +import * as dotenv from "dotenv"; + +dotenv.config(); + +// Validate required environment variables before doing anything else. +const required = ["COMMUNE_API_KEY", "TEST_PHONE_NUMBER"]; +for (const varName of required) { + if (!process.env[varName]) { + console.error( + `Missing env var: ${varName} — copy .env.example to .env and fill it in.` + ); + process.exit(1); + } +} + +const API_KEY = process.env.COMMUNE_API_KEY!; +const TEST_PHONE = process.env.TEST_PHONE_NUMBER!; + +const commune = new CommuneClient({ apiKey: API_KEY }); + +async function findSmsCapableNumber() { + const numbers = await commune.phoneNumbers.list(); + + if (!numbers || numbers.length === 0) { + console.error( + "No phone numbers found on your account. " + + "Provision one at https://commune.sh before running this script." + ); + process.exit(1); + } + + // Use the first number that has SMS enabled + for (const number of numbers) { + if (number.capabilities?.sms) { + return number; + } + } + + console.error( + "No SMS-capable phone numbers found. " + + "Check your Commune dashboard and ensure at least one number has SMS enabled." + ); + process.exit(1); +} + +async function main(): Promise { + console.log("Fetching phone numbers..."); + const phone = await findSmsCapableNumber(); + console.log(`Using phone number: ${phone.number} (id=${phone.id})`); + + console.log(`\nSending SMS to ${TEST_PHONE}...`); + + let result; + try { + result = await commune.sms.send({ + to: TEST_PHONE, + body: "Hello from Commune! Your SMS quickstart is working.", + phoneNumberId: phone.id, + }); + } catch (err) { + console.error("SMS send failed:", err); + process.exit(1); + } + + // Print the full delivery receipt so you can verify everything looks right. + console.log("\nDelivery receipt:"); + console.log(` message_id = ${result.messageId}`); + console.log(` thread_id = ${result.threadId}`); + console.log(` status = ${result.status}`); + console.log(` credits_charged = ${result.creditsCharged}`); + console.log(` segments = ${result.segments}`); + console.log("\nDone. Check your phone for the message."); +} + +main().catch((err) => { + console.error("Unexpected error:", err); + process.exit(1); +}); diff --git a/sms/quickstart/requirements.txt b/sms/quickstart/requirements.txt new file mode 100644 index 0000000..be06beb --- /dev/null +++ b/sms/quickstart/requirements.txt @@ -0,0 +1,2 @@ +commune-mail>=0.3.0 +python-dotenv>=1.0.0 diff --git a/sms/sms-marketing/.env.example b/sms/sms-marketing/.env.example new file mode 100644 index 0000000..ef88b66 --- /dev/null +++ b/sms/sms-marketing/.env.example @@ -0,0 +1,13 @@ +# Your Commune API key — get it at https://commune.sh +COMMUNE_API_KEY=comm_your_key_here + +# Your OpenAI API key — used to personalize messages per contact +# Get it at https://platform.openai.com +OPENAI_API_KEY=sk-your_openai_key_here + +# The Commune phone number ID to send campaigns from (pn_...) +# Find this in your Commune dashboard under Phone Numbers +PHONE_NUMBER_ID=pn_your_phone_number_id_here + +# A human-readable name for this campaign — used in status output +CAMPAIGN_NAME=Spring Campaign diff --git a/sms/sms-marketing/README.md b/sms/sms-marketing/README.md new file mode 100644 index 0000000..7e50835 --- /dev/null +++ b/sms/sms-marketing/README.md @@ -0,0 +1,102 @@ +# SMS Marketing Campaigns for AI Agents + +Send personalized SMS campaigns to your contact list, follow up with non-responders, and track response rates — all from the command line. + +```bash +python campaign.py broadcast "Hi {name}, we thought you'd love our new product!" +python campaign.py drip # follow up with non-responders after 48h +python campaign.py status # show response rates +``` + +--- + +## Quickstart + +**1. Install dependencies** + +```bash +pip install -r requirements.txt +``` + +**2. Configure environment** + +```bash +cp .env.example .env +# Fill in COMMUNE_API_KEY, OPENAI_API_KEY, PHONE_NUMBER_ID +``` + +**3. Add your contacts** + +Edit `contacts.csv` — one row per contact. The agent personalizes each message using the contact's name, company, and role fields. + +**4. Send the campaign** + +```bash +python campaign.py broadcast "Hi {name}, wanted to share something you'll love." +``` + +**5. Follow up with non-responders (48 hours later)** + +```bash +python campaign.py drip +``` + +**6. Check response rates** + +```bash +python campaign.py status +``` + +--- + +## .env.example contents + +``` +COMMUNE_API_KEY=comm_your_key_here +OPENAI_API_KEY=sk-your_openai_key_here +PHONE_NUMBER_ID=pn_your_phone_number_id_here +CAMPAIGN_NAME=Spring Campaign +``` + +--- + +## Compliance + +SMS marketing is regulated. Before sending: + +**STOP/START handling:** Commune automatically processes STOP and START replies and maintains your suppression list. The broadcast mode checks `commune.sms.suppressions()` before every send and skips opted-out contacts. + +**A2P 10DLC (US):** If you're sending marketing messages from a 10-digit long code (local number), you must register your brand and campaign with The Campaign Registry. This applies to US sending. Toll-free numbers have a separate verification process. See [commune.sh/docs](https://commune.sh/docs) for registration steps. + +**Consent:** Only message contacts who have opted in to receive SMS from you. Unsolicited marketing SMS is a legal liability and will get your number flagged by carriers. + +**Send rate:** The broadcast mode adds a 1-second delay between sends by default. For large lists (1,000+), increase this or use a registered toll-free number which supports higher throughput. + +--- + +## Drip sequences + +The `drip` mode finds contacts who received an outbound message but haven't replied after 48 hours, then sends a follow-up. It caps at 10 follow-ups per run to avoid overwhelming anyone. + +To build a full multi-step drip sequence, schedule `python campaign.py drip` as a cron job: + +```bash +# Run drip check every 24 hours +0 10 * * * cd /path/to/sms-marketing && python campaign.py drip +``` + +--- + +## Personalization + +The broadcast mode uses GPT-4o-mini to personalize each message based on the contact's name, company, and role from `contacts.csv`. This produces natural, human-sounding messages rather than obvious mail-merge substitutions. + +To disable AI personalization and use the template literally (with `{name}` substituted), remove the `personalize_message()` call and use `template.replace("{name}", contact.get("name", "there"))` instead. + +--- + +## What's happening + +1. **broadcast**: Reads `contacts.csv`, fetches suppression list, personalizes the template via GPT-4o-mini for each contact, sends via `commune.sms.send()`, and prints a summary. +2. **drip**: Calls `commune.sms.conversations()` to find threads where only one message was sent (no reply), filters to those older than 48 hours, and sends a follow-up to each. +3. **status**: Calls `commune.sms.conversations()` and counts threads with more than one message as "replied", reporting the response rate. diff --git a/sms/sms-marketing/campaign.py b/sms/sms-marketing/campaign.py new file mode 100644 index 0000000..3836c56 --- /dev/null +++ b/sms/sms-marketing/campaign.py @@ -0,0 +1,255 @@ +""" +SMS marketing campaign tool. + +Three modes: + python campaign.py broadcast [template] — personalize and send to all non-suppressed contacts + python campaign.py drip — follow up with non-responders after 48 hours + python campaign.py status — show response rates for the current phone number + +The broadcast mode checks the Commune suppression list before every send so that +contacts who replied STOP are never messaged again. +""" + +import csv +import sys +import time +import datetime +from typing import Optional +from commune import CommuneClient +from openai import OpenAI +from dotenv import load_dotenv +import os + +load_dotenv() + +# Validate required environment variables at startup. +required_vars = ["COMMUNE_API_KEY", "OPENAI_API_KEY", "PHONE_NUMBER_ID"] +for var in required_vars: + if not os.environ.get(var): + raise SystemExit(f"Missing env var: {var} — copy .env.example to .env and fill it in.") + +commune = CommuneClient(api_key=os.environ["COMMUNE_API_KEY"]) +openai_client = OpenAI(api_key=os.environ["OPENAI_API_KEY"]) +PHONE_ID = os.environ["PHONE_NUMBER_ID"] +CAMPAIGN_NAME = os.environ.get("CAMPAIGN_NAME", "Campaign") + +CONTACTS_FILE = os.path.join(os.path.dirname(__file__), "contacts.csv") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def personalize_message(contact: dict, template: str) -> str: + """ + Use GPT-4o-mini to personalize a message template for a specific contact. + + This produces natural-sounding messages rather than obvious mail-merge. + The model is instructed to stay under 160 characters (one SMS segment). + """ + prompt = ( + f"Personalize this SMS template for the contact below. " + f"Keep it under 160 characters. Be natural and conversational — " + f"it should not feel like a template.\n\n" + f"Template: {template}\n" + f"Contact: Name={contact.get('name', 'there')}, " + f"Company={contact.get('company', '')}, " + f"Role={contact.get('role', '')}\n\n" + f"Return only the personalized message, nothing else." + ) + response = openai_client.chat.completions.create( + model="gpt-4o-mini", + messages=[{"role": "user", "content": prompt}], + ) + return response.choices[0].message.content.strip() + + +def load_contacts() -> list[dict]: + """Load contacts from contacts.csv, skipping rows with no phone number.""" + if not os.path.exists(CONTACTS_FILE): + raise SystemExit(f"contacts.csv not found at {CONTACTS_FILE}") + with open(CONTACTS_FILE, newline="") as f: + reader = csv.DictReader(f) + return [row for row in reader if row.get("phone", "").strip()] + + +# --------------------------------------------------------------------------- +# Broadcast mode: send to all non-suppressed contacts +# --------------------------------------------------------------------------- + +def broadcast(template: str, delay: float = 1.0) -> None: + """ + Send a personalized SMS to every contact not on the suppression list. + + delay: seconds to wait between sends — keeps us under carrier rate limits. + For large lists, increase this to 1-2 seconds. + """ + # Fetch opt-out list first. We check this before every send rather than + # caching it because the list can change (e.g. someone stopped mid-batch). + suppressed_raw = commune.sms.suppressions(phone_number_id=PHONE_ID) + suppressed = {s.phone_number for s in suppressed_raw} + print(f"Suppression list: {len(suppressed)} numbers opted out") + + contacts = load_contacts() + results = {"sent": 0, "skipped_suppressed": 0, "skipped_no_phone": 0, "failed": 0} + + for contact in contacts: + phone = contact.get("phone", "").strip() + name = contact.get("name", phone) + + if not phone: + results["skipped_no_phone"] += 1 + continue + + if phone in suppressed: + print(f" SKIP {name} — opted out") + results["skipped_suppressed"] += 1 + continue + + try: + message = personalize_message(contact, template) + except Exception as e: + print(f" FAIL {name} — personalization error: {e}") + results["failed"] += 1 + continue + + try: + result = commune.sms.send( + to=phone, + body=message, + phone_number_id=PHONE_ID, + ) + print(f" SENT {name} ({phone}): {message[:60]}...") + results["sent"] += 1 + except Exception as e: + print(f" FAIL {name} ({phone}): {e}") + results["failed"] += 1 + + time.sleep(delay) + + print(f"\nBroadcast complete — {CAMPAIGN_NAME}") + print(f" Sent: {results['sent']}") + print(f" Skipped (opt-out): {results['skipped_suppressed']}") + print(f" Failed: {results['failed']}") + + +# --------------------------------------------------------------------------- +# Drip mode: follow up with non-responders after 48 hours +# --------------------------------------------------------------------------- + +def check_drip(hours: int = 48, max_sends: int = 10) -> None: + """ + Find contacts who received an outbound message but haven't replied after + `hours` hours, then send a follow-up. + + max_sends: caps the number of follow-ups per run to avoid overwhelming people. + """ + cutoff = datetime.datetime.utcnow() - datetime.timedelta(hours=hours) + + # Fetch all active conversations for this phone number + convos = commune.sms.conversations(phone_number_id=PHONE_ID, limit=200) + + # A thread with message_count == 1 means we sent but they haven't replied. + # We also check that the last message was sent before the cutoff. + no_reply = [] + for convo in convos: + if convo.message_count != 1: + continue + try: + last_at = datetime.datetime.fromisoformat( + convo.last_message_at.replace("Z", "+00:00") + ).replace(tzinfo=None) + if last_at < cutoff: + no_reply.append(convo) + except Exception as e: + print(f"[warn] Could not parse date for {convo.remote_number}: {e}") + continue + + print(f"Found {len(no_reply)} contacts with no reply after {hours}h") + + sent = 0 + for convo in no_reply[:max_sends]: + follow_up = ( + "Hey! Just wanted to follow up — did you get a chance to see my message? " + "Happy to answer any questions." + ) + try: + commune.sms.send( + to=convo.remote_number, + body=follow_up, + phone_number_id=PHONE_ID, + ) + print(f" Follow-up sent to {convo.remote_number}") + sent += 1 + except Exception as e: + print(f" FAIL {convo.remote_number}: {e}") + time.sleep(1) + + print(f"\nDrip complete. Follow-ups sent: {sent}") + + +# --------------------------------------------------------------------------- +# Status mode: show response rates +# --------------------------------------------------------------------------- + +def show_status() -> None: + """ + Fetch all conversations and compute a response rate. + + A thread with message_count > 1 is counted as "replied" (they responded to us). + """ + convos = commune.sms.conversations(phone_number_id=PHONE_ID, limit=200) + total = len(convos) + + if total == 0: + print("No conversations found for this phone number.") + return + + replied = [c for c in convos if c.message_count > 1] + unread = [c for c in convos if c.unread_count > 0] + rate = 100 * len(replied) // total + + print(f"Campaign status — {CAMPAIGN_NAME}") + print(f" Total conversations: {total}") + print(f" Replied: {len(replied)} ({rate}%)") + print(f" No reply: {total - len(replied)}") + print(f" Unread: {len(unread)}") + + if replied: + print(f"\nMost recent replies:") + for convo in sorted(replied, key=lambda c: c.last_message_at, reverse=True)[:5]: + print(f" {convo.remote_number}: {convo.last_message_preview[:60]}") + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + mode = sys.argv[1] if len(sys.argv) > 1 else "help" + + try: + if mode == "broadcast": + template = ( + sys.argv[2] + if len(sys.argv) > 2 + else "Hi {name}! We thought you'd love what we're working on. Want to learn more?" + ) + broadcast(template) + + elif mode == "drip": + check_drip() + + elif mode == "status": + show_status() + + else: + print("Usage:") + print(' python campaign.py broadcast "Hi {name}, ..." — send to all contacts') + print(" python campaign.py drip — follow up non-responders") + print(" python campaign.py status — show response rates") + sys.exit(1) + + except KeyboardInterrupt: + print("\nCancelled.") + sys.exit(0) diff --git a/sms/sms-marketing/contacts.csv b/sms/sms-marketing/contacts.csv new file mode 100644 index 0000000..a6a0d89 --- /dev/null +++ b/sms/sms-marketing/contacts.csv @@ -0,0 +1,5 @@ +name,phone,company,role,notes +Alex Chen,+15551234567,Acme Corp,CTO,interested in agent automation +Sam Rivera,+15559876543,Startup Inc,Founder,met at AI conference +Jordan Kim,+15552223333,TechCo,Head of Engineering,referred by Alex +Taylor Moss,+15554445555,BuildCo,Product Manager,downloaded whitepaper diff --git a/sms/sms-marketing/requirements.txt b/sms/sms-marketing/requirements.txt new file mode 100644 index 0000000..f3cfa56 --- /dev/null +++ b/sms/sms-marketing/requirements.txt @@ -0,0 +1,3 @@ +commune-mail>=0.3.0 +openai>=1.30.0 +python-dotenv>=1.0.0 diff --git a/sms/two-way-sms/.env.example b/sms/two-way-sms/.env.example new file mode 100644 index 0000000..d8796f0 --- /dev/null +++ b/sms/two-way-sms/.env.example @@ -0,0 +1,12 @@ +# Your Commune API key — get it at https://commune.sh +COMMUNE_API_KEY=comm_your_key_here + +# Your OpenAI API key — get it at https://platform.openai.com +OPENAI_API_KEY=sk-your_openai_key_here + +# The Commune phone number ID to use for sending replies (pn_...) +# Find this in your Commune dashboard under Phone Numbers +PHONE_NUMBER_ID=pn_your_phone_number_id_here + +# Optional: override the agent's persona without changing code +# SYSTEM_PROMPT=You are a friendly customer support agent for Acme Corp. Keep replies brief. diff --git a/sms/two-way-sms/README.md b/sms/two-way-sms/README.md new file mode 100644 index 0000000..9a90e27 --- /dev/null +++ b/sms/two-way-sms/README.md @@ -0,0 +1,93 @@ +# Two-Way SMS Agent — TypeScript + +Receive and reply to SMS messages using an AI agent. The SMS equivalent of the email webhook handler — uses `commune.sms.send()` and `commune.phoneNumbers.setWebhook()`. + +## Architecture + +```mermaid +graph LR + A[User sends SMS] --> B[Commune phone number] + B --> C[Signed webhook POST /sms/webhook] + C --> D[Load conversation history] + D --> E[commune.sms.thread] + E --> F[OpenAI gpt-4o-mini] + F --> G[commune.sms.send reply] + G --> B + B --> H[User receives reply] +``` + +## Setup + +**1. Install dependencies** + +```bash +npm install +``` + +**2. Configure environment** + +```bash +cp .env.example .env +# Fill in COMMUNE_API_KEY and OPENAI_API_KEY +``` + +Get a Commune API key at [commune.sh](https://commune.sh). + +**3. Provision a phone number** + +Go to the Commune dashboard and provision a phone number. The agent picks up the first available number automatically. + +**4. Register the webhook** + +Run this once: + +```typescript +import { CommuneClient } from 'commune-ai'; +const commune = new CommuneClient({ apiKey: process.env.COMMUNE_API_KEY! }); + +const numbers = await commune.phoneNumbers.list(); +await commune.phoneNumbers.setWebhook(numbers[0].id, { + endpoint: 'https://your-app.railway.app/sms/webhook', + events: ['sms.received'], +}); + +console.log(`Phone number: ${numbers[0].number}`); +``` + +**5. Run** + +```bash +npm run dev +``` + +**6. Test** + +Text your Commune phone number. The agent will reply within a few seconds. + +For local development, expose with [ngrok](https://ngrok.com): + +```bash +ngrok http 3000 +# Use the https URL as your webhook endpoint +``` + +## How it works + +### Conversation history + +Each inbound SMS is paired with its full conversation history via `commune.sms.thread(from, phoneNumberId)`. This returns all past messages between your number and the sender, giving the LLM context for multi-turn conversations. + +### Reply format + +The system prompt instructs the LLM to keep replies under 160 characters and avoid markdown. SMS has no formatting support — plain text only. Messages longer than 160 characters are split into multiple segments by carriers; the agent aims to avoid this. + +### No signature verification + +Unlike email webhooks, this example omits HMAC verification for brevity. For production, add HMAC verification using the same `verifyCommuneWebhook` pattern shown in `typescript/webhook-handler/`. + +## Customisation + +- **Change the persona** — edit the `system` message in the `openai.chat.completions.create` call. +- **Add keyword triggers** — check `body.toLowerCase().includes('stop')` to handle opt-outs. +- **Combine with email** — use `commune.messages.send()` alongside the SMS reply to create a cross-channel notification trail. +- **Rate limiting** — add a per-sender rate limit map to prevent runaway conversation loops. diff --git a/sms/two-way-sms/agent.py b/sms/two-way-sms/agent.py new file mode 100644 index 0000000..d8f6d0d --- /dev/null +++ b/sms/two-way-sms/agent.py @@ -0,0 +1,138 @@ +""" +Two-way SMS agent (minimal Flask implementation). + +Receives inbound SMS via Commune webhook, loads full conversation history, +generates a reply via GPT-4o-mini, and sends it back. + +This is the leanest starting point for a custom SMS agent. It deliberately +has no extra features so it's easy to read and modify. + +Usage: + python agent.py + +Environment: + COMMUNE_API_KEY — your Commune API key + OPENAI_API_KEY — your OpenAI API key + PHONE_NUMBER_ID — the Commune phone number ID (pn_...) + SYSTEM_PROMPT — optional: override the default system prompt + +Expose with ngrok for local testing: + ngrok http 8000 + # Set https://.ngrok.io/webhook/sms as your Commune webhook URL +""" + +import os +import sys +from flask import Flask, request, jsonify +from commune import CommuneClient +from openai import OpenAI +from dotenv import load_dotenv + +load_dotenv() + +# Validate required environment variables at startup so failure is obvious. +required_vars = ["COMMUNE_API_KEY", "OPENAI_API_KEY", "PHONE_NUMBER_ID"] +for var in required_vars: + if not os.environ.get(var): + raise SystemExit(f"Missing env var: {var} — copy .env.example to .env and fill it in.") + +commune = CommuneClient(api_key=os.environ["COMMUNE_API_KEY"]) +openai_client = OpenAI(api_key=os.environ["OPENAI_API_KEY"]) +PHONE_NUMBER_ID = os.environ["PHONE_NUMBER_ID"] + +# Allow the system prompt to be overridden via env without changing code. +# This makes it easy to deploy different "personas" from the same codebase. +DEFAULT_SYSTEM_PROMPT = ( + "You are a helpful AI assistant communicating via SMS. " + "Keep replies under 160 characters and use plain text — no markdown or bullet points." +) +SYSTEM_PROMPT = os.environ.get("SYSTEM_PROMPT", DEFAULT_SYSTEM_PROMPT) + +app = Flask(__name__) + + +@app.route("/webhook/sms", methods=["POST"]) +def handle_inbound_sms(): + """ + Process an inbound SMS from Commune and send an AI-generated reply. + + Commune sends a JSON payload with: + from_number — E.164 number of the sender + to_number — our Commune phone number + body — text content of the incoming message + thread_id — stable conversation thread identifier + """ + + # TODO: Add HMAC signature verification here for production deployments. + # Commune sends a 'commune-signature' header. Compare it against your + # webhook secret (COMMUNE_WEBHOOK_SECRET env var) using hmac.compare_digest(). + + data = request.get_json(silent=True) + if not data: + return jsonify({"error": "Invalid JSON payload"}), 400 + + from_number: str = data.get("from_number", "") + body: str = data.get("body", "").strip() + + # Guard against malformed payloads before doing any API calls. + if not from_number: + return jsonify({"error": "Missing from_number in payload"}), 400 + + # Empty body is valid (e.g. MMS image with no caption). Nothing to reply to. + if not body: + return jsonify({"status": "ok", "note": "empty body — no reply sent"}) + + # Fetch conversation history so the LLM has context for multi-turn dialogue. + # An empty history is fine — the agent still works for first-time senders. + try: + history = commune.sms.thread( + remote_number=from_number, + phone_number_id=PHONE_NUMBER_ID, + ) + except Exception as e: + print(f"[warn] Could not fetch thread for {from_number}: {e}") + history = [] + + # Build the OpenAI messages array. Map Commune directions to OpenAI roles: + # inbound → "user" (messages they sent to us) + # outbound → "assistant" (messages we sent to them) + messages = [{"role": "system", "content": SYSTEM_PROMPT}] + for msg in history[-10:]: # window of 10 keeps token usage predictable + role = "user" if msg.direction == "inbound" else "assistant" + content = msg.content or "" + if content: + messages.append({"role": role, "content": content}) + + try: + response = openai_client.chat.completions.create( + model="gpt-4o-mini", + messages=messages, + ) + reply = response.choices[0].message.content or "Sorry, I couldn't generate a reply." + except Exception as e: + print(f"[error] OpenAI call failed: {e}") + reply = "Something went wrong. Please try again in a moment." + + try: + commune.sms.send( + to=from_number, + body=reply, + phone_number_id=PHONE_NUMBER_ID, + ) + except Exception as e: + print(f"[error] Failed to send reply to {from_number}: {e}") + return jsonify({"error": "Failed to send reply"}), 500 + + print(f"[sms] from={from_number} body='{body[:50]}' reply='{reply[:50]}'") + return jsonify({"status": "ok"}) + + +if __name__ == "__main__": + print(f"Two-way SMS agent starting on port 8000") + print(f"Webhook URL: POST /webhook/sms") + print(f"Phone number ID: {PHONE_NUMBER_ID}") + try: + app.run(port=8000, debug=False) + except KeyboardInterrupt: + print("\nShutting down.") + sys.exit(0) diff --git a/sms/two-way-sms/package.json b/sms/two-way-sms/package.json new file mode 100644 index 0000000..20e9804 --- /dev/null +++ b/sms/two-way-sms/package.json @@ -0,0 +1,23 @@ +{ + "name": "commune-two-way-sms", + "version": "1.0.0", + "description": "Two-way SMS agent — receive and reply to SMS messages with AI", + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "commune-ai": "latest", + "dotenv": "^16.0.0", + "express": "^4.18.0", + "openai": "^4.0.0" + }, + "devDependencies": { + "typescript": "^5.0.0", + "@types/node": "^20.0.0", + "@types/express": "^4.17.0", + "tsx": "^4.0.0" + } +} diff --git a/sms/two-way-sms/requirements.txt b/sms/two-way-sms/requirements.txt new file mode 100644 index 0000000..d200388 --- /dev/null +++ b/sms/two-way-sms/requirements.txt @@ -0,0 +1,4 @@ +flask>=3.0.0 +commune-mail>=0.3.0 +openai>=1.30.0 +python-dotenv>=1.0.0 diff --git a/sms/two-way-sms/src/index.ts b/sms/two-way-sms/src/index.ts new file mode 100644 index 0000000..755deec --- /dev/null +++ b/sms/two-way-sms/src/index.ts @@ -0,0 +1,136 @@ +/** + * Two-Way SMS Agent — TypeScript + * + * Receives inbound SMS messages via Commune webhook, generates a reply + * with OpenAI, and sends it back. The SMS equivalent of the email + * webhook handler. + * + * Install: + * npm install + * + * Set up webhook in Commune: + * const numbers = await commune.phoneNumbers.list(); + * await commune.phoneNumbers.setWebhook(numbers[0].id, { + * endpoint: 'https://your-app.railway.app/sms/webhook', + * events: ['sms.received'], + * }); + * + * Usage: + * npm run dev + */ +import express from 'express'; +import { CommuneClient } from 'commune-ai'; +import OpenAI from 'openai'; +import * as dotenv from 'dotenv'; + +dotenv.config(); + +// Validate required environment variables at startup. +// Failing here gives a clear error instead of a confusing API failure later. +const requiredEnv = ['COMMUNE_API_KEY', 'OPENAI_API_KEY']; +for (const varName of requiredEnv) { + if (!process.env[varName]) { + console.error(`Missing env var: ${varName} — copy .env.example to .env and fill it in.`); + process.exit(1); + } +} + +const app = express(); +const port = process.env.PORT || 3000; + +const commune = new CommuneClient({ apiKey: process.env.COMMUNE_API_KEY! }); +const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! }); + +// ── Get phone number ─────────────────────────────────────────────────────── + +const numbers = await commune.phoneNumbers.list(); +if (!numbers.length) { + throw new Error('No phone numbers found. Provision one at commune.email/dashboard.'); +} +const PHONE_NUMBER_ID = numbers[0].id; +const PHONE_NUMBER = numbers[0].number; + +console.log(`SMS agent using phone number: ${PHONE_NUMBER}`); + +// ── Middleware ───────────────────────────────────────────────────────────── + +app.use(express.json()); + +// ── Inbound SMS webhook ──────────────────────────────────────────────────── + +app.post('/sms/webhook', async (req, res) => { + const { event, message } = req.body as InboundSmsWebhookPayload; + + // Only handle inbound SMS messages + if (event !== 'sms.received' || message?.direction !== 'inbound') { + return res.status(200).json({ ok: true }); + } + + const from = message.from; + const body = message.body; + + console.log(`\nInbound SMS from ${from}: "${body}"`); + + // Acknowledge immediately + res.status(200).json({ ok: true }); + + try { + // Load conversation history so the LLM has context + const history = await commune.sms.thread(from, PHONE_NUMBER_ID); + const messages = history.map(m => ({ + role: m.direction === 'inbound' ? 'user' as const : 'assistant' as const, + content: m.body, + })); + + // Generate reply + const completion = await openai.chat.completions.create({ + model: 'gpt-4o-mini', + messages: [ + { + role: 'system', + content: `You are a helpful assistant replying via SMS. Keep replies concise — under 160 characters where possible. No markdown, no bullet points. Plain text only.`, + }, + ...messages, + ], + }); + + const reply = completion.choices[0].message.content!; + + // Send reply + await commune.sms.send({ + to: from, + body: reply, + phone_number_id: PHONE_NUMBER_ID, + }); + + console.log(` Reply sent: "${reply.slice(0, 80)}${reply.length > 80 ? '...' : ''}"`); + + } catch (err) { + console.error('Error handling SMS:', err); + } +}); + +// ── Health check ─────────────────────────────────────────────────────────── + +app.get('/health', (_, res) => res.json({ ok: true })); + +app.listen(port, () => { + console.log(`Two-way SMS agent running on port ${port}`); + console.log(` POST /sms/webhook — receives inbound SMS events`); + console.log(` GET /health — health check`); +}); + +// ── Types ────────────────────────────────────────────────────────────────── + +interface InboundSmsWebhookPayload { + event: string; + message?: { + message_id: string; + direction: 'inbound' | 'outbound'; + from: string; + to: string; + body: string; + phone_number_id: string; + created_at: string; + }; +} diff --git a/typescript/README.md b/typescript/README.md index 8daaaa6..8599ba2 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -76,6 +76,11 @@ async function handleEvent(event: CommuneWebhookEvent): Promise { break; } + case "sms.received": { + const { phone_number_id, from_number, body } = event.data; + // Handle inbound SMS... + break; + } } } @@ -116,12 +121,30 @@ const results = await commune.search.threads({ const context = results.map(r => r.subject + ": " + r.snippet).join("\n"); ``` +## SMS flow + +```typescript +// Provision a real phone number +const phone = await commune.phoneNumbers.provision(); +console.log(`Phone number: ${phone.number}`); + +// Send SMS +await commune.sms.send({ + to: "+14155551234", + body: "Your order has shipped, expected delivery Friday.", + phoneNumberId: phone.id, +}); + +// Inbound SMS arrives via webhook — same handler as email, event.type === "sms.received" +``` + ## Examples in this folder | File | Description | |------|-------------| | `customer_support_agent.ts` | Express webhook + reply flow | | `multi_agent_coordination.ts` | Two agents coordinating via email threads | +| `sms_notifications.ts` | Provision number, send SMS, handle replies | | `webhook_handler.ts` | Reference HMAC verification implementation | ## Tips diff --git a/use-cases/README.md b/use-cases/README.md index e8ebb1c..7b08dfc 100644 --- a/use-cases/README.md +++ b/use-cases/README.md @@ -9,13 +9,15 @@ Real-world production patterns organized by domain. Every example is standalone | Example | Stack | Complexity | |---------|-------|------------| | [AI Email Support Agent](customer-support/email-support-agent/) | LangChain + Commune | Beginner | -| [Omnichannel Support](customer-support/omnichannel-support/) | Python + Commune | Advanced | +| [SMS Support Bot](customer-support/sms-support/) | Python + Commune | Beginner | +| [Omnichannel Support (Email + SMS)](customer-support/omnichannel-support/) | Python + Commune | Advanced | ### Hiring & Recruiting | Example | Stack | Complexity | |---------|-------|------------| | [Candidate Outreach Sequence](hiring-and-recruiting/candidate-email-outreach/) | Python + Commune | Intermediate | +| [SMS Worker Dispatch](hiring-and-recruiting/sms-worker-dispatch/) | Python + Commune | Intermediate | | [Automated Interview Scheduling](hiring-and-recruiting/interview-scheduler/) | LangChain + Commune | Advanced | ### Sales & Marketing @@ -23,6 +25,7 @@ Real-world production patterns organized by domain. Every example is standalone | Example | Stack | Complexity | |---------|-------|------------| | [Cold Email Outreach](sales-and-marketing/cold-outreach-sequences/) | Python + Commune | Intermediate | +| [SMS Lead Qualification](sales-and-marketing/sms-lead-qualification/) | Python + Commune | Intermediate | | [Newsletter Agent](sales-and-marketing/newsletter-agent/) | Python + Commune | Beginner | ### Notifications & Alerts @@ -30,6 +33,7 @@ Real-world production patterns organized by domain. Every example is standalone | Example | Stack | Complexity | |---------|-------|------------| | [Incident Alert System](notifications-and-alerts/incident-alerts/) | Python + Commune | Advanced | +| [Transactional SMS](notifications-and-alerts/order-and-transactional-sms/) | Python + Commune | Beginner | ### Research @@ -44,8 +48,14 @@ Real-world production patterns organized by domain. Every example is standalone - [Candidate Outreach Sequence](hiring-and-recruiting/candidate-email-outreach/) — personalized recruiter sequences - [Cold Email Outreach](sales-and-marketing/cold-outreach-sequences/) — multi-step sales campaigns -- [Omnichannel Support](customer-support/omnichannel-support/) — unified email support agent -- [Incident Alert System](notifications-and-alerts/incident-alerts/) — email escalation with on-call alerts +**SMS only** +- [SMS Worker Dispatch](hiring-and-recruiting/sms-worker-dispatch/) — mass SMS to workers, track YES/NO confirmations +- [SMS Lead Qualification](sales-and-marketing/sms-lead-qualification/) — qualify inbound leads via SMS conversation +- [Transactional SMS](notifications-and-alerts/order-and-transactional-sms/) — order updates via SMS + +**Both Email + SMS** +- [Omnichannel Support](customer-support/omnichannel-support/) — unified email and SMS support agent +- [Incident Alert System](notifications-and-alerts/incident-alerts/) — email escalation with SMS on-call paging ## Quick start (any use case) @@ -66,13 +76,16 @@ print(f"Your agent's address: {inbox.address}") - [research/email-research-agent/](research/email-research-agent/) — agent emails primary sources **Staffing / HR** -- [hiring-and-recruiting/](hiring-and-recruiting/) — candidate outreach, interview scheduling +- [hiring-and-recruiting/](hiring-and-recruiting/) — worker dispatch, candidate outreach, interview scheduling **Sales / Marketing** -- [sales-and-marketing/](sales-and-marketing/) — cold outreach, newsletter agent +- [sales-and-marketing/](sales-and-marketing/) — cold outreach, SMS qualification, newsletter agent + +**E-commerce** +- [notifications-and-alerts/order-and-transactional-sms/](notifications-and-alerts/order-and-transactional-sms/) — order updates via SMS **Operations** -- [notifications-and-alerts/incident-alerts/](notifications-and-alerts/incident-alerts/) — on-call escalation over email +- [notifications-and-alerts/incident-alerts/](notifications-and-alerts/incident-alerts/) — on-call escalation over email and SMS ## Related diff --git a/use-cases/customer-support/README.md b/use-cases/customer-support/README.md index f324069..74a4d18 100644 --- a/use-cases/customer-support/README.md +++ b/use-cases/customer-support/README.md @@ -7,7 +7,8 @@ Three ready-to-run examples for building AI-powered customer support with [Commu | Example | Channel | Stack | Description | |---------|---------|-------|-------------| | [email-support-agent/](./email-support-agent/) | Email | Python + OpenAI | Standalone email support agent with knowledge base, thread-aware replies, and spam filtering | -| [omnichannel-support/](./omnichannel-support/) | Email | Python + OpenAI | Single agent loop handling email from one place | +| [sms-support/](./sms-support/) | SMS | TypeScript + Express | Webhook-driven SMS support bot with full conversation history | +| [omnichannel-support/](./omnichannel-support/) | Email + SMS | Python + OpenAI | Single agent loop handling both email and SMS from one place | --- @@ -15,15 +16,20 @@ Three ready-to-run examples for building AI-powered customer support with [Commu ``` Do you need email support? -├── Yes, single-channel → email-support-agent/ +├── Yes, email only → email-support-agent/ │ -Do you want a unified loop for your inbox? -└── Yes → omnichannel-support/ +Do you need SMS support? +├── Yes, SMS only → sms-support/ +│ +Do you need both? +└── Yes, email + SMS → omnichannel-support/ ``` **email-support-agent** — Best starting point for most teams. Includes a knowledge base, semantic search over past threads, and thread-aware replies. Zero framework dependencies — just Python and Commune. -**omnichannel-support** — A polling loop for your inbox. The same agent, same knowledge base, same LLM handles all your email. Right choice if you want a single entry point for all inbound email. +**sms-support** — Webhook-driven TypeScript handler. Fires on every inbound SMS, loads conversation history, replies in seconds. Deploy to Railway or any Node.js host in minutes. + +**omnichannel-support** — One polling loop, two channels. Customers can contact you by email or SMS — the same agent, same knowledge base, same LLM handles both. Right choice if your customers use a mix of channels. --- @@ -33,3 +39,4 @@ All examples require: - A [Commune](https://commune.sh) account and API key (`comm_...`) - An [OpenAI](https://platform.openai.com) API key (`sk-...`) +- SMS examples also require a Commune phone number (provision in the dashboard) diff --git a/use-cases/customer-support/sms-support/.env.example b/use-cases/customer-support/sms-support/.env.example new file mode 100644 index 0000000..1e9487d --- /dev/null +++ b/use-cases/customer-support/sms-support/.env.example @@ -0,0 +1,8 @@ +# Commune API key — get one at https://commune.sh/dashboard +COMMUNE_API_KEY=comm_... + +# OpenAI API key — get one at https://platform.openai.com/api-keys +OPENAI_API_KEY=sk-... + +# Port to listen on (default: 3000) +PORT=3000 diff --git a/use-cases/customer-support/sms-support/README.md b/use-cases/customer-support/sms-support/README.md new file mode 100644 index 0000000..79727c6 --- /dev/null +++ b/use-cases/customer-support/sms-support/README.md @@ -0,0 +1,94 @@ +# SMS Customer Support Agent + +Give your support team an AI-powered SMS line. Customers text in, the agent replies — with full conversation history and OpenAI. + +## How it works + +```mermaid +flowchart LR + A[Customer texts in] --> B[Commune phone number] + B --> C[Signed webhook POST /sms/webhook] + C --> D[Load conversation history] + D --> E[commune.sms.thread] + E --> F[OpenAI gpt-4o-mini] + F --> G[commune.sms.send reply] + G --> B + B --> H[Customer receives reply] +``` + +Commune routes every inbound SMS to your webhook. The handler loads the full conversation history, builds an OpenAI chat from it, generates a reply, and sends it back — all within a few seconds. + +## Setup + +**1. Install dependencies** + +```bash +npm install +``` + +**2. Configure environment** + +```bash +cp .env.example .env +# Fill in COMMUNE_API_KEY and OPENAI_API_KEY +``` + +Get a Commune API key at [commune.sh](https://commune.sh). + +**3. Provision a phone number** + +Go to the [Commune dashboard](https://commune.sh/dashboard) and provision a phone number. The agent automatically uses the first number on your account. + +**4. Register the webhook** + +Run this once to point your phone number at your server: + +```typescript +import { CommuneClient } from 'commune-ai'; +const commune = new CommuneClient({ apiKey: process.env.COMMUNE_API_KEY! }); + +const numbers = await commune.phoneNumbers.list(); +await commune.phoneNumbers.setWebhook(numbers[0].id, { + endpoint: 'https://your-app.railway.app/sms/webhook', + events: ['sms.received'], +}); + +console.log(`Webhook set for ${numbers[0].number}`); +``` + +For local development, expose your server with [ngrok](https://ngrok.com): + +```bash +ngrok http 3000 +# Use the https:// URL as your webhook endpoint above +``` + +**5. Run** + +```bash +npm run dev +``` + +**6. Test** + +Text your Commune phone number. The agent will reply within a few seconds. + +## Use cases + +- **Support hotline** — customers text questions, the agent answers using your knowledge base +- **Order status** — customers text an order number, the agent looks it up +- **Appointment confirmations** — two-way SMS for scheduling flows +- **After-hours coverage** — the agent handles inbound messages 24/7 while your human team sleeps + +## How conversation history works + +Each inbound SMS triggers a call to `commune.sms.thread(fromNumber, phoneNumberId)`. This returns every message exchanged between your number and the customer — both inbound and outbound — in chronological order. The handler maps this history to OpenAI `user`/`assistant` turns so the LLM has full context for multi-turn conversations. + +## Customization + +| Thing | Where | +|-------|-------| +| Agent persona / instructions | `system` message in `src/index.ts` | +| Character limit | `content` of system message | +| Model | `model: 'gpt-4o-mini'` in the OpenAI call | +| Port | `PORT` env var (default: 3000) | diff --git a/use-cases/customer-support/sms-support/package.json b/use-cases/customer-support/sms-support/package.json new file mode 100644 index 0000000..b5c2e4a --- /dev/null +++ b/use-cases/customer-support/sms-support/package.json @@ -0,0 +1,22 @@ +{ + "name": "commune-sms-support", + "version": "1.0.0", + "description": "AI-powered SMS customer support agent — receives inbound SMS, replies with OpenAI", + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "commune-ai": "latest", + "express": "^4.18.0", + "openai": "^4.0.0" + }, + "devDependencies": { + "typescript": "^5.0.0", + "@types/node": "^20.0.0", + "@types/express": "^4.17.0", + "tsx": "^4.0.0" + } +} diff --git a/use-cases/customer-support/sms-support/src/index.ts b/use-cases/customer-support/sms-support/src/index.ts new file mode 100644 index 0000000..54967f6 --- /dev/null +++ b/use-cases/customer-support/sms-support/src/index.ts @@ -0,0 +1,143 @@ +/** + * SMS Customer Support Agent — TypeScript + * + * Express webhook handler for inbound SMS messages from Commune. + * On each inbound message: + * 1. Parse the URL-encoded Twilio-style payload Commune sends + * 2. Load the full conversation history for this number + * 3. Generate a reply with OpenAI (under 160 chars) + * 4. Send the reply via commune.sms.send() + * + * Commune routes inbound SMS to your webhook as URL-encoded form data + * using Twilio's callback format: { From, To, Body, MessageSid, ... } + * + * Install: npm install + * Run: npm run dev + * + * Register your webhook once (run separately or in a setup script): + * const numbers = await commune.phoneNumbers.list(); + * await commune.phoneNumbers.setWebhook(numbers[0].id, { + * endpoint: 'https://your-app.railway.app/sms/webhook', + * events: ['sms.received'], + * }); + */ + +import express from 'express'; +import { CommuneClient } from 'commune-ai'; +import OpenAI from 'openai'; + +const app = express(); +const port = process.env.PORT || 3000; + +const commune = new CommuneClient({ apiKey: process.env.COMMUNE_API_KEY! }); +const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! }); + +// ── Parse URL-encoded body ────────────────────────────────────────────────── +// +// Commune's SMS webhooks arrive as application/x-www-form-urlencoded, +// matching Twilio's callback format. Use express.urlencoded — NOT express.json. + +app.use(express.urlencoded({ extended: false })); + +// ── Resolve phone number on startup ──────────────────────────────────────── + +const numbers = await commune.phoneNumbers.list(); +if (!numbers.length) { + throw new Error( + 'No phone numbers found on this account. ' + + 'Provision one at commune.sh/dashboard and restart.' + ); +} + +const PHONE_NUMBER_ID = numbers[0].id; +const PHONE_NUMBER = numbers[0].number; + +console.log(`SMS support agent using: ${PHONE_NUMBER}`); +console.log(` POST /sms/webhook — receives inbound SMS`); +console.log(` GET /health — health check\n`); + +// ── Inbound SMS webhook ───────────────────────────────────────────────────── + +app.post('/sms/webhook', async (req, res) => { + // Acknowledge immediately — Commune (and Twilio) expect a fast 200. + // All async work happens after the response is sent. + res.status(200).send(''); + + // Commune delivers Twilio-style URL-encoded fields: + // From — the customer's phone number e.g. "+14155552671" + // To — your Commune phone number + // Body — the message text + // MessageSid — unique message ID + const fromNumber: string = req.body.From; + const messageBody: string = req.body.Body; + const messageSid: string = req.body.MessageSid; + + // Bail if the payload is missing required fields (e.g. a test ping) + if (!fromNumber || !messageBody) { + console.log('Webhook received without From/Body — ignoring.'); + return; + } + + console.log(`\nInbound SMS [${messageSid}]`); + console.log(` From: ${fromNumber}`); + console.log(` Body: "${messageBody}"`); + + try { + // Load the full conversation history between this customer and our number. + // commune.sms.thread() returns messages in chronological order. + const history = await commune.sms.thread(fromNumber, PHONE_NUMBER_ID); + + // Map to OpenAI chat turns: + // inbound → role: "user" (customer sent this) + // outbound → role: "assistant" (we sent this) + const chatHistory = history.map(m => ({ + role: m.direction === 'inbound' ? 'user' as const : 'assistant' as const, + content: m.body, + })); + + // Generate a reply. + // System prompt instructs the model to be concise — SMS has a 160-char limit + // and carriers split longer messages into multiple segments. + const completion = await openai.chat.completions.create({ + model: 'gpt-4o-mini', + messages: [ + { + role: 'system', + content: + 'You are a helpful SMS support agent for Acme SaaS. ' + + 'Keep every reply under 160 characters. ' + + 'Be direct and concise — no markdown, no bullet points, plain text only. ' + + 'If you cannot answer, say "Please email support@acme.io for help."', + }, + ...chatHistory, + ], + }); + + const reply = completion.choices[0].message.content!.trim(); + + // Send the reply back to the customer on the same phone number + await commune.sms.send({ + to: fromNumber, + body: reply, + phone_number_id: PHONE_NUMBER_ID, + }); + + console.log(` Reply sent (${reply.length} chars): "${reply.slice(0, 100)}${reply.length > 100 ? '...' : ''}"`); + + } catch (err) { + console.error('Error handling inbound SMS:', err); + // Do not re-throw — we already sent 200. Log and move on. + } +}); + +// ── Health check ──────────────────────────────────────────────────────────── + +app.get('/health', (_, res) => { + res.json({ ok: true, phoneNumber: PHONE_NUMBER }); +}); + +// ── Start server ──────────────────────────────────────────────────────────── + +app.listen(port, () => { + console.log(`Server listening on port ${port}`); +}); diff --git a/use-cases/hiring-and-recruiting/README.md b/use-cases/hiring-and-recruiting/README.md index c977796..d10762a 100644 --- a/use-cases/hiring-and-recruiting/README.md +++ b/use-cases/hiring-and-recruiting/README.md @@ -6,6 +6,7 @@ Three ready-to-run examples for building AI-powered hiring and recruiting workfl | Example | Channel | Stack | Description | |---------|---------|-------|-------------| +| [sms-worker-dispatch/](./sms-worker-dispatch/) | SMS | Python + Flask + OpenAI | AI staffing agent dispatches jobs to a worker pool via SMS, collects YES/NO replies via webhook, tracks status, and emails manager summaries | | [candidate-email-outreach/](./candidate-email-outreach/) | Email | Python + OpenAI | AI recruiter reads a candidate list, sends personalized outreach emails, monitors for replies, and continues the conversation in-thread | | [interview-scheduler/](./interview-scheduler/) | Email | Python + OpenAI | Agent monitors an inbox for interview requests, proposes available time slots, and sends confirmation emails — all within a single email thread | @@ -14,6 +15,10 @@ Three ready-to-run examples for building AI-powered hiring and recruiting workfl ## Which should I use? ``` +Are you coordinating shift workers, gig workers, or field staff? +└── Yes → sms-worker-dispatch/ + SMS reaches workers instantly, no app required. Webhook captures replies. + Are you doing candidate sourcing or outreach at scale? └── Yes → candidate-email-outreach/ Reads a CSV, personalizes every email with LLM, tracks replies per thread. @@ -23,6 +28,8 @@ Do you need to automate the back-and-forth of interview scheduling? Monitors inbox, proposes time slots, confirms bookings in the same thread. ``` +**sms-worker-dispatch** — The most complete example. An end-to-end staffing platform in ~200 lines: mass SMS dispatch, inbound webhook processing, OpenAI classification, auto-reply, manager email notifications, and a `/summary` endpoint. Start here if you work with hourly, gig, or shift workers. + **candidate-email-outreach** — Best for teams doing sourcing at volume. A polling agent that reads a CSV, writes a personalized email per candidate with OpenAI, tracks every reply by `thread_id`, and continues the conversation automatically. No separate database needed — Commune's threading keeps everything organized. **interview-scheduler** — A focused, single-purpose agent. Monitors an inbox for scheduling requests, parses availability from the email, proposes slots, and sends calendar-ready confirmations. All replies stay in the same email thread with a single `thread_id`. @@ -35,3 +42,4 @@ All examples require: - A [Commune](https://commune.sh) account and API key (`comm_...`) - An [OpenAI](https://platform.openai.com) API key (`sk-...`) +- `sms-worker-dispatch` also requires a Commune phone number (provision in the dashboard) diff --git a/use-cases/hiring-and-recruiting/sms-worker-dispatch/.env.example b/use-cases/hiring-and-recruiting/sms-worker-dispatch/.env.example new file mode 100644 index 0000000..6d6d6fe --- /dev/null +++ b/use-cases/hiring-and-recruiting/sms-worker-dispatch/.env.example @@ -0,0 +1,14 @@ +# Commune API key — get one at commune.sh +COMMUNE_API_KEY=comm_your_key_here + +# OpenAI API key — used to personalize SMS and classify replies +OPENAI_API_KEY=sk-your_key_here + +# Manager who receives "job filled" notifications and daily summaries +MANAGER_EMAIL=manager@company.com + +# How many confirmed YES replies are needed before notifying the manager +SLOTS_REQUIRED=3 + +# Port for webhook_handler.py +PORT=3000 diff --git a/use-cases/hiring-and-recruiting/sms-worker-dispatch/README.md b/use-cases/hiring-and-recruiting/sms-worker-dispatch/README.md new file mode 100644 index 0000000..cc2be27 --- /dev/null +++ b/use-cases/hiring-and-recruiting/sms-worker-dispatch/README.md @@ -0,0 +1,188 @@ +# AI Worker Dispatch via SMS — Staffing & Gig Platform + +AI agent dispatches jobs to workers via SMS, collects availability responses via webhook, tracks status, and emails managers daily summaries. + +Built on [Commune](https://commune.sh) for SMS send/receive and email. No separate messaging infrastructure required. + +--- + +## How it works + +```mermaid +flowchart TD + A[Job opens up] --> B[dispatcher.py] + B --> C[Load available workers from workers.json] + C --> D[commune.phone_numbers.list — get sending number] + D --> E[OpenAI personalizes SMS per worker] + E --> F[commune.sms.send to each worker] + F --> G[Worker receives: 'Hi Maria, warehouse shift Thu 9am-5pm SF. Reply YES or NO'] + + G --> H[Worker replies YES or NO] + H --> I[Commune fires SMS webhook] + I --> J[POST /sms webhook_handler.py] + + J --> K[Parse From and Body from request] + K --> L[Match phone to worker in job_status.json] + L --> M[OpenAI classifies reply: YES / NO / MAYBE / OTHER] + + M -->|YES| N[Update status: confirmed] + M -->|NO| O[Update status: declined] + M -->|MAYBE / OTHER| P[Update status: unclear — ask to clarify] + + N --> Q[commune.sms.send confirmation to worker] + O --> Q + P --> Q + + N --> R{All slots filled?} + R -->|Yes| S[commune.messages.send — notify manager by email] + R -->|No| T[Continue waiting for replies] + + subgraph Daily Summary + U[POST /summary endpoint] --> V[Aggregate all worker responses] + V --> W[OpenAI writes summary paragraph] + W --> X[commune.messages.send summary to manager] + end +``` + +--- + +## Files + +``` +sms-worker-dispatch/ +├── dispatcher.py # Sends job dispatch SMS to all available workers +├── webhook_handler.py # Flask server: receives replies, updates status, auto-replies, alerts manager +├── workers.json # Worker pool with name, phone, skills, status +├── requirements.txt +└── .env.example +``` + +`job_status.json` is created automatically by `dispatcher.py` when a job is dispatched. It tracks every worker's response and is read/updated by `webhook_handler.py`. + +--- + +## Setup + +**1. Install dependencies** + +```bash +pip install -r requirements.txt +``` + +**2. Configure environment** + +```bash +cp .env.example .env +# Fill in COMMUNE_API_KEY, OPENAI_API_KEY, MANAGER_EMAIL, MANAGER_PHONE +``` + +Get a Commune API key at [commune.sh](https://commune.sh). Provision a phone number in the Commune dashboard — this is the number your workers will receive SMS from (and reply to). + +**3. Load your worker data** + +Edit `workers.json` with real worker names and phone numbers, or leave the sample data in place for testing. + +**4. Dispatch a job** + +```bash +python dispatcher.py \ + --job "Warehouse Packer" \ + --date "Thursday Jan 16, 9am-5pm" \ + --location "SF Warehouse, 123 Main St" +``` + +This sends a personalized SMS to every worker with `"status": "available"` and writes `job_status.json`. + +**5. Start the webhook handler** + +```bash +python webhook_handler.py +``` + +Expose it publicly so Commune can deliver inbound SMS events. For local development: + +```bash +# Using ngrok +ngrok http 3000 +# Copy the https URL, e.g. https://abc123.ngrok.io +``` + +**6. Register the webhook in Commune** + +Point Commune's SMS webhook at your handler. Do this once in code or from the dashboard: + +```python +from commune import CommuneClient +commune = CommuneClient(api_key="comm_...") +numbers = commune.phone_numbers.list() +commune.phone_numbers.set_webhook( + numbers[0].id, + endpoint="https://abc123.ngrok.io/sms", + events=["sms.received"], +) +``` + +Workers can now reply and their responses will be captured automatically. + +**7. Trigger a summary (optional)** + +```bash +curl -X POST http://localhost:3000/summary +``` + +Sends an email to `MANAGER_EMAIL` with a breakdown of all worker responses. + +--- + +## Step-by-step walkthrough + +### 1. Dispatching a job + +`dispatcher.py` runs as a one-shot script. When you call it with a job, date, and location it: + +1. Loads all workers from `workers.json` and filters to `"status": "available"` +2. Calls `commune.phone_numbers.list()` to get your sending number +3. For each worker, asks OpenAI to write a short personalized SMS (under 160 chars) — friendly, first name, job details, "reply YES or NO" +4. Calls `commune.sms.send()` for each worker with a 0.5 second delay between sends +5. Writes `job_status.json` with the job details and every worker who was messaged + +### 2. Receiving replies + +When a worker replies, Commune fires a `POST /sms` to your webhook handler. The payload is URL-encoded (Twilio-style): + +``` +From=+14155550101&To=+14155559000&Body=YES&MessageSid=SM... +``` + +`webhook_handler.py`: + +1. Acknowledges the request immediately with `200 OK` (before doing any processing) +2. Looks up the worker by their phone number in `job_status.json` +3. Sends the reply text to OpenAI to classify as `YES`, `NO`, `MAYBE`, or `OTHER` +4. Updates that worker's entry in `job_status.json` +5. Sends a confirmation SMS back to the worker +6. If the reply was YES and all required slots are now filled, emails the manager via `commune.messages.send()` + +### 3. Daily summary + +`POST /summary` can be called by a cron job or manually. It: + +1. Reads the current `job_status.json` +2. Asks OpenAI to write a clean summary paragraph +3. Sends it to the manager's email address via Commune + +--- + +## Customisation + +**Worker data format** — `workers.json` supports any extra fields you need. Add `hourly_rate`, `certifications`, `preferred_locations`, etc. and reference them in the `personalize_sms()` prompt to make outreach more targeted. + +**Slots required** — Set `SLOTS_REQUIRED` in `.env` to control how many YES responses trigger the "job filled" manager notification. + +**Multiple job types** — Run `dispatcher.py` multiple times with different `--job` arguments. Each run overwrites `job_status.json`, so run one job at a time, or change `STATUS_FILE` to include the job name. + +**Manager email** — Set `MANAGER_EMAIL` in `.env`. The handler and summary both read from this variable. Add multiple recipients by comma-separating in the env var and splitting in code. + +**Webhook security** — Add a `COMMUNE_WEBHOOK_SECRET` to your `.env` and validate inbound requests using `verifyCommuneWebhook` (TypeScript) or check the `X-Commune-Signature` header (Python) before processing. + +**Scaling** — For large worker pools (100+), replace `workers.json` with a database query and consider running `dispatcher.py` in async batches. The webhook handler is stateless and can run on multiple instances behind a load balancer. diff --git a/use-cases/hiring-and-recruiting/sms-worker-dispatch/dispatcher.py b/use-cases/hiring-and-recruiting/sms-worker-dispatch/dispatcher.py new file mode 100644 index 0000000..33db9e8 --- /dev/null +++ b/use-cases/hiring-and-recruiting/sms-worker-dispatch/dispatcher.py @@ -0,0 +1,170 @@ +""" +Worker Dispatch Agent — powered by Commune + +Sends a personalized job dispatch SMS to every available worker in workers.json. +Each message is written by OpenAI to feel human — not a template blast. + +After sending, writes job_status.json so webhook_handler.py can match +incoming replies to the right worker. + +Usage: + python dispatcher.py \\ + --job "Warehouse Packer" \\ + --date "Thursday Jan 16, 9am-5pm" \\ + --location "SF Warehouse, 123 Main St" + +Environment: + COMMUNE_API_KEY — your Commune API key (comm_...) + OPENAI_API_KEY — your OpenAI API key (sk-...) +""" +import argparse +import json +import os +import time + +from dotenv import load_dotenv +from commune import CommuneClient +from openai import OpenAI + +load_dotenv() + +# Validate required environment variables at startup +_REQUIRED_ENV = ["COMMUNE_API_KEY", "OPENAI_API_KEY"] +for _var in _REQUIRED_ENV: + if not os.getenv(_var): + raise SystemExit(f"Missing required environment variable: {_var}\n" + f"Copy .env.example to .env and fill in your values.") + +# ── Clients ──────────────────────────────────────────────────────────────────── + +commune = CommuneClient(api_key=os.environ["COMMUNE_API_KEY"]) +openai_client = OpenAI(api_key=os.environ["OPENAI_API_KEY"]) + +# ── File paths ───────────────────────────────────────────────────────────────── + +WORKERS_FILE = os.path.join(os.path.dirname(__file__), "workers.json") +STATUS_FILE = os.path.join(os.path.dirname(__file__), "job_status.json") + +# ── Helpers ──────────────────────────────────────────────────────────────────── + +def load_workers() -> list[dict]: + """Load worker pool from workers.json.""" + with open(WORKERS_FILE) as f: + return json.load(f) + + +def get_phone_number() -> tuple[str, str]: + """ + Return (phone_number_id, phone_number) for the first provisioned number. + Provision a number in the Commune dashboard if this raises. + """ + numbers = commune.phone_numbers.list() + if not numbers: + raise ValueError( + "No phone numbers found. Provision one at commune.sh/dashboard." + ) + return numbers[0].id, numbers[0].number + + +def personalize_sms(worker: dict, job: str, date: str, location: str) -> str: + """ + Ask OpenAI to write a short, friendly, personalized job offer SMS. + The prompt constrains output to 160 chars so it fits in a single SMS segment. + """ + first_name = worker["name"].split()[0] + skills_note = "" + if worker.get("skills"): + skills_note = f"Worker skills: {', '.join(worker['skills'])}. " + + response = openai_client.chat.completions.create( + model="gpt-4o-mini", + messages=[ + { + "role": "user", + "content": ( + f"Write a short SMS (max 160 chars) to a gig worker offering a job shift.\n\n" + f"Worker first name: {first_name}\n" + f"Job title: {job}\n" + f"Date/time: {date}\n" + f"Location: {location}\n" + f"{skills_note}\n" + f"Requirements:\n" + f"- Conversational, friendly tone\n" + f"- Use their first name\n" + f"- Ask them to reply YES or NO\n" + f"- Under 160 characters\n" + f"- No emojis, no markdown" + ), + } + ], + ) + return response.choices[0].message.content.strip() + +# ── Main dispatch ────────────────────────────────────────────────────────────── + +def dispatch_job(job: str, date: str, location: str) -> None: + phone_id, phone_number = get_phone_number() + workers = load_workers() + available = [w for w in workers if w.get("status") == "available"] + + if not available: + print("No available workers found in workers.json.") + return + + print(f"\nDispatching: {job}") + print(f" Date: {date}") + print(f" Location: {location}") + print(f" Sending to {len(available)} available workers...\n") + + job_status = { + "job": job, + "date": date, + "location": location, + "slots_required": int(os.environ.get("SLOTS_REQUIRED", "3")), + "dispatched": [], + "responses": {}, + } + + for worker in available: + sms_text = personalize_sms(worker, job, date, location) + + result = commune.sms.send( + to=worker["phone"], + body=sms_text, + phone_number_id=phone_id, + ) + + print(f" SMS -> {worker['name']} ({worker['phone']})") + print(f" \"{sms_text}\"") + + job_status["dispatched"].append({ + "name": worker["name"], + "phone": worker["phone"], + "skills": worker.get("skills", []), + "message_id": getattr(result, "message_id", None), + }) + + time.sleep(0.5) # brief delay to stay well within rate limits + + with open(STATUS_FILE, "w") as f: + json.dump(job_status, f, indent=2) + + print(f"\nDispatched to {len(available)} workers.") + print(f"Status saved to job_status.json") + print(f"\nNext step: start webhook_handler.py to capture replies.") + print(f" python webhook_handler.py") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Dispatch a job to available workers via SMS." + ) + parser.add_argument("--job", required=True, help="Job title, e.g. 'Warehouse Packer'") + parser.add_argument( + "--date", required=True, help="Date and time, e.g. 'Thursday Jan 16, 9am-5pm'" + ) + parser.add_argument( + "--location", required=True, help="Location, e.g. 'SF Warehouse, 123 Main St'" + ) + args = parser.parse_args() + dispatch_job(args.job, args.date, args.location) diff --git a/use-cases/hiring-and-recruiting/sms-worker-dispatch/requirements.txt b/use-cases/hiring-and-recruiting/sms-worker-dispatch/requirements.txt new file mode 100644 index 0000000..92ad653 --- /dev/null +++ b/use-cases/hiring-and-recruiting/sms-worker-dispatch/requirements.txt @@ -0,0 +1,4 @@ +commune-mail>=0.3.0 +openai>=1.30.0 +flask>=3.0.0 +python-dotenv>=1.0.0 diff --git a/use-cases/hiring-and-recruiting/sms-worker-dispatch/webhook_handler.py b/use-cases/hiring-and-recruiting/sms-worker-dispatch/webhook_handler.py new file mode 100644 index 0000000..5956cbd --- /dev/null +++ b/use-cases/hiring-and-recruiting/sms-worker-dispatch/webhook_handler.py @@ -0,0 +1,314 @@ +""" +SMS Webhook Handler — powered by Commune + Flask + +Receives inbound SMS replies from workers, classifies YES/NO intent with OpenAI, +updates job_status.json, sends confirmation SMS to the worker, and notifies the +manager by email when all slots are filled or when a summary is requested. + +Endpoints: + POST /sms — Commune SMS webhook (URL-encoded Twilio-style payload) + POST /summary — Trigger a manager summary email immediately + GET /health — Liveness check + +Usage: + python webhook_handler.py + +Expose publicly before registering with Commune: + ngrok http 3000 +""" +import json +import os +from datetime import datetime + +from commune import CommuneClient +from flask import Flask, jsonify, request +from openai import OpenAI + +# ── Clients ──────────────────────────────────────────────────────────────────── + +commune = CommuneClient(api_key=os.environ["COMMUNE_API_KEY"]) +openai_client = OpenAI(api_key=os.environ["OPENAI_API_KEY"]) + +MANAGER_EMAIL = os.environ["MANAGER_EMAIL"] +PORT = int(os.environ.get("PORT", 3000)) +STATUS_FILE = os.path.join(os.path.dirname(__file__), "job_status.json") + +# ── Flask app ────────────────────────────────────────────────────────────────── + +app = Flask(__name__) + +# ── ANSI colour helpers (terminal logging) ───────────────────────────────────── + +GREEN = "\033[92m" +YELLOW = "\033[93m" +RED = "\033[91m" +CYAN = "\033[96m" +BOLD = "\033[1m" +RESET = "\033[0m" + + +def log(colour: str, label: str, message: str) -> None: + ts = datetime.now().strftime("%H:%M:%S") + print(f"{colour}{BOLD}[{ts}] {label}{RESET} {message}") + +# ── Phone number setup ───────────────────────────────────────────────────────── + +def get_phone_number_id() -> str: + """Return the ID of the first provisioned Commune phone number.""" + numbers = commune.phone_numbers.list() + if not numbers: + raise ValueError("No phone numbers found. Provision one at commune.sh/dashboard.") + return numbers[0].id + + +PHONE_NUMBER_ID = get_phone_number_id() + +# ── Status file helpers ──────────────────────────────────────────────────────── + +def load_status() -> dict: + if not os.path.exists(STATUS_FILE): + return {} + with open(STATUS_FILE) as f: + return json.load(f) + + +def save_status(data: dict) -> None: + with open(STATUS_FILE, "w") as f: + json.dump(data, f, indent=2) + +# ── Reply classification ─────────────────────────────────────────────────────── + +def classify_reply(body: str) -> str: + """ + Use OpenAI to classify a worker's SMS reply. + Returns one of: YES, NO, MAYBE, OTHER + """ + response = openai_client.chat.completions.create( + model="gpt-4o-mini", + response_format={"type": "json_object"}, + messages=[ + { + "role": "user", + "content": ( + f"A gig worker replied to a job offer SMS. Classify their reply.\n\n" + f"Reply: \"{body}\"\n\n" + f"Return JSON with a single key 'intent'. Value must be one of:\n" + f" YES — confirmed, accepting, available\n" + f" NO — declining, not available, can't make it\n" + f" MAYBE — uncertain, might work, need more info\n" + f" OTHER — question, unrelated, unclear\n\n" + f"Example: {{\"intent\": \"YES\"}}" + ), + } + ], + ) + result = json.loads(response.choices[0].message.content) + return result.get("intent", "OTHER").upper() + + +# ── Confirmation SMS ─────────────────────────────────────────────────────────── + +CONFIRMATION_MESSAGES = { + "YES": "Got it — you're confirmed! We'll send shift details shortly. Thanks.", + "NO": "No problem, thanks for letting us know. We'll reach out for future shifts.", + "MAYBE": "Thanks for your reply. Could you let us know for sure — YES or NO? We need to confirm the shift.", + "OTHER": "Thanks for your message. If you meant to confirm the shift, reply YES. To decline, reply NO.", +} + + +def send_confirmation_sms(to: str, intent: str) -> None: + """Send an appropriate confirmation SMS back to the worker.""" + body = CONFIRMATION_MESSAGES.get(intent, CONFIRMATION_MESSAGES["OTHER"]) + commune.sms.send(to=to, body=body, phone_number_id=PHONE_NUMBER_ID) + log(GREEN, "SMS OUT", f"Confirmation -> {to}: \"{body}\"") + + +# ── Manager notification ─────────────────────────────────────────────────────── + +def get_or_create_inbox() -> str: + """Get or create a 'dispatch' inbox for outbound manager emails.""" + for ib in commune.inboxes.list(): + if ib.local_part == "dispatch": + return ib.id + ib = commune.inboxes.create(local_part="dispatch") + return ib.id + + +def notify_manager_job_filled(status: dict) -> None: + """Email the manager when all required slots have been confirmed.""" + inbox_id = get_or_create_inbox() + + confirmed = [ + name for name, resp in status.get("responses", {}).items() + if resp.get("intent") == "YES" + ] + + body = ( + f"Job filled: {status['job']}\n\n" + f"Date: {status['date']}\n" + f"Location: {status['location']}\n\n" + f"Confirmed workers ({len(confirmed)}):\n" + + "\n".join(f" - {name}" for name in confirmed) + + "\n\nAll required slots are now filled." + ) + + commune.messages.send( + to=MANAGER_EMAIL, + subject=f"Job filled: {status['job']} — {status['date']}", + text=body, + inbox_id=inbox_id, + ) + log(GREEN, "EMAIL", f"Manager notified: job filled -> {MANAGER_EMAIL}") + + +def send_summary_email(status: dict) -> None: + """Generate an AI-written summary of current worker responses and email the manager.""" + if not status: + log(YELLOW, "SUMMARY", "No job_status.json found — nothing to summarise.") + return + + inbox_id = get_or_create_inbox() + + # Build a plain-text breakdown for OpenAI to summarise + lines = [f"Job: {status.get('job', 'Unknown')}"] + lines.append(f"Date: {status.get('date', '')}") + lines.append(f"Location: {status.get('location', '')}") + lines.append(f"Workers messaged: {len(status.get('dispatched', []))}") + lines.append("") + + responses = status.get("responses", {}) + for name, resp in responses.items(): + intent = resp.get("intent", "PENDING") + raw = resp.get("raw_reply", "") + lines.append(f"{name}: {intent} (\"{raw}\")") + + # Workers who were dispatched but haven't replied yet + dispatched_names = {w["name"] for w in status.get("dispatched", [])} + replied_names = set(responses.keys()) + pending = dispatched_names - replied_names + for name in pending: + lines.append(f"{name}: NO REPLY") + + breakdown = "\n".join(lines) + + ai_response = openai_client.chat.completions.create( + model="gpt-4o-mini", + messages=[ + { + "role": "user", + "content": ( + f"Write a concise, professional summary email body for a hiring manager. " + f"Based on this worker response data:\n\n{breakdown}\n\n" + f"Include: number confirmed, number declined, number pending. " + f"One short paragraph. Plain text, no markdown." + ), + } + ], + ) + summary_text = ai_response.choices[0].message.content.strip() + + full_body = f"{summary_text}\n\n---\n\nFull breakdown:\n{breakdown}" + + commune.messages.send( + to=MANAGER_EMAIL, + subject=f"Worker dispatch summary: {status.get('job', 'Job')} — {status.get('date', '')}", + text=full_body, + inbox_id=inbox_id, + ) + log(GREEN, "EMAIL", f"Summary sent to {MANAGER_EMAIL}") + +# ── Routes ───────────────────────────────────────────────────────────────────── + +@app.route("/sms", methods=["POST"]) +def inbound_sms(): + """ + Commune SMS webhook endpoint. + + Payload is URL-encoded (Twilio-compatible): + From=+14155550101&To=+14155559000&Body=YES&MessageSid=SM... + """ + # Acknowledge immediately — Commune expects a fast 200 before we do work + from_number = request.form.get("From", "").strip() + body = request.form.get("Body", "").strip() + + if not from_number or not body: + return jsonify({"ok": True}) + + log(CYAN, "SMS IN", f"{from_number}: \"{body}\"") + + status = load_status() + if not status: + log(YELLOW, "WARN", "No job_status.json found. Run dispatcher.py first.") + return jsonify({"ok": True}) + + # Find which dispatched worker this reply came from + dispatched = status.get("dispatched", []) + worker = next((w for w in dispatched if w["phone"] == from_number), None) + + if not worker: + log(YELLOW, "SKIP", f"Unknown number {from_number} — not in dispatched list.") + return jsonify({"ok": True}) + + worker_name = worker["name"] + + # Classify the reply intent + intent = classify_reply(body) + log( + GREEN if intent == "YES" else RED if intent == "NO" else YELLOW, + "CLASSIFY", + f"{worker_name}: intent={intent} (raw: \"{body}\")", + ) + + # Update status + status.setdefault("responses", {})[worker_name] = { + "phone": from_number, + "raw_reply": body, + "intent": intent, + "replied_at": datetime.utcnow().isoformat() + "Z", + } + save_status(status) + + # Send confirmation SMS back to worker + send_confirmation_sms(from_number, intent) + + # Check if all required slots are now filled + slots_required = status.get("slots_required", 3) + confirmed_count = sum( + 1 for r in status["responses"].values() if r.get("intent") == "YES" + ) + + if confirmed_count >= slots_required: + log(GREEN, "FILLED", f"{confirmed_count}/{slots_required} slots confirmed — notifying manager.") + notify_manager_job_filled(status) + + return jsonify({"ok": True}) + + +@app.route("/summary", methods=["POST"]) +def trigger_summary(): + """ + Manually trigger a manager summary email. + Can be called by a cron job or manually during / after the dispatch window. + + Example: + curl -X POST http://localhost:3000/summary + """ + status = load_status() + send_summary_email(status) + return jsonify({"ok": True, "message": f"Summary emailed to {MANAGER_EMAIL}"}) + + +@app.route("/health", methods=["GET"]) +def health(): + return jsonify({"ok": True}) + +# ── Entry point ──────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + print(f"\n{BOLD}Worker dispatch webhook handler{RESET}") + print(f" POST /sms — inbound worker replies") + print(f" POST /summary — email manager summary") + print(f" GET /health — liveness check") + print(f"\n Manager email: {MANAGER_EMAIL}") + print(f" Listening on port {PORT}\n") + app.run(host="0.0.0.0", port=PORT) diff --git a/use-cases/hiring-and-recruiting/sms-worker-dispatch/workers.json b/use-cases/hiring-and-recruiting/sms-worker-dispatch/workers.json new file mode 100644 index 0000000..f1898a0 --- /dev/null +++ b/use-cases/hiring-and-recruiting/sms-worker-dispatch/workers.json @@ -0,0 +1,66 @@ +[ + { + "name": "Maria Santos", + "phone": "+14155550101", + "status": "available", + "skills": ["warehouse", "forklift", "inventory"], + "rating": 4.8, + "shifts_completed": 42 + }, + { + "name": "James Wilson", + "phone": "+14155550102", + "status": "available", + "skills": ["delivery", "warehouse", "heavy lifting"], + "rating": 4.5, + "shifts_completed": 29 + }, + { + "name": "Priya Nair", + "phone": "+14155550103", + "status": "available", + "skills": ["event staffing", "hospitality", "cashier"], + "rating": 4.9, + "shifts_completed": 67 + }, + { + "name": "DeShawn Carter", + "phone": "+14155550104", + "status": "available", + "skills": ["warehouse", "forklift", "logistics"], + "rating": 4.7, + "shifts_completed": 55 + }, + { + "name": "Lena Fischer", + "phone": "+14155550105", + "status": "unavailable", + "skills": ["event staffing", "hospitality"], + "rating": 4.6, + "shifts_completed": 18 + }, + { + "name": "Kwame Asante", + "phone": "+14155550106", + "status": "available", + "skills": ["delivery", "driving", "warehouse"], + "rating": 4.4, + "shifts_completed": 33 + }, + { + "name": "Sofia Reyes", + "phone": "+14155550107", + "status": "available", + "skills": ["cashier", "inventory", "customer service"], + "rating": 4.8, + "shifts_completed": 51 + }, + { + "name": "Tyler Nguyen", + "phone": "+14155550108", + "status": "available", + "skills": ["warehouse", "heavy lifting", "forklift"], + "rating": 4.3, + "shifts_completed": 22 + } +] diff --git a/use-cases/notifications-and-alerts/README.md b/use-cases/notifications-and-alerts/README.md index 497edec..aaad22e 100644 --- a/use-cases/notifications-and-alerts/README.md +++ b/use-cases/notifications-and-alerts/README.md @@ -8,7 +8,8 @@ Two ready-to-run examples for building AI-powered notification systems with [Com | Example | Channels | Stack | Description | |---------|----------|-------|-------------| -| [incident-alerts/](./incident-alerts/) | Email | Python + TypeScript | AI-assessed incident alerts to on-call engineers with 10-minute escalation and acknowledgment via email reply | +| [incident-alerts/](./incident-alerts/) | Email + SMS | Python + TypeScript | AI-assessed incident alerts to on-call engineers with 10-minute escalation and acknowledgment via email reply | +| [order-and-transactional-sms/](./order-and-transactional-sms/) | SMS | Python | Pattern library for AI-personalized transactional SMS — confirmations, shipping updates, delays | --- @@ -17,15 +18,26 @@ Two ready-to-run examples for building AI-powered notification systems with [Com ``` Are you alerting engineers about system incidents? └── Yes → incident-alerts/ - Sends email with full context and stack traces. + Sends SMS for immediate attention + email with full context. Escalates to secondary on-call if no response in 10min. + +Are you notifying customers about order status? +└── Yes → order-and-transactional-sms/ + Drop-in functions for confirmation, shipping, delivery, and delay SMS. + Works with any order management system. ``` --- -## Why email for alerts? +## Why email + SMS together? + +Different situations call for different channels: + +**SMS** is an interrupt. People see it instantly, even away from a computer. Ideal for urgent alerts and time-sensitive order updates. + +**Email** carries context. Stack traces, runbook links, full order details, reply threads — SMS can't hold all of that. Email is where the engineer (or customer) reads the full picture. -Email carries context. Stack traces, runbook links, full details, reply threads — all of that lives in the thread. When an engineer (or customer) needs to take action, they read the full picture in their inbox and reply directly in thread. +Commune gives you both channels from a single API, so your agent can use whichever is right for the moment — or both at once. --- diff --git a/use-cases/notifications-and-alerts/order-and-transactional-sms/.env.example b/use-cases/notifications-and-alerts/order-and-transactional-sms/.env.example new file mode 100644 index 0000000..f891933 --- /dev/null +++ b/use-cases/notifications-and-alerts/order-and-transactional-sms/.env.example @@ -0,0 +1,3 @@ +COMMUNE_API_KEY=comm_... +COMMUNE_PHONE_NUMBER_ID=phone_... +OPENAI_API_KEY=sk-... diff --git a/use-cases/notifications-and-alerts/order-and-transactional-sms/README.md b/use-cases/notifications-and-alerts/order-and-transactional-sms/README.md new file mode 100644 index 0000000..4e44ac7 --- /dev/null +++ b/use-cases/notifications-and-alerts/order-and-transactional-sms/README.md @@ -0,0 +1,129 @@ +# Transactional SMS — Order Updates & Notifications + +Send order confirmations, shipping updates, and delivery notifications via SMS. AI personalizes each message. Built on Commune's SMS API. + +```mermaid +flowchart LR + A[Order event] --> B{Event type} + B -->|Order placed| C[send_order_confirmation] + B -->|Shipped| D[send_shipping_update] + B -->|Delivered| E[send_delivery_confirmation] + B -->|Delayed| F[send_delay_notification] + C & D & E & F --> G[OpenAI: personalize message] + G --> H[commune.sms.send] + H --> I[Customer phone] +``` + +--- + +## Pattern overview + +This is a pattern library, not a full agent — four functions you drop into whatever order management system you already have. Each function: + +1. Takes structured order data +2. Calls OpenAI to generate a concise, personalized SMS (<160 chars) +3. Sends via `commune.sms.send()` +4. Returns the message SID + +```python +from notifications import send_order_confirmation, send_shipping_update + +# Order placed +send_order_confirmation(order=order_data, customer_phone="+14155551234") + +# Order shipped +send_shipping_update(order=order_data, tracking=tracking_data, customer_phone="+14155551234") +``` + +--- + +## Notification types + +### Order confirmation + +Sent immediately when the order is placed. + +```python +send_order_confirmation( + order={"order_id": "ORD-8821", "items": ["Blue hoodie (L)", "Beanie"], "total": 89.00}, + customer_phone="+14155551234", +) +# → "Thanks for your order! ORD-8821 (Blue hoodie (L), Beanie) — $89.00. Ships in 1-3 days." +``` + +### Shipping update + +Sent when the carrier picks up the package. + +```python +send_shipping_update( + order={"order_id": "ORD-8821", "items": ["Blue hoodie (L)"]}, + tracking={"carrier": "UPS", "tracking_number": "1Z9999W99999999999", "eta": "Feb 28"}, + customer_phone="+14155551234", +) +# → "ORD-8821 shipped via UPS. Track: 1Z9999W99999999999. Estimated delivery Feb 28." +``` + +### Delivery confirmation + +Sent when the carrier marks the package as delivered. + +```python +send_delivery_confirmation( + order={"order_id": "ORD-8821", "items": ["Blue hoodie (L)"]}, + customer_phone="+14155551234", +) +# → "Your order ORD-8821 was delivered. Enjoy your Blue hoodie! Questions? Reply anytime." +``` + +### Delay notification + +Sent when the estimated delivery date slips. + +```python +send_delay_notification( + order={"order_id": "ORD-8821", "items": ["Blue hoodie (L)"], "original_eta": "Feb 25"}, + new_eta="Mar 2", + customer_phone="+14155551234", +) +# → "Heads up: ORD-8821 is running a bit late. New estimated delivery: Mar 2. Sorry for the wait!" +``` + +--- + +## Why AI-generated messages? + +- Messages stay under 160 characters (no split-SMS billing surprises) +- Natural language adapts to item names, order context, and carrier names +- Delay messages include a genuine-sounding apology instead of boilerplate +- Easy to adjust tone by editing the system prompt in each function + +--- + +## Quickstart + +```bash +pip install -r requirements.txt +cp .env.example .env +# Fill in COMMUNE_API_KEY, COMMUNE_PHONE_NUMBER_ID, OPENAI_API_KEY +python notifications.py # runs the example block +``` + +--- + +## Configuration + +| Variable | Description | +|---|---| +| `COMMUNE_API_KEY` | Your Commune API key (`comm_...`) | +| `COMMUNE_PHONE_NUMBER_ID` | Phone number ID for outbound SMS | +| `OPENAI_API_KEY` | OpenAI API key for message generation | + +Get your phone number ID: + +```python +from commune import CommuneClient +commune = CommuneClient(api_key="comm_...") +numbers = commune.phone_numbers.list() +print(numbers[0].id, numbers[0].number) +``` diff --git a/use-cases/notifications-and-alerts/order-and-transactional-sms/notifications.py b/use-cases/notifications-and-alerts/order-and-transactional-sms/notifications.py new file mode 100644 index 0000000..038f01f --- /dev/null +++ b/use-cases/notifications-and-alerts/order-and-transactional-sms/notifications.py @@ -0,0 +1,175 @@ +""" +Transactional SMS — Order Notifications +A pattern library for sending personalized order update SMS messages. + +Usage: + from notifications import send_order_confirmation, send_shipping_update + send_order_confirmation(order=order_dict, customer_phone="+14155551234") +""" + +import os + +from dotenv import load_dotenv +from commune import CommuneClient +from openai import OpenAI + +load_dotenv() + +# Validate required environment variables at startup +_REQUIRED_ENV = ["COMMUNE_API_KEY", "OPENAI_API_KEY", "COMMUNE_PHONE_NUMBER_ID"] +for _var in _REQUIRED_ENV: + if not os.getenv(_var): + raise SystemExit(f"Missing required environment variable: {_var}\n" + f"Copy .env.example to .env and fill in your values.") + +commune = CommuneClient(api_key=os.environ["COMMUNE_API_KEY"]) +openai_client = OpenAI(api_key=os.environ["OPENAI_API_KEY"]) +PHONE_NUMBER_ID = os.environ["COMMUNE_PHONE_NUMBER_ID"] + + +def _generate_sms(system_prompt: str, user_content: str) -> str: + """Call OpenAI to generate a personalized SMS under 160 characters.""" + response = openai_client.chat.completions.create( + model="gpt-4o-mini", + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_content}, + ], + max_tokens=80, + ) + return response.choices[0].message.content.strip() + + +def send_order_confirmation(order: dict, customer_phone: str) -> str: + """Send SMS when order is placed. Returns the message SID.""" + items_str = ", ".join(order.get("items", [])) + total = order.get("total", "") + order_id = order.get("order_id", "your order") + + message = _generate_sms( + system_prompt=( + "Write a friendly order confirmation SMS under 160 characters. " + "Include the order ID, item names (abbreviated if needed), and total. " + "Mention it ships in 1-3 business days. No hashtags. Plain text only." + ), + user_content=f"Order ID: {order_id}\nItems: {items_str}\nTotal: ${total}", + ) + + result = commune.sms.send( + to=customer_phone, + body=message, + phone_number_id=PHONE_NUMBER_ID, + ) + return result.id + + +def send_shipping_update(order: dict, tracking: dict, customer_phone: str) -> str: + """Send SMS when order ships. Returns the message SID.""" + order_id = order.get("order_id", "your order") + items_str = ", ".join(order.get("items", [])) + carrier = tracking.get("carrier", "the carrier") + tracking_number = tracking.get("tracking_number", "") + eta = tracking.get("eta", "soon") + + message = _generate_sms( + system_prompt=( + "Write a shipping confirmation SMS under 160 characters. " + "Include the order ID, carrier name, tracking number, and estimated delivery date. " + "Keep it informative and upbeat. Plain text only." + ), + user_content=( + f"Order ID: {order_id}\nItems: {items_str}\n" + f"Carrier: {carrier}\nTracking: {tracking_number}\nETA: {eta}" + ), + ) + + result = commune.sms.send( + to=customer_phone, + body=message, + phone_number_id=PHONE_NUMBER_ID, + ) + return result.id + + +def send_delivery_confirmation(order: dict, customer_phone: str) -> str: + """Send SMS when order is delivered. Returns the message SID.""" + order_id = order.get("order_id", "your order") + items_str = ", ".join(order.get("items", [])) + + message = _generate_sms( + system_prompt=( + "Write a delivery confirmation SMS under 160 characters. " + "Tell the customer their order arrived. Mention the main item. " + "Be warm and brief. Invite them to reply with any questions. Plain text only." + ), + user_content=f"Order ID: {order_id}\nItems: {items_str}", + ) + + result = commune.sms.send( + to=customer_phone, + body=message, + phone_number_id=PHONE_NUMBER_ID, + ) + return result.id + + +def send_delay_notification(order: dict, new_eta: str, customer_phone: str) -> str: + """Send SMS when order is delayed — AI personalizes the apology. Returns message SID.""" + order_id = order.get("order_id", "your order") + items_str = ", ".join(order.get("items", [])) + original_eta = order.get("original_eta", "the original date") + + message = _generate_sms( + system_prompt=( + "Write a delay notification SMS under 160 characters. " + "Acknowledge the delay, give the new estimated delivery date, and apologize briefly. " + "Sound human and genuine, not robotic. No excessive apologies. Plain text only." + ), + user_content=( + f"Order ID: {order_id}\nItems: {items_str}\n" + f"Original ETA: {original_eta}\nNew ETA: {new_eta}" + ), + ) + + result = commune.sms.send( + to=customer_phone, + body=message, + phone_number_id=PHONE_NUMBER_ID, + ) + return result.id + + +# --------------------------------------------------------------------------- +# Example usage +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + # Dummy data — replace with your actual order objects + order = { + "order_id": "ORD-8821", + "items": ["Blue hoodie (L)", "Beanie"], + "total": 89.00, + "original_eta": "Feb 25", + } + tracking = { + "carrier": "UPS", + "tracking_number": "1Z9999W99999999999", + "eta": "Feb 28", + } + customer_phone = "+14155550000" # replace with a real number + + print("Sending order confirmation...") + sid = send_order_confirmation(order=order, customer_phone=customer_phone) + print(f" Sent. SID: {sid}") + + print("Sending shipping update...") + sid = send_shipping_update(order=order, tracking=tracking, customer_phone=customer_phone) + print(f" Sent. SID: {sid}") + + print("Sending delivery confirmation...") + sid = send_delivery_confirmation(order=order, customer_phone=customer_phone) + print(f" Sent. SID: {sid}") + + print("Sending delay notification...") + sid = send_delay_notification(order=order, new_eta="Mar 2", customer_phone=customer_phone) + print(f" Sent. SID: {sid}") diff --git a/use-cases/notifications-and-alerts/order-and-transactional-sms/requirements.txt b/use-cases/notifications-and-alerts/order-and-transactional-sms/requirements.txt new file mode 100644 index 0000000..32e9125 --- /dev/null +++ b/use-cases/notifications-and-alerts/order-and-transactional-sms/requirements.txt @@ -0,0 +1,3 @@ +commune-mail>=0.4.0 +openai>=1.30.0 +python-dotenv>=1.0.0 diff --git a/use-cases/sales-and-marketing/README.md b/use-cases/sales-and-marketing/README.md index 863cd91..04cc967 100644 --- a/use-cases/sales-and-marketing/README.md +++ b/use-cases/sales-and-marketing/README.md @@ -7,6 +7,7 @@ Three ready-to-run examples for building AI-powered sales and marketing automati | Example | Channel | Stack | Description | |---------|---------|-------|-------------| | [cold-outreach-sequences/](./cold-outreach-sequences/) | Email | Python + OpenAI | AI SDR agent that runs personalized multi-step outreach sequences and stops automatically on reply | +| [sms-lead-qualification/](./sms-lead-qualification/) | SMS | TypeScript + Express | Instantly texts new leads to qualify budget, timeline, and intent — emails qualified leads to your sales team | | [newsletter-agent/](./newsletter-agent/) | Email | Python + OpenAI | Generates and sends a personalized newsletter to every subscriber, with automatic unsubscribe compliance | --- @@ -17,12 +18,17 @@ Three ready-to-run examples for building AI-powered sales and marketing automati Are you doing outbound prospecting? ├── Yes, multi-step email sequences → cold-outreach-sequences/ │ +Are you handling inbound leads? +├── Yes, qualify them fast → sms-lead-qualification/ +│ Do you want to nurture an existing list? └── Yes, newsletters → newsletter-agent/ ``` **cold-outreach-sequences** — Best starting point for outbound sales teams. Sends a personalized initial email, then follows up at day 3 and day 7 if there's no reply. Commune's `thread_id` keeps every step in a single email thread so prospects see the full conversation. Sequence halts the moment a prospect replies. +**sms-lead-qualification** — SMS has a 98% open rate. When a new lead hits your CRM or form, this agent texts them within seconds, runs a short qualification conversation (budget, timeline, decision maker), and emails a summary to your sales rep only if the lead qualifies. Built in TypeScript with Express webhooks. + **newsletter-agent** — Reads your subscriber list, generates a newsletter personalised to each subscriber's interests using OpenAI, and sends it via Commune. Commune automatically adds RFC 8058 `List-Unsubscribe` headers on every send — no extra work for CAN-SPAM or GDPR compliance. --- @@ -33,3 +39,4 @@ All examples require: - A [Commune](https://commune.sh) account and API key (`comm_...`) - An [OpenAI](https://platform.openai.com) API key (`sk-...`) +- SMS examples also require a Commune phone number (provision in the dashboard) diff --git a/use-cases/sales-and-marketing/sms-lead-qualification/.env.example b/use-cases/sales-and-marketing/sms-lead-qualification/.env.example new file mode 100644 index 0000000..04e0dee --- /dev/null +++ b/use-cases/sales-and-marketing/sms-lead-qualification/.env.example @@ -0,0 +1,17 @@ +# Commune — get your API key at https://commune.sh/dashboard +COMMUNE_API_KEY=comm_... + +# Phone number to send SMS from — provision in the Commune dashboard +COMMUNE_PHONE_NUMBER_ID=pn_... + +# Inbox used to send the sales team summary email +COMMUNE_INBOX_ID=inbox_... + +# OpenAI — used to score and summarise the qualification conversation +OPENAI_API_KEY=sk-... + +# Where to email qualified lead summaries +SALES_EMAIL=sales@yourcompany.com + +# HTTP server port +PORT=3000 diff --git a/use-cases/sales-and-marketing/sms-lead-qualification/README.md b/use-cases/sales-and-marketing/sms-lead-qualification/README.md new file mode 100644 index 0000000..5118576 --- /dev/null +++ b/use-cases/sales-and-marketing/sms-lead-qualification/README.md @@ -0,0 +1,73 @@ +# SMS Lead Qualification Agent + +When a new lead comes in, your AI agent texts them immediately — qualifies interest, budget, and timeline via SMS conversation. Qualified leads get emailed to your sales team. + +SMS has a 98% open rate and an average response time under 3 minutes. By the time a sales rep picks up the phone, this agent has already filtered the list. + +## How it works + +```mermaid +flowchart TD + A[New lead arrives — form / CRM webhook] --> B[POST /lead] + B --> C[Agent sends immediate SMS to lead] + C --> D[Lead replies] + D --> E[POST /sms/webhook — Commune delivers reply] + E --> F{Qualification question answered?} + F -->|More questions| G[Ask next qualifying question] + G --> D + F -->|All answered| H{Score >= threshold?} + H -->|Qualified| I[Email sales team with lead summary] + H -->|Not qualified| J[SMS: we will be in touch] + I --> K[Mark lead as qualified in state] + J --> K +``` + +## Quickstart + +```bash +npm install +cp .env.example .env +# Fill in COMMUNE_API_KEY, COMMUNE_PHONE_NUMBER_ID, OPENAI_API_KEY, SALES_EMAIL, COMMUNE_INBOX_ID +npm run dev +``` + +Then expose your local server with [ngrok](https://ngrok.com) and configure the Commune SMS webhook to point to `https://your-ngrok-url/sms/webhook`. + +Test it by sending a POST to `/lead`: + +```bash +curl -X POST http://localhost:3000/lead \ + -H "Content-Type: application/json" \ + -d '{"name":"Alex","phone":"+15551234567","email":"alex@example.com","source":"website"}' +``` + +## Qualification logic + +The agent runs up to three qualifying questions in sequence: + +| # | Question | Qualifies if… | +|---|----------|---------------| +| 1 | Implementing within 3 months? | Replies YES | +| 2 | Budget range (< $5k / $5k–$20k / > $20k)? | $5k+ | +| 3 | Are you the decision maker? | Replies YES | + +Each affirmative answer adds 1 to a score. Score ≥ 2 → email the sales team. + +## File overview + +| File | Purpose | +|------|---------| +| `src/index.ts` | Express server — `/lead` intake and `/sms/webhook` handler | +| `package.json` | Dependencies | +| `.env.example` | Required environment variables | + +## Environment variables + +``` +COMMUNE_API_KEY=comm_... +COMMUNE_PHONE_NUMBER_ID=pn_... +COMMUNE_INBOX_ID=inbox_... +OPENAI_API_KEY=sk-... +SALES_EMAIL=sales@yourcompany.com +PORT=3000 +``` diff --git a/use-cases/sales-and-marketing/sms-lead-qualification/package.json b/use-cases/sales-and-marketing/sms-lead-qualification/package.json new file mode 100644 index 0000000..7de1c87 --- /dev/null +++ b/use-cases/sales-and-marketing/sms-lead-qualification/package.json @@ -0,0 +1,23 @@ +{ + "name": "sms-lead-qualification", + "version": "1.0.0", + "description": "AI SMS lead qualification agent powered by Commune", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "dev": "ts-node src/index.ts", + "start": "node dist/index.js" + }, + "dependencies": { + "commune-ai": "^0.1.0", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "openai": "^4.52.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.14.0", + "ts-node": "^10.9.2", + "typescript": "^5.5.2" + } +} diff --git a/use-cases/sales-and-marketing/sms-lead-qualification/src/index.ts b/use-cases/sales-and-marketing/sms-lead-qualification/src/index.ts new file mode 100644 index 0000000..6ed6767 --- /dev/null +++ b/use-cases/sales-and-marketing/sms-lead-qualification/src/index.ts @@ -0,0 +1,265 @@ +/** + * SMS Lead Qualification Agent + * ============================== + * Express server with two endpoints: + * + * POST /lead — receive a new lead (from a form, CRM webhook, etc.) + * immediately sends a qualifying SMS + * + * POST /sms/webhook — Commune delivers inbound SMS replies here + * runs the multi-turn qualification conversation + * + * Qualification flow (up to 3 questions): + * 1. Are you looking to implement within the next 3 months? + * 2. What is your budget range? + * 3. Are you the decision maker? + * + * Score >= 2 → email the sales team with a lead summary + * Score < 2 → polite "we'll be in touch" SMS + * + * State is stored in memory (Map). For production, swap for SQLite or Redis. + */ + +import express, { Request, Response } from 'express'; +import dotenv from 'dotenv'; +import OpenAI from 'openai'; +import { CommuneClient } from 'commune-ai'; + +dotenv.config(); + +// --------------------------------------------------------------------------- +// Clients +// --------------------------------------------------------------------------- + +const commune = new CommuneClient({ apiKey: process.env.COMMUNE_API_KEY! }); +const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! }); + +const PHONE_NUMBER_ID = process.env.COMMUNE_PHONE_NUMBER_ID!; +const INBOX_ID = process.env.COMMUNE_INBOX_ID!; +const SALES_EMAIL = process.env.SALES_EMAIL!; +const PORT = parseInt(process.env.PORT ?? '3000', 10); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface Lead { + name: string; + phone: string; // E.164 format, e.g. +15551234567 + email: string; + source: string; +} + +type QualStep = 'timeline' | 'budget' | 'decision_maker' | 'done'; + +interface ConversationState { + lead: Lead; + step: QualStep; + score: number; + answers: Record; + startedAt: Date; +} + +// --------------------------------------------------------------------------- +// In-memory state (phone number → conversation) +// --------------------------------------------------------------------------- + +// Key: remote phone number (E.164) +const conversations = new Map(); + +// --------------------------------------------------------------------------- +// Qualification questions and scoring +// --------------------------------------------------------------------------- + +const QUESTIONS: Record = { + timeline: + 'are you looking to implement this within the next 3 months? Reply YES or NO.', + budget: + 'what is your rough budget range? Reply A for under $5k, B for $5k–$20k, or C for over $20k.', + decision_maker: + 'are you the primary decision maker for this purchase? Reply YES or NO.', + done: '', +}; + +/** + * Evaluate a raw SMS reply against the current qualification step. + * Returns 1 if the answer is positive/qualifying, 0 otherwise. + */ +function scoreAnswer(step: QualStep, reply: string): number { + const r = reply.trim().toUpperCase(); + if (step === 'timeline') return r.startsWith('Y') ? 1 : 0; + if (step === 'budget') return r === 'B' || r === 'C' ? 1 : 0; + if (step === 'decision_maker') return r.startsWith('Y') ? 1 : 0; + return 0; +} + +/** + * Return the next step in the qualification sequence. + */ +function nextStep(current: QualStep): QualStep { + const order: QualStep[] = ['timeline', 'budget', 'decision_maker', 'done']; + const idx = order.indexOf(current); + return order[idx + 1] ?? 'done'; +} + +// --------------------------------------------------------------------------- +// OpenAI: generate lead summary for sales team email +// --------------------------------------------------------------------------- + +async function buildLeadSummary(state: ConversationState): Promise { + const { lead, answers, score } = state; + const prompt = `Write a concise, structured lead summary for a sales rep. + +Lead: + Name: ${lead.name} + Email: ${lead.email} + Phone: ${lead.phone} + Source: ${lead.source} + +Qualification answers: + Timeline (within 3 months?): ${answers.timeline ?? 'not answered'} + Budget range: ${answers.budget ?? 'not answered'} + Decision maker?: ${answers.decision_maker ?? 'not answered'} + +Qualification score: ${score}/3 + +Keep it under 8 bullet points. Start with a one-sentence verdict.`; + + const response = await openai.chat.completions.create({ + model: 'gpt-4o-mini', + messages: [{ role: 'user', content: prompt }], + temperature: 0.3, + }); + + return response.choices[0].message.content?.trim() ?? 'Summary unavailable.'; +} + +// --------------------------------------------------------------------------- +// POST /lead — new lead intake +// --------------------------------------------------------------------------- + +const app = express(); +app.use(express.json()); + +app.post('/lead', async (req: Request, res: Response): Promise => { + const { name, phone, email, source } = req.body as Lead; + + if (!name || !phone || !email) { + res.status(400).json({ error: 'name, phone, and email are required' }); + return; + } + + console.log(`[NEW LEAD] ${name} <${email}> from ${source ?? 'unknown'}`); + + // Initialise conversation state + conversations.set(phone, { + lead: { name, phone, email, source: source ?? 'unknown' }, + step: 'timeline', + score: 0, + answers: {}, + startedAt: new Date(), + }); + + // Send the first qualifying question immediately + const firstName = name.split(' ')[0]; + const firstQuestion = QUESTIONS['timeline']; + + await commune.sms.send({ + to: phone, + body: `Hi ${firstName}! Thanks for your interest. Quick question — ${firstQuestion}`, + phone_number_id: PHONE_NUMBER_ID, + }); + + console.log(`[SMS SENT] Opening question to ${phone}`); + res.status(200).json({ ok: true, message: 'Qualification started' }); +}); + +// --------------------------------------------------------------------------- +// POST /sms/webhook — Commune delivers inbound SMS replies here +// --------------------------------------------------------------------------- + +app.post('/sms/webhook', async (req: Request, res: Response): Promise => { + // Acknowledge immediately — Commune expects a fast 200 + res.status(200).json({ ok: true }); + + const { from, body: replyText } = req.body as { from: string; body: string }; + + if (!from || !replyText) return; + + const state = conversations.get(from); + + if (!state) { + // Unknown number — someone texted the number directly + console.log(`[UNKNOWN] SMS from ${from}: "${replyText}" — no active session`); + await commune.sms.send({ + to: from, + body: "Hi! We don't have an active session for this number. Visit our website to get started.", + phone_number_id: PHONE_NUMBER_ID, + }); + return; + } + + if (state.step === 'done') return; // sequence already complete + + const { lead, step } = state; + console.log(`[REPLY] ${from} (step: ${step}): "${replyText}"`); + + // Record the answer and update score + state.answers[step] = replyText.trim(); + state.score += scoreAnswer(step, replyText); + state.step = nextStep(step); + + // ---- More questions to ask -------------------------------------------- + if (state.step !== 'done') { + const question = QUESTIONS[state.step]; + await commune.sms.send({ + to: from, + body: question, + phone_number_id: PHONE_NUMBER_ID, + }); + console.log(`[SMS SENT] Follow-up question (${state.step}) to ${from}`); + return; + } + + // ---- All questions answered — evaluate -------------------------------- + console.log(`[QUALIFIED?] ${lead.name} scored ${state.score}/3`); + + if (state.score >= 2) { + // Qualified — email the sales team + const summary = await buildLeadSummary(state); + + await commune.messages.send({ + to: SALES_EMAIL, + subject: `Qualified lead: ${lead.name} (${lead.source})`, + text: `A new lead has been qualified via SMS.\n\n${summary}\n\n---\nGenerated by SMS Lead Qualification Agent`, + inboxId: INBOX_ID, + }); + + console.log(`[QUALIFIED] Email sent to sales team for ${lead.name}`); + + await commune.sms.send({ + to: from, + body: `Thanks ${lead.name.split(' ')[0]}! A member of our team will reach out to ${lead.email} within one business day.`, + phone_number_id: PHONE_NUMBER_ID, + }); + } else { + // Not qualified — polite close + console.log(`[NOT QUALIFIED] ${lead.name} — score ${state.score}/3`); + + await commune.sms.send({ + to: from, + body: `Thanks for your time, ${lead.name.split(' ')[0]}! We'll keep your details on file and reach out if a good fit comes up.`, + phone_number_id: PHONE_NUMBER_ID, + }); + } +}); + +// --------------------------------------------------------------------------- +// Start server +// --------------------------------------------------------------------------- + +app.listen(PORT, () => { + console.log(`\nSMS Lead Qualification Agent running on port ${PORT}`); + console.log(` POST /lead — submit a new lead`); + console.log(` POST /sms/webhook — Commune SMS webhook\n`); +});