OpenOutSend
The sending half of OpenOutreach. OpenOutFind finds and qualifies B2B leads and prints them; this is what sends them. Want both in one install, with one wizard and one command? That is OpenOutreach, which requires both packages and hosts them in one process — this repo stays a program of its own underneath it.
The boundary between the two halves is a pipe, and nothing else crosses it:
outfind find 50 --json | outsend
find writes qualified leads as JSON Lines on stdout. outsend reads them, stores them in its own
database, and exits — it transmits nothing at that moment. Delivery, pacing and whatever step
structure it grows are its own business, on its own clock.
The pipe is one-way by design. Every consumer sees the same bytes, so a file dropped into Instantly or Smartlead gets exactly what this receiver gets, and "our own sender has no privileged path" is held by construction rather than by discipline.
Legal notice
Every message this sends ends with one added line, Sent with OpenOutreach, appended after your
signature and opt-out (openoutsend/emails/sender.py). It is always on — there is no setting to
remove it. It carries no link, no pixel, and no per-install identifier; nothing reports back whether a
send happened or whether the line survived. This is how the project sustains itself with no
subscription fee, no promo campaign, and no markup on anything you buy through it.
Everything else about a send — the address you write from, what you write, and your compliance with anti-spam law (CAN-SPAM, GDPR/ePrivacy, CASL, and others) — is yours: this tool provides the mechanism, not legal cover.
Status: it runs end to end — install, pipe, connect a box, send
outsend is a command and the store is its own. Install it, pipe leads in, and they land as rows;
a second, separate invocation is what mails them:
uv tool install openoutsend # or pip install -e . from a checkout
outsend check # confirm the environment can send from a box
outfind find 50 --json | outsend # store
outsend send # one pass: read, answer, later steps, follow up, open
outsend send 5 # or: keep going until 5 are open
outsend send all # or: until nobody is left to email
The two invocations are separate on purpose: a pipe's right-hand side must not block on the network
while a producer is still writing, and the cadences differ — leads arrive when find runs, mail moves
on the mailbox's clock. So the cron line is two entries, not one command doing both.
It reads JSON Lines on stdin, upserts on lead_id within the sequence (cold, unless --sequence
names another; see Sequences below), checks every address against the
suppression list at the door, skips and counts a malformed line, prints the counts to stderr,
and exits 0 when every line became a row. Its database is
~/.openoutsend/data/db.sqlite3 (OUTSEND_HOME / OUTSEND_DB override it) and it migrates itself on
first run, so a fresh install is an ingest that works rather than a traceback.
outsend send is one pass: it reads the mail, answers every thread the lead has replied in, writes
again to the ones who went quiet, opens as many first emails as the guards allow right now, and
exits. Reading first is what makes the rest honest — an opt-out that arrived overnight suppresses the
person before anything is written to them. That is the form a timer wants, and it is what the cron
line fires.
outsend send 5 is a goal, and it waits. Five means five new conversations, and the run keeps
passing until they are open — sleeping out the spacing clock between sends, the daily ceiling, and the
sending window, including overnight and across a weekend if that is what the guards say. Nothing
polls: the planner is asked when it could next open one (planner.next_first_email_at) and the
run sleeps until then, so the external cycler that used to check whether now was a good time has
nothing left to do. The wait is spent working — every five minutes it wakes for a full pass, so
replies are still answered on a mailbox's cadence while the openers are on hold. Only openers count
toward the goal; a busy inbox cannot satisfy it. It stops short, and says so, when the lead pool is
drained (waiting cannot refill it — ingest is a separate invocation), when no mailbox is connected, or
when two passes in a row fail a send and open nothing, which is a receiver's answer rather than a
clock. Ctrl-C hands back the conversations already opened.
outsend send all makes the pool itself the goal — everybody who has an address gets one, and the
run ends when nobody is left. It is the same run with one verdict inverted: the drained pool that
fails send 5 at three of five is exactly what all asked for, so it exits 0 saying how many went.
Prefer it to typing a count you had to look up, since that number is stale by the time you type it.
The other endings stay failures — no mailbox, and a receiver refusing twice in a row.
outsend send --agent-draft opts the opener step out of AI_MODEL, for a caller that is itself an
LLM (Claude Code driving the CLI) and would otherwise pay for a second, redundant model call to write
what it could write itself. The pass stops at the first deal needing an opener and exits non-zero with
error: draft_pending: <message> ({"error": {"type": "draft_pending", ...}} under --json), carrying
the deal's own profile_text/company/title — the same fields AI_MODEL's prompt is built from.
Judge the opener the same way you would judge anything else in the conversation, then re-run with the
answer:
outsend send --agent-draft --subject "Quick one" --body "Saw you're hiring for platform roles..."
This resumes the same deal — no id to track, since at most one is ever pending — and sends it the
moment a mailbox is free (an answer already given is kept, not thrown away, if the spacing clock or the
sending window is holding every box right now). No count yet — it is one pass at a time, like a bare
outsend send. Don't pass --agent-draft unless you intend to answer every draft_pending it raises;
without it, AI_MODEL writes every opener as before. --agent-draft also drops the model out of
outsend check's requirements for that run — OUTSEND_AI_MODEL/OUTSEND_LLM_API_KEY are never
checked or pinged, since a key that will never be spent is not a reason to stop.
So find N emails --json | outsend && outsend send N needs no cron entry at all: the second command
returns when the conversations are open, whether that takes four minutes or spans a weekend. A timer
firing a bare outsend send remains the other way to run it, and the two do not conflict — the pass is
the same either way, and nothing here is state a lost process would strand.
Every email is written from the whole history with that person. The model reads every earlier
prompt, every email sent, every reply and how long each silence lasted, verbatim, and then the prompt
for the email at hand. The first email's prompt is a file, openoutsend/sequences/cold/E0-opener.md;
a copy of the cold/ folder at ~/.openoutsend/sequences/cold/ replaces it whole.
A lead who never answers gets two more emails, then the pursuit ends — after three working days,
then five, and the deal closes as unresponsive. OUTSEND_FOLLOW_UPS=off turns those two off for the
install. A reply at any point ends the follow-ups and the conversation takes over. Follow-ups are cold volume and are treated as such: they share one daily
cap, one spacing clock and one sending window with the openers, rather than claiming ahead of them out
of a budget of their own. A reply obeys none of the three, because answering someone who wrote to you
is not cold volume and holding the answer until Monday is worse than sending it at 21:00.
Sequences
Cold outreach is one sequence, built in. Any other is a folder of prompt files, one per email, kept wherever its owner keeps it and named on the command line:
after-a-demo/
E0-1d-thanks-and-one-question.md
E1-3d-the-thing-they-asked-about.md
E2-5d-last-note.md
some-export --json | outsend --sequence after-a-demo # enrol or update, exit
outsend send # mail what is due, in every sequence
outsend send --sequence after-a-demo # or in this one alone
- The filename is the schedule:
E<number>[-<days>d]-<name>.md.E0goes its days after the lead'sstart_at(the ingest time when the line has none); every later step goes its days after the previous step, in business days, on the same thread and box. A bad name, a duplicate or missing number, a0dafterE0or a Jinja syntax error fails the command that named the folder. - The body is the prompt, rendered by Jinja with every key of the piped line that is not a lead
column:
{{ first_name }},{{ booking_url }}, whatever the export carries. A field the line lacks is an error for that lead, never a blank. {field}is what the model writes. A prompt that asks for the link "written exactly as{order_url}" gets the placeholder back, and it is filled in from the piped line after the model has written, so a link or a price is never typed wrong. A placeholder naming a field the line lacks, or a phrase fromOUTSEND_BANNED_PHRASES, is redrafted once and then held: never sent, reported, and out of every send until its line is piped again.- One deal per lead per sequence. The same
lead_idpiped into cold and into an event sequence is two deals. Re-piping into the same sequence is a correction, latest wins key by key, and a line with"stop": truetakes the lead out of every remaining send. - A pass sends every sequence, cold among them, in one queue, oldest due first, because they all
spend the same boxes' cap, spacing and window, and at most one message per address.
--sequence Fnarrows a pass toF. Reading the mail and answering replies are never narrowed. - Replies are per install (
OUTSEND_REPLIES). Withagent, the default, the agent answers and the remaining steps stop. Withperson, each reply is listed once for you to answer by hand, a worded unsubscribe in it is still honoured, and the steps go on. - Follow-ups are per install (
OUTSEND_FOLLOW_UPS); an install mailing event sequences usually turns them off. With them on, each lands only if it fits before the next step is due. - Every send records its sequence, its step, the step file's path, the prompt as rendered and, when one was drawn, the prompt line's id.
examples/sequences/after-a-demo/ is a whole sequence to copy:
three emails after a product demo, with a piped date, {{ field }}, a {booking_url} placeholder
and the stop field, and the four commands that run it.
outsend check verifies what a run needs — what this install sells and to whom, the name that
signs the mail, and a mailbox to send it from — and it runs implicitly on every send, so a setup step
is never something a timer discovers. It comes from the environment and nothing is asked, on a
terminal as much as headless: this is the right-hand side of a pipe, where a question has nobody to
answer it. Whatever is missing is one error naming every variable that would have satisfied it. The
mailbox is connected only once its credentials pass an SMTP login, because the provider has no health
API and that login is the only gate there is.
Releases are every green push to main (.github/workflows/deploy.yml): tests, then a build and a
PyPI upload over trusted publishing, with the version derived from the commit count rather than
committed — the finder's rule, for the reason it went there, since a release nobody has to remember
cannot drift. No token is stored anywhere; the publisher is registered against the workflow filename
and the pypi environment, so neither may be renamed.
The package is on PyPI as openoutsend — uv tool install openoutsend, or
uvx --from openoutsend outsend check to try it without installing anything. openoutreach pins it
exactly and installs it for you.
🤖 Use it from Claude Code
This repo ships a Claude Code plugin, so you can send without leaving your agent session:
/plugin marketplace add eracle/OpenOutSend
/plugin install openoutsend@openoutsend
The skill (skills/send-mail/SKILL.md) teaches Claude when send is safe to run and when it isn't
(never unasked — it puts mail in strangers' inboxes), how --agent-draft lets it write the opener
itself instead of spending a second LLM key, and how to read the narration on stderr. Prefer skills to
plugins? Copy skills/send-mail/ into ~/.claude/skills/ instead.
Tests
pytest
pytest-django against a throwaway state dir (conftest.py redirects OUTSEND_HOME before Django
loads, so a test run never touches ~/.openoutsend). Nothing is skipped or ignored: the files that
came across with the transport now assert against this side's own models.
Layout
| Path | What it is |
|---|---|
openoutsend/leads/ |
app outsend_leads — what comes through the pipe: the models, ingest, suppression, the facts extraction |
openoutsend/emails/ |
app outsend_emails — the transport: SMTP, IMAP sync, the mail pass, threads, delivery policy, warmth |
openoutsend/core/ |
app outsend_core — the outreach agent, its templates, the sending window, and the stored site configuration |
openoutsend/docs/ |
how the agent and its templating work |
openoutsend/sequences/ |
the built-in cold sequence, one prompt file per step |
openoutsend/planner.py |
what is due, from which box, and when the next send can go |
openoutsend/settings.py |
this repo's own Django settings — one host of the apps, not the only one |
openoutsend/defaults.py |
what any host must splat: APPS, state_dir(), app_settings() |
openoutsend/send_pass.py |
one pass — read, answer, open — and the line saying what held it |
openoutsend/send_job.py |
send N — passes until the goal is open, waiting out the send clocks |
openoutsend/first_run.py |
what check verifies — the message fields, the model, the operator, the mailbox |
openoutsend/core/config.py |
the OUTSEND_* a message is written from, read fresh on every run |
openoutsend/__main__.py |
the outsend console script |
This repo carries no roadmap/ of its own — open work for it lives in the openoutreach-docs repo,
under roadmap/OpenOutSend/.
The app labels are namespaced outsend_* because these apps are hosted twice: by this
repo's settings.py, and by OpenOutreach, which installs them in one registry beside
OpenOutFind's — whose own engine app is also called core, and two apps cannot share a
label. So write cross-app model references label-first ("outsend_emails.Mailbox"), and
never write a table name as a literal — ask the model (Mailbox._meta.db_table).
Configuration
The environment is the operator seam — the only way in a timer has:
OUTSEND_OPERATOR_COUNTRY |
ISO 3166 alpha-2; resolves the local clock the sending window is measured in |
OUTSEND_AI_MODEL |
a pydantic-ai provider:model id, e.g. anthropic:claude-sonnet-4-5-20250929 |
OUTSEND_LLM_API_KEY / OUTSEND_LLM_API_BASE |
credentials for it; pinged once per run before any mail moves |
OUTSEND_PRODUCT_DOCS / OUTSEND_CAMPAIGN_TARGET / OUTSEND_BOOKING_LINK |
what a message is written from; the first two are required |
OUTSEND_OPERATOR_NAME / OUTSEND_OPERATOR_EMAIL |
who signs the mail, and the address every send is blind-copied to (blank for none) |
OUTSEND_MAILBOX_ADDRESS / OUTSEND_MAILBOX_PASSWORD |
the box to send from, and its app password — a Google box rejects the login password |
OUTSEND_SMTP_HOST / OUTSEND_SMTP_PORT / OUTSEND_IMAP_HOST / OUTSEND_IMAP_PORT |
only for a box that is not on Google Workspace; those four default to Gmail's and are never prompted for |
OUTSEND_SIGNATURE |
the sign-off appended to every send from that box; empty declines one for good |
OUTSEND_FOLLOW_UPS |
off stops the two follow-ups to a lead who never answered; on by default |
OUTSEND_REPLIES |
who answers a reply: agent (the default) writes back and stops the remaining steps; person lists it for you, still honours a worded unsubscribe, and lets the steps go on |
OUTSEND_BANNED_PHRASES |
a file of phrases no step or follow-up may contain, one per line, # for comments; none by default |
OUTSEND_HOME / OUTSEND_DB |
where the store lives |
These are read on every run, and none of them is remembered. Remembering what
somebody typed is a convenience for somebody who types; this program is an agent's, and
an agent supplies its environment every time. So outsend check — which outsend send
runs implicitly before any mail moves — verifies them and stops with one error naming
every variable that is missing. Two are verified against the provider rather than merely
read: the mailbox by its SMTP login, the model by one ping, neither of which has a health
API. Changing a variable changes the next run, with nothing stale to clear out first.
Two things are still rows, because neither is an answer somebody gave. The operator
is a Django User, written once — identity, not configuration. And the mailbox keeps
what it measured: its spacing clock and the daily capacity it learned from the
provider. Its credentials are seeded from the environment on the run that connects it,
because a box is connected by an accepted SMTP login rather than by a variable.
The store therefore still holds the mailbox's app password.
~/.openoutsend/data/db.sqlite3 is a file to keep: back it up, and do not copy it around.
The contract it has to implement
The full design — what crosses, the record's field set, exit-code meaning, and why ingest is
idempotent — lives in
roadmap/p1-e2-find-send-boundary-contract.md
in the openoutreach-docs repo. The parts this side owes:
- Ingest is idempotent, keyed on
lead_idand the sequence, so the pipe is allowed to be lossy and recovery is running it again. - Suppression is checked at the door and is terminal — the legal duty came here with
emails/, and a re-ingest must never resurrect somebody who opted out. An address that changed is re-checked, because ingest is lead-keyed while suppression is address-keyed. - Conflicts resolve latest-wins, field by field: a re-ingest is a correction, not a duplicate.
- A malformed line is skipped and counted, named on stderr, with a non-zero exit —
findspent real money on the rows behind it, so aborting the batch throws away paid work. - A blank
emailis stored, not rejected. An exportable row is not a mailable one; the address is an enrichment that a later run fills in for free.
License
MIT — see LICENSE.
Metadata
Release files for openoutsend 0.1.54
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| openoutsend-0.1.54.tar.gz | 224.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| openoutsend-0.1.54-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 397.3 kB
Release files / openoutsend-0.1.54.tar.gz
| Download URL | openoutsend-0.1.54.tar.gz |
|---|---|
| Size | 224.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d3871deb18959b295cd04fb6dda95b5a3cfc7e1552463c164efd87a7995ac606
|
|
BLAKE2b-256 checksum How to use checksums |
ca557886342367b6c5243da96d984d8cb7af41f003ff252032111b1af4529026
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 12, 2026.
Transparency logRelease files / openoutsend-0.1.54-py3-none-any.whl
| Download URL | openoutsend-0.1.54-py3-none-any.whl |
|---|---|
| Size | 172.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0e88fa68b2ef4113c1974479bbe4b7cb785f6eff601150ee22bbea52bd32474b
|
|
BLAKE2b-256 checksum How to use checksums |
d748f22329c27b7f2d70663e689352ccfb217ab0ef3e8eec5a758522f0ff181b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 12, 2026.
Transparency log