System Design · Proposal

Relay (working name) — multi-platform publishing pipeline

A ground-up redesign of the Sentinel architecture. Instead of writing TSX into one website repo, every post is authored as canonical markdown in a content repo, reviewed through the same PR loop, and on merge fanned out through platform adapters to the owned website, Dev.to, Hashnode, Medium, Substack, and LinkedIn — own site first, syndication with canonical links after.

ported reused from Sentinel generalized reworked from Sentinel new built for Relay API  browser transport reliability
00

Triggers & channel config

generalized scheduler + config

APScheduler cron and PR polling port straight over. The new piece is a channel registry: each configured channel defines a niche/audience, a style profile, target platforms, and posting cadence — so one deployment can run several content properties, not one hardcoded brand.

cron per channel PR poll dashboard triggers channels.yaml · niche / voice / platforms / cadence
01

Discovery

generalized from agents/discovery.py

Same engine — Claude Code with web search, dedupe against DB + published archive, GitHub issue per brief. Now parameterized by the channel's niche instead of hardcoded GPU topics, and briefs include a platform plan: which platforms this piece targets and how the angle differs per audience (a Substack read is not a Dev.to read).

claude · WebSearch WebFetch GitHub issues · briefs dedupe · DB + archive
02

Writing — canonical markdown

new replaces TSX writer

The biggest break from Sentinel. Posts are written as markdown + frontmatter in a dedicated content repo — the single source of truth all platforms render from. The branch/PR mechanics and Claude CLI spawning port over; the TSX/POSTS-array/sitemap coupling is gone.

  • content/{channel}/{slug}/index.md — the post body, platform-agnostic
  • frontmatter: title, slug, tags, channel, target platforms, per-platform overrides (title/teaser)
  • assets/ — hero + OG images generated alongside (media pipeline)
claude · Read Write Edit WebSearch content repo · PR labeled post
03

Review loop

ported from agents/reviewer.py

Sentinel's most valuable machinery, kept almost intact: per-PR worktrees, resolvable line comments, fix-and-push iteration, score gate. One change — the hardcoded pricing check becomes a pluggable validator slot, so each channel registers its own domain checks.

iterates until pass · max 5 · reviews markdown, not TSX
Review pass Editorial scoring · fact-check via web search · link targets WebFetched before suggesting · channel validators (pricing, code snippets, claims)
Gatescore ≥ threshold and zero open issues?
fix pushed → next iteration · fail 5× → PR closed
claude · Read Grep WebFetch Bash GitHub · line comments validator plugins per channel
04

Canonical publish — own site first

generalized from auto-publish + deploy poller

Merge enqueues publish jobs, but the owned website always goes first. Its adapter renders markdown into the site's format (TSX/MDX page, or a CMS API call for WordPress/Ghost), the ported deployment poller confirms the page is live, and that URL becomes the post's canonical URL.

Why the ordering matters: syndicated copies on Medium, Dev.to and Hashnode carry rel=canonical back to this URL. Publishing everywhere simultaneously would make the platforms compete with your own site in search; canonical-first means syndication adds reach without cannibalizing SEO.
renderer · md → TSX/MDX/CMS deploy verify · ported poller canonical URL recorded
05

Syndication fan-out

new adapter framework + job queue

Every adapter implements the same interface — render() transforms canonical markdown to the platform's format, publish() ships it, verify() confirms it's live. Jobs run from a publish queue with retries and per-platform status, so one flaky platform never blocks the rest.

Dev.toAPI

Official REST API, markdown-native, first-class canonical_url field. The easiest win.

forem API · api key
HashnodeAPI

Official GraphQL API with originalArticleURL for canonical attribution.

gql publishPost · pat token
Mediumbrowser

Official API is closed to new tokens. Playwright drives the import-story flow, which preserves the canonical link natively.

playwright · saved session
Substack browser

No official API. Email-to-draft creates the draft reliably; Playwright polishes formatting and hits publish/send.

smtp draft + playwright
LinkedInbrowser

Articles require automation; fallback is an API share-post linking to the canonical URL with a generated teaser.

playwright · saved session
Own website(s)git/API

The Sentinel model, now one adapter among many — git commit for static sites, REST for WordPress/Ghost. Supports multiple sites.

already shipped in stage 04
06

Verify, report, learn

generalized from Slack notify + dashboard

Each adapter's verify() re-fetches its published URL. Slack gets one digest per post — a URL per platform, failures flagged with retry state. The dashboard grows a post × platform matrix so the whole fan-out reads at a glance. Later: pull per-platform stats back in to inform discovery.

Slack digest dashboard matrix analytics loop · later
Deployment topology — what runs where
Relay host one always-on server · VM (Hetzner/EC2) or a machine you own · Docker or run.sh · everything below is one deployable unit
API server + dashboard FastAPI on uvicorn serves the REST API and the built React SPA from one process. :8500 · single port exposed
Scheduler APScheduler runs in-process with the API — per-channel crons and the PR poll loop. No separate service. in-process · asyncio
Agent runner Spawns Claude Code CLI subprocesses for discovery / writing / review / fixes with per-task tool whitelists. subprocess · stream-json
Adapter workers Async tasks draining the publish queue; browser adapters run headless Chromium via Playwright on this same box. asyncio tasks + playwright
SQLite database data/relay.db on local disk (WAL mode). Holds pipeline state and telemetry only — the content itself lives in GitHub, so the DB is rebuildable. local disk · nightly backup to object storage
Workspace + vault workspace/ git clones and per-PR worktrees; encrypted credential vault (API keys, Playwright session state) beside it. local disk · vault encrypted at rest
Network edges — everything else is someone else's server
GitHubHTTPS + git Content repo (markdown source of truth) and website repo. Issues, PRs, line comments, merges via REST/GraphQL; branch push/pull via git. PR poll every 120s.
AnthropicHTTPS The Claude Code CLI subprocess calls the Claude API (or reuses the host's logged-in session). No model runs locally.
Website hostingHTTPS Vercel / Cloudflare builds the site on merge — Relay never deploys it directly, it just polls the production URL until the post is live (canonical established).
Dev.to · HashnodeHTTPS API Direct REST / GraphQL publish calls with API keys from the vault. Verify by re-fetching the returned URL.
Medium · LinkedInbrowser Headless Chromium sessions (Playwright storage_state from the vault) drive the real web editors over HTTPS from the Relay host.
SubstackSMTP + browser Draft created by emailing the publication's secret post address via an SMTP relay (Resend/SES); Playwright then formats and publishes.
Slackwebhook Outbound-only incoming-webhook POSTs: publish digests, failure alerts, session-expired warnings.
Scale path: this single-host shape (the Sentinel model) is right until multiple channels publish daily. The seams are pre-cut for the next step: swap SQLite → Postgres, move adapter workers to a separate process consuming the queue (Redis), and keep the API/scheduler node stateless.
Tech stack
Backend core
  • Python 3.11+asyncio throughout
  • FastAPI + uvicornAPI, SPA serving, webhook receivers
  • httpxall outbound REST/GraphQL calls
  • APSchedulerchannel crons + PR polling
  • aiosqliteSQLite in WAL mode, Postgres-ready schema
  • python-frontmatter + markdown-it-pyparse canonical posts, render per-platform HTML
Agent layer
  • Claude Code CLIsubprocess per task: --print --output-format stream-json
  • Tool whitelistsdiscovery: WebSearch/WebFetch · writer: Read/Write/Edit · reviewer: +Grep/Bash
  • stream_parserported — turns stream-json into run logs + structured results
  • Timeouts900s write · 1200s review, ported budget model
Publishing & automation
  • Playwrightheadless Chromium; per-platform storage_state sessions
  • Dev.to RESTPOST /api/articles, markdown body, canonical_url
  • Hashnode GraphQLpublishPost mutation, originalArticleURL
  • aiosmtplib + Resend/SESSubstack email-to-draft
  • tenacityexponential backoff on publish/verify attempts
Frontend · storage · ops
  • React 18 + Vitedashboard SPA, built and served by FastAPI
  • SQLite → Postgresstate/telemetry; content truth stays in GitHub
  • cryptography (Fernet)credential vault encryption at rest
  • Docker / docker-composeone-container deploy; run.sh for bare metal
  • Slack webhooksdigests + operational alerts
Key technical contracts
Canonical post frontmattercontent/{channel}/{slug}/index.md
# everything a platform adapter needs, in one place
title:    "Cutting GPU costs with spot marketplaces"
slug:     gpu-spot-marketplaces
channel:  infra-blog
tags:     [gpu, cloud-costs, infrastructure]
hero:     assets/hero.png
platforms:
  website:  { render: tsx }                  # goes first, sets canonical
  devto:    { canonical: true }
  hashnode: { canonical: true }
  medium:   { via: import }                 # import flow preserves canonical
  substack: { send: true, title: "..." }     # per-platform title override
  linkedin: { mode: share }                 # teaser + canonical link
Adapter interfacebackend/adapters/base.py
class Adapter(Protocol):
    name: str                            # "devto", "medium", ...
    transport: Literal["api", "email", "browser"]

    async def render(self, post: Post) -> Payload: ...      # canonical md → platform format
    async def publish(self, payload: Payload) -> PublishedRef: ...
    async def verify(self, ref: PublishedRef) -> bool: ...   # re-fetch, confirm live
    async def healthcheck(self) -> SessionHealth: ...        # creds/session still valid?
Publish queue schemadata/relay.db · plus channels, posts, review_logs, agent_runs
CREATE TABLE publish_jobs (
  id            INTEGER PRIMARY KEY,
  post_id       INTEGER REFERENCES posts(id),
  platform      TEXT,                -- website | devto | hashnode | medium | substack | linkedin
  status        TEXT DEFAULT 'queued', -- queued → rendering → publishing → verifying → published | failed
  attempts      INTEGER DEFAULT 0,   -- tenacity backoff, max 5
  published_url TEXT,                -- filled by verify()
  error         TEXT,
  updated_at    TEXT
);
-- invariant: syndication jobs stay 'queued' until the website job is 'published'
Port map — every Sentinel component, dispositioned
Sentinel componentFate in Relay
claude_service + stream_parserUnchanged — CLI spawning with per-agent tool whitelists stays the execution coreported
reviewer.py loop machineryWorktrees, line comments, iteration gate, batch logic — kept; reviews markdownported
github_serviceKept; pointed at the content repo, plus merge-event hooks for the queueported
OG banner generationKept; grows into the media pipeline (per-platform image sizes, uploads)ported
scheduler / cron.pyKept; schedules per channel instead of one global crongeneralized
discovery.pySame engine, niche-parameterized with per-platform angle planninggeneralized
deployment poller + Slack serviceBecome adapter verify() + multi-platform digestgeneralized
SQLite schemaExtended: channels, publish_jobs, per-platform URLs on postsgeneralized
writer.py TSX outputReplaced by the canonical-markdown writer; TSX becomes one renderernew
pricing_serviceReplaced by the pluggable channel-validator interfacenew
— (no equivalent)Adapter framework, publish queue, credential vault, session keepernew
Shared foundation
Publish queuenew
publish_jobs: post × platform, states queued → rendering → publishing → verifying → published / failed, attempt counts, exponential retry.
Credential vaultnew
API keys (Dev.to, Hashnode), Playwright storage-state sessions (Medium, Substack, LinkedIn), git tokens. Encrypted at rest; Slack alert when a session expires and needs re-login.
Dashboard + APIgeneralized
FastAPI + React shell ported; adds channels view, post × platform matrix, job retry buttons, session-health indicators.
relay · system design proposal post lifecycle: discovered → drafted → in_review → ready → canonical_live → syndicating → published