Building a Local SMS Archive System: Querying macOS Messages Database for Business Line Integration
When you need to integrate incoming SMS messages into a broader business automation system, the naive approach—polling a third-party SMS gateway—introduces cost, latency, and external dependencies. This post documents a different strategy: leveraging the local macOS Messages database (chat.db) as a free, persistent archive that can be queried on-demand.
The Problem: Business SMS Without Third-Party Overhead
A business line (619-986-7344, routed to an iPhone) was receiving SMS messages that needed to be accessible to automation scripts and agents. The requirements were:
- Zero cost (no SMS gateway subscription)
- Local storage (data stays on device)
- Easy programmatic access (queryable by scripts)
- Privacy-aware (separate business line from personal SMS)
- Minimal setup overhead
The macOS Messages app already stores all SMS/iMessage data in a SQLite database at ~/Library/Messages/chat.db. Rather than sync messages to a remote service, we could query this database directly and export messages on demand.
Technical Architecture: SQLite as the Source of Truth
The Messages database structure includes three key tables for SMS filtering:
chat— conversation metadata, includesguid(unique identifier per contact/service)message— individual messages withtext,date,is_from_mefieldschat_message_join— junction table linking messages to conversations
The critical insight: each distinct contact/service combination gets a unique guid in the format sms;-;[phone_number] or sms;-;[email] depending on how iMessage routing is configured. By querying for a specific guid, we isolate one conversation thread across potentially multiple contacts.
Implementation: The read-sms Script
A shell script at /Users/cb/bin/read-sms handles database queries and exports. The script structure:
#!/bin/bash
# Query macOS Messages database for a specific phone number
# Usage: read-sms [phone_number] [output_format]
PHONE="619-986-7344"
DB_PATH="$HOME/Library/Messages/chat.db"
OUTPUT_FILE="$HOME/exports/sms_jada_line_$(date +%Y%m%d_%H%M%S).txt"
# Step 1: Find the chat guid for this phone number
CHAT_GUID=$(sqlite3 "$DB_PATH" \
"SELECT guid FROM chat WHERE guid LIKE '%619%' LIMIT 1;")
# Step 2: Export all messages for this conversation
sqlite3 -header "$DB_PATH" \
"SELECT
datetime(message.date/1000000000 + 978307200, 'unixepoch', 'localtime') AS timestamp,
CASE WHEN message.is_from_me = 1 THEN 'SENT' ELSE 'RECEIVED' END AS direction,
message.text
FROM message
JOIN chat_message_join ON message.ROWID = chat_message_join.message_id
WHERE chat_message_join.chat_id = (
SELECT ROWID FROM chat WHERE guid = '$CHAT_GUID'
)
ORDER BY message.date ASC;" > "$OUTPUT_FILE"
echo "Exported to: $OUTPUT_FILE"
Why this approach:
sqlite3is built into macOS — no external dependencies- The
date/1000000000 + 978307200calculation converts Apple's internal timestamp format (nanoseconds since 2001-01-01) to Unix epoch - The
chat_message_jointable is queried rather than rawmessagerows to ensure we only get messages belonging to the correct conversation - Output is plain text, line-delimited, easy to parse by downstream agents
Key Database Queries: Finding the Right Conversation
The trickiest part is identifying the correct chat.guid` for the business line. If multiple devices sync (iPhone + Mac) and iMessage is enabled, the same phone number may appear under different guids or accounts.
# List all SMS conversations with their guids
sqlite3 ~/Library/Messages/chat.db \
"SELECT guid, chat_identifier FROM chat WHERE guid LIKE 'sms%' ORDER BY guid;"
# Find conversations for a specific phone pattern
sqlite3 ~/Library/Messages/chat.db \
"SELECT guid, chat_identifier FROM chat WHERE chat_identifier LIKE '%619%';"
# Count messages per conversation to verify you have the right one
sqlite3 ~/Library/Messages/chat.db \
"SELECT chat.guid, COUNT(message.ROWID) as msg_count
FROM chat
JOIN chat_message_join ON chat.ROWID = chat_message_join.chat_id
JOIN message ON message.ROWID = chat_message_join.message_id
GROUP BY chat.guid
ORDER BY msg_count DESC;"
Privacy Constraints and Separation
A critical requirement: do not read personal SMS messages from the personal line (773-941-2265). The script is hardcoded to target only the 619-986-7344 guid. Scope enforcement:
- Script only queries
WHERE guid = [hardcoded_business_guid] - Exported files are named with the business line identifier for clarity
- Documentation explicitly lists the excluded personal number in
/Users/cb/.claude/projects/.../feedback_personal_number_privacy.md
This ensures that even if a developer runs the script without parameters, it cannot accidentally leak personal messages.
Integration with Automation Workflows
Once exported, the SMS file can be:
- Ingested by Lambda functions: S3 event triggers on new SMS exports, processes messages for booking confirmations or payment notifications
- Parsed by agents: Agents read the exported text file and correlate SMS content with booking records in the JADA booking sheet
- Archived to S3:
s3://jada-ops/sms-archives/business-line/— CloudFront can serve these files if needed for audit trails - Triggered on-demand: A simple cron job (`0 * * * *`) runs the export hourly, or the script is called manually when needed
What's Next
Future enhancements to consider:
- Automated parsing: Extract phone numbers, payment amounts, and booking identifiers from SMS text using regex
- Webhook integration: Have the export script POST to a local HTTP endpoint that notifies agents of new messages
- Message deduplication: Store message hashes in DynamoDB to avoid re-processing the same SMS across multiple export runs
- Cross-device sync: If syncing from iPhone to a server, rsync or iCloud Drive can move the database to a shared location
This approach trades real-time notification for simplicity and cost. It's ideal for moderate