Tabs & Spaces Developers

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:

ScopeShared bySeen by
workspaceEvery tab of your app, in every spaceEveryone in the workspace
spaceYour app's tabs in one spaceEveryone who can see that space
tabOne tabEveryone who can see its space
userOne person, wherever they open your appOnly 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

ts
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:

json
{
  "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:

ts
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

TypeValues
stringText, up to 2,000 characters. With enum, one of its values.
textLonger text, up to 20,000 characters
numberA finite number
booleantrue 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:

  • space and tab data, in the spaces the agent may look at for that conversation;
  • workspace data;
  • user data 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 value16 KB
A record32 KB
A request64 KB
Everything your app keeps in a workspace100 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 tab data goes with it. space data 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 space and tab data go with it. A workspace export carries space, tab and workspace data. People's own (user) data stays with them, like their My Space.