Build a records-backed app (CRM, asset tracker, admin panel, directory) without writing per-object CRUD. You declare objects and fields as data (rows in a registry, inserted by your migration); the records kit serves them through two generic functions with validation, full-text search, soft delete, optimistic concurrency, and an event spine - every mutation writes the row and an audit event in one transaction. The views kit renders tables, kanban boards, and create/edit forms straight from that registry - zero per-object UI code.

gipity add records     # backend: functions + migrations + client API (needs a database template)
gipity add views       # frontend: table / kanban / form renderers (requires records)
gipity deploy dev

Needs web-fullstack or api. The agent-api kit adds named API keys so scripts/agents can write through the same path with AGENT/API attribution.

⚠️ The read path is PUBLIC by default

record-read ships at auth: "public"; record-write is auth: "user". So by default anyone with the app's id can read every row of every object - schema, PII composite fields, and the audit log with actor names - with no sign-in. Fine for a public directory or demo; wrong for anything private. Before shipping private data, change record-read's auth to "user" or "member" in gipity.yaml.

What gets installed

Declare an object (you own these files)

1. In your own migration (migrations/001-myapp.sql), create the table using the base-entity shape, then register it:

CREATE TABLE IF NOT EXISTS assets (
    id            VARCHAR(20) PRIMARY KEY,
    name          TEXT NOT NULL,
    status        VARCHAR(40) NOT NULL DEFAULT 'in_storage',
    price         JSONB,                        -- currency {amountMicros, currencyCode}
    position      REAL NOT NULL DEFAULT 0,
    created_by    JSONB NOT NULL DEFAULT '{}',  -- ACTOR {source, memberId, name}
    updated_by    JSONB NOT NULL DEFAULT '{}',
    created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deleted_at    TIMESTAMPTZ,                  -- soft delete
    search_vector tsvector GENERATED ALWAYS AS (to_tsvector('simple', coalesce(name, ''))) STORED
);
CREATE INDEX IF NOT EXISTS idx_assets_search ON assets USING GIN (search_vector);

INSERT INTO kit_objects (name, table_name, label, label_plural, icon, app, membership, title_field)
VALUES ('asset', 'assets', 'Asset', 'Assets', '📦', 'myapp', 'open', 'name')
ON CONFLICT (name) DO UPDATE SET label = EXCLUDED.label;

INSERT INTO kit_fields (object_name, name, label, type, options, required, in_list, position) VALUES
  ('asset', 'name',   'Name',   'text',     '{}', TRUE, TRUE, 1),
  ('asset', 'status', 'Status', 'select',   '{"values":["in_use","in_storage"]}', TRUE, TRUE, 2),
  ('asset', 'price',  'Price',  'currency', '{}', FALSE, TRUE, 3)
ON CONFLICT (object_name, name) DO UPDATE SET
  label = EXCLUDED.label, type = EXCLUDED.type, options = EXCLUDED.options,
  required = EXCLUDED.required, in_list = EXCLUDED.in_list, position = EXCLUDED.position;

Every column in the table must be registered as a field (plus the base-entity columns above). title_field names the field used as the record's display title in labels, events, and cards.

2. Add your table to BOTH functions' tables: lists in gipity.yaml - record-read AND record-write (the kit ships declaring only its own core tables). Your edits survive a kit re-install.

3. gipity deploy dev. The object is now served by the generic API - no new function needed.

Field types

Type Stored as / write shape
text, textarea string (max 10000 chars)
number numeric
boolean true/false
date 'YYYY-MM-DD' string
select one of options.values (declare {"values":[...]})
currency JSONB {amountMicros, currencyCode}; write a plain number of whole units (e.g. 19.5 → 19,500,000 micros USD)
relation JSONB {id, label}; write the target record's id string; declare {"object":"company","labelField":"name"}. Existence is checked and the label denormalized in the write transaction
emails / phones / links JSONB composites ({primaryEmail, additionalEmails} etc.); writing a plain string sets the primary
json JSONB object (not array/scalar)

Validation errors are self-correcting - they say what valid input looks like ('status' must be one of: in_use, in_storage (got 'x')).

Client API (records kit)

import { getSchema, listRecords, getRecord, aggregate,
         createRecord, updateRecord, deleteRecord, createMany } from '@gipity/records';

const { objects } = await getSchema('myapp');            // objects each carry .fields

const { records, total } = await listRecords('asset', {
  q: 'macbook',                                          // full-text search
  filters: [{ field: 'status', op: 'eq', value: 'in_use' }],
  sort: { field: 'price', dir: 'desc' },
  limit: 100, offset: 0,                                 // default 100, max 10000
});

const { record, events } = await getRecord('asset', id); // record + its last 50 audit events

// Server-side GROUP BY (count + optional sum) - one query, no client paging.
// sum comes back in micros for currency fields. Honors the same filters/q as list.
const { groups } = await aggregate('opportunity', { group_by: 'stage', sum: 'amount' });
// → [{ group: 'won', count: 5, sum: 1525000000000 }, …]

const { record: created } = await createRecord('asset', { name: 'MacBook', status: 'in_use' });
await updateRecord('asset', created.id, { status: 'in_storage' });
await deleteRecord('asset', created.id);                 // soft delete (sets deleted_at)

// Bulk create, auto-chunked to the object's per-call query budget. Per-row
// results in input order - one bad row doesn't sink the rest.
const asset = objects.find(o => o.name === 'asset');     // createMany takes the schema object
const results = await createMany(asset, rowsFromCsv, { source: 'IMPORT', onProgress });
// → [{ ok: true, record }, { ok: false, error }, …]

Auth and membership

record-write requires a signed-in Gipity user. On first successful write, kit_members maps the identity to an app role: with membership: 'open' the first user becomes owner, later ones member; readonly members are rejected. membership: 'invite' blocks unknown users until an owner adds a member row.

The membership row is written on the write's own transaction, so a call that gets rejected - failed validation, unknown action, a record that doesn't exist - claims nothing. That means you can safely probe record-write with a deliberately invalid payload (the {"values":{}} smoke-test) without taking ownership of the app. Note the flip side: any successful write by a signed-in user on an open app makes them a member, and the very first one makes them owner. Switch to membership: 'invite' before you share the URL if that isn't what you want.

History / activity feeds

Every create/update/delete lands in kit_events (action, actor, changes as {field: {from, to}}, and a prompt-ready plain-English summary like Steve updated asset "MacBook" (status: in_use → in_storage)). Render history from what you already get:

Concurrency and provenance

The one rule

Never write record tables from anywhere but record-write (or the agent-api kit's agent-write). A direct UPDATE - from your own function or gipity db query - bypasses validation and poisons the event spine. Read-only access from your own functions is fine.

Views kit (registry-driven UI)

// Bare '@gipity/views/...' works only on import-mapped pages; elsewhere use relative paths:
import { renderTable } from '../packages/views/table.js';
import { renderKanban } from '../packages/views/kanban.js';
import { openRecordForm } from '../packages/views/form.js';
import { renderDataTable } from '../packages/views/readtable.js';
import { formatValue, prettyLabel, buildWidget } from '../packages/views/fields.js';
import { getSchema } from '../packages/records/api.js';

const { objects } = await getSchema('myapp');
const object = objects.find(o => o.name === 'asset');

// Table: search box, one dropdown filter PER SELECT FIELD, click-to-sort headers,
// dense rows (loads up to 200). Returns { refresh, getState }.
const table = renderTable({
  mount: document.querySelector('#list'),
  object,
  onRowClick: rec => openDetail(rec),
  initial: {},                 // pre-apply a saved view's { q, filters, sort }
  editable: () => !!user,      // double-click a cell to edit in place; concurrent
});                            // edits come back as a clean conflict error

// Kanban: one column per option of a SELECT field; drag-to-update goes through
// record-write (so drops land on the event spine). metricField (a currency
// field) adds a per-column total next to the count.
renderKanban({ mount, object, groupField: 'status', cardFields: ['price'],
               metricField: 'price', onCardClick: rec => openDetail(rec), canEdit: () => !!user });

// Form: create/edit/delete <dialog> with type-appropriate widgets. Relation
// fields get a debounced server-side typeahead. Pass record: null to create.
openRecordForm({ object, record, onSaved: rec => table.refresh(), onDeleted: () => table.refresh() });

// Read-only table for any { columns, rows } payload (reports, introspection) - no registry needed.
renderDataTable({ mount, title: 'Report', columns: ['name', 'total'], rows });

Include the stylesheet once per page: <link rel="stylesheet" href="./packages/views/views.css">. It's deliberately minimal - brand by overriding .kit-table, .kit-card, .kit-column, etc. in your own CSS.

Known limits of the generated UI:

Saved views: persist table.getState() (returns { q, filters, sort }) as a record of your own (e.g. a saved_view object with a json config field), and pass it back as initial to restore.

Test it

Tests hit the deployed functions against an isolated auto-reset test database (app-testing). The kit lists kit_objects/kit_fields under test.preserve in gipity.yaml at install, so your migration-inserted registry rows survive the reset. record-read is callable anonymously; record-write is auth: user, so use callAs:

test('create and list assets', async (ctx) => {
    const { record } = await ctx.fn.callAs(ctx.users.alice, 'record-write',
        { action: 'create', object: 'asset', values: { name: 'MacBook', status: 'in_use' } });
    assert.ok(record.id);
    const { records, total } = await ctx.fn.call('record-read', { action: 'list', object: 'asset' });
    assert.equal(total, 1);
});

Common mistakes to avoid

Related skills