From zio-skills
Use when asked to build a reactive web app with ZIO HTTP, add real-time updates or server-sent events (SSE) to a Scala server, stream HTML fragments to the browser, sync browser state with ZIO signals, build live-search, real-time clocks, typewriter effects, or multi-client chat with Datastar. Also use when replacing HTMX, AJAX, or React with server-driven HTML patching, or when using the Datastar SDK with Scala/ZIO HTTP.
How this skill is triggered — by the user, by Claude, or both
Slash command
/zio-skills:zio-http-datastarThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Use this skill when a user asks to:
Use this skill when a user asks to:
Add zio-http-datastar-sdk to your build.sbt:
libraryDependencies ++= Seq(
"dev.zio" %% "zio-http" % "3.11.0",
"dev.zio" %% "zio-http-datastar-sdk" % "3.11.0",
)
Include these three imports in any file using Datastar:
import zio.http._
import zio.http.datastar._
import zio.http.template2._
Key types:
import zio.http.template2._ provides the HTML DSL for generating typed DOM elementsimport zio.http.datastar._ enables the events and event route wrappers plus all data* HTML attributesDatastar inverts the request/response cycle. The browser keeps an SSE connection open and the server pushes HTML fragments at will. Use the events { handler { ... } } route wrapper to stream updates:
import zio._
import zio.http._
import zio.http.datastar._
import zio.http.endpoint.Endpoint
import zio.http.template2._
object StreamingApp extends ZIOAppDefault {
val routes = Routes(
Method.GET / Root -> handler {
Response(
headers = Headers(Header.ContentType(MediaType.text.html)),
body = Body.fromCharSequence(indexPage.render),
)
},
Method.GET / "stream" -> events {
handler {
ZIO.foreachDiscard(1 to 5) { i =>
ServerSentEventGenerator.patchElements(
div(id("output"), s"Update $i of 5")
) *> ZIO.sleep(500.millis)
}
}
},
)
val indexPage = html(
head(title("Streaming Demo"), datastarScript),
body(
dataInit := Endpoint(Method.GET / "stream").out[String].datastarRequest(()),
div(id("output"), "Waiting..."),
),
)
def run = Server.serve(routes).provide(Server.default)
}
Key types:
events { handler { ... } } — streaming route wrapper; handler body is ZIO[Datastar, Nothing, Unit]ServerSentEventGenerator.patchElements(dom) — morphs the DOM in place; automatically finds element by iddatastarScript — injects the Datastar JavaScript from CDN (version 1.0.1)dataInit := Endpoint(...).out[String].datastarRequest(()) — auto-triggers the SSE endpoint when the page loads⚠️ Critical mistake to avoid: The handler inside events { handler { ... } } must return Unit. All output happens via ServerSentEventGenerator.* methods. Attempting to return a Response will not compile.
Datastar sends the current signal state with every request. Two patterns work:
Pass data via the URL itself:
val $query = Signal[String]("query")
Routes(
Method.GET / "search" -> events {
handler { (req: Request) =>
val term = req.url.queryParameters.getAll("q").headOption
val results = filterResults(term)
ServerSentEventGenerator.patchElements(
div(id("results"), ol(id("list")), results.map(li(_)))
)
}
},
)
// In your HTML:
input(
`type` := "text",
dataSignals($query) := "",
dataBind($query.name),
dataOn.input.debounce(300.millis) := js"@get('/search?q=' + ${$query})",
)
Key types:
Signal[String]("query") — typed signal referencedataSignals($signal) := "" — declares signal in HTML with initial valuedataBind($signal.name) — two-way binds input to signaldataOn.input.debounce(300.millis) — waits 300ms after typing stops, fires oncereq.url.queryParameters.getAll(name) — reads the URL paramUse readSignals[T] for larger forms:
case class SearchQuery(query: String)
object SearchQuery {
implicit val schema = DeriveSchema.gen[SearchQuery]
}
Routes(
Method.GET / "search" -> events {
handler { (req: Request) =>
for {
params <- req.readSignals[SearchQuery].orElseSucceed(SearchQuery(""))
results <- filterResults(params.query)
_ <- ServerSentEventGenerator.patchElements(
div(id("results"), results.map(li(_)))
)
} yield ()
}
},
)
// In your HTML:
input(
`type` := "text",
dataSignals($query) := "",
dataBind($query.name),
dataOn.input.debounce(300.millis) := js"@get('/search')",
)
Key types:
req.readSignals[T] — ZIO[Any, String, T]; returns the current signal state as a typed objectimplicit val schema = DeriveSchema.gen[T] (Scala 2) or derives Schema (Scala 3)dataOn.input.debounce(300.millis) := js"@get('/search')" — automatically includes all signals in the request⚠️ Common mistake: Forgetting dataSignals(...) declaration. Without it, the signal is undefined on the client and dataBind silently fails.
eventWhen you need exactly one DOM update (e.g., form submission), use event { handler { ... } } instead of events:
Routes(
Method.GET / "greet" -> event {
handler { (req: Request) =>
DatastarEvent.patchElements(
div(
id("greeting"),
p(s"Hello, ${req.queryParam("name").getOrElse("Guest")}!")
)
)
}
},
)
// In your HTML:
form(
dataOn.submit := js"@get('/greet')",
input(`type` := "text", name := "name", placeholder := "Your name"),
button(`type` := "submit", "Greet me"),
)
div(id("greeting"))
Key types:
event { handler { ... } } — single-shot route wrapper; handler returns DatastarEvent, not ZIODatastarEvent.patchElements(dom) — returns a value (not an effect)dataOn.submit — no .prevent needed; Datastar prevents form default automaticallyWhen to use event: Exactly one DOM patch in response to a single request. Use events for loops or streaming.
For data-heavy updates (live clocks, progress), push a signal update instead of re-rendering HTML:
Method.GET / "server-time" -> events {
handler {
ZIO.clockWith(_.currentDateTime)
.map { dt =>
val time = dt.toLocalTime.format(DateTimeFormatter.ofPattern("HH:mm:ss"))
s"{ 'currentTime': '$time' }"
}
.flatMap(ServerSentEventGenerator.patchSignals(_))
.schedule(Schedule.spaced(1.second))
.unit
}
}
// In HTML:
val $time = Signal[String]("currentTime")
span(
dataSignals($time) := "'--:--:--'",
dataText := $time,
dataInit := Endpoint(Method.GET / "server-time").out[String].datastarRequest(()),
)
Key types:
ServerSentEventGenerator.patchSignals(jsonString) — updates browser signals (faster than DOM patching)dataText := signal — reactively displays signal value as element textFor live search results or chat messages, append without re-rendering the whole list:
ServerSentEventGenerator.patchElements(
li("New item"),
PatchElementOptions(
selector = Some(CssSelector.id("my-list")),
mode = ElementPatchMode.Append,
),
)
Key types:
PatchElementOptions(selector, mode) — targets a CSS selector and specifies how to patchElementPatchMode.Append — adds to the end; also: Prepend, Inner (replace contents), Outer (morph), Before, After, RemoveFor chat or notifications, use a Hub to broadcast to all connected SSE clients:
case class ChatRoom(messages: Ref[List[String]], subscribers: Hub[String])
events {
handler {
for {
messages <- ChatRoom.getMessages
_ <- ServerSentEventGenerator.patchElements(
messages.map(m => li(m)),
PatchElementOptions(selector = Some(CssSelector.id("messages")), mode = ElementPatchMode.Inner),
)
stream <- ChatRoom.subscribe
_ <- stream.mapZIO { msg =>
ServerSentEventGenerator.patchElements(
li(msg),
PatchElementOptions(selector = Some(CssSelector.id("messages")), mode = ElementPatchMode.Append),
)
}.runDrain
} yield ()
}
}
See references/examples/ChatServer.scala for the full multi-file pattern with ZIO.Hub, service injection, and multi-user chat.
Show/hide elements based on request state:
val $loading = Signal[Boolean]("loading")
button(
dataIndicator($loading), // sets $loading=true during request, false after
dataOn.click := js"@get('/do-something')",
"Do Something",
),
span(
dataShow := js"${$loading}",
"Loading...",
)
| Type / Function | Purpose |
|---|---|
events { handler { ... } } | Streaming SSE route; handler is ZIO[Datastar, Nothing, Unit] |
event { handler { ... } } | Single-shot route; handler returns DatastarEvent |
ServerSentEventGenerator.patchElements(dom) | Stream DOM patch to client |
ServerSentEventGenerator.patchSignals(json) | Stream signal update to client |
ServerSentEventGenerator.executeScript(js) | Execute JavaScript on client |
DatastarEvent.patchElements(dom) | Return DOM patch as value |
DatastarEvent.patchSignals[T: Schema](obj) | Return signal update as value |
req.readSignals[T] | Decode signal state from request (ZIO[Any, String, T]) |
Signal[A](name) | Create typed signal reference |
dataSignals($s) := expr | Declare signal in HTML with initial value |
dataBind($signal.name) | Two-way bind input to signal |
dataOn.click := js"..." | Attach action to event |
dataOn.input.debounce(ms) | Debounce input events by milliseconds |
dataInit := request | Fire request when page loads |
dataText := signal | Display signal value as text |
dataShow := expr | Show/hide element from expression |
dataIndicator($loading) | Track loading state of a request |
Endpoint(...).out[T].datastarRequest(...) | Render Datastar request from typed Endpoint |
PatchElementOptions(selector, mode) | Target CSS selector and specify patch mode |
zio-http-imperative-to-declarative for best practices on endpoint organizationzio-http-endpoint-to-openapi to auto-generate documentation from endpointsreferences/examples/ChatServer.scala for complete Hub-based patternsreferences/api-guide.md for the complete Datastar SDK| Symptom | Likely cause | Fix |
|---|---|---|
| Browser shows the page but no updates arrive | Route's Content-Type isn't text/event-stream, or proxy buffering is on. | Use ServerSentEventsResponse(...) (sets the correct headers) and disable buffering on any reverse proxy. |
| Updates arrive but the DOM doesn't change | Wrong element selector, or mergeFragments used where replacement was intended. | Match selector to an existing element ID; choose the patching mode that matches your intent (merge vs replace). |
| Updates duplicate elements after the first emission | mergeFragments accumulates when replaceFragments was intended. | Use replaceFragments (or mergeMode := Replace) when new HTML should overwrite the existing block. |
| Signals don't reach the server | Datastar form/element isn't bound, or data-on-* attribute typo. | Confirm the page sends signals via data-signals-* and triggers via data-on-click / data-on-submit. |
Channel.send succeeds but client never observes the event | Client closed the EventSource before the event arrived (e.g., navigation). | Defer side effects until after onopen; reconnect on onerror. |
| One client's signal leaks into another client's view | Server-side state shared without per-connection scoping. | Scope state with a per-connection Ref, or include a session ID in the signal. |
references/api-guide.md — Complete API reference in this skillnpx claudepluginhub zio/zio-skills --plugin zio-skillsProvides behavioral guidelines to reduce common LLM coding mistakes, focusing on simplicity, surgical changes, assumption surfacing, and verifiable success criteria.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Creates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.