Example: Event-Driven Agent

Build a proactive, event-driven GitHub agent that uses Webhooks to handle replies in real time.

This tutorial walks you through building a sophisticated, dual-mode agent. It will:

  1. Proactively monitor a GitHub repository and send an outreach email when it detects a new “star”.
  2. Reactively process and reply to incoming emails in real-time using AgentMail Webhooks.

We will use Flask to create a simple web server and ngrok to expose it to the internet so AgentMail can send it events.

Prerequisites

Before you start, make sure you have the following:

Step 1: Project Setup

First, let’s set up your project directory and install the necessary dependencies.

  1. Create a project folder and navigate into it.

  2. Create a requirements.txt file with the following content:

    agentmail
    agentmail-toolkit
    openai
    openai-agents
    python-dotenv
    flask
    ngrok
  3. Install the packages:

    $pip install -r requirements.txt
  4. Create a .env file to store your secret keys and configuration.

    1AGENTMAIL_API_KEY="your_agentmail_api_key"
    2OPENAI_API_KEY="your_openai_api_key"
    3
    4NGROK_AUTHTOKEN="your_ngrok_authtoken"
    5INBOX_USERNAME="github-star-agent"
    6WEBHOOK_DOMAIN="your-ngrok-subdomain.ngrok-free.app"
    7DEMO_TARGET_EMAIL="your-email@example.com"
    8TARGET_GITHUB_REPO="YourGitHub/YourRepo"
    • Replace the placeholder values with your actual keys.
    • WEBHOOK_DOMAIN is your custom domain from your ngrok dashboard.
    • INBOX_USERNAME will be the email address for your agent (e.g., github-star-agent@agentmail.to).

Step 2: The Agent Code (main.py)

Create a file named main.py and add the full code example you provided. This script contains all the logic for our agent, including the new logic to idempotently create the inbox it needs.

main.py
1from dotenv import load_dotenv
2load_dotenv()
3
4import os
5import asyncio
6from threading import Thread
7import time
8
9import ngrok
10from flask import Flask, request, Response
11
12from agentmail import AgentMail
13from agentmail_toolkit.openai import AgentMailToolkit
14from agents import WebSearchTool, Agent, Runner
15
16port = 8080
17domain = os.getenv("WEBHOOK_DOMAIN")
18inbox_username = os.getenv("INBOX_USERNAME")
19inbox = f"{inbox_username}@agentmail.to"
20
21target_github_repo = os.getenv("TARGET_GITHUB_REPO")
22if not target_github_repo:
23print("\nWARNING: The TARGET_GITHUB_REPO environment variable is not set.")
24print("The agent will not have a specific GitHub repository to focus on.")
25print("Please set it in your .env file (e.g., TARGET_GITHUB_REPO='owner/repository_name')\n")
26
27demo_target_email = os.getenv("DEMO_TARGET_EMAIL")
28if not demo_target_email:
29print("\nWARNING: The DEMO_TARGET_EMAIL environment variable is not set.")
30print("The agent will not have a specific email to send the 'top starrer' outreach to.")
31print("Please set it in your .env file (e.g., DEMO_TARGET_EMAIL='your.email@example.com')\n")
32
33# Determine the target email, with a fallback if the environment variable is not set.
34
35# The fallback is less ideal for a real demo but prevents the agent from having no target.
36
37actual_demo_target_email = demo_target_email if demo_target_email else "fallback.email@example.com"
38
39# Use a fallback for target_github_repo as well for the instructions string construction
40
41actual_target_github_repo = target_github_repo if target_github_repo else "example/repo"
42
43# --- AgentMail and Web Server Setup ---
44
45# 1. Initialize the AgentMail client
46
47client = AgentMail()
48
49# 2. Idempotently create the inbox for the agent
50
51# Using a deterministic client_id ensures we don't create duplicate inboxes
52
53# if the script is run multiple times.
54
55inbox_client_id = f"inbox-for-{inbox_username}"
56print(f"Attempting to create or retrieve inbox '{inbox}' with client_id: {inbox_client_id}")
57try:
58client.inboxes.create(
59username=inbox_username,
60client_id=inbox_client_id
61)
62print("Inbox creation/retrieval successful.")
63except Exception as e:
64print(f"Error creating/retrieving inbox: {e}") # Depending on the desired behavior, you might want to exit here # if the inbox is critical for the agent's function.
65
66# 3. Start the ngrok tunnel to get a public URL
67
68print("Starting ngrok tunnel...")
69listener = ngrok.forward(port, domain=domain, authtoken_from_env=True)
70print(f"ngrok tunnel started: {listener.url()}")
71
72# 4. Idempotently create the webhook pointing to our new public URL
73
74webhook_url = f"{listener.url()}/webhooks"
75webhook_client_id = f"webhook-for-{inbox_username}"
76print(f"Attempting to create or retrieve webhook for URL: {webhook_url}")
77try:
78client.webhooks.create(
79url=webhook_url,
80client_id=webhook_client_id,
81)
82print("Webhook creation/retrieval successful.")
83except Exception as e:
84print(f"Error creating/retrieving webhook: {e}")
85
86# 5. Initialize the Flask App
87
88app = Flask(**name**)
89
90instructions = f"""
91You are a GitHub Repository Evangelist Agent. Your name is AgentMail. Your email address is {inbox}.
92Your primary focus is the GitHub repository: '{actual_target_github_repo}'.
93Your goal is to engage the user at {actual_demo_target_email} about the potential of '{actual_target_github_repo}' for building AI agents, using rich HTML emails.
94
95**You operate in two main scenarios:**
96
97**Scenario 1: Proactive Outreach (Triggered by internal monitor for '{actual_target_github_repo}')**
98
99- You will receive a direct instruction when a new (simulated) star is detected for '{actual_target_github_repo}'.
100- This instruction will explicitly ask you to:
101 1. Use the WebSearchTool to find fresh, compelling information or talking points about '{actual_target_github_repo}' (e.g., new features, use cases for agent development, benefits). You should synthesize this information, not just copy it.
102 2. Use the 'send_message' tool to send a NEW email to {actual_demo_target_email}.
103 - The email should start by mentioning something like: "Hello! We noticed you recently showed interest in (or starred) our repository, '{actual_target_github_repo}'! We're excited to share some insights..."
104 - You must craft an engaging 'subject' for this email.
105 - You must craft an informative 'html' (body) for this email in HTML format, based on your synthesized web search findings. **Do NOT include raw URLs or direct links from your web search in the email body.** Instead, discuss the concepts or information you found.
106 - The email must end with a clear call to action inviting the user to ask you (the agent) questions. For example: "I'm an AI assistant for '{actual_target_github_repo}', ready to answer any questions you might have. Feel free to reply to this email with your thoughts or queries!"
107- Your final output for THIS SCENARIO (after the send_message tool call) should be a brief confirmation message (e.g., "Proactive HTML email about new star sent to {actual_demo_target_email}."). Do NOT output the email content itself as your final response here, as the tool handles sending it.
108
109**Scenario 2: Replying to Emails (Triggered by webhook when an email arrives at {inbox})**
110
111- You will receive the content of an incoming email (From, Subject, Body).
112- **If the email is FROM '{actual_demo_target_email}':**
113 - This is a reply from your primary contact. Your goal is to continue the conversation naturally and persuasively. **Your entire output for this interaction MUST be a single, well-formed HTML string for the email body. It must start directly with an HTML tag (e.g., `<p>`) and end with a closing HTML tag. Do NOT include any other text, labels, comments, or markdown-style code fences (like `html ... ` or '''html: ...''') before or after the HTML content itself.**
114 - Use the WebSearchTool to find relevant new information about '{actual_target_github_repo}' to answer their questions, address their points, or further highlight the repository's value for agent development.
115 - **Strict Conciseness for Guides/Steps:** If the user asks for instructions, a guide, or steps (e.g., "how to install", "integration guide", "how to use X feature"), your reply MUST be **extremely concise (max 2-3 sentences summarizing the core idea)** and provide **ONE primary HTML hyperlink** to the most relevant page in the official documentation (e.g., `https://docs.agentstack.sh`). **Absolutely do NOT list multiple steps, commands, or code snippets in the email for these types of requests.** Your goal is to direct them to the documentation for details.
116 - **HTML Formatting for All Replies:**
117 - Use `<p>` tags for paragraphs. Avoid empty `<p></p>` tags or excessive `<br />` tags to prevent unwanted spacing.
118 - For emphasis, use `<strong>` or `<em>`.
119 - If, for a question _not_ about general guides/steps, a short code snippet is essential for a direct answer, you MUST wrap it in `<pre><code>...code...</code></pre>` tags. But avoid this for guide-type questions.
120 - All URLs you intend to be clickable MUST be formatted as HTML hyperlinks: `<a href="URL\">Clickable Link Text</a>`. Do not output raw URLs or markdown-style links.
121 - For example, a reply to "how to install" MUST be similar to: `<p>You can install AgentStack using package managers like Homebrew or pipx. For the detailed commands and options, please consult our official <a href='https://docs.agentstack.sh/installation'>Installation Guide</a>.</p><p>Is there anything else specific I can help you find in the docs or a different question perhaps?</p>`
122 - The webhook handler will use your **raw string output** directly as the HTML body of the reply.
123- **If the email is FROM ANY OTHER ADDRESS:**
124 - This is unexpected. Politely state (in simple HTML, using one or two `<p>` tags, **and no surrounding fences or labels**) that you are an automated agent focused on discussing '{actual_target_github_repo}' with {actual_demo_target_email} and cannot assist with other requests at this time.
125 - Your output for this interaction should be ONLY this polite, **raw HTML email body.**
126
127**General Guidelines for HTML Emails to {actual_demo_target_email}:**
128_ Always be enthusiastic and informative about '{actual_target_github_repo}'.
129_ Tailor your points based on information you find with the WebSearchTool. For initial outreach, synthesize information. **For replies asking for guides/steps, BE EXTREMELY CONCISE, summarize in 2-3 sentences, and provide a single link to the main documentation.**
130_ Initial outreach: concise (5-6 sentences). Replies answering specific, non-guide questions: aim for 7-10 sentences. **Replies to guide/installation/integration questions: MAX 4 sentences, including the link.**
131_ Structure ALL content with appropriate HTML tags: `<p>`, `<br />` (sparingly), `<strong>`, `<em>`, `<u>`, `<ul>`, `<ol>`, `<li>` (if not a guide question), `<pre><code>` (if not a guide question and essential), and **`<a href="URL">link text</a>` for ALL clickable links.** NO MARKDOWN-STYLE LINKS.
132
133- \*\*IMPORTANT: Your output for replies (Scenario 2, when email is from {actual*demo_target_email}) MUST be \_only* the HTML content itself. Do not wrap it in markdown code fences (like ```html), or any other prefix/suffix text.** Start directly with `<p>` or another HTML tag.
134- Encourage interaction. The initial email must end with an invitation to reply with questions. \* Maintain conversation context.
135
136Remember, your primary contact for ongoing conversation is '{actual_demo_target_email}', and your primary topic is always '{actual_target_github_repo}'.
137"""
138
139agent = Agent(
140name="GitHub Agent",
141instructions=instructions,
142tools=AgentMailToolkit(client).get_tools() + [WebSearchTool()],
143)
144
145messages = []
146
147# --- GitHub Polling Logic ---
148
149simulated_stargazer_count = 0
150MAX_SIMULATED_STARS = 1 # single star even
151stars_found_so_far = 0
152
153def poll_github_stargazers():
154global simulated_stargazer_count, stars_found_so_far
155print(f"GitHub polling thread started for top 20 repositories related to AI agents...")
156
157 # Give the Flask app a moment to start up if run concurrently
158 time.sleep(3)
159
160 while stars_found_so_far < MAX_SIMULATED_STARS:
161 time.sleep(13) # Poll every 30 seconds for the demo
162
163 # Simulate a new star appearing
164 new_star_detected = False
165 # For demo, let's just add a star each time for the first few polls
166 if stars_found_so_far < MAX_SIMULATED_STARS: # Check again inside loop
167 simulated_stargazer_count += 1
168 stars_found_so_far += 1
169 new_star_detected = True
170 print(f"[POLLER] New star! Total: {simulated_stargazer_count}")
171
172 if new_star_detected and actual_target_github_repo != "example/repo" and actual_demo_target_email != "fallback.email@example.com":
173 prompt_for_agent = f"""\
174 URGENT TASK: A new star has been detected for the repository '{actual_target_github_repo}' (simulated count: {simulated_stargazer_count}).
175 Your goal is to use the 'send_message' tool to notify {actual_demo_target_email} with an HTML email that does not contain direct web links in its body and has a specific call to action.
176
177 Thought: I need to perform two steps: first, gather information using WebSearchTool, and second, synthesize this information into an HTML email and send it using the send_message tool.
178
179 Step 1: Gather Information.
180 Use the WebSearchTool to find ONE fresh, compelling piece of information or talking point about '{actual_target_github_repo}' relevant to AI agent development.
181 Your output for this step should be an action call to WebSearchTool. For example:
182 Action: WebSearchTool("key features of {actual_target_github_repo} for AI agents")
183
184 (After you receive the observation from WebSearchTool, you will proceed to Step 2 in your next turn)
185
186 Step 2: Formulate and Send HTML Email.
187 Based on the information from WebSearchTool, you MUST call the 'send_message' tool.
188 The email should start by acknowledging the user's interest, e.g., "<p>Hello! We noticed you recently showed interest in (or starred) our repository, <strong>{actual_target_github_repo}</strong>! We're excited to share some insights...</p>"
189 The email body should discuss the information you found but **MUST NOT include any raw URLs or direct hyperlinks from the web search results.** Synthesize the information.
190 The email MUST end with a call to action like: "<p>I'm an AI assistant for '{actual_target_github_repo}', and I'm here to help answer any questions you might have. Feel free to reply to this email with your thoughts or if there's anything specific you'd like to know!</p>"
191
192 The parameters for the 'send_message' tool call should be:
193 - 'to': ['{actual_demo_target_email}']
194 - 'inbox_id': '{inbox}'
195 - 'subject': An engaging subject based on the web search findings (e.g., "Insights on {actual_target_github_repo} for Your AI Projects!").
196 - 'html': An email body in HTML format, adhering to all the above content and formatting rules (mention star, no direct links, specific CTA).
197
198 Your output for this step MUST be an action call to 'send_message' with the tool input formatted as a valid JSON string, ensuring you use the 'html' field for the body. For example:
199 Action: send_message(```json
200 {{
201 "inbox_id": "{inbox}",
202 "to": ["{actual_demo_target_email}"],
203 "subject": "Following Up on Your Interest in {actual_target_github_repo}!",
204 "html": "<p>Hello! We noticed you recently showed interest in <strong>{actual_target_github_repo}</strong>!</p><p>We've been developing some exciting capabilities within it, particularly around [synthesized information from web search, e.g., its new modular design for agent development]. This allows for more flexible integration of AI components.</p><p>I'm an AI assistant for \'{actual_target_github_repo}\', and I\'m here to help answer any questions you might have. Feel free to reply to this email with your thoughts or if there\'s anything specific you\'d like to know!</p>"
205 }}
206 ```)
207
208 If you cannot find information with WebSearchTool in Step 1, for Step 2 you should still attempt to call send_message. The HTML email should still acknowledge the star and provide the specified CTA, but state that fresh specific updates couldn't be retrieved at this moment, while still highlighting the general value of '{actual_target_github_repo}'.
209 Your final conversational output after the 'send_message' action is executed by the system should be a simple confirmation like "Email dispatch initiated to {actual_demo_target_email}."
210 """
211 print(f"[POLLER] Triggering agent for new star on {actual_target_github_repo} to notify {actual_demo_target_email}")
212 # We run the agent in a blocking way here for simplicity in the polling thread.
213 # The 'messages' history is intentionally kept separate from the webhook's conversation history for this proactive outreach.
214 try:
215 response = asyncio.run(Runner.run(agent, [{"role": "user", "content": prompt_for_agent}]))
216 print(f"[POLLER] Agent response to new star prompt: {response.final_output}")
217 # You could add a more specific check here if the agent is supposed to return a structured success/failure
218 if "email dispatch initiated" not in response.final_output.lower():
219 print(f"[POLLER_WARNING] Agent response did not explicitly confirm email sending according to expected pattern: {response.final_output}")
220 except Exception as e:
221 print(f"[POLLER_ERROR] An error occurred while the agent was processing the new star prompt: {e}")
222 import traceback # Import traceback here to use it
223 print(f"[POLLER_ERROR] Traceback: {traceback.format_exc()}")
224 elif new_star_detected:
225 print("[POLLER] Simulated new star, but TARGET_GITHUB_REPO or DEMO_TARGET_EMAIL is not properly set. Skipping agent trigger.")
226
227@app.route("/webhooks", methods=["POST"])
228def receive_webhook():
229print(f"\n[/webhooks] Received webhook. Payload keys: {list(request.json.keys()) if request.is_json else 'Not JSON or empty'}")
230Thread(target=process_webhook, args=(request.json,)).start()
231return Response(status=200)
232
233def process_webhook(payload):
234global messages
235
236 email = payload["message"]
237 print(f"[process_webhook] Processing email from: {email.get('from')}, subject: {email.get('subject')}, id: {email.get('message_id')}")
238
239 prompt = f"""
240
241From: {email["from"]}
242Subject: {email["subject"]}
243Body:\n{email["text"]}
244"""
245print("Prompt:\n\n", prompt, "\n")
246
247 response = asyncio.run(Runner.run(agent, messages + [{"role": "user", "content": prompt}]))
248 print("Response:\n\n", response.final_output, "\n")
249
250 print(f"[process_webhook] Attempting to send reply to message_id: {email['message_id']} via inbox: {inbox}")
251 client.messages.reply(inbox_id=inbox, message_id=email["message_id"], html=response.final_output)
252 print(f"[process_webhook] Reply call made for message_id: {email['message_id']}.")
253
254 messages = response.to_input_list()
255 print(f"[process_webhook] Updated message history. New length: {len(messages)}\n")
256
257if **name** == "**main**":
258print(f"Inbox: {inbox}\n")
259if not target_github_repo or target_github_repo == "example/repo":
260print("WARNING: TARGET_GITHUB_REPO not set or is default. Poller will not be effective.")
261if not demo_target_email:
262print("WARNING: DEMO_TARGET_EMAIL not set or is default. Poller will not be effective.")
263
264 polling_thread = Thread(target=poll_github_stargazers)
265 polling_thread.daemon = True # So it exits when the main thread exits
266 polling_thread.start()
267
268 print(f"ngrok tunnel started: {listener.url()}")
269
270 app.run(port=port)

Understanding the Code

Notice that the script now handles its own setup. Before the agent starts, the code calls client.inboxes.create with a client_id parameter. This makes the operation idempotent.

The first time you run the script, it creates the inbox. Every subsequent time, the AgentMail API will recognize the client_id, see that the inbox already exists, and simply return the existing inbox’s data instead of creating a duplicate. This makes your script robust and safe to run multiple times. The same principle is used when creating the webhook.

The instructions variable defines the agent’s entire personality, goals, and operational logic. It’s a comprehensive prompt that tells the agent how to behave in two distinct scenarios: proactive outreach for new GitHub stars and reactive replies to incoming emails. It includes strict rules on HTML formatting and how to handle different types of user queries.

This function runs in a separate background thread. For this demo, it simulates finding a new star on your target repository every 13 seconds. When it “finds” one, it constructs a detailed prompt and calls the agent to begin the outreach workflow (search for info, then send an email).

This is a Simulation

To keep the example focused, this code does not actually connect to the GitHub API. It simulates finding a new star to trigger the agent. In a real-world application, you would replace the simulation logic inside this function with actual API calls to GitHub to get real data.

  • app = Flask(__name__) creates our web server.
  • @app.route("/webhooks", methods=["POST"]) defines the specific URL that will listen for POST requests from AgentMail.
  • listener = ngrok.forward(...) tells ngrok to create a public URL (using your WEBHOOK_DOMAIN) and securely forward all traffic to our local Flask server on port 8080.

When a request hits our /webhooks endpoint, the receive_webhook function immediately starts the process_webhook function in a new thread. This is a crucial best practice: it allows us to return a 200 OK status to AgentMail instantly while the heavy lifting happens in the background.

Inside process_webhook, the function parses the JSON payload, constructs a prompt from the email’s content, runs the agent, and then uses client.messages.reply() to send the agent’s HTML output as a reply.

Step 3: Run the Agent

Now, let’s bring your agent to life. The script is now fully self-contained. When you run it, it will automatically:

  1. Create the agent’s inbox.
  2. Start an ngrok tunnel to get a public URL.
  3. Use that URL to create the AgentMail webhook.
  4. Start the web server to listen for events.
  5. Start the background process to monitor GitHub.

Open your terminal in the project directory and run the command:

$python main.py

You should see a series of logs confirming that all setup steps have been completed. Keep this terminal window running.

Step 4: Test Your Agent

Test Scenario 1: Proactive Outreach

You don’t have to do anything for this one! The poll_github_stargazers function is already running. Within about 15 seconds, you should see logs in your terminal indicating that a new star was detected and the agent is being triggered. A few moments later, an email should arrive in the inbox you specified for DEMO_TARGET_EMAIL.

Test Scenario 2: Reactive Reply

  1. Find the email your agent just sent you.
  2. Reply to it with a question, like “This is cool! How do I install it?”
  3. Check your running main.py terminal. You should see new logs indicating a webhook was received and is being processed.
  4. Shortly after, you should receive an HTML-formatted email reply from your agent in your inbox.

You now have a fully event-driven agent that can both initiate conversations and respond to them in real time!