Wren Automation platform. One TypeScript monorepo: durable automations on Restate, Postgres via Drizzle, dashboard in Next.js (later). Spec: designs/2026-09-17-platform-spec.md.
Overview
Wren Automation platform. One TypeScript monorepo: durable automations on Restate, Postgres via Drizzle, dashboard in Next.js (later). Spec: designs/2026-09-17-platform-spec.md. New here: walkthrough/ is the guided path (setup → content loop → Meta ads → email campaign → production) with read-only demos against prod. Drafts follow WREN_CONTENT_VOICE (a markdown file in your words), your last redraft notes on that platform and its best posts so far, and go out through the channels in WREN_CONTENT_CHANNELS. A local --media file lands in WREN_MEDIA_BUCKET first (the worker and the box cannot read this laptop); a URL is used as is. Design: designs/2026-09-22-content-loop.md. Video footers are templates: post:youtube/footer under a long video's description, post:youtube/shorts-footer under a Short's, post:instagram/reel-footer under a Reel's caption. Edit with wren templates get post:youtube/footer > f.post, then wren templates set post:youtube/footer f.post --why "...
README
wren
Wren Automation platform. One TypeScript monorepo: durable automations on Restate, Postgres via Drizzle, dashboard in Next.js (later). Spec: designs/2026-09-17-platform-spec.md.
New here: walkthrough/ is the guided path (setup → content loop → Meta ads →
email campaign → production) with read-only demos against prod.
Quick start
cp .env.example .env
pnpm install
docker compose up -d # Postgres :5434, Restate ingress :8080 admin :9070
pnpm db:migrate
pnpm worker # Restate endpoint on :9080 (keep running)
pnpm register # tell Restate where the worker is (once per worker restart)
ln -s "$PWD/bin/wren" ~/.local/bin/wren # the CLI, compiled on first run and after every source change
wren status
Content loop (every platform)
wren status # drafts, open ideas, spend; exit 1 on Thursday+ with nothing drafted this week
echo "shipped the spend gate today. every buy asks me first." | wren content add
wren content add idea.md --media short.mp4 --title "Spend gate" # a short: YouTube, Reels, TikTok, X, LinkedIn captions
wren content drafts # one row per platform, status draft
wren content show
wren content redraft "shorter, keep the discord line" # the model rewrites from your note
wren content edit fixed.md
wren content approve ... # each posts at its platform's next slot (LinkedIn 08:30 + 12:30 weekdays, X noon, IG 18:00 … on WREN_SEND_TIMEZONE)
wren content approve --at 2026-09-23T14:00:00Z
wren content approve --now # on the queue's next pass
wren content queue start # ContentScheduler/default: posts approved drafts as they come due
wren content metrics start # ContentMetrics/default: one look per young post per day; Monday = what-worked to the channel
wren content planner start --draft # ContentPlanner/default, 17:00: drafts tomorrow's open slots (ideas, build log, a reader's question); approve keeps the slot
wren content planner slots linkedin 08:30 --days 1-5 # one platform's post times (1 = Monday); keeps the rest, starts the planner
wren content results --days 7 # published posts, engagement per 100 views, best first
wren content costs --days 30 # drafting calls and tokens by platform and model
Drafts follow WREN_CONTENT_VOICE (a markdown file in your words), your last redraft notes
on that platform and its best posts so far, and go out through the channels in
WREN_CONTENT_CHANNELS. A local --media file lands in WREN_MEDIA_BUCKET
first (the worker and the box cannot read this laptop); a URL is used as is. Design: designs/2026-09-22-content-loop.md.
Video footers are templates: post:youtube/footer under a long video’s description,
post:youtube/shorts-footer under a Short’s, post:instagram/reel-footer under a Reel’s caption.
Edit with wren templates get post:youtube/footer > f.post, then wren templates set post:youtube/footer f.post --why "..." --expect --publish, or in Library, Templates. A new
version waits in To approve. Defaults: packages/templates/defaults/post/.
Meta ads
wren ads accounts # the ad accounts the Meta token admins
wren ads interests "shopify" # interest ids for the spec's targeting
wren ads launch ads/founders.json # campaign → ad set → creative → ad, all PAUSED; prints the ids
wren ads start --daily 20 # the one command that spends
wren ads insights --preset last_7d # spend, impressions, reach, clicks, ctr, cpc per campaign
wren ads stop
wren ads lead-form founders https://wrenautomation.com/privacy # an instant form; id → creative.leadForm
wren ads leads # what the form collected (needs leads_retrieval from app review)
wren ads launches # the ad_launches ledger: ids, budget, started/stopped, why
wren ads watch start # AdsWatch: daily guard, pauses a launch that spent $50 with nothing to show
wren ads spec-from --out ads/post.json # a post that worked → a PAUSED launch spec (same words, same media)
The spec file: name, objective (OUTCOME_LEADS, OUTCOME_TRAFFIC, …), dailyBudgetUsd,
targeting {countries, ageMin, ageMax, interests[{id,name}]}, creative {message, link, headline, description, callToAction, media {kind, source}, leadForm {id} | {name, privacyUrl, questions?, followUpUrl?}}. With leadForm (OUTCOME_LEADS, optimizationGoal: LEAD_GENERATION) the CTA opens the instant form on Facebook instead of the link, and
wren ads leads reads the answers. A local media file lands in
WREN_MEDIA_BUCKET first. Every ACTIVE write is a spend on the box: it asks (or auto-yes
under its small cap) and writes the ledger. WREN_META_AD_ACCOUNT_ID picks the account;
unset = the first one. Every launch/start/stop lands in ad_launches; AdsWatch/default
reads adset insights once a day and stops any active launch whose 7-day spend reached
WREN_ADS_PAUSE_AFTER_USD (default 50) with zero clicks and zero results — it only ever
stops spend, and each pass is one message to the channel. A launch that wins (a result,
or ten clicks) becomes one open idea for the content loop (wren content ideas, source
ads), once; nothing is drafted until you say. The other way: wren ads spec-from turns a published post into a spec. Design:
designs/2026-09-22-meta-ads.md.
SMS
wren sms runs Wren’s texts; wren --client sms ... runs a client’s, on its
database, once sms.texts is installed. Steps: walkthrough/05-sms.md. Design:
designs/2026-09-27-phone-channel.md, designs/2026-10-04-outbound-per-client.md.
Webhooks (the door)
wren [--client ] hooks add --subject [--name ...] prints a
https://phone.wrenautomation.com/hooks/ URL once. Anything that POSTs JSON or a form to
it enters that workflow’s input, about the payload’s ``. wren hooks list shows each
hook’s calls. Design: designs/2026-10-05-workflows.md (The spine).
wren hooks preset site makes the lander’s hook into Wren’s speed to lead and says where the URL
goes (the lander’s WREN_DOOR_URL Pages secret).
Workflow templates
wren --client workflows templates|plan|install|publish|uninstall puts a whole
workflow on a client: parts, copy, a draft and a shut door. Nothing starts until a person approves
it in To approve; the CLI never approves. wren [--client ] workflows save-template --name "..." keeps a published workflow’s live wiring as a template (the editor’s History menu
does the same); wren workflows delete-template takes one off, refused while a client
runs it live. Design: designs/2026-10-07-template-install.md.
The Monitor
wren monitor start|stop|status|sync: Watch/all on the box reads new mail in William’s inboxes
every 15 minutes (WREN_WATCH_MAILBOXES, else the books’ own), skipping promotions and social.
Each email goes along the watch workflow to triage: a rule with a sender and a verdict settles it
for $0, the model in WREN_WATCH_LLM (Cohere, set in lambda.tf) reads the rest. What needs him
shows in the Inbox app under Your mail; Hide like this writes a rule, Sort again (or wren monitor sort)
re-triages what’s waiting under today’s rules. No bodies are kept. A client’s Google data (a Gmail
mailbox, YouTube comments, Business Profile reviews) reaches only WREN_GOOGLE_LLM, a model that
doesn’t train on it; unset, none. The same pass reads each feed Wren
follows (hourly) and scores new items 0-10 against the pushed SOPs: 7 and up shows under Worth
reading, 4 to 6 holds. Follow a feed from Inbox → Feeds.
Social inbox
wren social start|stop|status|sync: SocialWatch/wren on the box, every 30 minutes 07:00-23:00
New York. It reads comments on our posts of the last 14 days (older than 3 days every 2 hours),
activity since the newest kept row (LinkedIn every 2 hours), and the follower count once a day.
LinkedIn’s count only on demand: wren social audience linkedin or Followers → Read now.
New comments go on the reach.comments spine. One Discord ping per pass that kept something; a text
only when a comment asks. Work them in Marketing → Inbox; answers go out only on William’s click.
Drafts
Every draft waiting on William, by its Inbox id (draft: a post, comment:3, thread:t3_x).
Nothing sends; each write is a runs row the item’s thread shows. In the console, Ask Claude on a
draft has Claude Code on William’s Mac rewrite it ($0), and Undo puts the old text back.
wren drafts list [--type post|comment|thread] [--limit 50] # newest first
wren drafts show comment:3 # the whole draft, what it answers, its Ask Claude thread
wren drafts set comment:3 --file answer.md # or pipe it in; the same field the console edits
wren drafts edits --type dm --limit 5 # how William changed drafts, before and after
node scripts/prod-wren.mjs drafts list # on prod
Decide
Machine calls beside William’s (designs/2026-10-16-decide.md). Jev judges drafted comments
(send, edit, drop) and Learn items (worth, skip); his send, drop, star or archive is logged next
to it. Decide/wren runs every 10 minutes. The Agents page shows the same numbers.
wren decide stats # per kind: mode, his calls, agreement, overrides, tokens, $
wren decide mode learn_worth shadow # off, shadow, recommend (a chip), gate (refused until built)
wren decide export --called --out decisions.jsonl # wren.decision/1: the distill set
wren decide backfill [--dry-run] [--sample 100 --max-usd 2] # history's calls; a Jev sample
wren decide pass # one pass here
Accounts
Every account we post and reply as, the same rows as Outreach → Accounts. A draft on a platform with two or more accounts has a Send as picker; the pick is kept with the draft and the send uses it. A pick that misses a sub’s karma or age rule gets a warning and still sends.
wren accounts [--platform reddit] # key, site, state, health, sent today, places
wren accounts show reddit@alt # one in full, with its last 10 sends
wren accounts assign smallbusiness reddit@alt # move a subreddit to another account
wren accounts pick thread:t3_x reddit@alt # send this draft as it; `default` clears
wren reach reply 12 --body "..." --account reddit@alt
Chores through autobrowse
restateDo(ctx, wake)({ goal: "upload this to youtube", inputs: { file } }) from a
handler, autobrowseDo({ url, token }) from a laptop: one verb, autobrowse routes
it (site API → compiled workflow → its agent, which compiles what it did for next
time). restateSites(ctx) stays for calls that need the official API shape.
Boards
Notes has a kanban Board. A card is a note, so it keeps a body, comments and history. Agents work the same board through wren tasks, and write as agent:claude (--agent for another).
wren tasks ready lists open cards that are the agent’s or nobody’s and have nothing open blocking them (--board, --limit). ls filters by --board, --column, --owner and --all (closed too). show , add (--board, --column, --owner, --due, --blocked-by, --body), move , assign , due , close --why , reopen , comment and block [--off] do what they say. Most take --json. When an agent moves or closes a card a person owns, the card lands in Review and the command says so.
On the page, Board has Export CSV and Import. Import takes a CSV or a table pasted from Sheets, asks which column is which once, and updates the card when a row’s link points at one.
Call recordings
The desk on William’s Mac records each booked call with OBS. It starts 5 minutes before the call if a Meet or Zoom window is open. It stops 3 minutes after the window closes. Record now and Stop recording sit on Calendar > Calls and Inbox > Calls. The desk asks wren calls upcoming what to wait for, and runs wren calls add on each finished file. That command encodes a 720p mp4 and transcribes it on the Mac. It writes the notes, uploads the video and puts a line on the person’s timeline. Calendar > Recordings lists every recording. Setup and the desk side live in autobrowse (autobrowse calls setup); the design is designs/2026-10-16-call-recording.md.
Groups and channels
A group’s rule reads as text, so agents write it the way the portal stores it:
all(trait persona is "agency owner", step saw on reddit in 30d, not message opened on email ever).
wren groups check|count reads one and counts it; save --rule makes or
changes one (each save is a version, history lists them); list, show, members,
and in (exits 1 when not). A rule can be inline, @file or - for stdin.
wren channels prints each channel’s funnel, reached to paid (--by source|trait:,
--channel, --days, --group, --rule, --attribution any|first|last,
--people : for the people behind one number, --json).
SOPs
wren sop add --account | file> [--priority n] [--no-screen]
ingests a source into ../sops//sources/ (a private folder, its own git). A video is
its captions plus an ## On screen section: Gemini (llm.env keys) reads the URL in clips and
transcribes the documents and slides shown; --no-screen skips that.
wren sop build writes the next SOP.md from notes.md (your rules, top
priority) and the sources on Claude Code. Iterate: edit notes.md or SOP.md, build again.
wren sop extract [source] lists every point a source makes into points/.md
(cited); delete lines you don’t want, prefix ! on ones that must appear, and build reads the
points instead of the raw source.
sop build also writes SKILL.md; wren sop link symlinks the folder into ~/.claude/skills/sop-
so Claude Code can follow the SOP as a skill. A non-text source (PDF, image) lands in refs/.
Gates
./scripts/gates.sh runs lint + typecheck, unit tests, the schema checks and integration tests (Docker). CI deploys on lint, unit and schema; integration runs in its own workflow and only reports.
Map
map/ is a system map for agents: what each table, type and loop is, why, and what a change hits. Start at map/CLAUDE.md; cards cite path:line. Generated files rebuild with map/_meta/build.sh (checked by gates.sh lint).
Layout
See spec §3. Rules: deps point down; channels never import each other; every package owns src/schema.ts and packages/db/drizzle.config.ts lists them; Restate handlers in src/restate/, pure step functions beside them.
Schema changes
Edit a package’s src/schema.ts, then pnpm db:generate (writes packages/db/drizzle/NNNN_*.sql; review it), then pnpm db:migrate. Never edit a committed migration.
Legacy data
scripts/import-legacy.sh [db] copies every row from the emails_gen Postgres into a migrated wren database. Run once, into an empty target. See designs/2026-09-17-channel-email-port-plan.md.
Deploy
Restate (self-hosted on the EC2 box) → Lambda → Postgres on the same box. deploy/README.md is the runbook; deploy/terraform the infrastructure; CI ships main after ci is green.
Recommended Tools
Try a different keyword or remove a filter.
Install
npx skillfish add wrenautomation/wren