Sub-skills
Tasks in depth
For your agentCopy this and give it to the AI you're building with.
Run `cubek skill`, then `cubek skill tasks`, read all of both and follow them exactly (no CLI: download https://cubek.dev/SKILL.md and https://cubek.dev/skills/tasks.md verbatim with curl; don't summarize them). Run `cubek check` after every edit until it is clean.
Background work: queued tasks, delays, retries, cron, and running them by
hand. One concept: task.on registers a handler, task.run queues it,
task.schedule runs it on a cron.
// schema.ts
import { table, t } from "cubek"
export const orders = table("orders", {
id: t.id(),
email: t.string(),
status: t.enum(["held", "paid", "expired"]).default("held"),
receiptSentAt: t.timestamp().nullable(),
createdAt: t.timestamp().default("now"),
}).index("status", "createdAt")// src/tasks.ts
import { db, fetch, log, secrets, task } from "cubek"
// Payload: plain data. A task may run more than once (a retry, a node
// failover): make it idempotent — check, act, record.
task.on("send-receipt", async (p: { orderId: string }) => {
const order = await db.orders.get(p.orderId)
if (order === null || order.receiptSentAt !== null) return
// No email primitive: the provider's HTTP API with a secret.
const res = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: { authorization: `Bearer ${secrets.get("EMAIL_API_KEY")}`, "content-type": "application/json" },
body: JSON.stringify({ from: "Shop <shop@example.com>", to: [order.email], subject: "Your receipt", text: `Order ${order.id} is paid.` }),
})
if (!res.ok) throw new Error(`email provider: ${res.status}`) // retried
await db.orders.update(order.id, { receiptSentAt: new Date().toISOString() })
})
// A hold that expires: queued with a delay when the hold is created.
task.on("expire-hold", async (p: { orderId: string }) => {
await db.orders.updateWhere({ id: p.orderId, status: "held" }, { status: "expired" })
})
// Cron (UTC, five fields): every day at 03:00.
task.on("cleanup", async () => {
const cutoff = new Date(Date.now() - 30 * 86400000).toISOString()
const n = await db.orders.deleteWhere({ status: "expired", createdAt: { lt: cutoff } })
log.info("cleanup", { deleted: n })
})
task.schedule("cleanup", "0 3 * * *")// src/app.tsx
import { app, db, task } from "cubek"
import "./tasks"
app.post("/orders", async (c) => {
const order = await db.orders.insert({ email: "buyer@example.com" })
await task.run("expire-hold", { orderId: order.id }, { delay: "15m" })
return c.json(order, 201)
})
app.post("/orders/:id/pay", async (c) => {
const id = c.param("id")
// The status change and the queued task commit together.
const ok = await db.transaction(async (tx) => {
const n = await tx.orders.updateWhere({ id, status: "held" }, { status: "paid" })
if (n === 1) await task.run("send-receipt", { orderId: id }, { retries: 5 })
return n === 1
})
return ok ? c.json({ ok }) : c.json({ error: "not held" }, 409)
})task.run(name, payload, { delay: "10m", retries: 3 })returns the task id at once; the request does not wait. Retries back off; a task that fails every attempt goes to the dead letters (cubek task list).- Inside
db.transaction,task.runis atomic with the writes. - Payloads are stored in the app's database until the run ends (a failed
run keeps its payload 7 days, then goes), never logged: pass ids, not
passwords or tokens (those go in
secrets). - Budgets per run: 5 min wall time, 500M instructions, 250 ms per query,
200
fetchcalls, each waiting 60 s by default (timeout: "3m": up to 5 min). Split big jobs: process a batch (500 rows, or the LLM calls that fit in 5 min), thentask.runthe next batch with the last id. - Handlers register at module top level in a file the entry imports.
- Tasks run away from the request path: a slow
fetch(LLM, webhook delivery, email provider) belongs here (a request's fetch waits at most 10 s), with the result written to the db (a live query shows it when it lands). cubek devruns no task or cron while the sources do not compile (they wait, and run once a save compiles);cubek task runthen fails with the compile errors.- Cron: standard five fields, UTC (
"*/5 * * * *","0 9 * * 1-5"); each scheduled time queues one run for the app. A run missed while the app was down runs once when it is back (the latest, if at most 24 h old);task.schedule(name, cron, { catchUp: false })skips it (an idle app woken for a run still runs it). - Previews (
<project>--r<N>) never run cron; tasks their own requests queue run there, on the preview's database copy.cubek task run <name> --project P --rev Nruns one there by hand.
Typed handles
// src/jobs.ts
import { task } from "cubek"
export const sendReceipt = task.define<{ orderId: string }>("send-receipt")
sendReceipt.on(async (p) => { /* p.orderId is a string */ })
// elsewhere: await sendReceipt.run({ orderId }, { retries: 5 }).on and .run are checked against the payload type (the string API
task.on/run("name") is not). cubek check warns when a literal
task.run("x") or task.schedule("x") has no task.on("x") or
task.define("x") in the project, and when a run's payload does not fit
the handler's annotated parameter.
Running tasks by hand
cubek task list # dev: handlers, cron with next run, queue, dead letters
cubek task run cleanup # dev: run now, wait up to 1 min for it
cubek task run send-receipt '{"orderId":"01J..."}'
echo "$JSON" | cubek task run import - # payload from stdin (not argv)
cubek task run cleanup --project shop --wait # production revision (developer role), until it ends, with its log lines
cubek task status <id> --project shop --follow # a run still going: its state, then its end
cubek task list --project shop
cubek logs shop --route "task cleanup" # what the runs loggedcubek dev runs cron and the queue locally. cubek has no email primitive:
call the provider's API with fetch and a secret (above); in dev without
the secret, log.info the message (or the link) instead of sending it.