# Kruvi Data

A headless data backend for AI agents: define objects and fields, store records,
query with read-only SQL. No UI; you are the interface. (Prototype: no
billing, webhooks or automations yet.)

Base URL: https://uk1.data.kruvi.dev
All requests and responses are JSON. Errors look like
{"error": {"code": "...", "message": "..."}} and the message says how to fix the call.

## How this goes

1. Sign up instantly (below) and build what your user needs in this
   conversation, using the temporary key you get back. Don't store the key.
2. Ask your user to confirm their email: they click the link we send and
   accept the terms. One click, whenever suits them.
3. When the setup is done, ask your user to connect Kruvi Data to their AI
   app for lasting access. In Claude: Settings > Connectors > Add custom
   connector, URL https://uk1.data.kruvi.dev/mcp, then sign in with the email they confirmed.
   That's secure OAuth: nothing to store, it renews itself, and the
   temporary key stops working. From then on, use the connector's tools.

If your user already has a workspace, skip to step 3.

## 1. Sign up (no auth)

POST /v1/signup  {"name": "Acme Plumbing", "email": "<your user's email>"}
-> {"tenant": {...}, "api_key": "kdata_uk1_...", "endpoint": "https://uk1.data.kruvi.dev", "credential": {...}}

Send the key on every other request: Authorization: Bearer <api_key>
The key is shown once, and it is a temporary key for getting started: use it
for this conversation, and don't save it to memory or notes, which are plain
text. Keys only work at their own cell's endpoint (uk1, eu1, ...); if you ever
get a 421 "wrong_cell" error, it contains the right endpoint.

Kruvi Data emails your user a confirmation link. Ask them to click it: until they
do, you can build and test, but you can't invite anyone or create keys, and
the key expires after 24 hours.

Lasting access comes from your user adding Kruvi Data as a connector in their
AI app (https://uk1.data.kruvi.dev/mcp) and approving it in their browser (OAuth). The
connector holds access securely between conversations, so no key needs storing.
"credential.your_job" (in the signup response, GET /v1/whoami, and the
kdata-credential header) says whether that is available yet and what to do
until it is. POST /v1/credentials/self/revoke revokes the key you're using.

## 2. Define the schema

POST /v1/schema
{
  "objects": [
    {"name": "customer", "fields": [
      {"name": "name", "type": "text", "required": true},
      {"name": "email", "type": "email", "unique": true},
      {"name": "status", "type": "picklist", "values": ["lead", "active", "former"]}
    ]},
    {"name": "job", "fields": [
      {"name": "customer", "type": "reference", "references": "customer", "required": true},
      {"name": "scheduled_for", "type": "datetime"},
      {"name": "price", "type": "number"}
    ]}
  ]
}

- Declarative and safe to repeat: send the objects you want; anything already
  matching is left alone. Objects or fields you leave out are NOT removed.
- To remove, list them explicitly: "drop": ["job.price", "old_object"].
- "dry_run": true returns the plan without applying it.
- Names: lowercase, digits and underscores. Every object gets id, created_at
  and updated_at automatically. Avoid SQL keywords (case, order, group...):
  they work, but SQL must then quote them ("case"), and you'll get a warning.
- Each object gets its own id prefix (id_prefix in the schema), so an id like
  cas_... always tells you which object it belongs to.
- Field types: text, long_text, email, url, phone, picklist, number, integer, boolean, date, datetime, reference, json.
  picklist needs "values"; reference needs "references". Optional: label,
  required, unique.
- Reference fields also have "on_delete", for when the record they point at
  is deleted: "restrict" (refuse the delete while anything still points at
  it), "set_null" (clear the reference) or "cascade" (delete this record
  too). Defaults: restrict for required references, set_null for optional
  ones. A required reference can't be set_null.
- Changing a field: send it again with the new definition. Picklist values,
  label, required and unique can change, and so can type within a family
  (text-like types with each other). Other type changes and renames aren't
  supported: add a new field, copy the data, drop the old one.

GET /v1/schema returns the current schema.

## 3. Records

POST   /v1/objects/{object}/records        one record, or an array of up to 200
GET    /v1/objects/{object}/records        ?limit=50&cursor=...&where={"status":"active"}
GET    /v1/objects/{object}/records/{id}
PATCH  /v1/objects/{object}/records/{id}   only the fields to change
DELETE /v1/objects/{object}/records/{id}   follows each reference's on_delete rule

Values: dates "YYYY-MM-DD", datetimes ISO 8601, booleans true/false, numbers as
JSON numbers, reference fields take the target record's id. Any field left out
is null (there are no defaults yet, so an unset boolean is null, not false).

Creating an array is all-or-nothing: if any record fails validation, nothing
is saved and the error names the failing index. Created records come back in
the order you sent them, so you can pair ids with your input.

## 4. Undo

Every write call creates a change set (returned as "change_set" in bulk and
delete responses, and in the kdata-change-set header otherwise).

GET  /v1/changes          newest first; ?object=&record_id=&limit=&before=
GET  /v1/changes/{id}     before/after state of every record it changed
POST /v1/undo             {"change_sets": ["cs_..."]} or {"since": "2026-09-26T10:00:00Z"}; "dry_run": true to preview

- All or nothing: if a record was changed after the change you're undoing,
  you get a 409 undo_conflict listing the later change sets. Include them,
  or use "since". Undoing an undo redoes it.
- The result gives records_restored (distinct records) and changes_reversed
  (per change, by kind). A delete that cascaded is undone in one go.
- Restores never trigger automations.
- On the free plan, history covers 7 days or 10,000 changes, whichever is less;
  history_starts in GET /v1/changes shows how far back you can go.

Dropped objects and fields go to the trash with their data:
GET  /v1/trash              what can be restored, and when it expires
POST /v1/trash/{id}/restore

## 5. Members, integrations and roles

Roles are defined by the owner. Managing people is itself a role permission,
so the owner can delegate it:
"members": {"read": true, "invite": true, "update": true, "remove": true,
            "integrations": false, "assign_roles": ["receptionist"]}
A role can only hand out the roles in assign_roles, and only manage people
who hold one of them. Nobody can manage the owner, or themselves.
GET /v1/members lists everyone with their role, last_seen_at, and "secure":
false if they're still on a bearer key.


Roles are declarative and deny by default:

POST /v1/roles
{"roles": [{"name": "sales", "objects": {
  "account": {"read": true, "create": true, "update": true,
              "fields": {"credit_limit": "read", "internal_notes": "none"},
              "read_filter": "region = 'North America'",
              "create_check": "region = 'North America'"},
  "sale":    {"create": true, "create_check": "account IN (SELECT id FROM account)"}
}}]}

- Per object: read, create, update, delete. Per field: "none", "read" or
  "write"; "*" sets the default (also for fields added later).
- Filters are SQLite expressions: read_filter, update_filter (also checked
  after the change, so a key can't move a record out of its reach),
  delete_filter, create_check. They may use me() (the caller's member id) and
  me_email(), and may subquery other objects, which are filtered for the
  caller too.
- "sql": false stops a role using POST /v1/sql. Filters and hidden fields
  apply to SQL automatically.

POST   /v1/invites          {"email": "...", "role": "sales"}; Kruvi Data emails the
                            invitee a one-time code, which is not returned to you
POST   /v1/invites/accept   {"code": "kdata_inv_..."}; no auth; the invitee's
                            assistant redeems the code for its own key
POST   /v1/integrations     {"label": "CRM sync", "role": "crm_sync"}; a key for
                            another system, shown once
GET    /v1/members          members, integration keys, pending invites
PATCH  /v1/members/{id}     {"role": "..."}
DELETE /v1/members/{id}     removes them and revokes their keys immediately

Scoped keys see only their own change sets and can only undo those.
GET /v1/whoami tells any key which workspace and member it belongs to.

## 6. Read-only SQL

POST /v1/sql  {"sql": "SELECT status, count(*) AS n FROM customer GROUP BY status", "params": []}
-> {"columns": [...], "rows": [...], "truncated": false}

SQLite dialect. Each object is a table and each field a column. SELECT and
WITH only; at most 1000 rows and 3 seconds per query. Storage: dates and
datetimes are ISO text (so date() and julianday() work), booleans are 1/0/NULL,
json fields are text, reference columns hold the target's raw id. Use ?
placeholders with "params".

## 7. Limits

The free plan includes 10,000 records across all objects. Every response has
a kdata-usage header, e.g. "records=8123/10000; status=warning".
GET /v1/usage gives the detail: how many more can be created, counts per
object, and advice written for you.

- warning (80%): tell your user when convenient.
- over (100%): writes still succeed within a small grace margin, so an
  import never fails halfway. Tell your user now.
- blocked (beyond 110%): creating records returns 402 limit_reached. Nothing
  else is blocked: reading, updating, deleting, SQL and undo all work, so you
  can always tidy up. Batches are all or nothing: check can_still_create
  before a large import.

Data is never deleted because of a limit.

## MCP

The same operations are available as MCP tools over Streamable HTTP at
https://uk1.data.kruvi.dev/mcp (send the same Authorization header).
