hoody-pipe is a named pipe that runs over the internet instead of a local filesystem. A sender writes bytes to a path with PUT or POST, receivers read the same path with GET, and the data streams straight through from sender to receivers in real time with no server-side storage. The path is any name you pick, the content is any bytes, and up to 256 receivers can read one transfer at the same time.
What pipe replaces
Section titled “What pipe replaces”Moving data between two machines usually means an FTP server, an SSH tunnel, a file upload service, WebSocket boilerplate, or a signaling server for WebRTC. Each of those needs setup, an authentication library, or a specialized client. hoody-pipe needs two curl commands:
# Terminal A: Send a filecurl -T report.pdf https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/my-report
# Terminal B: Receive the file (run this before, during, or after the sender connects)curl https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/my-report > received-report.pdfThere is no upload limit and no temporary storage: the bytes move from sender to receiver the moment both are connected.
Pipe has no per-pipe accounts and no service-level auth of its own. Hoody Proxy enforces access control centrally through IP allowlists, passwords, and JWT, so permissions are configured at the proxy layer rather than per pipe.
How it works
Section titled “How it works”- A sender POSTs (or PUTs) data to a path, for example
/api/v1/pipe/my-screen-share. - A receiver GETs the same path.
- Data streams directly from the sender to all receivers, with bounded buffers in memory and no temporary files.
- Either party can connect first. The server holds the early connection until the counterpart arrives, up to 300 seconds by default. Each participant can set its own deadline with
?wait=1through?wait=3600.
The path is a name you choose: /my-file, /demo-stream, /logs/today, or anything else that is not a reserved system path (/, /help, /noscript, /favicon.ico, /robots.txt). Reserved-path matching is normalization-robust, so /Help, /help/, /help., and /help%2e all fold to /help and are rejected; an attacker cannot register an aliased pipe over a built-in page. The canonical health endpoint is /api/v1/pipe/health, and the server explicitly 404s a bare /health with the hint '/health' is not a valid path. Use '/api/v1/pipe/health'. The same holds for /api/v1/pipe/metrics and a bare /metrics, so health and metrics cannot be pipe names either. The SDK and the CLI refuse every one of these names before sending a request, and the SDK’s URL builders (getUrl, getWsUrl, getDownloadUrl, getPageUrl) throw on them, with the same matching (any case, a trailing /, . or ./). They also refuse a name that only becomes reserved once repeated slashes are merged, such as /help or metrics//, because a proxy that merges slashes would route it to the built-in endpoint.
Quick examples
Section titled “Quick examples”Send and receive a file
Section titled “Send and receive a file”Run the CLI and HTTP sender and receiver in separate terminals. Keep the receiver output separate from the sender input; receiving into the source file truncates it. The SDK example starts both ends concurrently in one program. Replace the container placeholders with your container metadata. Every CLI example on this page needs the same routing on each end: --container $CONTAINER on every command, HOODY_CONTAINER set in every terminal, or --pipe-url / HOODY_PIPE_URL for a direct server (see Routing flags).
# Sender: Upload a filehoody pipe send backup ./backup.tar.gz --container $CONTAINER
# Receiver: Download the filehoody pipe receive backup -o received-backup.tar.gz --container $CONTAINER# Sender: Upload a file (PUT is natural for curl -T)curl -T ./backup.tar.gz \ https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/backup
# Receiver: Download the filecurl https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/backup \ -o received-backup.tar.gzimport { HoodyClient, PipeStream } from 'hoody-sdk';import { createReadStream, createWriteStream } from 'node:fs';import { Writable } from 'node:stream';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });// Generic byte-stream API (any source/sink) via PipeStreamconst pipe = PipeStream.fromClient(client, { id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER });
// Send: any PipeSource (file stream, Buffer, string, ReadableStream, TCP/Unix socket, AsyncIterable)const sending = pipe.send('backup', createReadStream('./backup.tar.gz')) .then(send => send.done);const receiving = pipe.receive('backup').then(recv => recv.body.pipeTo(Writable.toWeb(createWriteStream('./received-backup.tar.gz'))));await Promise.all([sending, receiving]);Stream text between containers
Section titled “Stream text between containers”# Container A: stream a long-running command's stdouthoody pipe send live-logs --from-cmd "tail -f /var/log/app.log"
# Container B: receive to stdout (the default sink)hoody pipe receive live-logs# Container A: Pipe logs in real-timetail -f /var/log/app.log | curl -T - \ https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/live-logs
# Container B: Watch the logscurl -N https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/live-logsimport { PipeStream } from 'hoody-sdk';import { Writable } from 'node:stream';
const pipe = PipeStream.fromClient(client, { id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER });
// Async-iterable source: works well for line-by-line producersasync function* tail() { for await (const line of process.stdin) yield line;}const sending = pipe.send('live-logs', tail()).then(send => send.done);const receiving = pipe.receive('live-logs').then(recv => recv.body.pipeTo(Writable.toWeb(process.stdout)));await Promise.all([sending, receiving]);Fan out to multiple receivers
Section titled “Fan out to multiple receivers”Send once and stream to several receivers at the same time. Run the sender and each receiver in separate programs or terminals:
# Sender: Stream to 3 receivershoody pipe send demo ./presentation.webm -n 3
# Receiver 1, 2, 3: Each runs thishoody pipe receive demo -n 3 -o received-presentation.webm# Sender: Stream to 3 receiverscurl -T ./presentation.webm \ "https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/demo?n=3"
# Receiver 1, 2, 3: Each runs this (all get identical copies)curl "https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/demo?n=3" \ -o received-presentation.webm// Sender: Stream to 3 receiversconst send = await pipe.send('demo', createReadStream('./presentation.webm'), { n: 3 });await send.done;
// Each receiverconst recv = await pipe.receive('demo', { n: 3 });await recv.body.pipeTo(Writable.toWeb(createWriteStream('./received-presentation.webm')));Live broadcast
Section titled “Live broadcast”Add live to broadcast a stream that anyone can join, leave and rejoin while it runs. The sender starts at once, even with nobody watching, and each viewer gets the stream from the moment it joins:
# Sender: broadcast a logtail -f app.log | hoody pipe send mylog - --live
# Viewers, whenever they like (up to 256 per broadcast)hoody pipe receive mylog --live# Sender: broadcast a logtail -f app.log | curl -sT - \ "https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/mylog?live"
# Viewers, whenever they like (up to 256 per broadcast)curl -sN "https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/mylog?live"
# A WebM stream: viewers join at the next keyframe; browsers open ?video&liveffmpeg -f x11grab -i :0 -c:v libvpx -deadline realtime -g 60 -f webm - | curl -sT - \ "https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/cam?live"# https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/cam?video&live// Senderconst send = await pipe.send('mylog', logStream, { live: true });
// Each viewerconst view = await pipe.receive('mylog', { live: true });for await (const chunk of view.body) process.stdout.write(chunk);- What a viewer gets. A recognized WebM video stream (MediaRecorder,
ffmpeg -f webm) starts with its header and then the next Cluster that begins with a video keyframe, so players can decode it; the join delay is the keyframe interval (-gin ffmpeg). Audio-only WebM joins at the next Cluster. Other streams, or WebM the relay cannot parse within its scan limits, start at the next chunk. There is noContent-Length, the response carriesX-Hoody-Pipe-Live: 1, and its headers come with the viewer’s first bytes. Bytes sent while nobody watches are dropped; nothing is stored. - Waiting. A viewer that arrives before the sender waits for it, like a receiver (5 minutes, or
?wait=). - Slow viewers never slow the stream. A viewer 2 MiB behind skips ahead to a keyframe (WebM). One that is 8 MiB or 4096 pieces behind, has queued data but takes nothing for 60 seconds, or is furthest behind when the server’s live memory budget is full, is cut: its body ends with an error, and
curlorfetchreports it. These limits apply to data waiting at the server. Under Bun,fetchcan read ahead without backpressure even when the SDK caller consumesPipeStreamslowly. The server can then see the viewer keeping up while the backlog grows in the viewer’s own memory; server queue limits do not bound that client memory. Under Node, slow consumption applies backpressure and the server can skip or cut the viewer as described. - Ending. When the sender finishes, each viewer has up to 60 seconds to take what it was already sent; its body then ends normally, or with an error if it has not taken everything by then. If the sender disconnects, fails, or sends nothing for 5 minutes, every viewer is cut.
- Status. The sender’s status shows
[INFO] Live: streaming. Viewers can join at any time., then[INFO] Live stream ended (peak N viewers).or an[ERROR]line.?statusshows"kind": "live"with the viewers watching inactiveReceivers;?progressstateandprogressevents carry"live": true(the finaldoneevent does not). - Not with
nabove 1,sha256,wsor a multipart body:400.
API endpoints summary
Section titled “API endpoints summary”All endpoints are relative to your pipe service URL:
https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.comData transfer:
POST /api/v1/pipe/{path}: send data to a pipe pathPUT /api/v1/pipe/{path}: send data (alias for POST, natural forcurl -T)GET /api/v1/pipe/{path}: receive data from a pipe pathPOST/PUTandGET /api/v1/pipe/{path}?live: broadcast a stream, and watch it from now on (see Live broadcast)GET /api/v1/pipe/{path}?ws(WebSocket): a two-way message connection between two peers on the same name (see WebSocket relay)GETorHEAD /api/v1/pipe/{path}?status: one JSON snapshot of a name, or of one transfer with&transfer=<id>, without taking a receiver slot (see Status, waiting and checksums)
Utilities:
GET /api/v1/pipe/health: service health check, with the standardized 9-field response (status,service,built,started,memory,fds,pid,ip,userAgent). Build identity is inbuilt(module mtime, ISO 8601), anduserAgentechoes the requesting client’sUser-Agentheader.GET /api/v1/pipe/metrics: counters and gauges in the Prometheus text format (see Metrics)GET /api/v1/pipe/help: usage instructions with curl examplesGET /api/v1/pipe/noscript: JavaScript-free HTML upload form, the fallback for the send page, with CSP-nonce-restricted stylesGET /api/v1/pipe: the browser send page for one file, a text or a pasted image, with live receiver status and a receive link with a QR code (see Browser send page)
Command-line interface
Section titled “Command-line interface”The hoody CLI ships a pipe namespace that wraps every operation above with shell-friendly source/sink flags. Bytes can come from a file, stdin, a literal --text value, a TCP or Unix socket, or any spawned command, and the receiving side takes the same set of sinks.
# Sources for `send`: file, "-" (stdin), --text, --from-tcp, --from-unix, --from-cmdhoody pipe send <path> [source] [flags]
# Sinks for `receive`: file via -o, "-" (stdout, default), --to-tcp, --to-unix, --to-cmdhoody pipe receive <path> [flags]Commands
Section titled “Commands”| Command | Purpose |
|---|---|
pipe send <path> [source] | Send bytes to a pipe path. Source = file / - / --text / --from-tcp / --from-unix / --from-cmd. Add --put for curl -T parity. --wait <s> sets how long to wait for the receivers (1 to 3600 seconds). --sha256 has the server hash the transfer and prints sha256: <hex> on stderr when it completes. The command exits 0 only when the server confirms the transfer ([INFO] Transfer complete., or [INFO] Live stream ended (peak N viewers). with --live). An [ERROR] line such as Timed out waiting for receivers., every receiver leaving, or a response that ends without either line prints the server’s text and exits 1; with --json, stderr then gets {"kind":"failed","status":200,"message":"…"} instead of {"kind":"complete","status":200}. --transfer-id <id> gives the transfer your own id (16 to 64 letters, digits, _ or -), so hoody pipe status <path> --transfer <id> reads it from the start; an id already in use is refused. --live starts a live broadcast (not with -n above 1 or --sha256). --browser opens the send page in your browser instead, filled in from <path> (optional here: without it the page picks a random name), -n, --text, --mode, --filename and --autostart (with --text only); --wait, --sha256 and --transfer-id do not apply to the page. |
pipe receive <path> | Receive bytes. --live watches a live broadcast from now on. Sink = -o file / stdout / --to-tcp / --to-unix / --to-cmd. Honors --download, --inline, --filename. --wait <s> sets how long to wait for a sender. --sha256 checks what was written against the server’s digest of the transfer: it prints sha256 OK <hex> on stderr, or exits 1 with the reason and keeps the output. --browser opens the receive page instead, filled in from -n, --filename, --wait, --sha256 and --autostart; the browser’s download manager saves the file. Ctrl-C exits 130 and SIGTERM 143, also while the terminal it writes to has stopped reading. |
pipe share [name] | Open the share page in your browser to stream the screen, a camera or the microphone. Prints the share page link, the viewer link (?video, with n when it is above 1) and a QR code of the viewer link. Fills in the page from --source, --audio / --no-audio, --surface, --quality, --fps and -n. --live opens it as a live broadcast and prints the live viewer link (?video&live=1); not with -n above 1. Without a name it picks a random one. --no-open only prints; --json prints {"name","shareUrl","viewerUrl"} without the QR code. |
pipe progress stream <path> | Subscribe to live progress via SSE. Does not consume a receiver slot. --json prints one JSON object per event and line, in the SDK’s event shape: every event has kind and an arrival time ts; state events count receivers in activeReceivers, progress events in receivers, with eta 0 when unknown. On a live broadcast, state lines end in (live) and progress lines count viewers instead of receivers. The last line says whether the transfer completed or failed, with the reason; a failed transfer exits 1. --until complete, --until failed or --until complete,failed stops when the transfer ends that way and exits 0 only then. Ctrl-C exits 130 and SIGTERM 143, also while its terminal has stopped reading. |
pipe url get <path> | Print the receiver URL for a path (no fetch). Append --video, --progress, --download, --filename, -n N, --wait <s>, --sha256, or --status (the JSON state of the name). --send, --share, --receive, or --noscript print a page link instead, with its controls filled in: -n (send, share, receive), --filename and --autostart (send, receive), --wait and --sha256 (receive, noscript), --text (send), --mode (send, noscript), --source, --audio / --no-audio, --surface, --quality, --fps, --live (share). --noscript links the form for browsers without JavaScript, with <path> in its path field. A flag the page does not take is refused. See the send, receive, and share page parameters. Only one of --video, --progress, --send, --share, --receive, --noscript. --ws prints the ws:// or wss:// relay URL (with ?wait= from --wait). --live adds live (alone, or with --video for the live player). With --video, --wait is how long the player waits for the stream. |
pipe forward <send-path> <recv-path> | Bidirectional TCP forwarder over two pipes. Pick exactly one of --listen host:port or --connect host:port; the peer must swap the two paths. --keepalive-ms (default 240000, 0 turns it off) sets how long a pipe may sit idle before it is ended cleanly and re-opened, so an idle tunnel outlives the server’s 5-minute idle timeout. With --ws, it takes one name instead, used by both sides, and carries the connection over one WebSocket relay pair in both directions. One TCP connection at a time: --listen opens a pair per local client, and --connect bridges one connection, then exits. With --ws, --connect exits 0 when the connection ended normally and 1 when it was cut (a reset on either side, the relay lost); Ctrl-C exits 130 and SIGTERM 143. |
pipe connect <name> | Open a WebSocket relay connection on <name>. Stdin is sent as messages and the messages received are written to stdout. By default each stdin chunk is one binary message; with --text, each line is one text message and each received text message is written as one line. When stdin ends the connection closes, unless --keep-open keeps receiving until the peer closes. --wait <s> sets how long to wait for the peer (default 300). --protocol <p> offers a subprotocol. Exits 0 on a normal close (1000 or 1001), 1 otherwise, 130 on Ctrl-C and 143 on SIGTERM, also while its terminal has stopped reading (a stalled SSH session, Ctrl-S). |
pipe status <path> | One line with the state of a name: state, sender, receivers, bytes, and the transfer id, reason and digest when there are any. --transfer <id> shows one transfer by the id a receiver got. --json prints the raw JSON. Takes no receiver slot. |
pipe metrics | The server’s metrics in the Prometheus text format. |
pipe health | Standard 9-field pipe-kit health JSON. |
pipe cheatsheet get | Server-rendered curl cheatsheet (text/plain). |
Routing flags (all commands)
Section titled “Routing flags (all commands)”| Flag / env | Purpose |
|---|---|
-c <id> / --container <id> / HOODY_CONTAINER | Container to address (resolves the kit URL through the Hoody API). --container-id and --containerId are accepted aliases. |
--pipe-url <url> / HOODY_PIPE_URL | Direct pipe-server URL that bypasses container resolution. Useful for self-hosted pipe servers, tests, or off-network clients. |
Examples
Section titled “Examples”# Bridge a TCP port over a pipe (e.g. expose a localhost service to a peer)# Side A (the side hosting the service)hoody pipe forward ab ba --connect 127.0.0.1:5432 --container $CTR# Side B (the side dialing in)hoody pipe forward ba ab --listen 127.0.0.1:15432 --container $CTR# Now `psql -h 127.0.0.1 -p 15432` on side B reaches the Postgres on side A.
# The same bridge over one WebSocket pair: both sides use one namehoody pipe forward pg --ws --connect 127.0.0.1:5432 --container $CTRhoody pipe forward pg --ws --listen 127.0.0.1:15432 --container $CTR
# Chat between two terminals, one line per messagehoody pipe connect chat --text --keep-open --container $CTR
# Stream stdout of a long-running commandhoody pipe send build-logs --from-cmd "make -j" --container $CTR
# Watch a transfer's progress as JSON without consuming a slothoody pipe progress stream big-upload --json --until complete,failed
# Build a shareable URL for an n=10 video stream (no request is issued)hoody pipe url get my-screen --video -n 10 --container $CTR
# Page links a person only has to confirmhoody pipe url get report --receive --filename report.pdf --autostart --container $CTRhoody pipe url get demo --share --source camera -n 2 --container $CTRhoody pipe url get handoff --send --text "hello" --container $CTR
# Open the pages themselves. Over SSH or with no browser, the link is printed to open by hand (exit 0)hoody pipe share demo --source screen --audio -n 2 --container $CTRhoody pipe receive report --browser --filename report.pdf --autostart --container $CTRhoody pipe send --browser --text "hello" --autostart --container $CTRSDK streaming helpers (Node.js)
Section titled “SDK streaming helpers (Node.js)”The auto-generated containerClient.pipe.* methods cover the JSON-shaped endpoints, but pipe is a streaming primitive, so the SDK also ships a hand-written Node-side companion at hoody-sdk’s top level.
import { PipeStream, // class: send / receive / status / metrics / subscribeProgress / forwardTcp / connect PipeWsError, // a refused or failed WebSocket relay connection (connect) coerceToReadableStream, // anything-byte-source → Web ReadableStream parseStatusStream, // [INFO]/[ERROR] line stream parser parseSseStream, // SSE block parser (for /{path}?progress) PipeReceiveEmptyBodyError, // distinguishes intentional empty bodies from network errors PipeTransferError, // a failed transfer (send's done); a refused send without a client transport validatePipePath, encodePipePath,} from 'hoody-sdk';PipeStream
Section titled “PipeStream”// Either resolve from the Hoody control plane:const pipe = PipeStream.fromClient(client, { id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER });
// Or address a pipe server directly (self-hosted, tests, off-network):// const pipe = new PipeStream({ pipeBaseUrl: 'https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com' });
// send: accepts any PipeSource:// string | Buffer | Uint8Array | ReadableStream | NodeJS.ReadableStream// | AsyncIterable | URL (file:) | { tcp: { host, port } } | { unix: '/path' }const send = await pipe.send('my-path', source, { method: 'POST', // or 'PUT' (curl -T parity) n: 1, // receivers to wait for, 1..256 wait: 600, // seconds to wait for them, 1..3600 (default 300) sha256: true, // have the server hash the transfer contentType: 'application/octet-stream', contentLength: stat.size, // optional; when known, enables receiver progress filename: 'report.pdf', // forwarded as Content-Disposition (a non-ASCII name as RFC 5987 filename*) headers: { 'X-Hoody-Pipe': 'kind=audit' }, onStatus: (m) => console.log(m.level, m.message), signal: AbortSignal.timeout(60_000),});// done resolves once the server confirms the transfer ([INFO] Transfer complete., or for// `live` [INFO] Live stream ended (peak N viewers).). The server answers 200 as soon as it takes// the upload, so a later failure arrives only as a status line: done then rejects with// PipeTransferError, whose message is the server's text (e.g. 'Timed out waiting for receivers.').// A refused request (non-2xx) throws ApiError through fromClient(); a helper built// directly with pipeBaseUrl throws PipeTransferError with that status.await send.done;console.log(await send.sha256); // the server's SHA-256 of the completed transfer, or null
// receive: gives you a Web ReadableStream<Uint8Array>const recv = await pipe.receive('my-path', { n: 1, download: true });await recv.body.pipeTo(Writable.toWeb(createWriteStream('out.bin')));
// receive with sha256: the body is hashed as you read it, and `verified` compares the// result with the server's digest of this transfer once the body has endedconst checked = await pipe.receive('my-path', { sha256: true });await checked.body.pipeTo(Writable.toWeb(createWriteStream('out.bin')));const hex = await checked.verified; // rejects with PipeIntegrityError: 'mismatch', 'unavailable' or 'incomplete'
// status: one snapshot of a name, or of one transfer by checked.transferIdconst st = await pipe.status('my-path');console.log(st.state, st.hasSender, st.activeReceivers, st.totalReceivers);
// subscribeProgress: async-iterable SSE events (does not consume a receiver slot)for await (const ev of pipe.subscribeProgress('my-path')) { if (ev.kind === 'progress') console.log(ev.bytesTransferred, ev.totalBytes, ev.speed, ev.receivers); // a live broadcast sets live: true; its state events have totalReceivers null (no n) if (ev.kind === 'state') console.log(ev.state, ev.live ? 'live' : `${ev.activeReceivers}/${ev.totalReceivers}`); // done is the last event: state 'complete', or 'failed' with the kit's reason if (ev.kind === 'done') console.log(ev.state, ev.reason ?? '', `${ev.duration}s`);}PipeStream.forwardTcp
Section titled “PipeStream.forwardTcp”Bidirectional TCP forwarding over two unidirectional pipes, the same primitive the CLI’s forward is built from:
// Side A: the peer uses sendPath ba, recvPath ab, and listen on port 15432.const fwd = pipe.forwardTcp({ sendPath: 'ab', // local → server recvPath: 'ba', // server → local connect: { host: '127.0.0.1', port: 5432 }, // or listen: { port: 15432 } n: 1, keepaliveMs: 240_000, // default; 0 turns it off});// fwd.address: Promise<{host, port}> when in listen mode// fwd.done: Promise<void> when fully drained// fwd.close(): stop accepting / tear down active bridgeAwait fwd.done to keep the program running and handle errors; in listen mode await fwd.address gives the bound host and port.
The server ends a transfer after 5 minutes without a byte. To keep an idle tunnel open, each side ends its send pipe with a clean EOF once it has had nothing to send for keepaliveMs, waits for the server’s [INFO] Transfer complete., and sends again on the same path. Every pipe carries X-Hoody-Pipe: kind=tcp-forward; segments=1, so the receiving side treats that EOF as the end of one pipe and connects again. The app’s own half-close travels as an empty pipe marked fin=1. Nothing is added to the forwarded bytes, and bytes the app writes while a pipe is being re-opened wait for the next pipe.
- The peer must run an SDK or CLI with this support. A pipe from an older forwarder has no
segments=1, and its EOF still means half-close. Once the older peer’s first pipe has arrived, nothing is re-opened toward it and an idle tunnel closes after about 5 minutes, as before. Until then the peer is unidentified, and an older peer takes the first clean end, afterkeepaliveMs, as the app’s half-close. - The pairing timeout still applies to the first pipe in each direction, so both directions must pair within 5 minutes. Connect mode opens its pipes once its dial connects, and listen mode only once a local client connects to it. That client must connect within 5 minutes of the connect side starting; starting the peer forwarder is not enough. In Node, a send pipe reaches the server only with the app’s first byte, or when it is re-opened after
keepaliveMswith nothing sent. - A pipe that ends any other way (the peer disconnected, a server timeout, a network error) closes the local connection, because some bytes may not have arrived. So does a
fin=1pipe the peer never receives. A peer whose app stops reading for 5 minutes also ends the tunnel this way. - Keep
keepaliveMsunder 300000; at or above the server’s idle timeout it cannot prevent it.0turns re-opening off, and an idle tunnel then closes after 5 minutes.
WebSocket relay
Section titled “WebSocket relay”For interactive text, run hoody pipe connect chat --text --keep-open --container $CONTAINER in two terminals. Each line reaches the other terminal. For a raw WebSocket client, build the address with hoody pipe url get chat --ws --container $CONTAINER and connect both peers to it. Curl can inspect ?status but does not relay WebSocket messages.
GET /api/v1/pipe/{name}?ws with a WebSocket upgrade pairs two peers on the name. Each message one peer sends reaches the other unchanged: text stays text, binary stays binary, and one message stays one message. Any WebSocket client works, including a browser’s new WebSocket(url).
- The first peer is connected at once. What it sends is held, up to 4096 messages or 2 MiB (each message counts 64 bytes more than its size), until the second peer arrives; past that the connection is closed with
1013. It waits 5 minutes, or?wait=seconds (1-3600), and is then closed with4408 Timed out waiting for peer.. - A third peer on the name is refused with
409. - A name holds one kind of session at a time.
?wson a name with a waiting or running transfer is409, and so is a sender or receiver on a name that has a pair. ?statusshows a pair as"kind": "ws"withpeers(0-2), and?progressfollows its byte counts.- When one peer closes, the other is closed with the same code and reason (1000-1003, 1007-1014, 3000-4999). A connection that drops reaches the other peer as
1001 Peer disconnected.. - Messages are at most 1 MiB; a larger one drops its sender’s connection (no close frame, so the sender sees
1006), and the other peer is closed with1009 Message too big.. A reader may fall up to 2 MiB behind in each direction, after which the pair is closed with1013. A peer that stops answering pings is dropped after about 2 minutes. - The first peer’s
Sec-WebSocket-Protocolchoice binds the pair. The second peer must offer the same subprotocol, or offer none when the first offered none; otherwise it gets409. ndoes not combine with?ws(400), and neither doeslive(400, checked first, except that a request with?statusgets the status). A plain GET with?wsand no upgrade gets426.
In Node, PipeStream.connect(name, options) opens the connection and returns { readable, writable, closed, close() }. Each chunk read is one message (string or Uint8Array), and each write is one message:
import { PipeStream, PipeWsError } from 'hoody-sdk';
const pipe = PipeStream.fromClient(client, { id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER });const chat = await pipe.connect('chat', { wait: 600 }); // also: protocols, signal, maxReceiveBytes, maxReceiveMessagesconst writer = chat.writable.getWriter();await writer.write('hello'); // text messageawait writer.write(new Uint8Array([1, 2, 3])); // binary message; over 1 MiB rejects with RangeErrorfor await (const msg of chat.readable) console.log(msg);// After a 1000/1001 close, every message that arrived first is read, then the stream ends.// Any other close makes the read fail with PipeWsError (its .code and .reason are the close's).console.log(await chat.closed); // { code, reason }A refused connection rejects with PipeWsError, whose .status is the HTTP status (for example 409) in Node.js and Bun, with or without kit credentials; in a browser the WebSocket API hides it and .status is undefined. The message explains the refusal when ?status can. Kit credentials go on the upgrade. getWsUrl(name, { wait }) returns the relay URL, and PipeBrowser has the same connect and getWsUrl. When the program reads more slowly than messages arrive, at most maxReceiveBytes (default 4 MiB) or maxReceiveMessages (default 4096) wait in memory, after which the connection is closed with 4013.
In a browser, connect two pages to the same name with PipeBrowser.connect(), or use the browser’s WebSocket directly:
const browserPipe = PipeBrowser.fromClient(client, { id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER });const socket = new WebSocket(browserPipe.getWsUrl('chat', { wait: 600 }));socket.addEventListener('open', () => socket.send('hello'));socket.addEventListener('message', event => console.log(event.data));A browser WebSocket cannot attach arbitrary authentication headers. Its connection still needs to satisfy the proxy’s access rule; use Node’s PipeStream.connect() when the rule requires kit credential headers.
forwardTcp({ transport: 'ws', path, listen | connect }) carries one TCP connection over one pair in both directions, with flow control and half-close, instead of two pipes. Both sides use transport: 'ws' and the same path, and neither takes sendPath, recvPath, n or keepaliveMs.
SDK browser helpers
Section titled “SDK browser helpers”In a browser, hoody-sdk also exports PipeBrowser, for files and progress, and PipeMedia, for live screen, camera, and audio. Create either with fromClient(client, container), or use new PipeBrowser({ pipeBaseUrl }) and new PipeMedia({ pipeBaseUrl }) respectively.
import { PipeBrowser, PipeMedia, PipeTransferError } from 'hoody-sdk';
const pipe = PipeBrowser.fromClient(client, { id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER });
// sendFile: a File, Blob, string or ArrayBuffer. Resolves once the server reports the transfer complete.const sent = await pipe.sendFile('report', fileInput.files[0], { n: 1, // receivers to wait for, 1..256 filename: 'report.pdf', // default: the File's name transfer: 'report-2026-10-04', // optional: your own transfer id (16-64 of A-Z a-z 0-9 _ -), for pipe.status('report', { transfer }) onProgress: ({ loaded, total }) => bar.value = loaded / total, onStatus: (m) => console.log(m.level, m.message), signal,});// Rejects with PipeTransferError on an [ERROR] line, all receivers leaving, or a// response that ends before completion. HTTP refusals through fromClient() throw// ApiError; a helper built directly with pipeBaseUrl throws PipeTransferError.
// receive: the body as a ReadableStream, with the forwarded headersconst { body, headers } = await pipe.receive('report');
// download: the browser's download manager receives ?download, so any size goes to diskpipe.download('report', { filename: 'report.pdf' });
// subscribeProgress: the same events as PipeStream.subscribeProgress, without a receiver slotfor await (const ev of pipe.subscribeProgress('report')) { if (ev.kind === 'done') console.log(ev.state, ev.reason ?? '');}fromClient() applies the client’s kitAuth to requests made through its transport. Browser navigations (download(), openPage() and shared page links) and direct media playback do not attach those credential headers; the browser must satisfy the proxy rule independently. When headers are required, use PipeBrowser.receive() or PipeMedia.receiveStream(). receiveMedia() uses the client transport on its MSE path, but falls back to direct playback when MSE is unavailable or the requested MIME type is unsupported.
PipeMedia streams a live capture to a pipe path that viewers open with ?video. Next to shareScreen() and shareWebcam(), shareAudio() sends audio only:
const media = PipeMedia.fromClient(client, { id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER });
// Microphone only. systemAudio: true also asks for tab or system sound// (the person ticks "Share audio" in the picker); both are mixed into one stream.const session = await media.shareAudio({ path: 'radio', systemAudio: true, live: true });console.log(session.url); // listeners open this ?video&live link while sharing runs// Call session.stop() from your Stop button; await session.done to handle the outcome.A share session also ends by itself when its transfer does. The capture stops (camera, screen, microphone), onEnded() callbacks run, and session.done settles. done rejects with PipeTransferError when the transfer failed: the stream stalled or the network dropped, and, without live: true, nobody joined within 5 minutes or every viewer left. A live share starts at once and keeps running with no viewers. The recording follows the upload: once 8 MiB of it wait to be sent (no viewer reading yet, a slow network), the recorder pauses, and it resumes when the upload is back to 4 MiB behind. More than 16 MiB waiting ends the share as failed. Each session uploads with its own transfer id (session.transferId) and follows only that transfer, so another sender on the same name, or an earlier share there, never ends it. A session whose transfer the pipe no longer knows for about 30 seconds after it was seen also ends as failed. Path validation runs before capture: a relative dot segment or a reserved name (help, noscript, health, metrics, favicon.ico, robots.txt, an empty name, and the variants listed under Page links) throws, so nothing is captured for a name the server keeps for itself.
try { await session.done;} catch (err) { if (err instanceof PipeTransferError) console.log('sharing ended:', err.message);}Screen and camera shares use the same session lifecycle. Start them from a user click and give viewers the returned URL:
// Screen: frameRate is the SDK option; fps belongs to the share page URL.const screen = await media.shareScreen({ path: 'screen', audio: true, frameRate: 30, live: true });console.log(screen.url);// Alternatively, camera and microphone:// const camera = await media.shareWebcam({ path: 'camera', audio: true, live: true, viewer: true });shareScreen() and shareAudio() return a player link by default. shareWebcam() needs viewer: true for that link; otherwise its URL receives the raw stream.
Receive into your own player with receiveMedia(), or read raw bytes with receiveStream():
// In a viewer's program, with a <video controls> element named videoElement:const playback = await media.receiveMedia('screen', videoElement, { live: true });// Call playback.stop() to stop receiving.// For data processing instead: const response = await media.receiveStream('screen', { live: true });These capture uploads need a Chromium-based browser over HTTPS with HTTP/2 or HTTP/3. Live shares need WebM recording. Capture asks for permission; a viewer may need to click Play to enable audio.
Page links
Section titled “Page links”getPageUrl(page, name, options) (on both classes) builds a link to one of the pipe’s pages with its controls filled in, and openPage() opens it with window.open and returns the window (or null when a popup is blocked). Each option is the page parameter of the same name, listed under Browser send page, Receive page, and Share page:
| Page | Options |
|---|---|
send | n, text, mode, filename, autostart (the name is the second argument) |
receive | n, filename, autostart, wait, sha256 |
share | source, audio, surface, quality, fps, n, live |
video | n, live, wait |
progress | none |
noscript | mode, wait, sha256 |
pipe.getPageUrl('receive', 'report', { filename: 'report.pdf', autostart: true });// → https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/report?receive&filename=report.pdf&autostart=1pipe.openPage('share', 'demo', { source: 'camera', n: 2 });The name is encoded segment by segment, as the kit expects. For noscript the encoded name goes in the path parameter, so the form posts to the same pipe as the other links; a name with / is refused there, because the form has a single path field. An option the page does not take, a value out of range, a reserved name (help, noscript, health, metrics, favicon.ico, robots.txt, or an empty name, in any case, with a trailing /, . or ./, or with leading or repeated slashes such as /help), a . or .. segment, or a name over 1024 characters once encoded throws an error before any URL is returned.
The pages are also embed views. The embed builder takes these parameters: pipe.send takes name, n, text, mode, filename; pipe.noscript takes noscriptPath (the form’s path), mode, wait, sha256; pipe.share takes path, source, audio, surface, quality, fps, n, live; pipe.receive takes path, n, filename, wait, sha256; pipe.video takes path, live, wait. Embeds cannot set autostart; build a direct page link for it. To capture inside an iframe, the share page’s frame needs allow="display-capture; camera; microphone; autoplay".
Sender status messages
Section titled “Sender status messages”When you POST or PUT, the response body streams real-time status messages:
[INFO] Waiting for 1 receiver(s) to connect...[INFO] A receiver connected.[INFO] Streaming to 1 receiver(s)...[INFO] Upload complete.[INFO] Transfer complete.With ?sha256, [INFO] SHA-256: <hex> comes between Upload complete. and Transfer complete., only when the transfer completes.
If receivers were already waiting when the sender connects, [INFO] N receiver(s) already connected. follows the first line rather than replacing it, so both appear back to back. Streaming begins as soon as the pipe has the receiver count the sender asked for.
A receiver disconnecting mid-transfer emits the first line below. The second appears only when all receivers have left before completion:
[INFO] A receiver disconnected.[INFO] All receivers disconnected before transfer completed.While a receiver takes no data (stopped reading, or its connection stalled), the sender gets [INFO] Waiting for receivers to read. every 15 seconds. If the sender has gone away by then, the server notices and ends the transfer as failed, so the name is free again within about 15 seconds.
The server emits [ERROR] … lines on the failure modes: capacity exceeded, idle timeout, sender error, and wait timeout. The full authoritative vocabulary lives in hoody-pipe’s OpenAPI under StatusMessage.
Receiver options
Section titled “Receiver options”Receivers can control download behavior with query parameters:
| Parameter | Effect |
|---|---|
?download | Force browser download (Content-Disposition: attachment) |
?download=false | Suppress Content-Disposition entirely; the browser then decides from the Content-Type whether to display or download |
?filename=report.pdf | Set a custom download filename (implies ?download) |
?video | Show an HTML video player for WebM and fragmented MP4 streams (browsers only; play MPEG-TS in a media player such as VLC or mpv) |
?progress | Watch transfer progress as SSE events or an HTML dashboard |
?receive | Show the receive page in a browser; the browser’s download manager receives the transfer |
?share | Show the share page in a browser: stream the screen, a camera, or the microphone to ?video viewers |
?ws | Upgrade to a two-way WebSocket relay; a plain GET without an upgrade gets 426 |
?status | Answer with one JSON snapshot of the name instead of the data; takes no receiver slot |
?wait=SEC | How long this receiver waits for a sender, 1 to 3600 seconds (default 300); senders take it too |
?sha256 | Have the server hash the transfer; senders take it too |
?transfer=ID | Senders only: the transfer’s own id, so the sender can follow it with ?status&transfer=ID (see Status, waiting and checksums) |
?live | Watch a live broadcast from now on; on a sender, start one; with ?video, the player keeps up with the newest data |
?video, ?share, and ?receive serve a page only to browsers. Other clients with these parameters receive the data like a plain GET. When one URL carries several of them, a browser gets ?progress first, then ?video, ?share, and ?receive. ?status comes before all of them, for every client, and before live, ws and transfer: a URL with ?status gets the status. Without status, ws is handled before the page parameters; live with ws gets 400.
Status, waiting and checksums
Section titled “Status, waiting and checksums”Run each sender and its receiver in separate terminals.
# The state of a name: JSON, no receiver slotcurl "https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/my-transfer?status"
# Wait up to an hour for the other side, on either end (one per terminal)curl -T big.iso "https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/my-transfer?wait=3600"curl --fail -o received-big.iso "https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/my-transfer?wait=3600"
# Sender: pick your own transfer id and have the server hash the streamcurl -T big.iso "https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/my-transfer?sha256&transfer=backup-2026-10-05-a1"# Receiver, in another terminal: save the bytes and hash themcurl --fail -o received-big.iso "https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/my-transfer"sha256sum received-big.iso# From the sender's arrival (404 before it) until 10 minutes after the end: state and sha256curl "https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/my-transfer?status&transfer=backup-2026-10-05-a1"?status answers 200 with a JSON object for any valid name, also one nobody is using ("state": "idle"). Its fields are state (idle, waiting, streaming, complete or failed), kind, peers, transferId, hasSender, activeReceivers, totalReceivers, bytesTransferred, totalBytes, speed, eta, elapsed, reason and sha256. A finished transfer stays visible for up to 30 seconds, or until the name is used again. At most 1000 finished names are kept; the oldest can be evicted sooner. HEAD returns the headers only.
Every ordinary HTTP transfer gets an id when streaming starts, sent to each receiver in the X-Hoody-Pipe-Transfer-Id header. A sender-chosen id is visible earlier, from the sender’s arrival. ?status&transfer=<id> returns that transfer while it runs and for up to 30 seconds after it ends, unless the name is used again first. A hashed transfer, or one with a sender-chosen id, stays readable for up to 10 minutes after it ends, even if the name has been reused. The server keeps at most 1000 receipts and evicts the oldest when full. An unknown or expired id gets 404, a malformed one 400.
A sender can’t read response headers while it is still uploading, so it can choose the id itself with ?transfer=<id> on its POST or PUT: 16 to 64 characters from A-Z a-z 0-9 _ -. That id then appears everywhere the server’s own id would: in ?status from the moment the sender arrives (also while it waits), in X-Hoody-Pipe-Transfer-Id, and in the done event. ?status&transfer=<id> works from the start. After the transfer ends, it keeps the result for up to 10 minutes, subject to the same 1000-receipt cap, whatever the outcome, also when the transfer ended before streaming, for example with "reason": "Timed out waiting for receivers" or "Sender disconnected". A malformed id gets 400. An id that is still in use, by a transfer on any name or by a result kept from an earlier one, gets 409, so use a new id for each transfer. transfer on a WebSocket upgrade is refused with 400. On a live broadcast sender it is the stream’s id in ?status from the start, and its result ("kind": "live") once the stream ends.
?wait=<seconds> sets how long each sender or receiver waits for its counterpart, from 1 to 3600 seconds; without it, 5 minutes. Each one has its own deadline counted from its own arrival, so one that gives up does not end the wait of the others. A value that is not a whole number in that range gets 400. Proxy or client timeouts can end a request sooner. For long waits with large files, let the receiver wait (?wait=3600) and start the sender once ?status shows it.
Curl does not interpret the sender’s status lines: even a failed transfer can answer HTTP 200 and leave curl with exit code 0. Check for [INFO] Transfer complete. and, for integrity, a completed receipt with a matching digest. --fail catches HTTP errors on the receiver but does not verify the payload.
?sha256 on the sender or on any receiver makes the server hash the whole stream once. The digest is lowercase hex, as sha256sum prints it, and only a completed transfer has one. It appears in the sender’s status, in the done progress event, in ?status, and in the transfer’s ?status&transfer=<id> record. A receiver checks its download by hashing what it read and comparing it with that record; hoody pipe receive --sha256 and the Node.js PipeStream.receive(path, { sha256: true }) do this for you (read the whole body, then await verified). PipeBrowser.receive() does not check the digest. Hashing costs CPU; throughput depends on the server and connection. It cannot be combined with live.
Video player
Section titled “Video player”Append ?video to the receiver URL and open it in a browser. hoody-pipe serves an embedded MSE video player that detects the container and codec from the stream:
# Sender: Stream your screen as WebMffmpeg -f x11grab -i :0 -c:v libvpx -f webm - | \ curl -T - https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/screen
# Receiver: Open in browser with video player# https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/screen?videoNon-browser clients (VLC, mpv, ffplay) with ?video get the raw stream automatically.
Add wait to the player URL (?video&wait=600) to set how long each of its receives waits for a sender, in seconds (1-3600). An invalid value is ignored and the default of 5 minutes applies.
Transfer progress
Section titled “Transfer progress”Watch transfer progress without consuming a receiver slot:
# SSE stream (curl, EventSource)curl -N -H "Accept: text/event-stream" \ "https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/my-transfer?progress"
# HTML dashboard (open in browser)# https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/my-transfer?progressSSE events include state transitions (idle / waiting / streaming, with the sender and receiver counts), progress updates (bytes, total bytes, speed, ETA, active receivers, elapsed seconds), and a final done event. Only done says how the transfer ended: its state is complete or failed, a failed transfer adds a reason such as Sender disconnected, and it carries the totals (bytes transferred, duration in seconds, average speed). An SSE request (Accept: text/event-stream) that arrives within 30 seconds after a transfer ends, before the path is used again, gets 204 and no events. A browser still gets the 200 dashboard, which shows “Transfer already finished”.
Browser send page
Section titled “Browser send page”Open / or /api/v1/pipe/ in a browser to send without a terminal. The page sends one file, a typed or pasted text, or a pasted image as a file. It fills in a random pipe name that you can edit, and a receiver count. While the transfer waits and runs, the page shows:
- upload progress
- how many receivers are connected
- the transfer state
- a link for the receiver, with a Copy button and a QR code
The link opens the receive page (?receive), which hands the data to the browser’s download manager. A file keeps its name for the receiver. Cancel stops the send and frees the name. A name with a . or .. part is refused with a message, because the browser would resolve it to another pipe. The page says Delivered only after the kit reports the transfer complete. If a receiver drops out while the others finish, it says how many got the data, for example Delivered to 1 of 2 receivers (1 disconnected). The page loads nothing from other origins and stores nothing in the browser. /api/v1/pipe/noscript is the form for browsers without JavaScript.
Every control can be filled in from the URL, so a script or an agent can build one link that the person only confirms:
| Parameter | Effect |
|---|---|
name | Pipe name, up to 1024 characters. Without it the page picks a random name |
n | Receiver count, 1 to 256 |
text | Text to send, up to 100000 characters. Selects text mode |
mode | file or text |
filename | File name the receiver gets for a text or a pasted image, up to 255 characters |
autostart=1 | In text mode, with a text, sends without a click. A name the page refuses shows the reason instead |
Invalid values fall back to the defaults. A file always needs the person to pick it and click Send.
# The page opens with the text filled in and sends it once loaded# https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/?name=handoff&text=hello&autostart=1For a JavaScript-free send, open /api/v1/pipe/noscript, choose a custom path and File or Text, then click Apply. Pick a file or enter text and click Send; a receiver must connect to the same path concurrently. The form uses one receiver and shows the sender’s status text as the response. Its text mode accepts at most 1 MiB of UTF-8 text; this cap does not apply to file uploads or raw text streams. After pairing, the server allows 30 seconds for a file part or the form’s text field to arrive. Once the text field starts, it may take longer than 30 seconds to finish; the upload remains subject to the five-minute idle timeout. A text overflow or parsing timeout produces HTTP 500 for receivers with [ERROR] Transfer failed — sender encountered an error.; the sender gets that error in its HTTP 200 status body.
Receive page
Section titled “Receive page”Open /{path}?receive in a browser to receive a transfer into the browser’s downloads:
# Receiver: open in a browser# https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/report?receive&autostart=1
# Sendercurl -T report.pdf https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/reportThe page shows the path name and whether a sender is waiting. On Receive, or as soon as the page opens with autostart=1, the browser’s download manager takes the transfer, so the file goes to disk at any size without passing through the page. The download’s progress and result appear in the browser’s downloads list. The page cannot see the download, so it never reports one as finished; under a “Status for this name” label it shows what is happening on the path for everyone using it (no sender yet, a sender waiting for receivers, a transfer running) as information only, and “Status unavailable, retrying…” while that status cannot be read. A receive waits up to 300 seconds by default (or the wait it was given). If no sender arrives, the download gets 408 with [ERROR] Timed out waiting for sender.; if a sender is waiting for the remaining receivers, it gets 408 with [ERROR] Timed out waiting for receivers.. A receiver-count mismatch or a taken receiver slot gets 400 with its own error text. Receive again retries. Cancel frees the receiver slot while the receive waits. A download that has already started continues in the browser’s downloads, where it can be cancelled. The page does not take a receiver slot of its own and stores nothing in the browser.
| Parameter | Effect |
|---|---|
n | Receiver count (1 to 256), the same n the sender uses |
filename | Name of the downloaded file, up to 255 characters |
autostart=1 | Start receiving when the page opens, without a click |
wait | Seconds this receive waits for a sender (1 to 3600), passed on to the download |
sha256 | Have the server hash the transfer, passed on to the download. The page shows no checksum; check one with hoody pipe status <path> --transfer <id> or the SDK |
Share page
Section titled “Share page”Open /{path}?share in a browser to stream your screen, a camera, or the microphone to viewers of /{path}?video, without a terminal:
# Sharer: open in a browser# https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/demo?share&source=screen&n=2
# Each viewer opens# https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/demo?video&n=2Pick Screen, window or tab (tick Include audio to share the tab or system sound), Camera and microphone, or Audio only, set the viewer count, and click Start sharing. The browser asks which screen or device to share. The page shows a viewer link, with a Copy button and a QR code. The stream starts once all viewers have opened the link. Until then the page shows how many are connected. While live, it shows the viewer count, the elapsed time, and the bytes sent. An audio-only share plays as audio in the player.
Tick Live to broadcast instead (see Live broadcast). The stream starts at once, even with nobody watching, and viewers open the viewer link (?video&live=1) whenever they like, leave and come back, up to the live limits (256 per broadcast). With video, a viewer who joins late starts at the next keyframe, which the page asks the browser for every 2 seconds; with audio only, at the next audio segment. The viewer count does not apply and is hidden; the page shows how many viewers are watching now. Live needs a browser that records WebM, and other browsers get a message instead.
Stop, or the browser’s own stop-sharing control, ends the stream. Start sharing shares again on the same path, and players still open on the link play the new stream. The page reports why a share ended:
- nobody, or not every viewer, opened the link within 5 minutes
- all viewers left
- someone else is already sending on the path
- the server refused the share (with its error text)
- the connection could not keep up
Sharing needs a Chromium-based browser (Chrome, Edge and similar) on an HTTPS connection with HTTP/2 or HTTP/3, because the page streams its upload. Other browsers get a message that says so. The page stores nothing in the browser.
Every control can be filled in from the URL. Capturing still needs the person’s click on Start sharing:
| Parameter | Effect |
|---|---|
source | screen, camera, or audio |
audio | 1, true, yes, or bare audio: include the screen’s audio. 0, false, or no: share the screen without audio (the default) |
surface | monitor, window, or browser: the kind of surface the screen picker offers first |
quality | low, medium, or high (480p, 720p, or 1080p; default medium). These are maximum dimensions for screen capture and preferred dimensions for camera capture |
fps | Frame rate, 1 to 60 (default 30). A maximum for screen capture, a preference for camera capture |
live | 1, true, yes, or bare live: tick Live for a broadcast viewers join at any time (n is then ignored) |
n | Viewer count, 1 to 256 |
Invalid values fall back to the defaults, except an n above 256, which is capped at 256.
Use cases
Section titled “Use cases”Screen sharing over HTTP
Section titled “Screen sharing over HTTP”Stream your real device screen to anyone who has the URL, with no WebRTC, signaling server, or app install:
# On your machine: Capture screen and pipe itffmpeg -f x11grab -i :0 -c:v libvpx-vp9 -f webm - | \ curl -T - "https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/my-screen?n=10"
# Exactly 10 viewers must connect before streaming starts:# https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/my-screen?n=10&videoFile transfer with progress
Section titled “File transfer with progress”Send a large file and let others watch the progress. Run the sender and receiver in separate terminals:
# Sendercurl -T ./dataset-50gb.tar.gz \ https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/dataset
# Receivercurl https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/dataset \ -o received-dataset-50gb.tar.gz
# Spectator (doesn't consume a receiver slot)# Open in browser: https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/dataset?progressLive event forwarding
Section titled “Live event forwarding”Stream events from one container to another in real time:
# Container A: Forward nginx access logstail -f /var/log/nginx/access.log | curl -T - \ https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/nginx-logs
# Container B: Process the log streamcurl -N https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/nginx-logs | \ grep --line-buffered "500" | tee errors.logDirectory transfer
Section titled “Directory transfer”# Sender: Tar and stream a directorytar czf - ./my-project | curl -T - \ https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/project.tar.gz
# Receiver: Download and extract into a separate directorymkdir -p receivedcurl https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/project.tar.gz | \ tar xzf - -C receivedEnd-to-end encryption
Section titled “End-to-end encryption”If you do not want the server to see plaintext, encrypt before piping:
# Sender: Encrypt and sendopenssl enc -aes-256-cbc -pbkdf2 -pass pass:SECRET < secret.doc | \ curl -T - https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/encrypted
# Receiver: Download and decryptcurl https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/encrypted | \ openssl enc -d -aes-256-cbc -pbkdf2 -pass pass:SECRET > received-secret.docSecurity
Section titled “Security”The pipe server applies these protections:
- Dangerous MIME types rewritten: script-executable types (HTML, XML, and SVG, plus the full WHATWG-canonical JavaScript MIME family:
text/javascript,application/javascript,application/ecmascript,text/javascript1.0through1.5,text/jscript,text/livescript, and the rest) are rewritten totext/plainso a browser receiver cannot execute them. Unless the receiver asks for?download=false(noContent-Dispositionat all), an inlineContent-Dispositionis also force-coerced toattachmentfor any MIME outside an explicit safelist (image/*,audio/*,video/*,text/plain, and similar), which covers the case where the dangerous-MIME table misses a niche format. - CRLF injection protection: the server sanitizes forwarded headers (Content-Disposition, X-Piping, X-Hoody-Pipe) against header injection.
- CSP nonces: every HTML page (send, receive, and share pages, video player, progress dashboard, noscript upload) uses Content-Security-Policy with nonces.
- Path length limit: 1024 characters, which prevents memory inflation.
- Response headers:
X-Content-Type-Options: nosniffon every response with a body, andX-Robots-Tag: noneon received data to keep it out of search indexes.
Limits
Section titled “Limits”| Limit | Value |
|---|---|
| Max concurrent pending (unestablished) pipes | 1000 |
| Max concurrent active (streaming) transfers | 1000 |
| Max receivers per pipe | 256 |
| Max spectators per path | 50 |
| Max spectator groups | 500 |
| Wait for the counterpart | 5 minutes by default, ?wait= from 1 to 3600 seconds, per sender or receiver; one whose wait runs out gets 408 (a receiver) or [ERROR] Timed out waiting for receivers. (a sender) |
?status&transfer= receipts of hashed or sender-id transfers | 1000, kept up to 10 minutes each; oldest evicted at capacity |
| Active-transfer idle timeout | 5 minutes (the byte stream must keep making progress; no bytes flowing for that long → [ERROR] Transfer aborted — idle timeout exceeded.) |
| Receiver that reads nothing after the transfer ended | connection closed after about 1 second when the transfer failed while streaming, after 255 seconds without progress when it completed |
| Finished names visible to status | 1000 at most, for up to 30 seconds; oldest evicted at capacity |
| Spectator connection TTL | 30 minutes (progress-watcher connection auto-closes after 30 minutes) |
| Max path length | 1024 characters |
| JavaScript-free form text | 1 MiB of UTF-8 text; file uploads and raw byte streams have no application size cap |
| Multipart part selection | 30 seconds after pairing for a file part or the form’s text field to arrive; this is not a deadline for finishing the text body |
| WebSocket relay pairs (waiting + connected) | 500; a second peer joining a waiting one is never refused for this |
| WebSocket message size | 1 MiB |
| WebSocket buffer per direction | 2 MiB (before the second peer arrives: also 4096 messages) |
| Live broadcasts | 100 at once, 256 viewers each |
| Live viewer responses | 4096 in all; a viewer’s response counts until it disconnects, or about 35 seconds after its body ended normally (about 6 seconds after a cut) |
| Live data held for viewers | 64 MiB per broadcast, 512 MiB in all; the viewer furthest behind is cut to make room. A sender’s incoming chunk can briefly use memory beyond this |
| Live viewer cut | 8 MiB or 4096 pieces behind, or queued data with nothing taken for 60 seconds |
Capacity refusals at admission return HTTP 429. If the active-transfer limit is reached after an ordinary sender has been accepted, that sender keeps HTTP 200 and receives [ERROR] Server at maximum active transfer capacity. Try again later. in its status body; its receivers get 429. Excessive path length returns 414, and an invalid receiver count returns 400.
Custom Metadata
Section titled “Custom Metadata”Forward arbitrary metadata from sender to receiver using custom headers:
# Sender: Include metadatacurl -T ./image.png \ -H "X-Hoody-Pipe: source=camera-1;timestamp=2026-03-04T12:00:00Z" \ -H "Content-Disposition: attachment; filename=\"snapshot.png\"" \ https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/snapshot
# Receiver: Gets the metadata headers forwardedcurl -v https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/snapshot \ -o snapshot.png# < X-Hoody-Pipe: source=camera-1;timestamp=2026-03-04T12:00:00Z# < Content-Disposition: attachment; filename="snapshot.png"Both X-Hoody-Pipe and X-Piping headers are forwarded to receivers and exposed via CORS.
Each forwarded header value is capped at 8 KiB: X-Hoody-Pipe, X-Piping, Content-Disposition and Content-Type alike. A value over the cap is dropped rather than failing the transfer, so the bytes still arrive and the receiver does not see the sender’s value (a receiver’s ?download or ?filename still makes its own Content-Disposition). Keep metadata small, or send it as part of the payload.
Health & Monitoring
Section titled “Health & Monitoring”# Standard 9-field health JSONhoody pipe health --container $CONTAINER# Check service healthcurl https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/health{ "status": "ok", "service": "hoody-pipe", "built": "2026-04-14T22:17:54Z", "started": "2026-04-22T09:12:03Z", "memory": { "rss": 44145050, "heap": 29884416 }, "fds": 17, "pid": 1234, "ip": "172.17.0.2", "userAgent": "curl/8.4.0"}userAgent is the requesting client’s User-Agent header, echoed back (useful for debugging client identity through the proxy). Server build identity lives in built (the module’s mtime as an ISO 8601 string, or null when it cannot be read).
import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });const containerClient = await client.withContainer({ id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER });
// Health check: build identity is in `built` (module mtime, ISO 8601, or null);// `userAgent` echoes whatever User-Agent the SDK's HTTP client sent.const health = await containerClient.pipe.kit.getHealth();console.log(health.data.status); // "ok"console.log(health.data.built); // "2026-04-14T22:17:54Z"One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Fetches the pipe service’s standard 9-field health check. Pipe has no service-level auth of its own; any access rule configured at Hoody Proxy still applies.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-pipe-1.SERVER.containers.hoody.com/api/v1/pipe/health&method=GET&response=transparent Metrics
Section titled “Metrics”GET /api/v1/pipe/metrics returns counters and gauges in the Prometheus text format, version 0.0.4, for a scraper or hoody pipe metrics. Every family is named hoody_pipe_* and carries only fixed labels, never a pipe name or a client address:
- transfers by result (
completed,failed), bytes read from senders, and bytes hashed for?sha256; - refusals and timeouts by reason:
pending_cap,active_cap,spectator_cap,ws_cap,live_cap,wait_timeout,idle_timeout; - pipes waiting and streaming, open
?progressstreams, and WebSocket pairs waiting and paired, with the bytes they relayed; - live viewers that joined and those cut off (
overflow,stall); - the build version and start time.
# Read Prometheus metricshoody pipe metrics --container $CONTAINER// pipe is a PipeStream from the setup aboveconsole.log(await pipe.metrics());// Inspect a transfer without receiving its bytesconsole.log(await pipe.status('my-transfer', { transfer: 'backup-2026-10-04-a1' }));# Read Prometheus metricscurl https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/metricsOnly /api/v1/pipe/metrics answers; a bare /metrics returns 404.
Troubleshooting
Section titled “Troubleshooting”Transfer Never Starts
Section titled “Transfer Never Starts”Cause: One party hasn’t connected yet. If sender and receiver use different n values, the server returns HTTP 400 instead of waiting.
Solution: Ensure both sender and receiver use the same ?n= value (or omit it for the default of 1). The connection blocks until the counterpart arrives, so check that both parties are running.
HTTP 429, too many transfers
Section titled “HTTP 429, too many transfers”Cause: Server has 1000 pending or 1000 active transfers.
Solution: Wait for existing transfers to complete or expire (each participant’s wait, 300 seconds by default), then retry. Reduce the number of concurrent transfers.
A sender that was already accepted when the active limit is reached keeps HTTP 200 and gets [ERROR] Server at maximum active transfer capacity. Try again later. in its status instead; its receivers get the 429.
Receiver Gets text/plain Instead of Expected Content-Type
Section titled “Receiver Gets text/plain Instead of Expected Content-Type”Cause: The sender’s Content-Type is a dangerous MIME type (text/html, application/javascript, etc.) that was rewritten for security.
Solution: This is intentional XSS protection. If you need the original type, the receiver can re-set it based on the filename or use the data as-is.
Path Already Has a Sender
Section titled “Path Already Has a Sender”Cause: Another sender is already connected to the same path.
Solution: Use a different path, or wait for the existing transfer to complete.