# SparkU API — custom integrations
> SparkU puts scripts and programs on the MacBook notch: a script pushes values over a local UNIX socket (HTTP/1.1 or the `sparku` CLI), SparkU draws them as an indicator, a status line or a card, relays button presses back and can ask the user a question.
## Overview
> What is the SparkU public API and why scripts output data to the Island.
### What it is
The SparkU public API is a way to display on the Island (notch/Dynamic Island) data that your own script or program collects: build progress, server metrics, a timer, a task queue. The script doesn't draw the interface — it only sends values to ready-made slots that you configure in advance.
Typical scenarios:
- **CI/deploy** — pipeline stage, branch, build link, "Open logs" button.
- **Builds** — percentage, stage, time remaining.
- **Server monitoring** — CPU, memory, latency, status "working / slow / down".
- **Timers** — countdown to end of meeting, kettle, backup.
- **AI agents ask humans** — agent sends a question with answer options and waits for you to tap on the Island.
### Model: You describe the form, scripts fill it in
```
Settings → Custom Integrations script / program
┌───────────────────────────────┐ │ sparku push deploy progress=0.7
│ "Deploy" address: deploy │ │ curl --unix-socket … /v1/…
│ fields: title, progress, stage ▼
│ indicator, line, card │ ~/.sparku/run/api.sock
│ buttons, priority, ttl │ ▼
└──────────────┬────────────────┘ field values
└──────────────────► Island elements
```
**Integration** — name, address (slug), fields, and how they look on the Island — is created only by you: by hand in the editor, by wizard, or by importing a `.sparku` file with explicit consent. The script gets an address and token and from that point can only **write values** to fields that already exist. It cannot add a new element, button, or card — the Island layout is fixed, only the content changes.
> [!WARNING]
> Custom Integrations is an experimental feature. It may behave unexpectedly
> (cards and the Island sometimes display differently than intended), and frequent updates
> from scripts increase CPU and battery usage. The feature is disabled by default —
> you must consciously enable it in Settings after reading the warning.
### Local only, no network
The API works through a UNIX socket at `~/.sparku/run/api.sock` and exists only while the feature is enabled in Settings. There is no network port: any program **on this same Mac** running under your user can write to the integration — that is, the same level of access as any of your scripts already have. The token is for addressing, protection from typos and other scripts, not as an access boundary between processes owned by one user.
### Next steps
- [Quick start](/docs/api/quickstart.md) — enable the feature, install the CLI, send your first value.
- [Concepts](/docs/api/concepts.md) — integration, slug, token, fields, Island elements and cards, priority, value TTL.
- [Field types](/docs/api/fields.md) — what and how to send in `push`, error texts.
- [sparku CLI](/docs/api/cli.md) — all commands, flags, exit codes.
## Quick start
> Enable the feature, install sparku, and send your first value to the Island.
### 1. Enable the feature
Settings > Integrations section > "Custom Integrations" block at the bottom (badge "EXPERIMENTAL") > "Turn On…". A warning appears: the feature is experimental, unexpected behavior and increased CPU and battery usage are possible. The "Turn On" button doesn't respond to Return — you must click it explicitly.
After consent, a "Custom Integrations" section appears in the Settings sidebar and a new-integration wizard opens.
### 2. Install the CLI
In the same Settings section — "sparku" line with "Install…" button. It creates a symbolic link `/usr/local/bin/sparku` pointing to the utility inside `SparkU.app`. If `/usr/local/bin` is not writable, macOS will ask for admin password once.
Check that the command is available:
```bash
sparku version
```
### 3. Create an integration with the wizard
The wizard offers five templates: "Task with progress", "Server metrics", "Timer", "Build status", "List". Each already contains fields, an indicator, a line, and a card — just give a name and address (slug) or accept the suggested one. "Custom" creates an empty integration without fields.
Let's use the "Build status" template with address `build`. It already has fields `stage` (status), `branch` (text), `started` (time), `link` (link), and an "Open" button on the card.
### 4. Copy the token
In the integration editor — "Connection" section: the line `export SPARKU_TOKEN_BUILD=…` and ready examples for curl, Python, Node, Swift. Copy the export and run it in the terminal where you'll test:
```bash
export SPARKU_TOKEN_BUILD=spk_your_token
```
`sparku` will find this environment variable by the integration address — you don't need to pass the token every time manually.
### 5. First push
```bash
sparku push build stage=running branch=main started=+0s
```
The indicator and line of the card on the Island should update. Expand the Island — you'll see the "Build status" card with an "Open" button.
### 6. Check that everything works
```bash
sparku doctor build
```
The command prints step-by-step: socket exists, SparkU responds, token is found, integration exists, and what fields it has. If something is wrong — `doctor` tells you at which step.
### 7. Clear values
```bash
sparku end build
```
Clears all values for the integration — the Island will have no data for it (elements with visibility "While there is data" disappear).
### The same thing via curl
Without the CLI, directly through the UNIX socket:
```bash
curl --unix-socket ~/.sparku/run/api.sock \
-H "Authorization: Bearer $SPARKU_TOKEN_BUILD" \
-d '{"stage": "running", "branch": "main", "started": "+0s"}' \
http://sparku/v1/integrations/build/fields
```
Clear values:
```bash
curl --unix-socket ~/.sparku/run/api.sock -X DELETE \
-H "Authorization: Bearer $SPARKU_TOKEN_BUILD" \
http://sparku/v1/integrations/build/fields
```
### Next steps
- [Concepts](/docs/api/concepts.md) — how fields, Island, cards, and tokens work.
- [Field types](/docs/api/fields.md) — what you can send in `push` for each type.
- [sparku CLI](/docs/api/cli.md) — all commands and exit codes.
## Concepts
> Integration, slug, token, fields, Island, card, priority, and value TTL.
### Integration
An integration is what you assemble in Settings: a name, an address (slug), a set of typed fields, and how they appear on the Island (indicator, line, card). The integration structure is static: scripts send values but cannot add a field, button, or element beyond what you configured. One integration gives exactly one indicator and one line on the Island — if you need two lines, they're two separate integrations.
A disabled integration responds with `409 integration_disabled` to writes and displays nothing; its configuration and token are preserved.
### Slug (address)
A slug is how a script finds an integration: `/v1/integrations/{slug}/…`, `sparku push {slug} …`. Rules:
- lowercase Latin letters, digits, and hyphens;
- starts with a letter or digit, doesn't end with a hyphen;
- no longer than 32 characters.
Valid slug examples: `deploy`, `build`, `server`, `home-server`. On `.sparku` import, if the address matches, SparkU picks a free one: `deploy` → `deploy-2`.
### Token
Each integration has its own token, issued when it's created. It's stored in Keychain and doesn't protect against other processes under your user — they have access to the socket anyway. Its purpose is addressing, protection from typos and other scripts, and selective access revocation by reissuing the token.
**Where to get it.** Settings > Custom Integrations > your integration > "Connection" section: the line `export SPARKU_TOKEN_
=…` and ready code examples.
**How to pass it.** Header `Authorization: Bearer ` in every request.
**Search order in CLI** (`sparku` tries in order until it finds a non-empty value):
1. `--token `
2. `--token-file `
3. environment variable `SPARKU_TOKEN_` (address in uppercase, hyphens → `_`; for example `home-server` → `SPARKU_TOKEN_HOME_SERVER`)
4. variable `SPARKU_TOKEN`
5. variable `SPARKU_TOKEN_FILE` (path to file with token)
`SPARKU_TOKEN` without an address suffix is convenient when a script writes to one integration; `SPARKU_TOKEN_` — when writing to several at once.
### Fields
A field is a named, typed slot: a key (`progress`, `cpu`), a type ([field types](/docs/api/fields.md)), and a label. A field key is lowercase Latin letters, digits, and `_`, starts with a letter, no longer than 32 characters (it's also an environment variable name `SPARKU_FIELD_` for button shell actions, so hyphens are not allowed). An integration has at most 32 fields.
Writing fields is **all or nothing**: if any value fails validation, `POST …/fields` rejects the entire request, and the Island doesn't end up half-updated.
### Island: indicator, line, card
#### Indicator (collapsed Island, 28 pt slot)
One per integration. Styles:
| Style | Shows | Source |
|---|---|---|
| Icon | static SF Symbol | — |
| Ring | progress ring with icon or number inside | field `progress` or `number` with `min`/`max` |
| Value | short string up to 4 chars | `text`, `number`, `progress`, `state`, `bool` |
| Equalizer | animation like "Media" | field `bool` enables motion |
| Spinner | rotating indicator | field `bool` enables rotation |
Field `state` can be assigned separately (`stateField`) — its value replaces the icon, color, and animation of the indicator without changing its type.
#### Card line (one per integration)
| Style | Shows | Source |
|---|---|---|
| Text | template `{title} · {progress}` | any fields via `{key}` |
| Timer | live countdown | field `time` |
| Scale | label and mini bar | field `progress` or `number` with `min`/`max` |
| Avatars | stack of circles with initials, "+N" above three | field `list` |
**Line template** (`{key}`): substitutes the value in the default display for the field type. `{{` and `}}` are literal braces, no format specifiers. Default display: number — up to two decimal places plus unit (`21°C`, `120 ms`), progress — `43%`, state — its label, time — countdown to moment or time since moment (`4:59`, `1:02:03`), list — number of lines, boolean — "yes"/"no". No value — em dash (`—`).
#### Card (expanded Island)
Built from a template with slots — there's no free grid to keep the visual row unified with built-in integrations.
| Template | Model | Slots / fields |
|---|---|---|
| Value | Currency | main (`number`/`progress`/`text`), label |
| Status | agents | main (`state`), label, "since" (`time`) |
| Progress | Pomodoro | main (`progress`/`number`), label, countdown (`time`) |
| Timer | Pomodoro | main (`time`), label, ring (`progress`) |
| Scales | Resource Monitor | 2–4 `progress`/`number` fields (with `min`/`max`) in a row |
| Limit | Claude Code | fast window (`progress`), slow window (`progress`), label |
| Metrics | agents | 2–4 `text`/`number`/`progress`/`state`/`bool`/`time` fields in columns |
| List | Calendar, Todo | one `list` field |
| Now | Media | title (`text`, required), label, cover (`image`), scale (`progress`) |
| Graph | — | one `number` field — drawn from accumulated history |
The card has a common header and a row of 0–3 controls. A card can request normal or wide width (like built-in integrations) and a suitable compact variant.
### Controls and their actions
A control is a button, capsule button, toggle (controls a `bool` field), or menu (picks a `state` field value). Up to three per card. What the tap does is decided by you in the editor, not the script:
| Action | What happens |
|---|---|
| `event` | sends an event to listeners `GET …/events` / `sparku listen`; without a listener the control is inactive |
| `url` | opens a link, file, or app (address — static or `{field}`) |
| `shell` | runs a command; field values are passed **only** through environment variables `SPARKU_FIELD_`, never substituted into the command text |
| `shortcut` | launches an app command from the Shortcuts app by name |
A listener gets an event for any tap regardless of what action is set above.
### Priority
The Island is a shared resource: the notch border, attention beacon, and dialog queue work as a stack of requests, not "first wins, rest wait". Your custom integration has three priority levels:
- **Low**
- **Normal** — equal to SparkU's built-in integrations (default)
- **High** — above built-in
With equal priority, the request submitted **last** wins. Updating an already-submitted request doesn't bump it forward: a module reporting progress once a second doesn't steal the slot from one that requested after it.
### Value TTL (time-to-live)
Field values live **in memory only** and don't survive a SparkU restart — integration configuration is always saved. By default, values live until a SparkU restart or explicit `sparku end`; an integration can set its own TTL, and a specific request can override it with the `?ttl=` parameter:
```
POST /v1/integrations/deploy/fields?ttl=5m
POST /v1/integrations/deploy/fields?ttl=90
```
`ttl` is a number of seconds or `5m` / `1h30m` (same units as time fields), at most 7 days. Empty or missing parameter — TTL from integration settings. Each new write restarts the TTL count.
`sparku push` supports the same parameter as the `--ttl` flag (see [CLI](/docs/api/cli.md#push)).
### Binding to a process
A TTL doesn't help when a script crashes: it wrote "Deploy 40%" and died, and the island keeps showing it until the TTL runs out. Binding solves this differently — values live as long as a process does:
```
POST /v1/integrations/deploy/fields?bind=process # the process that sent the request
POST /v1/integrations/deploy/fields?bind=4211 # the process with this PID
POST /v1/integrations/deploy/fields?bind=none # remove the binding
```
When the process exits, the values are cleared at once, as with `sparku end`. The binding stays for later writes without `bind`; a new `bind` replaces it. The TTL still applies too — whichever comes first.
`bind=process` is for programs that keep running themselves (a Python script, a Node service). `curl` and `sparku` exit right after the request, so they need the script's PID: in a shell that's `?bind=$$`, and `sparku push --bind` fills it in for you.
### Notify rate limit
`POST …/notify` (flash + line for a few seconds) can be called at most **once per 2 seconds per integration**. More frequent calls get `429 too_many_notices` with a `Retry-After` header. The limit is separate from writing values through `fields` — that's accepted without limits, just displayed no more than once per frame.
### Number history
For the graph (template "Graph") and trend, SparkU accumulates history for each numeric field: at most one point per second (a write within the same second replaces the last point), 120 points per field, in memory only — history is lost with values when SparkU restarts.
## Field types
> How fields are coerced from CLI strings and JSON, push examples, and 422 error texts.
### Coercion
`POST /v1/integrations/{slug}/fields` accepts a JSON object `{"key": value}`. Each value is coerced to the type you declared in the editor. If any value fails validation — the entire request is rejected (`422 invalid_fields`) with an error text for each field.
`sparku push` supports two assignment syntaxes:
- `field=value` — sends a **string**, the server coerces it to the field type;
- `field:=json` — sends the value **as is**, parsed as JSON
(`items:='[{"title":"a"}]'`, `done:=true`, `state:=null`).
`null` (via `field:=null`) clears a field without removing it from the schema.
### Type table
| Type | Value in `push` | Coercion from CLI string | Note |
|---|---|---|---|
| `text` | `"prod"` | taken as-is | truncated to length limit (default 120 chars), not rejected |
| `number` | `72` | `Double(string)` | unit, `min`/`max` for scales and rings; accumulated for graph history |
| `progress` | `0.7` | number 0…1, or `"70%"` | strictly 0…1 after coercion |
| `bool` | `true` | `true/false/yes/no/on/off/1/0` (case-insensitive) | for toggles and equalizer |
| `state` | `"failed"` | string, must be in the field's state list | each state has icon, color, animation, flash on transition |
| `time` | ISO 8601 or `+5m` | `+5m`, `-1h30m`, ISO 8601 | timers tick inside SparkU — no need to push every second |
| `image` | path or base64 (≤ 256 KB) | path `/…` or `~/…`, or base64 (can have `data:...,`) | PNG/JPEG only |
| `url` | `"https://…"` | string with scheme | for button actions; `javascript:` and `data:` rejected |
| `list` | `[{"title": "…"}]` | only `field:=json` | up to 50 rows |
### Push examples for each type
```bash
sparku push deploy title="Production deploy"
sparku push server cpu=37.5
sparku push build progress=70% # equivalent to progress=0.7
sparku push server ready=true
sparku push build stage=failed # value must be declared in the field
sparku push timer ends=+5m # 5 minutes from now
sparku push timer ends=2026-10-01T12:00:00Z # ISO 8601
sparku push now cover=~/cover.png
sparku push deploy link=https://ci.example.com/builds/482
sparku push queue items:='[{"title": "Build"}, {"title": "Tests", "state": "running"}]'
```
### Time format
One of the following is accepted:
- **Relative offset from now**: `+5m`, `-1h30m`, `+90s`, `+2d`. Units are `d`, `h`, `m`, `s`, can be combined but only in decreasing order (`1h30m`, not `30m1h`). A single `time` field works as both a timer (`+5m` — countdown) and a stopwatch (`-10m` — elapsed time).
- **ISO 8601** with time and timezone (`2026-10-01T12:00:00Z`, with fractions of a second too).
- **UNIX time** — only as a number in `field:=1699999999`, not as a string in `field=…`.
### `list` — row objects
Each element is an object `{"title", "subtitle"?, "value"?, "state"?, "icon"?}` or just a string (becomes `title`). Only `title` is required. At most 50 elements per write.
```bash
sparku push queue items:='[
"Simple string",
{"title": "Build", "subtitle": "main", "state": "running"},
{"title": "Tests", "value": "42/50", "icon": "checkmark"}
]'
```
### `image`
- Absolute path (`/Users/…`) or path with `~` — file must exist and be PNG or JPEG.
- Or content in base64 (can have prefix `data:image/png;base64,` — it's discarded). Limit — 256 KB after decoding; larger is rejected.
### Error texts for 422 (from code)
The `422 invalid_fields` response contains `fields: [{field, message}]`, and the combined message is `field: message; field2: message2`. Examples of real texts:
```
progress: expected 0…1, got 70
progress: expected number 0…1, got "many"
cpu: expected number, got "abc"
ready: expected true or false, got "maybe"
stage: expected one of: queued, running, passed, failed; got "cancelled"
ends: expected time (ISO 8601, +5m, -1h30m, or UNIX time), got "tomorrow"
cover: file not found: /Users/me/cover.png
cover: only PNG and JPEG are supported
cover: base64 not parsed or image larger than 256 KB
link: expected URL with scheme (https://…), got "example.com"
link: scheme "javascript" is not supported
items: expected array, got object
items: at most 50 rows, got 63
items[0]: no title
title: expected string, got array
```
Error for an undeclared field:
```
field not declared in integration "deploy"; available: title, progress, stage
field not declared: integration "deploy" has no fields yet
```
Both texts appear in the API response and in the integration log in Settings.
## sparku CLI
> All sparku commands, their flags, token search order, and exit codes.
### Installation
Settings > Custom Integrations > "sparku" line > "Install…". Creates a symbolic link `/usr/local/bin/sparku` pointing to the utility inside `SparkU.app/Contents/Helpers/sparku`; the link updates with the app.
```bash
sparku version
sparku help
```
### Token
The token is searched in order until a non-empty value is found:
1. `--token `
2. `--token-file `
3. `SPARKU_TOKEN_` (address uppercase, hyphens → `_`)
4. `SPARKU_TOKEN`
5. `SPARKU_TOKEN_FILE` (path to file with token)
The socket address can be overridden by the `SPARKU_SOCKET` environment variable or the `--socket` flag (default `~/.sparku/run/api.sock`).
> [!NOTE]
> SparkU unavailability is checked **before** token check: `sparku push … || [ $?
> -eq 69 ]` in a script reliably catches "SparkU not running", even if the token is also not configured.
### push
Writes fields (upsert — partial update, old values of undeclared keys are left alone).
```
sparku push field=value … [field:=json …] [--ttl 5m] [--bind]
```
- `field=value` — string, server coerces it to field type;
- `field:=json` — value as-is, parsed as JSON;
- `--ttl` — TTL for this write: number of seconds or `5m` / `1h30m` (otherwise — default TTL from integration settings);
- `--bind` — values live as long as the script that called `sparku`: once it exits or crashes, they're cleared (see [binding to a process](/docs/api/concepts.md#binding-to-a-process)).
```bash
sparku push deploy progress=0.4 stage=build
sparku push deploy progress=70% --ttl 10m
sparku push deploy stage=build --bind
sparku push queue items:='[{"title": "Build"}]'
```
### end
Clears all values for an integration (like TTL expiration, but immediate).
```
sparku end
```
```bash
sparku end deploy
```
### notify
Flash indicator and line for a few seconds (at most once per 2 seconds per integration).
```
sparku notify ["text"] [--state value] [--color red] [--duration 4]
```
- text — positional argument, up to 120 characters (longer is truncated);
- `--state` — state field value (takes its label, icon, and color);
- `--color` — semantic color (`blue`, `green`, `orange`, `red`, `purple`, `pink`, `yellow`, `teal`, `indigo`, `gray`, `success`, `warning`, `error`);
- `--duration` — 1…10 seconds, default 4.
At least `text` or `--state` is required.
```bash
sparku notify deploy "Build failed" --state failed
sparku notify server "CPU 95%" --color red --duration 6
```
### glow
Coordinate glow: spots of light at given places of the island outline. Each call
replaces the whole frame.
```
sparku glow position:color[:intensity[:width]] … [--intensity 1.2] [--ttl 5]
sparku glow --off
```
- position — 0…1 left to right across the island, 0.5 is the middle under the notch;
- color — a semantic name (`blue`, `red`, `success`, …) or `#RRGGBB`;
- intensity — 0…1, default 1; width — half-width of the spot as a fraction of the
island width, default 0.08;
- `--intensity` — outline thickness and glow strength, 0.2…3;
- `--ttl` — how long a frame stays lit without the next one, default 5 seconds;
- `--off` — turn it off.
```bash
sparku glow deploy 0.2:green 0.5:orange:0.6 0.8:#ff3b30
sparku glow build 0.5:blue:1:0.5 --ttl 1h # a wide band for an hour
sparku glow deploy --off
```
For visualizations with dozens of frames per second, send `POST …/glow` directly over
the socket — see [HTTP API](/docs/api/http.md).
### ask
Ask a question on the Island. Waits for answer (connection stays open until answered or `--timeout` expires).
```
sparku ask "question" [-o id:label …] [--multi] [--custom]
[--accept Yes --decline No --comment] [--title …]
[--timeout 10m] [--attach file]
```
- `-o id:label` (repeatable) — answer option; without `-o` and without `--custom` the question becomes a confirmation with two buttons;
- `--multi` — can select multiple options (requires ≥ 2 options);
- `--custom` — adds a free-form answer field;
- `--accept` / `--decline` — button labels (default "Yes" / "No");
- `--comment` — allow a comment on decline;
- `--title` — question title;
- `--timeout` — 5 seconds to 1 day, default 10 minutes; format — number of seconds or `90s` / `10m` / `2h`;
- `--attach` — absolute or relative path to a file that can be opened from the question.
Answer is printed to stdout: selected options — id one per line, custom answer — as text, decline reason (`--comment`) — as text too.
```bash
if sparku ask deploy "Deploy?"; then ./deploy.sh; fi
env=$(sparku ask deploy "Where?" -o prod:Prod -o stage:Staging)
sparku ask deploy "Delete branch?" --accept Delete --decline Keep --comment
```
An integration can have at most one unanswered question at a time — a second `ask` gets `409 dialog_pending`.
### listen
Prints taps on card controls.
```
sparku listen [--exec "command"] [--reconnect]
```
- without `--exec` — each event as NDJSON line by line to stdout (good for `jq`, `while read`);
- with `--exec` — runs a command on each event; data arrives **only** through the environment (`SPARKU_SLUG`, `SPARKU_CONTROL`, `SPARKU_VALUE`, `SPARKU_EVENT`), nothing is substituted into the command text;
- `--reconnect` — reconnect if SparkU closes the connection (e.g., restarts), instead of exiting with error.
```bash
sparku listen deploy --exec './on-click.sh' # $SPARKU_CONTROL, $SPARKU_VALUE
sparku listen deploy --reconnect | jq .
```
### import
Offers an integration from a `.sparku` file to you and waits for consent in SparkU Settings.
```
sparku import
```
On consent, prints to stdout `export SPARKU_TOKEN_=…`, so an installer can do:
```bash
eval "$(sparku import deploy.sparku)"
```
User decline — exit code `77`.
### doctor
Checks the environment step by step: socket, SparkU version, (if address is given) token, and integration existence.
```
sparku doctor [address]
```
```bash
sparku doctor
sparku doctor deploy
```
### openapi
The API description in OpenAPI 3.1 (JSON) — for client generators and AI agents.
```
sparku openapi [address]
```
Without an address — the general schema of all `/v1` routes; it doesn't need SparkU and is also on the site: [/openapi.json](/openapi.json). With an address — the schema of that integration: the `push` body lists its fields with types, bounds and state values, so a generated client catches a typo in a field name.
```bash
sparku openapi deploy > deploy.openapi.json
```
### version / help
```
sparku version
sparku help
```
### Exit codes
#### `push`, `end`, `doctor`, `notify`, `listen`, `import`
Codes from `` to not overlap with `ask` codes:
| Code | Meaning | When |
|---|---|---|
| `0` | success | |
| `64` | invalid arguments (`EX_USAGE`) | flag typo, missing required argument |
| `65` | values failed validation (`EX_DATAERR`) | HTTP 400, 411, 413, 422 |
| `67` | integration with this address not found (`EX_NOUSER`) | HTTP 404 |
| `69` | SparkU unavailable or API disabled (`EX_UNAVAILABLE`) | no socket, HTTP 409, 503 |
| `70` | SparkU internal error (`EX_SOFTWARE`) | HTTP 5xx |
| `75` | too frequent, retry later (`EX_TEMPFAIL`) | HTTP 429 (`notify` more than once per 2s) |
| `76` | unexpected response (`EX_PROTOCOL`) | CLI and app versions don't match |
| `77` | no token or wrong token (`EX_NOPERM`) | HTTP 401, 403; `sparku import` user decline |
`69` is checked first: a script can reliably distinguish "SparkU not running" from any other error, even if the token is also not configured.
#### `ask`
Separate scheme — doesn't overlap with ``, so shell `if sparku ask deploy "Deploy?"; then …` works:
| Code | Meaning |
|---|---|
| `0` | answer received (choice or custom text — to stdout) |
| `1` | declined |
| `2` | timeout, question removed from Island ("Hide"), or SparkU closed |
`409 dialog_pending` (integration already has an unanswered question) is returned as code `69`, as are all other unavailability errors.
## HTTP API
> Transport, authorization, and complete list of /v1 routes — request, body, response, error codes.
> [!TIP]
> A machine-readable description of all routes is at [/openapi.json](/openapi.json) (OpenAPI 3.1).
> [`sparku openapi `](/docs/api/cli.md#openapi) prints the schema with one integration's fields.
### Transport
SparkU listens to HTTP/1.1 on a UNIX socket `~/.sparku/run/api.sock`. The socket exists
only while "Custom Integrations" is enabled in Settings and SparkU is running — if the file
doesn't exist, the feature is disabled or the app is not running (see [If It Doesn't Work](/docs/api/troubleshooting.md)).
Socket permissions are `0700` for the directory and `0600` for the file: access only to the same
user. There is no TCP port, so requests can only be sent from the same Mac.
```bash
curl --unix-socket ~/.sparku/run/api.sock http://sparku/v1/health
```
The host in the URL (`http://sparku/...`) does not resolve — the connection still goes through the
socket; the name is only needed for `curl` to assemble a correct request.
Request and response bodies are JSON (`Content-Type: application/json; charset=utf-8`).
Body encoding is `Content-Length`; `Transfer-Encoding: chunked` is not supported.
### Authorization
All integration routes (`/v1/integrations/{slug}/...`) require a header:
```
Authorization: Bearer
```
The token is issued when an integration is created (Settings → Custom Integrations →
`slug` → Connection) or when importing `.sparku` ([.sparku Files and Import](/docs/api/packages.md)). Without
the header — `401 unauthorized`; with a wrong or revoked token — `401 invalid_token`.
`GET /v1/health` and `POST /v1/imports` don't require a token: the former has nothing to protect, and
the latter doesn't yet know which integration the request belongs to — the integration doesn't exist yet.
### Routes
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/v1/health` | protocol and app version |
| `GET` | `/v1/integrations/{slug}` | schema of fields and current values |
| `POST` | `/v1/integrations/{slug}/fields` | partial write values (upsert) |
| `DELETE` | `/v1/integrations/{slug}/fields` | clear all values |
| `POST` | `/v1/integrations/{slug}/notify` | flash and text for a few seconds |
| `POST` | `/v1/integrations/{slug}/glow` | light spots at given places of the outline |
| `DELETE` | `/v1/integrations/{slug}/glow` | turn the spots off |
| `POST` | `/v1/integrations/{slug}/dialogs` | question to user — [Questions to User](/docs/api/dialogs.md) |
| `GET` | `/v1/integrations/{slug}/events` | stream of taps (SSE) — [Buttons and Events](/docs/api/events.md) |
| `POST` | `/v1/imports` | import `.sparku` — [.sparku Files and Import](/docs/api/packages.md) |
A method that the route doesn't support responds `405 method_not_allowed` with
an `Allow` header. Unknown path under `/v1` — `404 not_found`.
#### `GET /v1/health`
No token required. Doesn't check that the integration exists — just "SparkU is here".
```bash
curl --unix-socket ~/.sparku/run/api.sock http://sparku/v1/health
```
```json
{"status": "ok", "api_version": 1, "app_version": "2.4.0"}
```
#### `GET /v1/integrations/{slug}`
Schema of the integration's fields and current values (if they haven't expired by TTL).
```bash
curl --unix-socket ~/.sparku/run/api.sock \
-H "Authorization: Bearer $SPARKU_TOKEN_DEPLOY" \
http://sparku/v1/integrations/deploy
```
```json
{
"slug": "deploy",
"name": "Deploy",
"enabled": true,
"fields": [
{"key": "progress", "type": "progress", "label": "Progress"},
{"key": "stage", "type": "state", "label": "Stage", "states": ["running", "done", "failed"]},
{"key": "title", "type": "text", "label": "Service", "max_length": 60}
],
"values": {"progress": 0.4, "stage": "running", "title": "api · prod"},
"updated_at": "2026-09-28T10:15:00Z",
"expires_at": "2026-09-28T11:15:00Z"
}
```
`expires_at` is absent from the response if values have no TTL. If values don't exist yet or have expired,
`values` is an empty object, and `updated_at`/`expires_at` are absent.
Codes: `200`, `404 unknown_integration`, `401`.
#### `POST /v1/integrations/{slug}/fields`
Partial write values — upsert: listed fields change, others are left alone. `null` for a field clears it.
Write is **all or nothing**: if any field fails validation, none are applied.
```bash
curl --unix-socket ~/.sparku/run/api.sock \
-H "Authorization: Bearer $SPARKU_TOKEN_DEPLOY" \
-H "Content-Type: application/json" \
-d '{"progress": 0.72, "stage": "running", "title": "api · prod"}' \
http://sparku/v1/integrations/deploy/fields
```
```json
{"ok": true, "updated": ["progress", "stage", "title"], "cleared": []}
```
Value types in the body — see [Field types](/docs/api/fields.md). Body `{"progress": null}`
goes into `cleared`, not `updated`.
**`?ttl=`** — how long to remember values from this write: number of seconds (`?ttl=90`)
or relative time (`?ttl=5m`, `?ttl=1h30m`), no more than 7 days. Without the parameter,
the TTL from the integration settings applies (default — "until SparkU restart").
The countdown resets for each write. If TTL is set, the response also contains `expires_at`:
```json
{"ok": true, "updated": ["progress"], "cleared": [], "expires_at": "2026-09-28T11:20:00Z"}
```
**`?bind=`** — bind values to a process: `process` (the one that sent the request),
a PID (`?bind=$$` from a shell) or `none` (remove the binding). When the process exits,
the values are cleared. A bound response contains `bound_pid`. See
[binding to a process](/docs/api/concepts.md#binding-to-a-process).
Codes: `200`, `400 empty_body`, `400 invalid_json`, `400 invalid_ttl`, `400 invalid_bind`,
`409 integration_disabled`, `422 invalid_fields`, `401`, `404`.
`422 invalid_fields` lists each invalid field:
```json
{"error": {"code": "invalid_fields", "message": "progress: expected 0…1, got 70",
"fields": [{"field": "progress", "message": "progress: expected 0…1, got 70"}]}}
```
#### `DELETE /v1/integrations/{slug}/fields`
Clears all values for the integration — equivalent to `sparku end`. Response is `204 No
Content`, no body.
```bash
curl --unix-socket ~/.sparku/run/api.sock -X DELETE \
-H "Authorization: Bearer $SPARKU_TOKEN_DEPLOY" \
http://sparku/v1/integrations/deploy/fields
```
Codes: `204`, `401`, `404`. Works even for disabled integrations.
#### `POST /v1/integrations/{slug}/notify`
Flash on the bezel and/or switch the text for a few seconds. At least one of `text` or `state` is required.
```bash
curl --unix-socket ~/.sparku/run/api.sock \
-H "Authorization: Bearer $SPARKU_TOKEN_DEPLOY" \
-d '{"text": "Build failed", "state": "failed", "color": "red", "duration": 4}' \
http://sparku/v1/integrations/deploy/notify
```
| Field | Type | Rule |
|---|---|---|
| `text` | string | truncated to 120 characters (with `…`) |
| `state` | string | value of any field with type `state` in the integration |
| `color` | string | one of semantic names (`blue`, `red`, `success`, …) |
| `duration` | number | 1…10 seconds, default 4 |
```json
{"ok": true}
```
No more than once per 2 seconds per integration: a call sooner than that returns `429
too_many_notices` with a `Retry-After` header (seconds until the next call is allowed).
Codes: `200`, `400 invalid_json`, `409 integration_disabled`, `409 notify_disabled`
(notify is disabled in the integration settings), `422 invalid_notice`, `429
too_many_notices`, `401`, `404`.
#### `POST /v1/integrations/{slug}/glow`
Coordinate glow: light appears at given places of the island outline and stays there
until the next frame arrives. Useful for audio visualization, several parallel tasks
“by section”, and meters.
```bash
curl --unix-socket ~/.sparku/run/api.sock \
-H "Authorization: Bearer $SPARKU_TOKEN_DEPLOY" \
-d '{"spots": [{"position": 0.2, "color": "green"},
{"position": 0.8, "color": "#FF3B30", "intensity": 0.6, "width": 0.15}]}' \
"http://sparku/v1/integrations/deploy/glow?ttl=10"
```
| Field | Type | Rule |
|---|---|---|
| `spots` | array | required, at most 64 spots; an empty array means dark |
| `spots[].position` | number | 0…1 left to right across the island, 0.5 is the middle under the notch |
| `spots[].color` | string | a semantic name (`blue`, `red`, `success`, …) or `#RRGGBB` |
| `spots[].intensity` | number | 0…1, default 1 |
| `spots[].width` | number | half-width of the spot as a fraction of the island width, 0.005…1, default 0.08 |
| `intensity` | number | outline thickness and glow strength, 0.2…3, default 1 |
```json
{"ok": true}
```
- Each request replaces the whole frame. Overlapping spots blend their colors.
- No rate limit: send frames even 60 times a second, the island shows at most ~30.
- `?ttl=` — how long a frame stays lit without the next one: seconds or `5m` /
`1h30m`, default 5 seconds. If the script dies, the glow fades by itself.
- While a flash or status of another widget is on, it takes the shape of your spots:
its colors, brightness along the outline from the spots (at least 30%). Between
integrations with spots, the integration priority decides.
- `DELETE …/glow` and `sparku end` turn the spots off immediately.
Codes: `200`, `400 invalid_ttl`, `409 integration_disabled`, `422 invalid_glow`, `401`,
`404`.
#### `POST /v1/integrations/{slug}/dialogs`
Question to the user on the island. Details — [Questions to User](/docs/api/dialogs.md).
#### `GET /v1/integrations/{slug}/events`
Stream of taps in the card (SSE). Details — [Buttons and Events](/docs/api/events.md).
#### `POST /v1/imports`
Import `.sparku`. Details — [.sparku Files and Import](/docs/api/packages.md).
### Unified error format
Any error is JSON of the form:
```json
{"error": {"code": "invalid_fields", "message": "human-readable text"}}
```
`message` is written for humans and assumes the client is usually a shell script: the text
can be printed to the terminal as-is. Some errors add fields to the `error` object (for example `fields` for `invalid_fields`).
### Error codes
| Code | HTTP | Where |
|---|---|---|
| `not_found` | 404 | path not under `/v1` or doesn't exist |
| `unknown_integration` | 404 | no integration with that `slug` |
| `unauthorized` | 401 | no `Authorization: Bearer …` header |
| `invalid_token` | 401 | token doesn't belong to this integration |
| `method_not_allowed` | 405 | method not supported on this path |
| `empty_body` | 400 | request body is empty (`fields`) |
| `invalid_json` | 400 | body is not a JSON object |
| `invalid_ttl` | 400 | can't parse `?ttl=` |
| `invalid_bind` | 400 | `?bind=` is not `process`, `none` or the PID of a running process |
| `invalid_fields` | 422 | value(s) failed type validation |
| `invalid_notice` | 422 | `notify`: wrong `text`/`state`/`color`/`duration` |
| `invalid_glow` | 422 | `glow`: wrong spots, color or intensity |
| `invalid_dialog` | 422 | `dialogs`: can't parse question body |
| `invalid_package` | 422 | `imports`: can't parse `.sparku` file |
| `integration_disabled` | 409 | integration is disabled in Settings |
| `notify_disabled` | 409 | notify is disabled in the integration settings |
| `dialog_pending` | 409 | integration already has an unanswered question |
| `import_pending` | 409 | SparkU is already offering another import to the user |
| `import_declined` | 403 | user declined the offered integration |
| `too_many_notices` | 429 | `notify` more than once per 2 seconds |
| `too_many_connections` | 503 | more than 64 concurrent connections |
| `unavailable` | 503 | SparkU is not yet ready to receive requests |
| `withdrawn` | 503 | SparkU is closing or API was disabled while waiting |
| `bad_request` | 400 / 411 / 413 / 431 / 505 | HTTP violation: bad headers, no `Content-Length`, body over 1 MB, headers too large, unsupported version |
> [!NOTE]
> `bad_request` is a unified code for HTTP parsing errors; see the `message` and HTTP status
> for the specific reason.
### Coalescing and limits
- **`fields`** accepts requests without rate limiting, but updates to the island appear
no more than once per frame — a fast `push` cycle doesn't flicker intermediate values.
- **`notify`** — no more than once per 2 seconds per integration (`429` if exceeded).
- **`glow`** — no rate limit, the island shows at most ~30 frames per second.
- **Dialog** — no more than one unanswered question per integration (`409
dialog_pending`).
- **Import** — no more than one offer per entire app at a time (`409
import_pending`).
- **Connections** — no more than 64 concurrent per entire server (`503
too_many_connections`).
- **Request body** — no more than 1 MB (limit accounts for the `image` field in base64).
- **Number history** for chart and trend — no more than one point per second, 120 points per
field, memory-only in the SparkU process.
### Compatibility of `/v1`
Inside `/v1`, changes are only additive: new routes and new response fields, but
no changes to existing behavior. A script written today will continue to work after
a SparkU update without modification.
## 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 [--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_` / `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 in uppercase), plus `SPARKU_SLUG`,
`SPARKU_CONTROL`, and `SPARKU_VALUE` for toggle/menu.
- **Run a Shortcuts command** — by name, no shell: `shortcuts run
`.
Listeners get a tap event regardless of which of these actions is configured — it
executes alongside sending the event to the stream.
## Questions to User
> POST …/dialogs and sparku ask — options, confirmation, timeout, attachment; how an agent asks for permission.
### How it works
`POST /v1/integrations/{slug}/dialogs` shows a question on the island and keeps the
connection open until the user answers (or the question is withdrawn). The answer comes in the
body of the same HTTP response — `curl` or an HTTP client that doesn't close the
connection early needs to wait for `timeout` from the request body.
An integration can have at most one unanswered question at a time: a second `dialogs`
before an answer to the first gets `409 dialog_pending`. Dialogs must be explicitly
enabled in the integration settings.
### Request body
A question with options — has `options` or `custom`:
```json
{
"message": "Deploy to prod?",
"title": "Deploy",
"options": ["yes", {"id": "no", "label": "Cancel", "detail": "do nothing"}],
"multiple": false,
"custom": false,
"timeout": "10m"
}
```
Confirmation — no `options` or `custom`:
```json
{"message": "Delete branch feature/x?", "accept": "Delete", "decline": "Keep", "comment": true}
```
| Field | Type | Default | Rule |
|---|---|---|---|
| `message` | string | — | required, up to 600 characters |
| `title` | string | integration address | up to 60 characters |
| `options` | array of strings or `{id, label, detail?}` | — | up to 9 options; string is both id and label |
| `multiple` | bool | `false` | select multiple — requires ≥ 2 options |
| `custom` | bool | `false` | add free-text answer field |
| `accept` / `decline` | string | "Yes" / "No" | confirmation button labels, up to 30 characters |
| `comment` | bool | `false` | allow comment on decline |
| `timeout` | number of seconds or `"90s"` / `"10m"` / `"2h"` | `600` (10 min) | 5 seconds to 1 day |
| `attachment` | absolute path | — | file the user can open from the question; must exist |
With `options` or `custom` — a question with options; otherwise — a confirmation with two
buttons. You can't specify both `options`/`custom` and `accept`/`decline` — `accept`/`decline` apply only to confirmation.
### Answer
Always `200 OK`; the response body depends on the outcome:
| Situation | Body |
|---|---|
| selected option(s) | `{"answer": "chose", "options": ["yes"]}` |
| custom answer | `{"answer": "custom", "text": "…"}` |
| accepted | `{"answer": "accepted"}` |
| declined | `{"answer": "declined"}` or `{"answer": "declined", "comment": "…"}` |
| timeout expired | `{"answer": "expired"}` |
| question withdrawn | `{"answer": "withdrawn"}` |
`withdrawn` is returned if: the client disconnects before answering (though then there's
no one to answer — the question is just removed from the island), SparkU closes, the API is disabled,
or the integration is disabled/deleted while a question is waiting for an answer.
To hide a question with the "Hide" button only works for an integration with a card — you can
bring it back with the "Reply" button in the card; until then it remains unanswered on the server.
### Example: curl
```bash
curl --unix-socket ~/.sparku/run/api.sock \
-H "Authorization: Bearer $SPARKU_TOKEN_DEPLOY" \
-d '{"message": "Deploy to prod?", "accept": "Yes", "decline": "No"}' \
http://sparku/v1/integrations/deploy/dialogs
```
```json
{"answer": "accepted"}
```
### `sparku ask`
```
sparku ask "question" [-o id:label …] [--multi] [--custom]
[--accept Yes --decline No --comment] [--title …] [--timeout 10m] [--attach file]
```
```bash
sparku ask deploy "Deploy to prod?" -o yes:Yes -o no:No
sparku ask deploy "Where to deploy?" -o prod:Prod -o staging:Staging --custom
sparku ask deploy "Delete branch?" --accept Delete --decline Keep --comment
```
Selected options are printed to stdout one per line (id, not label), custom answer as text, and
the decline comment also to stdout.
#### Exit codes for `ask`
Separate from `` used by `push`/`end`/`doctor` — so `if sparku ask deploy "Deploy?"; then …` works:
| Code | Meaning |
|---|---|
| `0` | answer received (choice or custom text — in stdout) |
| `1` | declined |
| `2` | timeout expired, question withdrawn, or "Hide", or SparkU closed |
Transport errors (no socket, invalid token, `409 dialog_pending`) return regular `sparku` codes —
`69`, `77`, `69` respectively, see [sparku CLI](/docs/api/cli.md#exit-codes).
```bash
if sparku ask deploy "Deploy to prod?"; then
./deploy.sh
else
echo "deploy cancelled"
fi
```
### For AI agents
An agent like Claude Code or Codex that runs commands in a shell can ask for permission the same
way a CI script does: through `sparku ask` to an integration the user set up beforehand (for example,
"Agent" with the slug `agent`; if it has a card, the question can be hidden and answered later). This is not a separate protocol — an agent is just another API client,
like any other process.
```bash
if sparku ask agent "Delete branch feature/old and close its PR on GitHub?" \
--accept Delete --decline "Don't touch" --timeout 90s; then
git push origin --delete feature/old
gh pr close 42
else
echo "cancelled by user" >&2
exit 1
fi
```
While a question is on the island, the agent blocks on the `sparku ask` call — this is expected:
the user makes the decision, and the dangerous step can't run until they answer.
If the user doesn't answer before `--timeout`, default 10 minutes, `ask` returns `2`, and the
agent should treat it as a refusal, not as permission to proceed.
> [!TIP]
> The tool an agent runs commands with usually has its own timeout (2 minutes by default in
> Claude Code). Keep `--timeout` below it, otherwise the agent stops waiting before the user
> can answer, and the question is withdrawn from the island.
An instruction for the agent (in `CLAUDE.md`, `AGENTS.md`, etc.) can read:
```text
Before irreversible actions (deleting, force-pushing, deploying, migrations), ask
the user: sparku ask agent "" --accept Yes --decline No --timeout 90s
Exit 0 — go ahead, 1 — declined, 2 — no answer: treat it as declined.
```
## .sparku Files and Import
> .sparku format, what it doesn't include, POST /v1/imports and sparku import, user consent, risks.
### .sparku format
An integration export is its schema without secrets: name, address, fields, island and card
styling, control elements. The file is plain JSON:
```json
{
"format": "sparku.integration",
"version": 1,
"integration": {
"slug": "deploy",
"name": "Deploy",
"icon": "shippingbox.fill",
"accent": "blue",
"fields": [
{"key": "progress", "type": "progress", "label": "Progress"},
{"key": "stage", "type": "state", "label": "Stage", "states": [
{"id": "running", "label": "Running", "icon": "arrow.triangle.2.circlepath", "color": "blue"},
{"id": "done", "label": "Done", "icon": "checkmark.circle.fill", "color": "success"},
{"id": "failed", "label": "Failed", "icon": "xmark.octagon.fill", "color": "error"}
]},
{"key": "title", "type": "text", "label": "Service", "maxLength": 60}
],
"valueLifetime": null,
"island": {"...": "indicator and text settings"},
"card": {"...": "template, slots, control elements"}
}
}
```
**Not included in the file:** `id`, `createdAt`, `isEnabled`, and of course the token — an
imported integration always gets a new one. Only the user creates the schema: the file carries
what they've already configured, not a script's right to build the interface itself (see
[Concepts](/docs/api/concepts.md)).
File size limit is 512 KB: an integration is dozens of fields and templates, not megabytes.
A file saved by a newer SparkU version (`version` higher than supported) is rejected with a
request to update the app.
### Export
A button in the integration editor's header (Settings → Custom Integrations → `slug`) saves
the file `.sparku` (name is the integration name, transliterated to address).
### Import in Settings
"+" → "Import from file…" opens a `.sparku` from disk. SparkU shows a consent screen: who is
offering the integration, what will appear on the island and in the card, and separately — which
control elements open URLs, run commands, or launch Shortcuts. A taken address (`slug`) is renamed to an available one —
`deploy` → `deploy-2`.
Double-clicking a `.sparku` file in Finder opens the same screen — SparkU is registered as
the owner of the file type. If Custom Integrations are turned off, SparkU opens the
Integrations section and asks you to turn them on first.
### `POST /v1/imports`
Same endpoint, but initiated by a script or installer: the request body is the contents of
the `.sparku` file as-is (not a JSON wrapper, the file itself). No token needed — the
integration doesn't exist yet.
```bash
curl --unix-socket ~/.sparku/run/api.sock \
--data-binary @deploy.sparku \
http://sparku/v1/imports
```
The request **blocks** until the user opens Settings and answers the offer — SparkU opens that
section itself. Consent:
```json
{"slug": "deploy", "name": "Deploy", "token": "spk_9f2c...a31b"}
```
Status `201 Created`. Refusal — `403 import_declined`. While another offer is already waiting
for an answer (from settings or another request), a new `POST /v1/imports` gets `409 import_pending` immediately, without waiting.
If SparkU closes or the API is disabled while a request is waiting — `503 withdrawn`.
A file that fails schema validation (as in the editor) — `422 invalid_package`.
### `sparku import`
```bash
sparku import deploy.sparku
```
Waits for consent just like a direct `POST /v1/imports`, and prints to stdout the token export line:
```
export SPARKU_TOKEN_DEPLOY=spk_9f2c...a31b
```
So an installer script does:
```bash
eval "$(sparku import deploy.sparku)"
sparku push deploy progress=0
```
User refusal — exit code `77` (permission denied, like invalid token); in all other cases
codes are regular `sparku`, see [sparku CLI](/docs/api/cli.md#exit-codes).
### Risks
Importing `.sparku` is trust in someone else's configuration, not their code: the file itself
is not executed, but control elements in it can be set to **run commands** on this Mac when
tapped in the card (action "Run command" or "Launch Shortcut"). The import screen shows such
elements explicitly — with the command text or Shortcuts name — before the user agrees.
Read them as carefully as you would a command line from an email.
Control elements with "send event to listener" action are safe in themselves: they only
report a tap to whoever ran `sparku listen` — they can't execute anything without a separate
listener.
> [!WARNING]
> Don't run `sparku import` and don't open `.sparku` files from sources you don't trust,
> even if the import screen showed no risks: URLs in "Open" elements are also worth checking
> before you tap the button on the island.
### Response codes
| Code | HTTP | When |
|---|---|---|
| — | 201 | user agreed, integration added |
| `import_declined` | 403 | user refused the offer |
| `import_pending` | 409 | SparkU is already waiting for an answer to another offer |
| `invalid_package` | 422 | file is not in `.sparku` format or fails schema validation |
| `withdrawn` | 503 | SparkU closed or API was disabled while waiting |
## Recipes
> Ready-made examples — deploy progress, Xcode build, server monitoring, timer, task queue, agent confirmation.
Each recipe shows what integration you need (fields and card template, see
[Field types](/docs/api/fields.md)) and working code. Tokens everywhere are read from
`SPARKU_TOKEN_` — see [sparku CLI](/docs/api/cli.md#token).
### 1. Deploy progress in CI with flash on error
**Integration:** `deploy`, card template "Progress". Fields: `progress`
(`progress`), `stage` (`state`: `running`/`done`/`failed`), `title` (`text`).
```bash
#!/bin/bash
# deploy.sh — run in CI
set -euo pipefail
sparku push deploy title="api · prod" stage=running progress=0
total_steps=5
for step in $(seq 1 $total_steps); do
./run-step.sh "$step" || {
sparku push deploy stage=failed
sparku notify deploy "Deploy failed at step $step" --state failed --color red
exit 1
}
sparku push deploy progress="$(( step * 100 / total_steps ))%"
done
sparku push deploy stage=done progress=100%
sparku notify deploy "Deploy ready" --state done
```
### 2. Xcode build / `swift build`
**Integration:** `build`, template "Status". Fields: `phase` (`state`:
`building`/`testing`/`passed`/`failed`), `title` (`text`).
```bash
#!/bin/bash
# build.sh
sparku push build title="SparkU" phase=building
if ! xcodebuild -scheme SparkU -destination 'platform=macOS' build 2>build.log; then
sparku push build phase=failed
sparku notify build "Build failed" --state failed --color red
exit 1
fi
sparku push build phase=testing
if ! xcodebuild -scheme SparkU -destination 'platform=macOS' test 2>test.log; then
sparku push build phase=failed
sparku notify build "Tests failed" --state failed --color red
exit 1
fi
sparku push build phase=passed
sparku notify build "Build and tests green" --state passed
```
### 3. Server monitoring on Python every N seconds
**Integration:** `server`, template "Meters". Fields: `cpu` (`number`, 0–100, `%`),
`memory` (`number`, 0–100, `%`), `latency` (`number`, ms).
```python
#!/usr/bin/env python3
import http.client, json, os, socket, time
TOKEN = os.environ["SPARKU_TOKEN_SERVER"]
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"))
def push(fields):
api = SparkU()
api.request("POST", "/v1/integrations/server/fields", json.dumps(fields), {
"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json",
})
response = api.getresponse()
response.read()
def read_metrics():
# replace with real metric collection (psutil, /proc, ssh to prod, etc.)
import random
return {"cpu": random.uniform(10, 90), "memory": random.uniform(30, 80), "latency": random.uniform(40, 300)}
while True:
push(read_metrics())
time.sleep(5)
```
Run in background: `nohup python3 monitor.py &` or via `launchd`/systemd — the socket
survives script restarts; the next `push` will just restore values.
### 4. Timer / countdown with one push
**Integration:** `timer`, template "Timer". Field: `until` (`time`).
The timer ticks inside SparkU — no need to repeat `push` every second, just one call with
the end time:
```bash
# 25-minute countdown (Pomodoro)
sparku push timer until=+25m
# specific time in ISO 8601
sparku push timer until=2026-09-28T15:00:00Z
```
Cancel the countdown — `sparku end timer`.
### 5. Task queue (list)
**Integration:** `queue`, template "List". Field: `tasks` (`list`).
```bash
#!/bin/bash
# queue.sh — update build task list
tasks_json() {
cat <<'JSON'
[
{"title": "iOS build", "subtitle": "step 3 of 5", "value": "60%", "state": "running"},
{"title": "API tests", "subtitle": "412 of 420", "value": "98%", "state": "done"},
{"title": "DB migration", "subtitle": "staging", "state": "failed"},
{"title": "Land deploy", "subtitle": "queued"}
]
JSON
}
sparku push queue "tasks:=$(tasks_json)"
```
`tasks:=...` — the colon before `=` in `sparku push` tells the CLI to parse the value as
JSON, not a string (see [sparku CLI](/docs/api/cli.md)).
### 6. Agent asks before a dangerous command
**Integration:** `agent`, dialogs enabled in settings, card is optional.
```bash
#!/bin/bash
# cleanup-branch.sh — called by an AI agent (Claude Code, Codex, etc.) from shell
set -euo pipefail
if sparku ask agent "Delete branch feature/old and close PR #42?" \
--accept Delete --decline "Don't touch" --timeout 5m; then
git push origin --delete feature/old
gh pr close 42
else
echo "cancelled by user" >&2
exit 1
fi
```
Details on `ask` exit codes and the "For AI agents" section — [Questions to User](/docs/api/dialogs.md).
### 7. Wrapper `if sparku ask …` for any command
**Integration:** same `agent` as in recipe 6 — one integration for all confirmations in the terminal.
```bash
#!/bin/bash
# confirm.sh — wrapper: ask and run any command
# usage: ./confirm.sh "Delete node_modules?" rm -rf node_modules
question=$1; shift
if sparku ask agent "$question"; then
"$@"
else
echo "cancelled" >&2
exit 1
fi
```
```bash
./confirm.sh "Restart prod server?" ssh prod 'sudo systemctl restart api'
```
## If It Doesn't Work
> sparku doctor, typical errors — from missing socket to 409, request log and "Test" console.
### Start with `sparku doctor`
```bash
sparku doctor
sparku doctor deploy # plus check token and schema for a specific integration
```
Steps through and prints `✓`/`✗` on each: socket, `/v1/health`, CLI version against
app version, and with an integration address — the token source and its fields.
Stops on the first problem and returns the corresponding exit code
([sparku CLI](/docs/api/cli.md#exit-codes)).
```
sparku 2.4.0
✓ socket: /Users/you/.sparku/run/api.sock
✓ SparkU 2.4.0, API v1
✓ token for "deploy": $SPARKU_TOKEN_DEPLOY
✓ integration "Deploy": progress (progress), stage (state), title (text)
```
### Socket doesn't exist
```
✗ socket doesn't exist: /Users/you/.sparku/run/api.sock
```
One of two things:
- **SparkU is not running.** Start the app.
- **"Custom Integrations" is disabled.** Settings → Integrations → Custom Integrations →
"Enable…". The feature is experimental and disabled by default (see
[Overview](/docs/api/overview.md)).
Any request to a missing socket — code `69` (`sparku`) or a connection error
(`curl: (7) Failed to connect`, `ConnectionRefusedError` in Python). A script can reliably tell
"SparkU not running" from other errors: unavailability is checked first, before token and address.
### 401 — missing or wrong token
```json
{"error": {"code": "unauthorized", "message": "token required: Authorization: Bearer header (SparkU Settings → Custom Integrations → Connection)"}}
{"error": {"code": "invalid_token", "message": "token doesn't belong to integration \"deploy\" — it may have been reissued"}}
```
- `unauthorized` — no `Authorization` header at all. In `sparku` this means the token was not found in
`--token`, `--token-file`, `SPARKU_TOKEN_`, or `SPARKU_TOKEN` (lookup order —
[sparku CLI](/docs/api/cli.md#token)).
- `invalid_token` — token exists but doesn't match: typo, wrong token, or it was reissued in Settings
(for example after a suspected leak). Get the current token there: `slug` → Connection.
`sparku` exits with `77` in both cases.
### 404 — integration doesn't exist
```json
{"error": {"code": "unknown_integration", "message": "integration \"deploy\" doesn't exist; create it in SparkU Settings → Custom Integrations"}}
```
Typo in the address or the integration hasn't been created by the user yet — the API never
creates integrations itself ([Concepts](/docs/api/concepts.md)). `sparku` — code `67`.
### 422 — value failed validation
```json
{"error": {"code": "invalid_fields", "message": "progress: expected 0…1, got 70",
"fields": [{"field": "progress", "message": "progress: expected 0…1, got 70"}]}}
```
The error text is readable in the terminal as-is — which field and what's wrong with it. Write is
**all or nothing**: if any field in the request fails validation, none are applied, even if others
were correct. Check the field's type and range against the schema — `GET /v1/integrations/{slug}` or
the "Fields" section in the editor. `sparku` — code `65`.
### 429 — `notify` too frequent
```json
{"error": {"code": "too_many_notices", "message": "notify no more than once per 2s"}}
```
The response header `Retry-After` says how long to wait. This is not a data error — the limit
protects against a "strobe" if `notify` runs in a loop. `sparku` — code `75`; a script can catch
just `75` to tell frequent `notify` apart from other issues.
### 409 — state conflict
| Code | Reason | Fix |
|---|---|---|
| `integration_disabled` | integration is disabled in Settings | enable it in Custom Integrations |
| `notify_disabled` | `notify` is disabled in this integration's settings | enable in the "Island" section of the editor |
| `dialog_pending` | integration already has an unanswered question | wait for the answer or withdrawal |
| `import_pending` | SparkU is already offering another import to the user | wait for its answer |
All these codes are temporary state, not a request error: `sparku` returns `69`
("SparkU unavailable") for the first two and the same for the rest — see the code table in
[HTTP API](/docs/api/http.md#error-codes).
### Integration request log
Settings → Custom Integrations → `slug` → "Log" section — the last 50 requests to
this integration: action (`push`/`end`/`notify`/`ask`/`listen`/`describe`), response code,
error text or user answer, and the client process (`python3`, `curl`, etc. by PID on the
socket). This is the first place to look when a script "seems to be sending something" but
nothing changes on the island: the log shows whether a request arrived and what the server
responded, without needing to debug the script.
### "Test" console in the editor
In the integration editor (the "Test" section), you can send values the same way `push` does,
clear them, call `notify`, and ask a test question — all without leaving the terminal. Useful
to separate "card is misconfigured" from "script is sending wrong data": if values from the
console display on the island correctly but values from the script don't, the problem is in
the script (address, token, field names, JSON format), not the integration itself.
### Other issues
- **Expired values.** If `GET /v1/integrations/{slug}` shows an empty
`values` but you definitely wrote values — check `expires_at` from the previous response:
the TTL (integration's `valueLifetime` or `?ttl=`) expired. `push` is upsert; the next
write will restore them.
- **CLI and app version mismatch.** `sparku doctor` prints a warning
if `sparku` in `/usr/local/bin` links to a different copy of SparkU than the one running.
Refresh the symlink: Settings → Custom Integrations → "Command line".
- **`sparku listen` gets no events.** While the stream is not open, elements with "send event
to listener" action are inactive on the island itself — a tap doesn't go anywhere, no event
fires at all. Open `listen` first, then tap the button; see [Buttons and Events](/docs/api/events.md).