11 KiB
Exec Aide — Implementation Plan
Context
Adam wants a personal AI agent that monitors his Gmail inbox, classifies emails by urgency, detects bypassed work-order routing and unanswered threads, and delivers real-time Slack alerts plus a daily digest. The brief proposed Pipedream first, but that conflicts with Sea Haven conventions (SAM default, Secrets Manager, CloudFormation-managed resources). This plan goes straight to SAM.
Stack: exec-aide
Repo: exec-aide in Sea-Haven-Industries (private)
Runtime: Python 3.12 / arm64
IaC: SAM (template.yaml)
AI: Bedrock — Claude Haiku (us.anthropic.claude-haiku-4-5-20251001)
Directory Structure
exec-aide/
├── template.yaml
├── samconfig.toml.example
├── .gitignore
├── README.md
├── scripts/
│ └── get_gmail_token.py # One-time OAuth token exchange
└── src/
├── requirements.txt # Shared: google-api-python-client, google-auth, requests
├── fetch_classify/
│ ├── __init__.py
│ └── app.py # 15-min poll handler
├── daily_digest/
│ ├── __init__.py
│ └── app.py # 5 PM ET digest handler
└── shared/
├── __init__.py
├── secrets.py # Secrets Manager + SSM caching
├── gmail.py # Gmail API: OAuth refresh, history sync, message fetch
├── classify.py # Bedrock Haiku classification prompt
├── slack.py # Slack DM posting, Block Kit builders
└── dynamo.py # DynamoDB state operations
Both functions share CodeUri: src/ with different handlers. This deviates slightly from the canonical per-function directory layout but avoids duplicating the shared module.
CloudFormation Resources
DynamoDB Table: exec-aide
Single table, composite key. Three item types distinguished by pk prefix.
| Item | pk | sk | Key attributes |
|---|---|---|---|
| Message | MSG#<messageId> |
MSG |
thread_id, from_email, from_name, to_emails, cc_emails, subject, snippet, internal_date, classification (HIGH/NORMAL/LOW), classification_reason, bypassed_wo (bool), notified (bool), classified_date (YYYY-MM-DD) |
| Thread | THD#<threadId> |
THD |
last_message_from, last_message_date, adam_last_reply_date, subject, unanswered_since |
| Sync meta | META#sync |
META |
history_id, last_sync_at, initial_sync_done |
- GSI
by-date: PK=classified_date, SK=sk— for daily digest date queries - TTL:
ttlattribute, 90 days on MSG/THD items, no TTL on META - Billing: PAY_PER_REQUEST
Lambda Functions
| Function | Trigger | Timeout | Purpose |
|---|---|---|---|
exec-aide-fetch-classify |
EventBridge rate(15 minutes) |
120s | Poll Gmail, classify, alert on HIGH |
exec-aide-daily-digest |
EventBridge Scheduler cron(0 17 ? * MON-FRI *) tz=America/New_York |
120s | Build and send daily digest DM |
Both get explicit CloudWatch log groups with 60-day retention.
EventBridge Scheduler (digest)
AWS::Scheduler::Schedule with ScheduleExpressionTimezone: America/New_York — handles DST correctly. Requires an IAM role granting lambda:InvokeFunction to the scheduler service.
IAM Permissions (per function)
fetch-classify:
DynamoDBCrudPolicyon exec-aide tablesecretsmanager:GetSecretValueonexec-aide/gmail-oauth,exec-aide/slack-bot-tokensecretsmanager:PutSecretValueonexec-aide/gmail-oauth(token refresh writes back)ssm:GetParameteron/exec-aide/*bedrock:InvokeModelonanthropic.*foundation models
daily-digest:
DynamoDBReadPolicyon exec-aide table (+ write for digest-sent marker)secretsmanager:GetSecretValueonexec-aide/gmail-oauth,exec-aide/slack-bot-tokensecretsmanager:PutSecretValueonexec-aide/gmail-oauthssm:GetParameteron/exec-aide/*
CloudFormation Outputs
- FetchClassifyFunctionArn
- DailyDigestFunctionArn
- ExecAideTableName
Secrets & Config
Secrets Manager (credentials)
| Secret | Contents |
|---|---|
exec-aide/gmail-oauth |
{ client_id, client_secret, refresh_token, access_token, token_expiry } |
exec-aide/slack-bot-token |
Bot token string (xoxb-...) |
SSM Parameter Store (non-secret config)
| Parameter | Type | Value |
|---|---|---|
/exec-aide/adam-email |
String | adam@seahavenind.com |
/exec-aide/adam-slack-user-id |
String | U01XXXXXXXX |
/exec-aide/vip-senders |
String | JSON array of email addresses |
/exec-aide/vip-domains |
String | JSON array of domains |
/exec-aide/work-order-addresses |
String | JSON array: ["work-orders@seahavenind.com","work-orders@seahaven.com"] |
/exec-aide/unanswered-threshold-hours |
String | 24 |
Module Design
shared/secrets.py
Module-level cached globals. get_gmail_oauth(), get_slack_token(), get_config(). Writes updated Gmail tokens back via save_gmail_tokens().
shared/gmail.py
get_authenticated_service(): Refreshes access token viagoogle.oauth2.credentials.Credentials, persists new token to Secrets Manager, returns Gmail API service object.fetch_history(history_id): Callsusers.history.listwithhistoryTypes=messageAdded, labelIds=INBOX. Returns(message_ids, new_history_id).fetch_message(message_id): Callsusers.messages.getwithformat=metadata. Parses headers into structured dict.fetch_thread(thread_id): Gets all messages in a thread — used by daily digest to verify unanswered status.initial_sync(): First-run fallback.users.messages.listwithq=newer_than:2d&labelIds=INBOX.
shared/classify.py
classify_email(message, vip_senders, vip_domains): Calls Bedrock Converse API with Haiku. Returns{ classification, reason }.check_bypassed_workorder(message, work_order_addresses): Pure logic — checks if TO/CC contains work-orders addresses. No AI needed.- Prompt returns JSON:
{ "classification": "HIGH"|"NORMAL"|"LOW", "reason": "..." }
shared/dynamo.py
save_message(): PutItem withConditionExpression: attribute_not_exists(pk)for idempotency.save_thread_state(): UpdateItem onTHD#— tracks last message sender/date and Adam's last reply.message_exists(): Dedup check before processing.get_todays_messages(): GSI query byclassified_date.get_unanswered_threads(): QueryTHD#items whereunanswered_sinceis set.get/save_sync_metadata(): Read/writeMETA#sync.
shared/slack.py
send_dm(blocks, text): Opens DM viaconversations.open, posts viachat.postMessage. Raw HTTP withrequests(matches existing patterns — no slack_sdk).build_high_priority_alert(message): Block Kit for real-time alert — header, sender/time fields, subject+snippet, "Open in Gmail" button.build_daily_digest(high_items, bypassed, unanswered, stats): Block Kit with sections for each category, stats footer.
Handler Logic
fetch_classify/app.py (every 15 min)
- Load secrets + config (cached on warm start)
- Read
META#syncfrom DynamoDB - If no sync metadata →
initial_sync(); else →fetch_history(history_id) - For each new message:
- Skip if
message_exists()(dedup) fetch_message()for metadataclassify_email()via Bedrockcheck_bypassed_workorder()(pure logic)save_message()to DynamoDBsave_thread_state()to DynamoDB- If HIGH →
send_dm()immediately
- Skip if
- Update
META#syncwith new history_id - Return summary
Error resilience: Each message processed independently. Partial failures logged, don't block remaining messages. History ID only updated after successful processing. If history.list returns 404 (expired ID), falls back to initial_sync().
daily_digest/app.py (5 PM ET, weekdays)
- Load secrets + config
- Query today's messages from GSI
by-date - Query
THD#items withunanswered_sinceset - For unanswered threads: call
fetch_thread()via Gmail API to verify Adam hasn't replied (catches replies sent via phone/web that the 15-min poll missed) - Filter bypassed work-orders from today's messages
- Compute stats (total, by classification)
- Build digest Block Kit message
- Send DM to Adam
- Log digest-sent marker to DynamoDB
Gmail Incremental Sync
- Initial sync:
messages.listwithq=newer_than:2d&labelIds=INBOX. Fetch each message. Store latesthistoryId. - Incremental:
history.listwithstartHistoryId. Process onlymessagesAdded. Store newhistoryId. - History expired (404): Fall back to initial sync. Idempotency prevents double-processing.
Unanswered Thread Detection
- During fetch_classify: When a new message arrives, update
THD#<threadId>. If from Adam → clearunanswered_since. If from someone else and Adam hasn't replied after this message → setunanswered_since. - During daily_digest: Re-verify via Gmail
threads.getto catch replies the poll missed (sent from phone, web). Only include truly unanswered threads older than threshold.
One-Time Setup Steps
Gmail OAuth
- Google Cloud Console → create/reuse project → enable Gmail API
- OAuth consent screen (Internal for Workspace)
- Create OAuth Client ID (Web app), redirect URI
http://localhost:8080/callback - Run
scripts/get_gmail_token.py→ authorize as adam@seahavenind.com - Store credentials in Secrets Manager:
exec-aide/gmail-oauth - Scope:
gmail.readonly
Slack App
- api.slack.com/apps → Create New App → "Exec Aide" in Sea Haven workspace
- Scopes:
chat:write,im:write - Install to workspace
- Store bot token in Secrets Manager:
exec-aide/slack-bot-token - Store Adam's user ID in SSM:
/exec-aide/adam-slack-user-id - Update Notion Slack Apps Inventory page
SSM Parameters
Create all /exec-aide/* parameters listed above.
Implementation Order
- Scaffold repo, template.yaml, .gitignore, samconfig.toml.example
- Gmail OAuth setup (one-time, needed before testing)
- Slack app setup (one-time)
- Secrets Manager + SSM parameter creation
shared/secrets.py(foundational)shared/gmail.py+shared/dynamo.pyshared/classify.pyshared/slack.pyfetch_classify/app.pydaily_digest/app.pysam build && sam deploy- Smoke test: manual invoke → check DynamoDB → verify Slack DM
- README
- Notion: AWS Architecture Map + Slack Apps Inventory updates
- Project memory entry
Verification
sam buildsucceedssam deploy— stack creates without errors- Manual invoke of fetch-classify → check CloudWatch logs for successful Gmail sync → DynamoDB has MSG and META items
- Send test email with "URGENT" subject → wait 15 min (or manual invoke) → Slack DM received
- Manual invoke of daily-digest → Slack digest DM received with correct sections
- Send email directly to Adam without CC'ing work-orders@ → verify it appears in "Bypassed Work Orders" section of next digest
- Leave an email unanswered for 24h → verify it appears in "Unanswered Threads" section