{"slug": "turn-x-posts-into-beautiful-visual-stories-with-ai", "title": "Turn X Posts into Beautiful Visual Stories with AI", "summary": "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.", "body_md": "**Beautify your x.com posts with LLM.** Paste a tweet link, optionally say how it should look, and get\nback a typeset image of the tweet sized for X — plus an interactive version with \"See more\".\n\nThis repository is `iloveposts`: a single Cloudflare Worker that serves the landing page and the API,\nand runs the whole pipeline on Cloudflare — Durable Objects, a Sandbox container and Browser\nRendering, with the designer on Workers AI.\n\nTo run your own copy, see **[docs/self-hosting.md](https://github.com/mkantwala/iloveposts/blob/main/docs/self-hosting.md)**.\n\n## iloveposts-demo-embed.mp4\n\nFor one tweet URL, a finished card is:\n\n- **a PNG** at the exact size of the chosen X format, ready to post\n- **a self-contained HTML file** — fonts, avatar and media inlined, no network access — with a working**See more / See less** for long tweets\n- **a preview page** that scales the card to fit any window\n\nThe card shows the tweet as a post — the author, \"Replying to @…\" for a reply, the text, its media, a\n**quoted tweet** with its own author, text and picture, then the date and counts — inside a visual\nworld designed for that tweet: a concept, artwork, palette and typography derived from what it says\nand shows. Every word, name, number and date comes from the tweet itself. The model decides how it\nlooks, never what it says.\n\nEvery card is one self-contained HTML document. Tick **Motion & 3D** on the page (or send\n`\"motion\": true`) and the designer also decides what moves and how —\nan entrance, depth and tilt, an ambient background, counts that count up — within one rule: motion\nreinforces the hierarchy, and nothing moves for its own sake.\n\nStatic cards can use a script too, drawn once on load — Canvas 2D artwork, generated SVG. Native\nHTML, CSS, Canvas 2D, SVG and JavaScript come first. Two vendored libraries are there when a\ndesign genuinely needs them — **Three.js** for WebGL 3D scenes and **GSAP** for timelines — built into\nthe 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\nthe preview. Viewers who prefer reduced motion get the card still.\n\n| `format` | Size | Ratio | For | \n|---|---|---|---|\n| `x_post`*(default)* | 1600 × 900 | 16:9 | an image attached to a post | \n| `x_square` | 1200 × 1200 | 1:1 | a square post image | \n| `x_card` | 1200 × 628 | 1.91:1 | a link-preview card ( `summary_large_image` ) | \n\nX shows post images at roughly a third of their size in the timeline, so type is set large: the tweet\nbody is at least 40px on `x_post`, which reads at about the size of a normal tweet on a phone.\n\n```\nPOST /v1/cards ─► CardJob (one Durable Object per card, one alarm per stage)\n\n  extract   one GET to the FxTwitter API: the tweet as structured JSON — author, verification,\n            text, date, media, counts, reply and quote — then measured: length, title line, excerpt\n  build     the pipeline writes the tweet as semantic HTML from data — every word, count, image\n            and attribute — and the designer (a Workers AI model, AGENT_MODEL) designs it in ONE\n            request: brief, tweet, page and the tweet's pictures in; the complete index.html out —\n            its <style>, its [data-artwork] layer and its <script>. Content lock then keeps only\n            that design and rebuilds the tweet's content from data again\n  validate  the card is rendered in Chromium and measured: canvas size, clipping, type sizes,\n            exact text, avatar/badge/media present, See more working, …\n  repair    at most one more request, carrying only the current page and the measured\n            violations, with \"fix only these\";\n            if it makes things worse, the previous version is kept. If the best version still\n            has high-severity problems, the built-in house design is tried in its place\n  export    HTML and PNG stored in KV, the container destroyed\n```\n\nEach 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.\n\nThat 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.\n\n**One request, not an agent loop.** A card is a 10–20 KB file. The opencode coding agent got there in\na dozen or more tool turns, each resending the whole growing conversation — about 500k tokens a card.\nThe direct designer is handed everything at once, through the Worker's `AI` binding, and answers with\nthe file: one request of roughly 20–30k tokens a card (most of it the model's reasoning), and one more\nfor a repair. opencode remains as the fallback when a design request fails, if its token is set (and\nas the engine with `DESIGN_ENGINE=agent`), on a hard budget of 12 model calls per build and 6 per\nrepair, enforced at egress. Every model call's tokens\nappear in the live log and in `usage`.\n\n**The designer only designs.** The page has two ownership zones: `[data-artwork]`, which is the\ndesigner's — any SVG, shapes, canvas or decorative markup — and `[data-card]`, the tweet, which is\nlocked. Only the artwork, the `<style>` and the `<script>` survive; the tweet is rebuilt from data, so\nthe model cannot misquote it, drop a label, invent a count or break the markup the checks rely on.\n\n**A broken card is never shipped.** When a model cannot lay a card out — text off the canvas, clipped\nor invisible — the pipeline tries its house design: a restrained, known-good design for the same\nmarkup that passes every check in every format, static and Motion & 3D. The better of the two is\nexported, and `quality.houseDesign` says which it was.\n\nThe goal is a card that looks designed for *this* tweet. The brief (`src/markdown/agents.ts`)\nconstrains quality, not visual language:\n\n| Fixed | Free | \n|---|---|\n| 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 | \n\n- **Concept first.** Before writing, the designer decides privately what the tweet is about, what\nvisual metaphor belongs to it, what should be noticed first, and what makes it unlike a generic card.\n- **No defaults.** Black backgrounds with dark cards, generic or purple/blue \"AI\" gradients, neon,\nglassmorphism and a centred floating rectangle as the whole idea are named and ruled out — dark is\nfine when the concept calls for it, not as the default.\n- **Signals.** The tweet's facts — the figures it quotes, technical vocabulary, a question, a list, a\nquote — are counted (`src/card/analysis.ts` ) and handed over as raw material for a concept.\n- **No repeats.** After each export the measured design is described in words (\"near-white orange\nbackground; white card, shadowed; Space Grotesk; canvas artwork\") and kept in KV\n(`src/card/recent.ts` ). The next designer sees the last six and must not repeat their palette,\nbackground treatment, composition or motif.\n\nTypefaces 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.\n\nThe validate stage is deterministic — no model looks at the result. It renders the card in Browser Rendering and checks, with measured values:\n\n| Area | Checks | \n|---|---|\n| Canvas | exact export size, page never scrolls, card inside the canvas with clear margins, card width within the format's range, vertical balance | \n| 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 | \n| Restraint | no randomly emphasised words in the tweet text, no stacked colour + highlight + underline, at most two body colours | \n| 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 | \n| 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 | \n| 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 | \n| 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 | \n\nEvery issue carries its measurement (\"the card is 18px from the canvas edge\"), and the repair pass gets a specific fix for each rule.\n\nAll responses are JSON. Errors are `{ \"error\": { \"code\": \"…\", \"message\": \"…\" } }`.\n\n```\nPOST /v1/cards\nContent-Type: application/json\nIdempotency-Key: 3f2a9c1e-optional-key\n\n{\n  \"url\": \"https://x.com/karpathy/status/2039805659525644595\",\n  \"format\": \"x_post\",\n  \"instructions\": \"calm and editorial\",\n  \"motion\": false\n}\n```\n\n| Field | Required | Values | \n|---|---|---|\n| `url` | yes | an `https://x.com/{handle}/status/{id}` (or`twitter.com` ) link | \n| `format` | no | `x_post` (default),`x_square` ,`x_card` | \n| `instructions` | no | free text, up to 500 characters, e.g. \"dark, minimal\" — the agent follows it | \n| `motion` | no | `true` for a Motion & 3D card;`false` (default) for a static one | \n\nReturns **`202 Accepted`** with the job and a `Location: /v1/cards/{id}` header. Sending the same\n`Idempotency-Key` again within 24 hours returns the existing job instead of starting another.\n\n```\nGET /v1/cards/{id}\n```\n\nPoll until `status` is `completed` or `failed`. A card takes roughly 2–6 minutes.\n\n```\n{\n  \"id\": \"card_mdpacshmdl49uvsvnhqf\",\n  \"status\": \"completed\",            // queued | processing | completed | failed\n  \"stage\": \"export\",                // extract | build | validate | repair | export\n  \"format\": { \"id\": \"x_post\", \"label\": \"X post image\", \"width\": 1600, \"height\": 900, \"ratio\": \"16:9\" },\n  \"input\": { \"url\": \"…\", \"format\": \"x_post\", \"instructions\": \"calm and editorial\", \"motion\": false },\n  \"tweet\": { \"url\": \"…\", \"author\": { \"name\": \"Andrej Karpathy\", \"handle\": \"@karpathy\", … }, \"text\": \"…\", \"stats\": { … }, \"replyingTo\": [], \"quote\": null },\n  \"motion\": false,\n  \"quality\": { \"passed\": true, \"repairs\": 0, \"houseDesign\": false, \"issues\": [], \"metrics\": { … } },\n  \"timings\": { \"extract\": 2, \"build\": 173, \"validate\": 13, \"export\": 0 },\n  \"usage\": { \"requests\": 21, \"promptTokens\": 330847, \"completionTokens\": 3844, \"totalTokens\": 334691, \"byModel\": { … } },\n  \"assets\": {\n    \"preview\": \"https://…/v1/cards/card_…/preview\",\n    \"png\": \"https://…/v1/cards/card_…/png\",\n    \"html\": \"https://…/v1/cards/card_…/html\"\n  },\n  \"error\": null,\n  \"retentionSeconds\": 2592000\n}\n```\n\n`assets` is `null` until the card has been rendered. `quality.passed` is `false` when measured issues\nremained after the repair pass; the card is still exported, and the issues are listed.\n\n| Endpoint | Returns | \n|---|---|\n| `GET /v1/cards/{id}/logs?after=N` | the live build log after line `N` —`{ status, stage, lines: [{ n, at, kind, text }], next }` , where`kind` is`agent` (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) or`pipeline` (stage lines, and one line per model call with its tokens). Poll it with the last`next` | \n| `GET /v1/cards/{id}/preview` | an HTML page showing the card scaled to the window, in a sandboxed frame | \n| `GET /v1/cards/{id}/html` | the card as one self-contained HTML file (strict CSP: no network, scripts sandboxed) | \n| `GET /v1/cards/{id}/png` | the PNG; add `?download` to get it as an attachment | \n\nCards and their records are kept for 30 days.\n\n| Endpoint | Returns | \n|---|---|\n| `GET /` | the landing page | \n| `GET /health` | `200 { \"ok\": true, \"problems\": [] }` , or`503` listing what the deployment is missing (names only, never values) | \n| `GET /tweet?url=…` | the tweet on its own, from FxTwitter: author, text, date, media, counts (including bookmarks and quotes), reply and quote info | \n\n| HTTP | `code` | Meaning | \n|---|---|---|\n| 400 | `invalid_body` ,`invalid_url` ,`invalid_format` ,`invalid_instructions` ,`invalid_motion` ,`invalid_idempotency_key` | the request was malformed | \n| 404 | `not_found` | no card with that id | \n| 404 | `not_ready` | the card exists but has not been rendered yet | \n| 429 | `rate_limited` | a per-caller limit was hit — see `Retry-After` | \n| 429 | `capacity_reached` | the deployment's daily card limit was hit | \n| 503 | `service_misconfigured` | the deployment is missing a secret, binding or valid model — see `/health` | \n\nA job that fails reports it in `error`, with one of: `tweet_not_found`, `engine_unavailable` (missing configuration), `workspace_lost`, or\n`{stage}_failed` after that stage's retries.\n\n| Limit | Default | Enforced by | \n|---|---|---|\n| Cards per caller per hour | 5 | `Quota` Durable Object (exact) | \n| Cards per caller per day | 20 | `Quota` Durable Object (exact) | \n| Cards per day, whole deployment | 200 | `Quota` Durable Object (exact) — the cost ceiling | \n| `/v1/cards` requests per caller | 60 / minute | Rate Limiting binding | \n| `/tweet` requests per caller | 10 / minute | Rate Limiting binding | \n| Cards building at once | 2 | container `max_instances` | \n\nA caller is a hashed client IP. The API has no authentication; these limits are what bound its cost.\n\nAt Cloudflare's published prices:\n\n|  | Per card | \n|---|---|\n| 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 on`glm-5.3-flash` ) | \n| — the opencode agent loop instead ( `DESIGN_ENGINE=agent` , ~250–600k tokens) | roughly 10–20× that | \n| Container ( `standard-1` , a few minutes) | ~$0.005 | \n| Browser Rendering (~30–60s) | ~$0.001 | \n| Durable Objects, KV, requests | < $0.001 | \n\nPlus the $5/month Workers Paid plan, which Containers require; it includes 10 browser-hours a month and\nsome container time. The Free plan's 10,000 neurons a day cover a handful of cards on a cheap model.\nEvery job reports its exact token usage in `usage`, and every model call is a line in its live log.\n\n- **No model credential enters the container.** The direct designer uses the Worker's`AI` binding — no\ntoken at all. The opencode agent is given a placeholder API key; the real Workers AI token is\nattached at egress by the Sandbox's outbound handler, which runs in the Workers runtime.\n- **Card HTML is treated as untrusted.** It is served with a CSP that blocks all network access and\nsandboxes scripts, and the preview frames it with`sandbox=\"allow-scripts\"` , so model-written HTML\ncannot act on this origin.\n- **The tweet is rendered as data.** Text is inserted with`textContent` , never as markup, and the full\ntext for See more is written by the pipeline, not retyped by a model.\n- **Inputs are validated** at the API boundary: URL shape, enums, lengths, idempotency-key format.\n\n```\nsrc/\n  index.ts            routes: page, /health, /tweet, /v1/cards; exports the Durable Objects\n  health.ts           runtime configuration checks for /health and card creation\n  site/\n    page.ts           the landing page: form, live studio, result\n    style.ts          its look\n    script.ts         its behaviour: polling, the live terminal, resume on reload\n  scope.ts            hashed caller id for limits and idempotency\n  api/\n    cards.ts          the /v1/cards API\n    quota.ts          Quota Durable Object: per-caller and global creation limits\n    sandbox.ts        Sandbox subclass: egress credential injection and token-usage recording\n    assets.ts         downloads the real avatar, badge and media for the card\n    toggle.ts         generates the See more / See less script\n  card/\n    job.ts            CardJob Durable Object: the stage machine\n    log.ts            the job's live log: the designer's stream, its thinking, the stage and token lines\n    designer.ts       the direct designer: one streamed model request in, the whole index.html out\n    recent.ts         recent designs, described from the render, so the next card avoids them\n    review.ts         scoring a version, and the review a repair is given\n    formats.ts        the X formats — the only place card dimensions live\n    analysis.ts       deterministic content analysis\n    engines.ts        model configuration, and the opencode agent (the fallback engine)\n    base.ts           base.css: the technical floor — canvas size, See more, picture defaults\n    fonts.ts          the embedded type shelf, cached in KV\n    workspace.ts      lays out the sandbox, runs the agent, applies content lock, reads the output\n    scaffold.ts       writes the card's HTML from data, and rebuilds it around the agent's design\n    house.ts          the fallback design, used when the agent's best version is still broken\n    libraries.ts      the libraries a Motion & 3D card may use (Three.js, GSAP) and their versions\n    bundle.ts         folds the output into one self-contained HTML file\n    measure.ts        the in-browser measurement script\n    qa.ts             renders, measures and screenshots in Browser Rendering\n    viewer.ts         the preview page\n  markdown/\n    agents.ts         the design contract (fixed vs free, concept, defaults to avoid) and both engines' output rules\n    tweet.ts          the tweet as Markdown, and the excerpt cut\n  twitter/\n    fxtwitter.ts      fetches a tweet from the FxTwitter API and maps it onto a Tweet\n    tweet.ts          the Tweet record, tweet-URL parsing, the verification badge\nscripts/\n  preflight.mjs       deploy checks: machine, config, code, account, models, KV, secrets\n  check-page.mjs      checks the landing page's script parses\n  check-generated.mjs checks the generated scripts (toggle, viewer, measurement) parse\nDockerfile            the build sandbox image: opencode, plus Three.js and GSAP builds in /opt/card-libs\nwrangler.jsonc        bindings, models, limits, migrations\nnpm install\nnpm run dev          # wrangler dev on http://localhost:8787 (needs Docker running)\nnpm run typecheck    # TypeScript + generated-script checks\nnpm run preflight    # everything a deploy needs — see below\nnpm run deploy       # runs preflight first, then wrangler deploy\n```\n\n`npm run preflight` (and automatically, `npm run deploy`) checks, and refuses to deploy on failure:\n\n| Area | Checks | \n|---|---|\n| Machine | Node 22+, project Wrangler installed, Docker running, sandbox image tag matches the SDK | \n| Configuration | `wrangler.jsonc` parses; model ids, quotas and rate limiters valid; every Durable Object bound and migrated; container and KV configured | \n| Code | typecheck and generated-script checks | \n| 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 | \n\nIt also prints the estimated model cost per card from the catalog's current prices. Use\n`npm run preflight -- --local` to skip the account checks.\n\nAt runtime the same configuration is checked on every request that needs it: `/health` returns 503\nwith the problems, and `POST /v1/cards` refuses with `service_misconfigured` rather than starting a\njob that would fail.\n\nSeveral scripts are generated inside TypeScript template literals (the page, the toggle, the viewer,\nthe measurement script), where a `\\n` meant for the output is easy to break. `npm run typecheck`\nparses each of them; run it before every deploy.\n\n- **Tweets come from the FxTwitter API** (`api.fxtwitter.com` ), a public third-party service. If it is\ndown, extraction retries and then fails with`extract_failed` .\n- **Card quality depends on `AGENT_MODEL`.** Models differ a lot in design quality, speed and plan:\none Workers AI request is cut off after about five minutes, so a slow reasoning model may never\nanswer, and some models need Workers Paid. It is a one-line change in`wrangler.jsonc` or`.dev.vars` — see docs/self-hosting.md, step 7.\n- **Non-Latin scripts** fall back to the browser's own fonts; only Latin subsets are embedded.\n- **Video and GIF media** appear as their poster frame.", "url": "https://wpnews.pro/news/turn-x-posts-into-beautiful-visual-stories-with-ai", "canonical_source": "https://github.com/mkantwala/iloveposts", "published_at": "2026-10-07 02:28:03+00:00", "updated_at": "2026-10-07 02:49:30.938042+00:00", "lang": "en", "topics": ["ai-products", "ai-tools", "generative-ai", "developer-tools"], "entities": ["iloveposts", "Cloudflare", "Cloudflare Workers", "Workers AI", "Durable Objects", "Browser Rendering", "FxTwitter API", "mkantwala"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/turn-x-posts-into-beautiful-visual-stories-with-ai", "markdown": "https://wpnews.pro/news/turn-x-posts-into-beautiful-visual-stories-with-ai.md", "text": "https://wpnews.pro/news/turn-x-posts-into-beautiful-visual-stories-with-ai.txt", "jsonld": "https://wpnews.pro/news/turn-x-posts-into-beautiful-visual-stories-with-ai.jsonld"}}