On this page
- What you're going to build
- Prerequisites and real cost
- Step 1: Base project structure
- Step 2: Connect MCP servers
- Step 3: Python scripts for IMAP and Keychain
- Step 4: Telegram bot with persistent session
- Step 5: Scheduled briefings with launchd
- Step 6: Slash commands /morning, /evening, /inbox, /reply
- Step 7: Test the setup end-to-end
- Variants and scaling: Linux, Windows, and running 24/7
- Frequently asked questions
- Conclusion: 60 minutes between you and your first operational PA
By the Kiwop team · Digital agency specialized in Software Development and Applied Artificial Intelligence for global clients in Europe and the US · Published on April 19, 2026 · Last updated: April 19, 2026
TL;DR — Step-by-step tutorial to build your personal AI assistant on Claude Code MCP as the base stack: four channels (IMAP, Gmail, Slack, Telegram), automatic briefings at 08:00 and 19:00, a bidirectional bot, and slash commands. Around 500 lines of Python, zero paid dependencies beyond the Claude Max x20 subscription. Full setup in 60–90 minutes following the steps.
Want us to do it for you? See our AI Consulting service

This is the second article in the series about Kiwop's PA. The first, From OpenClaw to a PA with Claude Code and MCP, explains why we built it after Anthropic's policy change on April 4, 2026. This tutorial shows how we built it, with the real code we've had in production for two weeks. When you're done, you'll have a working personal AI assistant on your machine, with the same architecture we use daily at the agency. The reference repository is at https://github.com/purroy/majordomo.
It's not a toy demo. It processes production email, a real calendar, Telegram messages from the phone, and briefings that land at 08:00 and 19:00 without human intervention. The philosophy is deliberately minimalist: each piece does one thing, the glue between pieces is Unix, and secrets never touch disk in plain text.
What you're going to build
By the end of the tutorial, your PA will have four operational channels and a deliberately simple architecture.
The four active channels of the PA are:
- Own IMAP/SMTP email: for your domain's corporate mailbox (
mail.yourcompany.com), read with Python scripts using Python's standard library. Reading, triage, drafting and sending with explicit confirmation. - Google Workspace (Gmail + Calendar + Drive): via the official MCP servers — you can add similar ones like Outlook/Microsoft 365 if your organization uses them.
- Slack: DMs and mentions, via Slack's official MCP server for Claude.
- Telegram: a bidirectional bot that is both an output channel (briefings) and an input channel (you talk to the PA from your phone).
The technical stack has four layers. Claude Code as the conversational engine. MCP connectors for everything that has an official one (Google + Slack). Custom Python scripts for the gaps MCP doesn't cover (IMAP and Telegram). And launchd (on macOS) or systemd/Task Scheduler (on Linux/Windows) for startup and scheduled triggers.
The real marginal cost is zero on top of the Claude Max x20 subscription you're already paying for development. The ~500 lines of Python are yours — no installable dependencies, just the standard library. There's no backend, no database, no Docker. All persistent state lives in local files and in the operating system's keychain.
At a high level, the architecture follows three principles worth making explicit before we dive into the code. The first is that Claude Code is the engine but isn't the owner of the state: your prompts are in versionable Markdown files in your own repository, your secrets are in the system keychain, your briefings are files on disk. If tomorrow you wanted to switch engines, you'd just have to replace the calls to claude --print with another equivalent CLI. The second is that MCP does the heavy lifting when there's an official server (Google, Slack) and you write code only when there isn't (IMAP, Telegram). The third is that security lives in three independent layers — MCP OAuth, macOS Keychain, and the confirmation rule in prompts — so that compromising one doesn't compromise the system.
Prerequisites and real cost
Before starting, check you have what's needed. The tutorial assumes macOS because that's what we use, but there's a section at the end on how to port it to Linux or Windows.
Account and subscription requirements:
- Anthropic Claude Max x20: the "for power users" subscription, about $200/month. Claude Code is included and is the PA's engine.
- Google Workspace account with permissions to authorize Gmail, Calendar and Drive via OAuth.
- Slack workspace where you have an account (you don't need to be admin for the MCP to read your own DMs and mentions).
- An IMAP email domain if your main inbox isn't Gmail (optional — if all your email is Gmail, skip Step 3).
- Telegram account (free) to create the bot via
@BotFather.
System requirements:
- macOS 14+ with Homebrew installed (for the tutorial's base flow).
- Python 3.11+ (comes with recent macOS; if not,
brew install [email protected]). - Claude Code CLI installed:
npm i -g @anthropic-ai/claude-codeor viabrew. - Terminal with Full Disk Access permissions if you want
launchdto run briefings without asking for authorization repeatedly.
Projected monthly cost, honest breakdown:
| Item | Cost | Notes |
|---|---|---|
| Claude Max x20 | ~$200/month | Covers dev + PA + briefings within the plan |
| MCP servers (Google, Slack) | $0 | Official, free |
| Telegram Bot API | $0 | Free up to industrial volumes |
| Telegram bot hosting | $0 | Runs on your Mac, no VPS |
| Domain + IMAP | whatever you already pay | You reuse your existing mailbox |
| **Total marginal on top of Claude Max** | **$0/month** | — |
The key point: the marginal cost of the PA is zero if you already have Claude Max. If you don't have it and would buy it just for the PA, the math changes — you'd have to weigh whether the time savings justify $200/month. For anyone who already uses Claude Code in development, the answer is usually yes trivially.
The estimated time to complete the tutorial from scratch, counting OAuths and inevitable debugging, is 60 to 90 minutes. If you only want the first three channels (no Telegram or scheduled briefings), you drop to 30–40 minutes.
Step 1: Base project structure
The PA lives in its own directory outside any other project. The first decision is where to put it. We use ~/Dev/PA by convention — swap it if you prefer another path. The complete reference repository is at https://github.com/purroy/majordomo — use it if you get lost at any step.
mkdir -p ~/Dev/PA
cd ~/Dev/PA
git init
mkdir -p scripts .claude/commands briefings drafts scripts/launchd
touch CLAUDE.md README.md .gitignoreThe .gitignore should exclude anything with personal data — briefings, email drafts, bot state:
briefings/
drafts/
.telegram_state.json
.mail_watch_state.json
__pycache__/
*.pyc
.env
.env.localNow the most important file in the repo: CLAUDE.md. It defines the assistant's persona, default language, and the confirmation rule that is the heart of PA security.
# PA — Personal Assistant
You are [your name]'s personal assistant. You work in this repo as a
real PA: you read my email, my calendar (Google Calendar), my Drive
and my Slack, and you help me prioritize, draft and keep my day under
control.
## Critical rules (non-negotiable)
1. **Explicit confirmation before sending email or creating/moving
events.**
- For email: ALWAYS write a draft first (without `--yes`) and show
it. Only run `mail_send.py --yes` when I clearly say "send",
"ok", "confirm", etc.
- For Calendar: show the proposed event (title, date, time,
attendees) and wait for confirmation before `create_event`.
- For Slack: show the proposed message before sending.
- One confirmation covers ONE action. Don't reuse a previous "ok"
for a new action.
2. **Don't invent contacts, dates or subjects.** If you don't have
the data, ask for it.
3. **Bilingual based on context.**
- Conversation with me → English by default.
- Email reply drafts → same language as the original.
4. **Privacy.** Don't send inbox/calendar information to external
tools without explicit permission.
## Tone
Professional but close. Short sentences. No emojis unless the
original uses them.The confirmation rule is non-negotiable. It's the difference between a PA you can leave in the hands of a language model and one that's going to send you an embarrassing email when it misreads a "yes" from two turns ago. We've tested it throughout March with not a single false positive.
Initial commit:
git add CLAUDE.md .gitignore README.md
git commit -m "PA: initial structure + CLAUDE.md"Step 2: Connect MCP servers
MCP ([Model Context Protocol](https://modelcontextprotocol.io/specification/2026-07-28)) is the open standard Anthropic published in late 2024 so that language models can connect to external tools in a uniform way. An MCP server is a process that exposes a set of functions (read emails, search files, send messages) through a common protocol; an MCP client (like Claude Code) starts it and consumes those functions as if they were its own tools. If you want to dive deeper into why MCP is the piece that unlocks AI agents in 2026, we have a broader analysis in WebMCP: your website ready for AI agents.
For the PA we enable four official MCP servers, all four published by Anthropic/Claude or by the providers themselves:
- Gmail MCP: reading, searching and composing emails.
- Google Calendar MCP: calendar, creating and modifying events.
- Google Drive MCP: searching and reading files.
- Slack MCP: reading DMs, channels, and sending with confirmation.
The easiest way to configure them is through the Claude Code interface. From the project root (~/Dev/PA), run:
claudeInside the interactive Claude Code session, add the MCP servers with the /mcp add command for each. The command will launch the corresponding OAuth flow in the browser — authorize with the account you want the PA to use, not necessarily your main personal account.
/mcp add claude-ai-Gmail
/mcp add claude-ai-Google-Calendar
/mcp add claude-ai-Google-Drive
/mcp add claude-ai-SlackSecurity of the OAuth flow. A key aspect: official MCP servers use browser OAuth, not credentials on disk. Your refresh_token is stored by the MCP server in its own encrypted stores, not by the PA. That means if someone steals your .claude/ or your repo, they don't get access to your Gmail — they'd also have to compromise the MCP server's store and, ultimately, your macOS Keychain. The attack surface stays bounded.
Once the four are authorized, verify that Claude Code detects them:
/mcp listYou should see the tools available grouped by service: mcp__claude_ai_Gmail__search, mcp__claude_ai_Google_Calendar__list_events, and so on. The naming follows the pattern mcp__<server>__<tool>.

At this point you can already ask the PA "what do I have today on the calendar?" or "find the contract with client X in Drive" and it works. What's missing are the two channels MCP doesn't cover well: corporate IMAP email and Telegram.
Step 3: Python scripts for IMAP and Keychain
If all your email is on Gmail, this step is optional. If, like us, the main inbox runs on your own IMAP server (mail.kiwop.com in our case), it's time to write the bridge.
The architecture of the scripts is deliberately small. An internal module _mail.py with the common logic (IMAP connection, parsing, SMTP), and three minimal commands the PA can invoke:
mail_fetch.py: list recent or unread UIDs as JSON.mail_read.py: read the full body of a UID (without marking it as read — usesBODY.PEEK).mail_send.py: send an email or save it as a draft.
Golden rule about credentials. Never save the IMAP password in a file in the repo or in a persistent environment variable like ~/.zshrc. The risk isn't theoretical: every time you share a script, make a commit with a tracked .env, or install a VS Code extension that reads your environment, you're exposing secrets. On macOS we have Keychain, an OS-encrypted keyring accessible via the security command. The scripts read the credential on the fly when they need it and never write it to disk.
First, save the credentials to Keychain:
security add-generic-password -U -a "$USER" -s "PA-mail-user" -w "[email protected]"
security add-generic-password -U -a "$USER" -s "PA-mail-pass" -w "YOUR_PASSWORD"
security add-generic-password -U -a "$USER" -s "PA-mail-imap-host" -w "mail.yourdomain.com"
security add-generic-password -U -a "$USER" -s "PA-mail-imap-port" -w "993"
security add-generic-password -U -a "$USER" -s "PA-mail-smtp-host" -w "mail.yourdomain.com"
security add-generic-password -U -a "$USER" -s "PA-mail-smtp-port" -w "587"The usual ports: 993 for IMAP over SSL and 587 for SMTP with STARTTLS. If your provider uses implicit SMTPS, change to 465.
Now the shell helper that reads and writes to Keychain. We call it scripts/keychain.sh:
#!/usr/bin/env bash
# Helpers to read/write PA secrets in macOS Keychain.
set -euo pipefail
pa_kc_get() {
local name="$1"
security find-generic-password -a "$USER" -s "PA-${name}" -w 2>/dev/null
}
pa_kc_set() {
local name="$1" value="$2"
security add-generic-password -U -a "$USER" -s "PA-${name}" -w "${value}"
}
pa_kc_has() {
local name="$1"
security find-generic-password -a "$USER" -s "PA-${name}" >/dev/null 2>&1
}
# CLI usage: scripts/keychain.sh get mail-user
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
cmd="${1:-}"; name="${2:-}"; value="${3:-}"
case "$cmd" in
get) pa_kc_get "$name" ;;
set) pa_kc_set "$name" "$value" ;;
has) pa_kc_has "$name" && echo "yes" || echo "no" ;;
*) echo "usage: $0 {get|set|has} <name> [value]" >&2; exit 2 ;;
esac
fiThe Python equivalent uses subprocess to invoke security:
# scripts/_mail.py (fragment)
import subprocess
def get_secret(name: str) -> str:
"""Read PA-<name> from Keychain. Fallback to env var PA_<NAME>."""
env_name = "PA_" + name.upper().replace("-", "_")
if env_val := os.environ.get(env_name):
return env_val
out = subprocess.run(
["security", "find-generic-password",
"-a", os.environ["USER"], "-s", f"PA-{name}", "-w"],
capture_output=True, text=True, check=True,
)
return out.stdout.strip()The environment variable fallback is deliberate: when you port this to Linux (systemd), security doesn't exist there and you'll use env vars exported from the unit file, read from a file with 600 permissions.
With Keychain connected, mail_fetch.py is clear. It's a CLI that prints JSON so Claude Code can parse it easily:
#!/usr/bin/env python3
"""List recent / unread mail as JSON.
Usage:
mail_fetch.py [--folder INBOX] [--unread] [--since 24h|7d] [--limit 20]
"""
from __future__ import annotations
import argparse, json, sys
from _mail import (
fetch_envelope, imap_connect, MailConfig, search_uids,
select_folder, snippet, text_body, fetch_message, parse_since,
)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--folder", default="INBOX")
ap.add_argument("--unread", action="store_true")
ap.add_argument("--since", default=None, help='e.g. 24h, 7d')
ap.add_argument("--limit", type=int, default=20)
ap.add_argument("--with-body", action="store_true")
args = ap.parse_args()
conn = imap_connect(MailConfig.load())
try:
select_folder(conn, args.folder)
uids = search_uids(
conn, unread=args.unread,
since=parse_since(args.since) if args.since else None,
)
uids = uids[-args.limit:][::-1]
envs = fetch_envelope(conn, uids)
out = []
for uid in uids:
msg = envs.get(uid)
if not msg:
continue
item = {
"uid": uid,
"from": str(msg.get("From", "")),
"subject": str(msg.get("Subject", "")),
"date": str(msg.get("Date", "")),
}
if args.with_body:
full = fetch_message(conn, uid)
item["snippet"] = snippet(text_body(full), 200)
out.append(item)
json.dump(out, sys.stdout, ensure_ascii=False, indent=2)
finally:
try: conn.logout()
except Exception: pass
return 0
if __name__ == "__main__":
sys.exit(main())mail_send.py adds an important safeguard: without the --yes flag, it saves the message as .eml in drafts/ and doesn't send it. Only with an explicit --yes does it go out over SMTP. This enforces the CLAUDE.md confirmation rule at the code level, not just at the prompt level — even if the model skipped the rule, the script wouldn't send.
Critical detail about `BODY.PEEK`. When the PA "reads" an email to triage it, it must not mark it as read. If you mark it, you lose your pending tray as a visual signal in the email client. The IMAP flag that sets \Seen is BODY[]; the one that doesn't is BODY.PEEK[]. Always use it in mail_read.py.
Step 4: Telegram bot with persistent session
The Telegram bot is the most interesting piece of the PA because it inverts the flow: instead of you opening a terminal and typing to Claude, Claude receives your messages from your phone.
The plan: a Python daemon that does long polling against the Telegram API (no need for a public webhook), and forwards each message it receives to claude --print with a session run inside the PA repo. Claude executes the prompt with all its tools (MCP + Python scripts) and returns the answer. The daemon formats it for Telegram and sends it back to the chat.
Creating the bot takes two minutes. Open Telegram, talk to @BotFather, run /newbot, give it a name and a username. It returns a token like 123456:ABC-XYZ_.... Save it in Keychain and then detect your chat_id (the ID of the 1-on-1 conversation between you and the bot):
security add-generic-password -U -a "$USER" \
-s "PA-telegram-bot-token" -w "YOUR_TOKEN_HERE"
# Now open Telegram, send "hi" to your bot, and run:
TOKEN=$(security find-generic-password -a "$USER" \
-s "PA-telegram-bot-token" -w)
curl -s "https://api.telegram.org/bot${TOKEN}/getUpdates" \
| python3 -c "import json,sys; d=json.load(sys.stdin); \
print(d['result'][-1]['message']['chat']['id'])"
security add-generic-password -U -a "$USER" \
-s "PA-telegram-chat-id" -w "YOUR_CHAT_ID"Now the daemon. The skeleton of telegram_bot.py is this:
#!/usr/bin/env python3
"""PA Telegram bot — long-poll forwarding messages to `claude -p`."""
from __future__ import annotations
import json, os, subprocess, sys, time, uuid
import urllib.parse, urllib.request
from pathlib import Path
REPO_DIR = Path(__file__).resolve().parent.parent
STATE = REPO_DIR / ".telegram_state.json"
LOG_DIR = REPO_DIR / "briefings"
LOG = LOG_DIR / "telegram_bot.log"
sys.path.insert(0, str(REPO_DIR / "scripts"))
from _mail import get_secret
POLL_TIMEOUT_S = 50
CLAUDE_TIMEOUT_S = 600
CHUNK = 4000 # Telegram hard limit is 4096
def tg(token, method, params=None, timeout=30):
url = f"https://api.telegram.org/bot{token}/{method}"
if params:
body = urllib.parse.urlencode(params).encode()
req = urllib.request.Request(url, data=body)
else:
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.load(r)
def send_message(token, chat_id, text):
chunks = [text[i:i + CHUNK] for i in range(0, len(text), CHUNK)] or [""]
for ch in chunks:
tg(token, "sendMessage", {
"chat_id": chat_id, "text": ch,
"disable_web_page_preview": "true",
})
def ask_claude(prompt: str) -> str:
"""Invoke claude --print with a fresh session per message."""
cmd = [
"claude", "--print",
"--model", "sonnet",
"--session-id", str(uuid.uuid4()),
"--setting-sources", "project,user",
"--output-format", "text",
"--permission-mode", "bypassPermissions",
prompt,
]
out = subprocess.run(
cmd, cwd=REPO_DIR, capture_output=True, text=True,
timeout=CLAUDE_TIMEOUT_S,
)
return (out.stdout or "").strip() or "(no response)"
def main() -> int:
token = get_secret("telegram-bot-token")
allowed_chat = int(get_secret("telegram-chat-id"))
offset = 0
while True:
resp = tg(token, "getUpdates", {
"offset": offset + 1,
"timeout": POLL_TIMEOUT_S,
"allowed_updates": json.dumps(["message"]),
}, timeout=POLL_TIMEOUT_S + 15)
for update in resp.get("result", []):
offset = update["update_id"]
msg = update.get("message") or {}
chat_id = msg.get("chat", {}).get("id")
text = (msg.get("text") or "").strip()
if chat_id != allowed_chat:
continue # drop messages from unauthorized chats
if text == "/ping":
send_message(token, chat_id, "pong")
continue
if text == "/reset":
send_message(token, chat_id, "OK, session reset.")
continue
reply = ask_claude(text)
send_message(token, chat_id, reply)
if __name__ == "__main__":
sys.exit(main())Three non-obvious details in the design.
First, `chat_id` whitelist: the allowed_chat variable is your own chat and any message from another chat_id is silently dropped. If someone discovers your bot's username and sends it messages, it doesn't reply. It's the simplest but most effective barrier against abuse.
Second, `--permission-mode bypassPermissions`: the daemon runs without asking for confirmation to execute repo scripts because there's no human in front of it who can respond. Security doesn't come from Claude Code prompts, it comes from (a) the daemon only replying to the whitelisted chat_id, (b) the PA having the confirmation rule in CLAUDE.md, and (c) sensitive scripts like mail_send.py requiring --yes at the code level.
Third, ephemeral sessions per message. Each ask_claude generates a new session-id with uuid.uuid4(). That means the bot doesn't keep conversational memory between messages — each request starts fresh with your CLAUDE.md and your commands loaded. "Long" memory (your preferences, signature, priority contacts) lives in memory/ as files Claude reads when starting each session, not in the session itself. This avoids accumulating stale context and makes behavior very predictable.
Telegram-compatible Markdown. Telegram doesn't render GitHub-style Markdown (it doesn't understand tables, it doesn't understand links with brackets in many contexts, it misinterprets certain characters). Instead of fighting parse_mode: MarkdownV2 — which forces you to escape all special characters and breaks easily — we use a small readable plain-text converter (md_to_telegram.py) that removes Markdown syntax while preserving structure.
To test it before launchd:
cd ~/Dev/PA
python3 scripts/telegram_bot.pyOpen Telegram, type /ping to your bot. It should reply pong. Then send "what time is it?" — it should answer with the current time querying the system via a Claude session. Congratulations: you now have a PA accessible from your phone.

Step 5: Scheduled briefings with launchd
[`launchd`](https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html) is the process and scheduled task manager on macOS. It's the equivalent of cron + systemd combined into a single system. Each task is defined in an XML file (.plist) that tells macOS what to run, when, and what to do if it fails.
We're going to create three launchd jobs:
- Telegram bot as a permanent daemon (
KeepAlive: true). - Morning briefing every day at 08:00.
- Evening briefing every day at 19:00.
First the wrapper scripts/run_briefing.sh that launches Claude Code in non-interactive mode to generate the briefing:
#!/usr/bin/env bash
# Generates a PA briefing (morning|evening) by calling claude -p.
set -euo pipefail
KIND="${1:-morning}"
case "$KIND" in morning|evening) ;;
*) echo "usage: $0 {morning|evening}" >&2; exit 2 ;;
esac
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
DATE="$(date +%F)"
OUT_DIR="$REPO_DIR/briefings"
OUT_FILE="$OUT_DIR/${DATE}-${KIND}.md"
mkdir -p "$OUT_DIR"
cd "$REPO_DIR"
# claude -p: print mode (non-interactive)
# --setting-sources project,user: loads CLAUDE.md and .claude/settings.json
# --permission-mode bypassPermissions: cron can't answer prompts
claude -p "/$KIND" \
--setting-sources project,user \
--output-format text \
--permission-mode bypassPermissions \
> "$OUT_FILE" 2> "$OUT_FILE.err"
# Push the result to Telegram if configured
if security find-generic-password -a "$USER" \
-s "PA-telegram-bot-token" >/dev/null 2>&1; then
TOKEN=$(security find-generic-password -a "$USER" \
-s "PA-telegram-bot-token" -w)
CHAT=$(security find-generic-password -a "$USER" \
-s "PA-telegram-chat-id" -w)
BODY=$(python3 "$REPO_DIR/scripts/md_to_telegram.py" < "$OUT_FILE")
curl -s -o /dev/null \
-d "chat_id=${CHAT}" --data-urlencode "text=${BODY}" \
"https://api.telegram.org/bot${TOKEN}/sendMessage"
fi
echo "OK $OUT_FILE"Now the .plist for the morning briefing. Save it to scripts/launchd/com.kiwop.pa-briefing-morning.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.kiwop.pa-briefing-morning</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/YOUR_USER/Dev/PA/scripts/run_briefing.sh</string>
<string>morning</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/YOUR_USER/Dev/PA</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/Users/YOUR_USER/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
<key>USER</key>
<string>YOUR_USER</string>
<key>HOME</key>
<string>/Users/YOUR_USER</string>
</dict>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>8</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<key>RunAtLoad</key>
<false/>
<key>StandardOutPath</key>
<string>/Users/YOUR_USER/Dev/PA/briefings/cron-morning.out.log</string>
<key>StandardErrorPath</key>
<string>/Users/YOUR_USER/Dev/PA/briefings/cron-morning.err.log</string>
</dict>
</plist>Important key: the explicit `PATH` in EnvironmentVariables. launchd does not inherit your shell PATH, so if you don't pass it in, claude won't be available and the briefing will fail silently. Include /opt/homebrew/bin (Apple Silicon), /usr/local/bin (Intel) and ~/.local/bin (if you installed Claude Code via pip --user).
Duplicate the file for the evening by swapping morning for evening and the hour to 19. And create the one for the Telegram bot:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.kiwop.pa-telegram</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/python3</string>
<string>/Users/YOUR_USER/Dev/PA/scripts/telegram_bot.py</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/YOUR_USER/Dev/PA</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>15</integer>
<key>StandardOutPath</key>
<string>/Users/YOUR_USER/Dev/PA/briefings/telegram_bot.out.log</string>
<key>StandardErrorPath</key>
<string>/Users/YOUR_USER/Dev/PA/briefings/telegram_bot.err.log</string>
</dict>
</plist>The trio RunAtLoad: true + KeepAlive: true + ThrottleInterval: 15 means: start at login, restart if it crashes, and don't try to restart faster than every 15 seconds (to avoid failure loops).
To activate the three .plist files:
mkdir -p ~/Library/LaunchAgents
cp scripts/launchd/com.kiwop.pa-briefing-morning.plist ~/Library/LaunchAgents/
cp scripts/launchd/com.kiwop.pa-briefing-evening.plist ~/Library/LaunchAgents/
cp scripts/launchd/com.kiwop.pa-telegram.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/com.kiwop.pa-briefing-morning.plist
launchctl load ~/Library/LaunchAgents/com.kiwop.pa-briefing-evening.plist
launchctl load ~/Library/LaunchAgents/com.kiwop.pa-telegram.plistTo verify they're active:
launchctl list | grep com.kiwop.paYou should see the three entries with a PID (for the bot, which runs continuously) or with - (for the briefings, which only run at their scheduled times).

To trigger a briefing manually without waiting for 08:00 (useful for testing):
launchctl kickstart -k gui/$(id -u)/com.kiwop.pa-briefing-morningStep 6: Slash commands /morning, /evening, /inbox, /reply
Claude Code slash commands live in .claude/commands/ as Markdown files. Each file is a prompt template that Claude runs when you invoke /name in the session. They're the cleanest way to encapsulate repetitive flows without repeating the same long prompt each time.
For the PA we define four minimal commands: /morning, /evening, /inbox, /reply. This is the example of .claude/commands/morning.md:
---
description: Morning briefing — calendar + email triage + Slack + priorities
---
Prepare a morning briefing in English. Clear structure, short
sentences, actionable.
## Step 1: gather in parallel
1. **Today's calendar** (Calendar MCP, `list_events`): timezone
Europe/Madrid.
2. **Unread email from the last 24h** (list of UIDs):
```
python3 scripts/mail_fetch.py --unread --since 24h --limit 30 --with-body
```
3. **Slack**: messages received in the last 24h via MCP.
## Step 2: email triage (without marking as read)
For each UID, if the snippet makes clear it's noise, don't open it.
Otherwise, read the body with `mail_read.py UID`. Classify:
- FIRE: site down, angry client, deadline <24h.
- IMPORTANT: concrete question, pre-sale, invoice/contract.
- To review: informational.
- Noise: group together.
## Step 3: output
```markdown
# Good morning — <date>
## Today's calendar
- HH:MM–HH:MM · Title · (location / attendees)
## Email (N unread)
FIRE: [UID] Sender — Subject · context
IMPORTANT: [UID] ...
To review: [UID] ...
Noise: N items
## Slack
FIRE: ...
IMPORTANT: ...
## Suggested priorities
1. ...
2. ...
3. ...
```
## Hard rules
- NEVER mark emails as read.
- Read with `mail_read.py` (BODY.PEEK), never with tools that set
`\Seen`.
- If a source fails, say so at the end of the briefing, don't abort.The /reply command is particularly important because it enforces the confirmation rule:
---
description: Draft an email reply (with mandatory confirmation)
---
Arguments: `$ARGUMENTS` should contain a UID.
## Steps
1. Read the original: `python3 scripts/mail_read.py <UID>`.
2. Draft the reply IN THE LANGUAGE OF THE ORIGINAL. Professional but
close tone, no pompous greetings, brief length.
3. Save the draft to `/tmp/pa_reply_<UID>.txt`.
4. Show it:
- Subject (Re: ...)
- Recipient
- Full body
- Question: "Send, edit, or cancel?"
5. Wait for explicit confirmation:
- "send"/"ok"/"yes" → run:
```
python3 scripts/mail_send.py \
--to "<from-original>" \
--in-reply-to <UID> \
--body-file /tmp/pa_reply_<UID>.txt \
--yes
```
- "edit ..." → adjust and go back to step 4.
- "cancel" → don't send.
## Hard rules
- NEVER call `mail_send.py --yes` without a clear "yes" in the
current turn.
- NEVER mark the original as read.Notice the pattern: the command enforces the confirmation rule at the prompt level (step 5), and the script enforces the rule at the execution level (the mandatory --yes in mail_send.py). Double layer. If the model "forgets" step 5, the script doesn't send. If someone modifies the script to bypass --yes, the prompt still requires confirmation. It's the same defense-in-depth philosophy we recommend on LLM integration projects for any action with irreversible effects.
The four base commands are enough for 90% of daily use. You can add more: /schedule (create meetings), /book (schedule from an invite), /auth (diagnose the state of the MCPs).

Step 7: Test the setup end-to-end
With everything in place, it's time for a full round of tests before calling it functional. This is the checklist we use at Kiwop every time we replicate the setup on another machine:
1. MCPs authenticated. In a Claude Code session from ~/Dev/PA:
/mcp listThe four servers must appear with available tools. If any says "not authenticated", repeat the OAuth.
2. IMAP email working. From the terminal:
cd ~/Dev/PA
python3 scripts/mail_fetch.py --unread --limit 3Should return a JSON with your three most recent unread emails. If it fails with imaplib.IMAP4.error, check Keychain and ports.
3. Manual briefing. From an interactive Claude Code session:
/morningGenerates a full briefing. Should not take longer than 60 seconds.
4. Bidirectional Telegram bot. From Telegram, to your bot:
/ping→pong(immediate)- "what do I have today on the calendar?" → answer with the real calendar (30–40 seconds)
5. launchd logs without errors.
ls -la ~/Dev/PA/briefings/*.log
tail -20 ~/Dev/PA/briefings/telegram_bot.err.logThe telegram_bot.err.log should have a line "Bot up. Allowed chat=...". If there's a FATAL: secret read failed, check that Keychain has the PA-telegram-bot-token and PA-telegram-chat-id items.
6. Cold test of the scheduled briefing.
launchctl kickstart -k gui/$(id -u)/com.kiwop.pa-briefing-morning
# Wait 60s and check:
cat ~/Dev/PA/briefings/$(date +%F)-morning.mdIt should have generated the day's briefing and pushed a copy to your Telegram. If Telegram doesn't receive it, check the PA-telegram-* credentials in Keychain.
Common problems and how to diagnose them:
| Symptom | Common cause | Fix | |
|---|---|---|---|
| `claude: command not found` in launchd logs | `PATH` not configured in `.plist` | Add `EnvironmentVariables.PATH` with `/opt/homebrew/bin` | |
| MCPs appear but without tools | Expired OAuth token | `/mcp add <server>` again to re-auth | |
| `mail_fetch.py` timeout | Corporate firewall blocks 993 | Try from another network, verify IMAP ports | |
| Bot doesn't answer `/ping` | Daemon doesn't start in `launchd` | `launchctl list \ | grep pa-telegram` + check `.err.log` |
| Briefings don't come out at 08:00 | Mac asleep at that time | `pmset` to schedule wake, or accept it only runs if you're awake |
Once it passes the six checklist points, the PA is ready for daily use. From here on, everything is refinement: add memory/ with your style preferences, add more slash commands based on the flows you detect as repetitive, tune the prompts of morning.md and evening.md to the exact output that you find most useful.
Practical advice on the first seven days. After setting it up, the PA is going to do suboptimal things until you discover what context about you it's missing. We recommend starting in "read-only" mode the first week: let it generate briefings, let it triage the inbox, but don't use /reply or confirm write actions yet. During that week you'll start spotting patterns to correct — "it misclassifies emails from this client because it doesn't know they're VIP", "it over-summarizes short Slack messages", "it marks as noise things that for me are important". All those corrections go to memory/triage_rules.md and to CLAUDE.md. From day seven or eight, when triage is consistent at 85–90%, you can open up write use with /reply and start gaining real time. Skipping this calibration phase is the most common mistake and the main cause of abandonment in our sample of people we've helped set it up.
Variants and scaling: Linux, Windows, and running 24/7
The tutorial is written on macOS because that's where we work, but the architecture is portable. The four main adjustments:
Process manager.
| Platform | launchd substitute | Notes |
|---|---|---|
| Linux | `systemd --user` units | See example below |
| Windows | Task Scheduler + service | More friction, viable |
| Remote server | `systemd` (system level) | The cleanest model for 24/7 |
A systemd unit for the Telegram bot on Linux would be roughly this (~/.config/systemd/user/pa-telegram.service):
[Unit]
Description=PA Telegram bot
After=network-online.target
[Service]
Type=simple
WorkingDirectory=%h/Dev/PA
Environment="PA_TELEGRAM_BOT_TOKEN=..."
Environment="PA_TELEGRAM_CHAT_ID=..."
ExecStart=/usr/bin/python3 scripts/telegram_bot.py
Restart=always
RestartSec=15
[Install]
WantedBy=default.targetActivation: systemctl --user daemon-reload && systemctl --user enable --now pa-telegram.service.
Secrets store.
| macOS | Linux | Windows |
|---|---|---|
| Keychain (`security`) | Secret Service (`secret-tool`) / libsecret | Credential Manager (`cmdkey`) |
The fallback to environment variables in a file with 600 permissions works on all three platforms and is the common denominator if you want a single codebase for all systems.
launchd vs systemd, honest comparison:
| Dimension | launchd (macOS) | systemd (Linux) |
|---|---|---|
| Format | XML `.plist` | Plain-text INI |
| Schedule by hour | `StartCalendarInterval` | Timer unit (`OnCalendar=`) |
| Persistent daemon | `KeepAlive: true` | `Restart=always` |
| Logs | `StandardOutPath` | `journalctl --user -u ...` |
| CLI usability | Verbose `launchctl` | Cleaner `systemctl` |
| OS integration | Very tight | Very tight |
Running it 24/7 on a remote server? It's viable and we've tested it. The pattern: a small Linux VPS with systemd running the Telegram bot and the scheduled briefings, plus an IMAP/SMTP connection to the mailbox, plus the Claude Code CLI authenticated with your Anthropic account. Pros: the PA replies even if your laptop is off. Cons: OAuth for MCP servers is more delicate without a local browser (you'll have to do the initial flow on your machine and copy the refresh token), and any update to the Claude Code CLI requires SSH and deploy. For most professionals who use the Mac daily, keeping it local is the option with the best simplicity/utility ratio.
The jump to a multi-user PA (one that serves a team) isn't a variant of the same tutorial — it's a SaaS and requires very different architectural decisions (multi-tenancy, authentication, session isolation). If you're interested in exploring that route, we address it in our AI agent development projects.
Two extensions we've seen work in practice once the base PA is stable. The first is a memory/ populated with Markdown files Claude loads in each session: a triage_rules.md with your exact criteria for classifying email, a writing_style.md with examples of emails written by you so drafts sound like your voice, a vip_contacts.md with the addresses that should never fall into "noise". The second is a background email watcher (a daemon similar to the Telegram bot, but instead of listening to Telegram it listens to your IMAP inbox) that fires a notification when an email classified as fire according to your rules arrives — useful for flows where the 08:00 briefing isn't enough. Both extensions are built with the same philosophy as the rest: short scripts, secrets in Keychain, ephemeral Claude Code sessions.
Frequently asked questions
How much does it actually cost to build this PA?
If you already have Claude Max x20 (about $200/month), the PA's marginal cost is zero. The MCP servers are free, the Telegram bot runs on your Mac without hosting, and the Python scripts don't require paid dependencies. If you don't have Claude Max, the math changes: the $200/month is the fixed investment. The build time following this tutorial is 60–90 minutes.
Is it safe to give email and calendar access to a PA built this way?
Yes, with the following explicit conditions. First, the official MCP servers authenticate via OAuth without saving your password to disk — the token lives in the MCP server's own encrypted store. Second, IMAP/SMTP credentials are in macOS Keychain (the OS keyring, encrypted), never in plain files. Third, the Telegram bot only responds to the whitelisted chat_id and the mandatory confirmation rule in CLAUDE.md prevents write actions without an explicit "yes". The attack surface is very bounded, comparable to any desktop email client.
What about data privacy? Does Anthropic train with my emails?
Anthropic publicly declares (policy as of 2026) that data sent via API and via Claude Code is not used to train models. For regulated content (healthcare, legal with professional secrecy, confidential financial), it's worth reviewing the specific terms in force and considering whether a PA on your own infrastructure is more appropriate — in which case an enterprise RAG solution may be the right alternative.
Can WhatsApp be integrated into this PA?
As of April 2026, there's no official MCP for WhatsApp. There are unofficial bridges (Matrix, Beeper, Meta's own WhatsApp Business Cloud API) that can be connected as an additional channel, but all have caveats: unofficial bridges may violate Meta's ToS and be unstable, and the Business Cloud API requires a verified commercial account. Our pragmatic recommendation is to use Telegram as the primary mobile channel.
Can this PA serve multiple users?
No, by design. A PA is single-user: its credentials, memory, preferences, and CLAUDE.md point to a single person. For multi-user scenarios (a team sharing a common assistant) the correct architecture is a multi-tenant SaaS with per-user data isolation — not a copy of the PA with shared variables. It's a totally different conversation.
Does it work without internet?
No. Claude Code requires a connection to query the model, the MCP servers use online APIs (Google, Slack, Telegram), and IMAP/SMTP obviously too. The PA described here is a client that orchestrates cloud services — not a local model. If you need offline operation, it's a full redesign with a local LLM (Llama, Mistral) running on ollama or similar, with notable quality and performance constraints.
How long does the PA take to produce its first morning briefing?
The full run_briefing.sh takes between 40 and 90 seconds depending on how much email you have to triage. The slowest part is the IMAP fetch with --with-body (one connection per UID), followed by the model's reasoning on triage. If your inbox is very large (>50 unread), it can go up to 2–3 minutes. If it's the first run and the Calendar MCP is still warming up, add 5–10 extra seconds.
Can I customize it for my specific flow?
Yes, and it's the project's philosophy. The key files to tweak: CLAUDE.md (persona, tone, rules), .claude/commands/*.md (specific flows), memory/ (persistent preferences, signature, VIP contacts, triage rules). The Python code rarely needs to be touched except to add additional IMAP accounts or integrate a new channel. Start with the base setup working before customizing — it's cheaper to learn where to customize once you see what's extra and what's missing.
Conclusion: 60 minutes between you and your first operational PA
We've been using this PA at Kiwop for two weeks, and the most honest conclusion we can write is that the real productivity leap doesn't come from the model — it comes from the minimalist design around the model. Claude Code, Opus 4.7, the MCP servers: those are the expensive parts you don't build. The ~500 lines of Python, the handful of Markdown files, the three launchd .plist files — that's what you do, and that's what turns the model's capabilities into an assistant useful for your day.
This tutorial has covered the full path from an empty directory to an operational PA with four channels and scheduled briefings. The reference code we used is published at https://github.com/purroy/majordomo under a permissive license — clone, fork, adapt. It's not the only way to build a PA in 2026, but it's one we know works because we use it every morning at 08:00 before turning on the work Mac.
If while following the tutorial you've found a gap where your specific case needs more than this base stack — custom integrations with your CRM, a Teams channel, a proprietary MCP for your ERP, or directly a multi-user assistant for your whole team — at Kiwop we build those pieces as part of our AI agent development and AI consulting services. Our starting point is always the same: thin, decoupled, provider-agnostic layers. There's nothing in this tutorial you can't replicate yourself in an afternoon, but if your time is more expensive than the cost of outsourcing, contact our team and we'll do it with you.
The PA now running on your Mac is yours. No one can cut it off overnight. And that, in 2026, is the most valuable property an applied AI agent can have at daily work.