# Sparsh > A direct, encrypted wire between two paired keys. Files, messages and MCP itself, with no server that can read a byte. --- ## Sparsh > A direct, encrypted wire between two paired keys. Files, messages and MCP, with no server that can read a byte. > Source: https://jvoltci.github.io/sparsh/docs/ Two devices, one channel, nothing in between that can read it. Open Sparsh in a browser and send a file. Install the CLI and an agent can do the same thing headlessly. Grant one pairing the right, and an agent on one machine can call the tools on another, with no port, no VPN and no account. A round trip on a warm channel is **0.21** ms, measured, on loopback. Every number on this site is read from the ledger the test harnesses write, never typed. ## Start The browser side needs nothing at all: [open the app](../) on both devices. For the CLI, today, run it from the repository: ```bash git clone https://github.com/jvoltci/sparsh && cd sparsh/frontend && npm install ``` Then, on the device that will be reached: ```bash npm run peer -- invite ``` Open the link it prints on the other device. That is the pairing, and it is the only step a human has to do. ## For an agent ```bash claude mcp add sparsh -- sparsh mcp-tools ``` Four tools arrive: `sparsh_status`, `sparsh_devices`, `sparsh_send` and `sparsh_inbox`. Tools return paths, never bytes. There is no pairing tool, deliberately: an agent must never pair itself. ## Hand this page to your agent Every page here is also markdown at the same address with `.md` on the end, and the whole site is in [llms.txt](/llms.txt). Nothing has to be scraped. > Sparsh is a peer-to-peer transport for devices and agents. Read > https://jvoltci.github.io/sparsh/docs/llms-full.txt for the full documentation, > then answer using only what it says. ## What to read next - [Install](/install) puts the CLI on a machine and pairs two devices. - [Agents](/agents) is the MCP surface, local and remote. - [Security](/security) says what is encrypted, by what, and what is not. - [How it works](/how-it-works) is the handshake, the channels and the wire. - [Performance](/performance) is every measured number with its instrument. --- ## Install > Put the CLI on a machine, pair two devices, and send the first file. > Source: https://jvoltci.github.io/sparsh/docs/install/ ## The browser needs nothing Open [the app](../) on both devices. One shows a code or a link, the other opens it. That is the whole install, and it is the point: the far end never has to install anything. ## The CLI ```bash git clone https://github.com/jvoltci/sparsh && cd sparsh/frontend && npm install ``` **There is no published release yet.** `sparsh-cli` on npm is the reserved name and nothing more: its own description says "not yet functional". The bare name `sparsh` was refused outright, because it sits two edits from `slash`, which is pulled 123 million times a week, and that guard is not appealable. So every `sparsh ` on this site is `npm run peer -- ` from `frontend/` today. The forms and the flags are identical; only the prefix differs. Check it: ```bash npm run peer -- whoami ``` That prints this device's public key as an `npub`, its name, and the devices it has paired with. The key is generated once and kept in `~/.sparsh/state.json`, mode 0600. A stolen state file is this device, so it is treated that way. ## Pair two devices, once On the device that will be reached: ```bash sparsh invite ``` It prints a link and waits. Open the link on the other device, or hand the token to `sparsh pair` there. The two now know each other's keys, and neither needs a code again, on any network. ```bash sparsh devices ``` **A human does this step, always.** An agent is never given a verb for pairing, and the daemon refuses a session from a key it has not been introduced to. That line is what separates a useful network from a prompt-injection amplifier. ## Send something ```bash sparsh send report.pdf --to "Phone" ``` No code. The pairing is the authorisation, and the name is enough. ## Stay reachable A cold send pays about **0.59** s before the first byte: signalling, ICE, DTLS. A second send on the same session pays about **0.7** ms. So a resident process is worth more than any change to the wire: ```bash sparsh serve --into ~/Downloads ``` That keeps sessions warm and keeps this device findable, so a paired device can send to it unprompted. It exits on its own after ten idle minutes, because a background process nobody asked for is a bug however small. ## Environment `SPARSH_HOME` moves the identity and the pairings. `SPARSH_SIGNALING_URL` points at a signalling server; without one, the public Nostr relays are used and there is no device discovery. The full list is on the [CLI page](/reference/cli). The older `TELEPORT_*` names are still read. Sparsh was called Teleport until 2026-09-19, and dropping them would break every shell profile already written, silently, because an unset variable just falls back to a default. --- ## Send a file > From the app, from a terminal, and from an agent. > Source: https://jvoltci.github.io/sparsh/docs/send/ ## From the app Open it on both devices, pair once, then tap the device and choose the file. It starts immediately: there is no upload step, because there is nowhere to upload to. Files of any size. An interrupted transfer resumes where it stopped rather than starting again, because the receiver keeps a map of which chunks it already has. ## From a terminal ```bash sparsh send report.pdf --to "Phone" ``` By name, with no code, because the pairing already happened. Several files at once is several arguments. Exit code 0 means the far side has the bytes on disk and the hash matched, not that they were sent. To receive: ```bash sparsh receive --into ~/Downloads ``` Or leave `sparsh serve` running and the device is simply reachable. ## From an agent ``` sparsh_send(paths: ["/abs/path/report.pdf"], to: "Phone") ``` It returns when the far side has the file. A path inside the working directory goes straight through. Anything outside asks you first, because reading an arbitrary path and shipping it is exactly what a prompt-injected agent reaches for. Arrived files come back as **paths**, never contents: ``` sparsh_inbox() ``` Read them with your own file tools. A gigabyte base64'd into a tool result is a context-window fire, not a feature. ## What can go wrong **The device is offline.** Presence needs a signalling server; on public relays alone there is no device list and the honest answer is an empty one rather than a pretend one. **The pairing predates device keys.** The daemon refuses a session it cannot verify rather than accepting a weaker one. Run `sparsh invite` once and the pairing is upgraded for good. **Both peers are behind a strict NAT.** ICE finds a path or falls back to a TURN relay over 443. The relay forwards ciphertext; it cannot read a byte. --- ## Agents > Four tools locally, and one agent calling another device's tools through the pairing. > Source: https://jvoltci.github.io/sparsh/docs/agents/ There are two separate things here and they are easy to confuse. **Locally**, Sparsh gives your agent four tools for moving files to devices you have paired with. **Remotely**, Sparsh carries MCP itself, so an agent on one machine can call the tools a server on another machine exposes, with no port, no VPN and no account. ## Locally: four tools ```bash claude mcp add sparsh -- sparsh mcp-tools ``` `sparsh_status`, `sparsh_devices`, `sparsh_send` and `sparsh_inbox`. The [reference](/reference/mcp) is generated from the running server, so it cannot drift from what your agent actually sees. Two rules shape them: **Tools return paths, never bytes.** That is the MCP maintainers' own position and it is obvious at these sizes: a 1.59 GB file base64'd into a tool result is a context-window fire, not a feature. **There is no pairing tool.** An agent must never pair itself, and the surest way to guarantee that is to give it no verb for it. Sending a file from inside the working directory goes straight through. Anything outside it asks you first, because `send(path)` is "read any file and ship it", which is exactly what a prompt-injected agent reaches for. `SPARSH_ALLOWED_ROOTS` widens what counts as inside. ## Remotely: MCP over the wire On the device that has the server, a human grants one pairing the right to drive one command: ```bash sparsh expose "Mac · Claude Code" -- npx -y @modelcontextprotocol/server-filesystem ~/Documents ``` On the device that wants it: ```bash claude mcp add homebox -- sparsh mcp "Home box" ``` That is all. The real MCP client and the real MCP server now talk to each other. ### How it actually works The two daemons are a **transparent bridge**, not a proxy. A fourth data channel opens on the session that is already warm, carrying MCP's own stdio framing: newline-delimited JSON-RPC, exactly what a local server speaks. Neither end knows Sparsh is there, so protocol negotiation, resources, prompts, progress and cancellation all pass through unchanged, now and in the next spec revision. Measured: a `tools/call` through the whole chain is **0.67** ms median, p95 **1.15** ms, on loopback. The raw channel round trip is **0.21** ms, so the four process hops cost about half a millisecond between them. ### Who may **Identity is the connection.** The peer is the key its SDP was signed with, and WebRTC checks the DTLS certificate against the fingerprint inside that SDP. There is no token to steal and no header to forge. **Authorisation is one grant, made by a human.** No pairing has it by default. The grant names one server, not a shell. `sparsh unexpose ` takes it back and closes any live channel immediately. **Two requests never cross toward you.** `sampling/createMessage`, so a remote server cannot spend your model, and `roots/list`, so it cannot read your project layout. Every call leaves one audit line with the peer, the tool and the size. Never the arguments: those are your data and routinely contain secrets. ### What it cannot protect you from A remote server's replies are text in your agent's context, exactly as with any third-party MCP server. The device is authenticated; the agent on it may still be prompt-injected. Your MCP client's own approval prompts are the human in the loop, and they apply here as anywhere. Read [Security](/security) before exposing anything. --- ## Security > What is encrypted by what, what a paired peer can do to you, and what is still open. > Source: https://jvoltci.github.io/sparsh/docs/security/ The design goal: **the only thing leaving your device unencrypted is what your operating system already sees.** Your files meet that goal. They ride a DTLS handshake the two devices perform directly, and no relay ever holds a key for it. Your signalling meets it between devices that have paired. It does not meet it when somebody types a six-digit code, and that is said plainly below rather than buried. ## What is encrypted, by what | Layer | Protects | By | |---|---|---| | HTTPS | the page itself | TLS 1.3 | | Signalling | the SDP offer, answer and ICE candidates | AES-256-GCM over the relay's own TLS, and between paired devices the SDP is signed | | Data channel | files, messages, MCP | DTLS, fingerprint pinned in the SDP | | Media | audio, video, screen | SRTP from the DTLS handshake | DTLS keys are never sent anywhere. The peers exchange certificate fingerprints inside the SDP and then perform a fresh handshake directly between themselves. A relay that drops or reorders events can break a connection. It cannot read one. ## Between paired devices, the SDP is signed Every device holds a secp256k1 keypair and its public key **is** its identity. Once two devices have paired, each knows the other's key, so the SDP carrying the DTLS fingerprint is signed by the sender and verified by the receiver before WebRTC is ever given it. That matters most on the signalling server path, where the SDP is not encrypted end to end. The server can still read an offer, which it always could. It can no longer substitute one: swapping the fingerprint to sit invisibly in the middle now fails verification and the connection is refused. Proven rather than asserted. The end-to-end test was run with the check removed, and the tampered SDP was accepted and the file sent. With the check in place the same run is refused. ## First contact without a code `sparsh invite` prints a link carrying **128 bits of secret and this device's public key**, both in the URL fragment, which RFC 3986 keeps off the request line. Neither reaches a server or an access log. That closes first contact in both directions. The joiner verifies the host's offer against the key it was handed, and the host verifies the joiner through a proof of the secret bound to the joiner's own key, so a proof captured in flight cannot be replayed by a different device. The invitation is single use. An invitation offered over a transport that cannot check it is **refused**, not quietly downgraded. A link that looked strong while verifying nothing would be worse than the code it replaces, because it would not admit what it is. ## The typed code is still weak A six-digit code is 900,000 values, and the relay topic is a plain hash of it. An attacker computes all 900,000 hashes once, seconds of CPU, subscribes to every one on the public relays, and waits. Any session that appears is matched straight back to its code, and the code is the encryption key. The attack is passive, precomputed and reusable. It needs no guessing and no race. What it yields passively: the whole SDP, both peers' addresses and the DTLS fingerprints. The file bytes stay unreadable, because DTLS performs its own key exchange. What it yields actively: the file bytes, because whoever holds the code can publish a forged answer and become the far end. **Use the link.** It is why the link exists. ## What exposing an MCP server means `sparsh expose -- ` runs that command **as you, with your file permissions**, whenever that one paired device opens a tools channel. The device is authenticated, so the only question is whether you trust the agent on it with that server. Choose what you expose the way you choose what you `sudo`: a filesystem server rooted at one directory, not a shell. What bounds it: no pairing has the grant by default; the grant names one server; the server starts with a minimal environment rather than yours, so an API key in your shell does not travel; one channel per session; a 10 MiB cap per message; one audit line per call with the peer, the tool and the size, never the arguments; and `unexpose` closes a live channel immediately. Toward the device consuming tools, two requests never cross: `sampling/createMessage`, so a remote server cannot spend your model, and `roots/list`, so it cannot read your project layout. ## What is not solved Said plainly, because a list of mitigations with the gaps left out is worse than no list. - **There is no accept prompt for an incoming file.** A paired device can send and it starts arriving. The consent was the pairing. - **The peer chooses the filename**, and it reaches your downloads. The name is checked for path separators, not for honesty. - **A remote server's replies are text in your agent's context.** The transport authenticates the device, not the intentions of the software on it. - **None of this decides who you pair with.** It bounds the damage. --- ## How it works > The handshake, the four channels, and the wire that carries a file. > Source: https://jvoltci.github.io/sparsh/docs/how-it-works/ ## Two browsers cannot call each other They have no address anybody can dial. So before any byte moves, they must agree on what codecs they support, what network paths exist between them, and what keys to use. That is the SDP offer and answer, plus ICE candidates, and it is about 2 KB. Something has to carry those 2 KB. Sparsh has three ways and they are all the same interface: | Transport | When | Rendezvous | |---|---|---| | Nostr relays | the default | eight public relays, ephemeral events, nothing stored | | WebSocket | a signalling server is configured | your own box, and it adds trickle ICE, TURN credentials and device discovery | | Offline QR | no internet at all | two QR scans | After the handshake the relays are irrelevant. The session survives every one of them going down. ## Four data channels | Label | Carries | |---|---| | `file-transfer` | binary chunks, size negotiated per connection | | `clipboard-sync` | text | | `control` | file headers, progress, cancels, the round-trip probe | | `mcp` | MCP itself, opened on demand | All four are DTLS encrypted. There is no point at which the bytes are in the clear in transit. The fourth opens **after** the connection is up, in band, with no renegotiation. It carries newline-delimited JSON-RPC, which is exactly the framing a local MCP server speaks over a pipe, so both daemons are bridges and neither end knows Sparsh is in the middle. ## The wire Small files take a single ordered channel. Files over 8 MiB take the striped wire: several channels at once, each frame carrying its transfer id, its index and a SHA-256 of its payload, with a have-bitmap on the receiver so an interrupted transfer resumes rather than restarts. A frame that does not match all three is dropped, not written. ## Latency is the number | What | Measured | |---|---| | round trip on a warm channel, median of 30 | **0.21** ms | | the same, p95 | **0.54** ms | | cold connect, process start to first byte | **0.59** s | | warm first byte, second file on the same session | **0.7** ms | Cold start is roughly seven hundred times the warm one. Everything an agent waits for is in the setup, not the transfer, which is the entire argument for `sparsh serve` and why a resident process was built before any faster wire. **The rule that follows:** a change that raises throughput while raising the round trip is a regression here, and it is rejected on that basis. See [Performance](/performance) for the instruments. --- ## Performance > Every measured number, with the instrument that produced it. > Source: https://jvoltci.github.io/sparsh/docs/performance/ Every figure on this site is read from `docs/perf/data/*.jsonl` when the page is built. None is typed. Those files are appended by the test harnesses themselves, one JSON line per run, each carrying the commit that produced it. **A claim without an instrument is a hope, not a claim.** ## Latency Two headless peers over loopback on one Apple M5 Pro. | What | Value | |---|---| | application round trip, median of 30 | **0.21** ms | | the same, p95 | **0.54** ms | | cold connect, process start to first byte | **0.59** s | | warm first byte, second file on the same session | **0.7** ms | | a `tools/call` through the whole MCP chain, median | **0.67** ms | | the same, p95 | **1.15** ms | The round trip is at the floor. A fraction of a millisecond over loopback is the runtime and the kernel, not the protocol, so effort spent optimising messaging would be effort wasted. The MCP figure is the interesting one. It crosses four process boundaries: your agent, the `sparsh mcp` pipe, the Unix socket, the daemon, the channel, the far daemon, and the exposed server's stdin. All of that costs about half a millisecond above the raw channel. ## Throughput, and why one number is a lie A transfer costs **a fixed amount plus a rate**: ``` seconds = fixed + size / rate ``` Measured: the fixed part is roughly 3.3 to 3.6 s in a browser and **0.59** s headless. So a single megabits figure at a single file size is the two mixed together, and which one it mostly reports depends on the size. | File | Composite rate | How much is the fixed cost | |---|---|---| | 32 MiB | 60 Mbps | most of it | | 128 MiB | 173 Mbps | about half the seconds | | 512 MiB | 287 Mbps | about a fifth | The same wire does 368 to 379 Mbps steady state. Every 128 MiB row understates it, and overstates the effect of any change. **So quote the size with the number, and quote the instrument too.** At 512 MiB one harness measures 287 Mbps and another measures 394 on the same machine, because one instruments both pages and the other only the sender. ## Real devices | Route | Rate | |---|---| | Mac to Android, 1.59 GB, local network | 9 to 17.8 MB/s | | Mac to Mac, loopback | 46.8 MB/s | No iPhone row exists yet. When one does it will appear here, because this page reads the ledger rather than describing it. ## The rules 1. A regression under 10% is noise on one machine. Run it twice. The rows span plus 15.7% to minus 10.1% around their median, which is why the gate takes a median of three rather than trusting one. 2. Never quote one run at one size. 3. Label everything: **measured** has an instrument, a date and a device; **verified** was read in a primary source and cited; **suspected** is a hypothesis with a plan to test it. --- ## CLI > Every command the sparsh binary answers to, generated from its own usage text. > Source: https://jvoltci.github.io/sparsh/docs/reference/cli/ {/* GENERATED by site/scripts/reference.mjs from frontend/src/peer/cli.ts. Do not edit: run `node scripts/reference.mjs`. */} ```bash git clone https://github.com/jvoltci/sparsh && cd sparsh/frontend && npm install npm run peer -- whoami ``` The npm package `sparsh-cli` is the reserved name, not a release yet: what is published today is a placeholder that says so in its own description. Until it ships, `npm run peer -- ` is `sparsh `, and every form below reads the same either way. ## Commands | Command | What it does | |---|---| | `sparsh join [--into DIR] [--once] [--json]` | receive from the device showing `` | | `sparsh send ... (--to or --code ) [--json]` | send files to a device | | `sparsh host [--into DIR] [--send ...] [--json]` | show a code; receive, or send once joined | | `sparsh ping --code [--count N] [--json]` | round trip to that device, min/median/p95 | | `sparsh invite [--into DIR] [--json]` | show a link to pair with, no code | | `sparsh pair [--json]` | pair using a link someone showed you | | `sparsh devices [--json]` | paired devices, and which are online now | | `sparsh serve [--into DIR] [--json]` | stay resident: presence + warm sessions | | `sparsh status [--json]` | is a daemon running, and what is warm | | `sparsh receive [--into DIR] [--json]` | wait for a paired device, no code | | `sparsh mcp-tools` | stdio MCP server: this machine's four tools, for your own agent | | `sparsh mcp ` | stdio MCP endpoint for the server `` exposes to you | | `sparsh expose -- [args…]` | let `` drive this MCP server on this machine | | `sparsh unexpose ` | take that back; a live channel closes now | | `sparsh whoami` | this device key, name and pairings | ## Environment | Variable | Meaning | |---|---| | `SPARSH_SIGNALING_URL` | the signalling server the app is built with; Nostr relays if unset | | `SPARSH_HOME` | identity + remembered devices (default ~/.sparsh) | | `SPARSH_SITE_URL` | the site the join link points at | | `SPARSH_LABEL` | the name the other device sees | The older `TELEPORT_*` names are still read, for one release. Sparsh was called Teleport until 2026-09-19, and dropping the old names would break every shell profile and CI job that already sets them, silently. --- ## MCP tools > The tools an agent sees, read from the running server rather than written down. > Source: https://jvoltci.github.io/sparsh/docs/reference/mcp/ {/* GENERATED by site/scripts/reference.mjs by starting frontend/src/peer/mcp.ts and reading its own tools/list. Do not edit. */} ```bash claude mcp add sparsh -- sparsh mcp-tools ``` `mcp-tools` is this machine's four tools. `mcp ` is the other direction, a pipe to a server on a device you are paired with, and the two are separate verbs because registering the wrong one gets you a bridge to nothing and no error. Two rules shape all of these. **Tools return paths, never bytes**, which is the MCP maintainers' own position and obvious at the sizes Sparsh moves. And **there is no pairing tool**: an agent must never pair itself, so it is given no verb for it. ## `sparsh_status` Is Sparsh running on this machine, which device is it, and which sessions are warm. Start here if a send fails. _No arguments._ ## `sparsh_devices` The devices this machine has been paired with, and which are reachable now. Pairing is done by a human with `sparsh invite`; there is no tool for it. _No arguments._ ## `sparsh_send` Send files to a paired device, by its name. Returns when the far side has them on disk. Files outside this project will ask the user first. | Argument | Type | Required | Meaning | |---|---|---|---| | `paths` | `string[]` | yes | Absolute or relative file paths to send | | `to` | `string` | yes | The device name, or enough of it to be unambiguous | ## `sparsh_inbox` Files other devices have sent to this one, as PATHS on disk. Read them with your own file tools; this never returns their contents. | Argument | Type | Required | Meaning | |---|---|---|---| | `dir` | `string` | no | Where to look; defaults to the daemon's inbox |