Sections · 7/11
Buttons and Events
Event stream /events, at-most-once delivery, sparku listen in NDJSON and with --exec, other button actions.
What is a listener#
A button, capsule, toggle, or menu in a card can send an "event to a listener"
(Field types, control elements). A listener is a client that keeps
GET /v1/integrations/{slug}/events open. As long as the stream is open by at least one
client, elements with "send event" actions are active; without a listener — inactive, nothing to tap.
Delivery is at-most-once, with no queue: an event that happens while no one is listening is lost forever. Reconnecting the listener does not return missed taps.
Stream GET …/events#
curl --unix-socket ~/.sparku/run/api.sock \
-H "Authorization: Bearer $SPARKU_TOKEN_DEPLOY" \
http://sparku/v1/integrations/deploy/events
Response is text/event-stream; the connection stays open until one side closes it.
The first event is hello with a list of control elements in the card:
event: hello
data: {"integration":"deploy","controls":[{"id":"pause","kind":"toggle"},{"id":"restart","kind":"button"}]}
Then control on each tap:
event: control
data: {"integration":"deploy","control":"pause","value":true,"at":"2026-09-28T10:15:03Z"}
value only appears for a toggle (bool) or menu (state) — what the element was switched to.
Button and capsule don't have value in the event.
Every 15 seconds the server writes an SSE comment:
: ping
Clients ignore it; the server detects a lost client (sleep, network break) by a write error for that ping and closes the connection — there's no point keeping a dead listener.
While the stream is open, there is nothing to send into it and no need to: anything the client sends after the request is ignored by the server.
sparku listen#
sparku listen <slug> [--exec "command"] [--reconnect]
Without --exec, each event is printed to stdout as a JSON line (NDJSON) — handy for
jq and while read:
sparku listen deploy | while read -r line; do
control=$(echo "$line" | jq -r .control)
value=$(echo "$line" | jq -r .value)
echo "tapped $control = $value"
done
With --exec, a command runs on each control event; hello is not passed to it.
Event data goes to the handler only through environment variables, not as arguments or in the command text:
| Variable | Value |
|---|---|
SPARKU_SLUG |
integration address |
SPARKU_CONTROL |
id of the control element |
SPARKU_VALUE |
toggle/menu value (true/false or state id); empty for button |
SPARKU_EVENT |
entire event as received over SSE (JSON string) |
sparku listen deploy --exec './on-click.sh'
#!/bin/bash
# on-click.sh
case "$SPARKU_CONTROL" in
pause) echo "pause: $SPARKU_VALUE" ;;
restart) ./restart-deploy.sh ;;
esac
--reconnect reconnects while SparkU is unreachable or API is disabled, instead of
exiting. Without it, listen exits with code 69 when the connection breaks.
sparku listen deploy --reconnect --exec './on-click.sh' &
NOTE
sparku listen requires a token just like push: --token, --token-file or
SPARKU_TOKEN_<ADDRESS> / SPARKU_TOKEN.
Example in Python (no dependencies)#
Reads SSE straight from the UNIX socket via http.client:
import http.client, json, os, socket
class SparkU(http.client.HTTPConnection):
def __init__(self):
super().__init__("sparku")
def connect(self):
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.sock.connect(os.path.expanduser("~/.sparku/run/api.sock"))
token = os.environ["SPARKU_TOKEN_DEPLOY"]
api = SparkU()
api.request("GET", "/v1/integrations/deploy/events", headers={
"Authorization": f"Bearer {token}",
})
response = api.getresponse()
buffer = ""
for chunk in iter(lambda: response.read(1), b""):
buffer += chunk.decode("utf-8")
if buffer.endswith("\n\n"):
for line in buffer.splitlines():
if line.startswith("data: "):
event = json.loads(line[len("data: "):])
if "control" in event:
print("tapped", event["control"], event.get("value"))
buffer = ""
Example in Node#
const http = require("node:http");
const os = require("node:os");
const request = http.request({
socketPath: `${os.homedir()}/.sparku/run/api.sock`,
path: "/v1/integrations/deploy/events",
headers: { Authorization: `Bearer ${process.env.SPARKU_TOKEN_DEPLOY}` },
});
request.on("response", (response) => {
let buffer = "";
response.on("data", (chunk) => {
buffer += chunk.toString("utf8");
const frames = buffer.split("\n\n");
buffer = frames.pop();
for (const frame of frames) {
const line = frame.split("\n").find((l) => l.startsWith("data: "));
if (!line) continue;
const event = JSON.parse(line.slice("data: ".length));
if (event.control) console.log("tapped", event.control, event.value);
}
});
});
request.end();
Other button actions#
Sending an event to a listener is not the only thing a control element can do. The exact action is set by the user in the integration editor (not a script):
- Open a link, file, or app —
{key}in the address is replaced with the value of that field, percent-encoded; a template from a single URL field ({url}) is taken as-is. - Execute a shell command — the user types the command text in the editor. Field values
are never substituted into the command text, only into the environment —
SPARKU_FIELD_<KEY>(key in uppercase), plusSPARKU_SLUG,SPARKU_CONTROL, andSPARKU_VALUEfor toggle/menu. - Run a Shortcuts command — by name, no shell:
shortcuts run <name>.
Listeners get a tap event regardless of which of these actions is configured — it executes alongside sending the event to the stream.