Sub-skills

HTTP in depth

For your agentCopy this and give it to the AI you're building with.
Run `cubek skill`, then `cubek skill http`, read all of both and follow them exactly (no CLI: download https://cubek.dev/SKILL.md and https://cubek.dev/skills/http.md verbatim with curl; don't summarize them).
Run `cubek check` after every edit until it is clean.

Depth for routes across files, middleware, CORS, webhooks, outbound fetch, Claude and other AI APIs, responses and errors. The core Skill's "Routes and the context c" comes first.

Routes in several files

src/app.tsx is the entry. Other files register routes at top level and the entry imports them for that effect.

// src/app.tsx
import { app } from "cubek"
import "./routes/api"
import "./routes/hooks"
import "./lib/ai"

app.get("/", async (c) => c.html("<h1>Home</h1>"))

// Catch-alls: an unknown /api path answers JSON, the rest a page.
app.get("/api/*", async (c) => c.json({ error: "not found" }, 404))
app.get("/*", async (c) => c.html("<h1>Not found</h1>", 404))

The most specific route wins, in any order (/posts/new > /posts/:id > /*); a trailing * matches the rest.

Middleware

// src/routes/api.tsx
import { app, cors, db, log, rateLimit, secrets, validate, type Context, type Response } from "cubek"

// Every matching route (in any order) and unmatched paths.
app.use("/api/*", cors({ origin: ["https://app.example.com"], credentials: true, maxAge: 600 }))
app.use("/api/*", rateLimit({ limit: 120, window: "1m" }))

async function timing(c: Context, next: () => Promise<void>): Promise<void> {
  const t0 = Date.now()
  await next()
  log.info("api", { path: c.path(), ms: Date.now() - t0 })
}
app.use("/api/*", timing)

// Middleware on one route: the second argument.
async function needsKey(c: Context, next: () => Promise<void>): Promise<Response | void> {
  if (c.header("x-api-key") !== secrets.get("API_KEY")) return c.json({ error: "unauthorized" }, 401)
  await next()
}

const itemInput = validate.object({ name: validate.string({ min: 1, max: 100 }), qty: validate.number({ int: true, min: 0 }) })

app.post("/api/items", needsKey, async (c) => {
  const parsed = itemInput.safeParse(await c.body())
  if (!parsed.success) return c.json({ error: parsed.error }, 422)
  return c.json(await db.items.insert(parsed.data), 201)
})

app.get("/api/items", async (c) => {
  const limit = Math.min(Number(c.query("limit") ?? "50"), 100)
  return c.json({ items: await db.items.find({ orderBy: { name: "asc" }, limit }) })
})
// schema.ts
import { table, t } from "cubek"

export const items = table("items", { id: t.id(), name: t.string(), qty: t.number() }).index("name")
export const deliveries = table("deliveries", { id: t.id(), eventId: t.string().unique(), kind: t.string() })
  • app.use("/admin/*", ...) also runs for /admin/login: to leave a page open, keep it outside the prefix or guard route by route.
  • Files of public/ skip global middleware (app.use(mw), "*", "/*"); a narrower path gates them: app.use("/docs/*", auth) covers public/docs/.
  • A middleware returns a response to stop, or calls next(). There is no c.set: compute what a handler needs again (await currentUserId(c)).
  • cors() defaults: origin *, methods GET HEAD PUT POST DELETE PATCH, the requested headers, no credentials; it answers the OPTIONS preflight (204). With credentials: true list the origins (a list echoes the matching one).
  • rateLimit: X-RateLimit-* headers, 429 with retry-after, JSON under /api/*; fixed window from the first hit, counted per node (an app on several nodes allows limit on each). One global count: kv.incr(key, 1, { ttl: "1m" }) in a handler that also reads db (it then runs on the app's primary node, where kv is exact), and answer 429 over the limit.

Webhooks (signed bodies)

Verify the signature over the exact bytes, then parse; store the event id to ignore redeliveries.

// src/routes/hooks.tsx
import { app, crypto, db, encoding, secrets, validate } from "cubek"

const event = validate.object({ id: validate.string(), type: validate.string() })

app.post("/hooks/payments", async (c) => {
  const secret = secrets.get("PAYMENTS_WEBHOOK_SECRET")
  if (secret === null) return c.text("not configured", 500)
  const raw = await c.rawBody()
  const expected = crypto.hmac("sha256", secret, raw)
  if (!crypto.timingSafeEqual(expected, c.header("x-signature") ?? "")) return c.text("bad signature", 401)
  const parsed = event.safeParse(JSON.parse(encoding.utf8.decode(raw)))
  if (!parsed.success) return c.text(parsed.error, 400)
  if ((await db.deliveries.findOne({ where: { eventId: parsed.data.id } })) !== null) return c.text("duplicate", 200)
  await db.deliveries.insert({ eventId: parsed.data.id, kind: parsed.data.type })
  return c.text("ok")
})

Public-key signatures (Ed25519, ES256): crypto.verify(alg, publicKey, raw, signature) (push.md). Slow work after a webhook: task.run(...) and answer 200 at once (tasks.md).

Outbound fetch

// src/lib/weather.ts
import { fetch, secrets, validate } from "cubek"

const forecast = validate.object({ temp: validate.number(), summary: validate.string() })

export async function weather(city: string): Promise<{ temp: number; summary: string } | null> {
  try {
    const res = await fetch(`https://api.example.com/v1/weather?city=${encodeURIComponent(city)}`, {
      headers: { authorization: `Bearer ${secrets.get("WEATHER_KEY") ?? ""}` },
      timeout: "5s",
    })
    if (!res.ok) return null
    const parsed = forecast.safeParse(await res.json())
    return parsed.success ? parsed.data : null
  } catch (e) {
    return null  // timeout, DNS, refused
  }
}
  • timeout: "5s" (requests: 10 s at most; tasks: 60 s, up to "5m"); 10 MB responses, 20 fetches per request (200 per task). Public addresses only: private, loopback, metadata IPs are refused (other apps: their public URL; in cubek dev --workspace, their local URL).
  • An app may fetch its own public URL (a task posting to its own webhook): the app keeps serving while a fetch waits.
  • Slow or retried calls (LLMs, email APIs) belong in a task.

Claude (AI APIs)

No server npm (no Anthropic SDK): POST to the HTTP API from a task, which retries when it throws (429 rate limit, 529 overloaded, 5xx).

// src/lib/ai.ts
import { fetch, kv, log, secrets, task, validate } from "cubek"

const reply = validate.object({ content: validate.array(validate.object({ type: validate.string(), text: validate.string().optional() })) })

task.on("summarize", async (p: { id: string; text: string }) => {
  const res = await fetch("https://api.anthropic.com/v1/messages", {
    method: "POST",
    headers: { "x-api-key": secrets.get("ANTHROPIC_API_KEY") ?? "", "anthropic-version": "2023-06-01", "content-type": "application/json" },
    body: JSON.stringify({ model: "claude-opus-5", max_tokens: 2048, messages: [{ role: "user", content: `Summarize:\n\n${p.text}` }] }),
  })
  if (res.status === 429 || res.status >= 500) throw new Error(`claude: ${res.status}`)  // retried
  if (!res.ok) return log.error("claude", { status: res.status, body: await res.text() })  // 400/401: fix, no retry
  const text = reply.parse(await res.json()).content.map((b) => b.type === "text" ? b.text ?? "" : "").join("")
  await kv.set(`summary:${p.id}`, text)
})
  • task.run("summarize", { id, text }) from the route; the page reads the result later. No streaming needed in a task.
  • Bulk or fast work: "claude-haiku-4-5". Long generations: fetch(url, { timeout }) in the task.
  • Build URLs with URL/URLSearchParams or encodeURIComponent.

Forms

  • Form fields arrive as strings: validate.number({ coerce: true }) ("5" → 5, "" = absent), validate.boolean({ coerce: true }) (checkbox "on" or absent).
  • tags[] fields → string[]; another repeated name (<select multiple>, checkboxes): c.body() keeps the last; all: await c.formAll("ids").
  • Rows of inputs: name them productId[]/qty[] per row, then const b = await c.body<{ productId: string[]; qty: string[] }>() and b.productId.map((id, i) => ({ id, qty: Number(b.qty[i]) })).
  • Dynamic names (score_<id>): const b = await c.body<Record<string, string>>(), then Object.entries(b), or check it with validate.record(validate.string()).
  • Formats: validate.string({ pattern: /^[a-z0-9-]+$/ }) (searched: anchor it), { email: true }, { url: true }.

Responses and errors

  • c.json(data, 201), c.text(s, 200, { "cache-control": "max-age=60" }), c.body(bytes, 200, { "content-type": "image/png" }), c.redirect(url, 303).
  • Headers replace the content-type: a CSV download is c.text(csv.stringify(rows, { header: true }), 200, { "content-type": "text/csv" }).
  • A rendered page as a string (a cached copy, an email body): await (await c.render(<Page />)).text().
  • A thrown error is a bare 500 (see cubek logs); catch expected failures and answer 4xx with a message.
  • Limits: 10 MB request body, 32 MB response, 10 s per request.