About
Typed tables that agents read and write as rows.
README
Typed tables that agents read and write as rows.
Status: release 0.14.1. See CHANGELOG.md for the changes.
The Worker checks every request. It needs
Authorization: Bearer <token>, andagentic-identityverifies the token against credentials in the same store. A request with no valid token gets401. A token without the scope of a route gets403. The Worker no longer readsx-principal-*headers, andAGENTIC_TABLES_UNSAFE_NO_AUTHis gone: see "Upgrading the Worker" under 0.8.0 in CHANGELOG.md. The Node server (agentic-tables/node) still trusts headers, so it refuses to start withoutunsafeNoAuth: true. Use it for local work only.
Install
npm install agentic-tables agentic-identity
Node 22 or newer. The CLI runs with npx agentic-tables --help.
Subpath exports
| Import | Use it for |
|---|---|
agentic-tables/account |
tablesAccount, the Store-backed identity registry contributor. See Account export and erase. |
agentic-tables |
createTables, the core API. See Quick start. tablesAccount runs account export and erase. See Your data (GDPR). The package also exports VERSION and the field, principal, query, CSV, and error helpers. |
agentic-tables/worker |
createApp, which builds the HTTP API as a Hono app. The default export is the Worker. reportingRegion is the local-mode region helper. See HTTP API and Mount the API in your own Worker. |
agentic-tables/client |
createClient, a typed client for the HTTP API. See SDK. |
agentic-tables/node |
createNodeServer, the HTTP API in a Node process. It has no sign-in, so it needs unsafeNoAuth: true and is for local work only. See Run on Firebase. |
agentic-tables/file-store |
createFileStore, a local store that keeps one JSON file. The CLI and the MCP server use it. See Limits. |
agentic-tables/ui |
mount, which renders the web app in your page. See Mount the UI in your app. |
agentic-tables/ui.css |
The styles for agentic-tables/ui. Import it after agentic-design/styles.css. See Mount the UI in your app. |
Export reference
These are the public entry points and their TypeScript exports. The installed .d.ts files give the full fields of each type. Types use import type and have no runtime value.
Core SDK — agentic-tables
| Export | Signature or use |
|---|---|
createTables |
(store: Store, options?: TablesOptions) => Tables; creates the core API. |
tablesAccount |
The legacy guarded TablesAccount; exportAccount(subject: Actor, deps: TablesAccountDeps) returns JSON by TablesCollection, and eraseAccount(subject, deps) returns counts. Both return promises. Stop writes before erase. Use agentic-tables/account for the identity registry. |
VERSION |
string; the package version. |
FIELD_TYPES, PRINCIPAL_KINDS |
Readonly lists of supported field types and principal kinds. |
OPERATORS |
Record<FieldType, Op[]>; supported query operators by field type. |
checkValue |
(field: Field, value: unknown) => Omit<Issue, "field"> | null; checks one value, not the required rule. |
parseCsv |
(text: string) => string[][]; parses CSV records. |
inferFields |
(header: string[], records: string[][]) => FieldInput[]; infers a schema. |
csvToValues |
(table: Table, header: string[], records: string[][]) => Values[]; converts records to row values. |
toCsv |
(table: Table, rows: Row[]) => string; exports rows with spreadsheet formula guards. |
TablesError, ValidationError, NotFoundError, MigrationError |
Error classes. Constructors take (code, message), (issues), (what), and (report) respectively. ValidationError.issues and MigrationError.report give details. |
Actor, Field, FieldInput, FieldType, FileRef, Issue, PrincipalKind, Row, Table, Values |
Types for actors, schemas, rows, and validation. |
AddFieldInput, Base, ChangeTypeOptions, EraseSummary, ListRowsOptions, OwnerExport, Tables, TablesOptions |
Types for core API inputs and results. |
Failure, MigrationReport, Condition, Filter, Op, Sort |
Types for schema changes and queries. |
TablesAccount, TablesAccountDeps, TablesCollection |
Types for the account export and erase contract. |
import { checkValue, inferFields, parseCsv, OPERATORS } from "agentic-tables";
import type { Field, Filter } from "agentic-tables";
const records = parseCsv("Name\nAda\n");
const fields = inferFields(records[0], records.slice(1));
const field: Field = { id: "f1", name: "Name", type: "text", required: true };
const issue = checkValue(field, "Ada"); // null
const filter: Filter = { field: "Name", op: "eq", value: "Ada" };
console.log(fields, issue, filter, OPERATORS.text);
Account registry — agentic-tables/account
tablesAccount is the identity account registry contributor. Its exportAccount(subject, { store }) returns JSON records, and eraseAccount(subject, { store }) returns counts. Both return promises keyed by bases, tables, views, rows, authored_rows, and grants. Stop writes before erase. ERASED_ACCOUNT_ID is the reserved ID used for retained authorship. The TablesAccount, TablesAccountDeps, and TablesCollection types describe this contract. These differ from the same-named root exports, which use the legacy guarded { tables, sharing, ctx } contract.
import { tablesAccount, ERASED_ACCOUNT_ID } from "agentic-tables/account";
import type { TablesAccountDeps } from "agentic-tables/account";
import { createMemoryStore } from "agentic-identity";
const deps: TablesAccountDeps = { store: createMemoryStore() };
console.log(tablesAccount.id, ERASED_ACCOUNT_ID, deps);
HTTP client — agentic-tables/client
createClient(options: ClientOptions): TablesClient creates the remote SDK. Each method makes one request and accepts call options with an optional signal. TablesClientError(code: string, message: string, status: number, issues?: Issue[], report?: MigrationReport, cause?: unknown) gives the error code, HTTP status, and optional details.
The client exports these types: TablesClient, ClientOptions, ClientPrincipal, CallOptions, QueryOptions, PageOptions, Subject, Membership, MemberPage, InviteInput, Invited, LinkInput, ShareLink, CreatedLink, LinkList, and Unshared. It also exports Issue, Field, FieldInput, Row, Table, Values, MigrationReport, Filter, Sort, AddFieldInput, Base, ChangeTypeOptions, EraseSummary, OwnerExport, RowPage, View, ViewInput, ViewRun, Role, and SubjectKind for its inputs and results.
import { createClient, TablesClientError } from "agentic-tables/client";
import type { ClientOptions, CallOptions } from "agentic-tables/client";
const options: ClientOptions = { baseUrl: "https://tables.example.com", token: "<token>" };
const call: CallOptions = { signal: new AbortController().signal };
try { await createClient(options).listBases(call); }
catch (error) { if (error instanceof TablesClientError) console.error(error.code); }
Worker — agentic-tables/worker
createApp(tables: Tables, sharing: Sharing, auth: Auth, options?: AppOptions) returns a Hono app. AppOptions has optional origins and store. The default export is the Worker with fetch(request, env?). Env describes its bindings. reportingRegion(db: D1Like, region: "eu" | "us"): D1Like wraps local D1 queries that report no region; use it for local development only.
import worker from "agentic-tables/worker";
export default worker;
Node server — agentic-tables/node
createNodeServer(options: NodeServerOptions): Promise<RunningNodeServer> starts the local HTTP server. Options require store and unsafeNoAuth: true; host and port are optional. The result has url: string and close(): Promise<void>. See Run on Firebase for an example.
File store — agentic-tables/file-store
createFileStore(path: string, options?: FileStoreOptions): Store opens a local JSON-backed store. FileStoreOptions is AdapterOptions from agentic-identity.
import { createFileStore } from "agentic-tables/file-store";
import type { FileStoreOptions } from "agentic-tables/file-store";
const options: FileStoreOptions = {};
const store = createFileStore("./tables.json", options);
Embedded UI — agentic-tables/ui and agentic-tables/ui.css
mount(element: HTMLElement, options: TablesUiOptions): MountedTables renders the UI. MountedTables has update(options: TablesUiOptions): void and unmount(): void. The UI also exports the ClientPrincipal and TablesClient types. Import the CSS subpath for its styles. See Mount the UI in your app for an example.
Set readOnly: true to browse without UI writes. This disables cell edits and row deletion, hides table and column actions, and prevents automatic base creation. Filters, sort, navigation, and pagination still work. update can change this option and closes open editors and dialogs. The current UI has no saved-view or rename controls. This option controls the UI; server roles and scopes still enforce access.
To rename a table from your own UI, call client.renameTable(tableId, name). The guarded SDK and core expose renameTable too. The operation keeps the table id, fields, rows, and views. Names are trimmed and must contain 1 to 200 characters. The guarded API requires owner access and table:admin scope.
Quick start
The library, the CLI, the MCP server, the HTTP API, and the web UI work.
A base holds tables. A table has typed fields and rows. The library checks each row against the schema before it writes.
import { createMemoryStore } from "agentic-identity";
import { createTables } from "agentic-tables";
const tables = createTables(createMemoryStore());
const base = await tables.createBase("Crm", { id: "u1", kind: "human" });
const people = await tables.createTable(base.id, "People", [
{ name: "Name", type: "text", required: true },
{ name: "Stage", type: "single_select", options: ["lead", "won"] },
]);
await tables.insertRow(people.id, { Name: "Ada", Stage: "lead" }, { id: "u1", kind: "human" });
Features
- Field types: text, long text, number, checkbox, date, single select, multi select, link to another table, file ref.
- Every row keeps who created it and who last changed it, with the principal kind. An agent edit is labeled as an agent edit.
- Schema changes are safe. A rename keeps the values. A type change converts every value or changes nothing, and returns a report of the rows that fail. A dry run shows the report first. You can add and delete fields and change select options the same way.
- Queries: filters with
andandor, per-type operators, one sort,limit, and a cursor. Saved views: a grid, and a board grouped by a single select field. - Deleting a row removes it from the links that point at it.
- Storage sits behind the
Storeport fromagentic-identity. Use its memory adapter in tests, its D1 adapter in a Worker, or the file store fromagentic-tables/file-store. The file store passes the same contract suite (runStoreContract), so any adapter that passes it also works here. - A change that reads before it writes runs in one
Storetransaction that reads first and writes after. Creating a base, and deleting a view, are single writes. This is the shared port rule, and Firestore follows it. A query guards only the docs it returned, so adding a row or a view bumps arevcounter on its table, and adding a table bumps one on its base. A change that read the table or base then runs again and sees the new doc. A busy table serializes its inserts for that reason.
Queries
const page = await tables.queryRows(people.id, {
filter: { and: [{ field: "Stage", op: "eq", value: "lead" }, { field: "Name", op: "starts_with", value: "A" }] },
sort: { field: "Name" },
limit: 25,
});
// page.items, page.nextCursor, page.plan
The Store port has equality filters, one orderBy, a limit, and a cursor. Field values are not top-level fields, so the port cannot filter on them.
The store runs only queries of this shape: where on a top-level id field (tableId, baseId, ownerId with ownerKind), and orderBy on createdAt or updatedAt. A Firestore adapter needs one composite index for each of those pairs.
- In the store (
plan: "store"): a query with no filter and no field sort. The store filters by table, orders bycreatedAtorupdatedAt, and pages. - In memory (
plan: "memory"): any filter, and any sort on a field. The library reads the rows of the table, then filters, sorts, and pages them.
These also run in memory, because the port has no query for them: the export and the erase of one principal read every base, table, and row they touch (the rows it wrote in other bases are found by a scan, matching author id and kind), and a field-level filter or sort reads the whole table.
A memory query reads at most maxScan rows (10,000 by default). A larger table fails the query with SCAN_LIMIT. It never returns a partial answer. Empty values sort last in both directions.
Operators by type: text eq neq contains starts_with is_empty not_empty; number and date eq neq gt gte lt lte is_empty not_empty; checkbox eq neq; single select eq neq in is_empty not_empty; multi select has has_any has_all is_empty not_empty; link has is_empty not_empty; file is_empty not_empty.
UI
A small web app for your tables. It is built on agentic-design and talks to the Worker through agentic-tables/client. The Worker of this repository serves it at /, next to the API under /api/v1/tables: wrangler.jsonc points its assets at ui-dist. The npm package ships the built app in ui-dist. The Worker in the package (agentic-tables/worker) holds the API only, so your own Worker adds the assets. Point assets at the shipped folder and keep /api/* on the Worker:
Your Worker's entry file re-exports the API Worker. Your wrangler.jsonc serves the app from the assets, and binds the DB database:
// src/worker.ts of your own Worker
export { default } from "agentic-tables/worker";
// wrangler.jsonc of your own Worker
{
"name": "my-tables",
"main": "src/worker.ts",
"compatibility_date": "2026-01-01",
"assets": { "directory": "./node_modules/agentic-tables/ui-dist", "run_worker_first": ["/api/*"] },
"d1_databases": [{ "binding": "DB", "database_name": "agentic-tables", "database_id": "<your id>" }]
}
The Worker needs no compatibility flags. Do not add nodejs_compat: the default Workers runtime is enough, and a test runs the built Worker under wrangler dev with a config like this one. The config needs only a compatibility_date, the DB binding, and the region variable.
Set AGENTIC_TABLES_REGION for each deploy, as in the HTTP API section. A missing DB binding or region answers 500. A request with no valid token answers 401.
Run it on your machine (see also "Run locally" near the end of this file):
npm ci
npm run dev:ui
Open the address that wrangler dev prints. The script builds the app, then starts the Worker with AGENTIC_TABLES_LOCAL_DEV=1 and AGENTIC_TABLES_REGION=eu. Issue a token for yourself (see "Credentials" below), then open the app as http://127.0.0.1:8787/#token=<token>. The page keeps the token for that tab only and removes it from the address bar. Without a token every call answers 401.
What it does:
- Lists your tables. The first visit makes a base called "My tables" to hold them.
- Shows a table as a grid with typed cells: text, long text, number, checkbox, date, single select, and multi select.
- Adds a table, a column, and a row. Edits a cell. Deletes a row, a column, or a table after a confirm.
- Filters on one column and sorts on one column or on the time of the last edit.
- Shows loading, empty, and error states. A failed call shows its reason.
- Marks a row whose last edit an agent made with the word "Agent" and an icon. Colour is never the only sign.
- Works with the keyboard. In the grid, the arrow keys, Home, and End move. Enter edits a cell (or toggles a checkbox). Enter saves and Escape cancels. A double click edits a cell, and a click toggles a checkbox.
- Follows the system light or dark theme, or the pick in the top bar.
Run the browser tests, which start wrangler dev with a fresh local database:
npx playwright install chromium
npm run build && npm run test:e2e
They create a table, add a row, edit a cell, and reload to check that it stays. They also run an axe check for WCAG 2.2 AA on each view in both themes, and write screenshots to docs/screenshots (light and dark).
Limits of the UI: it shows the first base only. Link and file cells show their value and cannot be edited. A column cannot be renamed or change type. Filters take one condition. It does not refresh when another caller writes: reload to see new rows.
Mount the UI in your app
agentic-tables/ui renders the same app inside your own page. It needs no framework in your page. You give it an
element, a base URL, and a callback that returns the token. mount returns a handle. Install the peers with it:
npm i agentic-tables react react-dom agentic-design.
import { mount } from "agentic-tables/ui";
import "agentic-design/styles.css";
import "agentic-tables/ui.css";
const getToken = async () => session.token(); // called before each request; return nothing to send no token
const tables = mount(document.getElementById("tables")!, {
baseUrl: "https://tables.example.com",
getToken,
// getToken: () => "<token>", // the Worker needs a token on every request
// themePicker: true, // show a theme control; the app then sets data-theme on the page
// frame: false, // no banner, main landmark, or skip link: for a page with its own landmarks
// baseId: "base_...", // open this base (a shared one too). Without it: the base of tableId, else the first
// tableId: "tbl_...", // open this table: a deep link or a search hit. The URL hash #/t/<id> wins on mount
// baseSwitcher: true, // list every readable base, to switch between them
});
tables.update({ baseUrl: "https://other.example.com", getToken }); // use other options in the same place
tables.unmount(); // remove the app; a second call does nothing
mount throws a TypeError for a missing element, an element that already holds the app, a baseUrl that does
not start with https:// (http:// works for localhost only), or a getToken that is not a function. getToken
runs on each request, so an expired token is replaced and the UI keeps none. If it throws, that request fails and the
UI shows the error. By default the app leaves the theme of your page
alone. The app uses the hash of the page address (#/t/<id>) to pick a table. By default it adds its own banner and main
landmarks and a skip link, so mount it on a page that has none of its own. On a page with its own landmarks, pass
frame: false: the app then draws a plain block with the actions on top and the table list as a nav beside the open
table. A frame that is not true or false throws a TypeError. react and react-dom (version 19) are optional peer
dependencies. The web UI that the Worker serves renders the same app.
HTTP API
A Cloudflare Worker (Hono) serves /api/v1/tables. Data lives in D1, so it survives a restart and every Worker instance sees the same data.
GET /health answers 200 with { "ok": true, "name": "agentic-tables", "version": "<version in package.json>" }. It needs no token and does not open the database.
Create the database in the EU, put its id in wrangler.jsonc, and set AGENTIC_TABLES_REGION for each deploy. There is no default, so a deploy cannot claim a region you did not state.
This repository's wrangler.jsonc serves the app from ui-dist. Run npm run build first, because wrangler stops when that folder is missing. The build needs the dev dependencies:
npm run build
npx wrangler d1 create agentic-tables --location weur
npx wrangler deploy --var AGENTIC_TABLES_REGION:eu
AGENTIC_TABLES_REGION (eu or us) is a claim that you make. On each query, the adapter checks the region that D1 reports, and fails the query if it is outside the claim. The Worker answers 500 with MISCONFIGURED when the claim or the DB binding is missing, and when the claim is not exactly eu or us. It answers 503 when the table cannot be created. It creates the table it needs on first use. The adapter checks queries only, so a wrong claim is not caught on a write: create the database with the location hint.
With AGENTIC_TABLES_LOCAL_DEV=1, the Worker fills in the region of a query when D1 reports none, because wrangler dev reports none. A region that D1 reports is never changed. The flag is for wrangler dev only. It does not change who may call: every request still needs a token.
Local wrangler dev needs the same --var and keeps its own copy of the database and ignores the id. Two wrangler dev runs with the same --persist-to folder share it.
Credentials
The Worker reads credentials with createStoreAuth and checks each request with requirePrincipal, both from agentic-identity (agentic-identity/http for the second). A credential is a token that maps to a principal record. Both live in the D1 table of the Worker, in the collections credentials and principals. Issue them with issueCredential from agentic-identity, with the same store the Worker uses (for example createD1Store in a script that runs with wrangler). The Worker has no route that issues a credential.
import { createD1Store } from "agentic-identity/cloudflare";
import { issueCredential } from "agentic-identity";
// `db` is your D1 binding, in a script or another Worker. The principal record is a document in the store.
const store = createD1Store(db, { region: "eu" });
await store.put("principals", "ada", { id: "ada", kind: "human", scopes: ["*:admin"] });
const token = await issueCredential(store, { principalId: "ada", kind: "api_key" });
For wrangler dev, send one request first so the Worker makes its table. Then put a principal and a credential into the local database. A credential is stored under the SHA-256 of its token:
TOKEN="aid_$(openssl rand -hex 20)"
HASH=$(printf %s "$TOKEN" | sha256sum | cut -d' ' -f1)
npx wrangler d1 execute DB --local --command "
INSERT INTO store_docs (collection, id, body) VALUES
('principals', 'local-user', '{\"id\":\"local-user\",\"kind\":\"human\",\"scopes\":[\"*:admin\"]}'),
('credentials', '$HASH', '{\"principalId\":\"local-user\",\"kind\":\"api_key\"}')"
npm run build
npx wrangler dev --var AGENTIC_TABLES_LOCAL_DEV:1 --var AGENTIC_TABLES_REGION:eu
curl -X POST localhost:8787/api/v1/tables/bases -H "authorization: Bearer $TOKEN" -d '{"name":"Crm"}'
- A missing, malformed, unknown, expired, or revoked token returns
401withwww-authenticate: Bearer. The body is{ "error": { "code": "UNAUTHENTICATED", "message": "..." } }and never holds the token. A header such asx-principal-idproves nothing and is ignored. - A token whose principal is an agent of a person carries that chain. Each step can only narrow the scopes of the step above it. A broken chain returns
401. - Each route checks the scope of the token for its action, before it looks at any id:
base:read,base:write,base:admin,table:read,table:write,table:admin. A token without it returns403. After that, the role on the base or table decides. A caller with no role gets404. - A session cookie (
session=<token>) works for reads. A write with a cookie needs anOriginthat is listed inAGENTIC_TABLES_ORIGINS(comma separated), else it returns403. A bearer token needs no origin. - The kind of the principal is kept on every row, so an agent write stays labeled.
Mount the API in your own Worker
createApp from agentic-tables/worker builds the Hono app of /api/v1/tables. It takes:
tables: whatcreateTables(store)returns.storeis aStoreofagentic-identity.sharing:createSharing(store)fromagentic-identity. It holds the roles.auth: anAuthport ofagentic-identity, such ascreateStoreAuth(store). A request with no valid credential answers 401. A credential without the scope of a route answers 403.options.origins(optional): the origins that may send a write with a session cookie. A bearer token needs none. A cookie write from another origin answers 403.
import type { Store } from "agentic-identity";
import { createSharing, createStoreAuth } from "agentic-identity";
import { createTables } from "agentic-tables";
import { createApp } from "agentic-tables/worker";
// Your code: open an agentic-identity Store on env.DB, after the setup steps below.
declare function openStore(env: unknown): Promise<Store>;
// One app per Worker instance. A failed build is cleared, so the next request tries again.
let app: Promise<ReturnType<typeof createApp>> | undefined;
export default {
async fetch(request: Request, env: unknown): Promise<Response> {
app ??= openStore(env)
.then((store) => createApp(createTables(store), createSharing(store), createStoreAuth(store), { origins: ["https://tables.example.com"] }))
.catch((error: unknown) => {
app = undefined;
throw error;
});
return (await app).fetch(request);
},
};
createApp does not open the database or check the region. The default export of agentic-tables/worker does both, and it reads DB, AGENTIC_TABLES_REGION, AGENTIC_TABLES_ORIGINS, and AGENTIC_TABLES_LOCAL_DEV. Do the same in your Worker: run D1_SCHEMA from agentic-identity/cloudflare on the database, and check the region claim with assertRegion from agentic-identity. Local D1 under wrangler dev reports no region, so every read fails the D1 store's region check. With AGENTIC_TABLES_LOCAL_DEV=1, the default export wraps the database with reportingRegion. The wrap gives the declared region to a query that reports none. reportingRegion is exported from agentic-tables/worker.
SDK
agentic-tables/client is a typed client for the Worker API. It uses only fetch, so it runs in browsers, Workers, and Node 22. It has one method per route.
import { createClient, TablesClientError } from "agentic-tables/client";
const tables = createClient({
baseUrl: "http://127.0.0.1:8787",
token: process.env.TABLES_TOKEN, // a credential issued with issueCredential
});
const base = await tables.createBase("Crm");
const table = await tables.createTable(base.id, "People", [{ name: "Name", type: "text", required: true }]);
await tables.insertRow(table.id, { Name: "Ada" });
const page = await tables.queryRows(table.id, { sort: { field: "Name" }, limit: 50 });
// Sharing: members, invites, and share links. Each needs the owner role. A link token shows once.
const { grants } = await tables.listBaseMembers(base.id);
await tables.inviteToBase(base.id, { role: "viewer", handle: "claude" });
const link = await tables.createBaseLink(base.id, { role: "viewer", expiresInMs: 86_400_000 });
const { items } = await tables.listBaseLinks(base.id); // no tokens
await tables.revokeBaseLink(base.id, link.id);
try {
await tables.getRow("row_0000000000000000");
} catch (error) {
if (error instanceof TablesClientError) console.log(error.code, error.status); // NOT_FOUND 404
}
baseUrlis the Worker origin. The client adds/api/v1/tables.tokenis sent asAuthorization: Bearer, and the Worker checks it. A call with no token, or a bad one, answers401 UNAUTHENTICATED.principalsets thex-principal-*headers, which only the Node server in unsafe mode reads.fetchreplaces the globalfetch.- Every method takes
{ signal }last. Abort it to cancel the call. An abort throws the error offetchas it is. - A failed call throws
TablesClientErrorwithcode,status, and, where the API sends them,issuesandreport. A call that gets no answer has codeNETWORKand status0. An answer that is not JSON has codeBAD_RESPONSE. - The client never retries. A repeat of a write could write twice, so retry in your own code where it is safe.
Share links
A share link has a role (viewer, commenter, or editor), an optional expiry, and an optional sign-in rule. The token shows once, when the link is made. Only its hash is stored, so a list shows the id, role, sign-in rule, expiry, and revoke time. A list holds up to 200 links, live ones first, truncated: true says there are more, and skipped counts damaged records left out: a record that has a bad role, a bad expiry, or an id that differs from its key is never shown, even if its token still works. If skipped is above 0, ask the operator of the store to check the links records of that base. Only an owner of the base or table makes, lists, or revokes links, and a link of another resource is 404. The link routes of an app made with createApp need options.store: without it, they answer 503 UNAVAILABLE. Without it, the members and invite routes also skip the check that an id names a base on a base route and a table on a table route.
Access
A base and a table are shared resources, and a table sits inside its base. Roles come from agentic-identity: viewer, commenter, editor, owner. A role on a base passes down to its tables. The person who creates a base owns it. An agent that acts for a person has the role of that person, cut down by its scopes. A role belongs to a kind and an id together: the person sam and the agent sam hold separate roles, and a share names the kind (human or agent).
| Role | Can |
|---|---|
| viewer | Read bases, tables, rows, and views. |
| editor | Also insert, change, and delete rows, and create and delete views. |
| owner | Also create a table, change a schema (fields, types, options, required), delete a table or a base, and share. A link field also needs the right to read the table it points at. |
Every call checks the role with can(), which writes an audit entry for each allow and each deny. A caller with no role gets 404, the same answer as for an id that does not exist. A link value names a row, so it needs the right to read the linked table. A caller with a role that is too low, or a credential with too few scopes, gets 403 FORBIDDEN. PUT /bases/:baseId/members/:kind/:subjectId and PUT /tables/:tableId/members/:kind/:subjectId (:kind is human or agent) take {"role": "viewer"} and grant a role. DELETE on the same path takes it back. GET on /bases/:baseId/members and /tables/:tableId/members lists the roles in id order (limit, cursor). POST on /bases/:baseId/invites and /tables/:tableId/invites takes {"role", "email"} or {"role", "handle"} and an optional expiresInMs, and answers the invite id, never the email. GET, POST, and DELETE /…/links/:linkId on the same two resources list, make, and revoke share links (see Share links). A comment role exists, but no route needs it yet.
GET /me/export and DELETE /me/data work only for a person or an agent with its own identity, and need the base:admin scope (and table:admin for a base that has tables, since their grants go too), because they act on the bases the caller owns. An agent that acts for a person gets 403: it must not export or erase its owner's data. They touch the bases the caller created and still holds the owner role on. A base the caller gave away is not theirs to erase. They also cover every row the caller wrote in any other base, whatever the caller's role is now: a person may take back what they wrote. The export holds those rows as they are now, with the values that others wrote in them.
| Route | Use it for |
|---|---|
POST /bases, GET /bases |
Create and list bases. The list holds the bases the caller can read. |
POST /bases/:baseId/tables, GET /bases/:baseId/tables, GET /tables/:tableId |
Create, list, and read tables. |
PATCH /tables/:tableId |
Rename a table with { "name": "New name" }. Requires owner access and table:admin. |
POST /tables/:tableId/fields, PATCH and DELETE /tables/:tableId/fields/:field, POST /tables/:tableId/fields/:field/type |
Add, rename, delete, and retype a field. The retype body takes dryRun and onFailure. |
PUT /tables/:tableId/fields/:field/required |
Set required. A 409 lists the rows with no value. Add dryRun to only list them. |
DELETE /tables/:tableId, DELETE /bases/:baseId |
Delete with all rows and views. A table that another table links to returns 422. |
GET /me/export, DELETE /me/data |
Export or hard delete the bases the caller owns. |
POST /tables/:tableId/rows |
Insert {values} or an all-or-nothing {rows: [...]}. |
GET /tables/:tableId/rows?filter=<json>&sort=Field:desc&limit=&cursor= |
Filter, sort, and page rows. |
GET, PATCH, DELETE /rows/:rowId |
Read, change, and delete a row. |
POST and GET /tables/:tableId/views, GET /views/:viewId/rows, DELETE /views/:viewId |
Saved views. |
A failure returns {"error":{"code","message","issues"}}: 422 for a validation failure, 409 for a migration that would lose data (the body holds the report), 404, 403, 400, or 401.
Your data (GDPR)
A base has an owner: the principal that created it, by id and kind together. An id can belong to a person and to an agent, so export, erase, and the rows a principal wrote match both. Roles do not yet: agentic-identity keys a role on the id alone, so agent:x holds every role that human:x holds, until identity keys a role by kind too. A base with no owner kind is never exported and never erased as a whole. Two actions work by principal:
- Export: every base the principal owns, with its tables, views, and rows. It also lists
authoredRows: the rows the principal wrote in bases it does not own. The output is one JSON object (format: "agentic-tables-export",version: 2,owner: {id, kind}). Each row keeps the principal kind of its author. - Hard delete: every base the principal owns goes in one transaction, and so does every row the principal wrote in other bases. Links to a deleted row are removed. Other rows in those bases stay. The store keeps no copy and no marker. With the file store, the file is rewritten without the data.
The result of a hard delete counts what it removed: bases, tables, views, rows (rows inside the owned bases), and authoredRows (rows in other bases).
| Surface | Export | Hard delete |
|---|---|---|
| Library | exportOwnerData({id, kind}) |
eraseOwnerData({id, kind}) |
| CLI | export-data |
erase-data --yes |
| MCP | export_my_data |
erase_my_data |
| HTTP | GET /me/export |
DELETE /me/data |
The CLI, MCP, and HTTP actions act for the actor of the call only, and check the same roles as the HTTP API (see Access). In the unsafe mode the HTTP actor comes from unchecked headers, and the CLI and MCP actor comes from the environment, so anyone who can run the process can name any id. The CLI and MCP give each actor its own default base, so two actors on one file never share data by accident. Set AGENTIC_TABLES_BASE to a base id to work in a shared base: the owner shares it first.
Root account API
The package also gives tablesAccount for a host that runs account export and erase across many packages. It has exportAccount(subject, { tables, sharing, ctx }) and eraseAccount(subject, { tables, sharing, ctx }). They call exportMyData and eraseMyData of the guarded path, so they keep its rules: a base with no owner kind stops the call with LEGACY_BASE, a base the subject gave away is left alone (a row the subject wrote in it still counts as an authored row, and is exported and erased), and the sharing records go with the data. ctx is the context of the subject, with one step in its chain and the scopes base:admin and table:admin. A context for someone else, or a chain with an agent, throws. The subject is an id and a kind: the host takes the kind from the signed-in principal and does not guess it. The export has plain JSON in the collections bases, tables, views, rows, and authored_rows, with no format or time. The erase returns a count for each of those names. The host does not read the table layout. The export holds what the subject owns and wrote, and not the bases that others shared with them, so it is not a full list of what they can reach. Export and erase are two calls, so a row written between them is erased and not exported: stop writes first. If the erase throws ERASE_INCOMPLETE, the data is gone and the counts are lost, so a repeat counts zero. A base that the subject gave away is in nobody's export through this path, not even the new owner's, because the stored owner stays the subject.
Account export and erase
import { tablesAccount } from "agentic-tables/account";
import { createAccountRegistry } from "agentic-identity/account";
const registry = createAccountRegistry().register(tablesAccount, { store });
const subject = { id: "ana", kind: "human" } as const;
const { zip, manifest } = await registry.export(subject);
// Save the export and collect file keys before erase.
const report = await registry.erase(subject, store, { actor: ctx });
if (!report.ok) throw new Error("Account erase is incomplete");
The contributor implements AccountPackage<TablesAccountDeps> from identity 0.10. The host supplies the trusted subject, the Store, and the signed-in principal context (ctx) for the registry audit. Keep this entry point on the server. Pause writes by the subject between export and erase. Other subjects can keep working.
Export returns plain JSON collections: bases, tables, views, rows, authored_rows, and grants. Ownership comes from sharing grants, including parent grants. Rows keep field IDs and file references. The package stores no blob bytes or version history. Before erase, the host must collect file keys from the export or a read of the rows. After erase succeeds, the host deletes those blobs through its blob provider. The host handles identity audit data through identity.
Erase deletes sole-owned bases and tables with their rows, views, resources, grants, invites, links, and resource audit entries. Shared data stays. The subject's grants go. On retained rows, matching author and editor IDs become (erased); the kind stays, so agent work stays labeled. Row content stays with its other owners. A matching stored base creator ID also becomes (erased). If a table has another direct owner, its base stays as a container even when its last base grant goes.
Erase refuses a table delete while a surviving table links to it. Remove that link field first, as for deleteTable. In a retained base, damaged sibling fields also block erase: it cannot prove that no link exists. Export can still succeed in that case.
Both calls reject damaged records on the subject's resource paths and data. Unrelated data does not block the call. Resource cleanup reads first and writes in one Store transaction. Authored rows outside that tree are found outside the transaction, then each is read again and cleared in a small transaction. A failed transaction changes nothing in that transaction. A later failure can leave a partial erase; repeat the call to finish. Counts cover only the work done by that call. Erase returns counts for the export collection names. A second erase counts zero. The older root API and the HTTP self-data routes retain their earlier behavior.
Contributor limits: it holds the export in memory and scans rows to find authored edits. A large owned tree can exceed a backend transaction limit. It stores no blob bytes or history, has no retention setting, and cannot wipe file blocks or backups. Content on shared rows stays with the other owners. Pause subject writes until all erase calls succeed.
Root API limits: the rows keep the id of whoever last updated them (updatedBy) when another principal wrote them. File refs hold blob keys, and erase does not remove the blobs. A file store rewrite does not wipe old file blocks or backups. There is no retention setting. Rows that an agent writes for a person carry the id of the agent, so the person cannot export or erase them and the agent cannot either (it gets 403). Erase keeps the audit entries that can() wrote and the sharing records: they hold ids, kinds, and roles, and no row content. The export holds all data in memory, so a very large export needs a streaming version later.
Live demo
A demo runs at https://agentic-tables-demo.hello-fd1.workers.dev. It serves the web UI. No credential is issued for it, so the API answers 401 there by design, and the page shows no data. The database lives in the EU (D1, weur) and the region claim is eu.
To redeploy, log in to Cloudflare with wrangler login (or set CLOUDFLARE_API_TOKEN in your shell, never in a file). Then run:
WRANGLER_SEND_METRICS=false npm run deploy:demo
This builds the UI and deploys wrangler.demo.jsonc. No CI job deploys the demo, and no secret is stored in this repository.
Limits
- Upgrading from 0.1.0: a base made by 0.1.0 (the CLI, the MCP server, or the Worker) has no owner kind and no sharing record, so nobody has a role on it. Until its owner claims it, creating a base, export, and erase answer
409 LEGACY_BASEwith the base ids, and change nothing. Claim a base withagentic-tables claim-base <id>, the MCP toolclaim_base, orPOST /bases/:baseId/claim. The claim needs the scopesbase:admin,base:write, andtable:write. A credential without them gets403and changes nothing. The caller must be a person or an agent with its own identity, with the base owner id, and it states its own kind as the owner. The base and its tables then get sharing records. Rows a principal wrote in a base that is not its own are still exported and erased asauthoredRows. - A schema change runs in one store transaction. A very large table can exceed the transaction size of a backend.
- Deleting a table or a base runs in one store transaction. A very large one can exceed the transaction size of a backend.
- Deleting a base or a table, and erasing your data, also removes its sharing records (the resource and its grants), with
deleteResourcefromagentic-identity0.4. A repeat of a delete finishes one that stopped half way, and an erase that stopped after its data went answers409 ERASE_INCOMPLETEwith the base ids whose grants remain:DELETE /bases/:idon each one removes them. A base or a table with more than 100 owners is refused before anything is deleted: take some owners back first. Listing bases checks each base, so it grows with the number of bases and writes an audit entry per check. Roles pass to a base or a table, not to a single row. Whoever creates a table owns it. Revoking a person on a base also revokes them on each table in it (the revoker becomes a direct owner of each table first, so the person is never its last owner). It needs thetable:adminscope too. You cannot remove yourself from a base that has tables: ask another owner. If a step fails, call again. - A filter on a field value reads the whole table (up to
maxScan). An index on field values comes later. - On D1, a transaction is one batch of statements. A very large erase or import can exceed the limits of a D1 batch. D1 has no index for the queries this package makes, so each query scans its collection. Inserts into one table take turns, because each one bumps the table (see above).
- The file store (
agentic-tables/file-store) keeps one JSON file, throughagentic-identity/file. The CLI and the MCP server can share it: each write takes a lock file, and a write that meets a save from another process runs again, up to 50 times. Each call reads the whole file, so use it for local work. It needs one machine: it does not work on a network disk. A lock older than 3 seconds is removed, whatever its owner. A file made by an earlier version is converted to the new shape when the store opens. The original stays as<db>.bakwith mode 0600. An erase does not reach it, so delete it once you have checked the conversion. Do not open the file with an earlier version after that. Do not call the store from inside one of its own transactions: calls on a path run one at a time, so that call waits for the transaction. - CSV export puts a
'before any cell or header that starts with=,+,-,@, tab, or CR, so a spreadsheet shows it as text. Number and checkbox cells stay as they are. Import reads a guarded header back, but keeps a guarded cell as written, with the'.
CLI
export AGENTIC_TABLES_DB=./crm.json # default ./agentic-tables.json
export AGENTIC_TABLES_ACTOR=agent_of_human:claude # who acts, as kind:id. Default human:cli (agent:mcp for the MCP server).
export AGENTIC_TABLES_CHAIN=human:ada # who an agent of a person acts for, root first. Needs agent_of_human, not agent.
npx agentic-tables new People --field "Name:text!" --field "Age:number" --field "Stage:single_select=lead|won"
npx agentic-tables insert People --json '{"Name":"Ada","Age":36,"Stage":"won"}'
npx agentic-tables query People --filter '{"field":"Age","op":"gt","value":30}' --sort Name:desc
npx agentic-tables schema People
npx agentic-tables required People Age true --dry-run # lists rows with no Age
npx agentic-tables share claude editor --kind agent # a role on your base: viewer, commenter, editor, owner
npx agentic-tables share claude viewer --kind agent --table People # or on one table
npx agentic-tables shares # who holds a role on your base (--table People: roles given on that table only)
npx agentic-tables invite viewer --handle claude # an invite for an agent; use --email for a person
npx agentic-tables link viewer --expires-in-ms 86400000 # a share link; prints the token once (--table People, --signed-in-only)
npx agentic-tables links # the links of your base, without tokens
npx agentic-tables revoke-link <link-id>
npx agentic-tables unshare claude --kind agent
npx agentic-tables export People > people.csv
npx agentic-tables import Copy --file people.csv --create
npx agentic-tables delete-table People --yes # last: it removes People and its rows
AGENTIC_TABLES_SCOPES is optional. It narrows what the actor may do. Set it after the table exists. A read-only
value such as table:read,base:read makes new fail with FORBIDDEN, and it blocks every later write. AGENTIC_TABLES_BASE
is optional too. Leave it unset to use the default base, one for each actor or for the person it acts for. Or set it to
the id of a base that its owner shared with you.
Commands: new, list, schema, add-field, insert, update, query, export, import, required, rename-table, rename-field, delete-field, change-type, delete-table, bases, new-base, delete-base, views, new-view, view, delete-view, claim-base, share, shares, invite, unshare, export-data, erase-data, mcp. Each prints JSON, except export, which prints CSV. A failure prints {"error":{"code","message","issues"}} on stderr and exits 1. A bad command line prints "code":"USAGE", then a hint line, and exits 2. A bad AGENTIC_TABLES_ACTOR also prints USAGE, but exits 1.
CSV import checks every cell first. If one cell fails, no row is written, and the error names the line and field. --create makes the table and guesses a type for each column: number, checkbox, date, single select, long text, or text. A column is a number only if every value is a plain decimal. 00123, 0x1F, and 1e3 stay text.
MCP server
Run npx agentic-tables mcp. It uses the same database file as the CLI, and a CLI write while it runs is kept.
| Tool | Use it for |
|---|---|
list_tables |
List the tables with their fields. |
describe_table |
Read one schema, with the filter operators each field supports. Call it before you write. |
create_table, add_field |
Create a table. Add a field. |
set_required |
Make a field required or optional. It lists the rows that block it. |
delete_table |
Delete a table with its rows and views. |
share, unshare |
Give or take back a role on your base or on one table. Only an owner can. |
list_shares |
List who holds a role on your base, or the roles given on one table. A role on the base also applies to its tables. Only an owner can. |
list_links, create_link, revoke_link |
List the share links of your base or of one table (no tokens), make one (the token shows once), or revoke one. Only an owner can. |
create_invite |
Make an invite for a person (email) or an agent (handle). The answer holds the invite id, never the email. Only an owner can. |
claim_base |
Make a base from an earlier version usable. You must be its owner. |
export_my_data, erase_my_data |
Export or hard delete the bases the acting principal owns. |
insert_rows |
Insert up to 500 rows at once. One bad row writes none. |
update_row, delete_row, get_row |
Change, delete, or read one row. |
query_rows |
Filter, sort, and page rows. |
A failed call returns isError with a code and the issues. Rows written over MCP carry the actor from AGENTIC_TABLES_ACTOR (default agent:mcp). The server checks roles as the HTTP API does, and a refusal returns isError with the code FORBIDDEN or NOT_FOUND.
Bases and field edits
The CLI and the MCP server manage bases and edit fields, as the HTTP API does.
npx agentic-tables bases # the bases you can read
npx agentic-tables new-base Sales # prints the base. Your work base is the oldest base you own; set AGENTIC_TABLES_BASE to use another
npx agentic-tables delete-base base_... --yes # removes the base with its tables, rows and views
npx agentic-tables rename-table People Contacts
npx agentic-tables rename-field Contacts Name "Full name"
npx agentic-tables change-type Contacts Age --to number --dry-run
npx agentic-tables change-type Contacts Age --to number --on-failure clear --yes
npx agentic-tables delete-field Contacts Age --yes
change-type converts every value. By default it changes nothing if one value fails, and it lists the rows that fail. --on-failure clear empties the failed values and needs --yes. --dry-run writes nothing. A select target takes --options "a|b". A link target takes --link-table.
| MCP tool | Use it for |
|---|---|
list_bases, create_base, delete_base |
List, create, or delete a base. |
rename_table |
Rename a table. Its id and data stay the same. Requires owner access. |
rename_field, delete_field |
Rename a field. Delete a field with its values. |
change_field_type |
Change the type of a field. It has dryRun and onFailure. |
Renaming a base is not available yet.
Views and CSV import in the CLI and MCP server
The CLI and the MCP server save and run views, as the HTTP API does. A view keeps a filter and a sort. A board view groups rows by a single select field. A board shows up to 50 rows per group by default, up to --limit. It does not page.
npx agentic-tables new-view People Adults --kind grid --filter '{"field":"Age","op":"gt","value":17}' --sort Name
npx agentic-tables new-view People "By stage" --kind board --group-by Stage
npx agentic-tables views People # list the views of a table
npx agentic-tables view view_... --limit 20 # run a view
npx agentic-tables delete-view view_... --yes # the rows stay
| MCP tool | Use it for |
|---|---|
list_views, create_view |
List the views of a table. Save a view. |
run_view, delete_view |
Read the rows of a view. Delete a view. |
import_csv |
Insert rows from CSV text. It has the same checks as import, and create makes a missing table, which needs the owner role. A call takes at most 500 rows and 1 MB of CSV. |
Changing a view after you make it is not available yet. Delete the view and make it again.
Run on Firebase
The domain logic takes any Store from agentic-identity, so the same API runs on Firestore. agentic-tables/node serves the HTTP API from a Node process over the store you pass. It holds no Cloudflare or Firebase code.
import { createFirestoreStore } from "agentic-identity/firestore";
import { createNodeServer } from "agentic-tables/node";
const store = createFirestoreStore({
projectId: "my-project",
fetch: globalThis.fetch,
token: () => getAccessToken(), // an OAuth token for the datastore scope
region: "eu",
});
const server = await createNodeServer({ store, unsafeNoAuth: true, port: 8787 });
console.log(server.url); // http://127.0.0.1:8787/api/v1/tables
- Unlike the Worker, the server has no sign-in: the caller comes from
x-principal-*headers that nothing checks (x-principal-id,x-principal-kind, and optionallyx-principal-chainandx-principal-scopes). It refuses to start withoutunsafeNoAuth: true, and it binds127.0.0.1unless you passhost. Use it for local work. - Only
Storeis on Firestore. PassbaseUrl: "http://127.0.0.1:8080"and notokento use the emulator. - The HTTP end-to-end suite (
src/api-suite.ts) runs on the Worker over D1 and on this server over the Firestore emulator. CI runs both. To run the Firestore one:npm run test:firestore. It needs Java 21 or later for the emulator. - The emulator hangs on a document read that carries
?transaction=, which the Firestore adapter sends.src/emulator-fetch.tsrewrites that read tobatchGetfor the test run. Real Firestore needs no such change.
Planned surfaces
- Open formats: ODS and XLSX export next to CSV. The plan is to write both with a small zip library under a permissive license, with every cell typed (string, number, boolean, date) and no formula cells, so no cell can run as a formula. A flat
.fodsfile needs no zip.
Run locally
bun install # or: npm ci
bun run dev # or: npm run dev
curl http://localhost:8787/health
The script builds the UI, then runs wrangler dev with AGENTIC_TABLES_LOCAL_DEV=1 and AGENTIC_TABLES_REGION=eu. /health needs no token. Every other route needs one: see "Credentials" for how to issue it.
The scripts call npm and node (Node 22 or newer), so install both, even if you use bun. They need a POSIX shell.
Run npm run dev:smoke to check this path. It starts the dev server on port 8799, waits for GET /health to answer 200, and stops the server. CI runs it as the "dev smoke" step. Set DEV_SMOKE_PORT to use another port.
Develop
npm ci
npm run build && npm run typecheck && npm test
npm run build also builds the UI into ui-dist. The Worker config serves that folder, so build before you run wrangler dev or the tests.
License
MIT.
Install Agentic Tables in Claude Desktop, Claude Code & Cursor
unyly install agentic-tablesInstalls into Claude Desktop, Claude Code, Cursor & VS Code — handles npx, uvx and build-from-source repos for you.
First time? Get the CLI: curl -fsSL https://unyly.org/install | sh
Or configure manually
Run in your terminal:
claude mcp add agentic-tables --env AGENTIC_TABLES_ACTOR="" --env AGENTIC_TABLES_CHAIN="" --env AGENTIC_TABLES_DB="" --env HASH="" --env TABLES_TOKEN="" --env TOKEN="" --env WRANGLER_SEND_METRICS="" -- npx -y agentic-tablesStep-by-step: how to install Agentic Tables
FAQ
Is Agentic Tables MCP free?
Yes, Agentic Tables MCP is free — one-click install via Unyly at no cost.
Does Agentic Tables need an API key?
Yes, it requires environment variables: AGENTIC_TABLES_ACTOR, AGENTIC_TABLES_CHAIN, AGENTIC_TABLES_DB, HASH, TABLES_TOKEN, TOKEN, WRANGLER_SEND_METRICS. Unyly injects them into the config during install.
Is Agentic Tables hosted or self-hosted?
Self-hosted: the server runs locally on your machine via the install command above.
How do I install Agentic Tables in Claude Desktop, Claude Code or Cursor?
Open Agentic Tables on unyly.org, pick your client tab (Claude Desktop, Claude Code, Cursor) and press Install — the config is generated automatically, no JSON editing.
Changes
Versions and requested access over time.
- New version published
Related MCPs
Postgres
Query your database in natural language
by Anthropicwenb1n-dev/SmartDB_MCP
Connect to multiple databases at once (MySQL, PostgreSQL, SQL Server, MariaDB, Dameng, Oracle) for operations, health analysis and SQL optimization.
by wenb1n-devPostgres Server
This server enables interaction with PostgreSQL databases through the Model Context Protocol, optimized for the AWS Bedrock AgentCore Runtime. It provides tools
by madhurprashPostgreSQL
Read-only database access with schema inspection.
by modelcontextprotocolRedis
Interact with Redis key-value stores.
by modelcontextprotocolSQLite
Database interaction and business intelligence capabilities.
by modelcontextprotocolmxcp
Open-source framework for building enterprise-grade MCP servers using just YAML, SQL, and Python, with built-in auth, monitoring, ETL and policy enforcement.
by raw-labsjulien040/anyquery
Query apps with SQL from a single local-first binary; also connects to PostgreSQL, MySQL and SQLite-compatible databases.
by julien040drakonkat/wizzy-mcp-tmdb
Search and retrieve movie, TV show and person information from The Movie Database (TMDB) API.
by drakonkatlongevity-genie/opengenes-mcp
Query the OpenGenes database for aging and longevity research.
by longevity-genieCompare Agentic Tables with
Not sure what to pick?
Find your stack in 60 seconds
Author?
Embed badge for your README
Browse similar
All data MCPs
