Skip to content
Hoody.com

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.

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 window
# Terminal A: Send a file
curl -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.pdf

There 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.


  1. A sender POSTs (or PUTs) data to a path, for example /api/v1/pipe/my-screen-share.
  2. A receiver GETs the same path.
  3. Data streams directly from the sender to all receivers, with bounded buffers in memory and no temporary files.
  4. 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=1 through ?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.


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).

Terminal window
# Sender: Upload a file
hoody pipe send backup ./backup.tar.gz --container $CONTAINER
# Receiver: Download the file
hoody pipe receive backup -o received-backup.tar.gz --container $CONTAINER
Terminal window
# Container A: stream a long-running command's stdout
hoody 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

Send once and stream to several receivers at the same time. Run the sender and each receiver in separate programs or terminals:

Terminal window
# Sender: Stream to 3 receivers
hoody pipe send demo ./presentation.webm -n 3
# Receiver 1, 2, 3: Each runs this
hoody pipe receive demo -n 3 -o received-presentation.webm

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:

Terminal window
# Sender: broadcast a log
tail -f app.log | hoody pipe send mylog - --live
# Viewers, whenever they like (up to 256 per broadcast)
hoody pipe receive mylog --live
  • 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 (-g in 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 no Content-Length, the response carries X-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 curl or fetch reports it. These limits apply to data waiting at the server. Under Bun, fetch can read ahead without backpressure even when the SDK caller consumes PipeStream slowly. 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. ?status shows "kind": "live" with the viewers watching in activeReceivers; ?progress state and progress events carry "live": true (the final done event does not).
  • Not with n above 1, sha256, ws or a multipart body: 400.

All endpoints are relative to your pipe service URL:

https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com

Data transfer:

  • POST /api/v1/pipe/{path}: send data to a pipe path
  • PUT /api/v1/pipe/{path}: send data (alias for POST, natural for curl -T)
  • GET /api/v1/pipe/{path}: receive data from a pipe path
  • POST/PUT and GET /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)
  • GET or HEAD /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 in built (module mtime, ISO 8601), and userAgent echoes the requesting client’s User-Agent header.
  • GET /api/v1/pipe/metrics: counters and gauges in the Prometheus text format (see Metrics)
  • GET /api/v1/pipe/help: usage instructions with curl examples
  • GET /api/v1/pipe/noscript: JavaScript-free HTML upload form, the fallback for the send page, with CSP-nonce-restricted styles
  • GET /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)

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.

Terminal window
# Sources for `send`: file, "-" (stdin), --text, --from-tcp, --from-unix, --from-cmd
hoody pipe send <path> [source] [flags]
# Sinks for `receive`: file via -o, "-" (stdout, default), --to-tcp, --to-unix, --to-cmd
hoody pipe receive <path> [flags]
CommandPurpose
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 metricsThe server’s metrics in the Prometheus text format.
pipe healthStandard 9-field pipe-kit health JSON.
pipe cheatsheet getServer-rendered curl cheatsheet (text/plain).
Flag / envPurpose
-c <id> / --container <id> / HOODY_CONTAINERContainer to address (resolves the kit URL through the Hoody API). --container-id and --containerId are accepted aliases.
--pipe-url <url> / HOODY_PIPE_URLDirect pipe-server URL that bypasses container resolution. Useful for self-hosted pipe servers, tests, or off-network clients.
Terminal window
# 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 name
hoody pipe forward pg --ws --connect 127.0.0.1:5432 --container $CTR
hoody pipe forward pg --ws --listen 127.0.0.1:15432 --container $CTR
# Chat between two terminals, one line per message
hoody pipe connect chat --text --keep-open --container $CTR
# Stream stdout of a long-running command
hoody pipe send build-logs --from-cmd "make -j" --container $CTR
# Watch a transfer's progress as JSON without consuming a slot
hoody 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 confirm
hoody pipe url get report --receive --filename report.pdf --autostart --container $CTR
hoody pipe url get demo --share --source camera -n 2 --container $CTR
hoody 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 $CTR
hoody pipe receive report --browser --filename report.pdf --autostart --container $CTR
hoody pipe send --browser --text "hello" --autostart --container $CTR

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';
// 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 ended
const 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.transferId
const 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`);
}

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 bridge

Await 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, after keepaliveMs, 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 keepaliveMs with 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=1 pipe the peer never receives. A peer whose app stops reading for 5 minutes also ends the tunnel this way.
  • Keep keepaliveMs under 300000; at or above the server’s idle timeout it cannot prevent it. 0 turns re-opening off, and an idle tunnel then closes after 5 minutes.

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 with 4408 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. ?ws on a name with a waiting or running transfer is 409, and so is a sender or receiver on a name that has a pair.
  • ?status shows a pair as "kind": "ws" with peers (0-2), and ?progress follows 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 with 1009 Message too big.. A reader may fall up to 2 MiB behind in each direction, after which the pair is closed with 1013. A peer that stops answering pings is dropped after about 2 minutes.
  • The first peer’s Sec-WebSocket-Protocol choice binds the pair. The second peer must offer the same subprotocol, or offer none when the first offered none; otherwise it gets 409.
  • n does not combine with ?ws (400), and neither does live (400, checked first, except that a request with ?status gets the status). A plain GET with ?ws and no upgrade gets 426.

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, maxReceiveMessages
const writer = chat.writable.getWriter();
await writer.write('hello'); // text message
await writer.write(new Uint8Array([1, 2, 3])); // binary message; over 1 MiB rejects with RangeError
for 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.


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 headers
const { body, headers } = await pipe.receive('report');
// download: the browser's download manager receives ?download, so any size goes to disk
pipe.download('report', { filename: 'report.pdf' });
// subscribeProgress: the same events as PipeStream.subscribeProgress, without a receiver slot
for 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.

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:

PageOptions
sendn, text, mode, filename, autostart (the name is the second argument)
receiven, filename, autostart, wait, sha256
sharesource, audio, surface, quality, fps, n, live
videon, live, wait
progressnone
noscriptmode, 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=1
pipe.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".


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.


Receivers can control download behavior with query parameters:

ParameterEffect
?downloadForce browser download (Content-Disposition: attachment)
?download=falseSuppress Content-Disposition entirely; the browser then decides from the Content-Type whether to display or download
?filename=report.pdfSet a custom download filename (implies ?download)
?videoShow an HTML video player for WebM and fragmented MP4 streams (browsers only; play MPEG-TS in a media player such as VLC or mpv)
?progressWatch transfer progress as SSE events or an HTML dashboard
?receiveShow the receive page in a browser; the browser’s download manager receives the transfer
?shareShow the share page in a browser: stream the screen, a camera, or the microphone to ?video viewers
?wsUpgrade to a two-way WebSocket relay; a plain GET without an upgrade gets 426
?statusAnswer with one JSON snapshot of the name instead of the data; takes no receiver slot
?wait=SECHow long this receiver waits for a sender, 1 to 3600 seconds (default 300); senders take it too
?sha256Have the server hash the transfer; senders take it too
?transfer=IDSenders only: the transfer’s own id, so the sender can follow it with ?status&transfer=ID (see Status, waiting and checksums)
?liveWatch 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.

Run each sender and its receiver in separate terminals.

Terminal window
# The state of a name: JSON, no receiver slot
curl "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 stream
curl -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 them
curl --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 sha256
curl "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.

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:

Terminal window
# Sender: Stream your screen as WebM
ffmpeg -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?video

Non-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.

Watch transfer progress without consuming a receiver slot:

Terminal window
# 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?progress

SSE 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”.

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:

ParameterEffect
namePipe name, up to 1024 characters. Without it the page picks a random name
nReceiver count, 1 to 256
textText to send, up to 100000 characters. Selects text mode
modefile or text
filenameFile name the receiver gets for a text or a pasted image, up to 255 characters
autostart=1In 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.

Terminal window
# 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=1

For 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.

Open /{path}?receive in a browser to receive a transfer into the browser’s downloads:

Terminal window
# Receiver: open in a browser
# https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/report?receive&autostart=1
# Sender
curl -T report.pdf https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/report

The 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.

ParameterEffect
nReceiver count (1 to 256), the same n the sender uses
filenameName of the downloaded file, up to 255 characters
autostart=1Start receiving when the page opens, without a click
waitSeconds this receive waits for a sender (1 to 3600), passed on to the download
sha256Have 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

Open /{path}?share in a browser to stream your screen, a camera, or the microphone to viewers of /{path}?video, without a terminal:

Terminal window
# 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=2

Pick 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:

ParameterEffect
sourcescreen, camera, or audio
audio1, true, yes, or bare audio: include the screen’s audio. 0, false, or no: share the screen without audio (the default)
surfacemonitor, window, or browser: the kind of surface the screen picker offers first
qualitylow, medium, or high (480p, 720p, or 1080p; default medium). These are maximum dimensions for screen capture and preferred dimensions for camera capture
fpsFrame rate, 1 to 60 (default 30). A maximum for screen capture, a preference for camera capture
live1, true, yes, or bare live: tick Live for a broadcast viewers join at any time (n is then ignored)
nViewer count, 1 to 256

Invalid values fall back to the defaults, except an n above 256, which is capped at 256.


Stream your real device screen to anyone who has the URL, with no WebRTC, signaling server, or app install:

Terminal window
# On your machine: Capture screen and pipe it
ffmpeg -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&video

Send a large file and let others watch the progress. Run the sender and receiver in separate terminals:

Terminal window
# Sender
curl -T ./dataset-50gb.tar.gz \
https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/dataset
# Receiver
curl 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?progress

Stream events from one container to another in real time:

Terminal window
# Container A: Forward nginx access logs
tail -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 stream
curl -N https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/nginx-logs | \
grep --line-buffered "500" | tee errors.log
Terminal window
# Sender: Tar and stream a directory
tar 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 directory
mkdir -p received
curl https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/project.tar.gz | \
tar xzf - -C received

If you do not want the server to see plaintext, encrypt before piping:

Terminal window
# Sender: Encrypt and send
openssl 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 decrypt
curl 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.doc

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.0 through 1.5, text/jscript, text/livescript, and the rest) are rewritten to text/plain so a browser receiver cannot execute them. Unless the receiver asks for ?download=false (no Content-Disposition at all), an inline Content-Disposition is also force-coerced to attachment for 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: nosniff on every response with a body, and X-Robots-Tag: none on received data to keep it out of search indexes.

LimitValue
Max concurrent pending (unestablished) pipes1000
Max concurrent active (streaming) transfers1000
Max receivers per pipe256
Max spectators per path50
Max spectator groups500
Wait for the counterpart5 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 transfers1000, kept up to 10 minutes each; oldest evicted at capacity
Active-transfer idle timeout5 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 endedconnection closed after about 1 second when the transfer failed while streaming, after 255 seconds without progress when it completed
Finished names visible to status1000 at most, for up to 30 seconds; oldest evicted at capacity
Spectator connection TTL30 minutes (progress-watcher connection auto-closes after 30 minutes)
Max path length1024 characters
JavaScript-free form text1 MiB of UTF-8 text; file uploads and raw byte streams have no application size cap
Multipart part selection30 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 size1 MiB
WebSocket buffer per direction2 MiB (before the second peer arrives: also 4096 messages)
Live broadcasts100 at once, 256 viewers each
Live viewer responses4096 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 viewers64 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 cut8 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.


Forward arbitrary metadata from sender to receiver using custom headers:

Terminal window
# Sender: Include metadata
curl -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 forwarded
curl -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.


Terminal window
# Standard 9-field health JSON
hoody pipe health --container $CONTAINER

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 ?progress streams, 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.
Terminal window
# Read Prometheus metrics
hoody pipe metrics --container $CONTAINER

Only /api/v1/pipe/metrics answers; a bare /metrics returns 404.


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.

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.

Cause: Another sender is already connected to the same path.

Solution: Use a different path, or wait for the existing transfer to complete.