A PR agency in a box — an earned-media campaign run by a small team of scheduled agents.
Website · Quickstart · Architecture · Apache 2.0
Publicist runs the unglamorous half of public relations: researching outlets, writing a pitch that could only have been sent to that outlet, sending it, handling the reply, chasing exactly twice, answering journalist source requests, and reporting to the client every Friday whether the week went well or badly.
It is not a bulk-email tool. Every guarantee in it exists to stop the system doing the one thing that destroys an earned-media campaign: contacting the same person twice, or contacting them with something generic.
pip install git+https://github.com/chasewhughes/publicist # not on PyPI yet
publicist init ~/my-campaign
cd ~/my-campaign && publicist doctor
publicist run strategistNew campaigns default to a mail provider that sends nothing and writes
would-be messages to logs/outbox/. You have to choose to go live.
Seven single-purpose agents run on a cron schedule, in the order a real agency works: research in the morning, outreach after it, oversight at the end of the day. None of them remembers anything.
flowchart TB
subgraph agency["The five daily lanes — one agent each"]
direction LR
S["🔍 <b>Strategist</b><br/>07:30 daily<br/><i>researches outlets,<br/>writes pitch briefs</i>"]
O["✉️ <b>Outreach</b><br/>08:00 daily<br/><i>sends pitches,<br/>answers replies</i>"]
R["📰 <b>Source requests</b><br/>3× weekdays<br/><i>answers journalist<br/>queries, fast</i>"]
B["🌐 <b>Bookings</b><br/>Mon/Wed/Fri<br/><i>works booking<br/>platforms</i>"]
M["📊 <b>Manager</b><br/>19:00 daily<br/><i>QC, priorities,<br/>client reporting</i>"]
end
S -->|pitch brief| O
O -.->|ESCALATE| M
R -.->|ESCALATE| M
B -.->|ESCALATE| M
M -.->|guidance| S
agency --> CRM[("🗄️ <b>Campaign CRM</b><br/>targets · threads · messages<br/>briefs · budget · approvals<br/><br/><i>the only memory</i>")]
agency --> MAIL["📬 <b>Mail provider</b><br/>server-side threads"]
M ==>|weekly brief<br/>approvals| CLIENT["👤 <b>The client</b>"]
style CRM fill:#1f2937,stroke:#60a5fa,stroke-width:3px,color:#f9fafb
style CLIENT fill:#065f46,stroke:#34d399,stroke-width:2px,color:#ecfdf5
style agency fill:#0f172a,stroke:#334155,color:#e2e8f0
The agents never talk to each other. The strategist hands work to outreach by writing a row in the database. Any lane escalates to the manager by appending a line to a file. The manager pushes guidance back down the same way. A handoff written to disk survives a crash; a handoff held in a conversation does not.
| Agent | When | What it does | Can it send? |
|---|---|---|---|
🔍 strategist |
daily, early | Researches each outlet's recent work, picks the one angle that fits, writes a pitch brief | No |
✉️ outreach |
daily | Sends pitches from those briefs, handles inbound replies, chases exactly twice | Yes |
📥 inbox |
daily, afternoon | Inbound triage only — no new cold pitches | Replies only |
📰 requests |
3× weekdays | Answers journalist source requests before the query closes | Yes |
🌐 bookings |
Mon/Wed/Fri | Works booking platforms through a real browser | Yes |
📊 manager |
daily, evening | Reviews every lane, enforces the budget, and is the only agent that emails the client | Client only |
✍️ writer |
on demand | Drafts a bylined article against an accepted commission | No |
Before any pitch goes out, the sending agent runs it past a critic persona — a veteran booker who rejects anything generic, over-claimed, or longer than 120 words — and revises until the verdict is SEND.
Every lane is stateless. It starts with no memory of yesterday, does one job, records what it did, and exits. All durable memory lives in a SQLite CRM and the mail provider's own thread store.
That constraint is the design. A long-running agent that holds a campaign in its context drifts, forgets, and eventually re-pitches someone it already spoke to. A short-lived process that is forced to reconstruct state from a database before it writes a word cannot drift, because there is nothing to drift from. The interesting engineering is not in the agents — it is in the contract that makes forgetting safe.
Read docs/architecture.md for the full argument, and
src/publicist/prompts/STATE.md for the
contract itself, which is the file the agents actually read.
These are enforced in code and tested, not merely requested in a prompt:
- You cannot pitch the same target twice.
crm claimis a conditionalUPDATE; only one caller wins. A run that crashes between claiming and sending is recovered on the next run by checking whether a thread exists — never by guessing. - You cannot approach someone already approached on another channel.
crm already-reachedmatches on normalized name, domain and address, across every lane, and errs deliberately toward false positives. Silence from a target is not treated as permission to try again through a different door. - You cannot exceed the sending ramp. A new domain that opens at forty cold emails a day gets filtered, permanently on any useful timescale.
- Two lanes cannot run at once. A campaign-wide
flock, released by the kernel if a process dies. - A reply cancels the chase. Logging an inbound message clears the follow-up timer in the same transaction.
- Defeat bot protection. There is no CAPTCHA solver and no challenge
bypass. When a platform puts up a wall, the run stops and asks a human to sign
in once with
publicist login. Seedocs/browser-lane.mdfor the reasoning. - Claim things that are not true. Every campaign has a
positioning.mddefining the outer bound of what may be said about the client. The lanes are told, repeatedly, that a pitch needing a bigger claim is a pitch that needs a different angle. - Hide what it is. When a journalist's request says they do not want AI-written submissions, the default policy is to disclose and offer a call instead — or to skip entirely when the wording is stronger than a preference.
| Claude Agent SDK | required | Runs every lane. Works with a Claude Code subscription login or an ANTHROPIC_API_KEY. |
| SQLite | required | The campaign CRM. One file, no server, trivially backed up. |
| AgentMail | optional | A hosted inbox with real server-side threading, so an agent can reply into a conversation months later. |
| Console provider | default | Sends nothing. Writes every would-be message to logs/outbox/. |
| Podcast Index | optional | Four million public feeds. The strategist's discovery engine. Free credentials. |
| Playwright | optional | The browser lane, for platforms that have no API. |
| cron | optional | Scheduling. publicist crontab prints the lines. |
Providers are pluggable — adding SMTP, Postmark or SES means implementing four methods. See docs/providers.md.
Requirements: Python 3.11+ and access to Claude. Everything else is optional.
| Quickstart | From install to a first dry run |
| Architecture | Why stateless lanes plus a CRM, and what it buys |
| Campaign setup | Writing positioning.md, policies.md, and a target list |
| Operations | Cron, logs, going live, what to check weekly |
| Browser lane | Writing a platform adapter, and the line it will not cross |
| Providers | Adding a mail provider |
Alpha. The orchestration core has been running a real campaign daily since July 2026; the packaging, the provider abstraction and the example campaign are new. Interfaces may change before 1.0.
Apache 2.0. See LICENSE.