The native event channel 📡¶
Some capabilities don't answer once — they emit a stream of values over
time. This page shows how to consume that stream with async for, how it maps onto
the wire (subscribe → event → unsubscribe), and why leaving the loop is what cancels
the subscription. 🚀
Single-shot isn't enough¶
Most capabilities are single-shot: one request, one result. You await and move
on:
But "where am I now" is different from "where am I going". A running app, a
map that follows the user, a speedometer — all need continuous readings. The
same goes for battery level, device orientation, network state, speech transcript.
Polling with await in a loop would be wasteful and would miss events between
calls.
That's what the native event channel is for (the roadmap's T-EV phase): a typed stream from the client to Python, exposed as an async iterator.
The async for pattern¶
Every streaming capability is a method you iterate with async for. Each turn of
the loop delivers the next typed value:
from tempestweb import native
from tempest_core import App
async def follow_me(app: App[object]) -> None:
"""Follow the device position until the app stops consuming."""
async for pos in native.geolocation.watch():
app.set_state(lambda s: setattr(s, "here", (pos.latitude, pos.longitude)))
Read it slowly:
native.geolocation.watch()opens a subscription and returns an async iterator.- Each
posis a typedPosition— the same typegeolocation.get()returns in the single-shot shape. - The loop runs while events keep coming. It doesn't "finish" on its own: you
end it with
break, by letting the function return, or by cancelling the task.
Streaming reuses the single-shot types
watch() delivers the same typed model as the get()/state() version.
You learn the type once and use it in both shapes.
Today's streaming capabilities:
| Capability | Iterates |
|---|---|
geolocation.watch() |
Position as the device moves |
sensors.orientation() / sensors.motion() |
gyroscope/accelerometer readings |
network.watch() |
NetworkState on every connection change |
visibility.watch() |
"visible"/"hidden" on tab switch |
orientation.watch() |
OrientationState when the screen rotates |
battery.watch() |
BatteryStatus on every charge change |
speech.listen() |
SpeechResult (STT), interim and final |
idle.watch() |
IdleState when the user goes idle |
tabs.receive() |
messages broadcast by other tabs |
gamepad.watch() |
snapshots of connected controllers |
midi.messages() |
MidiMessage from any input port |
How it travels on the wire¶
Under the hood, the async iterator exchanges three messages with the client. In
Mode B (server) they cross the WebSocket/SSE; in Mode A (WASM) the
FFIBridge resolves them in-process with exactly the same shape:
// open the subscription
{ "kind": "native_subscribe", "sub_id": "s1", "capability": "geolocation.watch", "args": {"high_accuracy": true} }
// each event of the subscription (repeats)
{ "kind": "native_event", "sub_id": "s1", "event": { "latitude": -23.5, "longitude": -46.6, "accuracy": 5.0 } }
// terminal failure, OR normal end
{ "kind": "native_event", "sub_id": "s1", "error": "permission_denied", "message": "…" }
{ "kind": "native_event", "sub_id": "s1", "done": true }
// cancel the subscription
{ "kind": "native_unsubscribe", "sub_id": "s1" }
What async for does with each:
native_subscribe— emitted when you enter the loop. Thesub_idcorrelates the subscription and all its events (several can be in flight at once).native_eventwithevent— becomes the next value delivered byasync for.native_eventwitherror— raises aNativeErrorinside your loop, ending the subscription. Handle it withtry/except.native_eventwithdone: true— ends the loop normally (the iterator is exhausted).native_unsubscribe— emitted automatically when you leave the loop.
The canonical shape of each field lives in
docs/contract.md,
section "Canal de eventos nativo (streaming, T-EV)" — and the sibling single-shot
crossing is in the wire contract.
Leaving the loop cancels the subscription¶
This is the most important point — and the most elegant. You never call
unsubscribe by hand. Leaving the async for does it for you:
from tempestweb import native
from tempest_core import App
async def follow_until_close(app: App[object]) -> None:
"""Stop following as soon as the fix is accurate enough."""
async for pos in native.geolocation.watch():
app.set_state(lambda s: setattr(s, "here", pos))
if pos.accuracy < 10.0:
break # ← this fires native_unsubscribe; the subscription closes
The native_events() behind every watch() wraps the loop in a try/finally:
whatever the reason you leave — break, returning from the function, a raise, or
the task being cancelled — the finally sends native_unsubscribe. No leaked
subscriptions, no dangling callbacks in the browser.
Cancellation is cleanup
If you keep the task (task = asyncio.create_task(follow_me(app))) and later
task.cancel(), the async for raises CancelledError at the await point,
the finally runs, and the subscription is closed. Cancelling the task is
the way to stop the stream.
A stream needs someone consuming it¶
An async for only makes progress while someone iterates it. Creating the
coroutine and never await-ing it (nor scheduling it as a task) subscribes to
nothing — the generator just sits there.
Run the stream in a task, not inside a view()
Never put a streaming async for inside view(): view() must return the
tree quickly and is called on every render. Instead, start the consumption
once (in a handler, or at app bootstrap) and let it run in a dedicated task:
import asyncio
from tempestweb import native
from tempest_core import App
async def start_following(app: App[object]) -> asyncio.Task[None]:
"""Start the position stream in a background task and keep it."""
async def _loop() -> None:
async for pos in native.geolocation.watch():
app.set_state(lambda s: setattr(s, "here", pos))
return asyncio.create_task(_loop())
Keep the Task in your state so you can cancel() it when the screen goes
away — closing the subscription cleanly.
Recap¶
- Streaming capabilities deliver many events per subscription; you consume
them with
async for, notawait. - Each
watch()/listen()/messages()/receive()returns a typed async iterator that reuses the single-shot types. - On the wire, the loop becomes subscribe → event* → (error|done), with
sub_idcorrelating everything — identical in Mode A (in-process) and Mode B (WS/SSE). - Leaving the loop (
break, return,raise, cancellation) firesnative_unsubscribeautomatically — you never clean up by hand. - A stream needs a consumer: run it in a dedicated task, outside
view(), and cancel the task to stop.
See the full list of streams in the Native capability reference and a live showcase in the Device panel. 🚀