cd /news/ai-products/turn-x-posts-into-beautiful-visual-s… Β· home β€Ί topics β€Ί ai-products β€Ί article
[ARTICLE Β· art-146496] src=github.com β†— pub= topic=ai-products verified=true sentiment=↑ positive

Turn X Posts into Beautiful Visual Stories with AI

Developer mkantwala released iloveposts, an open-source Cloudflare Worker that turns an X post URL into a typeset PNG and a self-contained HTML card, with the design generated by a Workers AI model. The pipeline runs on Cloudflare Durable Objects, a Sandbox container and Browser Rendering, extracting tweet data via the FxTwitter API and validating each rendered card in Chromium before export. Cards come in three formats β€” x_post at 1600 Γ— 900, x_square at 1200 Γ— 1200 and x_card at 1200 Γ— 628 β€” with the tweet body set at a minimum of 40px on x_post and motion settling within 2.5 seconds.

read16 min views1 publishedOct 7, 2026
Turn X Posts into Beautiful Visual Stories with AI
Image: Michielbdejong (auto-discovered)

Beautify your x.com posts with LLM. Paste a tweet link, optionally say how it should look, and get back a typeset image of the tweet sized for X β€” plus an interactive version with "See more".

This repository is iloveposts: a single Cloudflare Worker that serves the landing page and the API, and runs the whole pipeline on Cloudflare β€” Durable Objects, a Sandbox container and Browser Rendering, with the designer on Workers AI.

To run your own copy, see docs/self-hosting.md.

iloveposts-demo-embed.mp4 #

For one tweet URL, a finished card is:

  • a PNG at the exact size of the chosen X format, ready to post
  • a self-contained HTML file β€” fonts, avatar and media inlined, no network access β€” with a workingSee more / See less for long tweets
  • a preview page that scales the card to fit any window

The card shows the tweet as a post β€” the author, "Replying to @…" for a reply, the text, its media, a quoted tweet with its own author, text and picture, then the date and counts β€” inside a visual world designed for that tweet: a concept, artwork, palette and typography derived from what it says and shows. Every word, name, number and date comes from the tweet itself. The model decides how it looks, never what it says.

Every card is one self-contained HTML document. Tick Motion & 3D on the page (or send "motion": true) and the designer also decides what moves and how β€” an entrance, depth and tilt, an ambient background, counts that count up β€” within one rule: motion reinforces the hierarchy, and nothing moves for its own sake.

Static cards can use a script too, drawn once on load β€” Canvas 2D artwork, generated SVG. Native HTML, CSS, Canvas 2D, SVG and JavaScript come first. Two vendored libraries are there when a design genuinely needs them β€” Three.js for WebGL 3D scenes and GSAP for timelines β€” built into the sandbox image and inlined into the card, so nothing is ever fetched. Motion settles within 2.5 seconds; the PNG is the settled frame, and the interactive version is the HTML and the preview. Viewers who prefer reduced motion get the card still.

format Size Ratio For
x_post(default) 1600 Γ— 900 16:9 an image attached to a post
x_square 1200 Γ— 1200 1:1 a square post image
x_card 1200 Γ— 628 1.91:1 a link-preview card ( summary_large_image )

X shows post images at roughly a third of their size in the timeline, so type is set large: the tweet body is at least 40px on x_post, which reads at about the size of a normal tweet on a phone.

POST /v1/cards ─► CardJob (one Durable Object per card, one alarm per stage)

  extract   one GET to the FxTwitter API: the tweet as structured JSON β€” author, verification,
            text, date, media, counts, reply and quote β€” then measured: length, title line, excerpt
  build     the pipeline writes the tweet as semantic HTML from data β€” every word, count, image
            and attribute β€” and the designer (a Workers AI model, AGENT_MODEL) designs it in ONE
            request: brief, tweet, page and the tweet's pictures in; the complete index.html out β€”
            its <style>, its [data-artwork] layer and its <script>. Content lock then keeps only
            that design and rebuilds the tweet's content from data again
  validate  the card is rendered in Chromium and measured: canvas size, clipping, type sizes,
            exact text, avatar/badge/media present, See more working, …
  repair    at most one more request, carrying only the current page and the measured
            violations, with "fix only these";
            if it makes things worse, the previous version is kept. If the best version still
            has high-severity problems, the built-in house design is tried in its place
  export    HTML and PNG stored in KV, the container destroyed

Each stage persists its result before the next begins, so a crash, deploy or eviction resumes where it left off, and a stage is the unit of retry.

That is the whole pipeline: tweet β†’ metadata β†’ one design request β†’ one self-contained HTML file β†’ Chromium β†’ PNG. There is no separate art-direction step and no menu of themes or layouts to choose from; the designer reads the tweet, looks at its pictures, and decides the look itself.

One request, not an agent loop. A card is a 10–20 KB file. The opencode coding agent got there in a dozen or more tool turns, each resending the whole growing conversation β€” about 500k tokens a card. The direct designer is handed everything at once, through the Worker's AI binding, and answers with the file: one request of roughly 20–30k tokens a card (most of it the model's reasoning), and one more for a repair. opencode remains as the fallback when a design request fails, if its token is set (and as the engine with DESIGN_ENGINE=agent), on a hard budget of 12 model calls per build and 6 per repair, enforced at egress. Every model call's tokens appear in the live log and in usage.

The designer only designs. The page has two ownership zones: [data-artwork], which is the designer's β€” any SVG, shapes, canvas or decorative markup β€” and [data-card], the tweet, which is locked. Only the artwork, the <style> and the <script> survive; the tweet is rebuilt from data, so the model cannot misquote it, drop a label, invent a count or break the markup the checks rely on.

A broken card is never shipped. When a model cannot lay a card out β€” text off the canvas, clipped or invisible β€” the pipeline tries its house design: a restrained, known-good design for the same markup that passes every check in every format, static and Motion & 3D. The better of the two is exported, and quality.houseDesign says which it was.

The goal is a card that looks designed for this tweet. The brief (src/markdown/agents.ts) constrains quality, not visual language:

Fixed Free
the tweet's content and structure, its hierarchy (author β†’ tweet β†’ media/quote β†’ date β†’ engagement), minimum type sizes and contrast, the safe area, the canvas size, artwork never covering the text the concept, palette, background treatment, the card's material (or no visible card), composition, illustration, patterns, texture, light and depth, typographic decoration, diagrams, Canvas and SVG artwork, motion
  • Concept first. Before writing, the designer decides privately what the tweet is about, what visual metaphor belongs to it, what should be noticed first, and what makes it unlike a generic card.
  • No defaults. Black backgrounds with dark cards, generic or purple/blue "AI" gradients, neon, glassmorphism and a centred floating rectangle as the whole idea are named and ruled out β€” dark is fine when the concept calls for it, not as the default.
  • Signals. The tweet's facts β€” the figures it quotes, technical vocabulary, a question, a list, a quote β€” are counted (src/card/analysis.ts ) and handed over as raw material for a concept.
  • No repeats. After each export the measured design is described in words ("near-white orange background; white card, shadowed; Space Grotesk; canvas artwork") and kept in KV (src/card/recent.ts ). The next designer sees the last six and must not repeat their palette, background treatment, composition or motif.

Typefaces come from an embedded shelf of eight open-licence families (sans, serif and mono); the designer links the ones it chooses and only those are inlined.

The validate stage is deterministic β€” no model looks at the result. It renders the card in Browser Rendering and checks, with measured values:

Area Checks
Canvas exact export size, page never scrolls, card inside the canvas with clear margins, card width within the format's range, vertical balance
Text excerpt word-for-word, title line set as its own heading, body and minimum text sizes, line length and leading, at most two font families
Restraint no randomly emphasised words in the tweet text, no stacked colour + highlight + underline, at most two body colours
Artwork never on top of the tweet's text (hit-tested at each text element); its own text and images kept out of the content checks; no script or style of its own
Content display name, handle, badge, "Replying to" and the quoted tweet fully visible, every media item, all four counts with their exact labels, metadata quieter than the tweet
Behaviour See more expands to the full text and back, no JavaScript errors, no scripts beyond the pipeline's own (and, in motion mode, one of the card's), no remote or missing resources
Motion measured after the card settles (3s, then every finite animation is finished); every piece of text fully visible; a prefers-reduced-motion fallback present

Every issue carries its measurement ("the card is 18px from the canvas edge"), and the repair pass gets a specific fix for each rule.

All responses are JSON. Errors are { "error": { "code": "…", "message": "…" } }.

POST /v1/cards
Content-Type: application/json
Idempotency-Key: 3f2a9c1e-optional-key

{
  "url": "https://x.com/karpathy/status/2039805659525644595",
  "format": "x_post",
  "instructions": "calm and editorial",
  "motion": false
}
Field Required Values
url yes an https://x.com/{handle}/status/{id} (ortwitter.com ) link
format no x_post (default),x_square ,x_card
instructions no free text, up to 500 characters, e.g. "dark, minimal" β€” the agent follows it
motion no true for a Motion & 3D card;false (default) for a static one

Returns 202 Accepted with the job and a Location: /v1/cards/{id} header. Sending the same Idempotency-Key again within 24 hours returns the existing job instead of starting another.

GET /v1/cards/{id}

Poll until status is completed or failed. A card takes roughly 2–6 minutes.

{
  "id": "card_mdpacshmdl49uvsvnhqf",
  "status": "completed",            // queued | processing | completed | failed
  "stage": "export",                // extract | build | validate | repair | export
  "format": { "id": "x_post", "label": "X post image", "width": 1600, "height": 900, "ratio": "16:9" },
  "input": { "url": "…", "format": "x_post", "instructions": "calm and editorial", "motion": false },
  "tweet": { "url": "…", "author": { "name": "Andrej Karpathy", "handle": "@karpathy", … }, "text": "…", "stats": { … }, "replyingTo": [], "quote": null },
  "motion": false,
  "quality": { "passed": true, "repairs": 0, "houseDesign": false, "issues": [], "metrics": { … } },
  "timings": { "extract": 2, "build": 173, "validate": 13, "export": 0 },
  "usage": { "requests": 21, "promptTokens": 330847, "completionTokens": 3844, "totalTokens": 334691, "byModel": { … } },
  "assets": {
    "preview": "https://…/v1/cards/card_…/preview",
    "png": "https://…/v1/cards/card_…/png",
    "html": "https://…/v1/cards/card_…/html"
  },
  "error": null,
  "retentionSeconds": 2592000
}

assets is null until the card has been rendered. quality.passed is false when measured issues remained after the repair pass; the card is still exported, and the issues are listed.

Endpoint Returns
GET /v1/cards/{id}/logs?after=N the live build log after line N β€”{ status, stage, lines: [{ n, at, kind, text }], next } , wherekind isagent (the designer's streamed file, or opencode's raw shell output: tool calls, commands and what they print),thought (the model's thinking, as it streams) orpipeline (stage lines, and one line per model call with its tokens). Poll it with the lastnext
GET /v1/cards/{id}/preview an HTML page showing the card scaled to the window, in a sandboxed frame
GET /v1/cards/{id}/html the card as one self-contained HTML file (strict CSP: no network, scripts sandboxed)
GET /v1/cards/{id}/png the PNG; add ?download to get it as an attachment

Cards and their records are kept for 30 days.

Endpoint Returns
GET / the landing page
GET /health 200 { "ok": true, "problems": [] } , or503 listing what the deployment is missing (names only, never values)
GET /tweet?url=… the tweet on its own, from FxTwitter: author, text, date, media, counts (including bookmarks and quotes), reply and quote info
HTTP code Meaning
400 invalid_body ,invalid_url ,invalid_format ,invalid_instructions ,invalid_motion ,invalid_idempotency_key the request was malformed
404 not_found no card with that id
404 not_ready the card exists but has not been rendered yet
429 rate_limited a per-caller limit was hit β€” see Retry-After
429 capacity_reached the deployment's daily card limit was hit
503 service_misconfigured the deployment is missing a secret, binding or valid model β€” see /health

A job that fails reports it in error, with one of: tweet_not_found, engine_unavailable (missing configuration), workspace_lost, or {stage}_failed after that stage's retries.

Limit Default Enforced by
Cards per caller per hour 5 Quota Durable Object (exact)
Cards per caller per day 20 Quota Durable Object (exact)
Cards per day, whole deployment 200 Quota Durable Object (exact) β€” the cost ceiling
/v1/cards requests per caller 60 / minute Rate Limiting binding
/tweet requests per caller 10 / minute Rate Limiting binding
Cards building at once 2 container max_instances

A caller is a hashed client IP. The API has no authentication; these limits are what bound its cost.

At Cloudflare's published prices:

Per card
Model β€” the direct designer, one request of ~20–30k tokens (and one more for a repair) billed in Workers AI neurons at AGENT_MODEL 's price;npm run preflight prints the estimate (β‰ˆ$0.01–0.02 onglm-5.3-flash )
β€” the opencode agent loop instead ( DESIGN_ENGINE=agent , ~250–600k tokens) roughly 10–20Γ— that
Container ( standard-1 , a few minutes) ~$0.005
Browser Rendering (~30–60s) ~$0.001
Durable Objects, KV, requests < $0.001

Plus the $5/month Workers Paid plan, which Containers require; it includes 10 browser-hours a month and some container time. The Free plan's 10,000 neurons a day cover a handful of cards on a cheap model. Every job reports its exact token usage in usage, and every model call is a line in its live log.

  • No model credential enters the container. The direct designer uses the Worker'sAI binding β€” no token at all. The opencode agent is given a placeholder API key; the real Workers AI token is attached at egress by the Sandbox's outbound handler, which runs in the Workers runtime.
  • Card HTML is treated as untrusted. It is served with a CSP that blocks all network access and sandboxes scripts, and the preview frames it withsandbox="allow-scripts" , so model-written HTML cannot act on this origin.
  • The tweet is rendered as data. Text is inserted withtextContent , never as markup, and the full text for See more is written by the pipeline, not retyped by a model.
  • Inputs are validated at the API boundary: URL shape, enums, lengths, idempotency-key format.
src/
  index.ts            routes: page, /health, /tweet, /v1/cards; exports the Durable Objects
  health.ts           runtime configuration checks for /health and card creation
  site/
    page.ts           the landing page: form, live studio, result
    style.ts          its look
    script.ts         its behaviour: polling, the live terminal, resume on reload
  scope.ts            hashed caller id for limits and idempotency
  api/
    cards.ts          the /v1/cards API
    quota.ts          Quota Durable Object: per-caller and global creation limits
    sandbox.ts        Sandbox subclass: egress credential injection and token-usage recording
    assets.ts         downloads the real avatar, badge and media for the card
    toggle.ts         generates the See more / See less script
  card/
    job.ts            CardJob Durable Object: the stage machine
    log.ts            the job's live log: the designer's stream, its thinking, the stage and token lines
    designer.ts       the direct designer: one streamed model request in, the whole index.html out
    recent.ts         recent designs, described from the render, so the next card avoids them
    review.ts         scoring a version, and the review a repair is given
    formats.ts        the X formats β€” the only place card dimensions live
    analysis.ts       deterministic content analysis
    engines.ts        model configuration, and the opencode agent (the fallback engine)
    base.ts           base.css: the technical floor β€” canvas size, See more, picture defaults
    fonts.ts          the embedded type shelf, cached in KV
    workspace.ts      lays out the sandbox, runs the agent, applies content lock, reads the output
    scaffold.ts       writes the card's HTML from data, and rebuilds it around the agent's design
    house.ts          the fallback design, used when the agent's best version is still broken
    libraries.ts      the libraries a Motion & 3D card may use (Three.js, GSAP) and their versions
    bundle.ts         folds the output into one self-contained HTML file
    measure.ts        the in-browser measurement script
    qa.ts             renders, measures and screenshots in Browser Rendering
    viewer.ts         the preview page
  markdown/
    agents.ts         the design contract (fixed vs free, concept, defaults to avoid) and both engines' output rules
    tweet.ts          the tweet as Markdown, and the excerpt cut
  twitter/
    fxtwitter.ts      fetches a tweet from the FxTwitter API and maps it onto a Tweet
    tweet.ts          the Tweet record, tweet-URL parsing, the verification badge
scripts/
  preflight.mjs       deploy checks: machine, config, code, account, models, KV, secrets
  check-page.mjs      checks the landing page's script parses
  check-generated.mjs checks the generated scripts (toggle, viewer, measurement) parse
Dockerfile            the build sandbox image: opencode, plus Three.js and GSAP builds in /opt/card-libs
wrangler.jsonc        bindings, models, limits, migrations
npm install
npm run dev          # wrangler dev on http://localhost:8787 (needs Docker running)
npm run typecheck    # TypeScript + generated-script checks
npm run preflight    # everything a deploy needs β€” see below
npm run deploy       # runs preflight first, then wrangler deploy

npm run preflight (and automatically, npm run deploy) checks, and refuses to deploy on failure:

Area Checks
Machine Node 22+, project Wrangler installed, Docker running, sandbox image tag matches the SDK
Configuration wrangler.jsonc parses; model ids, quotas and rate limiters valid; every Durable Object bound and migrated; container and KV configured
Code typecheck and generated-script checks
Account logged in with the scopes a deploy needs; Containers available (Workers Paid); AGENT_MODEL exists in the Workers AI catalog (and whether it needs Workers Paid, has vision and tool calling); KV namespace exists or will be created; the deployed Worker has its secrets; the.dev.vars token can use Workers AI

It also prints the estimated model cost per card from the catalog's current prices. Use npm run preflight -- --local to skip the account checks.

At runtime the same configuration is checked on every request that needs it: /health returns 503 with the problems, and POST /v1/cards refuses with service_misconfigured rather than starting a job that would fail.

Several scripts are generated inside TypeScript template literals (the page, the toggle, the viewer, the measurement script), where a \n meant for the output is easy to break. npm run typecheck parses each of them; run it before every deploy.

  • Tweets come from the FxTwitter API (api.fxtwitter.com ), a public third-party service. If it is down, extraction retries and then fails withextract_failed .
  • Card quality depends on AGENT_MODEL. Models differ a lot in design quality, speed and plan: one Workers AI request is cut off after about five minutes, so a slow reasoning model may never answer, and some models need Workers Paid. It is a one-line change inwrangler.jsonc or.dev.vars β€” see docs/self-hosting.md, step 7.
  • Non-Latin scripts fall back to the browser's own fonts; only Latin subsets are embedded.
  • Video and GIF media appear as their poster frame.
── more in #ai-products 4 stories Β· sorted by recency
── more on @iloveposts 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/turn-x-posts-into-be…] indexed:0 read:16min 2026-10-07 Β· β€”