StreamWeaver
Express intention, get interface. The joy of Ruby applied to UI.
app "Meeting Notes" do
header "1:1 with #{manager}"
md notes
end.run!That's it. No HTML. No CSS. No JavaScript. No webpack.
Platform support
| Tier | Platforms | What you get |
|---|---|---|
| Tested & supported | macOS + iTerm2 + Chrome + gh | The full get-started course: a controller canvas window, an agent worker tab, live demo panes, one-click sharing |
| Expected to work, untested | macOS plain terminal, Linux, WSL | The degraded path: canvas-read plus a browser-tab canvas, no panel/worker automation |
| Known degraded path | Any of the above | streamweaver get-started --degraded |
| Future | Windows native, other terminals/multiplexers | Not yet built |
A few things worth being explicit about:
-
streamweaver paneland the worker-tab automation it drives are macOS + iTerm2 only — they script iTerm2's Python API. - The premier
get-startedexperience also needs iTerm2 3.6.0 or newer — that's the release that added native browser panes, which is what the canvas window renders in, plus iTerm2's Browser Plugin (a one-time download — iTerm2 offers it in Settings, or grab it from https://iterm2.com/browser-plugin.html and drop it in /Applications). No saved profile setup is required: StreamWeaver turns any pane into a browser pane itself (a profile-customization override), so there's nothing to create or rename in Settings → Profiles. An older iTerm2 without browser-pane support still runsget-started; the canvas window just falls back to a plain browser tab. -
canvas-readis a plain local web server. It's the most portable piece of StreamWeaver and doesn't need iTerm2, Chrome, orgh— see docs/canvas-read.md. - The Chrome extension and the
ghCLI are enhancers for sharing docs (Gist links that render nicely), not requirements for reading them locally. - Never assume a port. Every command that starts a server prints the URL it actually bound — StreamWeaver auto-increments past busy ports.
Why StreamWeaver?
TL;DR: I want a quick UI. What do I need? Some text, a few inputs, a button. Why isn't that the interface? Instead: HTML, CSS, JavaScript, backend wiring... Streamlit showed me the interface can just be "text, inputs, button." StreamWeaver brings that to Ruby - and it turns out this minimal approach is perfect for AI agents too.
Skip to Quick Start →
The Interface Should Be What You Need
Think about what a simple UI actually requires: some text, a few inputs, maybe a dropdown, a button. That's it. That's what you're trying to build. But to get there you're dealing with HTML structure, CSS styling, JavaScript (or a backend framework), controllers, state management...
Streamlit's brilliance was recognizing that the DSL can just be the interface. You describe what you need - text, inputs, button - and you're done. StreamWeaver brings that philosophy to Ruby.
Why This Matters for AI Agents
When you're building with Claude Code (or other AI coding assistants), this "what matters" approach pays off even more:
-
Token efficiency - The LLM generates a concise DSL instead of verbose HTML/React. 5-10x fewer tokens means faster responses and lower costs.
-
Rich interactions - Instead of walls of terminal text for complex decisions, spin up an actual UI. What would be 5 pages of back-and-forth becomes one well-designed form.
-
Persistent output - Claude generates content (meeting notes, analysis, reports) but terminal output scrolls away. Canvas mode gives Claude a persistent display that stays visible.
-
Data-only generation - Pre-build your StreamWeaver app once, then have the LLM just generate the data to feed it. Minimal tokens, maximum speed.
# Agent generates just this data:
meetings = [{ title: "1:1 with Sarah", notes: "..." }, ...]
# Pre-built app renders it:
MeetingNotesApp.new(meetings: meetings).run!vs. "I can show you a mockup, but it's token-intensive"
Ask Claude for a visual and you'll often get something like this:
Some of what we're working on might be easier to explain if I can show it to you in a web browser. I can put together mockups, layout comparisons, and other visuals as we go. This feature is still new and can be token-intensive. Want to try it? (Requires opening a local URL)
That's an honest warning, not a bug - hand-written HTML/React artifacts are token-intensive, so tools built that way have to gate visual explanation behind an expensive, novel, opt-in mode. TypeScript-flavored tooling doesn't have Ruby's sensibilities about generating a lot of interface from a little code.
StreamWeaver's answer: mockups, layout comparisons, and diagrams aren't a special mode you opt into - they're the default, at 5-10x fewer tokens than hand-written markup (see below), and they're two-way interactive while a static artifact isn't. Same local-browser mechanism, none of the token tax.
The Modes
StreamWeaver evolved through real needs, resulting in four modes:
| Mode | Command | What It Does | Use Case |
|---|---|---|---|
| Standalone | ruby app.rb |
Auto-port, auto-browser, persistent server | Quick apps, utilities, prototypes |
| Agentic | app.run_once! |
Popup UI → collect input → return JSON → quit | Claude Code needs structured input |
| Canvas | streamweaver live SESSION |
Persistent display that Claude updates | Output that doesn't scroll away |
| Service | streamweaver app.rb |
Single server, multiple apps | Development, showcase, tutorial |
ruby app.rb vs streamweaver app.rb
| Command | Process Model | When to Use |
|---|---|---|
ruby app.rb |
You own the process, runs until you kill it | Quick one-off scripts, standalone apps |
streamweaver app.rb |
Managed by background service, multi-app routing | Multiple apps side-by-side, development |
The service mode runs one Sinatra server for all apps instead of one process per app.
From Local Script to Mobile Dashboard
StreamWeaver apps cover a wide range — the same DSL works whether you're hacking a quick one-off or running a persistent dashboard you check from your phone:
| Scenario | Host | Port | How |
|---|---|---|---|
| Quick one-off | localhost | auto-detect |
ruby app.rb — browser opens, use it, Ctrl+C |
| Agentic popup | localhost | auto-detect |
app.run_once! — collect input, return JSON, exit |
| Puma-dev | localhost | from Puma-dev |
config.ru + puma-dev link — access at http://myapp.test
|
| Mobile/Tailscale | 0.0.0.0 |
fixed | STREAMWEAVER_HOST=0.0.0.0 STREAMWEAVER_PORT=4580 ruby app.rb |
| LAN access | 0.0.0.0 |
fixed | Same — any device on your network can reach it |
| Always-on dashboard | 0.0.0.0 |
fixed | Bookmark http://your-machine:4580 on your phone |
Environment variables (overridden by code options if set):
-
STREAMWEAVER_HOST— bind address (default:127.0.0.1) -
STREAMWEAVER_PORT— fixed port (default: auto-detect from 4567) -
PORT— standard port variable (used by Puma-dev, Heroku, etc.)
For quick local work, the defaults are perfect — auto-find a port, open the browser, done. For mobile or remote access (Tailscale, LAN), set a fixed host and port so your URL stays stable across restarts. For Puma-dev, see examples/puma_dev.
Quick Start
Three steps to awesome:
gem install stream_weaver
streamweaver install
streamweaver get-startedinstall wires StreamWeaver into Claude Code (permissions + skills); get-started walks you
through a short interactive course, next to your own terminal. On macOS + iTerm2 you get the full
experience; anywhere else it falls back to a browser tab automatically (or jump straight there with
streamweaver get-started --degraded). See Platform support above.
The course tracks everything it creates -- the docs it saves, the .org files exported from them,
the gists published, the demo canvas sessions it opens -- and takes it all back on request:
streamweaver university-cleanup # confirm group by group; gists one at a time
streamweaver university-cleanup --scan # also adopt artifacts from a run before the manifestPrefer to explore on your own first?
# Interactive tutorial
streamweaver tutorial
# Browse examples
streamweaver showcase
# Run any example
ruby examples/basic/hello_world.rbStandalone Mode
The simplest path - one Ruby file, one command:
# todo.rb
require 'stream_weaver'
app "Todo List" do
text_field :new_todo, placeholder: "What needs doing?"
button "Add" do |state|
state[:todos] ||= []
state[:todos] << state[:new_todo]
state[:new_todo] = ""
end
state[:todos]&.each do |todo|
div { text "• #{todo}" }
end
end.run!ruby todo.rb
# Browser opens at http://localhost:4567Puma-dev Mode
Run StreamWeaver apps with Puma-dev for memorable local URLs like http://myapp.test that are always available without manually starting the server:
# config.ru
require 'bundler/setup'
require 'stream_weaver'
App = app "My App" do
header1 "Hello from Puma-dev!"
text_field :name, placeholder: "Your name"
end
run App# Link to Puma-dev
puma-dev link
# Access at http://[directory-name].test
# Browser won't auto-open - perfect for on-demand accessKey differences from standalone mode:
- Uses the
PORTenvironment variable set by Puma-dev - Browser doesn't auto-open (you access the URL when you need it)
- App starts automatically on first request
See examples/puma_dev for a complete example.
Agentic Mode
When Claude Code needs structured input, not terminal menus:
result = app "Project Setup" do
header "Configure New Project"
text_field :name, placeholder: "Project name"
select :database, ["PostgreSQL", "SQLite", "MySQL"]
checkbox :docker, "Include Docker setup"
end.run_once!
# Browser opens, user fills form, returns:
# { "name" => "myapp", "database" => "PostgreSQL", "docker" => true }The browser opens, user fills the form, JSON returns to the calling script. Perfect for AI workflows that need human input.
Canvas Mode
A persistent browser display that Claude Code can update. Content stays visible instead of scrolling away in the terminal.
# Start the canvas (keeps running)
streamweaver live mynotes
# Claude Code pushes content as it works
streamweaver push mynotes --dsl 'md "# Analysis Results\n\n## Key Findings\n- ..."'
# Canvas updates in real-timePro tip: iTerm2 has a built-in browser that fits into split panes - run Claude Code on the left, canvas on the right, same window.
Saving & Sharing Docs
A canvas session's "Save as doc" button writes it to disk in two possible formats:
-
.rb— the DSL source, canonical. Always lossless. -
.org— a human-readable, roundtrippable export (streamweaver org-export <file.rb>/org-render <file.org>from the CLI, or the Save-as-Org button in the UI).:doc-vocabulary content (doc_header,callout,card,table, etc.) round-trips cleanly and reads like a real document in GitHub's own file view or any generic org-mode viewer, with no StreamWeaver tooling required. Content outside that vocabulary still round-trips (a verbatim-recovered raw block), just without the readability payoff. Saving as org shows a coverage notice when a doc isn't a good fit for the format — never blocks the save.
Where a doc saves is automatic today: repo-local
(<repo>/docs/streamweaver_canvas/) if you're inside a git repo, ~/.streamweaver/canvas
otherwise — no way to choose yet. An explicit "global vs. this repo" toggle
is designed but not built (docs/plans/canvas-doc-location-and-discovery.md);
expect this UX to change.
Saved .rb/.org docs checked into a GitHub repo render with full
StreamWeaver styling via the browser extension — see
below.
Templates for Common Patterns
# Quick selection
streamweaver template choices mysession '{"title": "Pick DB", "options": ["PostgreSQL", "SQLite"]}'
# Returns: {"choice": "PostgreSQL"}
# Yes/No confirmation
streamweaver template confirm mysession '{"title": "Delete?", "message": "Cannot be undone"}'
# Returns: {"confirmed": true}
# Multi-step wizard
streamweaver template wizard mysession '{"steps": [...]}'
# Data table with selection
streamweaver template table mysession '{"headers": ["File", "Size"], "rows": [...]}'Browser Extension
Push a .rb or .org doc to GitHub or a Gist, and even without this
extension it's still legible — .org already reads close to markdown there.
With this extension, either format renders exactly as designed: sidebar nav,
callouts, cards, tables, live Mermaid diagrams, all compiled and displayed
entirely inside the browser. No StreamWeaver install, no Ruby, no server, no
vendor lock-in — just a "View rendered" button next to the file. Works on
public and private repo blob pages, GitHub Gists (including multi-file
gists), and local files dropped straight into the viewer with no GitHub at
all.
That's the actual point of shipping this publicly: docs like this only get to move freely between a work team and outside collaborators once anyone with a browser can open one.
Install
Chrome Web Store — the primary path. One click, no build step, works right away.
Dev path — building from source, for contributors working on the extension itself:
bin/vendor_browser_assets # once
bin/build_extensionThen chrome://extensions → Developer mode → Load unpacked → select
extension/. See extension/README.md for how it
works, the full architecture, and current known gaps.
Share a doc
Two ways to hand someone a doc, depending on how much staying power it needs:
- Quick collab — push the doc to a Gist, then send the Gist link plus the extension link. No install for you, one click for them: after installing, they open the Gist link and a View rendered button appears in that file's header bar, next to its Raw/Copy buttons — click it. (If the Gist tab was already open before installing, refresh the page first; a newly installed extension only runs in tabs loaded after install.) Good for a one-off review or looping in someone outside the team.
-
Level up — commit the same
.orgfile to the team repo. It renders identically in the repo's own file view, no format change required. The doc graduates from shared-once to living with the code it documents.
Components
Text & Headers
text "Plain text"
md "**Markdown** with *formatting* and [links](url)"
header "Section" # <h2> default
header1 "Page Title" # through header6Form Inputs
text_field :name, placeholder: "Name", default: "Alice"
text_area :bio, rows: 5
select :color, ["Red", "Green", "Blue"], default: "Green"
checkbox :agree, "I accept"
radio_group :size, ["S", "M", "L"]Buttons
button "Primary" do |state|
state[:clicked] = true
end
button "Secondary", style: :secondary do |state|
# ...
endScoped Fragments
Fragments keep the normal single full DSL rerun while limiting the HTML swapped into the page. Interactive controls target their enclosing fragment automatically.
action :refresh, updates: :sidebar_count do |state, account_id|
state[:account_id] = account_id
end
fragment :results do
text "Account: #{state[:account_id]}"
button "Refresh", action: :refresh, key: 42
end
fragment :sidebar_count do
text "Selected: #{state[:account_id] ? 1 : 0}"
endUse updates: on an action or button to refresh additional named fragments with
out-of-band swaps. Server-rendered content in other fragments is intentionally left
unchanged; omit fragments when an interaction must refresh the whole app. If a target
disappears or routing changes, StreamWeaver automatically falls back to a full swap.
Layout
columns widths: ['30%', '70%'] do
column { text "Sidebar" }
column { text "Main" }
end
vstack spacing: :md do
text "Item 1"
text "Item 2"
end
hstack justify: :between do
button "Cancel", style: :secondary
button "Save"
end
card do
header3 "Title"
text "Content"
end
collapsible "Show Details" do
text "Hidden until clicked"
endTables
# Simple
table headers: ["Name", "Age"], rows: [["Alice", 30], ["Bob", 25]]
# From hashes (headers inferred)
table [{ name: "Alice", age: 30 }, { name: "Bob", age: 25 }]
# With formatters
table users do
column :name
column :balance, format: :currency
column :joined, format: :date
end
# Interactive
table data, sortable: true, sticky_header: true, striped: true, markdown: trueCharts
bar_chart data: { sales: 100, costs: 60, profit: 40 }
line_chart data: [12, 19, 8, 15, 22], fill: true
pie_chart data: { frontend: 40, backend: 35, devops: 25 }
sparkline data: [45, 52, 48, 61, 55, 67, 72]Navigation & Modals
tabs :settings do
tab("General") { text_field :name }
tab("Advanced") { checkbox :debug, "Debug" }
end
modal :confirm, title: "Are you sure?" do
text "This cannot be undone."
modal_footer do
button "Cancel", style: :secondary do |s| s[:confirm_open] = false end
button "Delete" do |s| do_delete; s[:confirm_open] = false end
end
endURL Routing
Deep links, bookmarkable URLs, and browser back/forward — no router library, just a bidirectional map between a URL path and a slice of state:
route_with(
parser: ->(path) { path == '/goals' ? { main_nav: 2 } : nil },
builder: ->(state) { state[:main_nav] == 2 ? '/goals' : nil }
)route_by covers the simple single-key case. See docs/routing.md for the
full contract — including a Common Pitfalls section worth reading before an app grows past a
handful of routes (state merges rather than replaces on every GET, so an incomplete route table
fails silently rather than loudly).
Feedback
alert(variant: :success) { text "Saved!" }
progress_bar value: 75, variant: :success
spinner label: "Loading..."
toast_container position: :top_rightDashboard Components
For operations dashboards (best with theme: :dark):
status_dot status: :green, pulse: true
badge "5", variant: :danger
stat_display value: 42, label: "TASKS"
priority_item priority: :critical, title: "Server down"
app_shell sidebar_width: "320px" do
main { header "Dashboard" }
sidebar(header: "Alerts") { priority_item priority: :high, title: "CPU 90%" }
endTheming
app "My App", theme: :dark do
# Dark mode with glow effects
end
app "Report", theme: :document do
# Reading-optimized (serif, paper background)
endBuilt-in themes: :default, :dashboard, :document, :dark
Gem extensions
Installed gems can opt into StreamWeaver through one versioned gemspec metadata entry:
spec.metadata['stream_weaver.extensions.v1'] = 'my_gem/stream_weaver'The loader gives its contribution a stable ID:
StreamWeaver.register_extension(:my_gem)StreamWeaver.discover_extensions loads only gems that declare that metadata, in gem-name
order. Each loader is attempted once per process. A broken loader is returned as a structured
failure without preventing other extensions or built-in features from loading; restart the
process after correcting or installing a broken extension. Duplicate or blank IDs fail loudly.
This hook remains generic for third-party gems. SlimGraphR is StreamWeaver's built-in diagram
engine, so require 'stream_weaver' also loads its diagram DSL. Other integration gems can
keep their adapters behind explicit optional requires. Run streamweaver diagrams to open the
packaged Diagram Atlas with executable Ruby for every supported SlimGraphR type; it uses a live
canvas when available and falls back to Canvas Reader elsewhere.
The packaged Chrome viewer also includes SlimGraphR's browser-safe Opal entrypoint. Saved Ruby and Org documents render all 39 atlas types as self-contained inline SVG, including accessible titles and descriptions, inside the existing offline sandbox runtime.
An extension can also provide University shelf entries. Pass an object responding to
courses; it returns course hashes with id, title, blurb, nonempty ordered
steps, and a callable demo_resolver:
StreamWeaver.register_extension(:my_gem, course_provider: MyCourseProvider.new)University lists built-in Getting Started first, then providers by extension ID. It
validates and freezes this catalog before rendering; malformed entries raise an error
that names the provider and field. Provider courses render their own Run, Repeat,
Mark done, and Reset controls, with progress isolated by course ID. The terminal doors
accept the same stable ID through --course COURSE_ID, including
university-demo, university-done, and university-reset.
More Resources
- Canvas Mode Documentation
- canvas-read: the document shelf — the most portable way to read StreamWeaver docs, no iTerm2 required
- Templates Reference
- Components Reference
- Service Mode
-
URL Routing —
route_by/route_with, plus Common Pitfalls for larger apps
Contributing
Contributions welcome! GitHub repository
License
MIT License - see LICENSE.txt
