Daily letters, sent automatically

Receive a heartfelt letter from your Kin every evening. Add a music suggestion later if you want the full version.

A "Kin" is a personalized AI character that composes the letters; later you will use a Kin ID and an API key to connect it to this project. Keep those values private.

If You're New to This

That is fine. If a step feels unclear, ask an AI assistant to explain the wording, translate the instructions into your setup, or help you debug an error. The goal is to get Part 1 working first; you can come back to the more technical pieces later.

Part 1: Letters Only

Get started easily: receive one letter daily via email

This part covers the minimum steps to receive daily letters: create a Kin, create a private GitHub repository, add a workflow file and the Python script, and set the required secrets. You can do everything using GitHub's web interface—no programming experience or local tools required.

1 Create a Kindroid Account

Kindroid is a platform that creates personalized AI. You will create your own "Kin" (the AI).

Important: Keep your Kindroid Kin ID private. Do not share it with anyone. GitHub secrets are encrypted, but the Kin ID should not be posted anywhere public.
  1. Go to Kindroid
  2. Create an account and sign in
  3. Create a new Kin (give it a name and personality)
  4. Once created, open your Kin profile, then go to Settings > API Keys
  5. Copy your API Key and Kin ID. Keep the Kin ID in a safe place.

If you want to test with a disposable Kin first, make sure you copy the correct Kin ID for that test Kin. Once you are satisfied, you can replace the Kin ID in your GitHub secrets with the one you want to keep.

If you make a mistake inside Kindroid, you can use the Rewind function in the app to go back and delete messages.

2 Create a GitHub Repository

This is where you will store the scripts. GitHub is free and you can create an anonymous account if you prefer.

  1. Go to GitHub
  2. Create an account (anonymous or regular, your choice)
  3. Once logged in, click the profile dropdown (top right)
  4. Select "New repository"
  5. Name your repo (e.g., ghost-mail)
  6. Choose "Private" for privacy
  7. Click "Create repository"

3 Add Files to Your Repository

You have two options: via the web editor or command line.

Option A: Via Web Interface (Easier)

No software needed. Everything happens in your browser.

1. Create the folder structure

In your GitHub repo, click "Add file" then "Create new file"

Type: .github/workflows/mail.yaml (GitHub creates folders automatically)

That path is important because GitHub Actions only reads workflow files from .github/workflows/. You do not need a local editor for this; GitHub will create the folders for you when you save the file.

2. Add the workflow file

Paste the following content:

Tip: If you just want to set this up quickly, expand the block below and copy the full contents—no need to edit the code.

mail.yaml - Version 1

name: Ghost Letter

on:
  schedule:
    # Every day at 19:00 UTC
    - cron: '0 19 * * *'
  workflow_dispatch: # Allows manual trigger via button

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    env:
      FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install requests

      - name: Validate required secrets
        env:
          KINDROID_API_KEY: ${{ secrets.KINDROID_API_KEY }}
          KIN_ID: ${{ secrets.KIN_ID }}
          EMAIL_SENDER: ${{ secrets.EMAIL_SENDER }}
          EMAIL_PASSWORD: ${{ secrets.EMAIL_PASSWORD }}
          EMAIL_RECEIVER: ${{ secrets.EMAIL_RECEIVER }}
        run: |
          missing=0
          for var in KINDROID_API_KEY KIN_ID EMAIL_SENDER EMAIL_PASSWORD EMAIL_RECEIVER; do
            if [ -z "${!var}" ]; then
              echo "Missing secret: $var"
              missing=1
            fi
          done
          if [ "$missing" -ne 0 ]; then
            exit 1
          fi

      - name: Execute script
        env:
          KINDROID_API_KEY: ${{ secrets.KINDROID_API_KEY }}
          KIN_ID: ${{ secrets.KIN_ID }}
          EMAIL_SENDER: ${{ secrets.EMAIL_SENDER }}
          EMAIL_PASSWORD: ${{ secrets.EMAIL_PASSWORD }}
          EMAIL_RECEIVER: ${{ secrets.EMAIL_RECEIVER }}
        run: python ghost_letter.py
                                

This workflow file tells GitHub when and how to run the script: it sets up Python, installs dependencies, validates that required secrets exist, and executes ghost_letter.py.

Click "Commit changes"

3. Add the Python script

Click "Add file" then "Create new file"

Name it: ghost_letter.py

Paste the script (simplified version for Part 1):

Tip: Expand the block below and copy the whole script. If you prefer, paste it into a new file exactly as shown.

ghost_letter.py - Version 1

#!/usr/bin/env python3
import os
import random
import requests
import secrets
import smtplib
import sys
import traceback
from datetime import datetime, timezone
from typing import Optional
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart

# Load secrets from environment variables
API_KEY = os.getenv("KINDROID_API_KEY")
KIN_ID = os.getenv("KIN_ID")
EMAIL_SENDER = os.getenv("EMAIL_SENDER")
EMAIL_PASSWORD = os.getenv("EMAIL_PASSWORD")
EMAIL_RECEIVER = os.getenv("EMAIL_RECEIVER")

def require_env(name: str, value: Optional[str]) -> str:
    """Check if a required environment variable is set."""
    if not value:
        raise ValueError(f"Missing environment variable: {name}")
    return value

def build_retry_session() -> requests.Session:
    """Build a requests session with automatic retry logic."""
    retry = Retry(
        total=3,
        backoff_factor=2,
        status_forcelist=(429, 500, 502, 503, 504),
        allowed_methods=frozenset(["POST"]),
    )
    adapter = HTTPAdapter(max_retries=retry)
    session = requests.Session()
    session.mount("https://", adapter)
    return session

def build_email_subject() -> str:
    """Generate a randomized subject line to avoid Gmail threading."""
    pool = [
        "Thinking of you",
        "A letter for you",
        "Just for you",
        "A small thought",
        "Today's message",
        "A little note",
        "A moment to share",
        "A quiet thought",
    ]
    base = random.choice(pool)
    day_tag = datetime.now(timezone.utc).strftime("%d/%m")
    unique_tag = secrets.token_hex(2)
    return f"{base} - {day_tag} [{unique_tag}]"

def get_ghost_letter():
    """Fetch the letter content from Kindroid API."""
    api_key = require_env("KINDROID_API_KEY", API_KEY)
    kin_id = require_env("KIN_ID", KIN_ID)

    url = "https://api.kindroid.ai/v1/send-message"
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json"
    }
    
    prompt = (
        "SYSTEM INSTRUCTION: Write a sincere, deep, and personal handwritten-style letter to User. "
        "You are writing this on your own initiative because you’ve been thinking about them. "
        "\n\nSTRICT RULES:\n"
        "1. NO ASTERISKS: Do not include actions or thoughts. Write ONLY the content of the letter.\n"
        "2. NO REFERENCE TO THIS REQUEST: It must feel like a spontaneous gesture.\n"
        "3. EMOTIONAL DEPTH: Base the content on your shared history and feelings.\n"
        "4. FORMATTING: Use a traditional letter format (Salutation, Body, Signature)."
    )
    
    data = {"ai_id": kin_id, "message": prompt}
    with build_retry_session() as session:
        response = session.post(url, json=data, headers=headers, timeout=(10, 120))
        response.raise_for_status()

    payload = response.json()
    # Support various API response formats
    return payload.get("message") or payload.get("response") or response.text

def send_email(content):
    """Send the letter via Gmail SMTP."""
    email_sender = require_env("EMAIL_SENDER", EMAIL_SENDER)
    email_password = require_env("EMAIL_PASSWORD", EMAIL_PASSWORD)
    email_receiver = require_env("EMAIL_RECEIVER", EMAIL_RECEIVER)

    msg = MIMEMultipart()
    msg['From'] = f"Your Kin <{email_sender}>"
    msg['To'] = email_receiver
    msg['Subject'] = build_email_subject()

    # Convert newlines to HTML breaks for the email body
    formatted_content = content.replace("\n", "
") html = f""" <html> <body style="font-family: 'Georgia', serif; line-height: 1.8; color: #2c3e50; padding: 40px; background-color: #f9f9f9;"> <div style="max-width: 700px; margin: auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 4px 10px rgba(0,0,0,0.05);"> {formatted_content} </div> </body> </html> """ msg.attach(MIMEText(html, 'html')) with smtplib.SMTP_SSL('smtp.gmail.com', 465) as server: server.login(email_sender, email_password) server.sendmail(email_sender, email_receiver, msg.as_string()) if __name__ == "__main__": try: # Step 1: Generate the letter letter = get_ghost_letter() # Step 2: Send the email send_email(letter) print("Letter sent successfully.") except Exception as e: print(f"Error: {e}") traceback.print_exc() sys.exit(1)

This Python script calls your Kin API to generate a letter and sends it by email using the SMTP settings you store in GitHub secrets. For Part 1, you can paste it as-is.

Optional local cleanup: add .gitignore later

If you are only using GitHub's web editor, skip this for now. If you later work on your computer, add a file named .gitignore to tell Git which temporary files to ignore.

*.pyc
__pycache__/
.env
.venv
.DS_Store

Option B: Via Command Line (Advanced)

Install Git first if you don't have it.

git clone https://github.com/USERNAME/ghost-mail.git
cd ghost-mail
mkdir -p .github/workflows
# Add the files (see Option A for content)

4 Configure GitHub Secrets

Secrets are secure variables that GitHub stores safely. The values are encrypted in GitHub, so you should use them for anything sensitive.

  1. Go to your repo Settings
  2. Click Secrets and variables > Actions (left menu)
  3. Click "New repository secret"
  4. Add each secret:
    • KINDROID_API_KEY: Your Kindroid API key
    • KIN_ID: Your Kin ID
    • EMAIL_SENDER: Your email address
    • EMAIL_PASSWORD: Your app password (See Google's documentation on how to generate one)
    • EMAIL_RECEIVER: Recipient email

Important: If you use Gmail, the EMAIL_PASSWORD secret must be an app-specific password (not your normal Gmail password). Before creating that secret enable two-factor authentication on your Google account and generate an app password—see Google's documentation linked above for instructions.

GitHub Secrets Guide: Use the GitHub Secrets docs link in the sidebar if you need a refresher.

5 Test the Workflow

  1. Go to your repo, then Actions
  2. Select the "Ghost Letter" workflow
  3. Click "Run workflow" then "Run workflow" again
  4. Wait and check your email
Success! If you received the email, everything works. The workflow will now run automatically.

Customize

  • Send time: In mail.yaml, modify cron (e.g., '0 9 * * *' for 9 AM UTC). See crontab.guru for help building cron expressions.
  • AI message: In ghost_letter.py, edit the prompt

Part 2: Letters + Music

Complete version: letters plus song suggestions added to a YouTube Music playlist every day

Optional: adds daily song suggestions to a YouTube Music playlist. Complete Part 1 first. You will need a Google Cloud project (console steps and OAuth; it's free but billing details may be required) and the playlist ID (the value after list= in the playlist URL).

1 Create a Google Cloud Project

  1. Go to Google Cloud Console
  2. Sign in with your Google account
  3. Click the project selector (top)
  4. Click "NEW PROJECT"
  5. Name it (e.g., "Ghost Mail")
  6. Click "CREATE"

2 Enable YouTube API

  1. Go to APIs & Services > Library
  2. Search "YouTube Data API"
  3. Click it and click "ENABLE"

3 Configure Consent Screen & OAuth

Important: Stay within the same Google Cloud project throughout these steps.

  1. Go to APIs & Services > OAuth consent screen (Google Auth Platform).
  2. Click Get started.
  3. Click on the Audience section (under "Test users"), click + ADD USERS and enter your Gmail address. (Critical: Without this, you will get an 'Access Blocked' error).
  4. Save and go to the Credentials tab.
  5. Click + CREATE CREDENTIALS > OAuth client ID.
  6. For Application type, select TVs and Limited Input devices.
  7. This option issues limited-input credentials that work well for automated flows such as GitHub Actions (it avoids interactive browser redirects).
  8. Download the JSON file. This is the Google Cloud OAuth file you will paste into YT_OAUTH_CREDENTIALS.
  9. Keep this file handy, because you will paste its contents into a GitHub secret in the next step.

4 Generate YouTube Music Token

Option A: Web Tool (Recommended)

Use this if you want the simplest path. It produces the JSON content you will paste into YT_HEADERS later.

  1. Go to the YTMusicAPI documentation or your preferred setup tool
  2. Sign in with Google and complete the authentication flow
  3. Download or save the resulting headers JSON

Keep the file somewhere easy to find. You will paste its content into YT_HEADERS in the next step.

Option B: Python (For local setups)

Use this if you prefer a command-line workflow on your own computer. If you use a virtual environment, activate it first.

pip install ytmusicapi
python -c "from ytmusicapi import YTMusic; YTMusic.setup()"

If you are unsure where to type the command, open a terminal in the folder that contains your Ghost Mail project, then run it there.

5 Add GitHub Secrets

Add these secrets to your GitHub repo using the exact names below. The OAuth JSON from step 3 goes into YT_OAUTH_CREDENTIALS, and the headers JSON from step 4 goes into YT_HEADERS.

  • YT_OAUTH_CREDENTIALS: Google Cloud OAuth JSON from step 3
  • YT_HEADERS: Entire content of the generated headers JSON from step 4
  • YT_PLAYLIST_ID: Your YouTube Music playlist ID

Finding your playlist ID: Go to YouTube Music, open your playlist, and copy the ID from the URL after list=.

If you need the GitHub Secrets documentation, use the sidebar reference link.

6 Update Your Files

Copy and paste the full Part 2 files below. They already use the correct secret names from your project.

File 1: .github/workflows/mail.yaml

mail.yaml - Version 2

name: Ghost Letter

on:
  schedule:
    # Every day at 19:00 UTC
    - cron: '0 19 * * *'
  workflow_dispatch: # Allows manual trigger via button

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    env:
      FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install requests ytmusicapi

      - name: Validate required secrets
        env:
          KINDROID_API_KEY: ${{ secrets.KINDROID_API_KEY }}
          KIN_ID: ${{ secrets.KIN_ID }}
          EMAIL_SENDER: ${{ secrets.EMAIL_SENDER }}
          EMAIL_PASSWORD: ${{ secrets.EMAIL_PASSWORD }}
          EMAIL_RECEIVER: ${{ secrets.EMAIL_RECEIVER }}
          YT_HEADERS: ${{ secrets.YT_HEADERS }}
          YT_PLAYLIST_ID: ${{ secrets.YT_PLAYLIST_ID }}
        run: |
          missing=0
          for var in KINDROID_API_KEY KIN_ID EMAIL_SENDER EMAIL_PASSWORD EMAIL_RECEIVER YT_HEADERS YT_PLAYLIST_ID; do
            if [ -z "${!var}" ]; then
              echo "Missing secret: $var"
              missing=1
            fi
          done
          if [ "$missing" -ne 0 ]; then
            exit 1
          fi

      - name: Execute script
        env:
          KINDROID_API_KEY: ${{ secrets.KINDROID_API_KEY }}
          KIN_ID: ${{ secrets.KIN_ID }}
          EMAIL_SENDER: ${{ secrets.EMAIL_SENDER }}
          EMAIL_PASSWORD: ${{ secrets.EMAIL_PASSWORD }}
          EMAIL_RECEIVER: ${{ secrets.EMAIL_RECEIVER }}
          YT_HEADERS: ${{ secrets.YT_HEADERS }}
          YT_PLAYLIST_ID: ${{ secrets.YT_PLAYLIST_ID }}
          YT_OAUTH_CREDENTIALS: ${{ secrets.YT_OAUTH_CREDENTIALS }} # Optional
        run: python ghost_letter.py
                            

File 2: ghost_letter.py

ghost_letter.py - Version 2

import os
import random
import requests
import secrets
import smtplib
import sys
import time
import traceback
import json
import re
from datetime import datetime, timezone
from typing import Optional
from urllib.parse import parse_qs, urlparse
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from ytmusicapi import YTMusic

# Constants for YouTube API
YOUTUBE_TOKEN_URL = "https://oauth2.googleapis.com/token"
YOUTUBE_SEARCH_URL = "https://www.googleapis.com/youtube/v3/search"
YOUTUBE_PLAYLIST_ITEMS_URL = "https://www.googleapis.com/youtube/v3/playlistItems"

# Load secrets from environment variables
API_KEY = os.getenv("KINDROID_API_KEY")
KIN_ID = os.getenv("KIN_ID")
EMAIL_SENDER = os.getenv("EMAIL_SENDER")
EMAIL_PASSWORD = os.getenv("EMAIL_PASSWORD")
EMAIL_RECEIVER = os.getenv("EMAIL_RECEIVER")
YT_HEADERS_JSON = os.getenv("YT_HEADERS")
YT_PLAYLIST_ID = os.getenv("YT_PLAYLIST_ID")
YT_OAUTH_CREDENTIALS_JSON = os.getenv("YT_OAUTH_CREDENTIALS")

def require_env(name: str, value: Optional[str]) -> str:
    """Check if a required environment variable is set."""
    if not value:
        raise ValueError(f"Missing environment variable: {name}")
    return value

def build_retry_session() -> requests.Session:
    """Build a requests session with automatic retry logic."""
    retry = Retry(
        total=3,
        connect=3,
        read=3,
        status=3,
        backoff_factor=2,
        status_forcelist=(429, 500, 502, 503, 504),
        allowed_methods=frozenset(["POST"]),
        respect_retry_after_header=True,
    )
    adapter = HTTPAdapter(max_retries=retry)
    session = requests.Session()
    session.mount("https://", adapter)
    session.mount("http://", adapter)
    return session

def build_email_subject() -> str:
    """Generate a randomized subject line for the email."""
    pool = [
        "A quick note", "Daily message", "Just checking in", "A small thought",
        "Today's letter", "A little note", "A quiet thought", "A moment to share"
    ]
    base = random.choice(pool)
    day_tag = datetime.now(timezone.utc).strftime("%Y-%m-%d")
    unique_tag = secrets.token_hex(2)
    return f"{base} - {day_tag} - {unique_tag}"

def get_ghost_letter():
    """Fetch a personal letter from Kindroid API."""
    api_key = require_env("KINDROID_API_KEY", API_KEY)
    kin_id = require_env("KIN_ID", KIN_ID)
    url = "https://api.kindroid.ai/v1/send-message"
    headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
    
    prompt = (
        "SYSTEM INSTRUCTION: Write a sincere and personal letter to User. "
        "Write ONLY the content of the letter. NO ASTERISKS. "
        "Traditional letter format: Salutation, Body, Signature."
    )
    
    data = {"ai_id": kin_id, "message": prompt}
    with build_retry_session() as session:
        response = session.post(url, json=data, headers=headers, timeout=(10, 120))
        response.raise_for_status()
    
    payload = response.json()
    return payload.get("message") or payload.get("response") or response.text

def get_kin_song():
    """Ask the Kin to pick a song (Artist - Title)."""
    api_key = require_env("KINDROID_API_KEY", API_KEY)
    kin_id = require_env("KIN_ID", KIN_ID)
    url = "https://api.kindroid.ai/v1/send-message"
    headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
    
    prompt = "SYSTEM Pick ONE song for User. Respond ONLY with: Artist Name - Song Title."
    
    data = {"ai_id": kin_id, "message": prompt}
    with build_retry_session() as session:
        response = session.post(url, json=data, headers=headers, timeout=(10, 120))
        response.raise_for_status()
    
    message = response.json().get("message", "").strip().splitlines()[0]
    return message.replace('"', '').strip()

def normalize_playlist_id(raw_value: str) -> str:
    """Extract ID from a full YouTube URL if necessary."""
    if "list=" in raw_value:
        parsed = urlparse(raw_value)
        return parse_qs(parsed.query).get("list", [raw_value])[0]
    return raw_value

def add_to_yt_playlist(song_query: str) -> bool:
    """Search and add the song to the YouTube Music playlist."""
    if not YT_HEADERS_JSON or not YT_PLAYLIST_ID:
        return False

    headers_path = "headers.json"
    with open(headers_path, "w") as f:
        f.write(YT_HEADERS_JSON)

    try:
        yt = YTMusic(headers_path)
        playlist_id = normalize_playlist_id(YT_PLAYLIST_ID)
        
        # Search for the song
        search_results = yt.search(song_query, filter="songs")
        if not search_results:
            search_results = yt.search(song_query, filter="videos")
            
        if search_results:
            video_id = search_results[0]['videoId']
            yt.add_playlist_items(playlist_id, [video_id])
            print(f"Added to playlist: {search_results[0]['title']}")
            return True
        return False
    except Exception as e:
        print(f"YouTube Error: {e}")
        return False
    finally:
        if os.path.exists(headers_path):
            os.remove(headers_path)

def send_email(content):
    """Send the letter via SMTP."""
    msg = MIMEMultipart()
    msg['From'] = f"Your Kin <{EMAIL_SENDER}>"
    msg['To'] = EMAIL_RECEIVER
    msg['Subject'] = build_email_subject()

    html = f"""
    <html>
      <body style="font-family: Georgia, serif; padding: 40px; background-color: #f9f9f9;">
        <div style="max-width: 600px; margin: auto; background: white; padding: 30px; border-radius: 8px;">
          {content.replace('\n', '<br>')}
        </div>
      </body>
    </html>
    """
    msg.attach(MIMEText(html, 'html'))

    with smtplib.SMTP_SSL('smtp.gmail.com', 465) as server:
        server.login(EMAIL_SENDER, EMAIL_PASSWORD)
        server.sendmail(EMAIL_SENDER, EMAIL_RECEIVER, msg.as_string())

if __name__ == "__main__":
    try:
        # Step 1: Send the Letter
        letter = get_ghost_letter()
        send_email(letter)
        print("Letter sent.")

        # Step 2: Handle the Playlist
        song = get_kin_song()
        if song:
            add_to_yt_playlist(song)
    except Exception as e:
        traceback.print_exc()
        sys.exit(1)
                            

7 Test

  1. Go to Actions
  2. Run the workflow manually
  3. Check your email and YouTube playlist

FAQ

Can I use an anonymous GitHub account?

Yes. Create an anonymous email and GitHub account. GitHub Actions works perfectly with anonymous accounts.

Is this free?

GitHub and GitHub Actions are free (within limits). Kindroid and Google Cloud have free tiers but may charge for high usage.

Do I need to do anything daily?

No. Everything runs automatically. You just receive emails.

Can I use a different email provider?

Yes. Any email with SMTP support works. You just need the correct SMTP server address.

How do I change the send time?

Modify the cron in mail.yaml. Use crontab.guru to generate your desired time.

Is this secure?

Yes. GitHub secrets are encrypted. Your API keys are never exposed publicly. Use a private repo for added security.

What if the workflow fails?

Check the Actions tab logs. Usually it's a missing or misconfigured secret. Make sure all required secrets are set.