@chatu-ai/app-sdk
v0.21.0
Published
Runtime SDK for apps generated by ChatU Builder: kv, db (with atomic writes), storage, auth (app users, roles), ai (OpenAI-compatible LLM relay), pay and caps (enterprise capability center) with platform / byo / edgeone / sqlite / memory drivers selected
Maintainers
Readme
@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.
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):
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
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.
import { auth } from '@chatu-ai/app-sdk'
const { devCode } = await auth.sendCode('[email protected]') // devCode only in dev when no mail channel is configured
const { token, user } = await auth.verifyCode('[email protected]', code) // first login signs the user up
const me = await auth.getSession(token) // null when expired / disabled
await auth.signOut(token)
await auth.register('[email protected]', 'secret1') // password route (>= 6 chars)
await auth.login('[email protected]', 'secret1')
const { users, total } = await auth.users.list({ keyword: 'a@' })
await auth.users.update(id, { disabled: true }) // also revokes that user's sessionsIn 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.
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):
// 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 }]// 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 otherwisestart 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) |
// 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 })
}// 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)
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)
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
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
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.jsonBilled per page; add-ons are billed per page on top. Raw Azure Document Intelligence output stays in .raw.
Image generation
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
// 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).
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:
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
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 insertManyUTF-8 BOM by default (Excel shows CJK correctly), RFC-4180 quoting both ways.
Usage and quota
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 itPoints 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.
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.
Enterprise capabilities (caps)
Tools the enterprise connected in its control panel — intranet databases and internal APIs behind the chatu-edge gateway, hosted HTTP forwarders, WeChat Official Account APIs, payment channels — all exposed to the app as named tools with an input schema and an output schema. Which tools an app can call is decided by the platform (the capability set of the agent that built the app, narrowed per app); locally, read .chatu/capabilities.json or call caps.list().
const { tools, available } = await caps.list() // tools you can call (with inputSchema / outputSchema / sampleOutput)
const out = await caps.invoke<{ items: Customer[] }>('crm.search', { keyword: '张' }) // throws AppSdkError on failure
const r = await caps.call('crm.search', { keyword: '张' }) // never throws: { state: 'completed' | 'failed' | ... }
if (r.state === 'failed') console.warn(r.error.code, r.error.hint)The output schema is also a field whitelist: anything the tool returns beyond it is stripped server-side. Error codes are an open set (KNOWN_CAP_ERROR_CODES lists the current ones); always keep an "unknown" branch. Without platform config the memory driver serves tools registered via registerMemoryCapTool() for local development and tests.
Rate limiting
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
const profile = await kv.get('profile:1', ProfileSchema) // any Standard Schema: zod ≥3.24 / zod 4 / valibotReturns 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 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 repo;
skills/ here is a synced copy for distribution. Two hard rules:
- 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.
- Skills must never contain secrets, internal hostnames, or real account data.
