maquina_stream
Server-rendered streaming markdown for Rails, over Turbo, with a repair path.
A model writes markdown a token at a time. maquina_stream renders it to HTML
on the server, broadcasts small patches as it grows, and reconciles the browser
with the truth when frames go missing — which they do, because Action Cable
offers no delivery guarantee.
Only rendered HTML reaches the browser. The client never parses markdown.
Install
gem "maquina_stream"
Rails 8, Ruby 3.3+. Depends on maquina_remend, commonmarker, nokogiri and
rouge. maquina_components is optional — without it, components render plain
Tailwind fallbacks. Turbo is required: repair applies Turbo Stream morphs, and
with window.Turbo undefined every repair fails silently.
bin/rails generate maquina_stream:install
bin/rails generate maquina_stream:streamable Message
bin/rails db:migrate
The first wires the engine into the app — the initializer, the mount, the
importmap pins, the Stimulus registration, the stylesheets. The second is per
model, because a host may have several: the migration carrying the contract
columns, and the include plus macro in the model. Both are idempotent and
neither overwrites a file; a host missing an ingredient is told what to paste
rather than left with a silent no-op.
Two seams the generators will not guess
authorize, in config/initializers/maquina_stream.rb. It is generated as
a stub that denies everything, which is the safe failure and a broken feature
both — the browser can never repair a message until you replace it. Guessing a
host's authorization is how an engine leaks other people's messages:
c. = ->(record, request) { record.conversation.member?(request.session[:user_id]) }
stream_for:, in the model. Who may subscribe to a stream is your question,
not the engine's:
class Message < ApplicationRecord
include MaquinaStream::Streamable
maquina_stream buffer: :content,
stream_for: ->(record) { [:conversation, record.conversation_id, :messages] }
end
Streaming a message
Feed the broadcaster and seal exactly once:
broadcaster = MaquinaStream::Broadcaster.new()
chat.ask(prompt) { |chunk| broadcaster.append(chunk.content.to_s) }
broadcaster.seal!
Render it, live or from history. The engine appends block HTML into
#ms-msg-<sid> and never creates that element — the wrapper is yours:
<div id="ms-msg-<%= message.maquina_stream_id %>"
data-controller="ms-repair ms-reveal"
data-ms-repair-manifest-url-value="<%= maquina_stream.manifest_path(sid: message.maquina_stream_id) %>"
data-ms-repair-blocks-url-value="<%= maquina_stream.blocks_path(sid: message.maquina_stream_id) %>"
<%= "data-ms-streaming" if message.maquina_stream_open? %>><%= MaquinaStream.render(message) %></div>
That is the whole integration. Getting started walks the same path with a working end-to-end run, and lists every step the generators take for hosts that would rather do them by hand.
What you get
Markdown in:
## Status
All **good**. See [docs](https://example.com).
```ruby
puts 1
```
HTML out — sanitized, block-addressed, and highlighted by CSS class so a theme switch needs no re-render (attributes elided for width):
<h2 id="ms-42-b0" data-ms-element="h2" data-ms-block data-ms-block-digest="fa59d158d6fc403e">Status…</h2>
<p id="ms-42-b1" data-ms-element="p" data-ms-block data-ms-block-digest="c39b9098233d53d5">All <strong>good</strong>. See <a href="https://example.com" rel="noopener noreferrer">docs</a>.</p>
<div id="ms-42-b2" data-ms-code data-ms-code-lang="ruby" data-controller="ms-code" data-ms-block …>
<pre><code><span class="nb">puts</span> <span class="mi">1</span></code></pre>
<pre hidden data-ms-code-source>puts 1</pre>
</div>
Plus: copy and download on code blocks and tables, a link-safety dialog, a word-level reveal animation, autoscroll that gets out of the reader's way, client-rendered diagrams and math, and markdown export.
Agents
This engine is built for agent output, and a tool call is its own Streamable
record rather than a block inside the assistant's message — so each step of a
run seals, repairs and exports on its own, and two steps can be open at once,
which is what a parallel tool call is.
Nexo is the agent harness it is built
alongside: Agent#prompt reports tool activity through a block, and each
:tool_call opens a record. Message.stream_agent_run in
test/dummy/app/models/message.rb is the whole bridge, and /harness/agent
runs it against a real model. See streaming.md.
Nothing here depends on Nexo. Bare ruby_llm, or any client that hands you text
as it arrives, works the same way — /harness/chat is that version.
Documentation
| Document | What it covers |
|---|---|
| getting-started.md | Install, the model, the migration, the view, the JavaScript, one message end to end |
| configuration.md | Every option, its default, and how to choose |
| streaming.md | The Streamable contract, the broadcaster, sealing, agent runs, history |
| repair.md | Why repair exists, the two routes, find_stream and authorize |
| registries.md | Fences, custom tags, element overrides |
| javascript.md | The Stimulus controllers, importmap setup, controls, events |
| security.md | The sanitizer's guarantees and limits, URL hardening |
| deferred-renderers.md | Diagrams, math, writing your own |
test/dummy is a working host: the Streamable model, both seams, all three
fence strategies, the tag registry, and live pages at /harness/chat and
/harness/agent that talk to a real model.
What it deliberately does not do
- Deltas do not converge on their own. Only the open tail is patched; a block that changes after it stops being the tail is fixed by the repair path.
- Sealed blocks are never re-broadcast. That is what keeps bandwidth tracking drift instead of message length.
- An open fence is never highlighted, and a client-deferred fence emits no payload until it closes. Expect no syntax colours and no diagram until the closing ``` arrives.
maquina
Part of maquina — open source for Ruby and Ruby AI.
License
MIT, © Mario Alberto Chávez. See LICENSE.txt.