โ† All projects Agents that work ยท Beginner

A bot that reads your inbox and texts you the important bits

Every morning at 7am, a Telegram message with the three emails that actually need you and a one-line summary of each. Everything else stays in the inbox where it belongs.

You'll have at the end: a script running on a schedule that reads your unread mail, asks a model which ones matter, and sends you a short digest on Telegram before you open your laptop.

Why this one

This is the cleanest possible version of the pattern behind almost every useful agent: fetch data โ†’ ask a model to judge it โ†’ deliver the answer somewhere a human already looks. Once you've built this, you'll recognise that shape everywhere, and most of what you build later is a variation on it.

It's also genuinely useful on day one, which matters more than it sounds โ€” the projects people finish are the ones they wanted the output of.

Before you start

You need Python installed (any version from 3.10), a Google account, a Telegram account, and an API key from a model provider. Budget about an hour, of which twenty minutes is Google's OAuth screens, which are more tedious than difficult.

Step 1 โ€” Make the Telegram bot

In Telegram, message @BotFather. Send /newbot, give it a name and a username ending in bot. He replies with a token like 7891234567:AAF.... Keep it.

Now send your new bot any message โ€” this is required, because bots can't start conversations. Then find your chat ID by opening this URL in a browser, with your token in place:

https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates

In the JSON you'll see "chat":{"id":123456789. That number is your chat ID.

Step 2 โ€” Get Gmail access

Go to console.cloud.google.com, create a project, and enable the Gmail API under APIs & Services. Then under Credentials, create an OAuth client ID of type Desktop app, and download the JSON. Rename it credentials.json and put it in your project folder.

While you're in the OAuth consent screen, add your own email as a test user, or Google will refuse to authorize you.

Step 3 โ€” Install the libraries

pip install google-auth-oauthlib google-api-python-client anthropic requests

Step 4 โ€” The script

Save this as digest.py. It's complete โ€” read it once before running it, particularly the PROMPT, because that's the part you'll tune.

import os, json, base64, datetime, requests
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
import anthropic

# --- your settings -------------------------------------------------
TELEGRAM_TOKEN = os.environ["TELEGRAM_TOKEN"]
TELEGRAM_CHAT  = os.environ["TELEGRAM_CHAT"]
ANTHROPIC_KEY  = os.environ["ANTHROPIC_API_KEY"]
LOOKBACK_HOURS = 16          # how far back to look
MAX_EMAILS     = 30          # cap on how many we send to the model
# -------------------------------------------------------------------

SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"]

PROMPT = """You are triaging one person's inbox. Below are emails from the last
day. Pick AT MOST 3 that genuinely need this person's attention today.

Include: things a real human is waiting on, deadlines, money, anything broken,
anything from a named person asking a direct question.
Exclude: newsletters, marketing, automated notifications, receipts, social
media, calendar spam, anything that can wait a week without consequence.

Reply as a JSON array and nothing else. Each item:
  {"from": "sender name", "subject": "the subject", "why": "under 12 words"}

If nothing genuinely needs attention, reply with exactly: []
"""


def gmail_service():
    """Authorise once, then reuse token.json forever."""
    creds = None
    if os.path.exists("token.json"):
        creds = Credentials.from_authorized_user_file("token.json", SCOPES)
    if not creds or not creds.valid:
        if creds and creds.expired and creds.refresh_token:
            creds.refresh(Request())
        else:
            flow = InstalledAppFlow.from_client_secrets_file("credentials.json", SCOPES)
            creds = flow.run_local_server(port=0)
        with open("token.json", "w") as f:
            f.write(creds.to_json())
    return build("gmail", "v1", credentials=creds)


def header(msg, name):
    for h in msg["payload"]["headers"]:
        if h["name"].lower() == name.lower():
            return h["value"]
    return ""


def recent_emails(service):
    after = int((datetime.datetime.now() -
                 datetime.timedelta(hours=LOOKBACK_HOURS)).timestamp())
    query = f"is:unread -in:spam -category:promotions after:{after}"

    listing = service.users().messages().list(
        userId="me", q=query, maxResults=MAX_EMAILS).execute()

    emails = []
    for item in listing.get("messages", []):
        msg = service.users().messages().get(
            userId="me", id=item["id"], format="metadata",
            metadataHeaders=["From", "Subject"]).execute()
        emails.append({
            "from": header(msg, "From"),
            "subject": header(msg, "Subject"),
            "snippet": msg.get("snippet", "")[:200],
        })
    return emails


def pick_important(emails):
    if not emails:
        return []
    client = anthropic.Anthropic(api_key=ANTHROPIC_KEY)
    listing = "\n\n".join(
        f"From: {e['from']}\nSubject: {e['subject']}\nPreview: {e['snippet']}"
        for e in emails
    )
    reply = client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=800,
        messages=[{"role": "user", "content": PROMPT + "\n\n" + listing}],
    )
    text = reply.content[0].text.strip()
    # Models sometimes wrap JSON in a code fence. Strip it.
    if text.startswith("```"):
        text = text.split("```")[1].removeprefix("json").strip()
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        print("Model did not return JSON:", text[:300])
        return []


def send(text):
    requests.post(
        f"https://api.telegram.org/bot{TELEGRAM_TOKEN}/sendMessage",
        json={"chat_id": TELEGRAM_CHAT, "text": text,
              "parse_mode": "HTML", "disable_web_page_preview": True},
        timeout=20,
    ).raise_for_status()


def main():
    emails = recent_emails(gmail_service())
    print(f"Found {len(emails)} unread emails")
    picks = pick_important(emails)

    if not picks:
        send(f"โ˜€๏ธ Nothing needs you this morning. "
             f"({len(emails)} unread, none urgent.)")
        return

    lines = [f"โ˜€๏ธ {len(picks)} thing"
             f"{'s' if len(picks) != 1 else ''} need you\n"]
    for p in picks:
        lines.append(f"โ€ข {p['subject']}\n  {p['from']}\n  {p['why']}\n")
    lines.append(f"({len(emails)} unread in total.)")
    send("\n".join(lines))


if __name__ == "__main__":
    main()

Step 5 โ€” Run it

Set your three secrets as environment variables and run it:

export TELEGRAM_TOKEN="7891234567:AAF..."
export TELEGRAM_CHAT="123456789"
export ANTHROPIC_API_KEY="sk-ant-..."

python digest.py

The first run opens a browser for Google's consent screen. Approve it, and a token.json appears โ€” subsequent runs won't ask again. Within a few seconds you should have a Telegram message.

Step 6 โ€” Put it on a schedule

On a Mac or Linux machine that's on in the mornings, crontab -e and add:

0 7 * * * cd /path/to/project && /usr/bin/python3 digest.py >> digest.log 2>&1

Cron doesn't inherit your shell's environment variables, so either put the three export lines at the top of a small wrapper script, or set them directly in the crontab above the job.

If your laptop isn't reliably on at 7am, run it as a GitHub Action instead โ€” a scheduled workflow with the three values as repository secrets. That's free and always on. The one wrinkle is that token.json can't be regenerated interactively there, so generate it locally first and store its contents as a fourth secret that the workflow writes to disk before running.

When it goes wrong

"Model did not return JSON". Small models occasionally add a sentence before the array. The code already handles code fences; if it keeps happening, add "Output nothing but the JSON array." as the final line of the prompt.

Nothing is ever picked. Your criteria are too strict for your inbox, or everything genuinely is newsletters. Loosen the include list, or drop -category:promotions from the query to see what it's excluding.

Too much is picked. The opposite fix โ€” name the specific things you don't care about. "Exclude anything from GitHub, Jira or Slack notifications" works well because it's concrete.

Telegram 400 error. Almost always malformed HTML in a subject line containing < or &. Either escape the values with html.escape() before inserting them, or drop parse_mode entirely and send plain text.

Gmail returns nothing. Check your query in the Gmail search box itself โ€” is:unread after:... โ€” to confirm it matches what you expect. Query problems look like model problems and aren't.

What this costs

One run a day, thirty emails of metadata into a small model, is a fraction of a cent โ€” call it under a dollar a year. If you switch to a large model or start sending full email bodies instead of snippets, it's still only cents per day, but check your provider's dashboard after the first week rather than assuming.

Make it yours

Change the delivery. Slack, Discord, SMS via Twilio, or an email to yourself โ€” swap the send() function and nothing else changes. That separation is deliberate.

Change the judgment. The PROMPT is the whole product. Spend twenty minutes tuning it against a morning you remember well and it gets noticeably better.

Add a draft reply. For each pick, ask the model for a two-line suggested response and include it in the message. Reading a draft on your phone at breakfast is oddly effective at clearing things before you sit down.

Give it memory. Store the message IDs you've already reported in a small SQLite file and skip them next time โ€” so a thread you're deliberately ignoring stops resurfacing every morning. That change is the subject of the next project in this path.

What you just learned: fetch, judge, deliver โ€” with the judgment isolated in one prompt and the delivery isolated in one function. Keep those three parts separate in everything you build and your agents stay easy to change.
You got to the end

Did you build it?

Ticking it off keeps your pathway honest and moves the next build to the top of every page.

One new build sheet a week

A new build sheet every Thursday, written by a person who built it. No hype, no roundups.