Skills

AI コーディングエージェントに Cradler の使い方を教える Agent Skill。ドキュメント全体をコピーして AI ツールに貼り付けてください。

技術ドキュメントは英語のままにしています — AI コーディングツールは英語の原文で最も正確に動作します。このページをそのまま AI ツールに貼り付けてください。

name: cradler description: Use when an app needs to store data or files — adding a database, saving records, user data, settings, posts, comments, or uploading images and files. Cradler is a zero-config backend (a managed Postgres database plus file storage) used through the @cradler/sdk TypeScript client. There is no SQL, no schema to define, and no migrations.

Cradler

Cradler gives an app a backend — a database and file storage — through one typed TypeScript SDK. Use it whenever the app you are building needs to persist data or store uploaded files.

The most important thing to know: you never design a schema, write SQL, or run a migration. You just write data, and Cradler creates the tables and columns for you. Do not generate CREATE TABLE statements, schema files, ORM models, or migrations — that is not how Cradler works, and it will only confuse the user.

Setup

The user needs a Cradler project. If they do not have one, tell them to create one for free at https://cradler.ai. From the project dashboard they can copy three things — ask the user for them:

  • API URL — the data gateway URL, e.g. https://gateway.cradler.ai
  • Project ID — the project's slug
  • API keys — an anon key and a service key

Install the SDK:

pnpm add @cradler/sdk

Create the client. Read the keys from environment variables — never hardcode them:

import { createClient } from "@cradler/sdk";

const cradler = createClient({
  url: process.env.CRADLER_API_URL!,
  projectId: process.env.CRADLER_PROJECT_ID!,
  apiKey: process.env.CRADLER_API_KEY!,
});

Which key to use

  • service key — full access. Use it only in server-side code (API routes, server actions, backend jobs). Never send it to the browser.
  • anon key — safe for client-side / browser code. What it can do is limited by the table permissions the user sets in the dashboard.

If the app talks to Cradler directly from the browser, use the anon key there, and tell the user to enable the table permissions it needs in the dashboard.

Storing and reading data

A "collection" is a table. You do not create it — it appears the first time you write to it. Use plain camelCase field names; the SDK maps them to the database for you.

Insert

// The "posts" table and its columns are created automatically.
await cradler.from("posts").insert({
  title: "Hello world",
  body: "My first post",
  published: true,
});

// Insert many at once:
await cradler.from("posts").insert([{ title: "A" }, { title: "B" }]);

Writing a field that does not exist yet adds the column automatically.

Every row automatically gets three Cradler-managed fields — id, createdAt, and updatedAt. Read them and filter or order by them, but never pass them to insert() or update() — Cradler always sets them.

Query

// Rows — at most 50 unless you call .limit() (max 1000 per call):
const { rows } = await cradler.from("posts").select();

// Specific columns, filtered, ordered, paginated:
const { rows, count } = await cradler
  .from("posts")
  .select("id", "title")
  .eq("published", true)
  .order("createdAt", { desc: true })
  .limit(20);

// Just the first match, or null:
const post = await cradler.from("posts").select().eq("id", id).first();

// How many posts are there in total? `count` cannot answer that — it is the
// size of the page you got back, so it never exceeds `limit`. Ask for
// `total`, which counts every row matching the filters:
const { rows: page, total } = await cradler
  .from("posts")
  .select()
  .eq("published", true)
  .limit(20)
  .count("exact");

Filters — chain as many as needed: .eq .neq .gt .gte .lt .lte .like(field, pattern) .ilike(field, pattern) .in(field, [...]) .isNull(field) .notNull(field).

A query without .limit() returns at most 50 rows; .limit() accepts up to 1000. To show everything, page with .limit(n).offset(k) and use .count("exact") to know when to stop. Never pass undefined as a filter value (it throws) — check the variable first, or use .isNull().

Comparisons never match an empty (null) field. .eq("used", false) skips rows where used was never set; match emptiness with .isNull() / .notNull(). A field nobody has written to the collection yet is empty on every row, so a filter on it matches nothing — on update() / delete() too, which then change nothing yet succeed (.isNull() on such a field is refused instead). The result's warnings names the field. So any field you will later filter on — flags like used, revoked, processed — must be written explicitly on every insert:

// Write the flag up front, so the conditional update below can match:
await cradler.from("invites").insert({ code, used: false });

// "Claim once": only succeeds if nobody claimed it first.
const { rows } = await cradler
  .from("invites")
  .update({ used: true })
  .eq("code", code)
  .eq("used", false);
if (rows.length === 0) throw new Error("invite already used");

Counts, sums and averages

Never compute a statistic from select() rows — a query returns at most 1000 rows, so a total or average built from them is silently wrong on a larger collection. Use aggregate(), which runs in the database over every matching row:

// One row per status: order count, revenue, average order value.
const { rows } = await cradler
  .from("orders")
  .aggregate(
    { fn: "count" },
    { fn: "sum", field: "amount", as: "revenue" },
    { fn: "avg", field: "amount" },
  )
  .groupBy("status")
  .gte("createdAt", "2026-01-01")
  .order("revenue", { desc: true });
// rows: [{ status: "paid", count: 1840, revenue: 51230.5, avgAmount: 27.84 }, ...]

// Without groupBy() — one row over everything that matches:
const { rows: [stats] } = await cradler
  .from("orders")
  .aggregate({ fn: "count" }, { fn: "sum", field: "amount" })
  .eq("status", "paid");
// stats: { count: 1840, sumAmount: 51230.5 }
  • Functions: count (no field counts rows; with a field, rows where it is not null), sum / avg (numeric fields only), min / max (numbers, text, dates).
  • Each value comes back under its as key, or by default count / sumAmount / avgAmount (function + field).
  • .order() takes a groupBy field or an aggregate key. .limit() caps the number of groups (default 100, max 1000), never the rows counted; .count("exact") adds total, the number of groups.
  • Filters work exactly as on select().

To see which collections exist and what fields they have, call cradler.listCollections() — it returns [{ collection, columns: { field: type } }].

Upsert and unique fields

Never check-then-insert ("query for the email, insert if absent") — two requests at once both see nothing and both insert. Let Cradler enforce it:

// Insert, or update the row with the same email (only the fields passed are
// updated; the rest keep their values):
await cradler.from("users").upsert({ email, name }, { onConflict: "email" });

// Idempotent create — a retry returns the stored row unchanged:
const { rows: [post] } = await cradler
  .from("posts")
  .upsert({ idempotencyKey, text }, { onConflict: "idempotencyKey", ignoreDuplicates: true });

// Reject duplicates (sign-up): throws CradlerError, status 409,
// code "constraint_violation", if the email is taken.
await cradler.from("users").insert({ email, passwordHash }, { unique: "email" });
  • The first upsert() / insert(..., { unique }) naming a field makes it unique permanently — every later duplicate insert gets a 409, with or without unique. There is no self-serve undo, so only use it on fields that must never repeat (emails, external ids, idempotency keys).
  • Several fields are unique together: onConflict: ["userId", "platform"].
  • onConflict defaults to id. Every upserted row needs a value for each onConflict field.
  • If existing rows already repeat the value, the call fails with a 409 that says how many — merge or delete them first.
  • Only the service key can make a field unique (do it from server code); the anon key can use it afterwards.

Update and delete

// update() requires at least one filter — an unscoped update would rewrite
// every row in the collection, with no way to undo it.
await cradler.from("posts").update({ published: false }).eq("id", id);

// delete() requires at least one filter — it can never wipe a whole table.
await cradler.from("posts").delete().eq("id", id);

Every insert / query / update / delete resolves to { rows, count }, where count is rows.length. On a query it is the size of the page, so it is bounded by limit — to learn how many rows match overall, add .count("exact") and read total.

Files and images

Storage only works with the service key, so every storage call belongs in server-side code (API routes, server actions, backend jobs). The anon key gets 403 forbidden on storage. When a user picks a file in the browser, send it to the app's own server route (e.g. as FormData) and upload it from there — never move the service key to the browser to make it work.

// Upload (server-side) — body is a Blob/File, ArrayBuffer, Buffer, or string.
await cradler.storage.upload("avatars/cat.png", fileBlob, {
  contentType: "image/png",
});

// A temporary signed URL to display or download the file:
const url = await cradler.storage.getUrl("avatars/cat.png");

// Download as a Blob:
const blob = await cradler.storage.download("avatars/cat.png");

// List (optionally by prefix) and delete:
const files = await cradler.storage.list("avatars/");
await cradler.storage.remove("avatars/cat.png");

Do not set compress: true. It only works in a browser, and storage calls must not run in a browser (they need the service key) — on the server it throws. Upload the bytes as they are.

When you save a file reference in the database, store the path returned by upload(), not the URL. URLs from getUrl() are short-lived signed URLs that expire; persisting them leads to broken links. Resolve the path to a fresh URL with getUrl() when you need to display it.

Errors

Failed calls throw a CradlerError with an HTTP status, a machine-readable code, and a message that names what is wrong:

import { CradlerError } from "@cradler/sdk";

try {
  await cradler.from("posts").insert({ title: "x" });
} catch (err) {
  if (err instanceof CradlerError) {
    console.error(err.status, err.code, err.message);
  }
}

Read the message — it says how to fix the call. A type mismatch names the field, the type the column holds and the value it got (field 'views' expects numeric, got str ('many')); fix the value rather than retrying. The codes worth handling:

codestatusmeans
validation_failed422A value or filter does not fit the column. The message names the field.
schema_conflict422A field's type clashes with the column that already exists.
quota_exceeded402The project is out of its plan's monthly requests or storage.
constraint_violation409A unique index or similar rejected the write.
unauthorized / forbidden401 / 403Bad key, or the anon key lacks permission on that table.
database_unavailable503Transient — retry with backoff.

Errors from the gateway also carry err.requestId (SDK 0.4.1+), which appears in Cradler's own logs. Include it when reporting a problem.

Rules

  • Never write SQL, schema files, CREATE TABLE, ORM models, or migrations. Just write data — the schema evolves on its own.
  • Never put the service key in client-side / browser code; use the anon key there.
  • Do not hardcode keys — read them from environment variables.
  • Use camelCase field names.
  • id, createdAt, updatedAt are managed by Cradler — read them, but never include them in an insert() or update().
  • update() and delete() always need at least one filter.
  • For "create unless it exists" and "must be unique" use upsert() or insert(..., { unique }) — never a query followed by an insert.
  • Queries return at most 50 rows unless you set .limit() (max 1000).
  • Storage calls run server-side only, with the service key.
  • A field keeps the type it was first written with. Write 42, not "42", and keep it a number afterwards — mixing types in one field is rejected, including across the rows of a single batch insert.
  • Write every field you will later filter on (e.g. used: false) at insert time — a filter on an unset or never-written field matches nothing, and an update() / delete() scoped by it silently changes nothing.
  • count is how many rows came back, not how many exist. Use .count("exact") and total to answer "how many".
  • Totals, averages, per-group counts: use aggregate(), never arithmetic over select() rows (capped at 1000).

Related

To read and write a project's data directly from an agent — operating on the data rather than writing app code — Cradler also offers an MCP server, @cradler/mcp. Its query tool has the same distinction as the SDK: count is the page size, and countTotal: true returns total. Its aggregate tool is the SDK's aggregate(), and list_collections lists every collection with its columns.