Quick start
Let’s build the classic counter — small enough to fit in your head, complete enough to show the whole loop.
Prefer a full app shell? Scaffold with the Web starter kit
(pip install almasix-starter-kit-web then almasix new myapp --kit web) — Prism + Conduit auth,
settings, and teams out of the box.
1. The component
Section titled “1. The component”from almasix.conduit import Component
class Counter(Component): count = 0 label = "world"
def increment(self) -> None: self.count += 1
def decrement(self) -> None: self.count -= 1
def reset(self) -> None: self.count = 0
def render(self) -> str: return "conduit.counter"Public class attributes (count, label) become public state — snapshotted, checksummed, and syncable from the browser. Public methods become actions you can call with wire:click or $wire.
render() returns a Prism view name. Conduit looks it up, passes public properties into the template, and wraps the HTML with the wire fingerprint the client needs.
2. The view
Section titled “2. The view”<div> <h1 conduit:text="count">{{ count }}</h1>
<button type="button" conduit:click="decrement" data-loading>−</button> <button type="button" conduit:click="increment" data-loading>+</button> <button type="button" conduit:click="reset">Reset</button>
<label> Label <input type="text" conduit:model.live="label" value="{{ label }}"> </label> <p>Hello, <span conduit:text="label">{{ label }}</span>!</p></div>A few things happening here:
| Attribute | What it does |
|---|---|
conduit:click="increment" |
Calls Counter.increment() on the server |
conduit:text="count" |
Keeps the heading’s text in sync with count on the client and after morph |
conduit:model.live="label" |
Syncs the input on every keystroke (coalesced into one request) |
data-loading |
Lets CSS react while a request is in flight |
Server-rendered {{ count }} is the first paint. After that, Conduit owns the updates.
3. Embed it
Section titled “3. Embed it”In a layout (scripts once):
@conduitScriptsOn a page — pick your favorite embed style:
<conduit:counter /><!-- or -->@conduit('counter')Or from a controller / route action:
from almasix.conduit import conduitfrom almasix.prism.helpers import view
def show(): return view("pages.home", {"body": conduit("counter")})Pass initial state as kwargs when you need them:
conduit("counter", count=10, label="friend")@conduit('counter', count=10, label='friend')4. Click around
Section titled “4. Click around”Load the page. Hit +. Watch the number jump.
Under the hood Conduit:
- Reads the component snapshot from the DOM
- Queues a call (
increment) and coalesces any nearby updates (~16ms) POSTs to a signed/conduit/updatewith CSRF- Applies returned
datato client bindings immediately - Morphs the HTML (respecting
wire:key/wire:ignore)
You wrote Python. The UI feels like a tiny SPA. That’s the win.
Why it can feel like pure JS
Section titled “Why it can feel like pure JS”Conduit optimizes the happy path so typing and toggles don’t sit around waiting for a full redraw:
- Checksum-safe updates — the client never mutates
serverMemo.databeforePOST; bindings (wire:text,wire:show,wire:bind:*) refresh from the response memo so HMAC checksums stay valid. - Request coalescing — updates and calls within ~16ms merge into one
POST /conduit/update. - Idiomorph-lite morph — patches attributes and keyed children instead of blindly replacing the root.
- Islands —
wire:island/$wire.$island()scopes HTML morph to a region so the rest of the component stays put. .renderless— skip HTML when only server state or events matter.data-loading— loading hooks for CSS without extra roundtrips.
Pair wire:model.live with wire:text on the same property: coalescing keeps traffic light, and bindings update as soon as the roundtrip lands.
A slightly richer example
Section titled “A slightly richer example”Search box + results list — still one component:
from almasix.conduit import Component
CATALOG = ["Almasix", "Conduit", "Prism", "Articulate"]
class Search(Component): query = "" results: list[str] = []
def search(self) -> None: q = self.query.strip().lower() self.results = [item for item in CATALOG if q in item.lower()] if q else []
def render(self) -> str: return "conduit.search"<div> <input type="search" wire:model.live.debounce.300ms="query" placeholder="Search…"> <button type="button" wire:click="search">Search</button>
<ul> @foreach(results as item) <li wire:key="{{ item }}">{{ item }}</li> @endforeach </ul>
@if(not results and query) <p>No matches for “{{ query }}”.</p> @endif</div>wire:model.live.debounce.300ms keeps the network calm while you type. wire:key gives the morph algorithm stable identity for list items.
Next: dig into Components, or skip ahead to Alpine and islands if you want the $wire story.