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.
skychat is auto-launched by the visor when present in the apps
list. A typical entry in skywire-config.json:
The HTTP UI lands on http://127.0.0.1:8001 once the visor starts.
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. Defaultskynet.--retries N— HTTP/transport retry count. Default 1.--wait DURATION— peer-ack wait (server-clamped [100ms, 60s]).
skywire cli skychat listenStreams 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.
# 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| 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.
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.
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.
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 listingOpt-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.
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.
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 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.
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 — seepkg/skychat/profileandpkg/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 address —
skychat://<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.
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.
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.
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/importWhat 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.
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.
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.
These are browser-UI features; the CLI stays plain send/listen.
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.
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.
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.
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 (viaGroupSend) and prunes the original leaf (viaGroupUnsend). The tombstone rides the normalGroupPoll→ SSE path so it propagates live, and — being a durable leaf — also reaches members who were offline during the delete and future joiners; group/historyfilters 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.
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.
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:trueon/message, so the body is wrapped in an id'dchat-msgenvelope (ack=true) and the handler returns{ok,id}immediately (unlike--wait, it does not block). - The recipient's app auto-replies with a
chat-ackon receipt (→received); when its UI displays the message itPOSTs/read-receipt, sending achat-readenvelope back (→read). - Both receipts ride back as
dm-statuscontrol events on/sse({channel:"dm-status",id,status,peer}), which the sender's UI matches to the bubble by id. Line-based/sseconsumers (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.
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 1hFlags:
--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,168hfor 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.
The chat-app exposes a /status endpoint:
curl -s http://127.0.0.1:8001/status | jqKey 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.
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.
A robust listener loop for headless agents:
while true; do
skywire cli skychat listen
sleep 1
done >> /var/log/skychat.log 2>&1The 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.
/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).
- HTTP server on
--addrserves 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/groupendpoints. - 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 protocolcommands/filexfer.go— file send / serve / thumbnailscommands/filebackfill.go— file re-request / re-sendcommands/reply.go— quoted-reply envelope + enrichmentcommands/sendack.go— chat-msg/chat-ack/chat-read envelopes + ack routingcommands/dmstatus.go— DM status receipts +dm-statusSSE +/read-receipt- half-open-conn auto-recovery (
dropStaleConn)
- half-open-conn auto-recovery (
commands/group.go— browser group-chat HTTP proxy + SSE bridge (incl. group files + delete tombstones)group/— group chat (TreeStore-backed)history/— SQLite persistence layerpairing/— per-pair CXO encryption (see also docs/skychat_pairing.md)
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.
{ "app": "skychat", "auto_start": true, "port": 1 }