Reference

JavaScript SDK reference

The Ellivate JS SDK exposes seven primitives: `ellivate.get/set/del/list` (persistence), `ellivate.blob` (binary storage), `ellivate.collection` (structured lists with where-filters), `ellivate.reason` (LLM calls with provider/model picking handled for you), `ellivate.schedule` (cron / one-shot / delayed handlers), `ellivate.notify` (push), and `ellivate.viewer` (identity). Same API in bundled, vanilla, and mobile runtimes — the SDK auto-detects where it's running.

Import

Bundled apps (Next.js, Vite, Node)

import { ellivate } from "../ellivate-client";
// Or whatever relative path reaches the project root

Vanilla HTML

<script src="ellivate-client.global.js" defer></script>
<script src="ellivate-config.js" defer></script>
<script>
  // window.ellivate is ready after DOMContentLoaded
</script>

KV storage — ellivate.get/set/del/list

get<T>(key: string): Promise<T | null>

Read a value. Returns null if the key doesn't exist or the viewer lacks permission to read it. Never throws for missing keys.

const items = await ellivate.get<string[]>("shared:items");
if (items) {
  // ...
}

set<T>(key: string, value: T): Promise<void>

Write a value. value is anything JSON-serializable. Throws on 401 (viewer not allowed to write) so silent failures never look like success.

await ellivate.set("shared:items", ["a", "b", "c"]);
await ellivate.set("personal:settings", { theme: "dark" });

del(key: string): Promise<void>

Remove a key. Idempotent — removing a missing key doesn't throw.

await ellivate.del("shared:items");

list(prefix: string): Promise<string[]>

Return all keys matching a prefix. Useful for iterating over dynamic collections.

// All items under 'shared:tasks:'
const keys = await ellivate.list("shared:tasks:");
// keys: ["shared:tasks:abc", "shared:tasks:def", ...]

Collections — ellivate.collection(name)

The "many similar things" primitive — a sibling to KV. Use this when the shape is a list of items you'd want to filter, sort, or paginate (a roster, a feed, a list of expenses) rather than a single value. Each item is a JSON object with platform-managed id, createdAt, updatedAt fields.

collection<T>(name: string): EllivateCollection<T>

Open a handle for a collection. The collection is created on first add() — there is no separate provisioning step. Same scope rule as KV: the prefix on the name decides who sees it. No prefix or personal: is private per viewer (the default); ellivate.collection("shared:trips") is shared with the group; "public:trips" is readable by anyone. There is no scope argument — the prefix is it.

add<I>(value: I): Promise<T>

Add an item. The platform assigns id, createdAt, updatedAt and returns the full record.

const things = ellivate.collection<Thing>("things");          // private per viewer
const trips = ellivate.collection<Trip>("shared:trips");      // shared with the group

const item = await things.add({ name: "milk", category: "dairy" });
// → { id: "ck1...", name: "milk", category: "dairy", createdAt: "...", updatedAt: "..." }

list(opts?: ListOpts): Promise<ListResult<T>>

Return a page of items. Default sort is newest-first. Pass a cursor from a previous result to fetch the next page.

// Newest 100, no filter:
const { items, nextCursor } = await things.list();

// Filtered + paginated:
const dairy = await things.list({
  where: { category: "dairy", inStock: true },
  sort: "-createdAt",
  limit: 50,
});

// Next page:
if (dairy.nextCursor) {
  const more = await things.list({ where: { category: "dairy" }, cursor: dairy.nextCursor });
}

get(id: string): Promise<T | null>

Fetch a single item by id. Returns null for missing items.

update(id: string, patch: Record<string, unknown>): Promise<T>

Shallow-merge a patch into an existing item. Untouched fields are preserved; updatedAt bumps.

await things.update(item.id, { category: "dairy-and-cheese" });
// other fields untouched, updatedAt refreshed

replace(id: string, value: Record<string, unknown>): Promise<T>

Replace an item's value entirely. id and createdAt are preserved; updatedAt bumps. Anything not in value is lost.

delete(id: string): Promise<void>

Delete an item. Idempotent — deleting a missing item does not throw.

Type definitions:

type CollectionItem = {
  id: string;
  createdAt: string;
  updatedAt: string;
} & Record<string, unknown>;

type ListOpts = {
  /** Top-level field equality, e.g. { category: "dairy", bought: false }. */
  where?: Record<string, unknown>;
  /** "createdAt" (oldest first) | "-createdAt" (newest first, default). */
  sort?: "createdAt" | "-createdAt";
  /** Default 100, max 500. */
  limit?: number;
  /** Opaque cursor from a previous list() result. */
  cursor?: string;
};

type ListResult<T> = {
  items: T[];
  nextCursor: string | null;
};

v1 limits: per-item value capped at 256KB (same as KV); list() returns at most 500/page; where supports top-level equality only; sort is createdAt-only. Field names id, createdAt, updatedAt are reserved.

Blob storage — ellivate.blob

For binary data (photos, audio, documents). Use this — not ellivate.set — for anything you would otherwise base64-encode. KV is capped at 256KB per value; blobs hold up to 25MB and are served from a CDN. See blob storage for the full model.

put(bytes: Blob | ArrayBuffer | ArrayBufferView, opts?: { mimeType?, scope? }): Promise<BlobUploadResult>

Upload bytes. Returns a metadata record with a url field — the canonical reference to embed in <img>, store in KV, or pass back to get / del.

const file = input.files[0];
const result = await ellivate.blob.put(file, { mimeType: file.type });
// { url, blobId, mimeType, sizeBytes, scope, createdAt }

await ellivate.set("photos/" + slug, { url: result.url, caption });

get(url: string): Promise<Blob | null>

Fetch the bytes for a blob URL. Returns a web Blob, or null if the blob is gone. For most apps you don't need this — embed the URL directly in <img src> and let the browser fetch.

list(opts?: { scope? }): Promise<BlobEntry[]>

List blobs in the current scope. Each entry has the same shape as put's return value.

del(url: string): Promise<void>

Delete a blob by URL. Idempotent on missing blobs. Only the original uploader can delete PERSONAL blobs; SHARED blobs can be deleted by any group member.

Type definitions:

type BlobScope = "PERSONAL" | "SHARED" | "PUBLIC";

type BlobUploadResult = {
  url: string;
  blobId: string;
  mimeType: string;
  sizeBytes: number;
  scope: BlobScope;
  createdAt: string;
};

type BlobEntry = BlobUploadResult; // identical shape

Reasoning — ellivate.reason

Single-shot LLM completion, proxied server-side; the published app never sees a provider key. Billed to the caller — the viewer who triggered the call, not the app owner. The owner is charged only for their own usage and for owner-context calls with no viewer (scheduled / background fires). A per-app monthly soft cap (default $5) still applies. See Pricing & credits for the caller-pays model.

reason<T>(opts: ReasonOpts): Promise<string | T>

Two modes, picked by whether you pass schema:

  • No schema Promise<string>, the model's text output.
  • With schema (a JSON Schema object) → Promise<T>, a parsed value validated against the schema. Type the call with the generic — reason<{ items: ... }>(...) — and you get a typed object back, no JSON.parse.

Pass { raw: true } as the second argument to get the full result with usage metadata (text in string mode, data in schema mode).

// Simple — returns a string.
const summary = await ellivate.reason({
  prompt: `Summarize: ${text}`,
});

// Override the model when default isn't right (default = cheapest
// available across the providers the owner has keys for).
const draft = await ellivate.reason({
  prompt: `Draft a warm reply to: ${thread}`,
  system: "You write warm, concise replies.",
  model: "claude-sonnet-4-6",
  maxTokens: 400,
});

// Structured output — pass a JSON Schema, get a parsed, validated
// value back. The generic types the return.
const { items } = await ellivate.reason<{
  items: { category: string; item: string }[];
}>({
  prompt: `Turn this grocery dump into a categorized list: ${dump}`,
  schema: {
    type: "object",
    properties: {
      items: {
        type: "array",
        items: {
          type: "object",
          properties: {
            category: { type: "string" },
            item: { type: "string" },
          },
          required: ["category", "item"],
        },
      },
    },
    required: ["items"],
  },
});
// items is already a typed array — no JSON.parse, no regex extraction.

// Full result with usage metadata. text in string mode, data in
// schema mode.
const full = await ellivate.reason({ prompt: "..." }, { raw: true });
// {
//   text: string,        // string mode only
//   data?: unknown,       // schema mode: the parsed value
//   model: string,
//   provider: "anthropic" | "openai" | "google",
//   inputTokens: number,
//   outputTokens: number,
//   estimatedCents: number,
//   monthToDate: { yearMonth, inputTokens, outputTokens, estimatedCents, capCents },
//   warning?: "approaching_cap" | "exceeded_cap"
// }

// Attach images. URL form lets the provider fetch the bytes; base64
// form delivers them inline. Up to 20 images per call. Supported
// media types: image/jpeg, image/png, image/gif, image/webp.
const total = await ellivate.reason({
  prompt: "Return only the total amount as a number — no currency, no commas.",
  images: [{ url: receiptUrl }],
});

const caption = await ellivate.reason({
  prompt: "Write a short, friendly caption for this photo.",
  images: [{ base64, mediaType: "image/jpeg" }],
});

Images: attach pictures via images. Each entry is either { url } (provider fetches the bytes) or { base64, mediaType } (pre-encoded by your code). Up to 20 images per call. Google's Gemini accepts base64 only — pass { base64, mediaType } or pick a Claude / OpenAI model for URL-form images.

Structured output — pass a schema, get parsed data

When you need JSON back, don't ask for it in the prompt and parse the text — pass a schema (a JSON Schema object). The SDK uses the provider's native structured-output mode (Anthropic tool-use, OpenAI json_schema, Gemini responseSchema), validates the result against your schema, and returns the parsed value. No fence-stripping, no regex extraction, no JSON.parse that throws on a stray prose sentence.

Wrong (asks for JSON in the prompt, parses the text — truncates silently when the output is long):

const raw = await ellivate.reason({
  prompt: `Return JSON: { items: [...] } for ${dump}`,
  maxTokens: 2000,
});
const { items } = JSON.parse(extractJson(raw)); // SyntaxError when cut off mid-array

Right (schema mode — typed, validated, parsed):

const { items } = await ellivate.reason<{
  items: { category: string; item: string }[];
}>({
  prompt: `Turn this grocery dump into a categorized list: ${dump}`,
  schema: {
    type: "object",
    properties: {
      items: {
        type: "array",
        items: {
          type: "object",
          properties: { category: { type: "string" }, item: { type: "string" } },
          required: ["category", "item"],
        },
      },
    },
    required: ["items"],
  },
});

Token defaults + truncation

Output defaults are generous so a normal call doesn't get clipped: 4096 tokens for string mode, 8192–16384 for structured mode (a JSON object costs more tokens than the same answer in prose). Override with maxTokens when you know the shape is bigger.

If the model hits the cap before finishing, reason now throws a clear error rather than handing back a half-formed answer. Structured mode: “output too large to finish — narrow the request or split it into smaller calls.” String mode: “output was cut off — raise maxTokens, narrow the request, or split it.” A silently truncated string that your code then tried to JSON.parse was the old failure mode; the throw makes it loud.

Out of credits: if the caller has no AI credits and no provider key, reason throws on an HTTP 402 AI_BUDGET_REQUIRED response — the inference runs on the caller's budget and there is no silent fallback to the owner's credits. Catch it to surface a friendly “you're out of AI credits” state instead of a broken screen.

Models:

Model idProviderTier
gemini-2.5-flashGooglecheap default
gemini-2.5-proGooglepremium
gpt-5-miniOpenAIcheap default
gpt-5OpenAIpremium
claude-haiku-4-5Anthropiccheap default
claude-sonnet-4-6Anthropicmid
claude-opus-4-7Anthropicpremium

Model name picks the provider (claude-* → Anthropic, gpt-* → OpenAI, gemini-* → Google). Omit model to let the platform pick the cheapest cheap-default across the providers the owner has keys configured for.

Soft cap: per-app monthly cap, default $5. When crossed, the response includes a warning field and the SDK emits console.warn. The call is never refused — soft only. Edit the cap per app in account settings.

Type definitions:

type ReasonOpts = {
  /** What the model should do. v1 is single-shot — no chat history. */
  prompt: string;
  /** Optional system instructions. */
  system?: string;
  /** Optional model id override; defaults to platform-picked cheapest. */
  model?: string;
  /** Cap on output length. Default 4096 (string) / 8192–16384 (schema mode). */
  maxTokens?: number;
  /** Up to 20 images: { url } or { base64, mediaType }. */
  images?: { url?: string; base64?: string; mediaType?: string }[];
  /**
   * JSON Schema describing the desired output. When set, reason()
   * returns the parsed, validated value instead of a string.
   */
  schema?: Record<string, unknown>;
};

type ReasonUsage = {
  yearMonth: string;
  inputTokens: number;
  outputTokens: number;
  estimatedCents: number;
  capCents: number | null;
};

type ReasonResult<T = unknown> = {
  text: string;        // string mode
  data?: T;            // schema mode: the parsed, validated value
  model: string;
  provider: "anthropic" | "openai" | "google";
  inputTokens: number;
  outputTokens: number;
  estimatedCents: number;
  monthToDate: ReasonUsage;
  warning?: "approaching_cap" | "exceeded_cap";
};

State-aware reason — wire your tool's data into the prompt

The single most common reason-related failure: a chat / coach / advisor surface ships, the user asks a question about their own data in the tool, and the model says “I can't see that.” The model only sees the prompt text — it has no idea what's in the tool's KV / Collections / blobs unless you put it there.

For any question whose answer depends on something stored in the tool, fetch the relevant state and pass it via the system prompt. For pure transformations (translate, format, generate from input) — skip the wiring; tool state isn't needed.

Wrong (state-aware question, no context):

async function ask(userMessage: string) {
  return await ellivate.reason({
    prompt: userMessage,
    system: "You are a lacrosse coach.",
  });
}

Right (fetch what the question implies, attach as context):

async function ask(userMessage: string, todayId: string) {
  const today = await ellivate.collection("sessions").get(todayId);
  const drills = await ellivate.collection("drills").list({
    where: { id: { in: today.drillIds } },
  });

  return await ellivate.reason({
    system: `You are a lacrosse coach.

The athlete's session today (${today.date}):
${JSON.stringify(today, null, 2)}

Drills assigned:
${JSON.stringify(drills, null, 2)}

Answer using this context.`,
    prompt: userMessage,
  });
}

For multi-turn chat surfaces, re-fetch tool state on every turn. State changes between turns; cached context goes stale. The cost is small; the alternative is the bug.

The line: does the answer depend on something stored in the tool, or only on the prompt text? If only the prompt → skip the context wiring. If anything stored → fetch it and attach.

Time — ellivate.schedule

Scheduled HTTP callbacks. The platform calls back to the app at the scheduled time — the app's existing routes handle the fire. Three create verbs (at / cron / in) plus list / get / cancel. Owner-billed; per-app caps prevent runaway.

schedule.at(opts: ScheduleAtOpts): Promise<ScheduleCreated>

One-time fire at a specific UTC datetime.

const r = await ellivate.schedule.at({
  when: "2026-12-25T08:00:00Z",
  handler: "/api/birthday-reminder",
  data: { person: "Alice" },
});
// → { id: "ck1...", nextFireAt: "2026-12-25T08:00:00.000Z" }

schedule.cron(opts: ScheduleCronOpts): Promise<ScheduleCreated>

Recurring fire on a 5-field cron expression. Evaluated in the optional timezone (IANA name); when omitted the platform falls back to the owner’s profile timezone (auto-detected from device) and then to UTC. Pass timezone whenever the user picked a wall-clock time. Minimum interval 1 minute.

await ellivate.schedule.cron({
  cron: "0 9 * * 1",            // every Monday at 9am in the named tz
  timezone: "America/New_York",
  handler: "/api/weekly-digest",
});

schedule.in(opts: ScheduleInOpts): Promise<ScheduleCreated>

One-time fire after a relative delay. Fields are summed.

await ellivate.schedule.in({
  days: 7,
  handler: "/api/follow-up",
  data: { jobId: "abc" },
});

schedule.list(opts?: ScheduleListOpts): Promise<ScheduleListResult>

Page through schedules for the current app. Optional status filter (ACTIVE / PAUSED / COMPLETED / CANCELLED).

schedule.get(id: string): Promise<ScheduleRecord | null>

Fetch a single schedule by id. Returns null if missing.

schedule.cancel(id: string): Promise<void>

Soft-cancel: status flips to CANCELLED and nextFireAt clears. The worker checks status before firing, so an in-flight queue job is skipped.

What the handler receives

When a schedule fires, the platform POSTs the handler with body { scheduleId, firedAt, data } and headers:

  • X-Ellivate-Schedule-Fire: true — distinguishes from regular requests
  • X-Ellivate-App-Id: <appId> — same as other primitive calls
  • X-Ellivate-Viewer-Token: <token> — only when the schedule was created with a viewer in scope

Return 2xx quickly (within 30s). 5xx / timeout retries up to 3 times with 1m / 5m / 30m backoff. 4xx is treated as your bug — no retry.

Constraints

  • All times UTC. Cron expressions UTC.
  • Min interval: 1 minute.
  • Per-app active cap: 1000 schedules.
  • Per-app fire rate: 1000/hour. Soft — over-cap fires get re-enqueued with a delay (a few minutes), never refused.
  • Auto-pause after 5 consecutive failures. The schedule flips to PAUSED; resume from the dashboard.
  • data payload capped at 16KB.
  • Republish caveat: schedules are tied to the app's id; republishing creates a new app id and orphans existing schedules.

Type definitions:

type ScheduleType = "ONE_TIME" | "CRON" | "RELATIVE";
type ScheduleStatus = "ACTIVE" | "PAUSED" | "COMPLETED" | "CANCELLED";
type ScheduleFireStatus = "SUCCESS" | "FAILED" | "TIMEOUT";

type ScheduleAtOpts = {
  when: string | Date;             // UTC datetime, must be future
  handler: string;                  // path on the app, starts with "/"
  data?: Record<string, unknown>;   // ≤16KB JSON-serialized
};
type ScheduleCronOpts = { cron: string; handler: string; data?: Record<string, unknown>; timezone?: string };
type ScheduleInOpts = {
  seconds?: number; minutes?: number; hours?: number; days?: number;
  handler: string; data?: Record<string, unknown>;
};
type ScheduleCreated = { id: string; nextFireAt: string };
type ScheduleRecord = {
  id: string;
  type: ScheduleType;
  status: ScheduleStatus;
  handler: string;
  data: Record<string, unknown>;
  cron: string | null;
  nextFireAt: string | null;
  lastFire: { firedAt: string; status: ScheduleFireStatus; durationMs: number | null; error: string | null } | null;
  consecutiveFailures: number;
  viewerUserId: string | null;
  createdAt: string;
  updatedAt: string;
};

Notifications — ellivate.notify

Send a push notification to an Ellivate user. Delivery rides on the Expo push tokens the mobile shell already registered — your app never touches a native API. See Notifications for the full mental model.

notify(opts: NotifyOpts): Promise<NotifyResult>

Server-only. Calling ellivate.notify from a 'use client' component or vanilla browser script throws synchronously, and the publish-time scanner flags the same pattern in source. Route the call through a server action / server component / route handler.

// app/actions.ts — server action.
"use server";
import { ellivate } from "../ellivate-client";

export async function pingMe() {
  await ellivate.notify({
    title: "Reminder",
    body: "Time to refill your prescription.",
    deepLink: "/refills/123",
  });
}

Targeting (to):

toRecipient
omittedThe current viewer (most common case).
"owner"The app's owner.
{ username: "alice" }By Ellivate username. Must be the owner OR have the app shared with them.
{ userId: "..." }By user ID.
{ email: "..." }By email. Matches both primary and Apple-relay addresses.
{ spaceId: "..." }Posts into a specific Space's chat, rendered as a card. The tool must already be attached to that Space.
{ space: "current" }Posts into the Space the tool was opened from — resolved server-side from the viewer token. Loud-fails with a 400 if the tool wasn't opened from a Space. Prefer this over a hardcoded spaceId; it stops tools from posting into the wrong Space.

Posting to a Space — the card payload. When you target a Space ({ spaceId } or { space: "current" }), the notification renders as a card in the Space chat. Pass an optional card to give it structure — labeled fields, a tappable actionPath / actionLabel, and a thumbnailUrl. The card payload is ignored for non-Space targets.

// Post a card into the Space this tool was opened from.
await ellivate.notify({
  title: "Trip booked",
  body: "Friday 7pm at Lazy Bear.",
  to: { space: "current" },
  card: {
    fields: [
      { label: "When", value: "Fri 7:00 PM" },
      { label: "Party", value: "4" },
    ],
    actionPath: "/reservations/42",   // path inside the app
    actionLabel: "View reservation",
    thumbnailUrl: "https://example.com/lazy-bear.jpg",
  },
});

Rate limits: 5/hour and 50/day per (app, sender, recipient) pair; 100/hour per app across all recipients. Over-quota throws with retryAfterSeconds in the message.

Persistence + badges. Every notify also persists a record that drives an in-app red bubble on the recipient's tile (mobile home grid + web dashboard) and the native app-icon badge count. Both clear when the recipient opens the tool — no extra call required. Because each fire is loud (push + badge), reserve notify for events the user should look at when they get a moment; for noisier state changes, write to KV or a Collection and let the user pull instead.

Type definitions:

type NotifyTo =
  | "owner"
  | { userId?: string; username?: string; email?: string }  // a specific user
  | { spaceId: string }                                      // a specific Space's chat
  | { space: "current" };                                    // the Space this tool was opened from

type NotifyCard = {
  fields?: { label: string; value: string }[];
  actionPath?: string;     // path inside the app, must start with "/"
  actionLabel?: string;
  thumbnailUrl?: string;
};

type NotifyOpts = {
  title: string;        // ≤120 chars
  body: string;         // ≤500 chars
  to?: NotifyTo;        // omitted = current viewer
  deepLink?: string;    // path inside the app, must start with "/"
  card?: NotifyCard;    // honored only when targeting a Space
  data?: Record<string, unknown>;
};

type NotifyResult = {
  delivered: number;
  skipped: number;
  throttled: number;
};

OAuth integrations — ellivate.connection(name)

Returns a typed connection handle whose .fetch() and .fetchJson() helpers attach a valid, auto-refreshing OAuth access token to every request. The user authorizes once in the Ellivate shell; every tool that uses the integration just works. See Connections & integrations for the full mental model.

connection(integration: IntegrationKey, options?: ConnectionOpts): EllivateConnection

Synchronous and cheap — just returns a handle. The token-fetch and provider API call happen on the first .fetch() / .fetchJson(). Throws synchronously only on an unknown integration key.

import { ellivate, EllivateConnectionNeeded } from "../ellivate-client";

const cal = ellivate.connection("google-calendar");

try {
  const event = await cal.fetchJson(
    cal.apiBase + "/calendars/primary/events",
    {
      method: "POST",
      body: JSON.stringify({
        summary: "Lunch",
        start: { dateTime: "2026-06-01T12:00:00-07:00" },
        end:   { dateTime: "2026-06-01T13:00:00-07:00" },
      }),
    },
  );
} catch (err) {
  if (err instanceof EllivateConnectionNeeded) {
    // Surface a "Connect your Google account" UI
  }
}

Type definitions:

type IntegrationKey =
  | "google-calendar"
  | "google-drive"
  | "google-sheets"
  | "slack"
  | "strava"; // more shipping over time

type ConnectionScope = "PERSONAL" | "SHARED";

type ConnectionOpts = {
  /** PERSONAL (default) = per-viewer; SHARED = builder-owned. */
  scope?: ConnectionScope;
  /** Override the integration's default OAuth scopes. Rarely needed. */
  oauthScopes?: string[];
};

interface EllivateConnection {
  integration: string;
  provider: string;
  scope: ConnectionScope;
  oauthScopes: string[];
  /** Base URL for the integration's API. */
  apiBase?: string;
  /** Authenticated fetch — auto-refresh on 401. */
  fetch(input: string, init?: RequestInit): Promise<Response>;
  /** Convenience: authedFetch + JSON parse + throw on non-2xx. */
  fetchJson<T = unknown>(input: string, init?: RequestInit): Promise<T>;
}

class EllivateConnectionNeeded extends Error {
  integration: string;
  provider: string;
  providerDisplayName: string;
  scope: ConnectionScope;
  oauthScopes: string[];
  /** Set when an existing connection is missing some of the required scopes. */
  missingScopes?: string[];
}

PERSONAL vs SHARED

Defaults to PERSONAL — each viewer connects their own account. Pass scope: "SHARED" only when the tool operates on the builder's account for every viewer (e.g. a family meal planner writing to the family calendar). SHARED-scoped connections block publish until the builder has connected; PERSONAL connections resolve lazily at runtime.

Identity — ellivate.viewer

viewer(): Promise<Viewer | null>

Return the currently signed-in viewer, or null for anonymous. Don't use for authorization — by the time your code runs, the shell has already gated out unauthenticated visitors on gated apps. Use for display (welcome name, avatar) only.

const viewer = await ellivate.viewer();
// { id, email, username, verified } or null

Viewer shape:

type Viewer = {
  id: string;
  email: string;
  username: string;
  verified: boolean;
};

Scope prefixes

The key (or collection name) prefix decides who can see the data, resolved deterministically at runtime. There is no scope argument on kv or collection — the prefix is the whole mechanism. See Persistence & scopes for full details.

PrefixIn plain wordsScope
personal: (or none)Just for youPer-viewer; each user has their own copy. The default.
shared:Shared with this groupEveryone you share it with reads + writes the same data.
public:Anyone who opens itReadable by anyone, including anonymous visitors.

A key with no prefix is personal: — private to each viewer. There's no classifier guessing from the name; forget the prefix and your data stays private. Add shared: when you want the group to see it. blob.put and connection keep their explicit scope argument — those aren't prefix-driven.

Environment variables

The SDK reads these from process.env (Node) or window.__ELLIVATE_CONFIG (browser, injected by ellivate-config.js):

  • ELLIVATE_DATA_URL — KV backend URL. Ellivate injects this at deploy time.
  • ELLIVATE_DATA_KEY — API key for the KV backend. Ellivate injects.
  • ELLIVATE_APP_ID — your app's ID. Ellivate injects.

You never set these yourself. When running locally without them, the SDK warns once and returns null on reads — your app renders empty, not broken.

Viewer token delivery

The SDK auto-detects the viewer token from:

  1. window.ELLIVATE_VIEWER_TOKEN (mobile WebView injection)
  2. URL fragment #__ellivate_auth=... on first page load (scrubbed after reading)
  3. ellivate_viewer_token cookie (Next.js server-rendered via next/headers)

You don't interact with the token directly. The SDK handles lookup and forwarding transparently.

Caller lanes

Every KV call is dispatched as either client-lane or server-lane. See Auth model for the rules. Summary:

  • Browser: client lane (viewer context).
  • Next.js server components: client lane (cookie = viewer). The SDK is server-only here — it imports next/headers, so calling it from a 'use client' component fails next build. Wrap it in a server action. See Publish a Next.js app.
  • Node with no viewer token: server lane (app-wide namespace).

Type definitions

ellivate-client.d.ts ships alongside ellivate-client.js so strict TypeScript apps can import without additional configuration. You don't need to install @types/ellivate-client — the types are local.

What's next