our own product · comments

EchoThreadBeta runs on Siftfy, classifying every new comment before it goes public.

Updated May 12, 2026

EchoThread is a privacy-first comment platform — a 15 KB embeddable widget for blogs, docs sites, and web apps. Every new comment runs through POST /v1/predict synchronously, on the same HTTP request that submits the comment, before the widget renders it back.

This is our own product, not a customer story. EchoThread and Siftfy are both operated by VectraSEO LLC. Nobody chose Siftfy at arm's length here — we built both, so treat this as an engineering write-up of how they fit together rather than a recommendation from a third party.

The product

EchoThread is a drop-in comment widget: one <div>, one <script>, and a thread is live on any page. Threaded conversations, emoji reactions, image attachments, Google OAuth for commenters, and a magic-link dashboard for site owners. No third-party cookies, no ad tech, and no data sales — see echothread.io for the full pitch.

The product is free during beta, with paid plans post-beta. Site owners get a moderation dashboard with three buckets — pending, approved, spam — and the Siftfy probability is surfaced as a colour-coded badge alongside each comment in the queue.

Why synchronous, why fail-closed

Comments are the inverse of email. Email is a 1:1 channel into a private inbox; if a borderline message slips through, the recipient sees it and can correct course. A comment is a public broadcast — once it renders for one reader, it renders for everyone. Two consequences for the integration shape:

  • Synchronous, not background. The Siftfy call lives on the POST /comments request path. The submitter is already waiting on a network round-trip; budgeting a 5-second timeout for classification inside that wait is cheaper than building a queue, a worker, and a hold-then-publish state machine.
  • Fail-closed, not fail-open. If Siftfy times out or returns a 5xx, the comment lands in PENDING for moderator review — never APPROVED, even on sites configured to auto-approve. The webmail counterpart fails open for exactly the inverse reason; here, the cost of one held comment is a few minutes of moderator latency, and the cost of one auto-approved spam comment is a public footprint that gets indexed.

Two thresholds, three buckets — and a ceiling over both

Siftfy returns a calibrated probability and, in the same response, the max_confidence that was applied to this text. EchoThread routes on both. The configured block threshold is a ceiling, not a floor: it is capped down to whatever max_confidence came back, so the auto-hide branch stays reachable when the two drift apart.

This page used to be wrong about that. It described a Python service comparing against a block threshold of 0.85 — above the ceiling of 0.84 that Siftfy clamps every unaided score to, which made the auto-hide branch dead code. The comparison ran on every comment and could never once fire. That Python module no longer exists; the Go service below is what runs, and the cap is why the branch is reachable at all.

  • SIFTFY_BLOCK_THRESHOLD — at or above the capped value, and only with a corroborating content signal, the comment is saved with status SPAM and never appears in the public thread. The row is kept rather than deleted so moderators can inspect false positives later.
  • SIFTFY_REVIEW_THRESHOLD — between this and the block threshold, the comment is held in PENDING for the moderation queue. Visible to its author, not to the public.
  • Below the review threshold, the comment defers to the site's auto_approve toggle: published immediately on auto-approve sites, queued otherwise.

The deployed values of those two live in EchoThread's own config and are deliberately not reproduced here. Copying a threshold into a second document is the defect this whole page is a record of — read reading the score for why the only number you should trust is the one in the response.

A high score alone may not hide a comment

Capping the threshold to the ceiling is only safe because a second rule sits behind it: a score at or above the block threshold routes to SPAM only when a deterministic content signal corroborates it. Without corroboration it goes to review instead. Three signals count — link_density, promo_terms, and drive_by_link. Two that look similar are deliberately excluded: all_caps and repeated_chars describe tone, not intent, and must not bury a comment on their own.

That corroboration is the “second signal” the ceiling exists to insist on. Siftfy withdrew the model's authority to block alone, not its authority to block at all. The false positives that prompted the clamp here — account-deletion and magic-link requests, written in earnest and scored high — carry no link, no promo phrasing, and no drive-by tell, so they still land in review rather than the spam bucket.

One exception, and it runs the other way. Predominantly non-Latin comments and very short ones use a separately configured, higher block threshold, and that raise is not capped to the ceiling. The model is least trustworthy on exactly that content, so lowering the bar there would arm auto-hide hardest where the evidence is weakest. Those comments keep an unreachable block band on purpose and fall through to review.

The integration, end-to-end

The classifier input is just the comment body — no URL, no parent thread, no commenter identity. Authorship and page-context signals are kept out of the classifier on purpose: the spam decision should turn on the text the reader will actually see, not on who's posting it. spam_score — the raw probability — lands on the comment row, and the status is derived from it together with the ceiling and the corroboration check below.

go
// internal/api/moderation.go -- the real decision, and the one the
// home-page demo calls too. A demo that disagrees with production is
// worse than no demo.
func (a *App) modDecideStatus(body string, scored bool, spamScore, maxConfidence *float64,
	autoApprove, forceScreen bool) models.CommentStatus {

	// The configured block threshold is capped DOWN to the ceiling Siftfy
	// published for this very prediction. Without this, a threshold above
	// the ceiling makes the auto-hide branch unreachable -- which is
	// exactly what happened to us, silently, for months.
	effBlock := spamEffectiveBlockThreshold(body, a.Cfg.SiftfyBlockThreshold,
		a.Cfg.SiftfyNonLatinBlockThreshold, maxConfidence)

	return modStatusFromScore(scored, spamScore, autoApprove, forceScreen,
		effBlock, a.Cfg.SiftfyReviewThreshold,
		len(spamURLRe.FindAllString(body, -1)), spamHasCorroboratingSignal(body))
}

// internal/api/spam.go
func spamCapBlockThreshold(base float64, maxConfidence *float64) float64 {
	if maxConfidence != nil && *maxConfidence > 0 && *maxConfidence < base {
		return *maxConfidence
	}
	// A ceiling we did not receive must not be read as a low one.
	return base
}

// internal/api/moderation.go -- pure, so every rule below is unit-tested
// without a database.
func modStatusFromScore(scored bool, spamScore *float64, autoApprove, forceScreen bool,
	blockThr, reviewThr float64, linkCount int, corroborated bool) models.CommentStatus {

	if !scored {
		// Fail closed for guests: never auto-approve an unscreened guest,
		// even on a site configured to auto-approve.
		if autoApprove && !forceScreen {
			return models.StatusApproved
		}
		return models.StatusPending
	}
	switch {
	case spamScore == nil:                                 // Siftfy outage mid-request
		return models.StatusPending
	case *spamScore >= blockThr && corroborated:
		return models.StatusSpam                           // hide it
	case *spamScore >= blockThr:
		// High score, nothing corroborates it. Review, never a silent hide:
		// the clamp withdrew the model's authority to bury a comment alone.
		return models.StatusPending
	case *spamScore >= reviewThr:
		return models.StatusPending
	case autoApprove:
		return models.StatusApproved
	}
	return models.StatusPending
}

The moderation dashboard surfaces spam_score as a percentage badge — red above the block threshold, amber above the review threshold, grey below — so a moderator triaging the queue can see at a glance which holds the classifier was confident about and which were borderline. A comment that cleared the block threshold but had nothing corroborating it shows red and still sits in the queue, which is the pair a moderator most needs to see together.

What we got from it

  • One synchronous call, no queue. No worker pool, no Redis, no DLQ. The classifier is a single net/http call on the comment-submit path with a 5-second budget.
  • Three statuses, one model, no retraining.APPROVED, PENDING, and SPAM map directly to product UX — published, in-queue, hidden — without retraining or a handcrafted decision tree.
  • Per-site escape hatch. The spam_filter column on the Site row disables Siftfy entirely for invite-only sites where the operator already controls who can post. The classifier doesn't even get called.
  • The probability stays on the row. Storing spam_score alongside the comment lets moderators see the classifier's confidence in the queue UI and audit borderline decisions later — the same data that drove the routing is the data the moderator sees.
  • Build cost: a few hours. One service module, one config block, one moderation-dashboard badge. The hardest part was picking the two threshold defaults.
Try Siftfy free

10,000 classifications / month free. /v1/predict reference, related patterns: comments, contact forms.