HTTP requests as HTTP endpoints - Execute any HTTP request via simple GET calls, wrap complex POST operations into shareable URLs, schedule recurring requests with cron, and persist cookie sessions automatically. Powered by libcurl (Rust bindings) - the battle-tested, proven HTTP library trusted by millions of applications.
What You Can Do
Section titled “What You Can Do”- 🔄 POST→GET Wrapping - Turn any POST request into a shareable GET URL
- 🌐 WebSocket Multiplexed Channel - Pay TCP/TLS once, run hundreds of concurrent cURLs over one socket
- ⚡ Server-Sent Events (SSE) - Auto-detect upstream
text/event-streamand stream events as they arrive (OpenAI / Anthropic / your AI agent — all just work) - 📅 Scheduled Requests - Set cron schedules for recurring HTTP calls
- 🍪 Session Management - Auto-persist cookies across multiple requests
- ⚙️ Async Execution - Queue long-running requests and retrieve results later
- 💾 Response Storage - Save to
/hoody/storage/curl/downloads/directory - 🎯 Advanced Options - Full cURL power: proxies, auth, retries, timeouts
- 📦 TypeScript SDK - Drop-in
fetch()over the WebSocket channel; SSE responses arrive as streamingResponse.body - 🔧 Battle-Tested - Built on libcurl (Rust) for reliability
API Endpoints Summary
Section titled “API Endpoints Summary”All endpoints accessed relative to your cURL service URL:
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.icuRequest Execution:
GET /api/v1/curl/request- Simple HTTP requests via GETPOST /api/v1/curl/request- Advanced requests with full options
Sessions:
GET /api/v1/curl/sessions- List all cookie sessionsGET /api/v1/curl/sessions/{id}- Get session detailsGET /api/v1/curl/sessions/{id}/cookies- Get session cookiesDELETE /api/v1/curl/sessions/{id}- Delete session
Jobs:
GET /api/v1/curl/jobs- List async jobsGET /api/v1/curl/jobs/{id}- Get job detailsGET /api/v1/curl/jobs/{id}/result- Get job responseDELETE /api/v1/curl/jobs/{id}- Cancel job
Scheduling:
POST /api/v1/curl/schedule- Create scheduled requestGET /api/v1/curl/schedule- List schedulesGET /api/v1/curl/schedule/{id}- Get schedule detailsPATCH /api/v1/curl/schedule/{id}/toggle- Enable/disable scheduleDELETE /api/v1/curl/schedule/{id}- Remove schedule
Storage:
GET /api/v1/curl/storage- List saved filesGET /api/v1/curl/storage/{path}- Download saved fileDELETE /api/v1/curl/storage/{path}- Delete saved file
Realtime — WebSocket + Server-Sent Events:
GET /api/v1/curl/channel- Multiplexed request channel (one WebSocket, many concurrent cURLs)GET /api/v1/curl/ws- WebSocket job lifecycle events (alias/ws)GET /api/v1/curl/sse- Server-Sent Events job lifecycle stream (alias/sse)
The POST→GET Revolution
Section titled “The POST→GET Revolution”THE Killer Feature: Wrap any complex POST request into a simple, shareable GET URL.
Traditional Approach (NOT shareable):
curl -X POST "https://api.example.com/search" \ -H "Authorization: Bearer token123" \ -H "Content-Type: application/json" \ -d '{"query": "user data", "filters": {...}}'Hoody Approach (Shareable URL):
GET https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.icu/api/v1/curl/request?\url=https://api.example.com/search&\method=POST&\headers={"Authorization":"Bearer token123"}&\json={"query":"user data","filters":{...}}
# Now this complex POST is a simple GET URL you can:# - Share via email/chat# - Embed in documentation# - Bookmark in browser# - Use in no-code tools# - Schedule with cronWhy This Matters: POST operations become first-class URLs, unlocking workflows impossible in traditional HTTP.
Magic Links — Everything is a URL
Section titled “Magic Links — Everything is a URL”The POST→GET wrapping above is just the start. When you combine hoody-curl with other Hoody services, any workflow — no matter how complex — becomes a single clickable URL. We call these magic links.
One-click deploy — pull code, install deps, restart service:
GET .../api/v1/curl/request?url=https://CONTAINER-terminal-1.../api/v1/terminal/execute&method=POST&json={"command":"cd /app && git pull && npm install && npm run build","wait":true}AI-powered summary — send a webpage to Hoody AI, get a summary back:
GET .../api/v1/curl/request?url=https://ai.hoody.icu/api/v1/chat/completions&method=POST&headers={"Authorization":"Bearer container-1"}&json={"model":"claude-sonnet-4-20250514","messages":[{"role":"user","content":"Summarize: https://example.com/article"}]}More magic link ideas:
| Magic Link | What it does |
|---|---|
| One-click demo | Restore a snapshot → start the app → return the live URL |
| AI code review | Fetch a file from the container → send to Hoody AI → return review |
| Database export | Query SQLite → format as CSV → save to Files → return download link |
| Health check + auto-heal | Check daemon status → if FATAL, restart → return status report |
| Scheduled AI digest | Cron-fetch RSS feeds → send to AI for summarization → save report |
| Webhook relay | GitHub push → pull code in container → rebuild → restart daemon |
| Container factory | Create a new container → configure it → return its service URLs |
| One-click backup | Snapshot container + export SQLite + zip project → return download |
| AI translation | Fetch a doc → send to Hoody AI with target language → return translated version |
| Status dashboard | Query multiple daemons + services → combine into a single JSON health report |
The pattern: Any chain of Hoody API calls can be encoded into a single GET URL. Share it in chat, bookmark it, embed it in a no-code tool, or schedule it with cron. No client-side code needed — just click.
GET Wrapping for AI: The Universal Trigger
Section titled “GET Wrapping for AI: The Universal Trigger”This is hoody-curl’s killer use case for AI. Any AI that can web-fetch — ChatGPT, Claude, Claude Code, Cline, any agent — can trigger a full Hoody workflow through a single GET URL. No SDK. No auth ceremony. 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.../execute&method=POST&json={"command":"cd /app && git pull && bun run deploy","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 complexity is invisible. The URL is the interface.
Simple vs Advanced Requests
Section titled “Simple vs Advanced Requests”# Simple GET requesthoody curl exec \ --url "https://api.example.com/data" \ --follow-redirects --response json
# Advanced POST requesthoody 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 POSTimport { HoodyClient } from '@hoody-ai/hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.icu', token: process.env.HOODY_TOKEN });const containerClient = await client.withContainer({ id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER });
// Simple GET requestconst data = await containerClient.curl.execute({ url: 'https://api.example.com/data', follow_redirects: true, response: 'json',});
// Advanced POST requestconst result = await containerClient.curl.execute({ url: 'https://api.example.com/users', method: 'POST', timeout: 30,});
// Create scheduled request (6-field cron: second minute hour day month weekday)const schedule = await containerClient.curl.schedules.create({ cron: '0 0 9 * * MON-FRI', request: { url: 'https://api.example.com/daily-report', method: 'POST' },});# Simple GET requestcurl "https://PROJECT-CONTAINER-curl-1.SERVER.containers.hoody.icu/api/v1/curl/request?url=https://api.example.com/data&follow_redirects=true&response=json"
# Advanced POST requestcurl -X POST "https://PROJECT-CONTAINER-curl-1.SERVER.containers.hoody.icu/api/v1/curl/request" \ -H "Content-Type: application/json" \ -d '{ "url": "https://api.example.com/users", "method": "POST", "timeout": 30 }'Simple GET (query parameters):
Advanced POST (request body):
Cookie Sessions
Section titled “Cookie Sessions”Persist authentication across multiple requests:
Login - cookies saved automatically:
Access protected endpoint - cookies included automatically:
Logout - clear session:
Async Jobs
Section titled “Async Jobs”Queue long-running requests:
Submit async job - saves to /hoody/storage/curl/downloads/:
Returns: {"job_id": "550e8400-..."}. File saved to: /hoody/storage/curl/downloads/large-file.zip
Check status:
Get result when complete:
WebSocket Multiplexed Request Channel
Section titled “WebSocket Multiplexed Request Channel”The handshake tax is dead. Every HTTP call you make over the public Internet pays for TCP, TLS, HTTP. Negotiate. Negotiate. Negotiate. N requests, N round-trips of pure ceremony before a single byte of real data crosses the wire.
We refuse.
/api/v1/curl/channel is a single persistent WebSocket. You connect once. Then you multiplex hundreds of concurrent cURL requests over that one socket, each with its own stream_id, each cancellable, each returning headers + body + timing — exactly like the REST endpoint, but without paying the TCP/TLS tax per request.
# Open the channel. The server sends a `hello` frame with limits + features.websocat "wss://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.icu/api/v1/curl/channel"Wire protocol (JSON text frames; opt into binary frames with ?binary=true — see 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}Binary frame fast path — ?binary=true
Section titled “Binary frame fast path — ?binary=true”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 — no base64,
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.endstay JSON. - Binary request uploads work: send
request.startwith"binary_body": true(and nodata), then a binaryREQUEST_BODYframe carrying the bytes. The server holds execution until the body frame lands.
Omitting ?binary=true keeps the exact legacy text/base64 protocol — old
clients are unaffected. The TypeScript SDK negotiates binary automatically.
Measured: 2–3.6× faster large downloads, 2.3× faster binary uploads
versus 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 the relay CPU of decompressing on the hot path AND
the wire bytes of re-transmitting the decompressed body. Measured: 1.6×
throughput on gzip-encoded 1 MiB downloads, plus the upstream’s ~3–4×
gzip ratio in wire bytes. Edge: a 2xx text/event-stream upstream that
advertises Content-Encoding: gzip falls back to buffered (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
no longer waits 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. This collapses time-to-first-byte to a single
upstream round-trip + one WS frame regardless of body size. The SSE
semaphore is NOT consumed (channel max_concurrent is the relevant cap);
non-2xx responses still buffer so callers see the full error body and
status. Pairs naturally with compression pass-through: the upstream’s
compressed bytes stream straight into the SDK’s DecompressionStream.
Tiny bodies (Content-Length ≤ 16 KiB) stay buffered — for them the
streaming-setup overhead beats the TTFB win. Measured: 5.7× faster
1 MiB downloads, 3.4× faster 8 MiB downloads versus 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 + DNS) hot across requests, so the
second call to the same host skips the full handshake. Bench-irrelevant
on a local mock upstream; in production this 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. Per-origin cap
is 8; global cap is 64; 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 + creds, cert/key/cacert/cert_type. A request with
insecure=true gets a different pool slot from a plain request — they
can’t share a connection because the TLS handshake differs.
Per-request payload — Authorization headers, cookies, bearer
tokens, session_id, range, etc. — 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, etc.) 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_totalhoody_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 (phase 5, 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; never derived from request headers (no spoofing surface).
- Cache only caches 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 / state are NOT cached (the cache key doesn’t vary on those, so cachingBearer X’s response and serving it toBearer Ywould leak data). The POOL still reuses connections for those requests — the gates are separate. Varyresponses are skipped (Vary support is deferred to a future slice; the current model assumes the upstream returns a single representation per URL).- Streaming/SSE responses are NEVER cached.
Content-Encoding/Transfer-Encodingare stripped from the stored representation;Content-Lengthis 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.0powers RFC 9111 freshness (Age/Date/ Expires, private-cache semantics —s-maxageis ignored).- DNS-rebind defense:
stored_pin_ipis the actual IP libcurl used at store time; on lookup it must be in the CURRENTresolve_and_pinset, otherwise the entry is bypassed (cached responses from a domain that has rotated to a new IP are not served). - Schema + generation versioning: bump
--cache-generationto invalidate all entries on the next read.
Modes:
--cache-mode off(default) — no reads, no writes.--cache-mode readonly— lookups continue; new writes disabled. Useful for 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_totalhoody_curl_cache_object_bytes_stored,hoody_curl_cache_object_count_storedhoody_curl_cache_pin_mismatch_total— DNS-rebind defense activationshoody_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):
| Param | Default | Hard cap | Purpose |
|---|---|---|---|
binary | false | — | Opt into binary response/upload frames (see above) |
max_concurrent_streams | 64 | 128 | In-flight cURL transfers on this socket |
max_queue | 128 | 4096 | Streams waiting for an execution slot |
max_frame_bytes | 1 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_bytes | 64 KiB | 1 MiB | Bytes per outbound response-body chunk |
stream_timeout_secs | 300 | 3600 | Time-to-first-byte cap. After SSE promotion fires, sse_max_duration_secs takes over. |
idle_timeout_secs | 60 | 3600 | Idle-connection timeout |
max_outbound_messages | 1024 | 8192 | Outbound 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 not a security escape hatch — it’s a transport optimization.
Server-Sent Events (SSE)
Section titled “Server-Sent Events (SSE)”Modern endpoints stream. OpenAI streams. Anthropic streams. Your AI agent streams. The web is moving from request/response to long-lived event streams — and the proxy that can’t speak SSE is the proxy your AI calls are stuck on.
hoody-curl auto-detects when an upstream responds with Content-Type: text/event-stream on a 2xx status and promotes the connection to streaming mode in-flight — no second HTTP call, no body replay, no 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.
This works on three surfaces:
- Sync
/curland Channel WS auto-promote upstream SSE responses end-to-end. /sseis a server-emitted SSE stream for the job event bus (it doesn’t proxy an upstream — it streams hoody-curl’s ownjobstarted/jobprogress/jobcompletedevents to EventSource clients).
Sync /curl — Transparent SSE Passthrough
Section titled “Sync /curl — Transparent SSE Passthrough”Just curl the URL. The response is a chunked text/event-stream body, identical to what you’d get from the upstream directly:
curl -N "https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.icu/curl?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:
curl -N -X POST "https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.icu/curl" \ -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 doesn’t replay it, and SSE detection fires on the first response byte, so a buffered-mode prelude that gets promoted into streaming sends the body once. (Caveat: if you set retry_count > 0 and the underlying transfer fails before any byte arrives, retries do re-send the body — same as 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 deadline / 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.
Channel WS — Typed SSE Events, Multiplexed
Section titled “Channel WS — Typed SSE Events, Multiplexed”When the channel detects SSE on a stream, instead of response.body frames you get typed response.sse_event 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 cancellable via request.cancel, each bounded by sse_max_duration_secs (default 30 min), each held against a per-process max_sse_concurrent semaphore so a single client can’t exhaust the host.
Job Event Streams — /ws and /sse
Section titled “Job Event Streams — /ws and /sse”The same job lifecycle (jobstarted / jobprogress / jobcompleted) is available over both WebSocket and Server-Sent Events. Pick the one your client speaks:
# WebSocket (binary, full-duplex)websocat "wss://.../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://.../sse"# retry: 5000## event: jobstarted# data: {"job_id":"...","name":"..."}# id: 0## event: jobprogress# data: {"job_id":"...","progress":0.42}# id: 1Both 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).
SSE Configuration Flags
Section titled “SSE Configuration Flags”| Flag | Default | Purpose |
|---|---|---|
--no-sse | enabled | Disable /sse route AND upstream SSE auto-detection (compat escape hatch) |
--sse-heartbeat-secs | 15 | Idle heartbeat interval |
--sse-max-duration-secs | 1800 | Wall-clock cap on any single SSE stream |
--sse-channel-capacity | 256 | Bounded mpsc between executor and handler |
--sse-parser-aggregate-bytes | 1 MiB | Hard cap on one event’s accumulated bytes |
--sse-parser-partial-bytes | 256 KiB | Hard cap on a single partial line |
--max-sse-concurrent | 256 | Global cap on concurrent SSE streams |
When the global SSE cap is exhausted: sync /curl and /sse return 503 Retry-After: 5; channel WS emits error{error_type:"sse_capacity"} then response.end.
Channel error_type Vocabulary
Section titled “Channel error_type Vocabulary”Channel WS surface errors as {"type":"error","error_type":"<kind>","message":"...","stream_id":N} — the error_type is a stable enum your SDK should switch on:
error_type | Meaning | Retry strategy |
|---|---|---|
validation_error | Invalid request (bad URL, rejected field, etc.) | Don’t retry; fix the request. |
cancelled | Stream cancelled by client or server | N/A — caller initiated. |
timeout | stream_timeout_secs elapsed before completion | Retry with longer timeout, or use SSE for long-lived streams. |
queue_full | Per-connection max_queue exhausted | Back off + retry, or raise max_queue in query string. |
sse_capacity | Global max_sse_concurrent exhausted | Back off + retry (mirrors 503 + Retry-After). |
sse_max_duration | SSE stream exceeded sse_max_duration_secs | Reopen; consider chunking the upstream call. |
execution_error | libcurl-level error (DNS, TLS, upstream RST) | Retry once with backoff; permanent if it repeats. |
internal_error | SDK or server bug | Don’t retry blindly; surface to operator. |
protocol_error | Channel 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 wanting durable replay should fan the bus out to an external durable queue.
TypeScript SDK — fetch() over WebSocket
Section titled “TypeScript SDK — fetch() over WebSocket”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.icu/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);}Need 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. Modern ESM, browser + Node ≥18, peer-optional ws dependency for older Node.
Auto-reconnect
Section titled “Auto-reconnect”The channel reconnects automatically on transport drop with exponential backoff. A request issued mid-reconnect waits transparently for the next live socket — no caller-side retry loop required.
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).
Observability hooks
Section titled “Observability hooks”Every channel state transition fires a typed hook. Throwing inside a hook never wedges the state machine — exceptions are caught and console.warn’d, so callers can use them 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, request }) => metrics.inc("req.start"), onResponseStart: ({ streamId, status, isSse }) => metrics.observe("status", status), onResponseEnd: ({ streamId, timing }) => metrics.observe("rtt", timing.total * 1000), onError: (err) => log.error("channel error", err), },});Error handling
Section titled “Error handling”Abort errors are real DOMException("...", "AbortError") instances when the runtime supports them (browser, Node ≥17.3) — falling back 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
binaryenabled by default, so non-UTF-8Uint8Array/Blob/ArrayBufferrequest bodies upload as raw binary frames and binary downloads skip base64. UTF-8 content still rides the textdatafield. If you explicitly passbinary: false(or talk to a server withoutfeatures.binary_frames), a genuinely binary request body rejects with aChannelError— 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, sendsrequest.cancelupstream, and the iterator terminates. Drain the iterator promptly or accept the dropped marker as a signal of lost data. - Handle
sse_capacityon the channel. When the global--max-sse-concurrentsemaphore is exhausted, channel SSE streams receive{"type":"error","error_type":"sse_capacity",...}followed byresponse.end— the sync/curland/ssepaths instead return503 Retry-After: 5. Map both to a client-side retry-after-backoff.
Scheduled Requests
Section titled “Scheduled Requests”Cron-based recurring requests:
Daily report at 9 AM weekdays:
Hourly health check:
Response Modes
Section titled “Response Modes”JSON Mode - Structured response with metadata:
{ "statusCode": 200, "message": "OK", "data": { "success": true, "status_code": 200, "is_binary": false, "headers": {"Content-Type": "application/json"}, "body": "{\"message\": \"Hello\"}", "metadata": {"total_time": 0.531, "connect_time": 0.125} }}Transparent Mode - Raw response body:
curl ".../api/v1/curl/request?url=https://api.example.com&response=transparent"# Returns: Raw API response (JSON, HTML, etc.)Use Cases
Section titled “Use Cases”API Testing & Debugging
Section titled “API Testing & Debugging”Execute API calls through Hoody’s network, test endpoints with different parameters, share request URLs with team members for collaboration.
Web Scraping
Section titled “Web Scraping”Schedule recurring scrapes with cron, persist cookies for authenticated scraping, save responses directly to storage, retry on transient failures automatically.
Webhook Receivers
Section titled “Webhook Receivers”Transform webhooks into GET URLs, share webhook endpoints easily, schedule webhook calls for testing, persist webhook history in storage.
API Aggregation
Section titled “API Aggregation”Chain multiple API calls via sessions, orchestrate complex multi-step workflows, persist state across distributed requests, implement retry logic for reliability.
No-Code Integration
Section titled “No-Code Integration”Turn complex API calls into simple URLs, embed in no-code tools that only support GET, share API access without exposing credentials, make any API bookmark-able.
Monitoring & Alerts
Section titled “Monitoring & Alerts”Schedule health checks for external services, retry failed requests automatically, save responses for historical analysis, trigger notifications on status changes.
Best Practices
Section titled “Best Practices”Session Management
Section titled “Session Management”Use descriptive session IDs (user-123-session), delete sessions when done to free memory, sessions persist indefinitely until deleted, one session per user/context for isolation.
Async vs Sync
Section titled “Async vs Sync”Use mode: "sync" for quick requests (under 30 seconds), use mode: "async" for downloads or slow APIs, poll job status before retrieving results, clean up completed jobs periodically.
Scheduled Requests
Section titled “Scheduled Requests”Test cron expression before scheduling, set retry_count for reliability, use enabled: false during debugging, monitor schedule execution via jobs API.
Response Storage
Section titled “Response Storage”Set save: true to persist responses to /hoody/storage/curl/downloads/, organize with nested paths (reports/2024/monthly.csv), clean up old files to manage disk space, use storage for audit trails or caching, access via Storage API or Files service.
Error Handling
Section titled “Error Handling”Configure retry_count for transient failures, set appropriate timeout and connect_timeout, check status_code in responses, use sessions for authentication retries.
Useful Questions
Section titled “Useful Questions”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. Perfect 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 automatically saved and included in subsequent requests.
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. Access saved files via the storage API.
Q: How do I retry failed requests?
Set retry_count in your request. The service will automatically retry on network errors or timeouts.
Q: Can I use this behind a proxy?
Yes - set the proxy parameter with your proxy URL. Supports HTTP and SOCKS proxies.
Troubleshooting
Section titled “Troubleshooting”Request Fails with Network Error
Section titled “Request Fails with Network Error”Cause: Target server unreachable or timeout.
Solution: Check timeout and connect_timeout settings, verify target URL is accessible, use retry_count for transient failures, check proxy configuration if using one.
Session Cookies Not Persisting
Section titled “Session Cookies Not Persisting”Cause: Session ID not matching or cookies expired.
Solution: Use exact same session_id for all requests, check session exists with GET /sessions/{id}, verify target site’s cookie expiration, delete and recreate session if corrupted.
Scheduled Request Not Running
Section titled “Scheduled Request Not Running”Cause: Invalid cron expression or schedule disabled.
Solution: Test cron expression before scheduling, check schedule is enabled: true, verify next_run timestamp is in future, monitor jobs API for execution history.
Async Job Stays Pending
Section titled “Async Job Stays Pending”Cause: Queue backlog or job system issue.
Solution: Check queue with GET /jobs, cancel stuck jobs with DELETE /jobs/{id}, monitor active job count, restart service if queue is stuck.
Response Too Large
Section titled “Response Too Large”Cause: Target returns massive response.
Solution: Use save: true to stream to disk, set lower max_filesize limit, use range requests if supported, implement pagination at source.
Storage Full
Section titled “Storage Full”Cause: Too many saved responses.
Solution: List storage with GET /storage, delete old files, implement cleanup schedule, use expiring paths (date-based).