This is the complete guide: what it is, how to install it, and how every feature works. It is written for people and for AI agents setting it up on someone's behalf.
What it is
One Cloudflare Worker and one D1 (SQLite) database. A cron trigger runs every minute: it sends emails that are due, checks mailboxes for replies, advances drip series and retries webhooks. The app (UI), the REST API (/api) and the MCP endpoint (/mcp) are all served by the same Worker and protected by one passcode.
- Mailboxes: Gmail / Google Workspace (OAuth, sends through the Gmail API) and Amazon SES (sends through the SES API, replies arrive through Cloudflare Email Routing).
- Sending is always scheduled. Every email has a send time; empty means within a minute. Each mailbox has a daily limit and a minimum gap between sends that are always enforced.
- Your data stays yours. Contacts, emails, replies, settings and AI costs live in your own D1 database.
Typical uses: founder-led cold outreach, agency outreach across several mailboxes, product onboarding drips, newsletters to sign-ups, and AI agents that send and answer email.
Requirements
- A Cloudflare account. The free plan works at low volume; Workers Paid ($5/month) gives more CPU time and subrequests for steady per-minute work.
- Node.js 18+ and npm on the machine you deploy from.
- A Google account to send from (Gmail or Google Workspace) and a Google Cloud project for the OAuth client.
- Optional: an OpenRouter API key (AI reply categories and AI personalization), a Google Cloud Pub/Sub topic (instant reply detection), an AWS account with SES (newsletter-style sending), a domain on Cloudflare DNS (for SES replies).
Plan on 30–45 minutes for a first install.
Install
1. Get the code
git clone https://github.com/yusufnb/stay-in-sight
cd stay-in-sight
npm install
npx wrangler login
2. Create the database
npx wrangler d1 create cms-outbound
Copy the database_name and database_id it prints into wrangler.toml under [[d1_databases]]. The binding must stay DB. Then create the tables:
npm run db:migrate
3. Deploy and set the public URL
npm run deploy
It prints your URL, for example https://cms-outbound.<your-subdomain>.workers.dev. Put it in wrangler.toml as PUBLIC_URL and deploy again. The public URL is used for the tracking pixel, unsubscribe links and the Google OAuth redirect, so set it before connecting mailboxes. If you leave it empty, the first URL you use to reach the app is remembered. A custom domain can be added under Workers & Pages → your Worker → Settings → Domains & Routes.
4. Set the passcode
openssl rand -base64 24 # generate a strong passcode if you like
npx wrangler secret put PASSCODE
The passcode signs you into the app (30-day cookie), and is the Bearer token for the API and MCP. It is the only setting that can't be changed from inside the app. Changing it signs everyone out.
5. Connect Google (Gmail)
- In Google Cloud Console, create or pick a project and enable the Gmail API.
- OAuth consent screen:
- Only Google Workspace mailboxes in your own organization → choose Internal (no verification; refresh tokens don't expire).
- Gmail.com mailboxes, or several domains → choose External, add each sending address under Test users, then click Publish app. While an app is in Testing, Google expires refresh tokens after 7 days. An unverified published app shows a “Google hasn't verified this app” warning you can click through for your own mailboxes.
- Scopes used:
gmail.send,gmail.readonly,openid,email,profile.
- Credentials → Create OAuth client ID → Web application. Authorized redirect URI:
https://<your-worker>/oauth/google/callback(the app shows the exact value on the MCP & API page). - Store the client, either as Worker secrets or by pasting both values on the app's Settings page:
npx wrangler secret put GOOGLE_CLIENT_ID
npx wrangler secret put GOOGLE_CLIENT_SECRET
- Open the app, sign in, go to Mailboxes → Connect Google mailbox, and approve. Set the mailbox's reply webhook URL (and optional signing secret) if you want replies posted to your system.
6. Optional: AI (OpenRouter)
npx wrangler secret put OPENROUTER_API_KEY
Or paste the key on Settings. The default model is OPENROUTER_MODEL in wrangler.toml; any OpenRouter model id can be chosen in the app. Without a key, only literal STOP replies get a category and AI personalization is unavailable.
7. Optional: instant reply detection (Gmail push)
Without this, replies are found by polling Gmail history every minute, which is enough for most use.
- Create a Pub/Sub topic, e.g.
projects/<project>/topics/gmail-outbound, and grant[email protected]the Pub/Sub Publisher role on it. - Create a push subscription whose endpoint is the URL shown under Mailboxes → Reply detection (
https://<worker>/webhooks/gmail?token=…). - Set
GMAIL_PUBSUB_TOPICinwrangler.tomland redeploy, or save the topic on Settings (it sets up push on every connected mailbox straight away). Watches are renewed automatically before their 7-day expiry.
8. Check it end to end
- Send → Single email to an address you control in another account, time left empty. It goes out within a minute; watch it on Emails.
- Reply with “Sounds good, can we talk Tuesday?”. It appears under Replies, categorized, and your webhook shows delivered.
- Reply STOP. The address appears under Do-not-send.
- Connect MCP (exact command on the MCP & API page) and ask your agent for an overview.
One-command deploys (tenants)
Each deployment can be one file, tenants/<tenant>.env, holding its Worker name, database, settings and secrets:
cp tenants/example.env tenants/acme.env # fill it in
npm run deploy:tenant -- acme --dry-run # check, changes nothing
npm run deploy:tenant -- acme
That command finds or creates the tenant's D1 database, writes a Wrangler config based on wrangler.toml, applies migrations, deploys the Worker, uploads the secrets in the file, sets PUBLIC_URL on a first deploy, and prints the Google redirect URI, MCP URL and Pub/Sub push endpoint. Tenant files are gitignored. One Google OAuth client can serve several tenants: add each tenant's redirect URI to it.
Settings you can change in the app
After the first deploy, everything except the passcode can be set on the Settings page instead of with wrangler secret put or a redeploy: Google client ID and secret, OpenRouter key, Gmail Pub/Sub topic, emails per minute, public URL and app name. A value saved in the app overrides the deploy-time value; Clear falls back to it. Secrets are write-only. App-saved values live in D1; Worker secrets are hidden even from the D1 console, so choose per value.
Local development
cp .dev.vars.example .dev.vars # set PASSCODE (and Google credentials to test OAuth)
npm run db:migrate:local
npm run dev # http://localhost:8787
curl "localhost:8787/__scheduled" # run the cron once
npm test # unit and flow tests with mocked Gmail
npm run typecheck
For OAuth locally, add http://localhost:8787/oauth/google/callback as a redirect URI on the Google client. Stop the dev server before running local migrations.
Updating
git pull
npm run db:migrate # applies any new files in migrations/
npm run deploy # or: npm run deploy:tenant -- <tenant>
npx wrangler tail # live logs
Mailboxes
A mailbox is a Gmail / Workspace account connected with OAuth, or an SES identity. Each one has:
- Daily limit (1–2,000, default 50), counted over any rolling 24 hours, and a throttle (minimum minutes between sends, 1–1,440, default 5). Both always apply, however an email was scheduled. An email over a limit stays scheduled with a
waiting: …reason and goes out at the next free slot. - Purpose: cold outreach, newsletter or conversation (below).
- Senders: identities it can send as.
- A reply webhook URL and optional signing secret.
Gmail caps sending at about 500 a day for gmail.com and 2,000 for Workspace; keep limits well under that. A mailbox whose Google connection was revoked or expired shows Reconnect.
Mailbox purposes
| Cold outreach | Newsletter | Conversation | |
|---|---|---|---|
| For | One-to-one emails to people who haven't heard from you | Bulk mail to people who signed up | Back-and-forth with people who wrote to you |
| Tracking pixel | Off | On | Off |
List-Unsubscribe + one-click |
Off | On | Off |
| Footer | If you'd rather not hear from me, just reply "stop". |
Unsubscribe: {{unsubscribe_url}} |
None |
List-Id, Precedence: bulk |
No | Yes | No |
| Auto-send spacing | Throttle plus up to 60% more, uneven | Exactly the throttle | Exactly the throttle |
| Blocked by do-not-send scopes | all, marketing | any | all |
| A STOP or unsubscribe adds scope | marketing | newsletter | all |
| Suggested limits | 30/day, 1 every 8 min | 1,000/day, 1 every 1 min | 200/day, 1 every 1 min |
New Gmail mailboxes start as cold outreach, SES mailboxes as newsletters. Tracking, the unsubscribe header and the footer can be overridden per mailbox. For commercial email, put your postal address in the footer. Mail is built when it's sent, so changes apply to emails already queued.
Warm-up
Turn it on in a mailbox's Sending tab (or PATCH /api/mailboxes/:id {"warmup": true, "warmup_start": 5, "warmup_step": 3}). Day 1 allows warmup_start emails, each day adds warmup_step, until the daily limit. With 5 and 3 and a limit of 30: 5, 8, 11, … and 30 a day from day 10. Auto-send plans around the growing limit. Warm-up paces volume only; it doesn't send or answer warm-up emails, so use an engagement warm-up service as well if you want one.
Mailbox groups
Several mailboxes that send as one, mainly for cold outreach. Create one under Mailboxes → Mailbox groups (or POST /api/groups {"name": "Outreach team", "mailboxes": [1, 2, 3]}). Send through it by choosing the group as the sender, or "group": "<name or id>" in the API and MCP.
- Each email goes to the member with the earliest free slot by its own throttle, daily limit and warm-up. Paused or disconnected members are skipped.
- Each email goes out as that mailbox's default sender (
from_nameandreply_tocan be overridden). - Group sends are always auto-planned;
send_at/start_atis the earliest time. - Someone who already has email from a member stays with that member, so follow-ups come from the same address.
Senders
A sender is an identity: From name, From address, Reply-To, a label, a key for the API and MCP, and a description of when to use it that people and agents read. Every mailbox has a default sender. For Gmail the From address must be the mailbox or a verified “Send mail as” alias; for SES any address on the verified domain. Values are copied onto each email when scheduled.
Amazon SES mailboxes
SES has no inbox, so each SES email gets a reply address like reply+<token>@reply.example.com, which Cloudflare Email Routing delivers to the Worker; bounces and complaints arrive through SNS. Your domain's DNS must be on Cloudflare.
- Verify your domain in SES (Verified identities → Domain, Easy DKIM on). Add the three DKIM CNAMEs in Cloudflare DNS as DNS only.
- Request production access to leave the SES sandbox.
- Create a send-only IAM user with
ses:SendEmail,ses:SendRawEmailon your identity and configuration set, plusses:GetAccount,ses:GetEmailIdentity. Create an access key. - Route replies: Cloudflare → your domain → Email → Email Routing → add subdomain
reply→ Catch-all → Send to a Worker → your Worker. - Bounces and complaints: create an SNS topic, an HTTPS subscription to the URL under Settings → AWS SES events (SNS) (raw delivery off; it confirms itself), and an SES configuration set with an SNS destination for hard bounces, complaints and rejects.
- Add the mailbox: Mailboxes → Add AWS SES mailbox with From address, region, keys, configuration set, reply domain and an optional forward-to inbox.
- Test: send to
[email protected]; it shows Bounced and lands on do-not-send.
Contacts and lists
Import a CSV on Contacts (an email or to column is required; name optional; every other column becomes a field), or add contacts through the API:
curl -X POST https://<worker>/api/contacts \
-H "Authorization: Bearer <PASSCODE>" -H "content-type: application/json" \
-d '{"email":"[email protected]","first_name":"Jane","company":"Acme","plan":"pro","lists":["Q1 Leads"]}'
Field keys are lowercased with spaces turned into _, and every field is a {{variable}}. Posting the same email again updates the contact; blanks never erase values. A contact can be on any number of lists.
Filtering, sorting and bulk actions
On Contacts, sort by any column or field and add conditions (match all or any). Quick filters: never emailed, not emailed in 30 days, opened but never replied, replied, on do-not-send, added in the last 7 days. Tick the header box, then Select all N matching to act on everyone across pages: add to list, remove from list, export CSV, delete.
| field | ops |
|---|---|
email, name, field:<name> |
is, is_not, contains, not_contains, starts_with, ends_with, is_empty, is_not_empty, gt, lt |
sent_count |
is, gt, lt |
created_at, last_sent_at, last_reply_at |
within_days, older_than_days, before, after, is_empty, is_not_empty |
opened, replied, do_not_send |
is true / false |
list, series |
in, not_in |
The API takes the same rules, and bulk actions take {"filter": {...}, "exclude_ids": [...]} instead of ids.
Templates
A template has a permanent template_id (e.g. intro-v1), a subject and a body with {{variables}}. A template reaches each address at most once: anyone who has it scheduled, sending, sent or bounced is skipped, enforced by a unique index. Cancelled, failed and skipped emails don't count. Re-send a template to a growing list and only new people get it. A required variable that's blank skips the contact (unless AI personalization is on). Values inserted into HTML are escaped.
Sending
Single emails and the API
POST /api/emails
{ "sender": "sam", "to": "[email protected]", "subject": "Quick question",
"body": "Hi Jane…", "send_at": "2026-11-12T09:30:00-05:00",
"campaign": "q4-founders", "metadata": { "crm_id": "123" } }
body is HTML if it contains tags. send_at is ISO 8601 or epoch; omit to send on the next minute. metadata is echoed in reply webhooks. Send up to 1,000 at once with {"emails": [...], "auto_schedule": true}; use template_id and vars instead of subject and body.
Auto-send
With auto_schedule, each email gets the earliest time its mailbox allows, in order: at least the throttle after any other send, and no more than the daily limit in any rolling 24 hours, counting what's already queued. Existing scheduled emails keep their times. Preview with GET /api/mailboxes/:id/plan?count=N or GET /api/groups/:id/plan?count=N.
Sending to lists
On Send → Send to lists, pick lists, a sender or group, and a template or message. Filter, order and limit narrows it:
- Conditions (same as Contacts): only matching contacts get it.
- Send order: the order emails are scheduled in — the order contacts joined the list by default, or name, date added, emails sent, last emailed, or any field (numbers sort by value).
- Only send to the first N: counts only people who can get it. With a template, sending again reaches the next N — an easy way to send a big list in daily batches.
The preview shows how many match, how many will get it, who's skipped and why (do-not-send, already received, missing variable), how many the limit holds back, the first few names in order, and the day-by-day plan.
POST /api/lists/send
{ "lists": ["Users"], "sender": "me", "template_id": "upgrade-offer", "auto_schedule": true,
"filter": { "rules": [ {"field":"field:plan","op":"is","value":"pro"},
{"field":"last_sent_at","op":"older_than_days","value":30} ] },
"sort": "field:score", "dir": "desc", "limit": 200 }
Up to 5,000 per send. POST /api/lists/preview takes the same body and sends nothing.
Managing scheduled emails
On Emails, filter by status, campaign, mailbox, purpose, group, template or engagement. Tick the header box and Select all N scheduled to cancel or send now across pages. Send now sends the first 25 straight away (skipping the throttle, not the daily limit) and makes the rest due for the next runs. Unsent emails can be edited or rescheduled one by one; editing a cancelled or failed email re-queues it.
AI personalization
Turn on Personalize each email with AI in a template and write instructions in plain words. Just before each email from that template is sent — from any path: Send page, list send, series, API, MCP — a model rewrites it for that person.
- It sees the email as written for them, every contact field as it is at that moment (keep fields like
last_loginorplancurrent through the API), their lists, engagement (emails sent, last emailed, opened, replied), the email's metadata and today's date. - It can change subject and body as instructed while keeping tone, links and HTML; fill a missing
{{variable}}; or skip with a reason (markedAI: logged in today). An email is never sent with a placeholder left in. - If it fails: retry (5 minutes later, up to 3 times, then failed) or send as written.
- Test with a contact in the template editor: written vs personalized side by side, with the model, cost and data it saw. Works on unsaved instructions. API
POST /api/templates/personalize; MCPpreview_personalization. - System prompt: a default frames every call; a template can replace it. The skip rule, JSON answer format and date are always appended.
- Cost: one call per email on the app's model or the template's own. Each email records status, model and cost.
PATCH /api/templates/checkin-v1
{ "ai_enabled": true, "ai_on_error": "retry",
"ai_prompt": "If first_name is missing, work it out from the email address. If last_login is within 7 days, mention what they did recently; if it's over 30 days ago, invite them back. If they logged in today, skip." }
Series (drip campaigns)
An ordered set of template emails sent one after another to everyone on the series' lists.
- Steps are templates (write one in the step, or pick a saved one). Contact fields fill the variables.
- Delays: step 1 goes
delay_daysafter someone joins (0 = right away); each later stepdelay_daysafter the previous email actually went out. - Joining later: anyone added to an attached list later is enrolled at step 1. Attach a list with
"only_new": trueto skip people already on it. Your app enrolls people by adding them to the list. - Adding steps later reaches everyone who hasn't had them, including people who had finished.
- Stopping: do-not-send and hard bounces stop a person; optionally a reply does too (
stop_on_reply). Pause a whole series withactive: false. - Emails carry
series_id,series_step_idand series metadata for webhooks.
POST /api/series
{ "name": "Onboarding", "sender": "product", "lists": ["New users"],
"steps": [
{ "subject": "Welcome, {{first_name}}", "body": "…", "delay_days": 0 },
{ "subject": "Capture leads with forms", "body": "…", "delay_days": 2 } ] }
Replies
Replies are detected by Gmail history polling every minute or instantly with Gmail push (SES replies arrive through Email Routing). Auto-replies and bounces are filtered out, quoted history is stripped, and the thread becomes a conversation.
- AI categories: define categories by name and description on Reply categories (e.g. interested, not now, referral). Fill in About your outreach so replies are read in context. One short call per reply; the cost is stored.
stopalways exists. - STOP:
stop,unsubscribe,remove me,opt outon the first line, or astopcategory, puts the replier and the original recipient on do-not-send and skips their pending emails. - Threaded replies: answer any sent email in the same thread from its page,
POST /api/emails/:id/reply {"body": "…"}, or MCPreply_to_email.
Reply webhook
For every human reply, the Worker POSTs to the mailbox's webhook URL with headers x-outbound-event: reply | stop, x-outbound-category, and x-outbound-signature: sha256=<HMAC-SHA256 of the raw body with your secret>:
{ "event": "reply.received", "mailbox": "[email protected]", "campaign": "q4-founders",
"email": { "id": 12, "to": "[email protected]", "subject": "Quick question", "metadata": { "crm_id": "123" } },
"reply": { "id": 7, "from": "[email protected]", "text": "Sounds good, Tuesday works.", "is_stop": false,
"category": { "name": "interested", "description": "…" }, "category_source": "ai" },
"thread_id": "18c…",
"conversation": [ { "role": "us", "text": "Hi Jane…" }, { "role": "them", "text": "Sounds good, Tuesday works." } ] }
Non-2xx responses are retried with backoff (up to 6 attempts); redeliver by hand from the app.
Do-not-send
Entries come from STOP replies, unsubscribe links, hard bounces, spam complaints, and manual adds. Each has a scope: all (no mailbox emails them), marketing (no outreach or newsletters; conversation mailboxes can still answer) or newsletter. An address keeps its widest scope. Pending emails to a blocked address are skipped.
Person timeline and campaigns
Open any address for its timeline: every email from any mailbox, sender, list send, series or group (with opens), replies, list joins, series progress and do-not-send changes. GET /api/people/:email/activity; MCP get_activity. Campaigns shows sent, opened, replied and STOP rates per campaign name with a per-day breakdown.
Authentication
| Where | How |
|---|---|
| App | /login with the passcode → 30-day signed cookie |
| REST API | Authorization: Bearer <PASSCODE> (or x-passcode header) |
| MCP | Authorization: Bearer <PASSCODE>, or ?key=<PASSCODE> for URL-only clients |
Public without the passcode: the tracking pixel /t/<token>.gif, unsubscribe /u/<token>, the OAuth callback (signed state), and the Gmail push endpoint (its own token, regenerable on Settings).
MCP
Endpoint: https://<worker>/mcp (streamable HTTP, stateless JSON).
claude mcp add --transport http outbound https://<worker>/mcp --header "Authorization: Bearer <PASSCODE>"
64 tools: overview, get_settings, update_settings, list_mailboxes, update_mailbox, add_ses_mailbox, check_ses_mailbox, refresh_send_as, list_senders, save_sender, delete_sender, list_groups, save_group, delete_group, send_email, reply_to_email, schedule_emails, preview_schedule, list_emails, get_email, update_email, cancel_emails, send_now, get_conversation, list_templates, get_template, save_template, delete_template, check_template_recipients, preview_personalization, list_lists, save_list, delete_list, list_contacts, save_contact, get_contact, update_contact, delete_contacts, import_contacts, set_contact_lists, preview_list_send, send_to_lists, list_series, save_series, save_series_step, delete_series_step, delete_series, list_series_enrollments, set_series_enrollment, get_activity, list_replies, check_replies, redeliver_webhook, list_categories, save_category, delete_category, set_categorizer, set_reply_category, recategorize_reply, list_campaigns, campaign_stats, dnc_list, dnc_add, dnc_remove.
Everything in the app can be done over MCP except disconnecting a mailbox and regenerating the Gmail push token, which stay in the UI on purpose.
REST API
Interactive docs (Swagger UI) are at /api-docs on your deployment and the OpenAPI 3 spec at /api-docs/openapi.json (signed in, or with the Bearer passcode). All routes are under /api, JSON in and out.
| Area | Routes |
|---|---|
| Overview and settings | GET /overview, GET /config, GET/PUT /settings, POST /run |
| Mailboxes | GET /mailboxes, PATCH/DELETE /mailboxes/:id, POST /mailboxes/:id/sync, POST /mailboxes/:id/send-as, GET /mailboxes/:id/plan |
| Groups and senders | GET/POST /groups, GET/PATCH/DELETE /groups/:id, GET /groups/:id/plan, GET/POST /senders, GET/PATCH/DELETE /senders/:id |
| Emails | GET/POST /emails, GET/PATCH /emails/:id, GET /emails/:id/conversation, POST /emails/:id/reply, POST /emails/cancel, POST /emails/send-now |
| Templates | GET/POST /templates, GET/PATCH/DELETE /templates/:id, POST /templates/:id/check, POST /templates/personalize |
| Contacts and lists | GET/POST /contacts, GET/PATCH /contacts/:id, POST /contacts/import, POST /contacts/lists, POST /contacts/delete, GET /contacts/fields, GET /contacts/export, GET/POST /lists, PATCH/DELETE /lists/:id, POST /lists/preview, POST /lists/send |
| Series | GET/POST /series, GET/PATCH/DELETE /series/:id, POST /series/:id/steps, PATCH/DELETE /series/:id/steps/:step, GET /series/:id/enrollments, PATCH /series/:id/enrollments/:id, POST /series/:id/run |
| Replies and AI | GET /replies, POST /replies/:id/redeliver, POST /replies/:id/category, POST /replies/:id/categorize, GET/POST /categories, DELETE /categories/:name, GET/PUT /ai, POST /ai/test |
| People, campaigns, do-not-send | GET /people/:email/activity, GET /campaigns, GET /campaigns/:name, GET/POST/DELETE /dnc |
Behaviour notes
- Sending runs every minute, up to
SEND_BATCH(default 20) emails per run across mailboxes. Failed sends retry twice, 5 minutes apart; auth failures flag the mailbox for reconnect. - Opens are approximate: Apple Mail Privacy Protection and Gmail's image proxy can fetch the pixel without a human, and clients that block images never register.
- Replies are matched by Gmail thread, so only replies to emails sent by Stay in sight are picked up. If polling is down for more than about a week, Gmail's history cursor expires and that gap is missed (the mailbox warns you).
- Cloudflare plan: free works at low volume; Workers Paid helps once several mailboxes send and sync every minute.
Troubleshooting
- “Auth failed, reconnect mailbox”: the Google consent screen is probably still in Testing (tokens expire after 7 days). Publish the app, then click Reconnect.
- Emails stay scheduled with
waiting: …: a daily limit, throttle or warm-up limit is holding them; they go at the next free slot. Check the mailbox's Sending tab. - Contacts skipped with “missing value for …”: the template needs a field those contacts don't have. Fill the field, or turn on AI personalization to fill it.
- “already received this template”: by design; a template reaches an address once. Use a new template id to send again.
- No replies showing: replies are only matched to emails sent by this app, and arrive within a minute (seconds with Gmail push). Click Run now to check immediately.
- Logs:
npx wrangler tail.
How it compares
Hosted cold-email tools (lemlist, Instantly, Smartlead) are quicker to start and include lead databases, engagement warm-up networks and multi-seat teams. Stay in sight is self-hosted and MIT licensed: your data sits in your own Cloudflare D1 database, there are no per-seat or per-contact fees, newsletters and conversations live alongside cold outreach, and agents get a 64-tool MCP server and a full REST API. See /compare for the detailed comparison.
Links
- Source and issues: https://github.com/yusufnb/stay-in-sight
- License: MIT
- Full README and DEPLOY guide in the repository