TypeScript SDK — @tallpond/sdk

The browser/JS client over the gateway API. It runs the OAuth/PKCE flow, sends credentialed requests, and exposes a chainable query builder. Session tokens live in httpOnly cookies, so the SDK can only observe "signed in or not" — never the token.

Setup#

ts
import { createClient } from "@tallpond/sdk";

// On the hosted platform: no arguments. The gateway injects
// window.__TALLPOND__ = { gatewayUrl, clientId } into served HTML — the
// serving domain decides which app this is, not the bundle. Hosted apps get
// gatewayUrl: "/_osg", the same-origin edge mount.
export const tallpond = createClient();

Local dev and self-hosted setups pass config explicitly:

ts
export const tallpond = createClient({
  gatewayUrl: "http://localhost:3000",
  clientId: "your-oauth-client-id",
  redirectUri: window.location.origin, // optional
});

The app never configures the issuer directly — signIn() redirects to the gateway's /auth/authorize, which 302s to Ory. See authentication.md.

gatewayUrl may be absolute ("https://api.example.com") or, in browsers, relative ("/_osg"). Relative URLs are resolved against window.location.origin; non-browser callers must pass an absolute gateway URL.

Non-browser clients#

Two bearer modes replace the cookie session when there is no browser:

ts
// A dev-minted test session (`tallpond test-session`). Static: a 401 is final.
createClient({ gatewayUrl, accessToken });

clientId is optional here (the gateway derives the app from the token), no cookies are sent, and no CSRF header is required.

Every method returns a promise and throws TallpondError ({ message, status, data, requestId }) on failure — see error codes. requestId is worth surfacing in any error report; it's what a gateway log line keys on.

auth#

ts
await tallpond.auth.signIn(); // redirect to Ory
await tallpond.auth.handleRedirectCallback(); // on ?code return; resolves a Session | null
const { authenticated, userId } = await tallpond.auth.getSession();
await tallpond.auth.refreshSession(); // → boolean
await tallpond.auth.signOut();

Data#

Private tables#

ts
tallpond.table("notes"); // → TableQuery (private scope)

Resource handle#

ts
const r = tallpond.resource(roomId);
r.table("messages"); // → TableQuery (resource scope)
r.files("photos"); // → FileHandle (resource scope)
r.members; // → membership methods (below)
r.rooms; // → create/list rooms (assignment, below)
r.room(id); // → one room: its tables, files and grants
await r.get(); // → ResourceInfo
r.members.live(); // → LiveSubscription<MembershipChange> — this resource's roster, live

No r.delete() — deleting a resource is dashboard-only (destructive for every member, no undo), not an SDK call. See resources.md.

Membership, live#

ts
// Your own membership across every resource in the app — new invites, accepted
// requests, role changes, removals, from any device. This is the feed that
// answers "was I just invited?"
tallpond.resources.live();

Both r.members.live() and tallpond.resources.live() return the same LiveSubscription<MembershipChange> every other .live() returns — see resources.md for the row shape and realtime.md for why membership needs its own rule.

Resource management#

ts
await tallpond.resource.create("room", { name: "General" }); // → ResourceInfo
// Each row carries currentMember { role, state } — the caller's own membership —
// so role-aware UI needs one request, not a members.list() per resource.
await tallpond.resource.list({ type: "room" }); // → ResourceInfo[]  (active memberships)
await tallpond.resource.browse({ type: "room", query, limit, cursor }); // → Page<ResourceInfo>
await tallpond.resource.static("feed", "global"); // → ResourceInfo
await tallpond.resource.invitations({ type: "room" }); // → InvitationInfo[]  (my pending invites)

Federated read#

ts
tallpond.resources({ type: "room" }).table("messages"); // → TableQuery (federated, read-only)

Atomic batch#

ts
await tallpond.batch(
  tallpond.table("notes").insert({ title: "a" }),
  tallpond.table("notes").insert({ title: "b" })
); // pass unawaited builders

TableQuery#

Chainable and thenableawaiting it runs the query. Building selectors:

ts
await tallpond.table('notes')
  .select('id, title, createdAt')      // projection; default '*'
  .eq('archived', false)
  .gt('rank', 3).gte().lt().lte()
  .in('status', ['a', 'b'])
  .like('title', 'draft%')
  .isNull('deletedAt')
  .orderBy('createdAt', 'desc')
  .limit(20)
  .after(cursor)                       // opaque cursor pagination

Mutations & terminals

ts
await tallpond.table("notes").insert({ title: "Hi" });
await tallpond.table("notes").update({ title: "Edited" }).eq("id", id); // filter required
await tallpond
  .table("notes")
  .upsert({ id, title: "x" }, { onConflict: ["id"] });
await tallpond.table("notes").delete().eq("id", id); // filter required

await tallpond.table("notes").count(); // → number
await tallpond.table("notes").eq("id", id).single(); // → Row  (404 if none, 409 if >1)
await tallpond.table("notes").eq("id", id).maybeSingle(); // → Row | null
await tallpond.table("notes").select().limit(20).page(); // → { rows, nextCursor }

Shared-table verbs (resource scope)

ts
await tallpond.resource(id).table("photos").link(rowId); // publish an existing owned row here
await tallpond.resource(id).table("photos").unlink(rowId); // withdraw from this context (moderation)
await tallpond.table("photos").contexts(rowId); // → string[] of resource ids (private scope)

Thenable reads resolve to Row[]; the next-page cursor is also attached as .nextCursor on the returned array.

Resources & membership#

ts
const m = tallpond.resource(roomId).members;
await m.invite("user_123", { role: "writer" }); // → { ok, state: 'invited' }
// A shareable link for someone with no account, or whose id you cannot name.
// Single use, 7 days, admin+. You never learn who claims it until they do.
await m.createInviteLink({ role: "writer" }); // → { url, role, expiresAt }
await m.request({ role: "writer" }); // → { ok, state: 'requested' }
await m.accept(); // accept my invite (or accept(userId) as admin)
await m.reject(); // reject(userId) as admin
await m.join(); // public self-join → { ok, state: 'active' }
await m.leave();
await m.setRole("user_123", "admin"); // active member or pending invite
await m.remove("user_123"); // remove an active member or cancel a pending invite
await m.list(); // → MemberInfo[]

Rooms#

A room partitions one resource. members is consent — who is in at all; rooms is what role they hold over which content. See resources.md.

ts
const ws = tallpond.resource(workspaceId);

await ws.rooms.create({ name: "Leadership" }); // → RoomInfo
await ws.rooms.list(); // rooms you hold, with your role

const room = ws.room(roomId);
await room.grants.set("user_123", "writer"); // admin only
await room.grants.remove("user_123");
await room.grants.list(); // the ACL — admin only

room.table("documents").select(); // that room alone
room.files("photos").upload(path, blob); // uploads land in that room

tallpond.rooms.live(); // granted / role changed / revoked, app-wide

Reads through the resource scope span every room you can read; writes target the default room. moveRoom relocates content between rooms and needs admin where it currently is:

ts
await ws.table("documents").moveRoom([rowId], roomId);
await ws.files("photos").moveRoom([path], roomId);

Files#

ts
const b = tallpond.files("avatars"); // private: owner-only, no rooms
const rb = tallpond.resource(id).files("photos"); // resource: room-partitioned

await b.upload("me.png", blob, { contentType: "image/png", upsert: true });
await b.download("me.png"); // → Blob
b.url("me.png"); // same-origin cookie-authed URL, for <img src>
await b.list("prefix/"); // → FileMetadata[]
await b.metadata("me.png");
await b.updateMetadata("me.png", { cacheControl: "public, max-age=3600" });
await b.move("old.png", "new.png"); // metadata-only
await b.copy("a.png", "b.png"); // new blob, charged as a write
await b.delete("me.png");

await rb.link(path); // publish one of your private files here
await rb.unlink(path); // withdraw it; the file survives

Full semantics, pricing and the room rules: files.md.

AI#

ts
const res = await tallpond.ai.chat({
  model: "openai/gpt-4o",
  messages: [{ role: "user", content: "Hi" }],
});

const { models } = await tallpond.ai.models(); // catalog: id, rates, output limits

Functions#

ts
// Runs a deployed function as the signed-in user; resolves its return value.
const move = await tallpond.functions.invoke("submitMove", {
  resourceId: room.id,
  args: { move: { to: "e4" } },
});

The function executes with exactly the caller's authority and bills the caller; { role, via } schema rules make it the mandatory write path. Failures reject with TallpondError (function_error, function_limit, function_unavailable). See functions.md.

Wallet & apps (dashboard)#

ts
await tallpond.wallet.get(); // → Wallet
await tallpond.wallet.topup(25); // → { url }
await tallpond.apps.list(); // → { apps }
await tallpond.apps.setCap("app_client_id", 20); // USD/month; 0 blocks

Escape hatch#

ts
await tallpond.gateway.request<T>("/v1/…", { method, headers, body });

Sends a credentialed request with the session cookie + CSRF header and a single transparent refresh-on-401 retry.

Type safety (typegen)#

Row types are loose (Record<string, unknown>) until an app augments the Register interface via a generated declare module '@tallpond/sdk'. The SDK is fully usable without the build step — just not statically typed to your schema.