Generating blog articles from Elixir with LlmBlog

LlmBlog turns a prompt into a Markdown file on disk. It sends the prompt to a model, waits for the answer, converts the rendered response back into Markdown, applies the blog’s conventions, and writes the file.

{:ok, r} = LlmBlog.from_prompt("james", "Explain SQLite WAL mode in about 250 words")
r.url

That is the whole interface for the common case. The rest of this describes when the common case is the wrong one, and what the module does that a plain file write would not.

Do not block IEx

from_prompt/3 blocks until the answer arrives. A short answer takes twenty seconds; a long analytical one runs past three minutes, which is long enough that whatever called it may give up first. The browser carries on working regardless, so the answer exists and nobody is left holding it.

The asynchronous form avoids the problem entirely:

{:ok, id} = LlmBlog.from_prompt_async("james", "Explain SQLite WAL mode")
# returns in about a millisecond

LlmBlog.status(id)
# => %{status: "pending"}

# ... later ...
LlmBlog.status(id)
# => %{status: "ok", filename: "2026-08-02-14-42-sqlite-wal-mode.md",
#      url: "https://...", elapsed_ms: 20127, answer_chars: 2005}

Two details make this useful rather than merely non-blocking.

The database row is created before the function returns, not inside the background task. So the id is pollable immediately, and a run that is still working is distinguishable from one that never started. Creating the row in the task would leave the caller with an id that does not exist yet for the several minutes the run takes.

The work runs under a supervised Task.Supervisor, not a bare spawn and not Task.async. Task.async links, which means a crashed generation would take the IEx session down with it, and an exited IEx session would take a seven minute generation down with it. Neither is wanted. Work started from a shell that then closes still completes and still writes its file.

A Pushover notification fires when the article lands or the run fails. This is on by default, because the entire point of not blocking is that nobody is watching, and silence on completion would mean checking by hand.

The conventions it enforces

A model does not know the blog’s rules, and applying them at the boundary is more reliable than remembering to apply them afterwards.

Frontmatter marking the post public is prepended unconditionally. Without it the public route returns 404, so an article that renders perfectly well when you look at it locally is invisible to everyone else. This is the failure most worth automating away, because nothing about the file looks wrong.

Em and en dashes are rewritten. They render as mojibake on the site, and models emit them constantly.

Timestamped filenames in the form YYYY-MM-DD-HH-MM-slug.md, so the directory sorts chronologically.

A heading is added only when the answer lacks one. Two titles render as two titles.

A failed prompt writes nothing and returns an error. Filing a stub article and letting the site serve it would turn a failure into published content. The database row still records the attempt, so a run that produced no article is auditable rather than simply absent.

Where the title comes from

The title determines the filename, and it is taken from the first of these that exists:

  1. an explicit title: option
  2. the answer’s own first # heading
  3. the first line of prose in the answer
  4. the prompt

Case three consumes the line rather than copying it. Deriving a title from the opening sentence and then prepending that title as a heading printed the same sentence twice: once truncated as a heading, once in full as the first paragraph. Each half was reasonable alone. The composition was wrong.

Case four exists for answers with no usable prose at all, such as one that is entirely a code block. Detecting that requires tracking fenced-block state rather than pattern matching line starts, because the lines between fences do not look like markup and would otherwise be mistaken for prose.

The practical consequence: a model that writes a real heading gets a good filename, and one that does not still gets a reasonable one. Asking for a heading in the prompt improves the result but is not load bearing.

Structure survives because the DOM is converted, not the text

The model writes Markdown, the chat interface renders it to HTML, and the syntax appears to be gone. It is not. <h2> still says heading, <pre><code> still says code block, <ul><li> still says list.

Reading the rendered text instead of the HTML throws that away. Headings become ordinary lines, list markers vanish, code loses its fences and its language. The tempting repair is to ask the model to please emit Markdown, which is a prompt-level fix for an extraction-level problem, and one that fails silently whenever the model forgets.

Converting the HTML back to Markdown is mechanical and does not depend on the prompt at all. Headings, nested lists, fenced code with language detection, inline code, bold, italic, links, blockquotes and tables all round trip.

Images are dropped

Every <img> is removed from the answer before conversion.

This started as a narrower rule. An answer where the model ran a web search came back carrying ten favicon images, three thumbnail images and seven citation links wrapped around roughly two paragraphs of prose, and the resulting article’s title was an image URL. Filtering the favicon service and the model’s own image CDN removed thirteen of those and left a publisher’s header image as the first node, which promptly became the new title.

Blocklisting hosts does not converge. There is no finite list of sites a search result might cite.

The stronger reason is that none of those images are the model’s output. They are assets belonging to the sites it searched, and embedding them in a blog post means hotlinking someone else’s file from a page presented as original writing. Dropping them is correct regardless of how the markup happens to be shaped.

Citation links are kept. A link with text is a reference worth having; a link wrapping nothing but a favicon collapses to empty once its image is gone.

Filing an answer you already have

write/4 is the file-writing half without the model call:

{:ok, r} = LlmBlog.write(answer, original_prompt, query_id, title: "A Better Title")

Two situations call for it. The first is recovering a long generation whose caller timed out: collect the answer from the browser, then file it, with no second request and no waiting a second time. The second is overriding a title after seeing what came back, which is common, because a model’s first heading is often a section name rather than an article title.

What is recorded

Every run writes a row to a shared llm_queries table, whatever model produced it, and the row is created before the prompt is sent. A run that hangs still leaves a record. Write on completion and the failures, which are the cases most worth reading later, are exactly the ones that leave no trace.

On success the filename is stamped into the row, so which article came from which prompt stays answerable afterwards. Any URL in the prompt is pulled into its own column, which makes “what did each model write about this source” a query rather than a scan.

LlmQuery.recent(5)
LlmQuery.stats()                    # counts and timings by provider
LlmQuery.compare("the same prompt") # every model's answer, side by side

Swapping the model

The provider is an option:

LlmBlog.from_prompt("james", prompt, provider: SomeOtherModel)

Any module implementing three callbacks qualifies. Recording lives in the wrapper rather than in each provider, which makes a provider that forgets to record impossible and means the error branch cannot be quietly skipped under time pressure. A provider that raises still closes its row.

What it does not do

Nothing checks whether the article is any good. The frontmatter is correct, the dashes are sanitised, the structure is intact, and the argument may still be wrong or dull. Read it before you consider it published.

Nothing verifies that the model actually reached a URL you gave it. One recent answer opened by disclosing that the target site had blocked retrieval and that it had worked from indexed excerpts instead. That disclosure was the model’s own doing, not the pipeline’s, and a less scrupulous answer would have looked identical minus the caveat.

The pipeline gets the words onto disk in the right shape. Whether they are worth publishing is still a judgment call, and it is still yours.