# Kestrel > One app for your cameras and recorders: live view, recordings, events and health, through a small server on your own network. # Overview Source: https://kestrel.timmo.dev/ Kestrel is one app for your cameras and recorders: live view, recordings, events and each one's health, from any browser or as an app on your phone. It talks to each system directly, so nothing goes through a vendor's cloud and old hardware stays useful after its own apps stop working. It starts with Raysharp-made recorders, which Swann, Lorex, Night Owl and others sell, and whose own apps and plugins have stopped working in modern browsers. Reolink cameras, a Bambu Lab printer's camera, any stream address and recording to your own disk are on the way, as [timmo001/kestrel#11](https://github.com/timmo001/kestrel/issues/11) sets out. A small server sits between your cameras and the page. Browsers can't open a recorder's control port or play its RTSP streams, so the server holds the sessions, passes on what they report, and runs go2rtc for the video. See [Running it](/running) to set it up. ## What it does - **Sources:** each source's connection and, for the recorder, its clock, its disks, and each camera's signal, recording mode and motion. - **Recordings:** which days have recordings, and each day's clips on a channel, with why they were recorded (motion, PIR, alarm or normal) and their size. The player is laid out as the recorder's own client lays out playback: the video, its controls (stop, a frame at a time, speed up to 32 times, the recordings either side, cut, a still, download, the same zoom as Live (fit, fill or a step at a time, only for the visit) and full screen, each with a key, listed under the help button), then a timeline of the day that zooms to ten minutes, where a click plays from that moment. Playback runs on from one recording into the next, any stretch of a day saves as an MP4, and the page remembers where it was, its speed and its timeline zoom in the browser. Up to four cameras play together on one clock, ticked in the camera picker: each one that recorded at that moment plays, the rest show a placeholder until their next recording, and the timeline has a track for each. They play the full stream while the recorder keeps up, about 14 times real time across them, and the sub stream past that. - **Pages:** Live, Recordings, Events and Sources each have an address of their own, such as `/recordings?camera=swann/1&date=2026-11-11`, or `/recordings?camera=swann/1&cameras=swann/1,swann/3,swann/4&date=2026-11-11` for several cameras together. A camera's id is its source's and its own, such as `swann/3` for the recorder's channel 3. Older addresses by channel number still work. - **Events:** motion, PIR, video loss and disk events as each source reports them, and today's motion and PIR clips found by search. - **Live view:** the cameras with a signal through go2rtc, all at once (A, shown as the count and All) or any number fewer at a time (from the sub stream past four), paged with Previous and Next (Page Up and Page Down) or picked from the page list, which names each camera when there's one at a time. All is the default. The arrow keys move the pick around the page and on to the next page past the edge. Each picture keeps its shape, fitted whole into its tile. The zoom controls act on the picked camera, and each camera keeps its own zoom, once in the tiles and once on its own (so one zoomed in its tile still shows whole alone). It zooms 25% at a time (+, - and 0 to fit it whole), with Fill, where the picture covers its tile, one of the steps, so Fit or Fill (Z) jumps between whole and covering. Ctrl and the wheel or a pinch zooms the one under the pointer, and a drag moves around any picture bigger than its tile. Pause closes the streams and holds the last frames; a double click or Enter shows the picked camera alone, and Esc or Backspace goes back. S saves a still of it, and ? shows or hides the keys. The layout, the picked camera, each camera's zoom and how many cameras there were are kept in the browser, so the loading placeholders already sit where the cameras will. ## Parts | Part | What it is | | ---------------------------- | ----------------------------------------------------------------- | | `packages/core` | What every source shares, in TypeScript with Effect | | `packages/protocol-raysharp` | The Raysharp recorders' control protocol | | `packages/source-raysharp` | Raysharp recorders as a source, over that protocol and their RTSP | | `packages/protocol-baichuan` | Reolink's own protocol, for replaying recorded files | | `packages/server` | The server: the [API](/api) and go2rtc | | `packages/web` | The web client, installable on a phone | | `packages/cli` | The `kestrel` CLI, with Code Mode and MCP for [agents](/agents) | | `docs` | This site | ## Machine-readable docs | URL | Use | | --------------------------------------- | ---------------------------------------- | | https://kestrel.timmo.dev/llms.txt | Compact page index | | https://kestrel.timmo.dev/llms-full.txt | Full docs bundle | | https://kestrel.timmo.dev/mcp | Hosted MCP (search, page and navigation) | | `/{route}.md` | One page as Markdown | --- # Agents Source: https://kestrel.timmo.dev/agents The `kestrel` CLI in `packages/cli` reads your cameras through the server's [API](/api). Every command takes `--json`. They only read from the sources; `download` also writes the file it saves. | Command | Returns | | ------------------------------------------------------ | ----------------------------------------------------------- | | `kestrel status` | What needs you, each source's connection and its state | | `kestrel details -s ` | A source's facts, clock, storage and each camera's state | | `kestrel sources` | Each source's settings and camera names, with no secrets | | `kestrel cameras` | Each camera's id, name and what it can do | | `kestrel recordings -c swann/1 -d ` | A camera's recordings on one day, today by default | | `kestrel download -c swann/1 -d --from … --to …` | Saves that stretch as an MP4, across recordings | | `kestrel days -m ` | The days with recordings in a month, this month by default | | `kestrel events` | The newest events | `-c` takes a camera's id, such as `swann/1`; while there's one source, its camera's own id, such as `1`, is enough. `details` shows the only source with details unless `-s` names one. `download` takes times of day on `--date`, such as `21:46` or `21:46:30`, or full ones such as `2026-11-11T21:46:30`. `--sub` saves the small sub stream, and `-o` names the file; by default it's named after the camera and start. It talks to `KESTREL_SERVER`, `http://127.0.0.1:7380` by default. Run it from the repository with `bun packages/cli/src/cli.ts`. ## Code Mode and MCP `kestrel agent tools --json` prints the Code Mode manifest: every operation, its access and the command behind it. `download` is the one `write`, since it saves a file. `kestrel mcp` serves the other operations over MCP on stdio, as read-only tools; a video file means nothing in an MCP reply, so `download` stays in the CLI. ## The skill `skills/kestrel/SKILL.md` tells an agent to use Code Mode first, then MCP, then the CLI, and how to read what comes back: start from `status`, and look for PIR and motion recordings when no events have come in. --- # API Source: https://kestrel.timmo.dev/api The server's API is plain JSON over HTTP, with no login of its own. Its OpenAPI document is at `/api/openapi.json`. Recorder times have no time zone: they're the recorder's own clock, as it keeps them, such as `2026-11-11T22:24:31`. It serves **sources**, such as the recorder, and their **cameras**. A camera's id is its source's id and its own, such as `swann/3` for the recorder's channel 3, and its routes take the two as path segments. | Endpoint | Returns | | ---------------------------------------------------- | ------------------------------------------------------------- | | `GET /api/sources` | Each source: its kind, name and whether it's connected | | `GET /api/sources/:source/details` | Its facts, clock, storage and each camera's state | | `GET /api/sources/kinds` | Each kind of source the server runs and the settings it takes | | `GET /api/sources/settings` | Every source's settings and the camera labels, no secrets | | `PUT /api/sources/settings` | Saves them and restarts the sources that changed | | `GET /api/attention` | What needs you, each an `alert` or `passive`, with its source | | `GET /api/cameras` | Each camera: its id, name and what it can do | | `GET /api/cameras/:source/:camera/recordings?date=…` | A camera's recordings on one day, such as `2026-11-11` | | `GET /api/cameras/:source/:camera/video?…` | Recorded video as MP4; see below | | `GET /api/cameras/:source/:camera/still` | A camera's still picture now, for one with `still` | | `GET /api/recordings/days?month=…` | The days with recordings on any camera in a month | | `GET /api/events/` | The last 200 events from each source, newest first | | `GET /api/events/live` | Events as server-sent events | | `GET /api/health` | 204 while the server is up | A camera in `/api/cameras` says what it can do: `live` has its go2rtc stream names, or is `null` for a camera with no live view; `still` says whether it has a still picture, which the page shows in place of video for a camera with no live view; `recordings` and `events` say whether it has them; `signal` is whether it has a picture now, or `null` when its source can't tell. A source that isn't connected yet leaves its cameras out until it knows them. The settings read back carry a `revision`. A save sends back the one it read, and is refused with `InvalidSettings` when the settings have changed since, such as from another tab, so a page left open can't undo a newer save. A camera's `name` is its source's name for it, such as `Channel 3`, and its `label` the name you gave it on the Settings page, or `null`. The settings are what `sources.json` keeps: each source's `id`, `kind`, `name` and `settings`, its `cameras` for a kind you list cameras for yourself, and the camera `label`s. Each kind in `/api/sources/kinds` lists its fields, each `text`, `number`, `secret` or `address`. Reading them back leaves secret fields out, naming the ones that are set in `secrets`, and hides the password in an address as `********`. A save keeps each secret it leaves out and each hidden password, checks the settings, and returns 400 with `InvalidSettings` saying what's wrong, such as two sources with one id. A source's details are general, so any source can fill them in: `facts` are labelled values, such as the recorder's name, firmware and channel count; `clock` is its own time, where it keeps one; `storage` lists each disk or card with its state; and `cameras` gives each camera's signal, what it's recording and whether there's motion now. A source with nothing to show returns 404 with `NotSupported`. `/api/attention` is the status source the CLI's `status`, the page and push read. Each source works out its own. Alerts are what needs you: a source can't be reached, a disk is bad or unformatted, or a camera that had a signal while the server ran has lost it. A full disk is passive, since the recorder records over its oldest recordings; the page shows it in the source's Storage card. An event in `/api/events` has its source, its camera where it's about one, its kind (such as `motion`, `PIR`, or a camera's own `person`, `vehicle` or `animal` detection), whether it started or ended, the source's own time for it, and when the server received it. Every endpoint that needs a source returns 503 with a `SourceUnavailable` error while it isn't connected. An id that names no source or camera returns 404 with `UnknownSource` or `UnknownCamera`, and asking for something a source can't do, such as a stream's recordings, returns 404 with `NotSupported`. ## Recorded video `GET /api/cameras/swann/1/video?begin=2026-11-11T21:46:00&end=2026-11-11T21:48:00` sends what a camera recorded between two recorder times as fragmented MP4, H.264 with no audio, which plays as it arrives. Add `stream=sub` for the small sub stream, and `download=true` to have a browser save it as a file rather than play it. `speedUp=2`, up to 4, shortens each frame so it plays that many times faster: browsers play no faster than 16 times. `every=1`, up to 60, sends key frames only, one per that many seconds of recording, from the recorder's own fast forward. The whole main stream only downloads at about 17 times real time, so it can't keep up past 16, but its key frames come at about 58. So the page plays 32 times as `every=1` with `speedUp=2`, at 16 times, in full resolution. The recorder runs one playback or download at a time; the server waits up to 15 seconds for it to let go of the last one. - It runs across recordings, one after another, with the gaps between them closed up, so a range over a run of motion clips plays them back to back. - It starts at the first key frame in the second at `begin`, and runs to the end of the key frame group at `end`, so about a second either side. - It comes faster than real time, around 17 times on the main stream, so a minute of 1080p takes a few seconds. Nothing is kept on the server. - A range with nothing recorded in it returns 404 with a `NoRecordings` error. Once video has started, a failure can only cut the response short. The page plays it through Media Source Extensions. It sets the length from the range, asks for video from a new `begin` when you seek past what has loaded, and keeps only a few minutes around the playhead, as a full recording is about 250 MB. ## Live video The server serves go2rtc under `/go2rtc/`, its WebSocket included, so live video plays from the same address as the page, through a proxy or over HTTPS. Each camera's `live` in `/api/cameras` names its streams, from its id: such as `swann_1` for the main stream and `swann_1_sub` for the sub stream. Play one with `/go2rtc/stream.html?src=swann_1_sub`. `subRatio` is the width over height to draw the sub stream at when its frames squeeze the picture into another shape, as the DVR8-4575's 352×288 sub stream does with a 16:9 picture, and `null` when it's drawn at its own shape. The DVR8-4575 offers an audio track on every stream but sends nothing on it, and an MSE player that asks for audio waits for it forever. The page plays video only, with go2rtc's own `video-rtc.js` player and `media` set to `video`, so do the same in other players. ## Push The server pushes each alert from `/api/attention` once, when it starts, to every browser that subscribed. It follows Groundwork's push contract, outside `/api`: | Endpoint | Does | | ---------------------------- | ---------------------------------------------------------- | | `GET /push/vapid-key` | The VAPID public key, as `{ publicKey }` | | `POST /push/subscriptions` | Keeps a subscription, replacing the client's last one; 204 | | `DELETE /push/subscriptions` | Drops the subscription with `{ endpoint }`; 204 | The page's Notify me button subscribes. The keys and subscriptions are kept in `push.json`, readable only by you, under `KESTREL_STATE`, else `$XDG_STATE_HOME/kestrel`, else `%LOCALAPPDATA%\kestrel` on Windows, else `~/.local/state/kestrel`. --- # Protocol Source: https://kestrel.timmo.dev/protocol Raysharp recorders take control messages on TCP port 9000. `packages/protocol-raysharp` follows the message numbers and layouts in the vendor SDK's protocol header, and the notes here say where firmware SRV7.1.0 differs. The mode 2 scrambling and the recorded video's frame header were worked out by reverse engineering, and everything is checked against a real recorder. ## Framing Every message has a 16-byte header: the protocol mode, an ack code, the message type and subtype, the body's size and a sequence number, all little-endian. Replies carry the request's sequence number. In mode 2, which these recorders use, everything after the first two header bytes, and the body, is scrambled with a fixed bit permutation that needs no key. ## Messages used | Type | Subtype | What it does | | ---- | ------- | ------------------------------------------------------- | | 31 | 0 | Log in with the username and password in 36-byte fields | | 32 | 0 | Heartbeat, every 5 seconds | | 890 | 614 | Status: clock, disks and each channel's state | | 111 | 101 | Recordings on one channel between two times | | 111 | 102 | Days with recordings on a set of channels | | 131 | 0 | Download a stretch of recording, on its own connection | | 121 | 0 | Play recordings, on its own connection | | 122 | 121 | Ask a playback for more data | | 124 | step | Key frames only, one per `step` seconds of recording | | 896 | 196 | Subscribe to alarm reports | | 891 | various | Alarm reports, sent by the recorder once subscribed | ## Differences on SRV7.1.0 - Status subtype 610 replies blank, so the client asks for 614, which fills the same layout. Both reply with ack 1 even when they hold good data. - The day outline's time of day is junk; only the date is reliable. - Model and serial come back empty in the login reply. ## Downloads The recorder has no RTSP address for recordings, so recorded video comes through the download message, 131. It goes on a second connection with no login of its own: the 24-byte request carries the user key from the main connection's login reply, the channel from 0 and the stream (0 main, 1 sub). One 28-byte record follows, of the kind a search returns, with the range. The reply is an empty ack, then packs: a 12-byte header (its version, type, how many packs the frame takes, the frame's number, the pack's number and its size), then a piece of a frame. Type 0 is a frame piece, and type 3 marks the end of the file. Each frame starts with a 40-byte header the SDK doesn't describe: | Offset | Size | What it is | | ------ | ---- | ------------------------------------------------ | | 0 | 4 | `RSFm` | | 4 | 1 | `I` for a key frame, `P` for the rest | | 12 | 4 | The size of the H.264 data that follows | | 16 | 8 | The recorder's clock in microseconds, local time | | 32 | 2 | Width | | 34 | 2 | Height | | 36 | 1 | Frames per second | The H.264 is in Annex B form, padded to a multiple of 8 bytes, and key frames carry their SPS, PPS and SEI. On the DVR8-4575 the main stream is 1920×1080 at 15 frames a second with a key frame each second; the sub stream is 352×288 at about 4. The range can be any stretch inside a file, and the record can say "all types, size unknown". SRV7.1.0 handles ranges in its own way: - It sends only the first record, though the SDK allows several in a request, so a range across files takes a request for each. - It picks the file by the range's end. Where one file runs straight into the next, a range ending on the join gets the next file, from that file's start. The client ends those ranges a second early; the last key frame group still runs to the file's end. ## Fast forward Playback, message 121, takes a 68-byte request on its own connection: the user key, the channel from 0, the type (all), the begin and end times at 12 and 20, and the stream at 28. The recorder sends data as the client asks for it with 122/121, in the same packs as a download's. SRV7.1.0 ignores the end and plays to the end of the file, where pack type 2 marks the end of the recording, so the client stops at the end itself and asks for each file in turn. The play control message, 124, carries its setting in the subtype. On SRV7.1.0 a subtype from 1 up is a step in seconds: the recorder then sends only key frames, one per step of recording. With a step of 1 that's every key frame of the main stream, at about 58 times real time where the whole stream downloads at about 17, which is how the page plays past 16 times in full resolution. Message 404, the SDK's key frame request, gets no answer. The recorder runs one playback or download at a time. Another one meanwhile is turned down with ack 322, and the recorder lets go of the last one a few seconds after its connection closes. ## Alarm reports Motion (191), PIR (187), video loss (189) and alarm input (192) reports start with the channel from 0, a status (1 on, 0 off) and the time. Recording status (212) and disk (202, 213) reports put those fields elsewhere. These layouts come from the SDK header and are checked in real use. --- # Running it Source: https://kestrel.timmo.dev/running You need [mise](https://mise.jdx.dev), which installs Bun, go2rtc and the other tools at the versions the repository pins. ```sh mise install mise run build ``` ## Your sources The server's sources, such as a recorder, and the names you give cameras are kept in `sources.json` in its config folder: `~/.config/kestrel` (or `$XDG_CONFIG_HOME/kestrel`) on Linux and macOS, `%APPDATA%\kestrel` on Windows, or the folder `KESTREL_CONFIG` names. The server writes it when you save from the Settings page, readable only by you, and you can edit it by hand too; restart the server after a hand edit. The Settings page, in the app bar, lists each source as a panel that opens to the settings its kind takes; opening one closes the one open before. Add source picks a kind, Remove takes one out, and a `stream` source lists its cameras with their addresses. Under each source, name its cameras: the name shows across the app in place of the source's own name for it, such as "Garden", or "Ch 3" for a recorder's numbered channel. Camera order, below the sources, sets the order cameras show in across the app: drag one by its handle, or focus the handle and move it with the arrow keys, Home and End. Cameras left out of the order, such as a new source's, follow in the order their sources give them. Save restarts only the sources whose settings changed. If the settings changed since the page loaded them, such as from another tab, the save is refused: reload the page and make the change again. ```json { "sources": [ { "id": "swann", "kind": "raysharp", "name": "Swann recorder", "settings": { "host": "192.0.2.10", "username": "admin", "password": "…" } }, { "id": "garden", "kind": "stream", "name": "Garden cameras", "settings": {}, "cameras": [ { "id": "porch", "name": "Porch", "main": "rtsp://user:…@192.0.2.20:554/stream1", "sub": "rtsp://user:…@192.0.2.20:554/stream2" }, { "id": "shed", "name": "Shed", "still": "http://192.0.2.21/snapshot.jpg" } ] } ], "cameras": [{ "camera": "swann/1", "label": "Front door" }], "order": ["garden/porch", "swann/1"] } ``` A `raysharp` source is a Raysharp-made recorder, such as a Swann DVR, with live view, recordings, events and its disks; each channel is a camera. A `stream` source is cameras you list yourself, each with an `id`, a `name` and a `main` stream address, any that go2rtc plays (RTSP, RTMP, HLS, MJPEG and more), with an optional `sub` stream for small tiles. A camera with only a `still` address shows that picture on the Live page, a new one every few seconds. Streams have live view only: no recordings or events. A `reolink` source is a Reolink camera, or a Reolink Home Hub or recorder with a camera on each channel, from its `host`, `username` and `password`. Start the host with `https://` where its HTTP port is off. It has live view from each camera's RTSP main and sub streams, so turn RTSP on in the camera's network settings, and stills, motion and person, vehicle and animal detections, read every couple of seconds, and its disks or SD card. A hub or recorder's recordings are copied into the recordings folder like a recorder's: each file is replayed over port 9000, Reolink's own protocol, and converted from H.265 to H.264 with ffmpeg. A camera's own SD card recordings are listed beside the server's, marked as on the source only and faded on the timeline, but not copied or played: it cuts a download short, and older firmware won't replay a file. Record the camera on the server for footage you can play. A `bambu` source is a Bambu Lab P1 or A1 printer's camera, from its `host` and the LAN `accessCode` on its network settings screen. These printers have no video stream on the network, only a picture every second or two on port 6000, so the camera shows on the Live page as a still, a new one every few seconds. Turn on its LAN mode, or LAN-only liveview, for the feed to answer. The printer's recordings come from its storage over FTPS on port 990, with the same access code: what its camera records during prints, as the camera's continuous footage, and the timelapse of each print, as a second camera, such as `bambu/timelapse`. They're MJPEG, so each is converted to H.264 with ffmpeg as it's copied or played, one at a time. Set `KESTREL_FFMPEG` for an ffmpeg that isn't on the path; it needs `libx264`. A source's `id` names its cameras, such as `swann/3`, so links and labels keep working as long as it stays the same. It holds logins, so keep it to yourself. The API never sends a password back: the page shows which are set, and a save keeps each one you didn't change. ## The recorder's details from the environment Before there's a sources file, the server runs one Raysharp recorder, `swann`, from the environment. A Raysharp source whose settings leave its address or login out falls back to the same variables, so the login can stay out of the file. Copy `.env.example` to `.env.local`, which git ignores, and fill it in: | Variable | What it is | | -------------------------------- | --------------------------------------------------- | | `KESTREL_RAYSHARP_HOST` | The recorder's address on your network | | `KESTREL_RAYSHARP_PORT` | The control port, 9000 unless it was changed | | `KESTREL_RAYSHARP_RTSP_PORT` | The RTSP port, from the recorder's network settings | | `KESTREL_RAYSHARP_USERNAME` | A user on the recorder | | `KESTREL_RAYSHARP_PASSWORD` | That user's password | | `KESTREL_RAYSHARP_RTSP_USERNAME` | The RTSP user, when it isn't the one above | | `KESTREL_RAYSHARP_RTSP_PASSWORD` | The RTSP password, when it isn't the one above | Live video logs in to the RTSP port with the RTSP user and password when they're set. Some recorders keep a separate RTSP password: if live players show `wrong user/pass`, set it there. `mise run serve:server` reads the file through 1Password's `op run`, so the login can be `op://vault/item/field` references rather than the values themselves. A field whose name has spaces can be referenced by its ID. So 1Password doesn't ask each time the server starts, run `mise run auth:save` once. It fills in the references and saves the result to `~/.config/kestrel/recorder.env`, which only you can read, and the server uses that file while it's there. After changing the login, run it again; `mise run auth:forget` removes the file. ## Starting the server ```sh mise run serve:server ``` It runs in the background through Pitchfork, at , and serves the web client and its API. It starts each source as it starts, and a recorder logs in again, waiting longer each time, whenever the connection drops. A source that can't start, such as one with no address, shows as not connected with the reason. `mise run serve:server:logs` follows its log. The server listens on `127.0.0.1:7380`. go2rtc only listens on `127.0.0.1:1984`, and the server passes it through under `/go2rtc/`; it starts go2rtc again whenever the cameras' streams change. go2rtc's RTSP server stays on `127.0.0.1:1985`, since its ffmpeg sources hand their output back through it. Set `KESTREL_HOST` to listen on another address, such as your machine's address on your network to use it from a phone, and `KESTREL_PORT`, `KESTREL_GO2RTC_PORT` and `KESTREL_GO2RTC_RTSP_PORT` for other ports. The server doesn't ask for a login, so only listen where everyone who can reach it should see the cameras and change its sources. ## Desktop app The desktop app runs the server itself and opens the web client in a window, so Kestrel keeps running without Pitchfork or a terminal. It listens on `127.0.0.1:7380` like `serve:server`, with go2rtc built in, and takes its sources from `sources.json`. For a recorder that leaves its address and login out there, it reads the file `mise run auth:save` keeps; it doesn't read `.env.local`, since that needs 1Password. Each [release](https://github.com/timmo001/kestrel/releases) has it for Linux and Windows (x64): unpack the Setup file and run the installer in it, or install it with mise and start it with `kestrel`: ```sh mise use -g github:timmo001/kestrel ``` Neither is signed, so Windows asks before running it, but each release's files have a GitHub attestation, which mise checks, or check one yourself with `gh attestation verify -R timmo001/kestrel`. Recording needs `ffmpeg` on the path, or `KESTREL_FFMPEG`; the app doesn't bring its own. The Windows build hasn't been tried on a real machine yet, and Start at login is Linux only. With mise, Start at login points at that version, so tick it again after upgrading. Closing the window leaves it running in the tray. The tray menu opens the window again, shows whether the server is running, how many sources are connected and how many cameras it records, opens these docs and quits. The docs are built into the app, so they open in your browser without a connection, from the tray or the page's More menu. If the server stops, the app starts it again, waiting longer each time up to 30 seconds. Opening the app again while it runs brings its window to the front. Tick Start at login in the tray to start it in the tray when you log in. It adds `~/.config/autostart/dev.timmo.kestrel.desktop`; untick it to remove that again. The app is the always-on server, so stop the background one with `mise run serve:server:stop` while the app runs: both use ports 7380 and 1984 and the same recordings folder. | Task | What it does | | -------------------------- | --------------------------------------------------------------------------------- | | `mise run desktop:start` | Builds the app for development and opens it | | `mise run desktop:build` | Builds the release, in `packages/desktop/artifacts` | | `mise run desktop:install` | Builds the release and installs it for you, in `~/.local/share/dev.timmo.kestrel` | ## Recordings Recordings are local first: the web client and CLI play from the recordings folder on the server wherever it has a copy. The server writes its own recordings there, and copies each source's in beside them, with why each was made, such as motion, in the file's name: `/