Slack-native AI studio for ecommerce ad agencies — one bot that runs the daily creative work (ad copy, advertorial drafts, future ad-platform digests) for multiple client brands.
The architectural idea is a shared brand brain: describe a client brand once via /brand setup, and every module reads from it. Ad copy, listicles, and future modules inherit voice, audience, do-not-say rules, and the running winners log without rework. Adding a new module is one folder under src/modules/, one Slack command, zero schema changes.
Slack workspace
|
v
+----------+-----------+
| @slack/bolt App |
| (socket | http) |
+----------+-----------+
|
+----------------------+----------------------+
| | |
v v v
slash commands action handlers modals
/ping /brand creative approve brand setup
/creative /listicle listicle approve
| |
+----------+-----------+
|
v
+--------+---------+
| modules |
| creative/ |
| listicle/ |
+--------+---------+
|
v
+--------+---------+
| shared core |
| llm -> claude | prompt cache on brand prefix
| -> groq | fallback / dev
| retry | exp backoff + jitter
| tavily | research
| brand winners | shared brain
| generations |
| db (sqlite/WAL) |
+------------------+
| Module | Status | Slack surface | Description |
|---|---|---|---|
| Brand setup | shipped | /brand setup, /brand show, /brand list |
Modal-based editor for the per-brand context block every other module reads. |
| Creative engine | shipped | /creative brand=... goal=... |
Multi-pass pipeline: analyze prior winners, generate angles, pick hooks, write Meta-style copy variants. Approve a variant to feed it back as a future winner. |
| Listicle engine | shipped | /listicle brand=... topic=... count=N |
Research via Tavily, outline, per-item drafting, framing pass. Returns Slack preview plus an HTML attachment. |
- TypeScript (Node 22+), ESM with NodeNext resolution
- Slack:
@slack/bolt— Socket Mode for dev, ExpressReceiver/HTTP for prod - LLM:
@anthropic-ai/sdkwith prompt caching on the brand-context prefix, plusgroq-sdkas a free dev alternative - Research:
@tavily/corefor web search + extraction - Storage: SQLite via
better-sqlite3(WAL mode, foreign keys on) - Validation:
zodv4 - Logging:
pinowithpino-prettyin dev - Retry: shared
withRetryhelper with full-jitter exponential backoff, used by both LLM providers
The LLM_PROVIDER env var switches between anthropic (default — Claude with prompt caching, used in production) and groq (Llama via groq-sdk, free for dev testing but without caching and with less reliable structured JSON). Set GROQ_API_KEY when using Groq, and verify the wiring with npm run smoke:llm.
Run npm run smoke:claude to verify Anthropic prompt caching is working end-to-end — the script makes two identical calls and asserts the second one hits the cache (cache_read_input_tokens > 0).
Both providers go through the same shared retry layer: 429s, 529s, and 5xx responses are retried up to three times with full-jitter exponential backoff (base 500ms, cap 8s). Retry attempts log at warn level with provider, attempt, delayMs, status, and message fields so you can grep for them.
cp .env.example .envand fill in the values (at minimum: Slack bot token, signing secret, app token for socket mode,ANTHROPIC_API_KEY,TAVILY_API_KEY).npm installnpm run dev— starts the Bolt app undertsx watchin Socket Mode.- In Slack, run
/pingto confirm the bot is alive.
- Create a new app at https://api.slack.com/apps from a manifest or scratch.
- Under OAuth & Permissions, add these bot scopes:
commands,chat:write,files:write,im:write. - Under Socket Mode, enable Socket Mode for dev. Generate an app-level token (
xapp-...) with scopeconnections:writeand put it inSLACK_APP_TOKEN. - Under Slash Commands, register four commands:
/ping,/brand,/creative,/listicle. Point each at the same Request URL (Socket Mode uses internal dispatch, so the URL is only checked when running HTTP mode). - Under Interactivity & Shortcuts, enable Interactivity and set the Request URL to the same
/slack/eventspath used by your slash commands. Slack uses this endpoint for button clicks (e.g. the Approve buttons) and modal submissions (e.g./brand setup). For Socket Mode dev the URL field is not actually called, but Slack may still require a value — usehttps://example.com/slack/eventsas a placeholder. - Under Basic Information, copy the Signing Secret into
SLACK_SIGNING_SECRET. - Install the app into your workspace and copy the Bot Token (
xoxb-...) intoSLACK_BOT_TOKEN.
After completing local development setup:
npm run seed:demo
npm run dev
# in Slack:
/creative brand=Drift goal="cold prospect, women 35-55, sleep issues"
# approve one of the variants by clicking its button
/listicle brand=Drift topic="best magnesium supplements for sleep" count=7
seed:demo upserts a fully-formed Drift brand (a sleep-supplement brand built on magnesium glycinate plus L-theanine) and seeds three in-voice creative winners so the creative pipeline has a "winners pattern memo" to learn from on the first call. The script is idempotent — re-running it skips winner insertion if the brand already has them.
Atelier ships with a railway.json that selects the NIXPACKS builder, runs npm run start, and registers /health as the healthcheck endpoint. The HTTP transport is required in production; Socket Mode does not bind a port and Railway needs one.
- Create a new Railway project from this repo (Connect GitHub, pick branch).
- Under Variables, set the following:
SLACK_TRANSPORT=httpSLACK_BOT_TOKEN=xoxb-...SLACK_SIGNING_SECRET=...ANTHROPIC_API_KEY=...(orGROQ_API_KEY=...if testing on the free provider)LLM_PROVIDER=anthropic(orgroq)TAVILY_API_KEY=...NODE_ENV=productionDATABASE_PATH=/data/atelier.dbPORTis set automatically by Railway; the app will respect it.
- Under Settings → Volumes, create a volume and mount it at
/dataso the SQLite file persists across deploys. - Under Settings → Networking, generate a public domain (or attach a custom one).
- In the Slack app dashboard, switch to event-driven mode: disable Socket Mode, then under Slash Commands set each command's Request URL to
https://<your-railway-domain>/slack/events(the default ExpressReceiver path). Reinstall the app if Slack prompts for it. - Under Interactivity & Shortcuts, enable Interactivity and set the Request URL to
https://<your-railway-domain>/slack/events. Without this, button clicks (Approve) and modal submissions (/brand setup) will silently fail in production. - Trigger a redeploy if env vars changed after the first build, then run
/pingfrom your workspace to confirm.
The shared-brain pattern makes the next modules incremental work, not greenfield work. Three high-value adds from the original scope:
- Google Ads digest — a
/google-adscommand that ingests a CSV export (or live API pull) and produces a Slack-native performance read: top movers, audience-segment commentary, suggested next-week budget shifts. Reads brand context (voice, do-not-say) to write the commentary in the same register as the rest of the agency's deliverables; readswinnersto flag which ad concepts are tied to active campaigns. - Meta Ads digest — symmetric module for Meta. Same shape: ingest, analyze, narrate. Crucially, the creative-engine output and the Meta digest input live in the same brand record, so the agency can close the loop on which approved variants actually performed and feed that signal back into the winner pool.
- Slack-native team management — a
/teamcommand surface for assigning ownership of a brand to specific Slack users, scoping who can approve winners, and producing weekly summaries of agency activity per brand. This reusesbrands,winners, andgenerationsdirectly; no schema changes required.
Each of those modules adds one folder under src/modules/, registers one Slack command, and inherits the entire brand brain — voice, audience, do-not-say, competitors, product notes, and the running winners log — without rework.
Proprietary. Portfolio / demo project.