Skip to content
Hoody.com

hoody-curl turns HTTP requests into HTTP endpoints. It executes any HTTP request via a simple GET call, wraps complex POST operations into shareable URLs, schedules recurring requests with cron, and persists cookie sessions automatically. The request engine is libcurl, driven through Rust bindings.

  • POST→GET wrapping: turn any POST request into a shareable GET URL.
  • Multiplexed WebSocket channel: pay the TCP/TLS handshake once and run hundreds of concurrent cURLs over one socket.
  • Server-sent events (SSE): detect an upstream text/event-stream response and forward events as they arrive, whether the upstream is OpenAI, Anthropic, or your own AI agent.
  • Scheduled requests: run recurring HTTP calls on a cron schedule.
  • Session management: persist cookies across multiple requests automatically.
  • Async execution: queue long-running requests and retrieve results later.
  • Response storage: save responses to the downloads/by-job/{job_id}/ storage tree.
  • Advanced options: auth, retries, timeouts, cookies, redirects, and speed limits. Two groups are deliberately rejected with a 400: the proxy fields (proxy, proxy_user, proxy_password), because libcurl would reach the proxy before URL validation could inspect it, and the certificate fields (cacert, cert, key), because they take filesystem paths that could be used to probe local files.
  • TypeScript SDK: drop-in fetch() over the WebSocket channel; SSE responses arrive as a streaming Response.body.
  • libcurl engine: requests are executed by libcurl through Rust bindings.

All endpoints are relative to your cURL service URL:

https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com

Request execution:

Sessions:

Jobs:

Scheduling:

Storage:

Realtime (WebSocket and SSE):

Operations:

  • GET /api/v1/curl/health - Service health check
  • GET /metrics - Prometheus metrics

hoody-curl can wrap any complex POST request into a simple, shareable GET URL.

A POST request written the traditional way cannot be shared as a link:

Terminal window
curl -X POST "https://api.example.com/search" \
-H "Authorization: Bearer token123" \
-H "Content-Type: application/json" \
-d '{"query": "user data", "filters": {...}}'

The same request wrapped as a GET URL:

GET https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?\
url=https://api.example.com/search&\
method=POST&\
header=Authorization: Bearer token123&\
json={"query":"user data","filters":{...}}
# The POST is now a GET URL you can:
# - Share via email or chat
# - Embed in documentation
# - Bookmark in a browser
# - Use in no-code tools
# - Schedule with cron

POST operations become first-class URLs, which makes workflows possible that traditional HTTP does not support: anything that can follow a link can now trigger the request.

The query is parsed by splitting on & and then on the first =, so slashes, colons, braces, quotes and semicolons all pass through and encoding them only makes the link unreadable.

Four things must be encoded inside a value:

CharacterEncode asWhat happens if you don’t
&%26The value ends there. The remainder is read as cURL’s own parameters, so the request runs truncated and returns 200 on something you did not ask for.
+%2BDecodes to a space.
#%23The browser treats it as a fragment and never sends it.
%%25An existing %41 in your value decodes to A.

Control characters need encoding too: a raw carriage return or newline is stripped by the browser before the request is sent, so the link succeeds while the body silently loses bytes.

The & case is the one that bites, because a shell command containing &&, or a target URL carrying its own query string, hits it immediately:

# Wrong: the value ends at the first &, and "git pull" becomes a parameter
json={"command":"cd /app && git pull"}
# Right
json={"command":"cd%20/app%20%26%26%20git%20pull"}

POST→GET wrapping is the base case. Combine hoody-curl with other Hoody services and any workflow, no matter how complex, becomes a single clickable URL. We call these magic links.

One-click deploy (pull code, install dependencies, restart the service):

GET .../api/v1/curl/request?url=https://CONTAINER-terminal-1.../api/v1/terminal/execute&method=POST&json={"command":"cd%20/app%20%26%26%20git%20pull%20%26%26%20npm%20install%20%26%26%20npm%20run%20build","wait":true}

AI-powered summary (send a webpage to Hoody AI, get a summary back):

GET .../api/v1/curl/request?url=https://ai.hoody.com/api/v1/chat/completions&method=POST&header=Authorization:%20Bearer%20container-1&json={"model":"anthropic/claude-sonnet-4.5","messages":[{"role":"user","content":"Summarize:%20https://example.com/article"}]}

More magic link ideas:

Magic LinkWhat it does
One-click demoRestore a snapshot → start the app → return the live URL
AI code reviewFetch a file from the container → send to Hoody AI → return review
Database exportQuery SQLite → format as CSV → save to Files → return download link
Health check + auto-healCheck daemon status → if FATAL, restart → return status report
Scheduled AI digestCron-fetch RSS feeds → send to AI for summarization → save report
Webhook relayGitHub push → pull code in container → rebuild → restart daemon
Container factoryCreate a new container → configure it → return its service URLs
One-click backupSnapshot container + export SQLite + zip project → return download
AI translationFetch a doc → send to Hoody AI with target language → return translated version
Status dashboardQuery multiple daemons + services → combine into a single JSON health report

The pattern generalizes: any chain of Hoody API calls can be encoded into a single GET URL, then shared in chat, bookmarked, embedded in a no-code tool, or scheduled with cron. The caller needs no client-side code; opening the link runs the workflow.

A wrapped request is a long URL. A proxy alias can hold the whole thing — path and query — so the request gets a short hostname you choose and running it becomes a matter of opening that name.

Point the alias at the container’s curl service and put the entire wrapped request in target_path:

Terminal window
curl -X POST "https://api.hoody.com/api/v1/proxy/aliases" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"container_id": "890abcdef12345678901cdef",
"alias": "health",
"program": "curl",
"index": 1,
"target_path": "/api/v1/curl/request?url=https://api.example.com/health&method=GET&response=transparent",
"allow_path_override": false
}'

Opening https://health.{server}.containers.hoody.com/ now performs the wrapped request. With allow_path_override: false that hostname does exactly this one call and nothing else, which is what makes it safe to hand out.

Aliases can also expire. Pass expires_at to hand someone a shortcut that stops working on its own.

GET wrapping is what makes hoody-curl useful to AI tools. Any AI that can fetch a URL, whether ChatGPT, Claude, Claude Code, Cline, or any other agent, can trigger a full Hoody workflow through a single GET request. There is no SDK, no auth ceremony, and no POST body to construct. You wrap the complex operation once; from that point on, any platform capable of fetching a URL can deploy your code, run a database migration, or restart a service.

# Chatbot triggers a full deployment pipeline:
GET .../api/v1/curl/request?url=https://CONTAINER-terminal-1.../api/v1/terminal/execute&method=POST&json={"command":"cd%20/app%20%26%26%20git%20pull%20%26%26%20bun%20run%20deploy","wait":true}

Combine with @hoody.com Skill delivery: an external AI learns your infrastructure’s API, wraps the critical actions as GET URLs, and hands them back to non-technical users as one-click links. The user sees only the link, not the API call it encodes.


Both forms call the same /api/v1/curl/request endpoint: the GET form takes query parameters for simple requests, and the POST form takes a JSON body with the full option set.

Terminal window
# Simple GET request
hoody curl get-url \
--url "https://api.example.com/data" \
--follow-redirects --response json
# Advanced POST request
hoody curl exec \
--url "https://api.example.com/users" \
--method POST \
--timeout 30
# Create a scheduled request (6-field cron: second minute hour day month weekday)
hoody curl schedules create \
--cron "0 0 9 * * MON-FRI" \
--request-url "https://api.example.com/daily-report" \
--request-method POST

Persist authentication across multiple requests:

Log in once, reuse the cookies on later calls, then clear them:

Terminal window
# Log in; cookies are saved under the session automatically
hoody curl exec \
--url "https://example.com/login" \
--method POST \
--json '{"user": "john", "pass": "secret"}' \
--session-id user-123
# Saved cookies are sent automatically
hoody curl exec \
--url "https://example.com/api/data" \
--session-id user-123
# Log out and clear the session
hoody curl sessions delete user-123

Queue long-running requests:

Submit the job, poll it, then collect the result:

Terminal window
# Submit; the response is saved under downloads/by-job/{job_id}/
hoody curl exec \
--url "https://example.com/large-file.zip" \
--mode async \
--save \
--save-path large-file.zip
# Check status
hoody curl jobs get JOB_ID
# Collect the result once the job completes
hoody curl jobs result JOB_ID

Submitting returns 202 Accepted with {"success": true, "job_id": "<uuid>", "message": "Job submitted for execution"}, and the file lands under downloads/by-job/{job_id}/large-file.zip.

Every HTTP call over the public Internet pays for TCP, TLS, and HTTP negotiation: N requests cost N round trips of setup before a byte of real data crosses the wire. /api/v1/curl/channel pays that cost once. It is a single persistent WebSocket over which you multiplex hundreds of concurrent cURL requests, each with its own stream_id, each cancellable, each returning headers, body, and timing exactly like the REST endpoint, without the per-request TCP/TLS setup.

Terminal window
# Open the channel. The server sends a `hello` frame with limits + features.
websocat "wss://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/channel"

Wire protocol (JSON text frames; opt into binary frames with ?binary=true, described below):

// server → client (on connect)
{"type":"hello","version":2,"connection_id":"...","limits":{...},"features":{"sse":true,...}}
// client → server (issue a request)
{"type":"request.start","stream_id":7,"request":{
"url":"https://api.example.com/things",
"method":"POST",
"json":{"hello":"world"}
}}
// server → client (response delivered in chunks). `is_sse` is omitted for
// non-SSE responses (only present when true). `headers` is the lowercase-keyed
// map; `raw_headers: [{name, value}]` preserves original header order + case.
{"type":"accepted","stream_id":7}
{"type":"response.start","stream_id":7,"status_code":200,"headers":{...},"raw_headers":[...],"content_type":"...","effective_url":"...","body_bytes":...}
{"type":"response.body","stream_id":7,"offset":0,"encoding":"base64","data":"..."}
{"type":"response.end","stream_id":7,"timing":{...},"metadata":{...}}
// client → server (cancel mid-flight)
{"type":"request.cancel","stream_id":7}

Base64 inside JSON inflates every response body by ~33% and forces a JSON-parse of the whole blob. Open the channel with ?binary=true and the server advertises features.binary_frames: true in hello; from then on:

  • Response bodies arrive as raw binary WebSocket frames, with no base64 and no JSON envelope. Each frame is a 16-byte little-endian header (version, kind, flags, stream_id) followed by the raw chunk. response.start / response.end stay JSON.
  • Binary request uploads work: send request.start with "binary_body": true (and no data), then a binary REQUEST_BODY frame carrying the bytes. The server holds execution until the body frame lands.

Omitting ?binary=true keeps the text/base64 protocol, so a client that does not ask for binary is unaffected. The TypeScript SDK negotiates binary automatically. Measured: large downloads run 2 to 3.6 times faster and binary uploads 2.3 times faster than the base64 path.

Compression pass-through. Under ?binary=true the relay also stops asking libcurl to auto-decompress upstream gzip/deflate. Instead it sends its own Accept-Encoding: gzip, deflate header, leaves the response bytes compressed end to end, and forwards the upstream’s Content-Encoding header verbatim; the SDK pipes the body through DecompressionStream on its side. This saves both the relay CPU spent decompressing on the hot path and the wire bytes of re-transmitting the decompressed body. Measured: 1.6× the throughput on gzip-encoded 1 MiB downloads, plus the upstream’s roughly 3 to 4× gzip ratio in wire bytes. One edge case: a 2xx text/event-stream upstream that advertises Content-Encoding: gzip falls back to buffered mode, because the relay’s SSE parser only handles plain bytes; the SDK still receives the compressed body and the consumer can parse SSE off the decompressed text.

Streaming response bodies. Under ?binary=true a 2xx non-SSE response does not wait for libcurl to finish: response.start ships as soon as the upstream’s header section completes, and BIN_KIND_BODY frames flow out interleaved with the upstream’s writes. The final frame carries BIN_FLAG_LAST so the SDK can finalize its ReadableStream without parsing response.end. Time to first byte collapses to a single upstream round trip plus one WebSocket frame regardless of body size. The SSE semaphore is not consumed (channel max_concurrent is the relevant cap), and non-2xx responses still buffer so callers see the full error body and status. Streaming pairs with compression pass-through: the upstream’s compressed bytes stream straight into the SDK’s DecompressionStream. Tiny bodies (Content-Length ≤ 16 KiB) stay buffered, since for them the streaming-setup overhead outweighs the time-to-first-byte win. Measured: 5.7× faster 1 MiB downloads and 3.4× faster 8 MiB downloads than the buffered binary path.

Libcurl handle pool. The relay maintains a process-wide pool of warmed Easy2 handles shared by the sync /curl endpoint, the channel WS path, and the async job worker. Each pooled handle keeps its libcurl connection cache (TCP keep-alive, TLS sessions, and DNS) hot across requests, so the second call to the same host skips the full handshake. This shows no benefit against a local mock upstream; in production it is the difference between a 50ms TLS round trip and a sub-millisecond keep-alive hit when an AI agent makes many calls to the same API host.

The pool keys on (scheme, host, port, pinned_ip, conn_opts_hash). The pinned IP is the actual CURLINFO_PRIMARY_IP libcurl connected to on the last transfer, not a pre-transfer guess, so DNS rotation cannot accidentally reuse a connection bound to a stale address. The per-origin cap is 8, the global cap is 64, and eviction is approximate LRU.

The conn_opts_hash covers the connection-level options that determine whether two requests can share the same TCP/TLS session: insecure, proxy and its credentials, and cert/key/cacert/cert_type. A request with insecure=true gets a different pool slot from a plain request; they cannot share a connection because the TLS handshake differs.

Per-request payload (Authorization headers, cookies, bearer tokens, session_id, range, and similar) is not in the pool key. hoody-curl doesn’t authenticate callers (auth is a hoody-proxy concern; payload auth is forwarded to the upstream API). easy.reset() clears all such request-level state between handle uses, so payload credentials cannot leak across requests via the pool. This recovers warm TLS reuse for the dominant Hoody workload: relaying authenticated API calls (OpenAI, Anthropic, and similar) where every request carries an Authorization: Bearer … header.

Metrics on /metrics:

  • hoody_curl_pool_takes_total, hoody_curl_pool_hits_total, hoody_curl_pool_misses_total, hoody_curl_pool_puts_total
  • hoody_curl_pool_evictions_total{reason=per_origin|global|shutdown}
  • hoody_curl_pool_bypasses_total{reason=non_default_security|cancellation|promoted}
  • hoody_curl_pool_idle (gauge)

User-scoped HTTP response cache. Caching is opt-in: when the operator sets --cache-namespace ctn:<project>:<container> and --cache-mode readwrite, the relay serves GET/HEAD responses from a content-addressed on-disk cache (cacache crate) rooted at <storage>/cache/ns-<sha256(namespace)>/. The cache is conservative by design:

  • One namespace per process, set by the deployment orchestrator at startup and never derived from request headers, so there is no spoofing surface.
  • The cache only stores responses that are safe to replay independent of caller identity. Requests carrying Authorization, Cookie, a bearer token, session_id, range, or any other per-call credential or state are not cached: the cache key does not vary on those fields, so caching Bearer X’s response and serving it to Bearer Y would leak data. The pool still reuses connections for those requests; the two gates are separate.
  • Vary responses are skipped. Vary support is deferred; the current model assumes the upstream returns a single representation per URL.
  • Streaming and SSE responses are never cached.
  • Content-Encoding/Transfer-Encoding are stripped from the stored representation; Content-Length is recomputed against the decoded body length. Two size caps catch gzip bombs (compressed Content-Length ≤ cache_max_object_bytes, decoded ≤ cache_max_object_bytes_decoded).
  • http-cache-semantics = 3.0 powers RFC 9111 freshness (Age, Date, Expires, and private-cache semantics; s-maxage is ignored).
  • DNS-rebind defense: stored_pin_ip is the actual IP libcurl used at store time. On lookup it must be in the current resolve_and_pin set, otherwise the entry is bypassed; cached responses from a domain that has rotated to a new IP are not served.
  • Schema and generation versioning: bump --cache-generation to invalidate all entries on the next read.

Modes:

  • --cache-mode off (default): no reads, no writes.
  • --cache-mode readonly: lookups continue, new writes are disabled. Useful for a graceful drain before a deploy.
  • --cache-mode readwrite: full operation.

File modes are 0700 on the root and 0600 on each content file.

Metrics on /metrics:

  • hoody_curl_cache_enabled (gauge)
  • hoody_curl_cache_hit_total, hoody_curl_cache_miss_total
  • hoody_curl_cache_object_bytes_stored, hoody_curl_cache_object_count_stored
  • hoody_curl_cache_pin_mismatch_total - DNS-rebind defense activations
  • hoody_curl_cache_skip_request_total{reason=…} - every named skip reason (non_default_security, unsafe_request_header, url_userinfo, …)
  • hoody_curl_cache_skip_response_total{reason=…} - same on the response side (response_set_cookie, vary_present, event_stream, too_large_decoded, pin_mismatch, …)
  • hoody_curl_cache_bypass_total{reason=…} - bypass counters (e.g. cache_disabled, pin_mismatch)

Per-connection tunables (query string):

ParamDefaultHard capPurpose
binaryfalsenoneOpt into binary response/upload frames (see above)
max_concurrent_streams64128In-flight cURL transfers on this socket
max_queue1284096Streams waiting for an execution slot
max_frame_bytes1 MiB--max-request-body-bytes (default 16 MiB)Maximum inbound WebSocket frame
max_request_bytes--max-request-body-bytes(same)Maximum assembled request.start.request JSON size
chunk_bytes64 KiB1 MiBBytes per outbound response-body chunk
stream_timeout_secs3003600Time-to-first-byte cap. After SSE promotion fires, sse_max_duration_secs takes over.
idle_timeout_secs603600Idle-connection timeout
max_outbound_messages10248192Outbound queue backpressure threshold

Every request still flows through the same SSRF guard, header validation, and field-rejection rules as the REST /curl endpoint. The channel is a transport optimization, not a security escape hatch.

The web is moving from request/response to long-lived event streams: OpenAI, Anthropic, and most AI agents deliver their output over SSE. A relay that cannot speak SSE holds those calls back, so hoody-curl supports it end to end.

hoody-curl detects when an upstream responds with Content-Type: text/event-stream on a 2xx status and promotes the connection to streaming mode in flight, without a second HTTP call, a body replay, or waiting for the upstream to close. The first SSE frame the upstream emits reaches your client within milliseconds. Non-2xx responses (4xx/5xx) and non-SSE content types fall through to the standard buffered path.

SSE support spans three surfaces:

  • Sync /curl and the channel WS auto-promote upstream SSE responses end to end.
  • /sse is a server-emitted SSE stream for the job event bus. It does not proxy an upstream; it streams hoody-curl’s own jobstarted/jobprogress/jobcompleted events to EventSource clients.

Just curl the URL. The response is a chunked text/event-stream body, identical to what you’d get from the upstream directly:

Terminal window
curl -N "https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://stream.wikimedia.org/v2/stream/recentchange"
# event: message
# data: {"$schema":"/mediawiki/recentchange/1.0.0",...}
#
# event: message
# data: {...}

POST + SSE upstreams (the AI streaming case) work identically:

Terminal window
curl -N -X POST "https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.openai.com/v1/chat/completions",
"method": "POST",
"headers": {"Authorization": "Bearer sk-...", "Content-Type": "application/json"},
"json": {"model": "gpt-4o", "messages": [...], "stream": true}
}'

The upstream POST body is sent exactly once per attempt: promotion itself does not replay it, and SSE detection fires on the first response byte, so a buffered-mode prelude that gets promoted into streaming still sends the body once. One caveat: if you set retry_count > 0 and the underlying transfer fails before any byte arrives, retries do re-send the body, as with any HTTP client. For non-idempotent POSTs, keep retry_count: 0 and handle retries at the application layer.

One framing-level note: while the upstream is alive, the sync /curl SSE body is byte-identical to what the upstream sent. When the stream closes cleanly, the server appends one synthetic SSE frame, event: end\ndata: {"total_bytes":N}\n\n, so clients learn the total byte count without parsing every event. On a deadline or error, it appends event: error\ndata: {"error_type":"sse_max_duration",...}\n\n instead. SSE clients that subscribe to event: message only (the default) are unaffected; clients that listen on all events should know these tail frames exist.

When the channel detects SSE on a stream, you get typed response.sse_event frames instead of response.body frames: one per upstream event, with event, id, data, retry, and a per-stream seq number. The response.start frame carries is_sse: true so the client knows what to expect.

// server → client (SSE-promoted stream)
{"type":"response.start","stream_id":7,"is_sse":true,"status_code":200,...}
{"type":"response.sse_event","stream_id":7,"seq":0,"event":"message","data":"hello"}
{"type":"response.sse_event","stream_id":7,"seq":1,"event":"ping","data":"world","id":"42"}
// data_truncated: true is set when the upstream event exceeds
// --sse-parser-aggregate-bytes (default 1 MiB). `data` is truncated at a
// UTF-8 char boundary; the rest of the stream continues normally.
{"type":"response.sse_event","stream_id":7,"seq":2,"event":"message","data":"...","data_truncated":true}
{"type":"response.end","stream_id":7,"sse_events":3,...}

You can run dozens of concurrent SSE streams on one WebSocket. Each is cancellable via request.cancel, bounded by sse_max_duration_secs (default 30 min), and held against a per-process max_sse_concurrent semaphore so a single client can’t exhaust the host.

The same job lifecycle (jobstarted / jobprogress / jobcompleted) is available over both WebSocket and server-sent events. Pick the one your client speaks:

Terminal window
# WebSocket (binary, full-duplex)
websocat "wss://.../api/v1/curl/ws"
# {"type":"jobstarted","job_id":"...","name":"..."}
# {"type":"jobprogress","job_id":"...","progress":0.42}
# {"type":"jobcompleted","job_id":"...","status":"completed"}
# Server-Sent Events (text, EventSource-friendly, browser-native)
curl -N "https://.../api/v1/curl/sse"
# retry: 5000
#
# event: jobstarted
# data: {"job_id":"...","name":"..."}
# id: 0
#
# event: jobprogress
# data: {"job_id":"...","progress":0.42}
# id: 1

Both filter by ?job_id=<uuid>. Heartbeats keep reverse proxies happy: /sse emits a :\n\n comment every --sse-heartbeat-secs (default 15s) of silence; /ws sends WebSocket pings every 30s and drops the connection if no Pong arrives within 90s (half-open detection).

FlagDefaultPurpose
--no-sseenabledDisable the /sse route and upstream SSE auto-detection (compatibility escape hatch)
--sse-heartbeat-secs15Idle heartbeat interval
--sse-max-duration-secs1800Wall-clock cap on any single SSE stream
--sse-channel-capacity256Bounded mpsc between executor and handler
--sse-parser-aggregate-bytes1 MiBHard cap on one event’s accumulated bytes
--sse-parser-partial-bytes256 KiBHard cap on a single partial line
--max-sse-concurrent256Global cap on concurrent SSE streams

When the global SSE cap is exhausted, sync /curl and /sse return 503 Retry-After: 5, and channel WS emits error{error_type:"sse_capacity"} then response.end.

Channel WS surfaces errors as {"type":"error","error_type":"<kind>","message":"...","stream_id":N}. The error_type is a stable enum your SDK should switch on:

error_typeMeaningRetry strategy
validation_errorInvalid request (bad URL, rejected field, etc.)Don’t retry; fix the request.
cancelledStream cancelled by client or serverN/A (caller initiated).
timeoutstream_timeout_secs elapsed before completionRetry with longer timeout, or use SSE for long-lived streams.
queue_fullPer-connection max_queue exhaustedBack off + retry, or raise max_queue in query string.
sse_capacityGlobal max_sse_concurrent exhaustedBack off + retry (mirrors 503 + Retry-After).
sse_max_durationSSE stream exceeded sse_max_duration_secsReopen; consider chunking the upstream call.
execution_errorlibcurl-level error (DNS, TLS, upstream RST)Retry once with backoff; permanent if it repeats.
internal_errorSDK or server bugDon’t retry blindly; surface to operator.
protocol_errorChannel wire violation (duplicate stream_id, etc.)Bug in client.

The Last-Event-Id header on /sse is accepted but ignored: the broadcast bus has no replay buffer, so reconnecting clients miss events emitted during the gap. Operators who want durable replay should fan the bus out to an external durable queue.

We built @hoody/curl-channel-sdk so your existing fetch-based code runs over the channel with one line of setup:

import { createFetch } from "@hoody/curl-channel-sdk";
const fetch = createFetch({
url: "wss://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/channel",
});
// Now use it exactly like global fetch.
const res = await fetch("https://api.example.com/things", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hello: "world" }),
});
console.log(res.status, await res.json());

SSE upstreams transparently return a streaming Response.body; pipe it to EventSource-style code without changes:

const messages = [{ role: "user", content: "hello" }];
const res = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${KEY}` },
body: JSON.stringify({ model: "gpt-4o", messages, stream: true }),
});
if (!res.body) throw new Error("no body");
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
process.stdout.write(value);
}

For parsed events instead of raw bytes, drop to the low-level API:

import { Channel } from "@hoody/curl-channel-sdk";
const messages = [{ role: "user", content: "hello" }];
const channel = await Channel.open({ url: "wss://.../api/v1/curl/channel" });
const stream = channel.request({
url: "https://api.openai.com/v1/chat/completions",
method: "POST",
json: { model: "gpt-4o", messages, stream: true },
});
const start = await stream.start;
if (start.is_sse) {
for await (const ev of stream.events) {
console.log(ev.event, ev.data); // typed { event, id?, data, retry?, seq }
}
}

Standard AbortController cancels mid-flight via request.cancel on the wire; the SDK rejects stream.start / fetch() immediately on signal.abort() without waiting for the server’s cancelled ack. The package is modern ESM, runs in browsers and Node ≥18, and takes a peer-optional ws dependency for older Node.

The channel reconnects automatically on transport drop with exponential backoff. A request issued mid-reconnect waits transparently for the next live socket, so the caller does not need a retry loop.

const channel = await Channel.open({
url: "wss://.../api/v1/curl/channel",
reconnect: {
enabled: true, // default
initialBackoffMs: 500, // default
maxBackoffMs: 30_000, // default
jitter: 0.2, // ±20% randomization on each delay
maxAttempts: Infinity, // default; set a finite cap if you want to fail fast
},
});

To opt out, pass reconnect: { enabled: false }; the channel then rejects in-flight streams and stays closed on the first drop (matches the pre-v0.2 behavior).

Every channel state transition fires a typed hook. Throwing inside a hook never wedges the state machine: exceptions are caught and logged with console.warn, so callers can use hooks safely for logging, metrics, or auth refresh:

const channel = await Channel.open({
url: "wss://.../api/v1/curl/channel",
hooks: {
onOpen: (hello) => log.info("channel open", hello.connection_id),
onClose: ({ code, reason, willReconnect }) => metrics.inc("ws.close"),
onReconnecting: ({ attempt, backoffMs }) => log.warn(`reconnect #${attempt} in ${backoffMs}ms`),
onRequestStart: ({ streamId, url, method }) => metrics.inc("req.start"),
onResponseStart: ({ streamId, status, isSse }) => metrics.observe("status", status),
onResponseEnd: ({ streamId, totalBytes }) => metrics.observe("bytes", totalBytes),
onError: (err) => log.error("channel error", err),
},
});

Abort errors are real DOMException("...", "AbortError") instances when the runtime supports them (browser, Node ≥17.3), with a fallback to an AbortError-named class otherwise. Either way, err.name === "AbortError" works everywhere. Channel-level failures throw ChannelError carrying an errorType from the same vocabulary as the wire protocol (validation_error, cancelled, sse_capacity, …).

Sharp edges to know:

  • Binary bodies need the binary fast path. The SDK opens the channel with binary enabled by default, so non-UTF-8 Uint8Array / Blob / ArrayBuffer request bodies upload as raw binary frames and binary downloads skip base64. UTF-8 content still rides the text data field. If you explicitly pass binary: false (or talk to a server without features.binary_frames), a genuinely binary request body rejects with a ChannelError; base64-encode it client-side in that case.
  • SSE event queue is bounded. A slow consumer combined with a fast upstream is capped at 4 096 buffered events; when full, the SDK emits a synthetic { event: "dropped", … } event, sends request.cancel upstream, and the iterator terminates. Drain the iterator promptly or accept the dropped marker as a signal of lost data.
  • Handle sse_capacity on the channel. When the global --max-sse-concurrent semaphore is exhausted, channel SSE streams receive {"type":"error","error_type":"sse_capacity",...} followed by response.end; the sync /curl and /sse paths instead return 503 Retry-After: 5. Map both to a client-side retry with backoff.

Run recurring requests on a cron schedule:

A daily report at 9 AM on weekdays, and an hourly health check that retries:

Terminal window
# Daily report, 9 AM on weekdays (6-field cron: second minute hour day month weekday)
hoody curl schedules create \
--cron "0 0 9 * * MON-FRI" \
--request-url "https://api.example.com/daily-report" \
--request-method POST \
--request-json '{"send_to": "ops@example.com"}'
# Hourly health check with retries
hoody curl schedules create \
--cron "0 0 * * * *" \
--request-url "https://api.example.com/health" \
--request-retry-count 3

JSON mode returns a structured response with timing and metadata:

{
"success": true,
"status_code": 200,
"is_binary": false,
"headers": {"content-type": "application/json"},
"body": "{\"message\": \"Hello\"}",
"job_id": null,
"timing": {"namelookup": 0.012, "connect": 0.043, "pretransfer": 0.045, "redirect": 0.0, "starttransfer": 0.128, "total": 0.135},
"metadata": {"effective_url": "https://api.example.com", "content_type": "application/json", "size_download": 31}
}

Transparent mode returns the raw response body:

Terminal window
curl ".../api/v1/curl/request?url=https://api.example.com&response=transparent"
# Returns: Raw API response (JSON, HTML, etc.)

Execute API calls through Hoody’s network, test endpoints with different parameters, and share request URLs with team members.

Schedule recurring scrapes with cron, persist cookies for authenticated scraping, save responses directly to storage, and retry transient failures automatically.

Transform webhooks into GET URLs, share webhook endpoints, schedule webhook calls for testing, and persist webhook history in storage.

Chain multiple API calls via sessions, orchestrate multi-step workflows, persist state across distributed requests, and add retry logic for reliability.

Turn complex API calls into simple URLs, embed them in no-code tools that only support GET, share API access without exposing credentials, and make any API bookmarkable.

Schedule health checks for external services, retry failed requests automatically, save responses for historical analysis, and trigger notifications on status changes.

Use descriptive session IDs (user-123-session) and keep one session per user or context for isolation. Sessions persist indefinitely until deleted, so delete them when done to free memory.

Use mode: "sync" for quick requests (under 30 seconds) and mode: "async" for downloads or slow APIs. Poll job status before retrieving results, and clean up completed jobs periodically.

Test the cron expression before scheduling. Set retry_count for reliability, pause a schedule during debugging with PATCH /api/v1/curl/schedule/{id}/toggle and body {"enabled": false}, and monitor schedule execution via the jobs API.

Set save: true to persist responses under downloads/by-job/{job_id}/, and organize files with nested paths relative to that job directory (reports/2024/monthly.csv). Clean up old files to manage disk space. Storage suits audit trails and caching; access it via the storage API or the Files service.

Configure retry_count for transient failures, set appropriate timeout and connect_timeout values, check status_code in responses, and use sessions for authentication retries.

Q: How do I share a complex POST request? Use Hoody cURL to wrap it: the POST becomes a GET URL with all parameters encoded. Share this URL freely.

Q: Can I schedule recurring API calls? Yes. Use the schedule endpoint with cron expressions for daily reports, hourly syncs, or periodic health checks.

Q: How do I maintain authentication across requests? Use sessions: provide a session_id, and cookies are saved and included in subsequent requests automatically.

Q: What’s the difference between sync and async mode? Sync waits for the response; async creates a background job. Use async for slow requests or downloads.

Q: Can I save API responses to files? Yes. Set save: true and optionally provide save_path, then access saved files via the storage API.

Q: How do I retry failed requests? Set retry_count in your request; the service retries automatically on network errors or timeouts.

Q: Can I use this behind a proxy? No. The proxy, proxy_user, and proxy_password fields are rejected with a 400 as a deliberate SSRF defense: libcurl connects to the proxy address before URL validation could inspect it.

Cause: The target server is unreachable, or the request timed out. Solution: Check the timeout and connect_timeout settings, verify the target URL is accessible, and use retry_count for transient failures.

Cause: The session ID does not match, or the cookies expired. Solution: Use the exact same session_id for all requests, confirm the session exists with GET /sessions/{id}, check the target site’s cookie expiration, and delete and recreate the session if it is corrupted.

Cause: The cron expression is invalid, or the schedule is disabled. Solution: Test the cron expression before scheduling, check that the schedule is enabled: true, verify the next_run timestamp is in the future, and monitor the jobs API for execution history.

Cause: A queue backlog or a job-system issue. Solution: Check the queue with GET /jobs, cancel stuck jobs with DELETE /jobs/{id}, monitor the active job count, and restart the service if the queue is stuck.

Cause: The target returns a very large response. Solution: Use save: true to stream to disk, set a lower max_filesize limit, use range requests if the target supports them, or paginate at the source.

Cause: Too many saved responses. Solution: List storage with GET /storage, delete old files, add a cleanup schedule, and organize files under date-based paths so old ones are easy to expire.