MISTL (binary name: mistl) is a Rust daemon CLI that unifies tik-choco ecosystem features in a single binary.
It integrates the tc-storage CLI and the
mistlink CLI, built on top of
mistlib (path dependencies).
- identity — user profile and
did:keyEd25519 key management (tc-storage compatible) - store — content-addressed storage (CIDv1 / sha2-256 / 1 MiB chunks, mistlib StorageEngine)
- stream — screen-share RTSP server (playable by VRChat's AVPro video player; Rust port of mistlink), from local screen capture or relayed from a tc-chat screen share (video + audio) over the p2p network
- mailbox — p2p store-and-forward messaging ("p2p mail server"): when the recipient is offline, a bot node holds the deposit and forwards it once they come online
- tunnel — P2P port forwarding over the mist network (Rust port of
p2p): tunnel TCP/UDP through NAT to a peer,
with per-connection approval, a persisted trust store, and an audit log. Wire-compatible
with the standalone
p2pbinary, so amistlnode and ap2pnode can tunnel to each other - ui — embedded web dashboard (
mistlwith no args, ormistl ui): operate all of the above from a browser athttp://127.0.0.1:6480/, bilingual EN/JA, with drag-and-drop file storage and live settings - ai — p2p AI network, wire-compatible with mistai protocol v1 (tc-mistllm / tc-translate peers can share the room): provide LLM inference to peers from any OpenAI-compatible upstream, and/or serve a local OpenAI-compatible API endpoint whose requests are answered by the network
The release build is a single, fully standalone mistl.exe: screen capture uses
Windows.Graphics.Capture, H264 encoding uses OpenH264 compiled into the binary, and
the MSVC runtime is statically linked — no external tools, DLLs, or installers.
- Rust (edition 2024) and git (to clone the mistlib and mistlib-consensus source repositories)
- just (optional but recommended task runner)
- cmake on PATH at build time (libopus is compiled from source
for the relay's audio pipeline). With cmake ≥ 4.0, also set
CMAKE_POLICY_VERSION_MINIMUM=3.5in the build environment. - Optional: ffmpeg on PATH, only if you switch
stream.capture_backendto"ffmpeg"(the default"native"backend has no external dependencies)
mistlib is a path dependency fetched into .mistlib-src/ (a plain git clone,
not committed) by scripts/fetch-mistlib, configured through .env:
$ cp .env.example .env # defaults to the public mistlib; no credentials needed
$ just release # fetches mistlib on first build, then cargo build --releaseMISTLIB_REPO/MISTLIB_REF in .env select the source: the public
mistlib (main, the default) or
the private mistlib-dev (develop) for unreleased upstream commits — the
example file carries both, one commented out. Changing them re-points the
existing .mistlib-src/ clone on the next fetch, so there is no need to delete
it. MISTLIB_CONSENSUS_REPO/_REF work the same way for
.mistlib-consensus-src/.
Without just: run scripts/fetch-mistlib.sh (or scripts\fetch-mistlib.ps1
on Windows) once, then cargo build --release. Re-run just fetch-mistlib
whenever you want to update to the latest MISTLIB_REF; the clone in
.mistlib-src/ is a normal git checkout, so you can also edit, branch, and
push mistlib changes from there while developing against it.
$ just release-linux # builds via WSL2, packages to dist/mistl-linux-x86_64This runs cargo build inside WSL2 against the same checkout rather than
true host cross-compiling, since mistlib links several C libraries
(openh264/opus via cmake, fdk-aac via cc, OpenSSL for reqwest's native-tls)
that need a real Linux toolchain/sysroot. Requires WSL2 with a distro that
has a Rust toolchain plus build-essential cmake pkg-config libssl-dev
(Ubuntu: sudo apt install build-essential cmake pkg-config libssl-dev and
rustup). The Linux build lands in
target/x86_64-unknown-linux-gnu/release/, kept separate from the Windows
build's target/release/.
Double-click mistl.exe (or run mistl with no arguments): the daemon starts in
the background and the dashboard opens in your browser — everything below can be
done from there, including settings. The dashboard is bilingual (English/日本語,
following your browser language).
The CLI acts as a client to a resident daemon (IPC: loopback TCP with token auth). Client commands start the daemon automatically if it isn't running.
# Daemon management
$ mistl status # combined overview (daemon, stream, ai)
$ mistl daemon start # start in the background (prints the dashboard URL)
$ mistl daemon start --host 0.0.0.0 # also bind the dashboard for other devices on the LAN
$ mistl daemon status
$ mistl daemon stop
# Configuration (no config.toml editing needed)
$ mistl config show # secrets masked
$ mistl config set ai.default_preset_id default
$ mistl config set stream.room my-room
# Profile / DID keys
$ mistl key did # generates a key on first call
$ mistl key list
# DID delegation (root/leaf chain): let a browser app's per-origin identity
# be recognized as the same person as this mistl-held root identity
$ mistl key delegate --leaf did:key:z6Mk... # manual transfer: prints a DelegationV1 JSON to paste into the app
$ mistl key delegations # list delegations issued so far
$ mistl key pair # no-copy-paste: prints a pairing code, waits for the app to claim it
$ mistl profile set display_name "yourname"
$ mistl profile show
# Storage
$ mistl store put .\file.bin # -> returns a CID
$ mistl store ls
$ mistl store get <cid> --output .\file.bin
# Screen share (VRChat)
$ mistl stream start # local screen -> rtsp://<LAN IP>:8554/stream
$ mistl stream start --room my-room # ...and also publish it into a mistlib room (native capture, Windows)
$ mistl stream relay --room my-room # tc-chat share (video+audio) -> same URL
$ mistl stream selftest --audio aac # synthetic video+audio feed for local testing
$ mistl stream status
$ mistl stream stop
# Mailbox
$ mistl mailbox send <did|node-id> --message "hello"
$ mistl mailbox send <did|node-id> --file .\data.bin
$ mistl mailbox fetch # receive messages pending for me
$ mistl mailbox ls # deposits this node is holding as a bot
# Tunnel (P2P port forwarding)
$ mistl tunnel start # join the configured room (generates one on first run)
$ mistl tunnel room # show the room id to hand to the other side
$ mistl tunnel room --new # issue a fresh room id and switch to it
$ mistl tunnel room --list # recently used rooms
$ mistl tunnel serve 22 # offer local port 22 to peers in the room
$ mistl tunnel connect <room> 10022:22 # from the other machine: local 10022 -> peer's 22
$ mistl tunnel status # peers, forwards, pending approvals, trust list
$ mistl tunnel approve <id> --remember # allow a peer's connection, and remember it
$ mistl tunnel tui # interactive terminal UI (talks to the daemon)
# Web dashboard
$ mistl ui # starts the daemon if needed, opens the browser
# AI network
$ mistl ai provide start # serve LLM inference to peers using the resolved default preset
$ mistl ai serve start # local OpenAI-compatible API -> http://127.0.0.1:6478/v1
$ mistl ai chat "hello" --model mock-echo-1
$ mistl ai models
$ mistl ai statusWith ai serve running, any OpenAI client works against this node — requests go to
the local provider when one is running, otherwise to the first provider discovered
on the p2p network:
$ curl http://127.0.0.1:6478/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"hi"}],"stream":true}'Someone shares their screen in a tc-chat room (browser screen share, optionally with tab/system audio). On the machine that should feed VRChat, run:
$ mistl stream relay --room <the tc-chat room id>
Paste this URL into the VRChat video player:
rtsp://192.168.x.x:8554/streammistl joins the room as a WebRTC peer, receives the share (H264 video + Opus audio),
transcodes the audio to AAC (what AVPro plays over RTSP), and serves both tracks on
the RTSP URL. Paste the printed URL into any AVPro-based VRChat video player.
Note: the p2p transport supports multiple simultaneous rooms per process, so
stream.room (shared by both stream relay and stream share) can name its
own room independent of mailbox.room_id and ai.room_id -- or reuse one of
them if you'd rather keep everything in one room.
Any number of viewers (mesh + cascade): VRChat's AVPro can only play a URL,
not join the p2p swarm, so the scalable and lowest-latency arrangement is for
each viewer to run mistl stream relay --room X on their own machine and
point their VRChat at their own rtsp://127.0.0.1:8554/stream. Everyone talks
to their own loopback (no public IP or port-forwarding).
With [stream] cascade = true (the default), every relay node in the room
runs a small Raft control plane to elect a leader among themselves (this
is relay-node-only leader election — separate from, and invisible to, the
tc-chat browser peers sharing the screen). The leader is the one that locks
onto the sharer directly and re-publishes what it receives back into the
room as its own tracks (raw passthrough, no re-encoding); every other relay
node (follower) locks onto the leader's re-published tracks instead of
the sharer. This means the sharer's browser only ever uplinks to one peer
(the leader) no matter how many relays are watching, and it also reaches
relays that have no direct p2p connection to the sharer, since mistlib's
overlay is a selective mesh rather than a full one. If the leader goes away,
the remaining relays elect a new one automatically and re-lock — no manual
intervention. Set cascade = false to opt a node out and fall back to the
original "lock onto the sharer directly" behavior.
See VERIFY.md for the full topology, the log lines that confirm
each stage (including cascade role/leader changes), and mistl stream selftest — a synthetic video+audio feed that exercises the exact two-track
RTSP output VRChat consumes, so you can confirm the playback path locally
(ffprobe/ffplay) without a live p2p share.
mailbox send returns one of three status values:
| status | meaning |
|---|---|
delivered |
recipient was online; delivered directly |
deposited |
deposited with a connected bot node |
queued |
no reachable peers; saved to the local outbox for retry |
Configuration lives in %APPDATA%\tik-choco\mistl\config\config.toml (created with
defaults on first run) and can be changed with mistl config set or in the
dashboard's Settings panel — changes hot-reload into the running daemon and apply
the next time the affected service starts (room ids and [ui] need a daemon
restart):
[identity]
# display_name = "yourname"
[storage]
# blocks_dir = 'D:\mistl-blocks'
capacity_bytes = 10737418240
# room_ids = ["my-storage-room"] # tc-chat room(s) for peer block exchange; default: local-only, no room joined
[stream]
rtsp_url = "rtsp://127.0.0.1:8554/stream" # use 0.0.0.0 to expose on the LAN
frame_rate = 30
audio_capture = false # local capture audio: not implemented yet
capture_backend = "native" # "native" (built-in) or "ffmpeg"
max_width = 1920 # native backend: downscale wider screens
# room = "my-room" # tc-chat room for `stream relay`/`stream share`
audio_codec = "aac" # relay audio track: "aac" (AVPro) or "opus"
cascade = true # cascade distribution across relay nodes (see below)
[mailbox]
# room_id = "my-private-room" # default: "mistl-mailbox-v1"
serve_as_bot = true # hold deposits for other peers
[tunnel]
enabled = false # join the tunnel room automatically at daemon start
# room_id = "a1b2c3d4" # default: generated on first `tunnel start` and saved here
auto_accept = false # approve every inbound connection without asking (use with care)
# allow_peers = ["<node-id>"] # auto-approve only these peers; anything else still prompts
stdio_enabled = false # let approved peers run `stdio_command` on this machine
# stdio_command = ["pwsh", "-NoLogo"] # only used when stdio_enabled = true
[ai]
# room_id = "my-llm-room" # default: the mailbox room (see note)
# default_preset_id = "default" # which [[ai.presets]] entry `ai provide`/`ai serve` use by default
# tts_preset_id = "tts-default" # which preset answers inbound tts_request; unset = TTS not offered
# stt_preset_id = "stt-default" # which preset answers inbound stt_request; unset = STT not offered
# advertised_models = ["llama3"] # default: fetched from the resolved preset's provider /models
api_listen = "127.0.0.1:6478" # local OpenAI-compatible API (serve)
request_timeout_secs = 120 # p2p inactivity timeout (resets per chunk)
# [[ai.providers]] # connection info ("where to connect")
# id = "default"
# label = "Default"
# base_url = "http://127.0.0.1:11434/v1" # OpenAI-compatible upstream, e.g. Ollama
# api_key = ""
# [[ai.presets]] # named model config ("how to call it")
# id = "default"
# label = "Default"
# provider_id = "default" # references an [[ai.providers]] id
# model = "llama3"
# temperature = 0.7
# reasoning_effort = "medium" # optional: "none" | "minimal" | "low" | "medium" | "high"
# voice = "alloy" # only meaningful for a preset referenced by tts_preset_id
# [ai.presets.lang_voices] # optional per-language voice overrides (tts_request.lang hint)
# en = "af_heart" # key: BCP-47 primary subtag, lowercase (matched case-insensitively)
# ja = "jf_alpha" # value: a real upstream voice id, same namespace as `voice` above
[ui]
enabled = true # serve the dashboard from the daemon
listen = "127.0.0.1:6480" # keep on loopback (no auth)Note: storage, mailbox, ai, and stream relay can each join their own room --
the p2p transport supports multiple simultaneous rooms per process. [ai] room_id
defaults to the mailbox room for convenience when unset; set it explicitly to
join a different room, e.g. an existing mistai app room. [storage] room_ids has
no such fallback -- leave it unset (or empty) to keep the store purely local (no
network join at all). Unlike the other room settings, room_ids is a list: the
store joins all listed rooms simultaneously, and the list can be changed at
any time without a daemon restart. The legacy single-room form (room_id = "my-room") still parses, loading as a one-element room_ids list.
Data lives in %APPDATA%\tik-choco\mistl\data\ (keys, blocks, spools, logs).
The user profile is a small JSON document, persisted at
data\identity\profile.json and returned (merged with the identity's did) by
profile.show. It is designed to be read as-is by sibling apps (tc-chat,
tc-storage) that share the same did:key identities and CID-addressed content
store:
{
"did": "did:key:z6Mk…",
"display_name": "Ada",
"bio": "Loves math",
"avatar_cid": "baf…",
"updated_at": "2026-07-06T12:34:56+00:00"
}did— the owner'sdid:key(Ed25519), the stable key other apps index by.display_name,bio— optional free-text fields.avatar_cid— optional. The root CID of the profile image in the shared content store (the same store asstore put/POST /api/store/upload, CIDv1 / sha2-256 / dag-cborFileManifest). The image itself is not inlined; any peer holding the block (or able to resolve it over the store) fetches the bytes by this CID — e.g.GET /api/store/download?cid=<avatar_cid>locally.updated_at— RFC 3339 timestamp of the last change, stamped on everyprofile.set, so a peer that has seen several copies can pick the freshest.- Any additional string fields set via
profile.setare preserved verbatim (they round-trip through the top level of the document).
Read/write it over the dashboard bridge or IPC:
profile.show{}→ the document above.profile.set{field, value}→ sets one field (display_name,bio,avatar_cid, or a custom name); an emptyvalueclears it.
The dashboard's Profile panel sets the avatar end-to-end: it downscales the
chosen image to 256 px client-side, uploads it to the content store
(POST /api/store/upload), and points avatar_cid at the returned CID.
A browser app's did:key is scoped to its own origin, so the same person looks
like a different identity in every browser/tab/dev-port. mistl can hold a
permanent root identity and sign short-lived delegations to an app's
existing per-origin leaf key, so peers that understand the chain treat
leaf messages as belonging to root. Chain depth is fixed at 1 (root → leaf,
no sub-delegation) and there's no revocation list — delegations are short-lived
(exp, recommended 60 days) and reissued rather than revoked. Full spec:
protocol/docs/data-contracts/docs/did-delegation.md (shared byte-for-byte
with the TypeScript implementation in mistai).
Two issuance paths:
- Pairing (
mistl key pair, recommended): joins a short-lived, code-derived mistlib room and prints aXXXX-XXXX-XXXX-XXXXcode. Enter that code in the browser app's "pair with mistl" flow; once it claims the code (MAC-authenticated so only someone who read/typed the code can), mistl signs and broadcasts a delegation, then leaves the room (one-shot — the code can't be reused).--ttlsets the issued delegation's lifetime (default 60d),--timeouthow long to wait for a claim (default 5m). - Manual transfer (
mistl key delegate --leaf <did:key> [--ttl 60d]): prints the signedDelegationV1JSON directly, to copy/paste (or QR) into an app that supports importing one.
mistl key delegations lists every delegation this identity has issued as
root, including expired ones.
IPC/dashboard bridge:
key.delegate{leaf, ttl?}→DelegationV1JSONkey.delegations{}→[DelegationV1 & {expired: bool}, ...]key.pair.start{ttl?, timeout?}→{code, formatted_code, room, expires_at}key.pair.status{}→{status, code?, leaf?, app?, delegation?, expires_at?}key.pair.cancel{}→{cancelled: bool}
mistl <subcommand> --(JSON over loopback TCP)--> mistl daemon run
├─ identity (did:key ed25519, profile)
│ ├─ crypto.rs (tc-storage-compatible AES-GCM+PBKDF2 envelope)
│ ├─ delegation.rs (root -> leaf DID delegation: issue/verify)
│ └─ pairing.rs (mistlib-room code pairing, issuer side)
├─ storage (mistlib StorageEngine + NativeBlockStore)
├─ stream (screen capture or p2p WebRTC relay → RTP → RTSP server)
├─ mailbox (signed envelope spools over net)
├─ ai (mistai protocol v1 over net + local OpenAI-compatible HTTP)
├─ web (embedded dashboard + /api/call bridge into the same router)
└─ net (shared mistlib WebRTC/Nostr transport: one engine, one room)
- IPC discovery info is written to
data\daemon.json(port + random token); only the local user can connect - tc-storage interop: DID format, AES-256-GCM + PBKDF2-SHA256 (210k) envelopes
(
identity::crypto), CID semantics - DID delegation (
identity::delegation+identity::pairing): root/leaf chain issuance, byte-compatible with mistai's independent TypeScript implementation of the same spec — see "DID delegation (root/leaf)" above - mistlink interop: AVPro-friendly dummy SPS/PPS RTSP keepalive, PT 96 / SSRC 0x12345678
streambackends:nativecaptures via Windows.Graphics.Capture and encodes with OpenH264 in-process;ffmpegspawns ffmpeg (gdigrab → MPEG-TS → demux) as a fallback;relay(viastream relay) receives a tc-chat WebRTC screen share over mistlib and re-serves it (H264 passthrough, Opus→AAC transcode, RTCP sender reports for lipsync)aispeaks mistai protocol v1 on the wire (provider_hello/llm_request/llm_response_chunkwith seq reordering /llm_response_done), so browser-based mistai consumers and providers in the same room interoperate; thenetmodule multiplexes mailbox and ai traffic over mistlib's single raw-message handler by message shape
mailboxfile sends forward only the envelope (cid/name/size); p2p block transfer of the file bytes is not implemented yetstreamlocal capture is video-only (audio_capture is ignored); relayed shares carry audio. Inbound NACK is not implemented (loss shows until the next keyframe; the relay requests one every 5s)- Cascade (
[stream] cascade) v1: a follower's own PLI targets the leader's re-published track, which isn't forwarded back to the original sharer -- only the leader's PLI actually reaches it, so a late-joining follower's keyframe wait is bounded by the leader's ~5s PLI cadence rather than its own. A relay that's already locked onto the sharer when a new leader is elected doesn't re-request the sharer's tracks either; it relies on either already being connected to the new leader's re-publish or receiving a fresh negotiation - No bot capability advertisement (every connected peer is treated as a bot candidate)
aiimplements the mistai protocol's chat, and TTS/STT oncetts_preset_id/stt_preset_idare configured (otherwise voice requests get an immediatevoice_error);raft_messagescheduling is passed through untouched- The local API server (
ai serve) has no auth; keepapi_listenon loopback unless the network is trusted - The web dashboard has no login; it rejects cross-origin and non-localhost requests,
but anyone with local access can use it — keep
[ui] listenon loopback
Binary builds statically link the
Fraunhofer FDK AAC Codec Library for Android
(via the fdk-aac crate) for the relay's Opus→AAC transcoding, which is licensed
under its own terms (© Fraunhofer-Gesellschaft; see the fdk-aac NOTICE), and
libopus (BSD-3-Clause).