@crediolabs/app
v0.2.0
Published
Framework-agnostic browser client for Tuan mini-apps (/_api/*). Zero dependencies.
Readme
@crediolabs/app
Framework-agnostic browser client for Tuan mini-apps. Zero runtime
dependencies. Talks to your app's /_api/* surface — authenticate end-users,
store per-user data and files, and stream turns from the app's agents.
This file is read by both humans and AI coding agents. Pair it with the bundled type declarations (
dist/app.d.ts) for the exact API surface.
Install
Build apps — install from npm:
npm install @crediolabs/appimport { createClient } from "@crediolabs/app";No-build apps served by the harness can skip npm and import the embedded copy:
<script type="module">
import { createClient } from "/_sdk/app.js";
const app = createClient();
await app.auth.anon();
await app.data.set("hello", { at: Date.now() });
console.log(await app.agent.run("hi").text());
</script>Create a client
// Same-origin (app served from its own published domain): no options needed.
const app = createClient();Cross-origin (host the frontend elsewhere, e.g. Cloudflare Pages)
Point the client at your app's API domain and authorize the frontend origin.
baseUrl rewrites every /_api/* call and the WebSocket URL.
const app = createClient({ baseUrl: "https://your-app.tuan.example" });For this to work, add the frontend's origin to the app's allowed origins
(in the app's settings). CORS is default-deny; wildcards match exactly one DNS
label, e.g. https://app-*.example.com. Auth is a Bearer token (no cookies), so
no credentials mode is needed.
Authentication
Each end-user gets a per-origin token (persisted in localStorage, key
tuanapp:token). Pick the mode the app is configured for:
await app.auth.anon(); // anonymous apps: mint a fresh uid + token
await app.auth.passcode("1234"); // passcode apps: exchange a code for a token
const me = await app.auth.whoami(); // { uid, mode: "anonymous" | "passcode" }
app.auth.token; // current token (string | null)
app.auth.signOut(); // clear the stored tokenAll other calls require a token; call one of the auth methods first.
Data (per-user key/value)
JSON values scoped to the current (app, uid). Isolated per user.
await app.data.set("profile", { name: "Ada" });
const profile = await app.data.get<{ name: string }>("profile"); // null if absent
const keys = await app.data.list();
await app.data.delete("profile");Artifacts (per-user files)
Files scoped to the current (app, uid). Both end-user uploads and
agent-presented files land here.
const { id } = await app.artifacts.upload(file, { name: "report.csv" }); // file: Blob/File
const all = await app.artifacts.list(); // ArtifactMeta[]
const one = await app.artifacts.get(id); // ArtifactMeta | null
// Rendering an image artifact in <img>:
const url = await app.artifacts.getBlobUrl(id); // authed fetch -> object URL
imgEl.src = url;
// NOTE: artifacts.fileUrl(id) returns the bare URL with NO token — it will NOT
// load in a bare <img src>. Use getBlobUrl() for that.ArtifactMeta = { id, name, mediaType, size, createdAt }.
Agent
Run a turn, stream events, or read history. agent defaults to the app's
configured default agent.
Run a turn
// One-shot text:
const reply = await app.agent.run("Summarize my latest upload.").text();
// Or stream events as they arrive (async iterable):
for await (const ev of app.agent.run("Write a haiku.")) {
if (ev.type === "delta") process.stdout.write(ev.text);
}Attach files to a turn
Upload first, then pass artifact ids. The agent reads the file with its own tools. Any file type; max 5 per turn; you may only attach your own artifacts.
const { id } = await app.artifacts.upload(file, { name: "data.csv" });
const answer = await app.agent
.run("What is the total in the attached CSV?", { attachments: [id] })
.text();The app's agent must have a file-reading tool (e.g.
Read) in its allowlist, or it cannot open the attachment.
Event types
agent.run(...) and agent.stream(...) emit AgentEvent:
| type | payload | meaning |
|--------------|--------------------------------------|----------------------------------|
| turn.start | — | the turn began |
| delta | { text } | a chunk of the assistant reply |
| message | { role: "assistant", content, id } | the finalized assistant message |
| artifact | { artifact: ArtifactMeta } | the agent presented a file (now in artifacts.list()) |
| turn.end | — | the turn finished |
| error | { error } | the turn errored |
Handle a presented file live (no polling needed):
for await (const ev of app.agent.run("Make me a chart.")) {
if (ev.type === "artifact") {
const src = await app.artifacts.getBlobUrl(ev.artifact.id);
// show ev.artifact.name / src
}
}Observe the agent over a WebSocket
stream opens a read-only socket for the agent's events (the turn itself is
started by run). Returns a handle with close().
const sub = app.agent.stream({}, (ev) => {
if (ev.type === "artifact") {/* ... */}
});
// later:
sub.close();History
const msgs = await app.agent.history(); // AgentMessage[] (user + assistant only)Errors
Failed calls throw AppError with a status (HTTP code) and a stable code
(the server's error string, e.g. unauthorized, unknown_artifact,
too_many_attachments, quota_exceeded, busy).
import { AppError } from "@crediolabs/app";
try {
await app.data.get("x");
} catch (e) {
if (e instanceof AppError && e.status === 401) await app.auth.anon();
else throw e;
}Notes
- One app per origin. The token store has no app id in its key — a given origin serves exactly one app.
- Per-user isolation. Data, artifacts, and agent history are scoped to the
authenticated
uid; a user can never see another user's. - Advanced
createClientoptions:token(seed an existing token),fetch/WebSocket(inject implementations for SSR/testing),storage(custom token persistence; defaults tolocalStorage, falls back to in-memory).
