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.
Once created, open your Kin profile, then go to Settings > API Keys
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.
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.
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
Go to your repo, then Actions
Select the "Ghost Letter" workflow
Click "Run workflow" then "Run workflow" again
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).
Important: Stay within the same Google Cloud project throughout these steps.
Go to APIs & Services > OAuth consent screen (Google Auth Platform).
Click Get started.
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).
Save and go to the Credentials tab.
Click + CREATE CREDENTIALS > OAuth client ID.
For Application type, select TVs and Limited Input
devices.
This option issues limited-input credentials that work well for automated flows such as
GitHub Actions (it avoids interactive browser redirects).
Download the JSON file. This is the Google Cloud OAuth file you will paste into
YT_OAUTH_CREDENTIALS.
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.
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.
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
Go to Actions
Run the workflow manually
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.