# cURL

**Page:** kit/curl

[Download Raw Markdown](./kit/curl.md)

---

**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

- 🔄 **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-stream` and 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 streaming `Response.body`
- 🔧 **Battle-Tested** - Built on libcurl (Rust) for reliability

## API Endpoints Summary

All endpoints accessed relative to your cURL service URL:
```
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.icu
```

**Request Execution**:
- [`GET /api/v1/curl/request`](/api/curl/execution/) - Simple HTTP requests via GET
- [`POST /api/v1/curl/request`](/api/curl/execution/) - Advanced requests with full options

**Sessions**:
- [`GET /api/v1/curl/sessions`](/api/curl/sessions/) - List all cookie sessions
- [`GET /api/v1/curl/sessions/{id}`](/api/curl/sessions/) - Get session details
- [`GET /api/v1/curl/sessions/{id}/cookies`](/api/curl/sessions/) - Get session cookies
- [`DELETE /api/v1/curl/sessions/{id}`](/api/curl/sessions/) - Delete session

**Jobs**:
- [`GET /api/v1/curl/jobs`](/api/curl/jobs/) - List async jobs
- [`GET /api/v1/curl/jobs/{id}`](/api/curl/jobs/) - Get job details
- [`GET /api/v1/curl/jobs/{id}/result`](/api/curl/jobs/) - Get job response
- [`DELETE /api/v1/curl/jobs/{id}`](/api/curl/jobs/) - Cancel job

**Scheduling**:
- [`POST /api/v1/curl/schedule`](/api/curl/scheduling/) - Create scheduled request
- [`GET /api/v1/curl/schedule`](/api/curl/scheduling/) - List schedules
- [`GET /api/v1/curl/schedule/{id}`](/api/curl/scheduling/) - Get schedule details
- [`PATCH /api/v1/curl/schedule/{id}/toggle`](/api/curl/scheduling/) - Enable/disable schedule
- [`DELETE /api/v1/curl/schedule/{id}`](/api/curl/scheduling/) - Remove schedule

**Storage**:
- [`GET /api/v1/curl/storage`](/api/curl/storage/) - List saved files
- [`GET /api/v1/curl/storage/{path}`](/api/curl/storage/) - Download saved file
- [`DELETE /api/v1/curl/storage/{path}`](/api/curl/storage/) - Delete saved file

**Realtime — WebSocket + Server-Sent Events**:
- [`GET /api/v1/curl/channel`](#websocket-multiplexed-request-channel) - Multiplexed request channel (one WebSocket, many concurrent cURLs)
- [`GET /api/v1/curl/ws`](#job-event-streams) - WebSocket job lifecycle events (alias `/ws`)
- [`GET /api/v1/curl/sse`](#job-event-streams) - Server-Sent Events job lifecycle stream (alias `/sse`)

## The POST→GET Revolution

**THE Killer Feature**: Wrap any complex POST request into a simple, shareable GET URL.

**Traditional Approach** (NOT shareable):
```bash
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 cron
```

**Why This Matters**: POST operations become first-class URLs, unlocking workflows impossible in traditional HTTP.

## 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

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


  
    ```bash
    # Simple GET request
    hoody curl exec \
      --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
    ```
  
  
    ```typescript
    import { 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 request
    const data = await containerClient.curl.execute({
      url: 'https://api.example.com/data',
      follow_redirects: true,
      response: 'json',
    });

    // Advanced POST request
    const 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' },
    });
    ```
  
  
    ```bash
    # Simple GET request
    curl "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 request
    curl -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

Persist authentication across multiple requests:

**Login - cookies saved automatically:**



**Access protected endpoint - cookies included automatically:**



**Logout - clear session:**



## 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

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

```bash
# 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):

```jsonc
// 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`

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.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 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_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** (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
  caching `Bearer X`'s response and serving it to `Bearer Y` would
  leak data). The POOL still reuses connections for those requests —
  the gates are separate.
- `Vary` responses 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-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, 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 + 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 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_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):

| 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)

**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 `/curl`** and **Channel WS** auto-promote upstream SSE responses end-to-end.
- **`/sse`** is a server-emitted SSE stream for the job event bus (it doesn't proxy an upstream — it streams hoody-curl's own `jobstarted`/`jobprogress`/`jobcompleted` events to EventSource clients).

### 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:

```bash
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:

```bash
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

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.

```jsonc
// 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`

The same job lifecycle (`jobstarted` / `jobprogress` / `jobcompleted`) is available over **both** WebSocket and Server-Sent Events. Pick the one your client speaks:

```bash
# 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: 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).

### 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

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

We built [@hoody/curl-channel-sdk](https://github.com/Hoody-Network/hoody-kit/tree/main/hoody-curl/sdk/typescript) so your existing fetch-based code runs over the channel with one line of setup:

```ts


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:

```ts
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:

```ts


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

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.

```ts
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

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:

```ts
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

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`, …](#channel-error_type-vocabulary)).

**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-after-backoff.

## Scheduled Requests

Cron-based recurring requests:

**Daily report at 9 AM weekdays:**



**Hourly health check:**



## Response Modes

**JSON Mode** - Structured response with metadata:
```json
{
  "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:
```bash
curl ".../api/v1/curl/request?url=https://api.example.com&response=transparent"
# Returns: Raw API response (JSON, HTML, etc.)
```

## Use Cases

### 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
Schedule recurring scrapes with cron, persist cookies for authenticated scraping, save responses directly to storage, retry on transient failures automatically.

### Webhook Receivers
Transform webhooks into GET URLs, share webhook endpoints easily, schedule webhook calls for testing, persist webhook history in storage.

### 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
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
Schedule health checks for external services, retry failed requests automatically, save responses for historical analysis, trigger notifications on status changes.

## Best Practices

### 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
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
Test cron expression before scheduling, set `retry_count` for reliability, use `enabled: false` during debugging, monitor schedule execution via jobs API.

### 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
Configure `retry_count` for transient failures, set appropriate `timeout` and `connect_timeout`, check `status_code` in responses, use sessions for authentication retries.

## 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

### 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
**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
**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
**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
**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
**Cause**: Too many saved responses.
**Solution**: List storage with `GET /storage`, delete old files, implement cleanup schedule, use expiring paths (date-based).

## What's Next