Concepts
Persistence and scopes
Ellivate ships a cloud key-value store as the one storage layer. A key's prefix decides who can see it: `personal:` (or no prefix) is private to each viewer — the default — `shared:` is shared with the group, `public:` is visible to anyone.
Why a KV store instead of a database?
Ellivate's target builder is someone who prompted an LLM to make an app that works. They're not choosing between PostgreSQL and MongoDB; they just need their data to persist across deploys and devices. A single, well-shaped KV store covers most of that:
- Ephemeral containers. The compute runs in Railway containers that are destroyed and recreated on every publish. Filesystem data disappears.
sqlite3, local JSON files, and cached data all vanish. - Cross-device. A user on their laptop and on their phone should see the same data. Browser-local storage (
localStorage,IndexedDB) can't do that. - Cross-user. When you share an app, your invitee's data lives alongside yours. A local store can't coordinate that.
The Ellivate KV handles all three. It's cloud-backed, replicated, and namespaced by app. If your app genuinely needs relational queries (joins, full-text search, transactions), the classifier flags that at publish time and asks you to upgrade to a paid tier with a real database.
The KV API
Same shape in every language:
# Python
ellivate.get("key") # returns value or None
ellivate.set("key", value) # value is anything JSON-serializable
ellivate.delete("key")
ellivate.list("prefix:") # returns keys matching prefix// JS / TS
await ellivate.get<T>("key");
await ellivate.set("key", value);
await ellivate.del("key");
await ellivate.list("prefix:");Keys are plain strings. Values are anything JSON-serializable — objects, arrays, strings, numbers, booleans, null.
Collections — the "many similar things" primitive
KV is for single values. When the shape is many similar items — a shopping list, a roster of contacts, a stack of calendar events, a list of expenses — ellivate.collection(name) is the better fit. Each item is a JSON object with platform-managed id / createdAt / updatedAt fields, and the API supports filtering, sorting, and pagination natively instead of round-tripping one key at a time.
# Python
things = ellivate.collection("things")
item = things.add({"name": "milk", "category": "dairy"}) # platform assigns id
page = things.list(sort="-createdAt", limit=100) # {"items": [...], "nextCursor": ...}
dairy = things.list(where={"category": "dairy"}) # filter by top-level field
things.update(item["id"], {"category": "dairy-and-cheese"}) # shallow merge
things.replace(item["id"], {...}) # full replace
things.delete(item["id"])// JS / TS
const things = ellivate.collection("things");
const item = await things.add({ name: "milk", category: "dairy" });
const { items, nextCursor } = await things.list({ sort: "-createdAt", limit: 100 });
const dairy = await things.list({ where: { category: "dairy" } });
await things.update(item.id, { category: "dairy-and-cheese" });
await things.replace(item.id, { /* ... */ });
await things.delete(item.id);Reach for collections whenever you find yourself writing ellivate.set("things:" + i, ...) in a loop, or modeling data as a sqlite table with INSERT/SELECT. Reach for KV when the shape is genuinely a single value (one cart, one settings blob, one piece of config).
Collections take the same scope prefix as KV — it goes on the collection name. ellivate.collection("trips") (or "personal:trips") is private per viewer; ellivate.collection("shared:trips") is shared with the group; "public:trips" is readable by anyone. There is no separate scope argument — the prefix is it. Field names id, createdAt, updatedAt are reserved — if your data uses those names, rename them.
If your code already uses Supabase
A common path into Ellivate is "Claude built me a Next.js app and reached for @supabase/supabase-js as the data store." You don't have to rewrite that yourself — when the pipeline sees Supabase CRUD usage (supabase.from(t).select/insert/update/delete), the Supabase-data translator rewrites those call sites toellivate.collection(t) equivalents automatically:
// Before — your original code
const { data } = await supabase
.from('habits')
.select('*')
.order('created_at', { ascending: true });
// After — what the pipeline writes
const { items: data } = await ellivate
.collection('habits')
.list({ sort: 'createdAt' });The app detail page shows what changed on each publish — a list of rewritten files plus a one-click Switch back to Supabase toggle if you'd rather keep your own Supabase project. Switching back leaves your code as-is and the publish gate will ask for your Supabase URL + anon key.
Your new tool starts with no data — the rewriter doesn't migrate rows from your Supabase project (we don't have your credentials). For personal-software-scale apps the re-entry is usually a few minutes. If you have a real production dataset, the revert toggle is the right path.
The three scopes — set by a key prefix
Who can see a piece of data is decided by the first part of its key — the prefix. There's nothing else to configure: no scope argument, no dashboard setting, no guessing. The prefix you write is the rule, applied the same way every time, and it stays on the key verbatim.
| Prefix | In plain words | Who can see it |
|---|---|---|
personal: (or no prefix) | Just for you | Each person who opens the tool has their own private copy. The default. |
shared: | Shared with this group | Everyone you share the tool with reads and writes the same data. |
public: | Anyone who opens it | Visible to everyone, no sign-in required. |
personal: — "just for you" (the default)
A key with personal: — or with no prefix at all — is private to each viewer. Everyone who opens the tool gets their own copy; nobody sees anyone else's, not even the owner. This is the default on purpose: if you don't think about scope, your data stays private. Perfect for settings, drafts, reading progress, and private notes.
ellivate.set("personal:draft", "In progress...")
ellivate.set("draft", "In progress...") # same thing — no prefix = personal
# each viewer has their own draftshared: — "shared with this group"
Add the shared: prefix and everyone you share the tool with reads and writes the same data. This is how "our grocery list" works — you and your household all touch shared:items and see the same values. It's also the data the Space chat agent acts on when the tool lives in a Space.
ellivate.set("shared:items", [...])
# everyone you share the tool with sees + adds to the same listWhich group counts as "everyone you share it with" depends on the app's share mode:
- Join owner's data — everyone shares the owner's group. One dataset, many editors.
- Own copy — each invitee gets their own share group, just them and the owner. The owner sees each invitee's data separately.
Set in the dashboard or via the share dialog. Changes apply to future KV reads; existing data stays in whichever group it was written to. Full details.
public: — "anyone who opens it"
A public: key is readable by everyone, including anonymous visitors on a public app — no sign-in. Writable only by the app's owner (and anyone they've invited as an editor). Good for config, splash content, or cached feeds.
ellivate.set("public:welcome", {heading: "Hi"})
# anyone who opens the tool can read thisCaller lanes — client vs server
When your code calls ellivate.get(...), the SDK picks a "lane" to dispatch on:
- Client lane. Used when there's a per-request viewer context — every browser call, every Next.js server component (the handoff cookie is the viewer), every Flask/FastAPI request after the auth middleware runs. The backend applies per-viewer scoping to
personal:andshared:reads. - Server lane. Used for code that has no per-request viewer — Streamlit, long-running Python scripts, cron-style startup code. Reads and writes land in a single app-wide namespace.
personal:on server lane means "the app's single personal-namespace copy", not per-user — so for single-user server apps like Streamlit this is usually what you want.
The lane is picked automatically. You don't write any code to choose it.
Atomic writes, eventual consistency
Individual writes are atomic, but there's no multi-key transaction. If two viewers concurrently append to the same list, use a read-modify-write pattern with idempotent operations:
items = ellivate.get("shared:items") or []
items.append(new_item)
ellivate.set("shared:items", items)Last write wins. For high-concurrency apps where this matters (shared whiteboard, CRDT document), model it with per-operation keys: shared:ops:<timestamp>-<client-id> and merge at read time.
What's out of scope
- Full-text search. Use a hosted search service (Algolia, Meilisearch) if you need it. Collections support top-level field equality, not text search.
- Relational queries and joins. Flagged at publish time.
- Binary blobs (photos, audio, documents). Use blob storage —
ellivate.blob.put/get/list/del— for files up to 25MB. KV is for the URL or metadata, not the bytes. - Real-time subscriptions. Poll or use server-sent events from your app code if you need reactive reads.
What's next
- Sharing & invites — the group model that scopes
shared:reads. - Auth model — how the viewer identity gets to your code in the first place.