Skip to content

Documentation

Setup guide and reference

Install Stay in sight on your own Cloudflare account, connect Gmail or SES, and learn every feature: limits, warm-up, groups, lists, templates, AI personalization, series, replies, webhooks, the REST API and MCP.

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)

  1. In Google Cloud Console, create or pick a project and enable the Gmail API.
  2. 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.
  3. 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).
  4. 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
  1. 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.

  1. Create a Pub/Sub topic, e.g. projects/<project>/topics/gmail-outbound, and grant [email protected] the Pub/Sub Publisher role on it.
  2. Create a push subscription whose endpoint is the URL shown under Mailboxes → Reply detection (https://<worker>/webhooks/gmail?token=…).
  3. Set GMAIL_PUBSUB_TOPIC in wrangler.toml and 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

  1. Send → Single email to an address you control in another account, time left empty. It goes out within a minute; watch it on Emails.
  2. Reply with “Sounds good, can we talk Tuesday?”. It appears under Replies, categorized, and your webhook shows delivered.
  3. Reply STOP. The address appears under Do-not-send.
  4. 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_name and reply_to can be overridden).
  • Group sends are always auto-planned; send_at / start_at is 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.

  1. Verify your domain in SES (Verified identities → Domain, Easy DKIM on). Add the three DKIM CNAMEs in Cloudflare DNS as DNS only.
  2. Request production access to leave the SES sandbox.
  3. Create a send-only IAM user with ses:SendEmail, ses:SendRawEmail on your identity and configuration set, plus ses:GetAccount, ses:GetEmailIdentity. Create an access key.
  4. Route replies: Cloudflare → your domain → Email → Email Routing → add subdomain reply → Catch-all → Send to a Worker → your Worker.
  5. 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.
  6. Add the mailbox: Mailboxes → Add AWS SES mailbox with From address, region, keys, configuration set, reply domain and an optional forward-to inbox.
  7. 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_login or plan current 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 (marked AI: 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; MCP preview_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_days after someone joins (0 = right away); each later step delay_days after 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": true to 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 with active: false.
  • Emails carry series_id, series_step_id and 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. stop always exists.
  • STOP: stop, unsubscribe, remove me, opt out on the first line, or a stop category, 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 MCP reply_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