# 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](/docs/api/fields.md), 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`

```bash
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`:

```bash
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) |

```bash
sparku listen deploy --exec './on-click.sh'
```

```bash
#!/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.

```bash
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`:

```python
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

```js
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), plus `SPARKU_SLUG`,
  `SPARKU_CONTROL`, and `SPARKU_VALUE` for 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.
