Skip to content

Named pipes, but over the internet. Send data to a path via PUT/POST, receive it via GET — data streams in real-time from sender to receiver(s) with zero server-side storage. Any path. Any content. Up to 256 simultaneous receivers.

Sharing data between machines has always been painful. FTP servers, SSH tunnels, file upload services, WebSocket boilerplate, signaling servers for WebRTC. Every approach requires setup, authentication libraries, or specialized clients.

hoody-pipe reduces all of it to two curl commands:

Terminal window
# Terminal A: Send a file
curl -T report.pdf https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.icu/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.icu/my-report > report.pdf

That’s it. No upload limits. No temporary storage. Data flows directly from sender to receiver the moment both are connected.

No per-pipe accounts — access control is enforced centrally by Hoody Proxy (IP allowlist, passwords, JWT). The pipe service itself has no service-level auth; permissions are configured at the proxy layer, not per-pipe.


  1. A sender POSTs (or PUTs) data to a path — e.g. /api/v1/pipe/my-screen-share
  2. A receiver GETs the same path
  3. Data streams directly from sender to all receivers — no buffering, no temporary files
  4. Either party can connect first — the server holds the early connection until the counterpart arrives (up to 5-minute TTL)

The path is just a name you choose. /my-file, /demo-stream, /logs/today — anything that isn’t a reserved system path (/, /help, /noscript, /favicon.ico, /robots.txt). Reserved-path matching is normalization-robust: /Help, /help/, /help., /help%2e all fold to /help and are rejected, so attackers can’t register an aliased pipe over a built-in page. The canonical health endpoint is /api/v1/pipe/health; the server also 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, stream to multiple receivers simultaneously:

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 accessed relative to your Pipe service URL:

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

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 (standardized 9-field response: status, service, built, started, memory, fds, pid, ip, userAgent). Build identity is in built (module mtime, ISO 8601); 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 (noscript fallback for the index page; CSP-nonce-restricted styles)
  • GET /api/v1/pipe — Main web UI for browser-based uploads (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/Unix socket, or any spawned command — and likewise on the receiving side.

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 — 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 issued
hoody pipe url my-screen --video -n 10 --container $CTR

The auto-generated containerClient.pipe.* methods cover the basic JSON-shaped endpoints, but pipe is fundamentally a streaming primitive — the SDK ships a hand-written Node-side companion at @hoody-ai/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-ai/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://...-pipe.containers.hoody.icu' });
// 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, // 1..256 — receivers to wait for
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 — 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.

[ERROR] … lines are emitted on failure modes — capacity exceeded, idle timeout, sender error, or 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 auto-detects the container/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.icu/api/v1/pipe/screen
# Receiver: Open in browser with video player
# https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.icu/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.icu/api/v1/pipe/my-transfer?progress"
# HTML dashboard (open in browser)
# https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.icu/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 with a URL — no WebRTC, no signaling server, no 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.icu/api/v1/pipe/my-screen?n=10"
# Share this URL with up to 10 viewers:
# https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.icu/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.icu/api/v1/pipe/dataset
# Receiver
curl https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.icu/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.icu/api/v1/pipe/nginx-logs
# Container B: Process the log stream
curl https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.icu/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.icu/api/v1/pipe/project.tar.gz
# Receiver: Download and extract
curl https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.icu/api/v1/pipe/project.tar.gz | \
tar xzf -

Don’t trust the server? 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.icu/api/v1/pipe/encrypted
# Receiver: Download and decrypt
curl https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.icu/api/v1/pipe/encrypted | \
openssl enc -d -aes-256-cbc -pbkdf2 -pass pass:SECRET > secret.doc

hoody-pipe takes security seriously:

  • Dangerous MIME types rewritten — 21 script-executable types (HTML/XML/SVG plus the WHATWG-canonical JavaScript MIME family — text/javascript, application/javascript, application/ecmascript, text/javascript1.01.5, text/jscript, text/livescript, etc.) are rewritten to text/plain so a browser receiver can’t execute them. Inline Content-Disposition is also force-coerced to attachment for any MIME outside an explicit safelist (image/*, audio/*, video/*, text/plain, …) — defense-in-depth if the dangerous-MIME table misses a niche format.
  • CRLF injection protection — Forwarded headers (Content-Disposition, X-Piping, X-Hoody-Pipe) are sanitized against header injection.
  • CSP nonces — All HTML pages (video player, progress dashboard, noscript upload) use Content-Security-Policy with nonces.
  • Path length limit — Max 1024 characters to prevent memory inflation.
  • X-Content-Type-Options: nosniff — On every response.
  • X-Robots-Tag: none — Prevents indexing of pipe data.

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 timeout5 minutes (no bytes flowing → [ERROR] Transfer aborted — idle timeout exceeded.)
Spectator idle TTL30 minutes (progress-watcher auto-close on inactivity)
Max path length1024 characters
Max URL length4096 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.icu/api/v1/pipe/snapshot
# Receiver: Gets the metadata headers forwarded
curl -v https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.icu/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 — 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.



Data transfer is just a URL now. Send to a path. Receive from the same path. Data flows through. No servers to configure. No clients to install. No files to upload. Just HTTP.