# @chatu-ai/app-sdk

Data, auth and AI SDK for apps generated by **ChatU Builder**. One API, driver picked from environment variables — works in the Builder preview, on your own server, or with no config at all.

```ts
import { kv, storage } from '@chatu-ai/app-sdk'   // server-side only (Route Handlers / Server Components / Server Actions)

await kv.set('todos:1', { title: 'hi' }, { ex: 3600 })
const todo = await kv.get<{ title: string }>('todos:1')
await kv.incr('views')
const { keys } = await kv.list('todos:')

await storage.put('avatars/u1.png', bytes, { contentType: 'image/png' })   // ≤5MB server-side
const { url } = await storage.uploadUrl('uploads/big.mp4', { contentType: 'video/mp4' })  // hand to the browser: fetch(url, { method: 'PUT', body: file })
const src = await storage.url('avatars/u1.png', { expiresIn: 3600 })         // temporary link for <img src>
```

Thumbnails are generated once and cached in the store, so the returned URL is a plain presigned GET (CDN-friendly, no function in the hot path):

```ts
const src = await storage.thumbnail('uploads/a.jpg', { width: 320, height: 320 })  // fit: 'cover' | 'contain', format: 'webp' | 'jpeg' | 'png'
```

Only the **platform** driver actually resizes (ImageSharp, ≤2048px, source ≤20MB); memory / edgeone / byo return the original URL so pages still render.

| Env | Driver | Notes |
| --- | --- | --- |
| `CHATU_DATA_URL` + `CHATU_APP_KEY` (+ `CHATU_DATA_ENV=dev\|prod`) | **platform** — ChatU hosted Data API, metered | set automatically in the Builder preview; copy from the publish panel for your own server |
| `REDIS_URL` and/or `S3_BUCKET` (+ `S3_ENDPOINT` `S3_REGION` `S3_ACCESS_KEY` `S3_SECRET_KEY` `S3_PREFIX`) | **byo** — your own Redis / S3-compatible bucket (Tencent COS, MinIO, AWS) | install optional deps: `npm i ioredis @aws-sdk/client-s3 @aws-sdk/s3-request-presigner` |
| `CHATU_DATA_DRIVER=edgeone` (+ optional `CHATU_EDGEONE_KV_STORE` / `CHATU_EDGEONE_STORAGE_STORE`, external access: `EDGEONE_BLOB_PROJECT_ID` + `EDGEONE_BLOB_TOKEN`) | **edgeone** — Tencent EdgeOne Pages Blob for both `kv` (JSON envelope, TTL emulated) and `storage` (presigned PUT via `createUploadUrl`; `storage.url()` returns the in-app proxy path `/_chatu/blob/<key>` served by the template route) | `npm i @edgeone/pages-blob` (preinstalled in the Builder template); credential-free inside Pages Functions. `ai` keeps using `CHATU_DATA_URL` + `CHATU_APP_KEY` |
| `CHATU_DATA_DRIVER=sqlite` (+ optional `CHATU_SQLITE_PATH`, default `./data/chatu.sqlite`) | **sqlite** — `db` and `kv` in one local SQLite file via Node's built-in `node:sqlite` (Node ≥ 22.13, zero deps); `auth` / `storage` / `ai` keep using `CHATU_DATA_URL` + `CHATU_APP_KEY` when present, otherwise storage falls back to memory and auth is unsupported | Not the default and not recommended for most apps: no EdgeOne Pages / serverless deploy (no persistent disk), single instance, data lives in the file (back it up yourself), `journal_mode=DELETE` (safe on network disks, modest write throughput). Same API — switch back by removing the variable |
| none | **memory** — in-process, lost on restart | local dev / fallback |

### Structured output

```ts
const data = await ai.json('extract {title, amount} from: ' + text, {
  schema: z.toJSONSchema(Schema),   // JSON Schema → sent as response_format: json_schema (hard constraint)
  validate: Schema,                 // a zod/valibot schema, or v => Schema.parse(v); failures are retried with the error
})
```

`ai.json` forces JSON-only output, strips code fences, parses, validates, and retries once by default.
With a JSON Schema it uses `response_format: { type: 'json_schema' }` and falls back to `json_object`
automatically when the model or relay rejects it. `strict: true` additionally asks for OpenAI strict mode
(every object needs `additionalProperties: false`).

## Auth (app users)

`auth` gives the generated app **its own end users** (separate from ChatU platform accounts), scoped per app + environment (`dev`/`prod`). Email code or email + password; sessions are opaque tokens (stored server-side as SHA-256, sliding 30-day expiry). Requires the **platform** driver.

```ts
import { auth } from '@chatu-ai/app-sdk'

const { devCode } = await auth.sendCode('a@b.com')            // devCode only in dev when no mail channel is configured
const { token, user } = await auth.verifyCode('a@b.com', code) // first login signs the user up
const me = await auth.getSession(token)                        // null when expired / disabled
await auth.signOut(token)

await auth.register('a@b.com', 'secret1')                      // password route (>= 6 chars)
await auth.login('a@b.com', 'secret1')

const { users, total } = await auth.users.list({ keyword: 'a@' })
await auth.users.update(id, { disabled: true })                // also revokes that user's sessions
```

In the Next.js template, `@/lib/platform` wraps this in HttpOnly-cookie helpers: `currentUser()`, `requireUser()`, `signInWithCode()`, `endSession()`.

### Roles (admin pages)

Roles live in `user.meta.roles`; emails listed in the `ADMIN_EMAILS` env var implicitly hold `admin`, so the first administrator needs no bootstrap step.

```ts
auth.requireRole(user, 'admin')          // throws AppSdkError('FORBIDDEN', …, 403)
auth.roles.has(user, 'admin', 'editor')  // pure check, no request
await auth.roles.grant(userId, 'editor') // merges into meta.roles
await auth.roles.revoke(userId, 'editor')
```

### Social login (WeChat / WeChat MP / GitHub / Gitee / QQ)

The platform runs the OAuth dance; the app only needs a provider's credentials in its env vars (`WECHAT_APP_ID`/`WECHAT_APP_SECRET`, `WECHAT_MP_APP_ID`/`WECHAT_MP_APP_SECRET`, `GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET`, `GITEE_CLIENT_ID`/`GITEE_CLIENT_SECRET`, `QQ_APP_ID`/`QQ_APP_KEY`) and two routes (bundled in the template):

```ts
// server: start → provider authorize URL (302 there); the platform redirects back to callbackUrl?ticket=…
const { url } = await auth.oauth.start('github', { callbackUrl: `${origin}/api/auth/oauth/callback`, returnTo: '/', mode: 'redirect' })
const { token, user } = await auth.oauth.exchange(ticket)     // one-time ticket (60 s) → session
const { providers, callbackDomain } = await auth.oauth.providers()  // [{ provider, configured, missing }]
```

```ts
// browser (client component): no secrets, safe to import
import { startOAuth, pickWeChatProvider } from '@chatu-ai/app-sdk/browser'
startOAuth(pickWeChatProvider(), { returnTo: '/' })   // popup inside an iframe (Builder preview), full redirect otherwise
```

`start` throws `OAUTH_NOT_CONFIGURED` (412) with `err.details.missing` listing the env vars still unset. Social users have `source: 'wechat' | 'wechat-mp' | 'github' | 'gitee' | 'qq'`, no password, and possibly `email: null`. The memory driver ships a mock flow (`start` returns `callbackUrl?ticket=memt_…`, `exchange` creates `wx_mock_wechat` / `gh_mock_github`).

Limits: 10k users per app/env, 200 codes and 500 signups per day, code valid 10 min / 5 tries, 60s per-email resend window, password login locks an email for 15 min after 10 consecutive failures.

Billing: every auth call is metered as `auth_ops` (100 calls = 1 point by default) and each **actually sent** verification email as `auth_emails` (1 email = 1 point). Since `getSession()` runs on every request, the platform driver keeps a 30-second in-process session cache — tune it with `CHATU_AUTH_SESSION_CACHE` (seconds, `0` disables) or `configure({ authSessionCacheSeconds })`. The cache is dropped on `signOut()` and on any `users.update()` / `users.delete()`, so disabling a user takes effect within that window.

## AI (LLM relay)

`ai` calls the platform's OpenAI-compatible endpoint (`{origin}/v1/chat/completions`) with the same app key used by the Data API. **Server-side only** — call it from a Route Handler / Server Action and let the browser `fetch` your own API; never ship the key to the client. Usage is metered and **billed to the app owner's ChatU points**.

| Env | Notes |
| --- | --- |
| `CHATU_DATA_URL` + `CHATU_APP_KEY` | same as the Data API — the AI base URL is derived by replacing the trailing `/data/v1` with `/v1` |
| `CHATU_AI_URL` (optional) | explicit override of the AI base URL, e.g. `https://api.chatuapi.com/v1` |
| `CHATU_AI_MODEL` / `PRIMARY_MODEL` (optional) | default model id when the caller does not pass `model`; the Builder sandbox sets `PRIMARY_MODEL`; if neither is set the server default is used |
| `CHATU_AI_EMBED_MODEL` (optional) | default embedding model for `ai.embed` / `ai.embedMany`; defaults to `text-embedding-3-small` (allowed: `text-embedding-3-small` / `text-embedding-3-large` / `text-embedding-ada-002`) |
| `CHATU_AI_IMAGE_AGENT` (optional) | default image agent for `ai.generateImage`; defaults to `Seedream4` (the cheapest) |
| `CHATU_AI_VIDEO_AGENT` (optional) | default video agent for `ai.generateVideo`; defaults to `Seedance2Fast` (the cheapest / fastest) |

```ts
// app/api/summarize/route.ts — one-shot
import { ai } from '@chatu-ai/app-sdk'

export async function POST(req: Request) {
  const { text } = await req.json()
  const { content, usage } = await ai.chat([
    { role: 'system', content: 'Summarize the user text in one sentence.' },
    { role: 'user', content: text },
  ], { temperature: 0.3, maxTokens: 200 })      // model optional; extra: { top_p, stop, response_format … } passes through
  return Response.json({ summary: content, usage })
}
```

```ts
// app/api/chat/route.ts — streaming (text/plain chunks; consume in the browser with res.body.getReader())
import { ai } from '@chatu-ai/app-sdk'

export async function POST(req: Request) {
  const { messages } = await req.json()          // [{ role: 'user', content: '…' }, …]
  const stream = new ReadableStream<Uint8Array>({
    async start(controller) {
      const enc = new TextEncoder()
      try {
        for await (const delta of ai.stream(messages, { signal: req.signal })) controller.enqueue(enc.encode(delta))
        controller.close()
      } catch (e) { controller.error(e) }
    },
  })
  return new Response(stream, { headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-cache' } })
}
```

`ai.chat('hello')` accepts a plain string as a single user message; `ai.models()` lists available model ids. Without platform env vars (memory / byo drivers) every call rejects with `AppSdkError('AI_NOT_CONFIGURED')` — there is no local fallback for LLM calls.

### Vision (image understanding)

```ts
import { ai, toDataUrl } from '@chatu-ai/app-sdk'

const bytes = new Uint8Array(await file.arrayBuffer())
const { content } = await ai.chat([{ role: 'user', content: [
  { type: 'text', text: 'What is in this picture?' },
  { type: 'image_url', image_url: { url: toDataUrl(bytes, file.type), detail: 'low' } },  // or a public https URL
] }])
```

### Tools (function calling)

```ts
const { content, steps } = await ai.runTools('What is the weather in Shanghai?', {
  tools: [{
    name: 'getWeather',
    description: 'Look up the weather for a city',
    parameters: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] },
    execute: async ({ city }) => await lookup(city),      // result is JSON-serialized back to the model
  }],
  maxRounds: 5,                                           // last round drops the tools so the model must answer
})
```

`ai.chat(msgs, { tools })` returns `toolCalls` without executing anything, if you want to drive the loop yourself.

`ai.stream` does **not** support tools (the SSE parser only reads text deltas) — passing them throws `AI_STREAM_TOOLS_UNSUPPORTED`.

### Embeddings and vector search

```ts
import { ai, db, splitText, vectorSearch } from '@chatu-ai/app-sdk'

// index
const chunks = splitText(longText, { chunkSize: 500, overlap: 50 })
const { vectors } = await ai.embedMany(chunks)             // batch of ≤100 per call
const coll = db.collection('kb')
for (const [i, text] of chunks.entries()) await coll.insert({ docId, text, embedding: vectors[i] })

// search
const hits = await vectorSearch(coll, await ai.embed(question), { filter: { docId }, topK: 5, minScore: 0.3 })
const context = hits.map(h => h.item.text).join('\n---\n')
```

Similarity is cosine, computed **in process** over the candidates the filter returns (200 docs per page,
`scanLimit` 2000 by default) — there is no server-side vector index, so keep a single search under a few
thousand candidates and narrow it with `filter`. The returned docs have the vector field stripped.

### OCR / document parsing

```ts
const { content, pages } = await ai.ocr(bytes, {          // Uint8Array | ArrayBuffer | Blob
  filename: 'invoice.pdf',                                // pdf / png / jpg / tiff / docx / xlsx / pptx / html
  features: ['keyValuePairs', 'queryFields'],             // optional add-ons, each billed per page
  queryFields: ['invoice number', 'total'],
})
// content is Markdown (tables included) — feed it straight to ai.chat / ai.json
```

Billed per page; add-ons are billed per page on top. Raw Azure Document Intelligence output stays in `.raw`.

### Image generation

```ts
const { images } = await ai.generateImage({
  prompt: 'a watercolor cat on a windowsill',
  agent: 'Seedream4',            // optional; Seedream4 | Seedream5Lite | Seedream45 | Seedream5Pro | NanoBanana | NanoBananaPro | Image2
  count: 1,                      // every image is billed
  size: '2K',                    // '1K' | '2K' | '4K', '16:9', or '1024x1024' — mapped per agent family
  referenceImages: ['https://…'],// image-to-image / style reference (not for Image2)
})
images[0].url                    // hosted URL — show it or store it with `storage`
```

Synchronous: the call waits for the agent (usually 5–60 s; multi-image high-quality runs can take 2–3 min), so call it
from a Route Handler with a generous timeout and never from the client. **Billed per image** to the app owner
(Seedream4 is the cheapest, NanoBanana / NanoBananaPro cost about twice as much); a failed run is not charged and
throws `AppSdkError` (`AI_IMAGE_FAILED`, `AI_INSUFFICIENT_BALANCE`). Agent-specific knobs (`watermark`, `seed`,
Image2 `quality`, …) go in `extra`. `ai.agents()` lists the agents the platform currently exposes to apps
(`mode: 'sync'` for image agents, `'async'` for video agents).

### Video generation

```ts
// simplest: submit and poll until done (usually 1–5 min)
const { video, thumbnailUrl, totalCredits } = await ai.generateVideo({
  prompt: 'a paper boat drifting down a rainy street, cinematic',
  agent: 'Seedance2Fast',        // optional; Seedance2Fast | Seedance2Mini | Seedance2 | Seedance25 | Seedance15 | Sora2 | MiniMaxH3
  duration: 5,                   // seconds — Seedance 2.x: 5 | 10, Seedance25: 4–30, MiniMaxH3: 4–15, Sora2: 4 | 8 | 12
  ratio: '16:9',                 // '16:9' | '9:16' | '1:1' (Seedance25 / MiniMaxH3 also 4:3, 3:4, 21:9, adaptive)
  resolution: '720p',            // '480p' | '720p' (Seedance25 up to 1080p; MiniMaxH3: '768P' | '2K'); ignored by Sora2
  firstFrameUrl: 'https://…',    // image-to-video (not for Sora2); add lastFrameUrl for first+last, referenceImages for multimodal
  onProgress: t => console.log(t.state, t.message),
})
video.url                        // hosted mp4

// serverless routes with a short execution limit: submit now, poll later
const task = await ai.generateVideo({ prompt, wait: false })   // → { taskId, agent, state }
const snap = await ai.getTask(task.agent, task.taskId)         // { state: 'working', message: '排队中…' } | { state: 'completed', output }
const done = await ai.waitForTask(task.agent, task.taskId, { pollIntervalMs: 5000, timeoutMs: 15 * 60_000 })
```

Video agents are **asynchronous only** on the platform: the POST returns a task id at once and the SDK polls
`GET /v1/agents/{agent}/tasks/{id}` every 5 s (up to 15 min; `AI_VIDEO_TIMEOUT` afterwards — the task keeps running
server-side, query it again later). **Billed per second × resolution** to the app owner and *expensive* — a 5 s 720p
clip is roughly 100k–400k points (about ¥2–8), Sora2 / MiniMaxH3 2K more — so confirm with the user before wiring it
into anything that runs unattended. Failures throw `AppSdkError` (`AI_VIDEO_FAILED`, `AI_INSUFFICIENT_BALANCE`,
`AI_VIDEO_EMPTY`); the state before failure is not charged. Agent-specific knobs (`seed`, `watermark`, `cameraFixed`,
MiniMaxH3 `referenceVideoUrls`, …) go in `extra`.

## Aggregation (dashboards)

Group and reduce **on the server** — never `find` the whole collection back to reduce it in the app (the 200-per-page cap silently truncates the numbers).

```ts
const [kpi] = await orders.aggregate({
  filter: { status: 'paid' },
  metrics: { n: { $count: true }, total: { $sum: 'amount' }, avg: { $avg: 'amount' }, users: { $countDistinct: 'userId' } },
})   // → { key: null, n: 128, total: 35600, avg: 278.1, users: 96 }

const byDay = await orders.aggregate({
  filter: { _createdAt: { $gte: Date.now() - 30 * 86400_000 } },
  groupBy: { field: '_createdAt', unit: 'day' },   // hour | day | week | month; tzOffsetMinutes defaults to +480 (Asia/Shanghai)
  metrics: { n: { $count: true }, total: { $sum: 'amount' } },
})   // → [{ key: '2026-01-01', n: 12, total: 3400 }, …] sorted by key

const top = await orders.aggregate({ groupBy: 'userId', metrics: { total: { $sum: 'amount' } }, sort: { total: -1 }, limit: 10 })
```

Metrics (`$count` / `$sum` / `$avg` / `$min` / `$max` / `$countDistinct`) always return numbers; non-numeric and missing fields are skipped. Up to 1000 groups per call.

## Atomic writes

"Check then write" races (duplicate sign-ups, overselling the last seat) are the most common bug in generated code. Use these instead of `findOne` + `insert` or read-modify-write:

```ts
await kv.setnx('order:' + requestId, 1, { ex: 3600 })        // idempotency: true only for the first caller

const lock = await kv.lock('seat:' + id, { waitMs: 2000 })   // mutex on top of setnx
if (!lock) return { error: 'busy, try again' }
try { /* … */ } finally { await lock.release() }

// conditional update (optimistic lock): null when the condition no longer holds
const ok = await seats.updateIf(id, { inc: { left: -1 } }, { left: { $gt: 0 }, status: 'open' })

// find-or-insert, serialized by a lock on the filter
const { doc, created } = await users.getOrCreate({ email }, { email, name })
```

Fully atomic on the **platform** driver (Redis `SET NX` / server-side CAS) and consistent on `sqlite` / `memory`; on `edgeone` it is best-effort (Pages Blob has no compare-and-swap).

## CSV export / import

```ts
import { toCsv, parseCsv } from '@chatu-ai/app-sdk'

const csv = toCsv(docs, { columns: ['title', { key: '_createdAt', label: 'Created', value: d => new Date(d._createdAt).toLocaleString() }] })
return new Response(csv, { headers: { 'content-type': 'text/csv; charset=utf-8', 'content-disposition': 'attachment; filename="orders.csv"' } })

const { rows } = parseCsv(await file.text())   // validate each row with zod before insertMany
```

UTF-8 BOM by default (Excel shows CJK correctly), RFC-4180 quoting both ways.

### Usage and quota

```ts
const u = await ai.usage()
// { month: '2026-09', dev: {calls, inputTokens, outputTokens, points}, prod: {…}, total: {…},
//   quota: { monthlyPoints: 2000 | null, used: 1367, remaining: 633 | null } }

await ai.setQuota(2000)   // this app may spend at most 2000 points this month; null clears it
```

Points are the ones actually charged (same source as the bill); `dev` and `prod` are counted separately. Past the cap, AI calls throw `AI_QUOTA_EXCEEDED` (402) while `db` / `kv` / `storage` keep working — pair it with `ratelimit` (per-user throttle) for abuse protection. Model calls made by Builder while generating the app are not counted.

## Payments (WeChat Pay)

Money goes **straight to the app owner's own WeChat Pay merchant account** — the platform only hosts the callback and keeps a payment record. Requires a business license (individuals cannot open a merchant account), and there is **no sandbox**: test with 1 cent.

```ts
const order = await pay.create({ amount: 1990, subject: '周末营地报名', method: 'native', bizId: signup._id })
// → { orderId, status: 'pending', codeUrl, h5Url, expiresAt, … }  (amount is in cents)

const o = await pay.getOrder(order.orderId)          // pending | paid | closed (re-checks with WeChat while pending)
const { orders } = await pay.orders({ status: 'paid' })
await pay.closeOrder(order.orderId)
```

Ship goods only when `status === 'paid'`, never on a client-side signal. No in-app refunds in this version — the owner refunds from the merchant console using `transactionId`.

## Rate limiting

```ts
import { ratelimit } from '@chatu-ai/app-sdk'

const { ok, reset } = await ratelimit(`ai:${userId}`, { limit: 20, window: 3600 })
if (!ok) return Response.json({ error: 'too many requests' }, { status: 429, headers: { 'retry-after': String(reset) } })
```

Fixed window on top of `kv.incr` (keys are bucketed by `floor(now / window)`), so it works on every driver.

## Validated reads

```ts
const profile = await kv.get('profile:1', ProfileSchema)   // any Standard Schema: zod ≥3.24 / zod 4 / valibot
```

Returns `null` when the key is missing, throws `AppSdkError('INVALID_DATA')` when the stored value drifted
from the schema. The SDK has **no** runtime dependency on a validation library — it only speaks the
[Standard Schema](https://standardschema.dev) interface.

Never expose `CHATU_APP_KEY` to the browser. MIT.

## Agent skills

The package ships `skills/` — task-focused manuals for coding agents: capability skills
(`chatu-{kv,db,storage,ai,auth,validation}`, one per SDK module) and process skills
(`chatu-{quickstart,ui,debug}`, the quickstart one bundling copy-paste `recipes/`). The ChatU Builder
sandbox copies them into the workspace `.claude/skills/` so Claude Code loads them on demand.

**Source of truth is the [chatu-builder-skill](https://github.com/chatu-ai/chatu-builder-skill) repo**;
`skills/` here is a synced copy for distribution. Two hard rules:

1. **Any SDK API change (add / rename / signature / behavior) MUST update the matching skill in the
   same changeset** — the skills are the agent's API docs; a stale skill makes agents write broken code.
   Commit the skill change to the chatu-builder-skill repo AND sync the copy here.
2. Skills must never contain secrets, internal hostnames, or real account data.
