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 > 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 no buffering and no temporary files.
  4. Either party can connect first. The server holds the early connection until the counterpart arrives, up to a 5-minute TTL.

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


Terminal window
# Sender: Upload a file
hoody pipe send backup ./backup.tar.gz --container $CONTAINER
# Receiver: Download the file
hoody pipe receive backup -o 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:

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 presentation.webm

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

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/help: usage instructions with curl examples
  • GET /api/v1/pipe/noscript: JavaScript-free HTML upload form, the noscript fallback for the index page, with CSP-nonce-restricted styles
  • GET /api/v1/pipe: the main web UI for browser uploads of a file or text, with a JS progress bar

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.
pipe receive <path>Receive bytes. Sink = -o file / stdout / --to-tcp / --to-unix / --to-cmd. Honors --download, --inline, --filename.
pipe progress <path>Subscribe to live progress via SSE. Does not consume a receiver slot. --json emits raw events; --until complete,failed exits on terminal state.
pipe url <path>Print the receiver URL for a path (no fetch). Append --video, --progress, --download, --filename, -n N. --video and --progress are mutually exclusive.
pipe forward-tcp <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.
pipe healthStandard 9-field pipe-kit health JSON.
pipe help-cheatsheetServer-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-tcp ab ba --connect 127.0.0.1:5432 --container $CTR
# Side B (the side dialing in)
hoody pipe forward-tcp 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.
# 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 big-upload --json --until complete,failed
# Build a shareable URL for an n=10 video stream (no request is issued)
hoody pipe url my-screen --video -n 10 --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 / subscribeProgress / forwardTcp
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
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
contentType: 'application/octet-stream',
contentLength: stat.size, // optional; when known, enables receiver progress
filename: 'report.pdf', // forwarded as Content-Disposition
headers: { 'X-Hoody-Pipe': 'kind=audit' },
onStatus: (m) => console.log(m.level, m.message),
signal: AbortSignal.timeout(60_000),
});
await send.done; // resolves when sender's response body fully drains
// receive: gives you a Web ReadableStream<Uint8Array>
const recv = await pipe.receive('my-path', { n: 1, download: true });
recv.body.pipeTo(Writable.toWeb(createWriteStream('out.bin')));
// 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.speed);
if (ev.kind === 'done') break;
}

Bidirectional TCP forwarding over two unidirectional pipes, the same primitive the CLI’s forward-tcp is built from:

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,
});
// fwd.address: Promise<{host, port}> when in listen mode
// fwd.done: Promise<void> when fully drained
// fwd.close(): stop accepting / tear down active bridge

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.

If receivers were already waiting when the sender connects, the first line is replaced by [INFO] N receiver(s) already connected. and streaming begins immediately.

If a receiver disconnects mid-transfer:

[INFO] A receiver disconnected.
[INFO] All receivers disconnected before transfer completed.

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 (always display inline)
?filename=report.pdfSet a custom download filename (implies ?download)
?videoShow an HTML video player for WebM/MP4/MPEG-TS streams (browsers only)
?progressWatch transfer progress as SSE events or an HTML dashboard

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.

Watch transfer progress without consuming a receiver slot:

Terminal window
# SSE stream (curl, EventSource)
curl -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 / complete / failed), progress updates (bytes, speed, ETA, active receivers), and a final done event with totals (bytes transferred, duration, average speed).


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"
# Share this URL with up to 10 viewers:
# 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:

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 dataset-50gb.tar.gz
# Spectator (doesn't consume a receiver slot)
# Open in browser: .../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 https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/nginx-logs | \
grep "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
curl https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/project.tar.gz | \
tar xzf -

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 > 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. 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 (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, and X-Robots-Tag: none to keep pipe data 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
Unestablished pipe TTL5 minutes (sender or receiver waiting for counterpart; the lonely side is evicted)
Active-transfer idle timeoutThe byte stream must keep making progress (no bytes flowing → [ERROR] Transfer aborted — idle timeout exceeded.)
Spectator idle TTL30 minutes (progress-watcher auto-close on inactivity)
Max path length1024 characters

Exceeding limits returns HTTP 429 (Too Many Transfers) or 414 (Path Too Long).


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.


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

Cause: Sender and receiver have mismatched n values, or one party hasn’t connected yet.

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 (5-minute TTL for unestablished pipes), then retry. Reduce the number of concurrent transfers.

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.