hoody-pipe is a named pipe that runs over the internet instead of a local filesystem. A sender writes bytes to a path with PUT or POST, receivers read the same path with GET, and the data streams straight through from sender to receivers in real time with no server-side storage. The path is any name you pick, the content is any bytes, and up to 256 receivers can read one transfer at the same time.
What pipe replaces
Section titled “What pipe replaces”Moving data between two machines usually means an FTP server, an SSH tunnel, a file upload service, WebSocket boilerplate, or a signaling server for WebRTC. Each of those needs setup, an authentication library, or a specialized client. hoody-pipe needs two curl commands:
# Terminal A: Send a filecurl -T report.pdf https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/my-report
# Terminal B: Receive the file (run this before, during, or after the sender connects)curl https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/my-report > report.pdfThere is no upload limit and no temporary storage: the bytes move from sender to receiver the moment both are connected.
Pipe has no per-pipe accounts and no service-level auth of its own. Hoody Proxy enforces access control centrally through IP allowlists, passwords, and JWT, so permissions are configured at the proxy layer rather than per pipe.
How it works
Section titled “How it works”- A sender POSTs (or PUTs) data to a path, for example
/api/v1/pipe/my-screen-share. - A receiver GETs the same path.
- Data streams directly from the sender to all receivers, with no buffering and no temporary files.
- 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'.
Quick examples
Section titled “Quick examples”Send and receive a file
Section titled “Send and receive a file”# Sender: Upload a filehoody pipe send backup ./backup.tar.gz --container $CONTAINER
# Receiver: Download the filehoody pipe receive backup -o backup.tar.gz --container $CONTAINER# Sender: Upload a file (PUT is natural for curl -T)curl -T ./backup.tar.gz \ https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/backup
# Receiver: Download the filecurl https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/backup \ -o backup.tar.gzimport { HoodyClient, PipeStream } from 'hoody-sdk';import { createReadStream, createWriteStream } from 'node:fs';import { Writable } from 'node:stream';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });const containerClient = await client.withContainer({ id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER });
// Generic byte-stream API (any source/sink) via PipeStreamconst pipe = PipeStream.fromClient(client, { id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER });
// Send: any PipeSource (file stream, Buffer, string, ReadableStream, TCP/Unix socket, AsyncIterable)const send = await pipe.send('backup', createReadStream('./backup.tar.gz'));await send.done;
// Receive: get a Web ReadableStream you can pipe anywhereconst recv = await pipe.receive('backup');await recv.body.pipeTo(Writable.toWeb(createWriteStream('./backup.tar.gz')));Stream text between containers
Section titled “Stream text between containers”# Container A: stream a long-running command's stdouthoody pipe send live-logs --from-cmd "tail -f /var/log/app.log"
# Container B: receive to stdout (the default sink)hoody pipe receive live-logs# Container A: Pipe logs in real-timetail -f /var/log/app.log | curl -T - \ https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/live-logs
# Container B: Watch the logscurl https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/live-logsimport { PipeStream } from 'hoody-sdk';
const pipe = PipeStream.fromClient(client, { id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER });
// Async-iterable source: works well for line-by-line producersasync function* tail() { for await (const line of process.stdin) yield line;}await (await pipe.send('live-logs', tail())).done;
// Receiver: pipe Web ReadableStream straight to stdoutconst recv = await pipe.receive('live-logs');await recv.body.pipeTo(Writable.toWeb(process.stdout));Fan out to multiple receivers
Section titled “Fan out to multiple receivers”Send once and stream to several receivers at the same time:
# Sender: Stream to 3 receivershoody pipe send demo ./presentation.webm -n 3
# Receiver 1, 2, 3: Each runs thishoody pipe receive demo -n 3 -o presentation.webm# Sender: Stream to 3 receiverscurl -T ./presentation.webm \ "https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/demo?n=3"
# Receiver 1, 2, 3: Each runs this (all get identical copies)curl "https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/demo?n=3" \ -o presentation.webm// Sender: Stream to 3 receiversconst send = await pipe.send('demo', createReadStream('./presentation.webm'), { n: 3 });await send.done;
// Each receiverconst recv = await pipe.receive('demo', { n: 3 });await recv.body.pipeTo(Writable.toWeb(createWriteStream('./presentation.webm')));API endpoints summary
Section titled “API endpoints summary”All endpoints are relative to your pipe service URL:
https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.comData transfer:
POST /api/v1/pipe/{path}: send data to a pipe pathPUT /api/v1/pipe/{path}: send data (alias for POST, natural forcurl -T)GET /api/v1/pipe/{path}: receive data from a pipe 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 inbuilt(module mtime, ISO 8601), anduserAgentechoes the requesting client’sUser-Agentheader.GET /api/v1/pipe/help: usage instructions with curl examplesGET /api/v1/pipe/noscript: JavaScript-free HTML upload form, the noscript fallback for the index page, with CSP-nonce-restricted stylesGET /api/v1/pipe: the main web UI for browser uploads of a file or text, with a JS progress bar
Command-line interface
Section titled “Command-line interface”The hoody CLI ships a pipe namespace that wraps every operation above with shell-friendly source/sink flags. Bytes can come from a file, stdin, a literal --text value, a TCP or Unix socket, or any spawned command, and the receiving side takes the same set of sinks.
# Sources for `send`: file, "-" (stdin), --text, --from-tcp, --from-unix, --from-cmdhoody pipe send <path> [source] [flags]
# Sinks for `receive`: file via -o, "-" (stdout, default), --to-tcp, --to-unix, --to-cmdhoody pipe receive <path> [flags]Commands
Section titled “Commands”| Command | Purpose |
|---|---|
pipe send <path> [source] | Send bytes to a pipe path. Source = file / - / --text / --from-tcp / --from-unix / --from-cmd. Add --put for curl -T parity. |
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 health | Standard 9-field pipe-kit health JSON. |
pipe help-cheatsheet | Server-rendered curl cheatsheet (text/plain). |
Routing flags (all commands)
Section titled “Routing flags (all commands)”| Flag / env | Purpose |
|---|---|
-c <id> / --container <id> / HOODY_CONTAINER | Container to address (resolves the kit URL through the Hoody API). --container-id and --containerId are accepted aliases. |
--pipe-url <url> / HOODY_PIPE_URL | Direct pipe-server URL that bypasses container resolution. Useful for self-hosted pipe servers, tests, or off-network clients. |
Examples
Section titled “Examples”# Bridge a TCP port over a pipe (e.g. expose a localhost service to a peer)# Side A (the side hosting the service)hoody pipe forward-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 commandhoody pipe send build-logs --from-cmd "make -j" --container $CTR
# Watch a transfer's progress as JSON without consuming a slothoody 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 $CTRSDK streaming helpers (Node.js)
Section titled “SDK streaming helpers (Node.js)”The auto-generated containerClient.pipe.* methods cover the JSON-shaped endpoints, but pipe is a streaming primitive, so the SDK also ships a hand-written Node-side companion at hoody-sdk’s top level.
import { PipeStream, // class: send / receive / 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';PipeStream
Section titled “PipeStream”// Either resolve from the Hoody control plane:const pipe = PipeStream.fromClient(client, { id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER });
// Or address a pipe server directly (self-hosted, tests, off-network):const pipe = new PipeStream({ pipeBaseUrl: 'https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com' });
// send: accepts any PipeSource:// string | Buffer | Uint8Array | ReadableStream | NodeJS.ReadableStream// | AsyncIterable | URL (file:) | { tcp: { host, port } } | { unix: '/path' }const send = await pipe.send('my-path', source, { method: 'POST', // or 'PUT' (curl -T parity) n: 1, // receivers to wait for, 1..256 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;}PipeStream.forwardTcp
Section titled “PipeStream.forwardTcp”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 bridgeSender status messages
Section titled “Sender status messages”When you POST or PUT, the response body streams real-time status messages:
[INFO] Waiting for 1 receiver(s) to connect...[INFO] A receiver connected.[INFO] Streaming to 1 receiver(s)...[INFO] Upload complete.[INFO] Transfer complete.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.
Receiver options
Section titled “Receiver options”Receivers can control download behavior with query parameters:
| Parameter | Effect |
|---|---|
?download | Force browser download (Content-Disposition: attachment) |
?download=false | Suppress Content-Disposition entirely (always display inline) |
?filename=report.pdf | Set a custom download filename (implies ?download) |
?video | Show an HTML video player for WebM/MP4/MPEG-TS streams (browsers only) |
?progress | Watch transfer progress as SSE events or an HTML dashboard |
Video player
Section titled “Video player”Append ?video to the receiver URL and open it in a browser. hoody-pipe serves an embedded MSE video player that detects the container and codec from the stream:
# Sender: Stream your screen as WebMffmpeg -f x11grab -i :0 -c:v libvpx -f webm - | \ curl -T - https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/screen
# Receiver: Open in browser with video player# https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/screen?videoNon-browser clients (VLC, mpv, ffplay) with ?video get the raw stream automatically.
Transfer progress
Section titled “Transfer progress”Watch transfer progress without consuming a receiver slot:
# 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?progressSSE 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).
Use cases
Section titled “Use cases”Screen sharing over HTTP
Section titled “Screen sharing over HTTP”Stream your real device screen to anyone who has the URL, with no WebRTC, signaling server, or app install:
# On your machine: Capture screen and pipe itffmpeg -f x11grab -i :0 -c:v libvpx-vp9 -f webm - | \ curl -T - "https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/my-screen?n=10"
# Share this URL with up to 10 viewers:# https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/my-screen?n=10&videoFile transfer with progress
Section titled “File transfer with progress”Send a large file and let others watch the progress:
# Sendercurl -T ./dataset-50gb.tar.gz \ https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/dataset
# Receivercurl https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/dataset \ -o dataset-50gb.tar.gz
# Spectator (doesn't consume a receiver slot)# Open in browser: .../api/v1/pipe/dataset?progressLive event forwarding
Section titled “Live event forwarding”Stream events from one container to another in real time:
# Container A: Forward nginx access logstail -f /var/log/nginx/access.log | curl -T - \ https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/nginx-logs
# Container B: Process the log streamcurl https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/nginx-logs | \ grep "500" | tee errors.logDirectory transfer
Section titled “Directory transfer”# Sender: Tar and stream a directorytar czf - ./my-project | curl -T - \ https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/project.tar.gz
# Receiver: Download and extractcurl https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/project.tar.gz | \ tar xzf -End-to-end encryption
Section titled “End-to-end encryption”If you do not want the server to see plaintext, encrypt before piping:
# Sender: Encrypt and sendopenssl enc -aes-256-cbc -pbkdf2 -pass pass:SECRET < secret.doc | \ curl -T - https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/encrypted
# Receiver: Download and decryptcurl https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/encrypted | \ openssl enc -d -aes-256-cbc -pbkdf2 -pass pass:SECRET > secret.docSecurity
Section titled “Security”The pipe server applies these protections:
- Dangerous MIME types rewritten: script-executable types (HTML, XML, and SVG, plus the full WHATWG-canonical JavaScript MIME family:
text/javascript,application/javascript,application/ecmascript,text/javascript1.0through1.5,text/jscript,text/livescript, and the rest) are rewritten totext/plainso a browser receiver cannot execute them. InlineContent-Dispositionis also force-coerced toattachmentfor any MIME outside an explicit safelist (image/*,audio/*,video/*,text/plain, and similar), which covers the case where the dangerous-MIME table misses a niche format. - CRLF injection protection: the server sanitizes forwarded headers (Content-Disposition, X-Piping, X-Hoody-Pipe) against header injection.
- CSP nonces: every HTML page (video player, progress dashboard, noscript upload) uses Content-Security-Policy with nonces.
- Path length limit: 1024 characters, which prevents memory inflation.
- Response headers:
X-Content-Type-Options: nosniffon every response, andX-Robots-Tag: noneto keep pipe data out of search indexes.
Limits
Section titled “Limits”| Limit | Value |
|---|---|
| Max concurrent pending (unestablished) pipes | 1000 |
| Max concurrent active (streaming) transfers | 1000 |
| Max receivers per pipe | 256 |
| Max spectators per path | 50 |
| Max spectator groups | 500 |
| Unestablished pipe TTL | 5 minutes (sender or receiver waiting for counterpart; the lonely side is evicted) |
| Active-transfer idle timeout | The byte stream must keep making progress (no bytes flowing → [ERROR] Transfer aborted — idle timeout exceeded.) |
| Spectator idle TTL | 30 minutes (progress-watcher auto-close on inactivity) |
| Max path length | 1024 characters |
Exceeding limits returns HTTP 429 (Too Many Transfers) or 414 (Path Too Long).
Custom Metadata
Section titled “Custom Metadata”Forward arbitrary metadata from sender to receiver using custom headers:
# Sender: Include metadatacurl -T ./image.png \ -H "X-Hoody-Pipe: source=camera-1;timestamp=2026-03-04T12:00:00Z" \ -H "Content-Disposition: attachment; filename=\"snapshot.png\"" \ https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/snapshot
# Receiver: Gets the metadata headers forwardedcurl -v https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/snapshot \ -o snapshot.png# < X-Hoody-Pipe: source=camera-1;timestamp=2026-03-04T12:00:00Z# < Content-Disposition: attachment; filename="snapshot.png"Both X-Hoody-Pipe and X-Piping headers are forwarded to receivers and exposed via CORS.
Health & Monitoring
Section titled “Health & Monitoring”# Standard 9-field health JSONhoody pipe health --container $CONTAINER# Check service healthcurl https://{projectId}-{containerId}-pipe-1.{server}.containers.hoody.com/api/v1/pipe/health{ "status": "ok", "service": "pipe", "built": "2026-04-14T22:17:54Z", "started": "2026-04-22T09:12:03Z", "memory": { "rss": 44145050, "heap": 29884416 }, "fds": 17, "pid": 1234, "ip": "172.17.0.2", "userAgent": "curl/8.4.0"}userAgent is the requesting client’s User-Agent header, echoed back (useful for debugging client identity through the proxy). Server build identity lives in built (the module’s mtime as an ISO 8601 string).
import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });const containerClient = await client.withContainer({ id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER });
// Health check: build identity is in `built` (module mtime, ISO 8601);// `userAgent` echoes whatever User-Agent the SDK's HTTP client sent.const health = await containerClient.pipe.health.check();console.log(health.data.status); // "ok"console.log(health.data.built); // "2026-04-14T22:17:54Z"One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Fetches the pipe service’s standard 9-field health check. No auth is required — pipe has no service-level auth of its own.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-pipe-1.SERVER.containers.hoody.com/api/v1/pipe/health&method=GET&response=transparent Troubleshooting
Section titled “Troubleshooting”Transfer Never Starts
Section titled “Transfer Never Starts”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.
HTTP 429, too many transfers
Section titled “HTTP 429, too many transfers”Cause: Server has 1000 pending or 1000 active transfers.
Solution: Wait for existing transfers to complete or expire (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.
Path Already Has a Sender
Section titled “Path Already Has a Sender”Cause: Another sender is already connected to the same path.
Solution: Use a different path, or wait for the existing transfer to complete.