Skip to content

Latest commit

 

History

History
856 lines (697 loc) · 37 KB

File metadata and controls

856 lines (697 loc) · 37 KB

Skywire Chat (skychat)

skychat is an app that runs alongside the skywire visor and provides messaging between visors over dmsg and/or skywire routes. It exposes a local HTTP server (default 127.0.0.1:8001) for the browser UI plus the skywire cli skychat family of subcommands for headless / scripted use.

This README focuses on what the app does and how to drive it from the CLI. For the per-pair encrypted CXO layer, see docs/skychat_pairing.md.

Quick start

skychat is auto-launched by the visor when present in the apps list. A typical entry in skywire-config.json:

{
  "app": "skychat",
  "auto_start": true,
  "port": 1
}

The HTTP UI lands on http://127.0.0.1:8001 once the visor starts.

Sending a message (CLI)

skywire cli skychat send -t <peer-pk> -m "hello"

Default semantics (as of 2026-05-14): the command waits up to 5 seconds for the peer's chat-app to acknowledge receipt. Output:

  • Acked: Acked by <pk> in <N>ms (id=<uuid>), exit 0.
  • Timeout: send to <pk> via <net> not acked: <reason>, exit 1.

Override the wait with --wait 0 for fire-and-forget (returns success as soon as the local visor's WriteFrame succeeds — useful against peers on pre-2026-05-12 binaries that can't ack).

Other flags:

  • --net skynet|dmsg — choose network. Default skynet.
  • --retries N — HTTP/transport retry count. Default 1.
  • --wait DURATION — peer-ack wait (server-clamped [100ms, 60s]).

Listening for inbound messages

skywire cli skychat listen

Streams every inbound DM as one JSON event per line. Output shape:

[<sender-pk>/<net>] <body>

Flags:

  • --from <PK> — filter by sender.
  • --net skynet|dmsg — filter by transport.
  • --raw — emit unescaped multi-line bodies (humans only).
  • --json — full JSON event per line (machine-readable).

The listener uses SSE under the hood (GET /sse on the chat-app's HTTP server). It auto-reconnects on visor restart and replays recent messages from a 256-message ring buffer so brief disconnects don't lose data.

Group chat

# Owner: create a group, a private group, or a broadcast channel
skywire cli skychat group create --name my-room
skywire cli skychat group create --name ops --kind private
skywire cli skychat group create --name news --kind channel

# Hand it out — as a link, or as a short address
skywire cli skychat group invite  <group-id>   # skychat:invite:<base64url>
skywire cli skychat group address <group-id>   # skychat://<host-pk>/<group-id>

# Member: join by either form
skywire cli skychat group join <invite-link>
skywire cli skychat group join skychat://<host-pk>/<group-id>

# "what is this thing someone sent me?"
skywire cli skychat group resolve <address-or-link>

# Send / read
skywire cli skychat group send <group-id> "hi everyone"
skywire cli skychat group listen
skywire cli skychat group info <group-id>
skywire cli skychat group list

Group kinds

kind admission bodies who posts
public open — anyone who asks plaintext on the feed every member
private an admin approves each request encrypted every member
channel open — anyone who asks plaintext on the feed admins only

Public and channel bodies are plaintext for the same reason: admission is open, so the key would go to any stranger who asked and would protect nothing. Transport is Noise-encrypted regardless. A channel differs from the reversible read_only flag in being permanent — it is what the group is, not what an admin has currently switched on — which is why the reader-side gate drops a non-admin's leaves whatever their age.

A channel's kind is immutable. The store refuses any write that would change a persisted group's Kind, including one that merely omits it — EnsureKind would re-derive an empty kind from Mode, and a channel shares ModePublic with a public group, so a forgetful write would silently hand the floor to every subscriber. There is no migration: the honest way to turn a channel into a group is to create a group.

What makes a channel scale

Three things differ from a group, and all three follow from "the audience is unbounded and one-way":

Subscriptions are O(admins), not O(members). Under the group rule an admin follows every member — an input pipe worth having when everyone can post. In a channel a subscriber's feed can never carry a message, so both roles follow only the admins. Without this, an admin of a 10,000-subscriber channel would open 10,000 CXO subscriptions to feeds guaranteed to stay empty.

History and files come from admins. PeerBackfillEnabled is always false for a channel, whatever the stored flag says: a subscriber has nothing of its own to serve, so mirroring the room onto every follower would give a large channel one copy of itself per member. SetPeerBackfill is refused on a channel rather than accepted-and-ignored.

Backlog arrives in chunks, and files only on request. A joiner takes the newest page immediately and walks backwards as it reads (history.Store.ListGroupBefore, GET /group/<id>/history?before=<ts_nano>) instead of pulling an entire archive before showing anything. Attachments are published as feed references and nothing more — acceptInbound refuses an unrequested file in a channel even from an admin, so one admin attaching a 2 GB file cannot land it on every subscriber's disk at once. Opening the card requests that one file from a host that has it.

Addresses vs invite links

Two ways to name a conversation, and they trade against each other rather than one superseding the other:

skychat://<pk>                 a person — opens a DM
skychat://<pk>/<group-id>      a group or channel
skychat:invite:<base64url>     a group invite

An address is short enough to print, read aloud, or encode in a low-density QR code, and stays valid as the group changes. It is not self-contained: a group's feed port is allocated at random per group, so whoever holds an address asks the host what the group is before joining (one round trip on skyenv.SkychatGroupProbePort, describe-only — it decides nothing and mutates nothing). That means the host has to be reachable.

An invite link carries the port, mode, admin list and proof-of-work price inline, so it works while the host is offline — at a few hundred characters, which is why it is not the thing you scan.

Both go through the same admission gate. An address is a shorter way to name a group, not a way around its door.

Whether a bare public key belongs to a person or to the host of a channel is not decidable by inspection — they are the same 66 characters. Only a group ID in the address distinguishes them, which is why resolve exists and why the UI asks before it offers an action.

Discovery catalog

A channel is the one thing here that is useless without discovery: a group gets its members from people who already know each other, but a channel wants an audience it has not met. So a visor can publish a catalog of its own groups and channels, and anyone holding its public key can ask:

skywire cli skychat group publish <group-id> on   # opt in (admin-only)
skywire cli skychat group catalog <host-pk>       # ask a visor
skywire cli skychat group catalog                 # see your own listing

Opt-in, off by default, and that direction is load-bearing. The catalog is the only mechanism in skychat that turns one public key into a list, so an entry has to be asked for — nothing a visor hosts becomes enumerable because somebody didn't read a checkbox, and existing records stay unlisted across the upgrade.

Listed is local and not gossiped: it says what this visor answers questions about, which is a hosting decision rather than a property of the group. Two admins of the same channel can legitimately differ on whether they advertise it, and neither can un-list the other's copy — which is honest, because they never could.

It shares the well-known probe port but is a separate frame, not the same request with an empty field, because the two disclose different things: a probe confirms one 122-bit group ID the asker already held, while a catalog turns a key into a list. Entries carry no member count — a listing is an invitation to join, not a report on who already did.

There is no network-wide index, no aggregator and no registration. A catalog is served by the host itself and answers only for the host's own groups, so discovering anything still starts from a public key a human gave you. That keeps the trust model identical to the rest of skychat while removing the part that was actually painful: needing a fresh invite link per person for something meant to be public.

In the browser UI this surfaces where it is useful rather than as a separate screen — entering a public key in Add by address also lists the channels that visor publishes, and tapping one fills the field so it is confirmed through the same path as a pasted address. Nothing is joined by tapping a row in a list.

Groups are built on top of CXO TreeStore feeds. The owner publishes the canonical group feed; members subscribe and (post-#2539) publish their own per-member feed. See cmd/apps/skychat/group/ for the implementation.

Asking to join costs something. Public keys are free to mint, so every per-PK gate a group has — the ban list, the approval queue, the allowlist — can be outrun by whoever generates identities faster than an admin declines them. Three things bound that: a join request must carry a proof of work bound to the group and to the requester's own key (default ~18 bits, tens of milliseconds once, minutes by the thousand); requests that would consume something are rate-limited per group; and the approval queue is capped, so a flood cannot bury the real requests. A throttled or unpaid request is never stored, so refusing one costs the admin a single hash. Set the price with skywire cli skychat group join-cost <group-id> <bits> (0 disables it, 26 is the cap) or from the group's admin panel; group info shows it as join_cost_bits. Raise it while a group is being flooded — links already handed out keep working, their holders are simply told the new price and pay it. This is not Sybil resistance: a determined attacker still gets in, it just stops being free.

Any online member can catch a new member up. Members mirror the leaves they receive onto their own feed — verbatim, still signed by the original author, so a mirroring peer is never trusted, only convenient — and each non-admin follows a couple of other members as well as the admins. That is what keeps a group readable when its admins happen to be offline; before it, two members could both be online and still not see each other. The creator chooses at create time (group create --no-peer-backfill to opt out) and any admin can change it later with skywire cli skychat group peer-backfill <group-id> on|off; group info shows the current setting as history_served_by. Turning it off restores the admins-only topology, and the group then goes dark whenever no admin is up. The cost of leaving it on is storage — every member keeps a copy of the room rather than just the admins — and it does not widen who may read: only members hold an allowlist seat, and private groups stay encrypted.

Group keys are encrypted at rest. groups.db no longer holds any group's AES key in the clear — each one is sealed with a key derived from the visor's own secret key (HKDF-SHA256 → ChaCha20-Poly1305), so a copy of the database on its own is inert. Records written by an older build are re-sealed the first time the visor opens the file, which it logs. This is not disk encryption: an attacker who takes the visor config as well can derive the sealing key. What it buys is that the file that actually travels — backups, bug reports, container volumes — stops being enough. Note the flip side: a groups.db is tied to the visor that wrote it and cannot be moved to another identity.

Private groups re-key when a member is evicted. Kicking or banning someone generates a new AES key and publishes it on the group feed as one copy per remaining member, each sealed to that member's own public key (secp256k1 ECDH), so the key the evicted member still holds opens nothing published afterwards — a ban takes away reading, not just connecting. Older messages stay readable: every visor keeps the keys it has already held. skywire cli skychat group rotate-key <group-id> re-keys on demand (a device or key you think was exposed), and group info shows the current key_epoch so you can confirm every member converged.

An invite link names the group's other admins alongside the founder, and a joiner asks all of them, so admission survives the founder being offline or its key being lost — promote a second admin (skywire cli skychat group promote) and every link minted afterwards has a fallback door. The founder is still the group's immutable recovery anchor and is still asked first; it just isn't the only one who can let people in.

The browser UI mirrors this, backed by an HTTP proxy to the visor's group RPC — GET/POST /group, POST /group/join (which takes either {invite} or {address}), GET /group/resolve?address=…, and /group/<id>/{invite,send,leave,history}. It needs the visor RPC connection (--pair-enable); without it the Groups section stays hidden and the UI is DM-only. Group text is decrypted by the visor for private groups, so the browser never handles keys.

Starting anything is one button — bottom-right on a phone, in the sidebar header otherwise — offering Add by address, New group and New channel. "Add by address" takes one field for every way of naming something: a bare public key, a skychat:// address, an invite link, or a scanned QR code. It resolves the input first and then offers the single action that applies — Start Chat, Join Group, Send Request, or Join Channel — so nobody has to know which kind of thing they are holding.

Every open conversation can show its own address as a QR code, with the address printed underneath as selectable text plus a copy button: a code is useless over a screen share, in a terminal, or on a device with no camera. Scanning uses the browser's own BarcodeDetector (Chrome, Edge, Android WebView) from the camera or from a chosen image; where the API is absent the dialog says so and the paste field — its primary input anyway — still works. The camera needs a secure context, which plain http only satisfies on 127.0.0.1/localhost.

Below 760px the sidebar and the chat stop sharing the screen: the list is the screen until a conversation is opened, which then replaces it and grows a back button. The device back button returns to the list rather than leaving the app.

Media & files (browser UI)

The browser UI can attach and render media inline — the CLI stays text-only. Click 📎 to send a file to the open conversation (DM or group); received images / video / audio render in place. Your own file bubbles carry the same delivery-status tick as text (see Message status). There is no size limit on uploads: /send-file bounds only the request-header read (ReadHeaderTimeout) and clears its write deadline for the transfer, so a large upload/transfer is never truncated.

  • Images show a downscaled thumbnail (GET /thumb/<name>, ~4–5× smaller than the original) and open full-size in an in-app lightbox on click.
  • Video / audio play in native <video> / <audio> players; GET /files/<name> sets an explicit media Content-Type and supports Range requests, so seeking works.
  • Other files render as a download card.

Sent and received media survive a cache wipe / fresh device: DM file events persist to /history, and both sender and receiver keep an id-named served copy under the downloads dir, so previews re-render anywhere the visor is reachable. Every peer-supplied name and URL is attribute-escaped before it reaches the DOM.

File backfill (re-request)

Transfers are point-to-point, so a peer that missed a file (offline at send time, a pruned local copy, or a brand-new device) can ask the original sender to re-send it. Received file bubbles carry a re-request link: it POSTs /request-file {pk,file_id,file_name}, the holder locates the bytes by id and re-sends preserving the original id + name, and the requester auto-accepts (it asked). When the bytes land, the existing bubble is patched in place rather than duplicated. The file id is persisted with the message in /history (file_id), so the re-request link keeps working after a reload or on a fresh device — not only while the bytes are missing.

Group files

Group files ride the feed as a small reference ({"skychat_file":{id,name,size}}), not as bytes — so nothing is fanned out and the feed stays cheap. Every member, including future joiners, pulls the bytes on demand via the same file-backfill request routed to the message's sender. A member who doesn't hold a file yet sees a card with a re-request link; once the bytes arrive the bubble is patched in place (an image becomes inline).

In an encrypted group the bytes are sealed before they leave the sending device and stay sealed at rest on every member's disk (commands/filecrypt.go). The container is "SGF1" | group id | file id | name | plaintext size | AEAD chunks, each 64 KiB chunk sealed under a key derived per file from the group key — HKDF(group key, "skychat-group-file-v1" | group id | file id) — so the group key itself never leaves the visor and a leaked file key opens exactly one attachment. Chunking is what keeps HTTP Range requests working, so video seeking behaves as before; /files/ and /thumb/ decrypt on the way to the browser.

Two consequences worth knowing. A re-send to someone outside the group (the backfill path answers by file id, not by roster) now hands over bytes they cannot open. And an attachment shared before a key rotation still opens for members who lived through it — they keep the retired key in their ring — but not for a joiner admitted afterwards, the same boundary that already applies to message history. Public groups have no key and their attachments stay plaintext, for the same reason their message bodies do.

Identity, contacts & the sidebar (browser UI)

Your profile and your address

The row at the top of the sidebar is you. Opening it gives two things that belong together: what this visor publishes about itself, and the address other people need in order to ask.

  • Name and picture are served on the same well-known describe port as group probes and the discovery catalog (profile_request / profile_response, a third frame kind — see pkg/skychat/profile and pkg/skychat/group/profile.go). All three questions are read-only, mutate nothing, and must be answerable before any relationship exists, which is exactly what a per-group port cannot do. Stored in <local>/skychat/profile.json; a visor that has set nothing answers empty rather than refusing.
  • The avatar is at most 256×256 pixels and 32 KB. It travels inline in one response frame on a port any stranger may dial, so an uncapped picture would turn "ask who this is" into a way to make a visor serve arbitrary bytes. 256 is what covers the largest box the UI renders it in (the 120px profile dialog) on a retina screen; the byte cap is the one that holds the wire promise, and it leaves ~21 KB of slack under the 64 KB frame limit after base64. The browser centre-crops, scales, and encodes down — PNG if it fits, otherwise JPEG at descending quality — so the cap is never something the user has to hit. The visor enforces the result by decoding the image (PNG or JPEG), never by trusting a declared size or MIME type.
  • Your addressskychat://<your-pk> — is shown as a QR code and as selectable text with a copy button, the same treatment a group gets. This half works with no visor RPC at all, so it is still available when profile publishing is not.

Nothing here is signed or verified. A profile is a label on top of the key, not an identity: the UI keeps showing the abbreviated key beside a name, and a nickname you set locally always wins over a published one.

Address book

The 📇 button in the sidebar header opens your contacts — a centred dialog on a desktop, a full-screen list on a phone. Entirely local; nothing is sent anywhere.

  • + opens a New contact form: paste a key, and if that visor publishes a name and picture both are filled in for you to accept or edit. If it publishes nothing, give them a nickname so the row reads as a person rather than as hex.
  • Saved names live in the same store the chat header, group sender labels, reply quotes and notifications already read, so a contact's name appears everywhere without those paths knowing contacts exist.
  • Add by address also offers to save whoever you just pasted, and the compose menu's Select from address book picks a saved contact into that same dialog — a row in a list never starts a conversation by itself, it goes through the same confirmation a pasted key does.

Settings

The ⚙ button in the sidebar header. Two sections, and they are the whole of it — everything else in this UI is a conversation, not a preference.

Notifications. Whether new messages raise a desktop alert, whether the alert shows a snippet, and the list of muted chats and groups. Opening Settings is also when the browser is asked for notification permission: that request is only honoured from a user gesture, and the preference defaults to on, so without an ask on open a fresh profile would silently drop every notification.

Import / export chat — see below.

Moving a chat to another device

Nothing here syncs. Two visors are two identities with two stores, and a message addressed to one was never addressed to the other, so a desktop's history simply does not exist on a phone. The honest alternative is a file you carry across yourself:

Route
GET /export the archive: address book + message history
GET /export/<name> the same; the path suffix only names the download
POST /import merge an archive into this visor
curl -sO -J http://127.0.0.1:8001/export          # write skychat-<date>.json
curl -s -X POST --data-binary @skychat-2026-08-06.json \
     http://127.0.0.1:8001/import

What travels is what is data: the messages this visor kept and the names you gave to keys. What does not travel is anything that is an identity or a key — the visor's keypair, group membership and its key material, pairing ratchets, transfers in flight. Importing a group's messages puts the conversation on the new device to read; it does not make that device a member, which still means rejoining.

Import is not Append in a loop, and the difference matters:

  • It is not rate limited. The per-peer limit exists to stop a peer filling your disk over the network at twenty messages a minute. An operator restoring their own archive is not that, and applying it would have delivered a handful of messages per conversation.
  • Records already present are skipped, so importing the same file twice changes nothing — the property you lean on when you are not sure the first attempt worked. Identity is the envelope ID where there is one, and timestamp + direction + text where there is not.
  • Every other guardrail stands. Oversized messages are rejected, the whitelist still filters, a full store takes nothing, and the per-peer cap still evicts oldest-first.

The reply counts what it stored and what it will not keep — how many records fall outside --persist-ttl (the sweep is about to take them) and how many the per-peer cap pushed back out. Both are silent losses otherwise, and finding out after wiping the old device is too late. A long history needs --persist-ttl 0 and a --persist-per-peer-cap to match before it is imported.

The address book half works with persistence off entirely; the messages then have nowhere to go, and the reply says so rather than reporting zero.

Sidebar tabs

All · DMs · Groups · Channels. The tabs only decide which sections are displayed — there is one rendering of each conversation, so switching tabs cannot lose a row's accumulated message preview or unread badge. The selected tab persists across reloads. A pinned conversation is shown in whichever tab it belongs to.

Saved Messages

A notes-to-self thread at the top of the DM list. Local to this browser by design: the alternative is a visor messaging itself over dmsg, which means dialing your own key for a "delivered" tick that means nothing. Type into it like any conversation, or send a message there from the per-message menu. The header's delete button empties it rather than removing it.

Replies, forwards, deletes & pinning (browser UI)

These are browser-UI features; the CLI stays plain send/listen.

Forwarding

The per-message menu's Forward offers every destination in one list — Saved Messages, people, groups, channels. What travels is the text, wrapped in a {"skychat_forward":{...}} envelope that rides the ordinary message body (same pattern as replies: no new endpoint, no wire-version bump, and an older build sees the plain text).

A forward out of a DM or a group carries nothing about who wrote it. Not the name, not the key, not the conversation. The envelope has no author field at all — that absence is the design, because a rule spelled "remember not to fill this in" survives until the first person forgets, while a field that does not exist cannot be populated by any future call site. A test asserts the envelope's key set for exactly this reason.

A forward out of a channel may name the channel. A channel post was broadcast to an audience its author cannot enumerate, under the channel's identity rather than a person's, so naming it repeats something already public. A DM has one intended reader and a group has a roster: in both, the author chose their audience, and attribution would hand a stranger the content and who said it. The content leaving is the forwarder's decision to make; the identity is not theirs to give away.

Forwarded messages render a quiet "↪ Forwarded" (or "↪ Forwarded from channel") line above the bubble — never a badge, since nothing here is signed and the claim is worth exactly what a pasted quotation is.

Touch message actions

On a narrow screen the hover-revealed ↩ Reply and links are unreachable — there is no hover, and the targets are far below the ~44px a finger needs. Below the one-pane breakpoint, tapping a message (the bubble or the empty space beside it) opens a bottom sheet with Reply, Forward, Save to Saved Messages and Delete, plus Delete for everyone where it applies. It stages the same state the desktop menu uses, so the two presentations cannot drift on what an action is allowed to do.

Quoted replies

Hover a message and click ↩ Reply to quote it: the reply rides the normal message body as a {"skychat_reply":{...}} envelope (no new endpoint, no wire-version bump), and every read boundary — DM /sse, DM /history, the group SSE poller, and group /history — unwraps it into the plain text plus additive reply_to_* fields. The quoted block renders above the reply, and clicking it scrolls to the original. The parent's preview is embedded, so the quote renders even for a reader who doesn't hold the parent (a fresh group joiner, or after backfill). Works for both DMs and groups.

Deleting messages

The per-message menu offers:

  • Delete for me — local only. DM threads are dropped from the browser cache; group messages are remembered in a persisted hidden-set (keyed by the message's ts_nano) and filtered on reload, since groups re-load from visor history.
  • Delete for everyone — shown only on your own group messages. DELETE /group/<id>/message?ts=<unixnano> publishes a durable {"skychat_delete":{to_ts_nano}} tombstone (via GroupSend) and prunes the original leaf (via GroupUnsend). The tombstone rides the normal GroupPoll → SSE path so it propagates live, and — being a durable leaf — also reaches members who were offline during the delete and future joiners; group /history filters both the tombstone and the deleted message. It is sender-scoped (the tombstone leaf is signed), so you can only delete your own messages for everyone. As with any federated store, a client that is offline forever or archived the bytes can't be forced to forget.

DM "delete for everyone" is intentionally not offered: DM messages carry no shared, stable id across the two visors, which a reliable delete-for-all would require.

Pinning

The 📌 button in a conversation header pins that conversation (DM or group) to a Pinned slot at the top of the sidebar; its copy in the normal list is hidden so it isn't shown twice. The pin persists across reloads and clears automatically if the conversation is deleted or left. One pin at a time — pinning another replaces it.

Message status

Your own DM bubbles carry a delivery-status tick that advances through the message's lifecycle:

Tick State Meaning
pending optimistic bubble, the send is in flight
sent the frame left this machine (/message returned)
✓✓ received the peer's app acknowledged receipt
✓✓ (blue) read the peer's UI displayed the message
⚠ Resend failed the send errored — click the tick to resend

There is no middle server — receipts are just messages travelling the other way over the same peer-to-peer conn. pending/sent/ failed are decided at send time; received/read arrive asynchronously and advance the tick later:

  • A browser send sets receipts:true on /message, so the body is wrapped in an id'd chat-msg envelope (ack=true) and the handler returns {ok,id} immediately (unlike --wait, it does not block).
  • The recipient's app auto-replies with a chat-ack on receipt (→ received); when its UI displays the message it POSTs /read-receipt, sending a chat-read envelope back (→ read).
  • Both receipts ride back as dm-status control events on /sse ({channel:"dm-status",id,status,peer}), which the sender's UI matches to the bubble by id. Line-based /sse consumers (cli skychat listen) ignore channel-tagged events, as they do for group/pair events.

Because delivery is direct-dial with no store-and-forward, an offline recipient means the send fails (nothing is queued); the tick is sent or failed, never a lingering pending. Status is browser-UI only — the CLI stays byte-identical plain send/listen — and persists in the local DM cache across reloads.

Stale-conn recovery. A cached connection can go half-open (the peer restarted, or an idle transport died) — a write into it succeeds without error but never lands, so no chat-ack returns and the bubble stops at sent. If no ack arrives within a short window the sender drops that conn, so the ⚠ Resend button (or the next message) redials a fresh one — no visor restart needed.

Files carry the same tick (○ sending → ✓✓ received on a completed 1:1 transfer; group files stop at ✓ sent — "on the feed"). A failed file shows a plain ⚠ with no resend, since the bytes are gone once the upload completes.

Group text messages show pending/sent/failed only; per-member received/read (a receipt fan-out on the feed) is not implemented.

Message history

When persistence is enabled, the chat-app stores every inbound and outbound message in a local SQLite database. Recover from listener-side missed events:

skywire cli skychat history --limit 50
skywire cli skychat history --peer <pk> --since 1h

Flags:

  • --peer <PK> — only one peer's messages.
  • --limit N — max messages, default 100, server cap 1000.
  • --since DURATION — drop messages older than (e.g. 1h, 24h, 168h for a week).
  • --json — NDJSON output, one event per line.

Enable persistence via skychat flags (set in the app's args in the visor config):

{
  "app": "skychat",
  "args": [
    "--persist-enable",
    "--persist-db", "/var/lib/skywire/skychat-history.db",
    "--persist-ttl-days", "30"
  ]
}

See commands/skychat.go for the full persistence flag list.

Health & introspection

The chat-app exposes a /status endpoint:

curl -s http://127.0.0.1:8001/status | jq

Key fields:

field meaning
app_uptime_sec seconds since the app started
inbound_msg_count DMs successfully decoded
outbound_msg_count DMs successfully written
inbound_drop_count ReadFrame errors
outbound_fail_count sends that gave up after retry
outbound_retry_count sends that took the redial-after-stale-conn path
sse_drop_count broadcasts where a subscriber's buffer was full OR no subscribers were connected
sse_subscribers live SSE listeners right now
active_peer_conns chat-app framed connections this app holds (NOT a dmsg session count — see below)
peers PKs of the active_peer_conns
last_rx_ts / last_send_ts RFC3339 timestamps of last successful inbound/outbound

Caveat on peers / active_peer_conns: these count chat-app framed connections, not underlying dmsg sessions. After a visor restart this starts at 0 and only grows when this app initiates an outbound DM or accepts an inbound one. Underlying dmsg may be fully reachable while these read 0 — for example, a probe via cli skychat send --wait 5s will succeed and populate the counter as a side effect.

Operational notes

Running headless

The default listen address is 127.0.0.1:8001 (localhost-only). For a multi-machine setup where the listener runs on a different host:

{
  "app": "skychat",
  "args": ["--addr", "0.0.0.0:8001"]
}

Gate the HTTP endpoints with basic auth via --password-file:

{
  "app": "skychat",
  "args": [
    "--addr", "0.0.0.0:8001",
    "--password-file", "/etc/skywire/skychat-auth.htpasswd"
  ]
}

The file contains a bcrypt hash (any single line); when set, every HTTP endpoint requires matching basic auth. The hypervisor's reverse proxy bypasses the gate via a per-process internal token the visor sets automatically.

Listener supervision

A robust listener loop for headless agents:

while true; do
  skywire cli skychat listen
  sleep 1
done >> /var/log/skychat.log 2>&1

The CLI auto-reconnects internally on SSE drops; the outer loop catches the (rare) hard exit. The 256-message replay buffer on the chat-app side means a brief restart window doesn't lose messages.

Frame protocol versioning

/status exposes frame_proto_version so operators can diagnose staggered-deploy version skew before it manifests as confusing wire failures. The current frame protocol is version 1 (length-prefixed frames, since #2504).

Architecture

  • HTTP server on --addr serves the browser UI, /status, /sse (listener stream), /message (send), /history, the file endpoints (/send-file, /files/, /thumb/, /request-file), and — when pairing is on — the pair-control and /group endpoints.
  • DM messages are length-prefixed framed connections (4-byte big-endian length + payload, max 64 KiB per frame).
  • File transfers run over a dedicated port (pkg/skychat/xfer); the bytes never ride the group feed — only a small file reference does.
  • Group messages are published over CXO TreeStore feeds.
  • The app talks to the visor via pkg/app — it does NOT speak directly to dmsg or the router; everything routes through the visor's app surface.

See:

  • commands/skychat.go — main app + framed-conn protocol
  • commands/filexfer.go — file send / serve / thumbnails
  • commands/filebackfill.go — file re-request / re-send
  • commands/reply.go — quoted-reply envelope + enrichment
  • commands/sendack.go — chat-msg/chat-ack/chat-read envelopes + ack routing
  • commands/dmstatus.go — DM status receipts + dm-status SSE + /read-receipt
    • half-open-conn auto-recovery (dropStaleConn)
  • commands/group.go — browser group-chat HTTP proxy + SSE bridge (incl. group files + delete tombstones)
  • group/ — group chat (TreeStore-backed)
  • history/ — SQLite persistence layer
  • pairing/ — per-pair CXO encryption (see also docs/skychat_pairing.md)

Two-visor local development

For local testing, two visors on the same host, each with skychat on a different HTTP port:

skywire1-config.json:

{
  "apps": [
    { "app": "skychat", "auto_start": true, "port": 1 }
  ]
}

skywire2-config.json:

{
  "apps": [
    {
      "app": "skychat",
      "auto_start": true,
      "port": 1,
      "args": ["--addr", "127.0.0.1:8002"]
    }
  ]
}

Build and run:

go build -o ./build/apps/skychat.v1.0 ./cmd/apps/skychat
go build -o ./build/skywire .
./build/skywire visor -c skywire1-config.json &
./build/skywire visor -c skywire2-config.json &

UI on http://127.0.0.1:8001 (visor 1) and http://127.0.0.1:8002 (visor 2). Each visor's PK is in its config; use those PKs as the -t <peer-pk> argument when sending between them.