m0serve Realtime from a synchronous Python app

What the agent runs asked for — 2026-10-07

The follow-ups from the agent usability runs: the lines each page lacked, three changes to m0, and the two skills. Each stumble the runs counted became a line on a page, a change to the tool, or a line in a skill. SPEC N27 and N52; decision D66.

Lines on the pages

The Python quickstart's serve commands pass --host 127.0.0.1, here and on the page after it, and the page says the default is every interface. It says the binary resolves libpython from PATH's python3, so a start script activates the virtualenv. It says what publish() does with a line break and what its return value means, and that a form a page posts meets Django's CSRF check. The home page says, above its first fold, that llms.txt indexes every page and that each page is Markdown at its URL with .md in place of the trailing slash: four of five Python runs fetched the HTML page first, through a fetch that summarises and drops code blocks.

On the Mojo side, the overview has a table of the API pieces and the page each is on, links llms.txt, and no longer says "no form library" of a framework with form(req). The views page gives reply.problem its four arguments, the other reply helpers beside it. The host and views pages say where a send from a view reaches. The scaffold's AGENTS.md names the host and views pages by URL before it names the installed source, which is where all five runs went first.

Three changes to m0

  • m0 new . writes the empty current directory and takes its name. Every Mojo run wrote ./NAME and then moved the files up a level; one then saw m0 doctor report the moved files.
  • The printed next step binds 127.0.0.1, as do both quickstarts.
  • --template board is a list every tab shares, whose sender is a view. Every run chose live and deleted about half of it, because its producer is a timer and the task's sender was a request.

The board, and two facts it rests on

POST /messages appends to a list held in the state and publishes the whole board through the state's DatastarStream: no producer, no database. Two facts about Datastar 1.0.4 shaped the rest, each read off the bundle rather than remembered.

  • An action's answer is applied only when its status is 200. The fetch code returns after its retry decision for any other status, so a 422 fragment never reaches the page, unlike htmx 4, which swaps every 4xx. The board keeps an empty message in the browser with required, and answers a client that bypasses it with reply.problem.
  • The morph keeps a typed value unless the value attribute changed: it compares the old element's attribute with the new one's, not the property. Answering the form again would not have cleared it. The input is bound to $text, and the POST answers application/json {"text":""}, which Datastar reads as a signal patch.

The form is a fragment of its own outside the streamed board, so a frame replaces the board in every tab and leaves another visitor's draft alone.

Every view that touches the state is on_loop=True. Under --blocking-threads N the host builds a state for each pool thread, and a post answered there lands in a list and a stream nobody reads. The gate serves the board behind a pool for that reason, and the template's own smoke.sh serves it without one, so both shapes are on the wire. Each on_loop reverted alone fails the gate in its own way: the post reaches no stream, the stream is refused 409, or the document shows an empty board.

The same rule found a defect in live: its /events view was not on_loop, and its handler answered only stateless loop routes, so behind a pool every stream was refused 409. Nothing served live behind one. The gate now serves both streaming templates behind a pool.

ViewsApp forwards three stream hooks and not sse_peer_frame, so a views application cannot deliver another worker's frame. The board stays one process (max_workers() -> 1), and D66 records what would add the hook.

The gate

smoke-scaffold holds the board as it holds the others, and adds the current directory to every template's new phase. sabotage-scaffold gained ten rules for the board, two for live's stream and two for m0 new, each run once before landing.

The skills

skills/m0serve/SKILL.md and skills/m0/SKILL.md carry what the runs looked for and where they found it. They live in this repository, so a change to the product changes its skill in the same pull request. The repository is public, which makes it where a reader installs them from; the owner's private wtalcott marketplace carries them as a plugin whose upstream is this directory. The run harness gained --plugin-dir, which loads a plugin into every session of a run and records its version and a hash of its files.

The Python track with its skill

Five more runs of the Python brief, with the m0serve skill loaded and the site as it stood before this change:

track tool calls, median (range) wall clock cost
m0serve, no skill 13 (13–15) 88 s $0.37
m0serve, with the skill 8 (7–10) 74 s $0.27

All five met the contract, each invoked the skill first, and each fetched one page, the quickstart as Markdown. Every flagged result was this machine's: three runs still chose the port the desk server holds, and each read the bind failure in its log and moved. The skill did not change the first port choice.

The Mojo track after the release

Ten more runs of the Mojo brief, with m0 0.9.0 on PyPI and the site deployed from these pages, five of them with the m0 skill loaded:

track tool calls, median (range) wall clock cost
m0 0.8.0, the pages before this change 33 (24–34) 196 s $1.14
m0 0.9.0, these pages 14 (11–25) 100 s $0.46
m0 0.9.0, with the skill 8 (8–9) 65 s $0.29

All ten met the contract. Every run wrote --template board with m0 new ., and none read the framework's source, where every earlier run had.

One stumble was left, in all five runs without the skill. Each ran m0 --help, which did not list the templates, then a bare m0 new . before it knew one's name. Each got views and replaced it with board once m0 new --help had named it. In two, the session refused the rm -rf that emptied the directory, and those are the 15- and 25-call runs.

So the top-level help now lists the four templates, a line each, and an m0 new given no template says it took the default and lists the other three (SPEC N27). Both read one table in new.py, which m0 new --help reads too. The next m0 release carries it.