Zyberis — AI-Operated Media Platform
Production media platform built around an autonomous AI agent (Hermes) that operates most editorial workflows through a token-authenticated API with no database credentials exposed. Next.js 16 App Router, MDX pipeline with dual-theme syntax highlighting, split-pane admin editor, tiered backup/restore, full SEO stack, and hardened VPS deployment.
View Live→The Problem
Running a media platform requires a lot of repetitive editorial work: drafting posts, enriching content with metadata, uploading images, managing the publish lifecycle. The premise of Zyberis was to automate as much of that as possible — not with a queue of scripts triggered by a human, but with an autonomous AI agent (Hermes) that owns the editorial workflow end-to-end.
The core design constraint: Hermes should be able to operate the platform without ever being given database credentials or direct data access. If a compromised agent token causes a problem, the blast radius is limited to what the API surface permits — it cannot drop collections, query arbitrary data, or bypass business logic. Every operation goes through the same API that a human admin would use, with the same validation and authorization checks.
Agent Architecture
Content lives in MongoDB — but it didn't start that way. The original architecture used a static export with a separate content repo: Hermes would write MDX files, push commits, and a CI pipeline would rebuild and deploy. That worked until the publish requirement became instant. A rebuild cycle between "Hermes writes the post" and "the post is live" was too slow for the editorial workflow Hermes needed to run. The platform migrated to a dynamic Next.js server where Hermes writes to MongoDB through the API and the post is live immediately — no rebuild per post.
The agent never gets database credentials or direct data access. Every operation goes through the same API that a human admin uses, with the same validation and authorization checks. If a compromised agent token is the threat model, the blast radius is limited to what the API surface permits.
Hermes operates the platform through five CLI wrappers:
- zyberis-publish — creates or updates a post from an MDX file in the content repo
- zyberis-go-live — transitions a post from draft to published
- zyberis-upload-image — uploads an image to the media library and returns a URL for use in content
- zyberis-query — retrieves post metadata, view counts, or analytics without direct database access
- zyberis-backdate — sets a publication date on a post (used when publishing previously-written content with accurate timestamps)
Each CLI wrapper makes authenticated HTTP requests to the platform API using a long-lived token stored in Hermes's environment. The token authenticates as an agent role with permissions scoped to content operations. It cannot delete posts from other authors, cannot access subscriber data, and cannot trigger backups.
The AGENTS.md and HERMES.md files in the content repo document the full operating procedure: what commands to run in which order, what the four-stage content pipeline looks like, how to handle image uploads, what to do when a build validation fails. The system is fully operable by any agent that can read those files and make HTTP requests.
A self-describing manifest endpoint (GET /api/hermes/manifest) exposes the full error-code registry and frontmatter schema at runtime. Hermes can query it before any operation to verify its understanding of the API surface without reading source files. This matters when the platform is updated — the manifest reflects the current state of the API, not a snapshot in documentation that might be stale.
Beyond the five CLI wrappers, Hermes has access to additional endpoints: a link injection API that inserts backlinks into existing published articles (used to reduce orphan articles without requiring a full republish), an orphan detection endpoint that returns articles with no inbound internal links, multi-signal related-article scoring (tag overlap + Fuse.js text similarity + category bonus — Hermes uses this to recommend related articles when enriching drafts), and an on-demand backup endpoint scoped to the agent token so Hermes can trigger a backup before making bulk changes.
Content Pipeline
The four-stage pipeline enforces a discipline on content state:
- Written — the post exists as an MDX file in the content repo; not yet submitted to the platform
- Enriched — metadata has been added (title, description, tags, reading time, Open Graph fields); the post passes build-time validation
- Edited — the post has been reviewed and marked ready; it exists in the database as a draft
- Published — the post is live and indexed
Moving backward through stages is intentional — a post can be pulled back to draft without going through the full pipeline again. Moving forward requires the previous stage to be complete. The pipeline prevents a post from going live with missing SEO fields or a broken internal link.
Content Validation
A content validation script runs before any post enters the pipeline. It checks:
- Frontmatter schema — required fields are present, types match, no unexpected keys
- Unique slugs — no two posts share a slug
- Broken internal links — Markdown links to other posts resolve to existing slugs
- SEO field lengths — title and description fall within the character ranges that search engines render without truncation
Hermes runs the script before submitting a post. A validation failure surfaces immediately with the specific field and reason, so Hermes fixes it before the post reaches the database. This eliminates a class of problems that would otherwise only appear after publication: a description that renders as "..." in search results, or a broken cross-link that creates a 404 in a live article.
MDX Pipeline
Content is rendered via next-mdx-remote. The pipeline adds:
- Syntax highlighting via
rehype-pretty-codewith Shiki as the tokenizer — dual light/dark themes that switch with the page theme without a page reload - Heading anchors — every heading gets an
idand a copyable anchor link - Reading time — calculated from word count and injected into the post metadata
- GFM tables — GitHub-Flavored Markdown table support for structured data in posts
- Custom Callout components —
<Callout type="note">,<Callout type="warning">, and<Callout type="tip">render styled aside boxes without raw HTML in the MDX - Copy-to-clipboard code blocks — a copy button appears on hover over fenced code blocks
- Image lightbox — clicking any article image opens a full-screen overlay (Framer Motion, body scroll-lock) so readers can examine detail without leaving the article
Admin Panel
The admin panel is a split-pane interface: the left pane is a live MDX editor loaded by immutable MongoDB _id (so category or slug renames never break the URL), the right pane renders the post. The editor debounces its auto-save — changes write to the server after 800ms of inactivity. This keeps the rendered preview reasonably fresh without hammering the API on every keystroke.
The media center handles image uploads with UUID filename generation, MIME-type validation, collision-safe renaming, and organized storage by upload folder. Images are served through the same Nginx instance as the application, which means they benefit from the same caching headers and SSL termination without a separate CDN or object storage bucket. GIF uploads are auto-converted to MP4 server-side via ffmpeg — the resulting video file is smaller, plays without controls, and loops silently. The AnimatedMedia MDX component renders converted files as silent autoplay <video> elements so they behave like animated GIFs in articles.
The media audit & cleanup tool cross-references every article image reference against disk. It flags two problems independently: missing files (the article references an image that no longer exists on disk) and orphaned uploads (the file exists on disk but no article references it). Missing files produce broken images in published articles; orphaned uploads accumulate silently and consume disk. The tool shows both categories with folder grouping and supports bulk-delete for orphaned files, with per-file confirmation to avoid accidental data loss.
The redirect manager maintains a MongoDB-backed table of 301 and 302 redirects with panel CRUD. On the proxy, redirects are resolved from a 60-second in-memory TTL cache — the database is only hit when the cache expires or a redirect is added. This means slug changes and retired URLs can be managed without a deploy, and the performance cost is amortized across the cache interval.
The link audit panel surfaces the health of internal and external links across all published articles. It normalizes absolute-to-relative internal links (links that point to the platform's own domain as an absolute URL are converted to relative paths — consistent format, no broken links if the domain changes), HEAD-checks external links for 4xx/5xx responses, and detects broken internal links by checking each link target against a Set of known article slugs. The audit runs nightly and caches results in a MongoDB link_audit_cache collection. An auto-fix mode applies the normalization changes in bulk; broken links and dead external links are surfaced for manual review rather than auto-fixed, since the correct resolution varies by case.
The analytics dashboard is described in the Analytics section below.
The backup/restore system produces two archives per run: a mongodump database dump and a separate tar of the media directory. Three retention tiers apply to both: daily (last 3 kept), weekly (last 1 kept), monthly (last 1 kept). Manual backups are never auto-deleted. Scheduling is via node-cron registered in Next.js instrumentation.ts, so no external crontab or separate process is required. A .backup.lock file prevents concurrent backup runs.
Google Drive offsite sync uploads each backup after it completes. The local copy stays unencrypted for fast local restore; the Drive copy is encrypted with AES-256-GCM using a BACKUP_ENCRYPTION_KEY environment variable — if the Drive account is compromised, the backup archives are unreadable without the key. A two-way sync panel in the admin shows each backup's status (local only, Drive only, or synced in both), with per-row controls for push, pull, and Drive-delete independently of the local copy.
Backup and restore progress stream as NDJSON — each log line arrives as a newline-delimited JSON object so the admin panel can render a live log without buffering the full response. A restore version gate checks the backup's application version metadata against the running version and returns 409 if they differ, preventing a restore that would silently downgrade schema or API behavior.
Analytics
The analytics system is cookieless by design. No consent banner, no third-party scripts, no persistent identifiers stored on the reader's device.
Session identification uses a daily-rotating SHA-256 fingerprint derived from: IP address, User-Agent string, Accept-Language header, and the current UTC date. The date rotation means a fingerprint from yesterday cannot be linked to one from today — sessions exist within a day, not across days. No fingerprint is stored on the reader's device. The fingerprint is hashed server-side; the raw inputs never appear in the database.
Engagement tracking uses navigator.sendBeacon fired on tab hide and page unload, sending the session's maximum scroll depth and total dwell time. The server writes these as a $max upsert into a per-session record in a 90-day TTL collection — a reader who returns to the same article in the same day accumulates only one record, with scroll depth and dwell ratcheting upward. Sessions are scoped to article + fingerprint; a reader who visits two articles creates two records.
The live SSE endpoint pushes a realtime snapshot of the last 5 minutes of activity to the admin panel every 5 seconds. This gives the operator immediate visibility into what's being read right now without polling, at the cost of one open connection per admin session.
Nightly rollup aggregates the raw 90-day TTL collection into persistent events_daily_* collections. This is the mechanism that makes historical data survive the 90-day raw-event expiry: the rollup writes the aggregated totals to a permanent store before the TTL prunes them. A rollup_meta collection tracks the last processed day boundary. If the nightly job fails or the server is offline, the next run reads rollup_meta to find the gap and backfills it — a self-healing pattern that doesn't require manual intervention after a missed run.
Country tracking reads the CF-IPCountry header injected by Cloudflare. No external geolocation API, no database lookup — Cloudflare already knows where the request originated and puts it in a header for free.
Additional signals: internal search analytics record every query with a result count, and queries that return zero results are flagged separately as a content gap signal. 404 tracking records which paths were requested and returned a 404, which surfaces broken external links and missing redirects. Outbound link click tracking uses a thin click handler to record which external URLs readers click, without a redirect through a tracking URL.
Period comparison lets the operator select any date range and see metrics compared to the prior equal-length window — if you select the last 7 days, you see the previous 7 days alongside it. A Persian Jalali calendar date picker handles date selection, since the primary editorial audience works in the Iranian calendar system.
Bot suppression uses isbot v5 (241+ community-maintained patterns) as a secondary gate after Cloudflare's bot score. Cloudflare rejects known bots at the edge before the request reaches the server; isbot catches anything that slips through with a User-Agent that matches a known bot pattern. The combination means the analytics numbers reflect human readers with high confidence.
Internal Link Graph
The internal link graph records every inbound and outbound internal link for every published article. A nightly job computes per-article counts from the link audit results and stores them in a link_graph_cache MongoDB collection. The admin panel exposes inbound and outbound counts per article, and an orphan filter pill lets the editor see articles with zero inbound links — content that exists on the site but cannot be discovered through normal reading.
Hermes uses the link injection API to add backlinks to orphan articles from contextually relevant published posts. The API takes a target article slug and a list of source slugs, finds the most appropriate paragraph in each source article, and inserts a link without requiring a full republish or a content-pipeline pass. This lets Hermes reduce the orphan count autonomously as part of its content enrichment workflow.
Go-Live Actions
When zyberis-go-live transitions an article from draft to published, two additional actions fire automatically. An IndexNow ping goes to both Bing and Yandex with the article URL — this signals the search engines to crawl the new content immediately rather than waiting for the next scheduled crawl. An optional Telegram photo-card announcement posts to the channel: a rich Persian caption with the article title, a short excerpt, and the live URL. The message ID is tracked in MongoDB per article, so the panel can delete or resend the announcement if needed without searching the channel history.
SEO
The full SEO stack includes:
- JSON-LD structured data —
BlogPostingfor articles,FAQPageandHowTofor relevant post types,BreadcrumbListfor navigation context, andOrganizationschema for the platform itself - Canonical URLs — set on every page, including paginated list views
- XML sitemap — ISR-cached at a 1-hour interval, auto-updating without a rebuild when new posts are published; includes image extension entries for Google Images indexing so article images appear in image search results
- Open Graph — per-post title, description, and image for social sharing
- hreflang — links between the Persian-primary and any localized versions of a post
The Fuse.js full-text search indexes post titles, descriptions, and tags client-side — no server round-trip on search. The index is embedded in the page bundle, which keeps search instant at the cost of bundle size. For the content volume of this platform, that tradeoff is correct. Persian and Arabic letter normalization (ي→ی, ك→ک) is applied to both the index and the query before matching, so a search typed with Egyptian Arabic keyboard variants finds Persian content correctly.
An RSS feed is generated at build time from the same content data used to render the post list. Subscribers who prefer RSS over browser visits get the same content without any additional infrastructure.
Contact form submissions go through a Cloudflare Workers microservice — the form POSTs to a Worker endpoint that forwards the message to a Telegram Bot, which delivers a notification to a private channel. No email server, no third-party form service, no database table for submissions. The Worker keeps the Telegram Bot token off the main server entirely; the channel doubles as a notification log.
Deployment
The platform runs on Ubuntu VPS with PM2, Nginx as reverse proxy, and Cloudflare Full Strict SSL (Origin Certificates). UFW restricts inbound traffic to the ports Nginx and SSH need. Fail2ban runs with jails for SSH and Nginx bad-bot patterns. Persian-first RTL layout uses Vazir Matn as a variable font, self-hosted, with Latin fallback for English content. Dark and light themes are toggled without page reload via a root-level dark class and CSS custom properties.
What I'd Change
The nightly rollup job runs on node-cron via instrumentation.ts, which means it only runs while the Next.js server is up. A server restart between midnight and the job window silently skips that night's rollup — the self-healing rollup_meta boundary catches it on the next run, but there's a gap. The correct architecture is an external scheduler (a system cron, a Cloudflare Worker cron trigger, or a queue) that HTTP-POSTs to an authenticated endpoint on the server. That way the rollup runs even if the server restarted at 11:59 PM.
The content validation script runs before Hermes submits a post, which means Hermes gets the error early — but only if it actually runs the script. The right answer is to wire the validator as a required step in each CLI wrapper rather than a separate script Hermes must remember to call. That would make validation unavoidable rather than advisable.
The link audit HEAD-checks external links nightly from the VPS. This is accurate but slow on articles with many external links, and it ties up a Node.js event loop slot per HEAD request. The correct implementation uses a background queue (BullMQ, a Cloudflare Queue, or similar) so HEAD checks run as non-blocking jobs with rate limiting and retries, rather than serially in the nightly job.