Skip to content
Architecture

Architecture

MilesAhead is one Go binary on a small OCI ARM VM. Every three hours it scans miles-and-points blogs, groups posts about the same story, skips stories it has already written, and has a model on the NVIDIA API write the article and a second model check it. An admin approves or rejects each article in the dashboard, while a background pass adds Scout’s Take, key details, tags and a photo. The same binary serves the public site and the admin behind Caddy, sends the weekly newsletter through OCI Email Delivery, and exposes metrics that Grafana Alloy ships to Grafana Cloud. Everything lives in one SQLite file, backed up daily and copied off the server.

System overview

Select a component to see what it does, where its code lives and how it is configured. With a keyboard, Tab moves between components, Enter opens one and Escape closes it.

The diagram is wider than this screen: swipe sideways to see all of it, or use the list below it.

  • Runs on the VM
  • Third-party service
  • Not running in production
  • Job or process
  • Service
  • Database
  • External API
  • Feed source
  • Scheduler
  • Mail
  • DNS
  • Proxy
  • People
  • Monitoring
  • CI and deploys
  • Docs hosting

Data flows down, from the news and image sources to readers' browsers and inboxes; the operations components are on the right. Solid boxes run on the OCI VM, dashed boxes are third-party services, and the dotted one is in the code but not running in production. The shape, fill and badge of each box say what kind of component it is. A clock line marks a job the Scheduler starts. Every component is also described in the list below.

Sources

News sources RSS feeds · blog pages Feed source

The miles-and-points blogs and news sites in the sources table, added and edited on the admin Sources page. Each has an RSS URL or a CSS selector for scraping, and can override the scan interval. Articles are written from them but never name them.

Code
internal/scanner
Reads / writes
Read by the scanner; rows managed in the admin (sources).
Config
scanner.default_interval, scanner.user_agent

Read more: Adding sources · Source references and outlet mentions

Image sources 4 free image APIs External API

The image search queries four APIs in parallel for every search term: Unsplash and Pexels (API keys), Openverse (no key; aggregates Flickr, Wikimedia and other open collections) and the Wikimedia Commons MediaWiki API. At most 3 results per API per query, 3 per source after de-duplication and 15 in total.

Code
internal/images/search.go, internal/images/sizing.go, internal/images/identity.go
Reads / writes
No database access; results go to the enrichment reconciler.
Config
ai.unsplash_key, ai.pexels_key
Metrics
milesahead_image_search_total{source,outcome}, milesahead_image_search_candidates

Every request carries a MilesAhead/1.0 User-Agent (Wikimedia rejects generic clients), and Wikimedia thumbnails are warmed at most two at a time, as its robot policy asks.

Read more: Image pipeline

Ingestion

Scanner internal/scanner Job or process

Fetches each active source’s RSS feed, or scrapes its page, and stores new posts. A SHA-256 hash of title and link rejects duplicates at insert. Runs at startup and then every scanner.default_interval minutes (180 in production); a scan still running when the next is due makes that one skip.

Code
internal/scanner, cmd/milesahead/scan.go
Reads / writes
Reads sources; writes raw_articles.
Config
scanner.default_interval, scanner.user_agent
Metrics
milesahead_scans_total
Clustering internal/cluster Job or process

Groups the last 48 hours of scanned posts by title similarity (TF-IDF cosine). Only a cluster with posts from at least two different sites goes on to generation, so every article has more than one source to be checked against.

Code
internal/cluster
Reads / writes
Reads raw_articles.
Config
scanner.cluster_threshold
Story dedup covered raws · 7-day check Job or process

Two checks before anything is written. Raw-ID check: a cluster that reuses a post some article was written from (article_sources) or already covers (covered_raws) is skipped, and its other posts are recorded in covered_raws, so late coverage cannot come back as a second article once the originals leave the 48-hour window. Story check: the cluster is compared with every article from the last 7 days, in any status: a TF-IDF prefilter keeps up to 5 scoring at least 0.25, then one short AI call on the enrichment chain decides “same story, and if so materially new?”. If that call fails, a lexical score of at least 0.55 decides.

Code
internal/storydedup, internal/db/coverage.go, cmd/milesahead/scan.go
Reads / writes
Reads generated_articles, article_sources, covered_raws; writes covered_raws.
Config
scanner.story_dedup (shadow in production; block or off)
Metrics
milesahead_story_dedup_decisions_total{decision,mode}

In shadow mode the story check logs story dedup decision and counts, but still generates. block skips the duplicate and records its posts with reason story_match.

Read more: Deduplication

AI pipeline

AI pipeline write · check · revise Job or process

Turns a cluster into a checked draft. The generator writes (with a fallback chain); editor notes are split out; citation markers are removed and any numbered source reference or news-site mention is flagged; the critic chain scores it with thinking off; remarks that only confirm a passed check are dropped; if any issue is left, the generator revises once and the higher-scoring version is kept. Key details and links come from the raw reply, with a separate extraction call only as a fallback.

Code
internal/ai, internal/editornote
Reads / writes
None directly; returns a result to the article manager.
Config
ai.generator_model, ai.fallback_generator_model, ai.critic_model, ai.critic_disable_thinking, ai.critic_threshold, ai.request_timeout
Metrics
milesahead_critic_score, milesahead_articles_generated_total, milesahead_ai_api_duration_seconds, milesahead_ai_api_errors_total

Read more: AI pipeline

NVIDIA API integrate.api.nvidia.com External API

The OpenAI-compatible endpoint (https://integrate.api.nvidia.com/v1) every model is called through. A startup preflight probes each configured model, since the provider’s catalogue lists models that do not answer.

Config
ai.hf_base_url, ai.hf_api_token
Metrics
milesahead_ai_preflight_models_reachable{role}, milesahead_ai_api_errors_total
RoleModels (tried in order)
Generatornvidia/nemotron-3-ultra-550b-a55b, then openai/gpt-oss-20b, nvidia/nemotron-3-super-120b-a12b
Critic (thinking off)nvidia/nemotron-3-super-120b-a12b, then moonshotai/kimi-k3
Short callsopenai/gpt-oss-20b, then nvidia/nemotron-3-super-120b-a12b
Visionmeta/llama-3.2-11b-vision-instruct

The API key is the NVIDIA_API_KEY GitHub secret.

Read more: Models

Enrichment internal/enrich Job or process

A background pass that fills what generation leaves empty: Scout’s Take, key details, tags and the image. A miss is simply retried next pass. Each pass first resizes stored oversized images and serves any Find a new image request; the new picture replaces the old one only once it is found.

Code
internal/enrich, internal/images/scorer.go
Reads / writes
Reads and writes generated_articles; article_image_requests, article_rejected_images.
Config
ai.enrichment_model, ai.enrichment_interval, ai.enrichment_max_attempts, ai.enrichment_max_duration, ai.vision_model
Metrics
milesahead_articles_degraded, milesahead_enrichment_passes_total, milesahead_enrichment_last_success_timestamp_seconds, milesahead_image_resize_total, milesahead_article_images_unsized

Short calls are capped at 30 s. A Scout’s Take that names a news site or cites “Source 1” is refused, and such key details are dropped. Grafana alerts when no pass has finished for an hour.

Read more: Enrichment · Find a new image

Content & data

Article manager status · revisions · undo Job or process

Owns an article’s status. A new article is pending at or above critic_threshold (75) and draft below. Approve and reject post the revision the admin saw and are refused if a rewrite landed since; an approval can be undone for 30 s. Rewrites start at once and a revision reconciler retries them every 5 min with backoff (5, 10, 20, 40 min), giving up after 5 failures.

Code
internal/article, internal/cardupdate, internal/linker
Reads / writes
generated_articles, article_revisions, admin_feedback, verification_results, approval_undo, article_sources.
Metrics
milesahead_articles_approved_total, milesahead_revisions_total, milesahead_revision_reconcile_total{outcome}, milesahead_revisions_waiting{state}

Read more: Article workflow

SQLite WAL · FTS5 · backups Database

One SQLite file in WAL mode (modernc.org/sqlite, pure Go) at /opt/milesahead/data/milesahead.db. Migrations run at every start. Search uses two FTS5 indexes (articles_fts, cards_fts; porter stemming, accents folded), rebuilt once when their version in search_index_versions changes.

Code
internal/db, deploy/backup.sh, .github/workflows/backup.yml
Config
db.path

Backups. milesahead-backup.timer runs backup.sh daily at 03:30: VACUUM INTO (a plain copy would miss pages still in the WAL), PRAGMA integrity_check, gzip, 30 days kept on the server. The Database Backup workflow then pulls the newest archive off the box daily, checks that it restores, and keeps the 14 most recent in the db-backups release.

Read more: Database tables · Backups

Review replies IMAP · not running Mail

Code for reviewing by email: review messages with a review+TOKEN@ Reply-To, and an IMAP poller that reads replies (APPROVE, REJECT, UNPUBLISH, or anything else as rewrite feedback). Off: nothing sends review emails and nothing matches a reply to its article, so the poller never starts. Startup logs review by email is off (mail.imap not configured) and the settings page says so. Reviews happen in the admin dashboard; ROADMAP.md lists what review by email would take.

Code
internal/mail/inbound.go, internal/mail/reviewbyemail.go
Config
mail.imap.host, mail.imap.user, mail.imap.password, mail.imap.poll_interval

Read more: Email

Delivery & mail

Web server site · admin · API Service

The Go net/http server on port 9090: the public site (home, articles, cards, compare, search, feed, sign-up), the admin dashboard (session login, CSRF, noindex), POST /api/vote, /healthz and /metrics. HTML is no-cache; content-versioned static files are cached for a year, others for an hour. It also sends sign-up confirmation emails. Admin decisions (approve, reject, rewrite, undo) go through the article manager.

Code
internal/web, templates/, static/
Reads / writes
Reads nearly every table; writes admin decisions, votes, subscribers, sessions and image requests.
Config
server.port, server.domain, admin.password_hash, newsletter.signup_enabled
Metrics
milesahead_http_requests_total, milesahead_http_duration_seconds, milesahead_http_response_bytes, milesahead_votes_total

Read more: Reader site · Article workflow

Caddy TLS · zstd/gzip Proxy

The VM’s shared reverse proxy, in Docker, reading one file per site from /opt/fynans/caddy-sites/. milesahead.caddy serves milesahead.kumaranik.com over HTTPS, compresses responses with encode zstd gzip, sets security headers and proxies to the app on port 9090. The deploy validates the file inside the container before installing it, and fails if the reload fails.

Code
deploy/milesahead.caddy

Read more: Deployment

Cloudflare DNS site · docs · mail records DNS

DNS for the site, which resolves to the VM, the docs subdomain (Cloudflare Pages), and the mail-authentication records that let inboxes trust mail from milesahead.kumaranik.com.

RecordNameValue
CNAME (DKIM)milesahead-202610._domainkey.milesahead.kumaranik.commilesahead-202610.milesahead.kumaranik.com.dkim.sjc1.oracleemaildelivery.com
TXT (SPF)milesahead.kumaranik.comv=spf1 include:rp.oracleemaildelivery.com ~all
TXT (DMARC)_dmarc.milesahead.kumaranik.comv=DMARC1; p=none;

Read more: Email

Newsletter weekly · Saturdays Job or process

Builds the weekly issue the evening before the send (AI-ranked candidates, Scout’s Pick, Card of the Week) and sends it on newsletter.send_day at send_time (Saturday 08:00 New York time) to confirmed subscribers. The issue is re-rendered at send time and each copy gets its reader’s own unsubscribe link, in the body and in List-Unsubscribe with one-click List-Unsubscribe-Post.

Code
internal/newsletter, internal/mail/outbound.go, internal/web/subscribe.go
Reads / writes
Reads generated_articles, reference_pages, subscribers; writes newsletters, newsletter_candidates.
Config
newsletter.send_day, newsletter.send_time, newsletter.timezone, newsletter.signup_enabled, newsletter.unsubscribe_mailbox
Metrics
milesahead_emails_sent_total{kind,outcome}, milesahead_subscribers_total

Read more: Newsletter

OCI Email Delivery SMTP 587 · STARTTLS Mail

The outbound mail relay for confirmation emails and newsletters (and vote alerts, when votes.alert_email is set): Oracle Cloud’s Email Delivery service, reached over SMTP with STARTTLS.

Config
mail.smtp.host, mail.smtp.port, mail.smtp.user, mail.smtp.password, mail.smtp.from
Metrics
milesahead_emails_sent_total{kind,outcome}
SettingValue
Regionus-sanjose-1, Always Free tier (3,000 emails a month)
Email domainmilesahead.kumaranik.com, DKIM selector milesahead-202610
Approved sender[email protected]
SMTP endpointsmtp.email.us-sanjose-1.oci.oraclecloud.com:587, STARTTLS
Sending identityIAM user milesahead-smtp in group milesahead-smtp-senders, policy milesahead-smtp-send: “Allow group milesahead-smtp-senders to use email-family in tenancy”
CredentialsGitHub secrets SMTP_HOST, SMTP_USER, SMTP_PASSWORD (optional SMTP_PORT, SMTP_FROM), written into mail.smtp by the deploy’s config job, JSON-quoted with jq, and checked with -check-config

A test message passed SPF, DKIM and DMARC at Gmail.

Suppression list. OCI adds its own List-Unsubscribe to any message that has no https one, and an unsubscribe through it puts the address on OCI’s suppression list, not in the app. Every reader email therefore carries the app’s own per-reader link, which OCI leaves alone. Admin emails still get OCI’s header, so do not use a mail client’s Unsubscribe button on them.

Read more: Email

Readers

Inboxes readers' mail Mail

Readers’ mail providers. They check SPF, DKIM and DMARC against the records in Cloudflare, and Gmail and Yahoo require a one-click unsubscribe from bulk senders, which the List-Unsubscribe and List-Unsubscribe-Post headers provide.

Read more: Email troubleshooting

Readers browsers · the admin People

People reading the site, and the admin, in a browser at milesahead.kumaranik.com. Cloudflare DNS sends them to the VM, Caddy answers over HTTPS and passes each request to the web server. Readers browse, search, vote and sign up for the newsletter; the admin reviews articles in the dashboard under /admin.

Read more: Reader site · Article workflow

Operations

Scheduler cron jobs Scheduler

robfig/cron with panic recovery, wired in cmd/milesahead/main.go. It starts every background job in the process.

Code
internal/scheduler, cmd/milesahead/main.go
Config
scanner.default_interval, ai.enrichment_interval, mail.imap.poll_interval
JobWhen
Scan, cluster, generateat startup, then every 3 h (overlapping runs skipped)
Enrichment pass~90 s after startup, then every 15 min
Revision reconciler~45 s after startup, then every 5 min
Newsletter build20:00 the day before the send (Fridays, New York time)
Newsletter sendnewsletter.send_day at send_time (Saturdays 08:00 New York time)
IMAP pollnever: review by email is not built

Shutdown is bounded to 85 s, inside systemd’s 90 s stop timeout: 60 s for running jobs, 10 s for HTTP, 15 s for request-started rewrites.

GitHub Actions tests gate deploys CI and deploys

deploy.yml runs on every push to main: test (go vet, go test -race, the node tests) → build (linux/arm64) → config (writes config.yaml from secrets, runs -check-config) → deploy (copy files, validate and reload Caddy, refresh Alloy and the backup timer, swap the binary, restart, check it is active) → seed and grafana. Deploys queue rather than overlap. ci.yml checks pull requests; backup.yml takes the daily off-box backup.

Code
.github/workflows/deploy.yml, .github/workflows/ci.yml, .github/workflows/backup.yml

Read more: Deployment

Docs site Cloudflare Pages Docs hosting

This site: Hugo with the Hextra theme, in docs/, published by Cloudflare Pages at docs.milesahead.kumaranik.com. This diagram is the architecture shortcode, drawn from docs/assets/architecture/architecture.yaml.

Code
docs/

Read more: Development

Grafana Alloy metrics · logs Monitoring

Runs on the VM under systemd. Scrapes localhost:9090/metrics every 30 s and remote-writes to Grafana Cloud’s Prometheus, and ships the milesahead.service journal to Grafana Cloud’s Loki.

Code
deploy/alloy-config.alloy, deploy/alloy.service

Read more: Observability

Grafana Cloud dashboards · alerts Monitoring

Holds the metrics and logs, two dashboards and five alert rules, all kept in deploy/ and pushed by the deploy’s grafana job when its secrets are set.

Code
deploy/grafana-alerts.json, deploy/grafana-dashboard-business.json, deploy/grafana-dashboard-service.json
AlertFires when
No articles generated in 24 hfewer than 1 generated in 24 h, for 1 h
AI API failures sustainedmore than 30 final AI API failures in 6 h
Enrichment reconciler has stalledno completed pass for over an hour
Confirmation emails failing2 or more failed in 6 h
Newsletter send failedany failed send in 6 h

Read more: Observability

Key components

PackagePurpose
internal/scannerFetches RSS feeds and scrapes blog pages into raw_articles; SHA-256 of title and link rejects duplicates
internal/clusterGroups the last 48 hours of posts about the same story by title similarity (scanner.cluster_threshold)
internal/storydedupStory-level duplicate check against every article from the last 7 days: TF-IDF prefilter, then one short AI call (scanner.story_dedup)
internal/aiGenerator with fallbacks, critic chain, auto-revision, the passed-check filter, source-reference and news-site guards, key details, model preflight, prompts
internal/editornoteSplits admin-only editor notes (<div class="editor-note">) out of article content
internal/enrichEnrichment reconciler: Scout’s Take, key details, tags and the image every 15 minutes; resizes stored images; serves “Find a new image” requests
internal/imagesImage search across four APIs, text and vision scoring, URL sizing and byte caps, and picture identity across sizes (identity.go)
internal/articleArticle manager: status changes with the revision guard, undo, rewrites and the revision reconciler
internal/cardupdateAfter an approval, drafts updates to credit-card pages the article affects, for the admin to review
internal/linkerFinds card mentions in an article and links them to the card pages
internal/dbSQLite (WAL) access, migrations, the FTS5 search indexes, the admin queue, image requests, approval undo, story coverage
internal/webPublic site, admin dashboard, sign-up and unsubscribe, JSON API, error pages, caching headers
internal/schedulerCron jobs: scan, enrichment, revision reconciler, newsletter build and send, mail polling
internal/newsletterWeekly issue: build, AI pick, Card of the Week, per-reader send; the admin’s manual send
internal/mailOutbound SMTP (outbound.go: confirmations, newsletters with per-reader List-Unsubscribe, vote alerts to votes.alert_email) and the IMAP review-reply poller (inbound.go, not running in production)
internal/offerReads an offer’s end date from its key details (expires_on) and decides, in Hawaii time, whether it has ended
internal/metricsPrometheus metrics served at /metrics
internal/voteReader votes with downvote alerts: counted over votes.alert_window, recorded in vote_alerts for the admin Published page
internal/configYAML config; ${NAME} expands only when it is a whole value
internal/seeddataReal card data plus made-up articles for tests and local previews, never production data

The commands in cmd/ are the server (milesahead, with the scan loop in scan.go) and its tools: seed-cards and seed-structured (card pages and comparison data), ab-models (measure candidate models), flag-outlet-mentions (read-only report of articles naming a news site), repair-editor-notes (move old inline notes out) and backfill-scouts-take. See Development for how to run them.

Database tables

One SQLite file in WAL mode. Migrations in internal/db/migrations.go run at every start and are idempotent.

TablePurpose
sourcesBlogs and news sites to scan, with RSS URL, scrape selector and interval
raw_articlesScanned posts, unique by a hash of title and link
generated_articlesArticles with status, revision number, structured fields and enrichment state (columns below)
article_sourcesWhich posts an article was written from
covered_rawsPosts an article covers without citing them: late coverage skipped as a duplicate, with the reason. Read only by the duplicate check, so an article’s sources stay as written
verification_resultsCritic score, issues and the four checks for each revision
admin_feedbackRewrite requests, from the dashboard or (in code) email
article_revisionsEach rewrite’s before and after content, linked to the feedback it answered
approval_undoThe last approval of each article and the state before it, for the dashboard’s 30-second Undo
article_image_requestsPending or failed “Find a new image” requests, with the admin’s search words and a sequence number
article_rejected_imagesPictures an admin turned down for an article, never picked for it again
article_votesReader up and down votes, one per hashed IP
vote_alertsArticles whose recent votes crossed the alert line, with the counts, until the admin acknowledges them (at most one open per article)
newslettersIssues with their HTML, status and send time
newsletter_candidatesThe articles ranked for a weekly issue, and which is featured
subscribersNewsletter addresses, confirmation and unsubscribe state
admin_sessionsAdmin session tokens and expiry
reference_pagesCredit-card pages: sections, verdict, keywords, structured comparison data, pending updates, flags
articles_fts, cards_ftsFTS5 search indexes (porter stemming, accents folded) over articles and cards
search_index_versionsThe version each search index was built with; a change rebuilds it once at startup

Columns added to generated_articles over time: tldr, key_details, links, image_url, image_thumbnail, image_attribution, scouts_take, tags_json, related_cards_json, linked_cards_heading, enrichment_attempts, enrichment_last_at, expires_on (the offer’s last day), revision_failures, revision_retry_after, revision_last_error (the revision reconciler’s retry state) and editor_notes (admin-only). subscribers gained unsubscribed_at (an unsubscribe keeps the row) and confirmation_sent_unix (the 10-minute resend cooldown).

Tech stack

LayerTechnology
LanguageGo 1.26, standard library net/http and html/template
DatabaseSQLite in WAL mode via modernc.org/sqlite (pure Go), FTS5 for search
AI modelsNVIDIA API: nvidia/nemotron-3-ultra-550b-a55b writes (fallbacks openai/gpt-oss-20b, nvidia/nemotron-3-super-120b-a12b); critic chain nvidia/nemotron-3-super-120b-a12b then moonshotai/kimi-k3, thinking off; short calls on openai/gpt-oss-20b then nvidia/nemotron-3-super-120b-a12b; vision meta/llama-3.2-11b-vision-instruct
ImagesUnsplash, Pexels, Openverse and Wikimedia Commons APIs
Feeds and scrapinggofeed, goquery
Schedulingrobfig/cron v3
Emailgomail over SMTP to OCI Email Delivery; go-imap v2 for the (unused) reply poller
Front endServer-rendered HTML, plain CSS and vanilla JavaScript, tested with node --test
HostingOCI ARM VM (VM.Standard.A1.Flex, us-sanjose-1, Always Free), Ubuntu, systemd; Caddy in Docker, shared with other sites
DNSCloudflare
ObservabilityPrometheus metrics, Grafana Alloy, Grafana Cloud (metrics, logs, dashboards, alerts)
CI/CDGitHub Actions: tests gate every deploy
DocsHugo with the Hextra theme, on Cloudflare Pages