Status: Decision-pending (awaiting Kristerpher's call on DQ-1 through DQ-6) Author: software-architect Date: 2026-05-02 Trigger: Kristerpher, 2026-05-03 — "Can we look at what creating our own would look like… if we're going to have to pay for FreeScout."
Short answer: buy the FreeScout modules now, revisit build at the 200-ticket milestone.
The one-time module cost is $148 ($69 Webhooks + $79 OAuth Login). Annualised against the Lightsail bill that is ~$268/yr all-in — a rounding error at this stage. Building a purpose-fit ticketing system in the console's visual vocabulary is genuinely attractive and architecturally sound, but it carries a 15–20 day agent-time estimate spread over 4–6 calendar weeks, during which FreeScout remains the live system anyway. At current ticket volume (sub-50) there is no operational pain that $148 cannot buy today. The build path makes strong sense once two conditions are true: (a) the console "Investigate" integration is a daily-use feature and a bare REST call to FreeScout feels brittle, or (b) FreeScout's annual module/maintenance drag crosses $300/yr. Neither is true yet. If you commit to the build, start after MBT v1 and the Velvet GA are out the door — not in parallel.
| Line item | Source / notes |
|---|---|
AWS Lightsail micro_3_0 |
$10/mo = $120/yr |
| FreeScout Webhooks module | $69 one-time |
| FreeScout OAuth Login (Tagras) | $79 one-time |
| Heroku Basic dyno | $7/mo = $84/yr |
| Heroku Eco dyno (shared, sleeps) | $5/mo = $60/yr (not suitable for webhook ingest) |
| Heroku Postgres Essential-0 | $5/mo = $60/yr (10k rows) |
| Heroku Postgres Essential-1 | $9/mo = $108/yr (1M rows — right tier for a small ticketing DB) |
| Postmark inbound processing | Free up to 100 msg/mo; $1.50/mo per additional 1k inbound |
| Postmark outbound | Included in existing Postmark account (already paying) |
| Agent time at notional $150/hr | Used for TCO; adjust to your own rate |
| Horizon | Capital | Annual recurring | Cumulative |
|---|---|---|---|
| 12 months | $148 modules (one-time) | $120 Lightsail | $268 |
| 24 months | $0 incremental | $120 Lightsail | $388 |
| 36 months | $0 incremental (possible major version upgrade) | $120 Lightsail | $508 |
Hidden costs: FreeScout is PHP 8.2 + Laravel 5.5 (frozen, no active development on the CE core). Each OS/PHP security patch cycle on the Lightsail instance is a manual SSH + apt-upgrade operation, ~30 min operator time/quarter. Over 36 months that is ~360 min / 6 hr. At $150/hr: $900 in maintenance drag not counted above. Real cumulative 36-month TCO: ~$1,408.
Time to ship: Zero. SMTP already works. Buying the Webhooks module unlocks the POST /api/conversations endpoint for the console "Investigate" integration within hours of purchase.
Agent-time estimate: 15–20 days (120–160 hrs), plus operator review and iteration cycles (+20%).
| Phase | Agent hrs | At $150/hr |
|---|---|---|
| M1 — Inbound email loop | 24–40 | $3,600–$6,000 |
| M2 — Outbound reply | 16–24 | $2,400–$3,600 |
| M3 — Console Investigate integration | 8–16 | $1,200–$2,400 |
| M4 — Customer record + thread merge | 16–24 | $2,400–$3,600 |
| M5 — Agent assignment + statuses + notes | 16–24 | $2,400–$3,600 |
| M6 — Search + tags | 24–32 | $3,600–$4,800 |
| Operator review cycles (~20%) | 21–32 | $3,150–$4,800 |
| Total build cost | 125–192 hrs | $18,750–$28,800 |
Infrastructure once built:
| Line item | Annual |
|---|---|
| Heroku Basic dyno (raxx-tickets-prod) | $84 |
| Heroku Postgres Essential-1 | $108 |
| Postmark inbound (at current volume) | ~$18 |
| Total recurring | ~$210/yr |
| Horizon | Capital (build) | Annual recurring | Cumulative |
|---|---|---|---|
| 12 months | $18,750–$28,800 | $210 | $18,960–$29,010 |
| 24 months | same | $210 | $19,170–$29,220 |
| 36 months | same | $210 | $19,380–$29,430 |
Break-even against buy: The build cost is 37–57x the 36-month buy TCO. The build is justified by capability and brand cohesion, not by cost savings.
Time to ship MVP (M1–M5): 4–6 calendar weeks assuming agent-driven development with daily operator check-ins. FreeScout must remain live during this window.
Three candidates, all moose-vocabulary and currently unused:
| Candidate | Meaning in context | Fit |
|---|---|---|
| Echo | What calls travel through — the thread of a conversation bouncing between parties | Strong. Evokes the back-and-forth of support. Short, clear. |
| Trail | What you follow back — the conversation history, breadcrumbs to resolution | Strong. Maps well to audit trail concept. |
| Hoof | What a moose leaves behind — evidence of presence, impression in the ground | Evocative but opaque to newcomers; trail and echo read faster |
Recommendation: Echo. It names the core metaphor (a conversation that bounces back), it's a single syllable, it pairs cleanly with the other names (Raptor handles trades; Echo handles support), and it does not collide with any existing codenames.
Three options:
| Option | Pros | Cons |
|---|---|---|
A. Blueprint inside raxx-console-prod |
Zero new Heroku apps. Same auth layer (CF Access + existing session). Shares console_audit_log. Cheapest infra. Shares Postgres. |
Console dyno handles more traffic. Ticketing UI is accessible wherever console is accessible — no independent access control. Operational coupling: ticketing outage = console outage. |
B. Blueprint inside raxx-api-prod |
Shares Raptor's Postgres. Postmark inbound webhook goes to a known stable host. | Conceptually wrong: Raptor is a customer-facing API, not an ops surface. Blurs the console/API boundary. RBAC complications. |
C. New Heroku app raxx-tickets-prod |
Clean separation. Independent deploy and scale. Own Postgres. Own dyno. Fault-isolated. Hostname choices are fully flexible. CF Access protects it independently. | $84+$108 = $192/yr additional Heroku spend. New app = new deploy pipeline, new Infisical paths, new CF Access application. |
Recommendation: Option A — blueprint inside raxx-console-prod. Rationale: at MVP volume (sub-200 tickets), the operational coupling is acceptable and the infra cost advantage is real. The console Postgres already holds audit logs and admin state; tickets are operational data that belongs in the same ownership tier. If Echo outgrows the console dyno, extracting it to its own app is a well-understood migration (split blueprint + DNS swap). Keep Option C as the v2 extraction path.
erDiagram
CUSTOMERS {
uuid id PK
text email UK
text display_name
timestamptz first_seen_at
timestamptz last_seen_at
timestamptz deleted_at
}
TICKETS {
uuid id PK
text number UK
uuid customer_id FK
uuid assigned_to FK
text status
text subject
text[] tags
timestamptz created_at
timestamptz updated_at
timestamptz closed_at
}
THREADS {
uuid id PK
uuid ticket_id FK
text direction
text body_html
text body_text
text postmark_message_id
text in_reply_to_message_id
boolean is_internal_note
uuid authored_by FK
timestamptz created_at
}
AGENTS {
uuid id FK
text role
}
ATTACHMENTS {
uuid id PK
uuid thread_id FK
text filename
text s3_key
bigint size_bytes
timestamptz created_at
}
CUSTOMERS ||--o{ TICKETS : "opens"
TICKETS ||--o{ THREADS : "contains"
TICKETS }o--o| AGENTS : "assigned to"
THREADS ||--o{ ATTACHMENTS : "has"
THREADS }o--o| AGENTS : "authored by"
Notes:
- AGENTS is a logical view over the existing admins table in the console — no separate table needed at MVP.
- status enum: open, pending (waiting on customer), resolved, closed.
- direction enum: inbound (customer email), outbound (agent reply), internal (note).
- number is a human-readable ticket number (e.g., TKT-0042), auto-incremented via a Postgres sequence.
- deleted_at on CUSTOMERS supports GDPR erasure — soft-delete the customer row, scrub email and display_name to [deleted].
sequenceDiagram
participant Customer
participant Postmark as Postmark<br/>(inbound processing)
participant Echo as Echo<br/>(POST /tickets/inbound)
participant DB as Console Postgres
participant AuditLog as console_audit_log
Customer->>Postmark: Email to support@raxx.app
Postmark->>Echo: POST /tickets/inbound<br/>(JSON: From, Subject, TextBody, HtmlBody,<br/>MessageID, InReplyTo, attachments)
Echo->>Echo: Validate Postmark webhook token<br/>(header X-Postmark-Signature or shared secret)
Echo->>DB: Upsert CUSTOMERS by email
alt InReplyTo matches existing thread
Echo->>DB: Append THREADS row (direction=inbound)
else New conversation
Echo->>DB: INSERT TICKETS + THREADS (direction=inbound)
end
Echo->>AuditLog: INSERT action=tickets.inbound<br/>target_type=ticket, target_id=uuid
Echo-->>Postmark: 200 OK
Echo->>Echo: Notify assigned agent (ops@ email via Postmark)
Threading key: match InReplyTo or References header against threads.postmark_message_id. Postmark preserves these headers faithfully for IMAP-fetched inbound.
sequenceDiagram
participant Agent as Agent browser
participant Echo
participant DB as Console Postgres
participant Postmark as Postmark<br/>(Server API)
participant Customer
Agent->>Echo: POST /tickets/{id}/reply<br/>(body_html, is_internal)
Echo->>Echo: CF Access + role check (ops/superadmin)
alt is_internal = true
Echo->>DB: INSERT THREADS (direction=internal)
Echo-->>Agent: 201 Created (no email sent)
else is_internal = false
Echo->>DB: INSERT THREADS (direction=outbound)
Echo->>Postmark: POST /email<br/>From: support@raxx.app<br/>ReplyTo: support@raxx.app<br/>In-Reply-To: last thread MessageID
Postmark->>Customer: Email reply
Postmark-->>Echo: 200 + Postmark MessageID
Echo->>DB: UPDATE threads.postmark_message_id
end
Echo->>DB: INSERT audit_log (action=tickets.reply)
Echo-->>Agent: 201 Created
Bounce handling: subscribe to Postmark's Bounce webhook. On hard bounce: mark tickets.customer_email_bounced = true, surface warning in ticket UI, halt further outbound to that address.
Two hostname options:
| Option | URL | Pros | Cons |
|---|---|---|---|
| Subfolder in console | console.raxx.app/tickets/ |
Single app, single deploy, same session cookie | URL reveals internal tooling structure |
| Subdomain | tickets.raxx.app/ |
Current FreeScout hostname — zero DNS change required for cutover | Requires CF Access application update if on a different Heroku app |
Recommendation: console.raxx.app/tickets/ if Echo lives inside the console blueprint (Option A). The subdomain tickets.raxx.app currently points to the Lightsail FreeScout instance and is needed until cutover; once cutover is ready, a single DNS CNAME edit moves it to Heroku. At that point, keep both hostnames working (redirect tickets.raxx.app → console.raxx.app/tickets/) for bookmarked links.
Visual vocabulary: Tailwind dark theme, same tile/card language as _status_grid.html, mobile-first 390 px breakpoint. Confidence Engine voice in empty states (no Bandz for ops-internal surfaces — Bandz lives in Antlers). Ticket list renders with same HTMX partial-swap pattern as the status grid.
CF Access protects console.raxx.app at the CDN edge — inherited automatically if Echo is a console blueprint. Role check inside the view: ops or superadmin (from admins.role, existing model). No customer-facing login surface; customers interact only by replying to email.
Every ticket state change, every reply, every assignment writes a row to console_audit_log with:
action: tickets.inbound / tickets.reply / tickets.assign / tickets.status_change / tickets.note / tickets.closetarget_type: tickettarget_id: ticket UUIDadmin_id: acting agent (null for inbound from Postmark webhook)payload_redacted: {"ticket_number": "TKT-0042", "new_status": "resolved"} — no customer PII in the audit rowRetention: follows the existing console audit log retention policy (90 days active, 7 years cold storage per GDPR DPA-ready logging requirement).
All estimates are agent-time. Calendar time assumes one focused sprint per milestone plus operator review.
| Milestone | Scope | Agent-time | Calendar |
|---|---|---|---|
| M1 — Inbound email loop | Postmark inbound webhook → /tickets/inbound endpoint → ticket + thread row → read-only ticket list view in console |
3–5 days | Week 1 |
| M2 — Outbound reply | Reply form in UI → POST to API → Postmark send → thread record + audit row; bounce webhook handler | 2–3 days | Week 2 |
| M3 — Console Investigate integration | POST /tickets/from-snapshot endpoint accepting deploy_id + surface_id + audit-log JSON snapshot; wires to "Investigate" button on degraded status tiles |
1–2 days | Week 2 |
| M4 — Customer record + thread merge | Customer upsert on inbound; In-Reply-To threading logic; merge-ticket UI (two open threads from same email → one ticket) |
2–3 days | Week 3 |
| M5 — Agent assignment + statuses + internal notes | Assignment dropdown, status transitions (open → pending → resolved → closed), internal note flag, email notification to assigned agent | 2–3 days | Week 4 |
| M6 — Search + tags | Postgres tsvector full-text index on threads.body_text + customers.email + tickets.subject; tag input on ticket; filter bar on list view |
3–4 days | Weeks 5–6 |
| Total | 13–20 days | 4–6 weeks |
M1–M3 are the minimum viable handoff. At M3 the system can receive inbound email, send replies, and accept structured payloads from the console status grid. M4–M6 harden it for sustained daily use.
FreeScout remains live at tickets.raxx.app. All real inbound email routes through FreeScout. The Echo blueprint under console.raxx.app/tickets/ runs in dark mode — the only traffic it receives is synthetic test emails and console "Investigate" payloads once M3 is live.
History decision (see DQ-3 below): at current volume (sub-50 tickets), the recommended path is drop history and start fresh. Export a PDF/CSV of FreeScout conversations to Google Drive for the record, then point all traffic to Echo. Importing FreeScout's MariaDB schema into Echo's Postgres is a non-trivial ETL with diminishing returns at this volume.
DNS swap: Update Cloudflare DNS tickets.raxx.app CNAME from the Lightsail static IP to raxx-console-prod.herokuapp.com (or keep console.raxx.app/tickets/ as the canonical URL and make tickets.raxx.app a redirect). Either is a single Terraform change.
Postmark inbound route: Update the Postmark inbound domain rule for support@raxx.app from the current SMTP fetch setup to a direct webhook to https://console.raxx.app/tickets/inbound. This is a one-line change in the Postmark dashboard.
Decommission FreeScout: Stop the Lightsail instance. Keep the Terraform state and last snapshot for 30 days, then destroy. Saves $120/yr.
If Echo has a critical bug post-cutover: revert the Cloudflare DNS CNAME (single Terraform apply), revert the Postmark inbound webhook URL. FreeScout instance is still running and still has its data. The window where both systems are live simultaneously means no data is at risk during rollback.
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Email threading breaks (customer reply opens new ticket) | Medium | Medium | Match on In-Reply-To + References headers AND on customer email + ticket-number pattern in subject line (e.g., [TKT-0042]). Postmark preserves headers; most email clients do too. |
| Bounce loop — agent reply bounces, system retries | Low | High | Hard-stop on bounce: set customer_email_bounced flag, surface in UI, no auto-retry. Manual operator override only. |
| Spam ingest floods the DB | Low (CF Access + Postmark spam score) | Medium | Postmark scores inbound; filter SpamScore > 5.0. Add a DB-level rate limit: max 10 tickets/24h from a single email address before auto-quarantine. |
Postgres LIKE search too slow at scale |
Low at MVP | Low | Use pg_trgm trigram index from M6. Migration is additive (CREATE INDEX CONCURRENTLY) — no downtime. |
| Operator burnout from no auto-close | Medium | Low | M6 stretch: cron job auto-closes tickets with pending status and no reply in 14 days. Customer gets a notification email. |
| Console dyno pressure from webhook bursts | Low | Medium | Postmark inbound is fire-and-forget; webhook handler must respond 200 within 5s or Postmark retries. Keep handler lightweight (DB write + audit row only; no outbound email in the webhook path). Outbound notifications fire from a deferred worker. |
| GDPR: customer PII in ticket body | Certain | High | DSR erasure path: soft-delete CUSTOMERS row, scrub email and display_name to [deleted], replace thread body_html/body_text with [erased per DSR YYYY-MM-DD]. Attachments: delete from S3, mark attachments.erased_at. Audit log entries: keep action metadata, scrub any PII in payload_redacted. |
The following are binary decisions that must be resolved before any sub-cards are filed. These are Kristerpher's calls.
DQ-1 — Surface placement
Options: (A) Blueprint inside raxx-console-prod [recommended], (B) Blueprint inside raxx-api-prod, (C) New Heroku app raxx-tickets-prod.
Default if no answer: A.
DQ-2 — Customer portal Should customers have a portal (log in, see ticket history)? Recommendation: NO for MVP. Customers reply by email only. Re-evaluate at 500 tickets. Default if no answer: No portal.
DQ-3 — Cutover history strategy Options: (a) Drop FreeScout history, start fresh [recommended — sub-50 tickets, low value to migrate], (b) ETL FreeScout MariaDB export → Echo Postgres (adds ~2–3 agent days), (c) Archive FreeScout PDF/CSV to Google Drive + start fresh. Recommendation: (c) — archive PDF/CSV to Drive for reference, start fresh in Echo. Default if no answer: (c).
DQ-4 — Hostname at cutover
Options: (a) Keep tickets.raxx.app [zero customer-visible change], (b) Move to console.raxx.app/tickets/ as canonical and redirect tickets.raxx.app, (c) New subdomain support.raxx.app or inbox.raxx.app.
Recommendation: (b) — console.raxx.app/tickets/ canonical, tickets.raxx.app redirects. Keeps the console as the single ops domain. No confusion about which surface has authority.
Default if no answer: (b).
DQ-5 — Codename Options: Echo [recommended], Trail, Hoof. This is cosmetic but affects every sub-card title, doc reference, and Heroku app name. Default if no answer: Echo.
DQ-6 — Mobile-first breakpoint Options: (a) 390 px mobile-first [recommended — consistent with console UX direction and Kristerpher's phone use], (b) 1280 px desktop-first. Default if no answer: (a) 390 px mobile-first.
customers.email and customers.display_name to [deleted]. Replace thread bodies with [erased per DSR YYYY-MM-DD]. Delete S3 attachments. Preserve audit log action metadata (action name, timestamp, ticket number) with PII stripped. Full erasure should complete within 30 days of DSR receipt (GDPR Art. 17).GET /tickets/customers/{email}/export (superadmin only, CF Access gated, audit-logged)./MooseQuest/echo/POSTMARK_SERVER_API_KEY. Postmark inbound webhook shared secret at /MooseQuest/echo/POSTMARK_INBOUND_SECRET. Both rotatable without redeploy via heroku config:set + Infisical sync. Never in code.X-Postmark-Signature header. Replay of a valid Postmark payload would produce a duplicate thread row (deduplicated by postmark_message_id unique constraint).console_audit_log (existing table). Inbound email receipt: action=tickets.inbound. Agent reply: action=tickets.reply. Status change: action=tickets.status_change. All entries include admin_id (null for automated inbound), target_type=ticket, target_id.ops@raxx.app + GDPR 72-hour supervisory authority report flow. The breach-notification automation must be confirmed to cover the new tickets table scope before M1 goes to production.FLAG_ECHO_INGEST gates the inbound webhook endpoint. Flip to false → endpoint returns 503 → Postmark retries for 72 hours → operator can restore before messages are lost./tickets/inbound webhook endpoint must be excluded from CF Access (Postmark cannot authenticate through it). Implement a Cloudflare Access bypass rule for POST console.raxx.app/tickets/inbound with the Postmark IP range allowlist as the bypass condition. The endpoint itself validates the HMAC secret independently.These are unresolved and block sub-card filing if the build path is chosen.
console.raxx.app (requires CF Access bypass rule) or on api.raxx.app/api/tickets/inbound (already public)? The latter avoids the CF Access bypass complexity but couples Raptor to an ops-internal feature.raxx-support-attachments bucket, already provisioned for FreeScout) or in Postgres bytea (simpler, but bloats DB fast)? Recommendation: S3, same bucket as FreeScout attachments.ops@raxx.app only, or also a Slack DM to D0AJ7K184TV? Slack DM is lowest-friction for solo ops.project_console_ticketing_integration.md) should be superseded by this doc. Confirm that the Investigate integration card (un-filed as of this writing) will target Echo's /tickets/from-snapshot endpoint rather than FreeScout's API.If you ship Echo weekend-and-evenings over 4–6 weeks while keeping the main focus on MBT v1 and Velvet GA, you end up with a ticketing surface that looks and feels like the console — dark theme, Confidence Engine voice, mobile-first, one-click "Investigate" that does not leave the ops context. You own the code, you own the schema, you own the DSR erasure path, and you never wrestle with PHP on a Lightsail box again. The brand coherence is real and will matter as the customer base grows.
But 4–6 weeks is not free. Every week on Echo is a week not on MBT v1 or Velvet polish. At sub-50 tickets and zero customer-facing integrations, the operational gap between FreeScout+Webhooks-module and a custom system is near zero. The $148 module cost is genuinely trivial.
The honest call: buy the Webhooks module this week ($69), get the console "Investigate" button working against FreeScout, and set a trigger: when the console Investigate button is used more than 5 times in a week, or when FreeScout causes more than 2 ops incidents in a quarter, greenlight the Echo build. You will not regret the $69. You will regret starting the build while MBT v1 is half-done.