Internal support platform for Studyflash. Ingests Outlook emails as tickets, enriches them with user context, and assists the team with triage and response drafts — built as an MVP for the Platform Engineer hiring challenge.
- Receives support emails from the shared Outlook inbox via IMAP — Chatwoot creates a ticket per thread.
- Enriches each new ticket with user context: recent Sentry errors, PostHog session recordings, and account data from the internal Postgres database.
- Translates the message to English (common analysis base) and categorizes it into one of four buckets:
bug-report,refund-request,product-question,other. - Drafts a suggested response and posts it as an internal note — visible only to agents, not sent to the customer.
- Assigns (or flags) the ticket: high-confidence categorizations auto-assign to the right team; low-confidence ones get a
needs-triagelabel. - Sends agent replies back through Outlook SMTP, keeping full thread parity via standard
Message-ID/In-Reply-Toemail headers.
┌──────────────────┐ IMAP/SMTP ┌───────────────────────────┐
│ Outlook Inbox │◀────────────▶│ Chatwoot │
│ (support@...) │ │ (tickets, agents, UI) │
└──────────────────┘ └────────────┬──────────────┘
│ webhook
│ (conversation_created,
│ message_created)
▼
┌────────────────────────────┐
│ AI Pipeline │
│ 1. fetch enrichment │
│ 2. translate → English │
│ 3. categorize │
│ 4. draft response │
│ 5. label + assign │
└────────────┬───────────────┘
│ REST
▼
┌────────────────────────────┐
│ Enrichment Service │
│ Sentry · PostHog · PG │
└────────────────────────────┘
Services (all via Docker Compose):
| Service | Port | Role |
|---|---|---|
chatwoot-web |
3000 | Rails ticket UI + email channel |
chatwoot-worker |
— | Sidekiq background jobs |
postgres |
5432 | Chatwoot database (pgvector) |
redis |
6379 | Cache + job queue |
ai-pipeline |
3100 | Webhook listener, Claude orchestration |
enrichment-service |
3200 | Sentry / PostHog / Postgres REST API |
When Chatwoot fires a webhook for a new conversation (or a new incoming message on an existing one), the pipeline runs the following sequence — once per ticket, guarded by an ai-processed label:
webhook received
│
▼
already processed? ──yes──▶ skip
│ no
▼
fetch enrichment (Sentry errors, PostHog recordings, account plan)
│
├─── post as internal note in Chatwoot
│
▼
detect language + translate to English ◀── run in parallel
│
▼
categorize (English text)
│
▼
draft response
│
├─── post draft as internal note: "[AI Draft - respond in user's language]"
│
▼
add labels (category, high-confidence | needs-triage, ai-processed)
│
▼
high confidence? ──yes──▶ auto-assign to matching agent
│ no
▼
post "[Suggested assignee]" note for manual triage
Subsequent customer replies on the same conversation are not re-processed — the ai-processed guard short-circuits. This is intentional for an MVP: the context and initial draft are attached to the thread for the agent to work from.
- Docker + Docker Compose
- An Anthropic API key
- Sentry, PostHog, and internal Postgres credentials for enrichment (the pipeline degrades gracefully if not configured)
cp .env.example .envGenerate a secret key for Chatwoot's Rails backend and paste it into .env as SECRET_KEY_BASE:
openssl rand -hex 64Fill in ANTHROPIC_API_KEY at minimum. Enrichment keys (SENTRY_AUTH_TOKEN, POSTHOG_API_KEY, INTERNAL_DB_URL) can be added later — the pipeline skips any enrichment source that is not configured.
docker compose up -ddocker compose run --rm chatwoot-web bundle exec rails db:chatwoot_prepareOpen http://localhost:3000 and complete the onboarding (create an account).
Then configure the following:
Settings → Inboxes → Add Inbox → Email
| Field | Value |
|---|---|
| IMAP Host | outlook.office365.com |
| IMAP Port | 993 (SSL) |
| SMTP Host | smtp.office365.com |
| SMTP Port | 587 (STARTTLS) |
[email protected] |
|
| Password | App password or account password |
Settings → Integrations → Webhooks → Add Webhook
- URL:
http://ai-pipeline:3100/webhook(Usehttp://host.docker.internal:3100/webhookif Chatwoot is not in the same Docker network as the pipeline, or an ngrok HTTPS URL for external testing.) - Subscribe to:
conversation_created,message_created
Create these labels in Settings → Labels before running the pipeline:
bug-report · refund-request · product-question · other · needs-triage · high-confidence · ai-processed
The AI pipeline authenticates against the Chatwoot API using a personal access token. To get one:
Profile (top-right avatar) → Profile Settings → Access Token → Copy
Paste it into .env as CHATWOOT_API_TOKEN.
Create an API channel inbox in Chatwoot (Settings → Inboxes → Add Inbox → API) to use as the demo inbox, then note its ID from the inbox settings URL and run:
export CHATWOOT_API_TOKEN=<your-token>
export CHATWOOT_ACCOUNT_ID=1
export CHATWOOT_INBOX_ID=<your-api-inbox-id>
node scripts/seed-tickets.js 10 # seeds 10 tickets; omit the number for all 100| Variable | Description |
|---|---|
SECRET_KEY_BASE |
Chatwoot Rails secret — openssl rand -hex 64 |
POSTGRES_PASSWORD |
Postgres password (default: postgres) |
REDIS_URL |
Redis connection string (default: redis://redis:6379) |
FRONTEND_URL |
Chatwoot base URL (default: http://localhost:3000) |
ANTHROPIC_API_KEY |
Claude API key |
CHATWOOT_API_TOKEN |
From Chatwoot → Profile Settings → Access Token |
CHATWOOT_BASE_URL |
Chatwoot URL reachable from the pipeline container |
CHATWOOT_ACCOUNT_ID |
Account ID (usually 1) |
CHATWOOT_INBOX_ID |
API inbox ID — only needed for the seed script |
ENRICHMENT_SERVICE_URL |
Internal URL for the enrichment service (default: http://enrichment-service:3200) |
SENTRY_AUTH_TOKEN |
Sentry personal auth token |
SENTRY_ORG_SLUG |
Sentry organization slug |
SENTRY_PROJECT_SLUG |
Sentry project slug (optional — searches all projects if omitted) |
POSTHOG_API_KEY |
PostHog personal API key |
POSTHOG_HOST |
PostHog instance URL (default: https://app.posthog.com) |
INTERNAL_DB_URL |
Postgres connection string for the Studyflash user database |
See .env.example for the full list with defaults.
Enrichment via webhook, not Chatwoot's sidebar integration
Chatwoot ships a "Custom Application" sidebar feature that embeds an iframe for each conversation. The natural approach would be to point it at http://enrichment-service:3200/sidebar so agents can pull context on demand.
This doesn't work in a local setup. Chatwoot serves its UI over HTTP on localhost, but browsers enforce mixed-content rules: an iframe loaded from a Docker-internal http:// URL inside any page counts as mixed content and gets blocked. Fixing this would require a valid TLS certificate and a public HTTPS endpoint — significant infrastructure overhead for an MVP.
Instead, enrichment is triggered automatically from the webhook handler the moment a new ticket arrives. The result is formatted as a markdown internal note and posted directly into the conversation. Agents see it immediately without any sidebar setup, and it requires no HTTPS.
The /sidebar HTML endpoint is still served by the enrichment service and can be wired up as a Chatwoot custom app in a production deployment with proper HTTPS.
Language normalization
Studyflash receives tickets in many languages (Dutch, German, French, Spanish, Italian, and more). The team is not always fluent in the customer's language, and Claude needs consistent input to categorize reliably.
All incoming ticket content is translated to English before it reaches the categorization step. This translation is done by Claude Haiku and runs in parallel with language detection. The English text is the sole input for categorization — it acts as a common, lossless base across all languages.
The draft response is generated from the original, untranslated message combined with the detected language code. Claude is instructed to reply in the customer's language. This is a best-effort approach: for an MVP it covers the common case well, and agents can adjust the draft before sending if the language inference is off.
Claude Haiku over Sonnet
The pipeline runs four sequential Claude calls per ticket (detect language, translate, categorize, draft). At the volume Studyflash handles, using Sonnet for all of these would multiply API costs significantly. Haiku is fast, cheap, and more than capable for structured classification and short-form generation tasks. Sonnet (or Opus) can be swapped in for any individual step if quality proves insufficient.
Chatwoot over a custom ticket UI
Building a custom ticket UI from scratch — with threading, assignment, notifications, search, inbox management — would dominate the implementation time and produce an inferior result. Chatwoot is a mature, open-source, self-hostable support platform with a polished UI, a full REST API, and native Outlook (IMAP/SMTP) integration. It covers requirements 1, 2, and 5 out of the box and exposes exactly the webhook surface needed to hook in custom AI logic.
Thread parity with Outlook
Chatwoot handles email threading by preserving Message-ID and In-Reply-To headers on all sent and received emails. A reply sent from Chatwoot arrives in the Outlook thread as a proper reply in the same chain. Conversely, a reply sent from Outlook arrives back in the same Chatwoot conversation. No custom synchronization logic is needed.
| Requirement | How it's met |
|---|---|
| Web platform to view and respond to tickets | Chatwoot UI at http://localhost:3000 |
| Assignable to individual team members | Chatwoot native assignment + AI auto-assign for high-confidence tickets |
| Enrichment (Sentry, PostHog, Postgres) | enrichment-service — called from webhook, result posted as internal note |
| AI categorization, draft, assignee suggestion | ai-pipeline — Claude Haiku via Anthropic SDK |
| Outlook thread parity (send + receive) | Chatwoot email channel with Message-ID/In-Reply-To header preservation |
The enrichment service exposes two endpoints:
GET /[email protected]— JSON response with Sentry errors (last 14 days), PostHog recordings link, and account info (plan, signup date).GET /[email protected]— Self-contained HTML panel, usable as a Chatwoot custom app sidebar in a production HTTPS deployment.GET /health—{ status: "ok" }
/
├── docker-compose.yml
├── .env.example
├── ai-pipeline/
│ └── src/
│ ├── index.ts # Webhook listener + orchestration
│ ├── translate.ts # Language detection + English translation
│ ├── categorize.ts # Claude categorization (4 categories)
│ ├── draft.ts # Draft response generation
│ └── chatwoot.ts # Chatwoot REST API client
├── enrichment-service/
│ └── src/
│ ├── index.ts # GET /enrich, /sidebar, /health
│ ├── sentry.ts # Recent errors by user email
│ ├── posthog.ts # Session recordings by user email
│ └── postgres.ts # Account info from internal DB
├── chatwoot-config/
│ └── setup-notes.md # Detailed Chatwoot configuration reference
├── scripts/
│ └── seed-tickets.js # Import sample tickets via Chatwoot API
└── tickets/ # 100 anonymized sample support tickets
# AI Pipeline
cd ai-pipeline && npm install && npm run dev
# Enrichment Service
cd enrichment-service && npm install && npm run devBoth services use ts-node-dev for hot reload.
- Draft language: Drafts attempt to match the customer's language via Claude's language instruction. This works well for common languages but is not guaranteed — it's a soft instruction, not a translation pipeline. A more robust approach would translate the draft explicitly after generating it in English.
- Subsequent replies: Only the first message in a thread triggers the full AI pipeline. Subsequent customer replies are skipped to avoid noise. In a next iteration, incoming replies could trigger a lightweight "draft only" pass (no enrichment re-fetch).
- Enrichment sidebar: The
/sidebarHTML endpoint exists but is not wired into Chatwoot's UI in this local setup due to the HTTP/HTTPS constraint described above. In production, it would function as a Chatwoot custom app. - Auto-send: Drafts are always posted as internal notes — never sent automatically. An agent reviews and sends. This is deliberate: AI-assisted drafts reduce effort without the risk of sending incorrect or off-policy replies.