A small marketing team was writing every post from a blank page, chasing approvals in chat, and losing track of what had gone out and how it landed. Generating more content was easy. Making AI drafts the team could trust, review properly and actually ship was the hard part.
- Campaign packs, not single posts: a weekly run builds five complete packs (company post, personal posts, blog, newsletter section, visual direction and a posting sequence) from fresh company research and a house voice contract. The team picks, edits in a rich-text editor, approves and posts
- Four-eyes approval enforced in the database: anyone on the team can approve a version, except its author or the person who requested it
- Video by an agent in a box: launch-style videos are made by a coding agent running in a container with its own spend-limited key and no database or social credentials. The app pulls the finished files
- Costs shown before they are spent: an estimate before every AI action, running monthly totals, and a confirmation at fixed thresholds of $50, $100, $200 and $500
- Clips: event recordings become per-speaker clips in each social format, and an events tracker with calendar views and AI event scoring replaced a spreadsheet-script tool
A week of ready-to-post campaigns arrives as packs the team chooses from, and nothing goes out without a second person signing it off. The first storyboard video run cost about $0.50 and took under three minutes.
- Next.js · Tiptap · Vercel
- Supabase (row-level security)
- Anthropic API · Claude Code in Docker
- LinkedIn API
Approval the app cannot skip
Four-eyes approval is easy to promise in the interface and easy to bypass anywhere else. So the rule lives in row-level security: an approval row can only be written by someone on the team who is neither the author nor the requester of that version. I verified it with simulated user tokens rather than trusting the UI to hide the button.
Status is derived from the approvals on every read and never stored, so there is no column that can drift out of step with the evidence. Column-level insert grants keep the audit timestamps server-set, which means a client cannot backdate when something was approved.
Clips follow the same thinking about people. Speakers are named once by a person. There is deliberately no face or voice recognition, because biometric data is special-category and the feature does not need it. External speakers are gated behind a "cleared to post" check in the database, and video platforms whose terms forbid downloading are never downloaded from.
Why the agent lives in a container
The container was not the first plan. The first runner let the coding agent render videos directly on the machine, but its own macOS sandbox blocks headless Chrome at the operating-system level: a system call is denied during browser startup, and no setting allows it. That is a sandbox doing its job, so I stopped fighting it.
Moving the agent into a container turned the workaround into the better design. It gets its own spend-limited key and nothing else: no database access and no social credentials, so the worst a bad run can do is waste a capped amount of money on a video nobody approves. The app only ever pulls finished files out, and those files go through the same approval flow as everything else.