Labs
Outbox
An assistant for everything a small firm publishes.
Outbox reads eight technical news sources every morning, writes a LinkedIn post in your voice or your company's, renders the image that goes with it in your brand colors, publishes it, and then keeps going into cold email, client proposals, and job applications. One writing system behind every surface where a small consultancy talks to a stranger.
Visit liveWhat is actually in it
Every figure is a count from the repository, not a projection.
Why it matters
A small consultancy has one voice, and it ends up retyped into four different tools. The LinkedIn post, the cold email, the client proposal, and the job application all need the same positioning and the same proof, and all four get written from a blank page by whoever has the hour. Staying current with the technical news that makes any of it worth reading is another hour on top.
Outbox is one writing system behind all of those surfaces. It reads eight sources every morning, drafts in your voice or your company's, renders the card image in your own brand colors, and publishes or schedules without leaving the tool. The same persona and brand kit drive the email campaigns, the proposals, and the job applications. You keep the editorial judgment. It removes the blank page and the retyping around it.
The parts worth defending are not the prompts. Credits are debited inside a Postgres transaction rather than in a route handler, so two concurrent generations cannot both spend the last one. Two model providers sit behind a single resolver, and a missing key degrades the app instead of breaking it. The craft rules a prompt kept ignoring, em dashes and hashtag spam, moved into a deterministic cleanup pass, because a rule in a prompt is a hope and a function is a guarantee.
Product walkthrough

The problem
A small consultancy talks to strangers on four surfaces at once: LinkedIn, cold email, client proposals, and job applications. Each one needs the same voice, the same proof, and the same brand, and each one gets written from a blank page in a different tool. Staying current with the technical news that makes any of it worth reading is another hour on top, every day the news keeps moving. The work is not hard. It is relentless, and it is the first thing dropped when client work gets busy.
How it works
Read: eight sources (Hacker News, Reddit, DEV.to, ArXiv, GitHub, VentureBeat, TechCrunch, HuggingFace) ranked against the topics on your profile, with NewsAPI and live web search adding current, dated facts on demand.
Write: your voice or the company's. A saved company profile switches the model from first person singular to the company's plural, with its services, clients, and proof as source material rather than a template.
Render: six card templates (hook, stat, quote, list, slide, bold), each in dark and light, drawn server side as PNG. The palette and logo are scraped from your own website.
Schedule: daily, weekly, or a custom cadence with topics rotated per run. Leave the topic blank and each run derives its own subject from your bio and company profile.
Publish: text and image straight to LinkedIn through its three-step asset upload, or emailed to you first as a draft with the visuals inline for review.
Beyond the main loop
Studio
Rewrite a draft, or score one out of ten on hook, structure, virality, clarity, and call to action. Writing from a bare idea is grounded in live web search first, so the post names a real company and a real date instead of asserting that the pace of change is incredible.
Email campaigns
Connect Gmail by OAuth, upload a lead list, generate and refine the copy, send in batches, and file campaigns into folders. Sends are chained across function invocations with per-lead claiming, so a retry cannot double-send.
Proposals
Upwork-style client proposals in a fixed seven-part structure, written as you or as the company, with toggles for asking for a call and including your site. Turning the meeting off restructures the brief to six parts rather than leaving a dangling reference.
Jobs
Discovery across several job boards with relevance scoring against your portfolio, auto-save above a threshold, and a tailored proposal per listing.
Usage and insights
Where credits actually go by feature, burn rate and projected runway, a day-by-hour posting heatmap with streaks, how much of your output the scheduler is carrying, email delivery rates, and warnings for schedules whose cron did not fire.
Admin console
Client accounts, credit grants, per-action pricing, platform-wide maintenance mode, announcements, and the access-request queue that opens every new account.
Stack
Decisions worth defending
Credits are deducted in Postgres, not in the route
A use_credit() function reads the price from an admin-editable action_costs table, takes SELECT FOR UPDATE on the balance, then debits and writes the ledger row in one transaction. Two concurrent generations cannot both spend the last credit. The scheduler needed a second function taking the owner explicitly, because auth.uid() is null when the cron holds the service key, and execute on it is revoked from anon and authenticated, or any signed-in user could drain someone else's balance.
Two model providers, chosen per account
Together AI and Groq sit behind one resolver. A provider whose key is missing falls back silently rather than failing the request, so pulling a key degrades the app instead of breaking it. Groq needed reasoning_effort set to low: its reasoning tokens were consuming the entire max_tokens budget and returning empty completions.
The brand kit is scraped, not uploaded
Give it your company URL and it fetches the page, extracts candidate logos and colors, and picks a brand color by weighing explicitly declared values over raw frequency, with a frequency cap, because otherwise a stylesheet's Tailwind defaults outvote the actual brand. Every fetch goes through an SSRF guard, and the chosen ink is contrast-checked against the card background before it is used.
Invite-only, with no signup path at all
Rather than gating a signup form, auth.signUp does not exist in the codebase. Accounts come into being exactly one way: an admin approves a request, which creates the user with no password and mints a set-password link. There is no wrong-email rejection to write, because there is no door to reject it at.
Post images are drawn inside the function
Visuals are rendered with Satori, which rasterizes a constrained subset of CSS: flexbox only, inline styles only, no grid. The logo is the interesting constraint, because it arrives as whatever URL the company's website happens to serve, so it is fetched server side, checked against a content-type allowlist, size-capped, and inlined as a data URI before it reaches the renderer. We had assumed SVG was the problem format and ranked it last. Testing showed SVG renders fine and ICO crashes the renderer outright, with an empty reply rather than an error, so the ranking was inverted and ICO rejected at the door.
Deterministic cleanup where prompting failed
Every prompt banned em dashes and the models kept emitting them, because a rule in a prompt is a hope. Output now passes through a function that converts them, strips markdown bold that LinkedIn renders literally, and trims the hashtag tail to five specific tags, dropping #AI, #Innovation and the rest, which are the clearest signal to a reader that a post was machine-written.
Problems worth the write-up
A single stuck row pegged the database CPU
- Symptom
- Supabase CPU sat at the ceiling for hours after an email campaign ran, and nothing in the app was obviously looping.
- Diagnosis
- Sending walks a chain of self-invoking batch requests. One hop was lost, so a campaign that had already processed all 239 of its leads kept the status sending forever. The client polls any campaign in that state every few seconds, so one stranded row turned every open tab into a permanent load generator.
- Fix
- The campaigns endpoint now self-heals: a campaign in sending whose sent plus failed counts already cover its total is closed out. The counts needed to detect it were in the rows being fetched anyway, so the check costs nothing on the normal path. Lead claiming became a compare-and-swap, so a retried batch cannot double-send.
Roughly 470 Tailwind utilities were emitting invalid CSS
- Symptom
- bg-primary rendered nothing on a toggle. Not the wrong color, no color.
- Diagnosis
- Tailwind v4's @theme inline block mapped --color-x to var(--x), while the design tokens were stored as bare HSL triples. Every color utility in the app resolved to a raw triple with no hsl() around it, which the browser discards silently.
- Fix
- All 33 color tokens wrapped at the mapping. The same audit turned up three stacked :root blocks left by earlier rewrites, the last of which was quietly overriding the sidebar back to white, which is why a day of edits to the wrong block had appeared to do nothing.
Thirty-five hover effects that had never once run
- Symptom
- Hover states defined in code, visibly absent in the browser, across most of the app.
- Diagnosis
- Framer Motion cannot interpolate hsl(var(--token)) and discards those values rather than erroring. Every whileHover written against a theme token was dead on arrival, and had been since the tokens were introduced.
- Fix
- A scripted sweep partitioned animation props into the ones Motion can interpolate and the ones it cannot, moving the latter to DOM event handlers with CSS transitions. It is now a documented rule in the shared design module, because the failure is completely silent and would otherwise be reintroduced within a week.
Posts that were fluent and said nothing
- Symptom
- Asked for the latest developments in AI, the model produced: AI surpasses humans in complex tasks. This is a fact. Machines learn faster. Then nine generic hashtags.
- Diagnosis
- Three causes, and only one of them was the model's. The prompt demanded specific details and evidence while supplying nothing but the topic string. A cap of twenty words per sentence, applied to a model with no material, manufactures exactly that telegraph style. And the hashtag spam was mandated: the prompt literally required six to ten.
- Fix
- The subject is now searched first and the dated results become the post's source material, with an instruction to pick one and build around it rather than survey all six. The craft rules moved into one shared module, because three copies had already drifted apart. The same prompt now opens: OpenAI announced on Sept 7 that it hit a milestone toward a self-improving AI system, with a target of an automated AI researcher by 2028.
Outcome
- Live and invite-only, designed and built end to end by a single engineer.
- Eight sources polled, six card templates, ten metered actions, all running in production.
- One voice and one brand kit behind LinkedIn posts, cold email, proposals, and job applications.
Still open
A case study that only lists wins is a brochure. These are the things we know about and have not closed.
- UntestedThe LinkedIn image upload path has never run against LinkedIn's production API. It soft-fails to a text-only post, which is the right failure, but it is unproven.
- LegalEmail campaigns have no unsubscribe link, no List-Unsubscribe header, and no suppression list. That is a real exposure and it gates any broader rollout.
- ExpiryLinkedIn tokens are stored with an expiry and never refreshed, so a connection silently dies at sixty days.
- GapNothing records whether a generated post was actually published, so the most interesting metric, drafted versus shipped, cannot be computed yet.