diff --git a/examples/agents_sdk/email_agent_with_commune.ipynb b/examples/agents_sdk/email_agent_with_commune.ipynb new file mode 100644 index 0000000000..053094e555 --- /dev/null +++ b/examples/agents_sdk/email_agent_with_commune.ipynb @@ -0,0 +1,627 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Customer Support Email Agent with GPT-4o and Commune\n", + "\n", + "This notebook walks through building a **production-ready customer support agent** that:\n", + "\n", + "- Reads incoming support emails from a [Commune](https://commune.email) inbox\n", + "- Classifies each ticket by type and priority using GPT-4o\n", + "- Drafts contextually appropriate replies\n", + "- Sends those replies automatically via the Commune API\n", + "\n", + "**What you'll learn:**\n", + "- How to define tools in OpenAI's function-calling JSON schema\n", + "- How to implement the full `tool_calls` → function execution → `tool` message loop\n", + "- How to build a robust multi-turn agent with error handling\n", + "- Production patterns: polling loops, rate limiting, graceful shutdown\n", + "\n", + "**Prerequisites:**\n", + "Set the environment variables `OPENAI_API_KEY` and `COMMUNE_API_KEY`.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Setup\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%pip install -q openai commune-mail\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import json\n", + "import time\n", + "import threading\n", + "from openai import OpenAI\n", + "from commune import Commune\n", + "\n", + "openai_client = OpenAI(api_key=os.environ[\"OPENAI_API_KEY\"])\n", + "commune_client = Commune(api_key=os.environ[\"COMMUNE_API_KEY\"])\n", + "\n", + "MODEL = \"gpt-4o\"\n", + "print(\"Clients initialised.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Email Tool Functions\n", + "\n", + "These Python functions wrap the Commune SDK and serve as the \"hands\"\n", + "the agent uses to interact with the real inbox.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def read_inbox(limit: int = 10, unread_only: bool = False) -> dict:\n", + " \"\"\"Fetch recent emails from the Commune support inbox.\"\"\"\n", + " emails = commune_client.emails.list(limit=limit, unread_only=unread_only)\n", + " return {\n", + " \"emails\": [\n", + " {\n", + " \"id\": e[\"id\"],\n", + " \"from\": e[\"from_address\"],\n", + " \"subject\": e[\"subject\"],\n", + " \"preview\": e[\"body\"][:400],\n", + " \"received_at\": e[\"received_at\"],\n", + " \"read\": e[\"read\"],\n", + " }\n", + " for e in emails\n", + " ],\n", + " \"count\": len(emails),\n", + " }\n", + "\n", + "\n", + "def get_email(email_id: str) -> dict:\n", + " \"\"\"Get the complete body of a single email.\"\"\"\n", + " e = commune_client.emails.get(email_id)\n", + " return {\n", + " \"id\": e[\"id\"],\n", + " \"from\": e[\"from_address\"],\n", + " \"subject\": e[\"subject\"],\n", + " \"body\": e[\"body\"],\n", + " \"received_at\": e[\"received_at\"],\n", + " }\n", + "\n", + "\n", + "def search_emails(query: str, limit: int = 10) -> dict:\n", + " \"\"\"Full-text search across the inbox.\"\"\"\n", + " results = commune_client.emails.search(query=query, limit=limit)\n", + " return {\n", + " \"results\": [\n", + " {\n", + " \"id\": e[\"id\"],\n", + " \"from\": e[\"from_address\"],\n", + " \"subject\": e[\"subject\"],\n", + " \"preview\": e[\"body\"][:400],\n", + " \"received_at\": e[\"received_at\"],\n", + " }\n", + " for e in results\n", + " ],\n", + " \"count\": len(results),\n", + " }\n", + "\n", + "\n", + "def send_email(to: str, subject: str, body: str) -> dict:\n", + " \"\"\"Send an email reply via Commune.\"\"\"\n", + " result = commune_client.emails.send(to=to, subject=subject, body=body)\n", + " return {\"status\": \"sent\", \"message_id\": result.get(\"id\")}\n", + "\n", + "\n", + "def send_sms(to: str, body: str) -> dict:\n", + " \"\"\"Send an SMS alert for urgent tickets (Commune SMS).\"\"\"\n", + " result = commune_client.sms.send(to=to, body=body)\n", + " return {\"status\": \"sent\", \"message_id\": result.get(\"id\")}\n", + "\n", + "\n", + "# Registry used by the agent executor\n", + "TOOL_REGISTRY = {\n", + " \"read_inbox\": read_inbox,\n", + " \"get_email\": get_email,\n", + " \"search_emails\": search_emails,\n", + " \"send_email\": send_email,\n", + " \"send_sms\": send_sms,\n", + "}\n", + "\n", + "print(\"Tool functions:\", list(TOOL_REGISTRY))\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. Tool Definitions (OpenAI Function-Calling Schema)\n", + "\n", + "OpenAI's `tools` list uses the `{\"type\": \"function\", \"function\": {...}}` wrapper.\n", + "The `parameters` field follows JSON Schema.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "tools = [\n", + " {\n", + " \"type\": \"function\",\n", + " \"function\": {\n", + " \"name\": \"read_inbox\",\n", + " \"description\": (\n", + " \"Read recent emails from the Commune support inbox. \"\n", + " \"Use this to check for new messages, triage tickets, or get an overview \"\n", + " \"of pending support requests.\"\n", + " ),\n", + " \"parameters\": {\n", + " \"type\": \"object\",\n", + " \"properties\": {\n", + " \"limit\": {\n", + " \"type\": \"integer\",\n", + " \"description\": \"Number of emails to return (1–50). Default 10.\",\n", + " },\n", + " \"unread_only\": {\n", + " \"type\": \"boolean\",\n", + " \"description\": \"Return only unread emails.\",\n", + " },\n", + " },\n", + " \"required\": [],\n", + " },\n", + " },\n", + " },\n", + " {\n", + " \"type\": \"function\",\n", + " \"function\": {\n", + " \"name\": \"get_email\",\n", + " \"description\": (\n", + " \"Retrieve the full body of a specific email by its ID. \"\n", + " \"Call this after read_inbox to read the complete content of a message.\"\n", + " ),\n", + " \"parameters\": {\n", + " \"type\": \"object\",\n", + " \"properties\": {\n", + " \"email_id\": {\n", + " \"type\": \"string\",\n", + " \"description\": \"The unique ID of the email.\",\n", + " },\n", + " },\n", + " \"required\": [\"email_id\"],\n", + " },\n", + " },\n", + " },\n", + " {\n", + " \"type\": \"function\",\n", + " \"function\": {\n", + " \"name\": \"search_emails\",\n", + " \"description\": (\n", + " \"Search the inbox by keyword, topic, or sender address. \"\n", + " \"Useful for finding all tickets related to a specific issue or customer.\"\n", + " ),\n", + " \"parameters\": {\n", + " \"type\": \"object\",\n", + " \"properties\": {\n", + " \"query\": {\n", + " \"type\": \"string\",\n", + " \"description\": \"Search terms or keywords.\",\n", + " },\n", + " \"limit\": {\n", + " \"type\": \"integer\",\n", + " \"description\": \"Max results to return (default 10).\",\n", + " },\n", + " },\n", + " \"required\": [\"query\"],\n", + " },\n", + " },\n", + " },\n", + " {\n", + " \"type\": \"function\",\n", + " \"function\": {\n", + " \"name\": \"send_email\",\n", + " \"description\": (\n", + " \"Send an email reply to a customer via Commune. \"\n", + " \"Always base the reply on the actual email content — do not fabricate details.\"\n", + " ),\n", + " \"parameters\": {\n", + " \"type\": \"object\",\n", + " \"properties\": {\n", + " \"to\": {\n", + " \"type\": \"string\",\n", + " \"description\": \"Recipient email address.\",\n", + " },\n", + " \"subject\": {\n", + " \"type\": \"string\",\n", + " \"description\": \"Email subject line.\",\n", + " },\n", + " \"body\": {\n", + " \"type\": \"string\",\n", + " \"description\": \"Plain-text email body.\",\n", + " },\n", + " },\n", + " \"required\": [\"to\", \"subject\", \"body\"],\n", + " },\n", + " },\n", + " },\n", + " {\n", + " \"type\": \"function\",\n", + " \"function\": {\n", + " \"name\": \"send_sms\",\n", + " \"description\": (\n", + " \"Send an SMS alert via Commune for urgent tickets that need immediate human attention. \"\n", + " \"Use sparingly — only for P0/P1 severity issues.\"\n", + " ),\n", + " \"parameters\": {\n", + " \"type\": \"object\",\n", + " \"properties\": {\n", + " \"to\": {\n", + " \"type\": \"string\",\n", + " \"description\": \"Recipient phone number in E.164 format, e.g. +15551234567.\",\n", + " },\n", + " \"body\": {\n", + " \"type\": \"string\",\n", + " \"description\": \"SMS message text (max 160 chars).\",\n", + " },\n", + " },\n", + " \"required\": [\"to\", \"body\"],\n", + " },\n", + " },\n", + " },\n", + "]\n", + "\n", + "print(f\"{len(tools)} tools registered.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. Agent Implementation\n", + "\n", + "The agent loop follows the standard OpenAI function-calling pattern:\n", + "\n", + "1. Call `chat.completions.create` with `tools` and `tool_choice=\"auto\"`\n", + "2. If the model returns `finish_reason == \"tool_calls\"`, execute each call\n", + "3. Append the function outputs as `{\"role\": \"tool\", ...}` messages\n", + "4. Loop until `finish_reason == \"stop\"`\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "SYSTEM_PROMPT = \"\"\"\\\n", + "You are an expert customer support agent with access to a Commune email inbox.\n", + "Your job is to:\n", + " 1. Triage incoming support emails by urgency and category.\n", + " 2. Draft professional, empathetic replies.\n", + " 3. Send those replies via the send_email tool.\n", + " 4. Escalate critical issues (outages, billing failures, data loss) via SMS.\n", + "\n", + "Guidelines:\n", + "- Always read the full email body before drafting a reply.\n", + "- Address the customer by their first name when possible.\n", + "- Be concise — support replies should be under 200 words.\n", + "- If you cannot resolve an issue, acknowledge it and promise a follow-up within 24 hours.\n", + "- Never make up product details — if unsure, say so.\n", + "\"\"\"\n", + "\n", + "\n", + "def execute_tool_call(tool_call) -> str:\n", + " \"\"\"Execute a single OpenAI tool call and return the result as a JSON string.\n", + "\n", + " Handles malformed tool arguments gracefully — a JSONDecodeError is caught\n", + " and returned as a tool-level error rather than raising and aborting the run.\n", + " \"\"\"\n", + " name = tool_call.function.name\n", + "\n", + " fn = TOOL_REGISTRY.get(name)\n", + " if fn is None:\n", + " return json.dumps({\"error\": f\"Unknown tool: {name}\"})\n", + "\n", + " try:\n", + " args = json.loads(tool_call.function.arguments)\n", + " result = fn(**args)\n", + " return json.dumps(result)\n", + " except json.JSONDecodeError as exc:\n", + " return json.dumps({\"error\": f\"Malformed tool arguments: {exc}\"})\n", + " except Exception as exc:\n", + " return json.dumps({\"error\": str(exc)})\n", + "\n", + "\n", + "def run_support_agent(user_message: str, verbose: bool = True) -> str:\n", + " \"\"\"Run the GPT-4o support agent for a single user request.\"\"\"\n", + " messages = [\n", + " {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n", + " {\"role\": \"user\", \"content\": user_message},\n", + " ]\n", + "\n", + " iteration = 0\n", + " max_iterations = 20 # safety guard against infinite loops\n", + "\n", + " while iteration < max_iterations:\n", + " iteration += 1\n", + "\n", + " response = openai_client.chat.completions.create(\n", + " model=MODEL,\n", + " messages=messages,\n", + " tools=tools,\n", + " tool_choice=\"auto\",\n", + " )\n", + "\n", + " choice = response.choices[0]\n", + " message = choice.message\n", + "\n", + " if verbose:\n", + " print(f\"[iteration {iteration}] finish_reason={choice.finish_reason}\")\n", + "\n", + " # Always append the assistant's message to history\n", + " messages.append(message)\n", + "\n", + " # ── Final answer ─────────────────────────────────────────────────\n", + " if choice.finish_reason == \"stop\":\n", + " return message.content or \"(no content)\"\n", + "\n", + " # ── Tool calls ───────────────────────────────────────────────────\n", + " if choice.finish_reason == \"tool_calls\" and message.tool_calls:\n", + " for tool_call in message.tool_calls:\n", + " if verbose:\n", + " print(f\" → {tool_call.function.name}({tool_call.function.arguments[:120]})\")\n", + "\n", + " result_str = execute_tool_call(tool_call)\n", + "\n", + " if verbose:\n", + " print(f\" ← {result_str[:200]}{'...' if len(result_str) > 200 else ''}\")\n", + "\n", + " messages.append({\n", + " \"role\": \"tool\",\n", + " \"tool_call_id\": tool_call.id,\n", + " \"content\": result_str,\n", + " })\n", + " continue\n", + "\n", + " # Unexpected finish reason\n", + " raise RuntimeError(f\"Unexpected finish_reason: {choice.finish_reason}\")\n", + "\n", + " raise RuntimeError(\"Agent exceeded max iterations — possible loop.\")\n", + "\n", + "\n", + "print(\"Support agent ready.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. Demo — Process the Inbox\n", + "\n", + "Run the agent against a real inbox. It will read, classify, and reply to\n", + "support emails autonomously.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Triage and classify unread tickets\n", + "result = run_support_agent(\n", + " \"Check the inbox for unread support emails. For each one:\\n\"\n", + " \" 1. Classify it as one of: billing, technical, feature-request, general.\\n\"\n", + " \" 2. Assign a priority: P0 (outage/data loss), P1 (broken feature), P2 (question).\\n\"\n", + " \" 3. Draft and send a reply that acknowledges the issue and sets expectations.\\n\"\n", + " \"After processing all tickets, give me a summary table.\"\n", + ")\n", + "\n", + "print(\"\\n\" + \"=\" * 60)\n", + "print(result)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Follow up on a specific topic\n", + "result = run_support_agent(\n", + " \"Search for any emails mentioning 'refund' or 'charge'. \"\n", + " \"Read each one in full, then send a reply that: \"\n", + " \"(1) apologises for any billing confusion, \"\n", + " \"(2) explains our 30-day no-questions-asked refund policy, \"\n", + " \"(3) tells them to reply to this email to initiate the refund. \"\n", + " \"Use the customer's name in the greeting.\"\n", + ")\n", + "\n", + "print(\"\\n\" + \"=\" * 60)\n", + "print(result)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. Production Patterns\n", + "\n", + "### 6a. Polling Loop with Rate Limiting\n", + "\n", + "Poll the inbox on a schedule. A simple exponential backoff prevents hammering\n", + "the API if something goes wrong.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import threading\n", + "import time\n", + "import math\n", + "\n", + "POLL_INTERVAL = 300 # seconds between polls (5 minutes)\n", + "MAX_BACKOFF = 1800 # max wait on repeated errors (30 minutes)\n", + "\n", + "_stop_flag = threading.Event()\n", + "\n", + "\n", + "def support_polling_loop(\n", + " escalation_phone: str = \"+15550001234\",\n", + " verbose: bool = True,\n", + "):\n", + " \"\"\"Continuously poll the support inbox and process new tickets.\"\"\"\n", + " consecutive_errors = 0\n", + "\n", + " while not _stop_flag.is_set():\n", + " wait = min(POLL_INTERVAL * (2 ** consecutive_errors), MAX_BACKOFF)\n", + " if consecutive_errors > 0:\n", + " print(f\"[poll] Backing off {wait}s after {consecutive_errors} error(s).\")\n", + " _stop_flag.wait(timeout=wait)\n", + " if _stop_flag.is_set():\n", + " break\n", + "\n", + " ts = time.strftime(\"%Y-%m-%d %H:%M:%S\")\n", + " print(f\"[poll] {ts} — checking inbox ...\")\n", + "\n", + " try:\n", + " summary = run_support_agent(\n", + " \"Check for unread emails received in the last 10 minutes. \"\n", + " f\"For critical P0 issues, send an SMS alert to {escalation_phone}. \"\n", + " \"Reply to all tickets. If the inbox is empty, say 'No new tickets.'\",\n", + " verbose=verbose,\n", + " )\n", + " print(f\"[poll] Result: {summary[:300]}\")\n", + " consecutive_errors = 0\n", + " except Exception as exc:\n", + " consecutive_errors += 1\n", + " print(f\"[poll] ERROR: {exc}\")\n", + "\n", + " _stop_flag.wait(timeout=POLL_INTERVAL)\n", + "\n", + " print(\"[poll] Stopped.\")\n", + "\n", + "\n", + "def start_support_agent(escalation_phone: str = \"+15550001234\"):\n", + " _stop_flag.clear()\n", + " t = threading.Thread(\n", + " target=support_polling_loop,\n", + " kwargs={\"escalation_phone\": escalation_phone},\n", + " daemon=True,\n", + " )\n", + " t.start()\n", + " print(\"Support agent polling started.\")\n", + " return t\n", + "\n", + "\n", + "def stop_support_agent():\n", + " _stop_flag.set()\n", + " print(\"Support agent polling stopped.\")\n", + "\n", + "\n", + "# To run:\n", + "# agent_thread = start_support_agent(escalation_phone=\"+15550001234\")\n", + "# # ... later ...\n", + "# stop_support_agent()\n", + "\n", + "print(\"Production polling helpers defined.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 6b. Webhook-Driven Processing (Recommended for Production)\n", + "\n", + "Instead of polling, configure Commune to POST a webhook when a new email arrives.\n", + "This removes the polling delay and reduces API calls.\n", + "\n", + "```python\n", + "# Example Flask webhook handler\n", + "from flask import Flask, request, jsonify\n", + "\n", + "app = Flask(__name__)\n", + "\n", + "@app.route(\"/commune/webhook\", methods=[\"POST\"])\n", + "def handle_new_email():\n", + " payload = request.json\n", + " email_id = payload[\"email_id\"]\n", + "\n", + " # Process asynchronously — don't block the webhook response\n", + " threading.Thread(\n", + " target=run_support_agent,\n", + " args=(f\"Process email {email_id}: read it, classify it, and reply.\",),\n", + " daemon=True,\n", + " ).start()\n", + "\n", + " return jsonify({\"status\": \"accepted\"}), 202\n", + "```\n", + "\n", + "Set your webhook URL in the [Commune dashboard](https://commune.email/dashboard).\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. Conclusion\n", + "\n", + "You now have a complete customer support email agent that:\n", + "\n", + "- Uses **GPT-4o function calling** to read, classify, and respond to support emails\n", + "- Integrates with **Commune** for real inbox access and email/SMS delivery\n", + "- Handles the full **tool_calls loop** correctly, including multi-step reasoning\n", + "- Includes **production patterns** for polling, error handling, and webhook integration\n", + "\n", + "### Extensions\n", + "\n", + "- Add a `create_ticket` tool to sync resolved issues to Jira or Linear\n", + "- Use [Structured Outputs](https://platform.openai.com/docs/guides/structured-outputs)\n", + " to get machine-readable triage metadata alongside the reply\n", + "- Store processed email IDs in Redis to avoid re-processing\n", + "- Implement a human-in-the-loop checkpoint for P0 replies before they send\n", + "\n", + "### Resources\n", + "\n", + "- [OpenAI Function Calling guide](https://platform.openai.com/docs/guides/function-calling)\n", + "- [Commune API reference](https://commune.email/docs)\n", + "- [commune-mail on PyPI](https://pypi.org/project/commune-mail/)\n", + "- [OpenAI Cookbook — Orchestrating agents](https://cookbook.openai.com/examples/orchestrating_agents)\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/registry.yaml b/registry.yaml index 3b45cebfc1..a0e38f949e 100644 --- a/registry.yaml +++ b/registry.yaml @@ -4,6 +4,21 @@ # should build pages for, and indicates metadata such as tags, creation date and # authors for each page. +- title: Customer Support Email Agent with GPT-4o and Commune + path: examples/agents_sdk/email_agent_with_commune.ipynb + slug: customer-support-email-agent-commune + description: Build a production-ready customer support agent that reads incoming emails via Commune, classifies tickets using GPT-4o function calling, and sends replies autonomously. + date: 2026-03-03 + authors: + - shanjairaj7 + tags: + - agents-sdk + - function-calling + - email + - tool-use + - communication + + - title: Long horizon tasks with Codex path: examples/codex/long_horizon_tasks.md slug: long-horizon-tasks