Seige SDK: sign-in, a database, files and AI for browser apps

One npm package gives a browser app user sign-in, an encrypted SQL database, file storage and AI calls, all kept in the user’s own Seige account. There is no backend to run, no project to register and no API key to manage.

Package
@seige_sdk/sdk
Version
0.7.2
Runs in
Browsers, and Node 24.22.1+
Setup
No registration or API keys

Build with an AI assistant

If an AI coding assistant writes your app, paste the instructions below into its chat or context before you describe the app. They work for a new project, which the assistant starts with Vite, and for an app that already exists. The assistant then installs the SDK, connects sign-in, data and files, and checks the result with a real save.

A complete app

app.js
import { createSeige } from "@seige_sdk/sdk";

const seige = createSeige();

async function planTrip(file) {
  await seige.from("trips").insert({ city: "Lisbon", nights: 3 });
  const { data: trips } = await seige.from("trips").select().order("city");
  await seige.files.put(`trips/${file.name}`, file);
  const { data: answer } = await seige.ai.generate({ prompt: "What should I pack?", input: trips });
  return answer;
}

document.querySelector("#plan").addEventListener("click", () =>
  planTrip(document.querySelector("#photo").files[0]),
);

That is a complete app. The first call that needs the user asks them to connect the app to their Seige account and then carries on. Tables are created the first time you insert into them. Your application supplies its interface and logic; the user’s account holds the data.

Who can use your app

Each user signs in with a Seige account. Account creation is currently open to invited usernames.

Install the SDK

npm install @seige_sdk/sdk

Use HTTPS in production; http://localhost works for development. The browser needs IndexedDB, Web Locks, WebCrypto, module workers and BroadcastChannel. The package includes TypeScript declarations and the complete SDK reference.

No registration is needed. Seige identifies your app by the website it runs on. To show your app's name on the approval screen, pass it as appName: createSeige({ appName: "Trips" }). Seige shows the name, up to 80 characters, next to the website address and uses it to name the app's new folder. The name is a label supplied by the app; the website remains the app's identity.

Registering the app in Connected apps and passing its clientId to createSeige shows the registered name instead and lets the app use several callback pages on one host. Adding a clientId to an app that already has users changes their user identifiers; read when to register first. Pass issuer to use a Seige environment other than https://account.seige.ai/oauth, such as a test environment.

Use the SDK with Vite, Next.js or webpack

The SDK runs two module workers, one for encryption and one for SQLite, and each loads a WebAssembly file. The package refers to them with new URL(…, import.meta.url), which these bundlers understand, so none of them needs SDK-specific configuration:

FrameworkTested withWhat to do
Vite 5, 6, 7 and 8 vite build and the development server Nothing. Import the SDK.
Next.js 16 (App Router), with Turbopack or webpack next build, next start and next dev Use the SDK in client components ("use client") and call it from event handlers or effects.
webpack 5 Production build Nothing. Import the SDK.
app/page.js (Next.js)
"use client";
import { useEffect, useState } from "react";
import { createSeige } from "@seige_sdk/sdk";

const seige = createSeige({ appName: "Trips" });

export default function Page() {
  const [user, setUser] = useState(null);
  useEffect(() => {
    seige.auth.user().then(({ data }) => setUser(data));
    return seige.auth.onChange(({ user }) => setUser(user));
  }, []);
  return user
    ? <p>Connected</p>
    : <button onClick={() => seige.auth.signIn()}>Sign in with Seige</button>;
}

The examples on these pages call the SDK from functions, such as event handlers. Vite’s default production build target does not allow top-level await in modules, so vite build fails on an await outside a function. Call the SDK from a function, or set build.target to "es2022" in vite.config.js.

createSeige() does no work until the first call, so creating the client at module level is safe. Make the calls themselves in the browser: in effects and event handlers, not during server rendering.

In a workspace that does not hoist the SDK into the app's own node_modules, Vite's development server can fail to load the workers, and calls return OPERATION_FAILED with message worker_unavailable. Add optimizeDeps: { exclude: ["@seige_sdk/sdk"] } to vite.config.js in that case. The page that loads the SDK should declare <meta charset="utf-8">, as the Vite and Next.js templates do.

The crypto module (about 3.1 MB, about 1 MB with gzip) loads when a call needs it, or, while no user is signed in, once the page is idle, because signing in needs it first. SQLite (about 870 KB, about 400 KB with gzip) loads with the first call that reads or writes data. seige.auth.user() itself loads neither. The SDK's JavaScript in the page is about 85 KB with gzip. Serve the WebAssembly files compressed and with long-lived caching; bundlers give them content-hashed names.

Use the primitives

Every call returns { data, error } and never throws. The exceptions are seige.sync.status() and seige.sync.resources(), which return plain values, seige.auth.onChange(), which returns a function that unsubscribes, and seige.close().

seige.from(table)
Queries the app's encrypted SQLite database: select, insert, update, upsert and delete with filters and ordering. Each call is one transaction. When Seige is reachable, the call resolves after the change is saved to Seige. While Seige is unreachable, the change is kept encrypted in this browser, the call resolves without an error, and the SDK publishes the change after reconnection.
seige.files.put(path, file)
Saves a Blob, File or Uint8Array in the app's folder. It returns once the file is encrypted and stored in this browser, and the SDK publishes it to Seige in the background. get returns a Blob; list and delete take relative paths. delete is permanent and frees the file's storage. Names, content and pending writes are encrypted.
seige.ai.generate({ prompt, input })
Generates an answer from the input your app supplies. The first call asks the user for AI access. Provider credentials stay on Seige's servers.
seige.auth.user()
Returns { data: user }, or { data: null } when no one is signed in. It never asks the user anything. The user is { id }: a stable identifier for the account that is specific to your website. It includes email and emailVerified only after the user approved signIn({ email: true }). Seige does not give apps a username or display name. signIn() asks to connect now, for example from a sign-in button; signOut() ends this browser's session and keeps the data.
seige.auth.onChange(callback)
Calls back with { user } after this tab signs in or out, after its session ends (user is null), and when another tab of the same app signs in or out. It is not called for the state at the time of subscribing; read that with seige.auth.user(). It returns a function that unsubscribes.
seige.sync.status()
Returns one of the strings synced, syncing, offline, needs_attention or reauthentication_required, not a { data, error } result. It reports synced until the app makes its first other call, and it has no change event: read it again after writes. The SDK retries encrypted pending writes on its own. seige.sync.flush() waits until every change saved in this browser has been published.
seige.close()
Stops this client and its handles, for example at the end of a test or when a component that owns the client is removed. It does not sign out and does not affect other tabs.

Handle errors by code

error is a SeigeError with a stable code, a message that names the specific reason, and a hint. The hint is written for you as the developer: it says what happened and what the app can do next. Log all three and choose the text your users see from code.

const { data, error } = await seige.from("trips").select();
if (error) console.warn(error.code, error.message, error.hint);

code is one of ACCESS_DENIED, SIGN_IN_CANCELLED, POPUP_BLOCKED, REAUTH_REQUIRED, PERMISSION_REVOKED, PERMISSION_DENIED, NETWORK_UNAVAILABLE, RATE_LIMITED, ALLOWANCE_EXCEEDED, CONFLICT, IN_USE, MIGRATION_FAILED, CONSENT_REQUIRED, UNSUPPORTED and OPERATION_FAILED, typed as SeigeErrorCode and exported at run time as ERROR_CODES. Queries also return Postgres SQLSTATE codes, such as 23505 for a unique value that already exists, PGRST116 when single() does not match exactly one row, and 20 when a query was aborted. These query codes are typed as QueryErrorCode and exported as QUERY_ERROR_CODES, so comparing error.code with a misspelled code is a type error. A missing file has no separate code: files.get() returns OPERATION_FAILED with message file_not_found. The error table gives the cause of each code and a message to show users.

How users connect your app

The first call that needs the user's data opens Seige in a small window. The user signs in if needed and approves your website's access to its own folder in their account. The call waits and continues when they approve. If the call did not come from a click, for example a read when the page loads, the page first shows a small “Continue with Seige” prompt, because browsers open windows only in response to a click. Its button, or the next click in your app that calls the SDK, opens the window. To avoid the prompt, call seige.auth.user() when the page loads and, if its data is null, make the first data call from a click. Approval is remembered in this browser, so later calls and reloads never ask again.

If the user closes the window or dismisses the page prompt, the call returns SIGN_IN_CANCELLED; if they select Cancel in the window, ACCESS_DENIED. Calls waiting for the same window receive the same result. Seige sends the sign-in result only to a window on your website, so another site cannot receive it. User identifiers are specific to your website.

The sign-in result returns to your page through window.opener. Send Cross-Origin-Opener-Policy: same-origin-allow-popups, or no COOP header; with same-origin, sign-in returns OPERATION_FAILED with message opener_policy_blocks_sign_in. A Seige window that returns no result within 10 minutes ends the call with message sign_in_timed_out. The launch checklist has a Content-Security-Policy that works with the SDK.

Sign-in uses OpenID Connect with pushed authorization requests, PKCE and DPoP-bound tokens. Each browser is its own installation of the app. The SDK generates the installation's keys in that browser and sends Seige only the public keys, with proofs bound to the app and to the browser's DPoP key. Seige never generates or stores app private keys. The account's password and master keys are never given to the app.

Seige requires a server-issued DPoP nonce in every proof sent to its OAuth endpoints. Seige's discovery document carries a current nonce, which the SDK uses for the first pushed authorization request of a sign-in. A nonce stays valid for about three minutes. When the page loaded the discovery document longer ago than that, Seige refuses the request with 400 use_dpop_nonce and a fresh DPoP-Nonce header, and the SDK repeats the request with that nonce. Browsers log that answer as a failed request; it is expected and does not mean sign-in failed.

While the Seige window is open, the SDK shows a card on your page with a connection badge, an emoji picture, and the fingerprint of the keys it sent. Seige's approval screen shows the badge and fingerprint of the keys it received. When they match, the keys reached Seige unchanged; if they differ, the user should select Cancel. Do not cover that card. seige.auth.pendingFingerprint() returns the same value for apps that show their own comparison. This detects keys replaced between your app and the approval screen. It does not protect against a compromised app origin or modified approval code.

Keys and tokens are protected in app-origin IndexedDB with a local non-extractable wrapping key. Code running in that origin can still use them. Host mutually untrusted apps on separate origins; different URL paths are not isolation boundaries.

Make your first verified remote write

This example saves a row and a file and reads both back from the copy stored in Seige. It runs as published in Seige's own tests.

first-write.js
import { createSeige } from "@seige_sdk/sdk";

// Saves a row and a file, then reads both back from Seige.
export async function firstWrite(options) {
  const seige = createSeige(options);
  try {
    const saved = await seige.from("messages").insert({ text: "Hello" }).select().single();
    if (saved.error) throw saved.error;
    const path = `notes/${crypto.randomUUID()}.txt`;
    const file = await seige.files.put(path, new Blob(["Hello"]));
    if (file.error) throw file.error;
    // A file write returns once it is saved on this device; wait for Seige.
    const synced = await seige.sync.flush();
    if (synced.error) throw synced.error;
    const { data: blob } = await seige.files.get(path);
    // Read the row from the copy saved in Seige, not this browser's cache.
    const { data: remote } = await seige.db.open("app.sqlite", { readonly: true });
    const { data: rows } = await remote.from("messages").select("text").eq("id", saved.data.id);
    await remote.close();
    const { data: user } = await seige.auth.user();
    return { user, rows, fileHead: file.data.head, text: await blob.text() };
  } finally {
    seige.close();
  }
}

Call firstWrite() from your app. Pass { issuer } for another Seige environment or { clientId } for a registered app. Each run adds one row and one file; use it as a setup check.

The success condition is a verified remote write followed by authenticated read-back. A local result or a queued upload does not prove that data reached Seige. To check this in automated tests, read Testing your app.

Query data with seige.from()

const { data: open, error } = await seige
  .from("todos")
  .select("id, title, done")
  .eq("done", false)
  .order("created_at", { ascending: false });

await seige.from("todos").insert({ title: "Pack camera" });
await seige.from("todos").update({ done: true }).eq("id", id);

The first insert into a table creates it, with an id and a created_at timestamp. A new key becomes a new column. Reading a table that does not exist yet returns no rows. true/false values read back as booleans, and objects and arrays as JSON. A misspelled column in a filter returns an error.

Filters include eq, neq, gt, lt, in, ilike, is and or(); order, range, single() and counts are supported. A query reads one table. update() and delete() require a filter. There are no access policies to write: each user's database belongs to that user. Coming from supabase-js? Read Coming from Supabase.

For constraints such as unique or required columns, define tables in SQL migration files and pass them as createSeige({ migrations }). They run once each, in order, in one transaction, and their names and checksums are recorded in the user's database so a replacement app can check the schema version. With migrations, reading or writing a table that does not exist returns 42P01: a read does not return no rows, and an insert does not create the table. An insert that omits id into a table whose primary key is a single text column named id gets a UUID, as automatic tables do. Foreign keys are enforced, and ON DELETE CASCADE and ON DELETE SET NULL apply when a row is deleted. A migration whose SQL changed after it was applied fails with MIGRATION_FAILED; add a new migration instead. The SDK reference lists every method.

Save files and handle conflicts

await seige.files.put("reports/summary.txt", new Blob(["Hello"]));
const { data: saved } = await seige.files.get("reports/summary.txt");
console.log(await saved.text());
// Before signing out, wait until Seige has every saved change.
const { error } = await seige.sync.flush();

A file write returns as soon as the file is encrypted and stored in this browser, and reads in this browser return it at once. The SDK publishes changes to Seige in the background, in order; when several saves of one file are waiting, only the newest is sent. seige.sync.flush() waits until everything saved in this browser has been published, and returns NETWORK_UNAVAILABLE while Seige is unreachable and ALLOWANCE_EXCEEDED while an upload is refused because the account is out of storage.

File names and metadata are encrypted. Updates check the remote version they started from, so a stale write becomes a conflict instead of replacing newer data. Temporary network failures keep encrypted pending writes for automatic retry.

When a pending change conflicts with a newer remote version, the SDK stops retrying that file or database and keeps the local change in its encrypted queue. Other resources continue to synchronize. seige.sync.resources() reports the resource as needs_attention with reason conflict, and reads return the remote version. seige.sync.flush() returns CONFLICT. Read a file conflict with seige.files.getConflict(path) and a database conflict with seige.db.getConflict(), which defaults to the database behind seige.from(). Resolve with "discard-local", or with a function whose result is written on top of the remote version. A database resolver receives a writable copy of the remote database and a read-only view of the local one, and copies the local rows to keep. There is no option to overwrite the remote version.

const { data: conflict } = await seige.files.getConflict("reports/summary.txt");
if (conflict)
  await conflict.resolve(async ({ remote, pending }) =>
    merge(remote.content, pending.content),
  );
const { data: dbConflict } = await seige.db.getConflict();
if (dbConflict)
  await dbConflict.resolve(async ({ remote, pending }) => {
    for (const row of await pending.all("SELECT * FROM todos"))
      await remote.run(
        "INSERT OR IGNORE INTO todos (id, created_at, title) VALUES (?, ?, ?)",
        row.id, row.created_at, row.title,
      );
  });

How data is stored and synchronized

Queries run in memory. Each change is encrypted in the browser and sent to Seige with a signed receipt before the call resolves. During an outage, the SDK keeps encrypted pending work and retries after reconnection. A database has one writer at a time. A browser takes the writer lease when it writes and hands it back once the write is published, and a page that closes asks Seige to release it. Reading never waits for another browser. A write made while another browser holds the lease is stored in this browser and waits for it; if the other browser stopped without handing it back, for example after a crash, the lease lasts at most 30 seconds after its last write. The write then applies to the newest version, or fails with IN_USE after about 45 seconds without saving. If the page closes during the wait, the write runs the next time the app calls the SDK in this browser.

Databases are limited to 32 MiB. Query results are limited to 10,000 rows and 8 MiB. seige.db.open(name, { readonly: true }) reads the copy stored in Seige without taking the writer; its handle offers from(), SQL reads through all, get and batch, and export(), which returns the database file. A write through it returns PERMISSION_DENIED with message read_only.

Node applications can use createSeigeNode from @seige_sdk/sdk/node with Node 24.22.1 or later. It takes explicit OAuth and storage connections instead of a browser session and offers the same queries. Its database is a local file that contains plaintext; keep it in a private directory. See the Node connection and database reference.

Add AI generation

const { data, error } = await seige.ai.generate({
  prompt: "Summarize this record in one sentence.",
  input: { title: "Weekend trip", destination: "Lisbon" },
  maxOutputTokens: 256
});
if (data) console.log(data.text);

The first call asks the user for AI access. If they decline, the call returns ACCESS_DENIED with message ai_access_denied, and the app stays connected for data and files. This is a standard model endpoint, not an agent: it has no tools and no access to the account. It sees only the input your application supplies, and your app has only the data the user gave it. Pass an AbortSignal as signal to cancel. Generation is not retried automatically. Prompts and input are processed remotely in plaintext; storage encryption does not make inference end-to-end encrypted.

App AI permits 20 requests per app per hour and 100 across apps per account, with one active request per account. Over a limit, the call returns RATE_LIMITED; when a request exceeds the user's available AI credit, ALLOWANCE_EXCEEDED. Pending requests reserve credit until settlement. AI credit has no automatic hourly reset. Output defaults to 2,048 tokens and can be set from 1 to 4,096. seige.ai.usage() reports request counts, the requests remaining and when the window resets, without storing prompt or answer content. It never opens the Seige window: before the user approved AI access it returns CONSENT_REQUIRED with message ai_consent_required.

Keep access scoped to approved data

Files and database changes are encrypted on the client before upload. Independent folder keys and owner-signed grants restrict an app to the folders the user approved. The SDK verifies encrypted records and server commit receipts before acknowledging writes.

A token alone does not provide decryption keys. An approved app can read and keep copies of data it receives. Read the encryption and protection documentation for the account's security model.

Disconnect apps without deleting their data

Users disconnect apps in Connected apps, which lists each app with its website. This stops further access and replaces the affected folder keys. Files and records stay in the account. The app's next call asks the user to connect it again. Disconnecting cannot erase copies an app already obtained.

A replacement app can read retained data after the user approves the folder. It must understand the stored format, so use portable formats for attachments and keep your table structure stable or documented in migrations.

Reference and integration guide

Server-side SQL, hosted functions and background jobs are not part of the SDK. Use the documented SDK exports rather than Seige's account endpoints.