Build
Keeping data
Your app can keep data in the workspace it's installed in, in the workspace's own database. It moves with the workspace's exports and moves, and counts toward its storage. There are two kinds:
- Values: free-form JSON under a key. Use them for settings and state: a view, a filter, how often someone opened your tab.
- Records: in collections your manifest declares, with typed fields. Use them for your app's real data: contacts, items, notes. Agents can read them.
Your page reaches its data through the workspace, so it never calls an API or handles credentials. The workspace knows which app, tab and person is asking.
Scopes
Every value and record lives in a scope, which decides who sees it:
| Scope | Shared by | Seen by |
|---|---|---|
workspace | Every tab of your app, in every space | Everyone in the workspace |
space | Your app's tabs in one space | Everyone who can see that space |
tab | One tab | Everyone who can see its space |
user | One person, wherever they open your app | Only that person |
The place comes from where your tab is and who's looking; your page can't name another space, tab or person. Reading needs only to see the space. Writing needs to be in it: someone looking at a public space they haven't joined can read its data and write their own (user), but not change the space's.
Values
const tands = await connect();
const mine = tands.values("user");
const visits = ((await mine.get<number>("visits")) ?? 0) + 1;
await mine.set("visits", visits);
await tands.values("tab").set("view", { layout: "board", sort: "due" });
const everything = await tands.values("tab").list(); // { view: { … } }
await tands.values("tab").delete("view");A value can be any JSON, up to 16 KB. get() returns null for a key that isn't set.
Collections
Declare collections in your manifest, under data. Each has a scope, fields, and whether agents may read it:
{
"manifestVersion": 1,
"id": "acme-crm",
"name": "Acme CRM",
"tabs": [{ "id": "contacts", "name": "Contacts", "url": "/tab" }],
"data": {
"collections": {
"contacts": {
"description": "People the team sells to.",
"scope": "space",
"fields": {
"name": { "type": "string", "required": true, "description": "Their full name" },
"stage": {
"type": "string",
"enum": ["lead", "customer"],
"description": "Where they are"
},
"value": { "type": "number", "description": "Deal value, in euros" },
"closed": { "type": "date", "description": "When the deal closed" }
}
},
"notes": {
"scope": "user",
"agents": "none",
"fields": { "text": { "type": "text", "required": true } }
}
}
}
}Then use them:
type Contact = { name: string; stage?: "lead" | "customer"; value?: number; closed?: string };
const contacts = tands.collection<Contact>("contacts");
const linus = await contacts.create({ name: "Linus", stage: "lead", value: 1200 });
await contacts.update(linus.id, { stage: "customer", value: null }); // null clears a field
const { records, cursor } = await contacts.list({
where: { stage: "lead" },
orderBy: { field: "name", direction: "asc" },
limit: 20,
});
const next = cursor ? await contacts.list({ where: { stage: "lead" }, cursor }) : null;
await contacts.delete(linus.id);Each record comes back as { id, data, createdBy: { userId, name }, createdAt, updatedAt }.
Fields
| Type | Values |
|---|---|
string | Text, up to 2,000 characters. With enum, one of its values. |
text | Longer text, up to 20,000 characters |
number | A finite number |
boolean | true or false |
date | "2026-10-14" |
datetime | "2026-10-14T09:30:00Z" |
Every write is checked against your collection: only its fields, of their types, with required ones present when a record is created. A refusal rejects with a WorkspaceError whose code is INVALID and whose message says which field and why.
where matches fields exactly (all of them). orderBy takes one of your fields, or createdAt or updatedAt; the default is oldest first. Pages hold up to 100 records; cursor gets the next page.
Describe your fields
Descriptions aren't decoration. Admins read them before installing, and agents read them to understand your data. "Deal value, in euros" lets an agent answer "what's our pipeline worth?"; "value" doesn't.
Agents
Agents can read the collections you allow (all of them, unless a collection says "agents": "none"). They read for the person who asked them, and see only what that person could see, under the same rules as messages and files:
spaceandtabdata, in the spaces the agent may look at for that conversation;workspacedata;userdata only in that person's My Space, never in a shared conversation, where others would read the answer.
Ask Tabby "what's in the guestbook here?" and it lists what apps keep, then reads it. Agents can't change your data yet.
Limits
| A value | 16 KB |
| A record | 32 KB |
| A request | 64 KB |
| Everything your app keeps in a workspace | 100 MB |
Past 100 MB, writes are refused with APP_DATA_FULL until something is deleted. Admins see how much your app keeps in Settings → Apps.
When things go away
- A tab is removed: its
tabdata goes with it.spacedata stays, since another tab of your app may show it, and goes when the space is deleted. - Your app is removed: its data stays, and is there again if your app is reinstalled. An admin can choose to delete it too.
- A space is exported: its
spaceandtabdata go with it. A workspace export carriesspace,tabandworkspacedata. People's own (user) data stays with them, like their My Space.